mirror of
https://github.com/langgenius/dify.git
synced 2026-01-21 12:35:21 +08:00
Compare commits
894 Commits
deploy/tri
...
deploy/end
| Author | SHA1 | Date | |
|---|---|---|---|
| b8236b29f3 | |||
| eb0e63c336 | |||
| f62926f0ca | |||
| b033bb02fc | |||
| 031cba81b4 | |||
| 693ab6ad82 | |||
| 541fd7daa2 | |||
| 61d79a1502 | |||
| 03357ff1ec | |||
| b4bed94cc5 | |||
| e924dc7b30 | |||
| 4b969bdce3 | |||
| d07afb38a0 | |||
| 5bb715ee2f | |||
| 3e5f683e90 | |||
| 31481581e8 | |||
| 16dc744908 | |||
| 2e0c2e8482 | |||
| 0343374d52 | |||
| c1fe394c0e | |||
| f3258bab9e | |||
| 876f48df76 | |||
| fbb2d076f4 | |||
| c7d2a13524 | |||
| 9b9588f20d | |||
| d6bbf0f975 | |||
| f48522e923 | |||
| 472f8a8a2b | |||
| c8807d3f89 | |||
| 17e1de18d5 | |||
| 5e6053b367 | |||
| f5e36a8a2b | |||
| 660efda7f5 | |||
| 5e96e4dda6 | |||
| a31eea8389 | |||
| 6aa0c9e5cc | |||
| 8fe47a3c04 | |||
| cb4670cd68 | |||
| 1400b9c6e2 | |||
| b7d9483bc2 | |||
| 9df9db3a8f | |||
| bf4f9b04bf | |||
| a659cbf71d | |||
| 72514904ea | |||
| f3fbd4f90e | |||
| 5947cc2bab | |||
| 31109735a3 | |||
| df025ac400 | |||
| f4a3e290cb | |||
| 66e85ca16c | |||
| ac8136cca4 | |||
| 8c111de6a9 | |||
| d784a0432c | |||
| 1636b228db | |||
| d5beccf0da | |||
| e36d460d67 | |||
| 68f195c5e9 | |||
| 200e801182 | |||
| 5bcf5be874 | |||
| b60ba0b192 | |||
| 26dc4d43bf | |||
| 0e355079fa | |||
| b51bf33b1e | |||
| 30594978f9 | |||
| 2e2d7a5345 | |||
| cf2457a03c | |||
| 4590dab046 | |||
| f2df8af4c8 | |||
| 5b93ed3fcd | |||
| 5fd83e9425 | |||
| 8f6937eea6 | |||
| 74fc026fe0 | |||
| bbd466eaba | |||
| 153609b968 | |||
| 6cd7ab4719 | |||
| 39de9e7248 | |||
| 5e93a61865 | |||
| 76069b5d6d | |||
| adf673d031 | |||
| 2fa6684c4d | |||
| 87439b8fec | |||
| 08034532f6 | |||
| c5f47ebccd | |||
| 6744306818 | |||
| 3ff14ccc89 | |||
| 9c30f16e4b | |||
| c31933c163 | |||
| 574eb1a10a | |||
| e6ac783fc3 | |||
| 689a75f44a | |||
| a25f469bde | |||
| 044ee7ef54 | |||
| 2725f28fa8 | |||
| c493e08df1 | |||
| 36ad784251 | |||
| 0a39e5c092 | |||
| 9169a5e35b | |||
| bfdcb79e19 | |||
| c37cce000f | |||
| 6d3fb9b769 | |||
| c04913ecf8 | |||
| 3a84a64c32 | |||
| b344d4add1 | |||
| 405a4ec9f8 | |||
| 8bb11a588c | |||
| 44f451bd7d | |||
| 35d914e755 | |||
| 9c37f8c1cb | |||
| 9de0e3c3a7 | |||
| 707c94f86e | |||
| 81afd087f6 | |||
| 0f952f328f | |||
| 50619fba0a | |||
| aad31bb703 | |||
| 7484a020e1 | |||
| 186828c13a | |||
| 203fb95391 | |||
| a94e650ffd | |||
| 00fdd06179 | |||
| 62fbc90389 | |||
| f19a21da11 | |||
| 7401792063 | |||
| 79e46c8a81 | |||
| 7658c92cf9 | |||
| 85a5c78b80 | |||
| 9d7b47c784 | |||
| e4c6ed9c60 | |||
| fcfade4778 | |||
| 000e8bd12b | |||
| ed8da2c760 | |||
| fb6dc14e9b | |||
| 77e6e98234 | |||
| fb3699ec5e | |||
| 4601be8b67 | |||
| cc4d4adfb9 | |||
| 6a08623949 | |||
| f0127ffc9a | |||
| f1e513830c | |||
| 7de533a643 | |||
| 052127c473 | |||
| 7a4be5c0d2 | |||
| a6208feed8 | |||
| c8f55549d7 | |||
| 132a86dcb3 | |||
| 9f59baed10 | |||
| ce56286329 | |||
| 6e76f2aff2 | |||
| 49edd58722 | |||
| 6a28aee13e | |||
| 79c70d09c9 | |||
| b9bb97887b | |||
| 7df6d9f1aa | |||
| 587f83bc34 | |||
| d81b2e6820 | |||
| 8315e0c74b | |||
| 3cc6690356 | |||
| 6507263b28 | |||
| 8fa0bb48df | |||
| 637a675681 | |||
| 085ada86e6 | |||
| f59d430219 | |||
| c415e5b893 | |||
| 67b6b3612c | |||
| 229b0e190f | |||
| 09d412cf2a | |||
| 2842cbf1e1 | |||
| e2543bcf30 | |||
| 3f75aa6848 | |||
| 57719f3ce9 | |||
| 45677ac57c | |||
| 4eacbf37ff | |||
| ef256ac276 | |||
| 2733e04039 | |||
| e49ec82258 | |||
| cf301eb1d9 | |||
| 98b9ba2b2e | |||
| 2126c64468 | |||
| 271a1b4f98 | |||
| 9be3c62c04 | |||
| 04bfa235a9 | |||
| 3b37ae1b4e | |||
| c1cb93cd26 | |||
| 75fa161c46 | |||
| d6d82cff33 | |||
| 5c266fecf9 | |||
| 7244978b24 | |||
| 623021dcff | |||
| 5af165fce9 | |||
| 9503fafc53 | |||
| 99fac21bdb | |||
| bc95678c5e | |||
| 3f34f38635 | |||
| 30f771369b | |||
| 20bd059a6c | |||
| f5eb406394 | |||
| cbebac1d45 | |||
| 030da43ae3 | |||
| b7f1394403 | |||
| ceb6a09387 | |||
| 14ad800967 | |||
| 34d1f86f76 | |||
| b9b9f8eae3 | |||
| 0de8596afe | |||
| 4dbd26ff66 | |||
| d018ef9033 | |||
| 979c985804 | |||
| 291e9a3aee | |||
| 5861ca773e | |||
| eb3b5f751a | |||
| 9bbfbf1c5f | |||
| 8cbd124b80 | |||
| d137d0eed0 | |||
| 58c5db3b00 | |||
| 8750796f9f | |||
| 7d4bb45f94 | |||
| db744444f2 | |||
| b25d379ef4 | |||
| e1e95f7ccd | |||
| edd50420ec | |||
| 9af8fe085b | |||
| ed6bb121bb | |||
| 4635b99153 | |||
| 282fde9a04 | |||
| e9078eedbd | |||
| 501698d844 | |||
| dd089b1b21 | |||
| 6260a1a28c | |||
| 8bc5035624 | |||
| 2dbfd9ea5a | |||
| 08e61d76d6 | |||
| 447127cee4 | |||
| 49ebbd05b5 | |||
| defea962f6 | |||
| b3866288e0 | |||
| bed2ce69bb | |||
| 4d37d61851 | |||
| 8a48db6d0d | |||
| ff0f645e54 | |||
| 6e0765fbaf | |||
| 1d03e0e9fc | |||
| cac60a25bb | |||
| 57c65ec625 | |||
| ffc3c61d00 | |||
| aa3b16a136 | |||
| 6e0b408dd5 | |||
| be9eeff6c2 | |||
| ca9d92b1e5 | |||
| 0607db41e5 | |||
| 48b1829b14 | |||
| 6767a8f72c | |||
| 1e477af05f | |||
| 9b5e5f0f50 | |||
| fb12f31df2 | |||
| db2c6678e4 | |||
| bc3421add8 | |||
| 61d8809a0f | |||
| d37cc9f9c8 | |||
| 0db082f6d0 | |||
| c94dc52310 | |||
| bebcbfd80e | |||
| dfc5e3609d | |||
| fa5765ae82 | |||
| 852d851996 | |||
| 0b599b44b0 | |||
| f06dc3ef90 | |||
| f9df61e648 | |||
| 6e76e02dba | |||
| dc24450e29 | |||
| 8bcecce627 | |||
| 66cb963df3 | |||
| 5c95c77604 | |||
| a264a609db | |||
| 3a876fd437 | |||
| 13bc68a646 | |||
| b41538d8c7 | |||
| 720480d05e | |||
| 71b1af69c5 | |||
| 18fd79fbe6 | |||
| c16421df27 | |||
| 0d686fc6ae | |||
| db352c0a18 | |||
| bf7b18d442 | |||
| 7d56ca5294 | |||
| 0b1015e221 | |||
| 96f0d648fa | |||
| c4996f9563 | |||
| 850c5fec32 | |||
| b1f79c34d1 | |||
| 96a461646e | |||
| 5df94fd866 | |||
| e074ba84d1 | |||
| 1335be8d60 | |||
| c79d75b32d | |||
| f18054847e | |||
| b2b81f3822 | |||
| 90753b2782 | |||
| c05fa9963a | |||
| 9de7a7d48f | |||
| 29cddc449f | |||
| dfed14ba67 | |||
| 440262a51b | |||
| d705fece9d | |||
| d08cc48368 | |||
| b94ad084c3 | |||
| 9453148233 | |||
| 1857d0e53f | |||
| ae422c2628 | |||
| d933116e46 | |||
| 5cf4afd7b2 | |||
| f7853f3b27 | |||
| 913d85302c | |||
| 33daedd7aa | |||
| cc219cc81c | |||
| 945295adc3 | |||
| 651cc81cfe | |||
| 3bd62f3fdf | |||
| e3484c8dc3 | |||
| eecbe533a1 | |||
| 4221e99362 | |||
| 5c69521973 | |||
| ffcaa67a56 | |||
| 64a070f6b0 | |||
| c61656c759 | |||
| 6d34e4e99b | |||
| d3a767364b | |||
| f3c6d1ca1d | |||
| 6098dc0242 | |||
| 29ec3c7d5c | |||
| 4597ab4efb | |||
| 1dddcf1194 | |||
| c4c38a51d9 | |||
| 8a8c0703b1 | |||
| 1b74869b04 | |||
| f065504ed6 | |||
| 3f5485605f | |||
| 399bb522e0 | |||
| 9fffa9a996 | |||
| aee9a8366f | |||
| c3eec7ea8a | |||
| 4b4ec3438f | |||
| 9aa43c9165 | |||
| 4ae23ed0f9 | |||
| efe68d5aa6 | |||
| 1604db02b5 | |||
| e7192de9c0 | |||
| f822b38a00 | |||
| 5a5c7f38d1 | |||
| 42a9a88ae2 | |||
| aea3fc6281 | |||
| bcdc11396a | |||
| ecd1d44d23 | |||
| 6df786248c | |||
| 37e75f7791 | |||
| 7ada2385b3 | |||
| a77aab96f5 | |||
| 13af48800b | |||
| 6e7fb59638 | |||
| 863b4f8fe9 | |||
| 949ac9d930 | |||
| 06d1a2e2fd | |||
| d478f62b49 | |||
| 128bc2241d | |||
| b2730d680c | |||
| 52b180104a | |||
| 9cdb62da93 | |||
| 5af08edfda | |||
| 24fa5f33d7 | |||
| caf0bf34dd | |||
| 181a1ae7f3 | |||
| e18ecead2c | |||
| 36a26adab2 | |||
| bc2edf5107 | |||
| 50bbac5973 | |||
| 45b221659b | |||
| 16957f14f1 | |||
| 0d7dde0639 | |||
| 37805184d9 | |||
| df9932088f | |||
| d101a83be8 | |||
| 94ea289c75 | |||
| e2539e91eb | |||
| 77e9bae3ff | |||
| d99644237b | |||
| 5cb268e99b | |||
| f179b03d6e | |||
| 28fe58f3dd | |||
| 14acd05846 | |||
| ccce135bf5 | |||
| cb5607fc8c | |||
| 7f70d1de1c | |||
| c36173f5a9 | |||
| 7acbe981e2 | |||
| dd6ab7c68c | |||
| a1ea256e79 | |||
| 14942c9ee9 | |||
| b0b316ed48 | |||
| 871cfbd40c | |||
| 9a3ca0ce3b | |||
| c90df5c12c | |||
| f4acc78f66 | |||
| 3d5e2c5ca1 | |||
| 55bf9196dc | |||
| 18a52b4937 | |||
| 439727746c | |||
| 04b55177b5 | |||
| 2793ede875 | |||
| dc4801c014 | |||
| d5e2649608 | |||
| 4102f0bc9d | |||
| 25e4203cb1 | |||
| e1a3ead941 | |||
| 6251090893 | |||
| aa5e04b70e | |||
| 8ac25c29ee | |||
| f4517d667b | |||
| dc2481c805 | |||
| 8d7435a51b | |||
| bb28c718df | |||
| 1b7a5b6209 | |||
| 448622b4fd | |||
| e9dda03e8d | |||
| 8d3d177932 | |||
| f0af4d692a | |||
| 075173e67d | |||
| f02d575379 | |||
| 735ebf6c59 | |||
| 96f0b7abe3 | |||
| eb1686f04b | |||
| d4b5d9a02a | |||
| f87f77ce7b | |||
| 24619e74f6 | |||
| f5c1646f79 | |||
| e26d77e78c | |||
| 8e1e81732a | |||
| 801f8c1592 | |||
| fd4b234171 | |||
| dff536ab6d | |||
| a152ce45d3 | |||
| 6a164f8811 | |||
| a03ff39f3e | |||
| a6373e357a | |||
| 538b639bef | |||
| fe0457b257 | |||
| d5b228f234 | |||
| 1c2f95eeb6 | |||
| 81b3436ec4 | |||
| 3e4f2bcf14 | |||
| c7696964b9 | |||
| fb8ecf7b5a | |||
| e3c2345b21 | |||
| bfe0d14409 | |||
| c7498c3a11 | |||
| 5fba41688a | |||
| b63b9c32f7 | |||
| 65c6203ad7 | |||
| 3a18337129 | |||
| b6b433626e | |||
| 5d6b9b0cb1 | |||
| 6d09330f98 | |||
| 5df9afa91a | |||
| 30a341331f | |||
| 31cf4b6619 | |||
| dd0da3218c | |||
| 11c9219848 | |||
| b1ffd2ef2b | |||
| 86cf7952fb | |||
| d790d2b6bc | |||
| a711a8e759 | |||
| 8a18b6e13b | |||
| 95aeb61d7c | |||
| e8b0144cf7 | |||
| 2c8c1860ca | |||
| 5edfbd5305 | |||
| 4ceae655bd | |||
| 6ae76d108b | |||
| 9cc3cfb63e | |||
| 58e4c0793a | |||
| 80f2c1be67 | |||
| 8a5174d078 | |||
| d0f357a690 | |||
| fbe3df5658 | |||
| 21e3ef91eb | |||
| 3f116dc74b | |||
| 32731c4622 | |||
| 3c1f0e1aec | |||
| 685e48636d | |||
| 7c4edaa636 | |||
| 35867707d0 | |||
| 5b884d750f | |||
| bc0d5f4e41 | |||
| f20452622a | |||
| 6ba26cf7b5 | |||
| 7510e0654b | |||
| 564bb22d8b | |||
| 5e2d5f0d83 | |||
| d90ffbcf14 | |||
| 771cc72dcf | |||
| 04c91111e9 | |||
| 5a13daefdb | |||
| c033c05ec1 | |||
| 5b2f323a87 | |||
| b855d95430 | |||
| fe4b63210e | |||
| 84c09ec59d | |||
| 40e17ef801 | |||
| f1fcb92691 | |||
| 3865555113 | |||
| 95e46806a4 | |||
| c9c3d03878 | |||
| b28ec4be6e | |||
| 29d7023fae | |||
| 22f6c23780 | |||
| 548db29a47 | |||
| 1089c5bf04 | |||
| 559cf6583f | |||
| b04f92715c | |||
| 671aba6ab7 | |||
| beaeb30dcc | |||
| 56abca1f41 | |||
| 52d5f219e1 | |||
| d4516e942c | |||
| 1c17a16830 | |||
| 1f6ab13fc5 | |||
| 7344df87e5 | |||
| 29353bd7c2 | |||
| 7b6f5d6860 | |||
| 2ccb20bf3a | |||
| 34b7e5cbca | |||
| a595e2df06 | |||
| 729e0e9b1e | |||
| c03b790888 | |||
| 112b5f63dd | |||
| 334e5f19bf | |||
| 35bbf67175 | |||
| 9aec255ee9 | |||
| b07e80e6ae | |||
| ad2b910d73 | |||
| f28a7218cd | |||
| 4164e1191e | |||
| bd31c6f90b | |||
| 8f7bef9509 | |||
| 06c91fbcbd | |||
| dab4e521af | |||
| b20f61356c | |||
| 4ec23eea00 | |||
| 270fd9cb07 | |||
| c7c5e07d43 | |||
| c1ba83f0d4 | |||
| d71200ee32 | |||
| 16ac05ebd5 | |||
| ac77b9b735 | |||
| 0fa4b77ff8 | |||
| 6773dda657 | |||
| bf42386c5b | |||
| 90fc06a494 | |||
| 8dfe693529 | |||
| d65d27a6bb | |||
| e6a6bde8e2 | |||
| c7d0a7be04 | |||
| e0f1b03cf0 | |||
| 902737b262 | |||
| 429cd05a0f | |||
| 46e7e99c5a | |||
| d19ce15f3d | |||
| 49af7eb370 | |||
| 8e235dc92c | |||
| 3b3963b055 | |||
| 378c2afcd3 | |||
| d709f20e1f | |||
| 99d9657af8 | |||
| 62efdd7f7a | |||
| ebcf98c137 | |||
| 7560e2427d | |||
| 920a608e5d | |||
| 4dfb8b988c | |||
| af6dae3498 | |||
| ee21b4d435 | |||
| 654adccfbf | |||
| b283a2b3d9 | |||
| cce729916a | |||
| 4f8bf97935 | |||
| ba88c7b25b | |||
| 0ec5d53e5b | |||
| f3b415c095 | |||
| 6fb657a89e | |||
| 90240cb6db | |||
| cca48f07aa | |||
| beff639c3d | |||
| 00359830c2 | |||
| f23e098b9a | |||
| 42f75b6602 | |||
| 4f65cc312d | |||
| 854a091f82 | |||
| 63dbc7c63d | |||
| a4e80640fe | |||
| fe0a139c89 | |||
| ac2616545b | |||
| c9e7922a14 | |||
| 12a7402291 | |||
| 33d7b48e49 | |||
| ee89e9eb2f | |||
| e793f9e871 | |||
| 18b02370a2 | |||
| d53399e546 | |||
| 622d12137a | |||
| bae8e44b32 | |||
| 24b5387fd1 | |||
| 0c65824cad | |||
| 31c9d9da3f | |||
| 8f854e6a45 | |||
| 75b3f5ac5a | |||
| 323e183775 | |||
| 380ef52331 | |||
| b8862293b6 | |||
| 85f1cf1d90 | |||
| 1d4e36d58f | |||
| 90ae5e5865 | |||
| 755fb96a33 | |||
| b8ca480b07 | |||
| 8a5fbf183b | |||
| 91318d3d04 | |||
| a33d04d1ac | |||
| 02222752f0 | |||
| 04d94e3337 | |||
| b98c36db48 | |||
| d05d11e67f | |||
| 3370736e09 | |||
| cc5a315039 | |||
| 6ea10cdaaf | |||
| 9643fa1c9a | |||
| 937a58d0dd | |||
| d9faa1329a | |||
| fec09e7ed3 | |||
| 31b15b492e | |||
| f96bd4eb18 | |||
| a4109088c9 | |||
| f827e8e1b7 | |||
| 82f2f76dc4 | |||
| e6a44a0860 | |||
| 604651873e | |||
| 9114881623 | |||
| 080cdda4fa | |||
| 32f4d1af8b | |||
| 1bfa8e6662 | |||
| 7c97ea4a9e | |||
| bea11b08d7 | |||
| 8547032a87 | |||
| 43574c852d | |||
| 5ecc006805 | |||
| 15413108f0 | |||
| 831c888b84 | |||
| f0ed09a8d4 | |||
| a80f30f9ef | |||
| fd2f0df097 | |||
| d72a3e1879 | |||
| 4a6903fdb4 | |||
| 8106df1d7d | |||
| 5e3e6b0bd8 | |||
| a06d2892f8 | |||
| e377e90666 | |||
| 19cc67561b | |||
| 92f2ca1866 | |||
| 1949074e2f | |||
| 1c0068e95b | |||
| 4b43196295 | |||
| 2c3cf9a25e | |||
| 67fbfc0b8f | |||
| 6e6198c64e | |||
| 6b677c16ce | |||
| 973b937ba5 | |||
| 48597ef193 | |||
| ffbc007f82 | |||
| 8fc88f8cbf | |||
| a4b932c78b | |||
| 2ff4af8ce3 | |||
| 6795015d00 | |||
| b100ce15cd | |||
| 3edf1e2f59 | |||
| 4d49db0ff9 | |||
| 7da22e4915 | |||
| 8d4a9df6b1 | |||
| f620e78b20 | |||
| 8df80781d9 | |||
| edec065fee | |||
| 0fe529c3aa | |||
| bcfdd07f85 | |||
| a9a118aaf9 | |||
| 60c86dd8d1 | |||
| 8feef2c1a9 | |||
| 4ba99db88c | |||
| b4801adfbd | |||
| 08e8f8676e | |||
| 2dca0c20db | |||
| 6f57aa3f53 | |||
| 1aafe915e4 | |||
| 6d4d25ee6f | |||
| 6b94d30a5f | |||
| 1a9798c559 | |||
| 764436ed8e | |||
| 2a1c5ff57b | |||
| cc4ba1a3a9 | |||
| d68a9f1850 | |||
| 4f460160d2 | |||
| d5ff89f6d3 | |||
| 452588dded | |||
| aef862d9ce | |||
| 896f3252b8 | |||
| 6853a699e1 | |||
| cd07eef639 | |||
| ef9a741781 | |||
| c5de91ba94 | |||
| bc1e6e011b | |||
| 906028b1fb | |||
| 034602969f | |||
| 4ca14bfdad | |||
| 59f56d8c94 | |||
| 63d26f0478 | |||
| eae65e55ce | |||
| 0edf06329f | |||
| 6943a379c9 | |||
| e49534b70c | |||
| 344616ca2f | |||
| 0e287a9c93 | |||
| 8141f53af5 | |||
| 5a6cb0d887 | |||
| 26e7677595 | |||
| 814b0e1fe8 | |||
| a173dc5c9d | |||
| a567facf2b | |||
| e76d80defe | |||
| 4a17025467 | |||
| bd1fcd3525 | |||
| 0cb0cea167 | |||
| ee68a685a7 | |||
| c78bd492af | |||
| 6857bb4406 | |||
| dcf3ee6982 | |||
| 76850749e4 | |||
| 91e5e33440 | |||
| 11e55088c9 | |||
| 57c0bc9fb6 | |||
| c3ebb22a4b | |||
| 1562d00037 | |||
| e9e843b27d | |||
| ec33b9908e | |||
| 67004368d9 | |||
| 94ecbd44e4 | |||
| ba76312248 | |||
| 50bff270b6 | |||
| bd5cf1c272 | |||
| d22404994a | |||
| 9898730cc5 | |||
| b0f1e55a87 | |||
| 6566824807 | |||
| 9249a2af0d | |||
| 112fc3b1d1 | |||
| 37299b3bd7 | |||
| 8f65ce995a | |||
| 4a743e6dc1 | |||
| 07dda61929 | |||
| 0d8438ef40 | |||
| 96bb638969 | |||
| e74962272e | |||
| 5a15419baf | |||
| e8403977b9 | |||
| add2ca85f2 | |||
| fbb7b02e90 | |||
| 249b62c9de | |||
| b433322e8d | |||
| 1c8850fc95 | |||
| dc16f1b65a | |||
| ff30395dc1 | |||
| 8e600f3302 | |||
| 5a1e0a8379 | |||
| 2a3ce6baa9 | |||
| 01b2f9cff6 | |||
| ac38614171 | |||
| eb95c5cd07 | |||
| a799b54b9e | |||
| 98ba0236e6 | |||
| b6c552df07 | |||
| e2827e475d | |||
| 58cbd337b5 | |||
| a91e59d544 | |||
| 814787677a | |||
| 85caa5bd0c | |||
| e04083fc0e | |||
| cf532e5e0d | |||
| c097fc2c48 | |||
| 0371d71409 | |||
| 81ef7343d4 | |||
| 8e4b59c90c | |||
| 68f73410fc | |||
| 88af8ed374 | |||
| 015f82878e | |||
| 3874e58dc2 | |||
| 9f8c159583 | |||
| d8f6f9ce19 | |||
| eab03e63d4 | |||
| 461829274a | |||
| e751c0c535 | |||
| 1fffc79c32 | |||
| 83fab4bc19 | |||
| f60e28d2f5 | |||
| a62d7aa3ee | |||
| cc84a45244 | |||
| 5cf3d24018 | |||
| 4bdbe617fe | |||
| 33c867fd8c | |||
| 2013ceb9d2 | |||
| 7120c6414c | |||
| 5ce7b2d98d | |||
| cb82198271 | |||
| 5e5ffaa416 | |||
| 4b253e1f73 | |||
| dd929dbf0e | |||
| 97a9d34e96 | |||
| 602070ec9c | |||
| afd8989150 | |||
| 694197a701 | |||
| 2f08306695 | |||
| 6acc77d86d | |||
| 5ddd5e49ee | |||
| 72f9e77368 | |||
| a46c9238fa | |||
| 87120ad4ac | |||
| 7544b5ec9a | |||
| ff4a62d1e7 | |||
| 41daa51988 | |||
| d522350c99 | |||
| 1d1bb9451e | |||
| 1fce1a61d4 | |||
| 883a6caf96 | |||
| a239c39f09 | |||
| e925a8ab99 | |||
| bccaf939e6 | |||
| 676648e0b3 | |||
| 4ae19e6dde | |||
| 4d0ff5c281 | |||
| 327b354cc2 | |||
| 6d307cc9fc | |||
| adc7134af5 | |||
| 10f19cd0c2 | |||
| 9ed45594c6 | |||
| c138f4c3a6 | |||
| a35be05790 | |||
| 60b5ed8e5d | |||
| d8ddbc4d87 | |||
| 19c0fc85e2 | |||
| a58df35ead | |||
| 9789bd02d8 | |||
| d94e54923f | |||
| 64c7be59b7 | |||
| 89ad6ad902 | |||
| 4f73bc9693 | |||
| add6b79231 | |||
| c90dad566f | |||
| 5cbe6bf8f8 | |||
| 4ef6ff217e | |||
| 87abfbf515 | |||
| 73e65fd838 | |||
| e53edb0fc2 | |||
| 17908fbf6b | |||
| 3dae108f84 | |||
| 5bbf685035 | |||
| a63d1e87b1 | |||
| 7129de98cd | |||
| 2984dbc0df | |||
| 392db7f611 | |||
| 5a427b8daa | |||
| 18f2e6f166 | |||
| e78903302f | |||
| 4084ade86c | |||
| 6b0d919dbd | |||
| a7b558b38b | |||
| 6aed7e3ff4 | |||
| 8e93a8a2e2 | |||
| e38a86e37b | |||
| 392e3530bf | |||
| 833c902b2b | |||
| 6eaea64b3f | |||
| 5303b50737 | |||
| 6acbcfe679 | |||
| 16ef5ebb97 | |||
| acfb95f9c2 | |||
| aacea166d7 | |||
| f7bb3b852a | |||
| d4ff1e031a | |||
| 6a3d135d49 | |||
| 5c4bf7aabd | |||
| e9c7dc7464 | |||
| 74ad21b145 | |||
| f214eeb7b1 | |||
| ae25f90f34 |
28
.github/workflows/deploy-end-user-dev.yml
vendored
Normal file
28
.github/workflows/deploy-end-user-dev.yml
vendored
Normal file
@ -0,0 +1,28 @@
|
||||
name: Deploy Trigger Dev
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build and Push API & Web"]
|
||||
branches:
|
||||
- "deploy/end-user-oauth"
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_branch == 'deploy/end-user-oauth'
|
||||
steps:
|
||||
- name: Deploy to server
|
||||
uses: appleboy/ssh-action@v0.1.8
|
||||
with:
|
||||
host: ${{ secrets.TRIGGER_SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
script: |
|
||||
${{ vars.SSH_SCRIPT || secrets.SSH_SCRIPT }}
|
||||
@ -1,6 +1,8 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from opentelemetry.trace import get_current_span
|
||||
|
||||
from configs import dify_config
|
||||
from contexts.wrapper import RecyclableContextVar
|
||||
from dify_app import DifyApp
|
||||
@ -26,8 +28,25 @@ def create_flask_app_with_configs() -> DifyApp:
|
||||
# add an unique identifier to each request
|
||||
RecyclableContextVar.increment_thread_recycles()
|
||||
|
||||
# add after request hook for injecting X-Trace-Id header from OpenTelemetry span context
|
||||
@dify_app.after_request
|
||||
def add_trace_id_header(response):
|
||||
try:
|
||||
span = get_current_span()
|
||||
ctx = span.get_span_context() if span else None
|
||||
if ctx and ctx.is_valid:
|
||||
trace_id_hex = format(ctx.trace_id, "032x")
|
||||
# Avoid duplicates if some middleware added it
|
||||
if "X-Trace-Id" not in response.headers:
|
||||
response.headers["X-Trace-Id"] = trace_id_hex
|
||||
except Exception:
|
||||
# Never break the response due to tracing header injection
|
||||
logger.warning("Failed to add trace ID to response header", exc_info=True)
|
||||
return response
|
||||
|
||||
# Capture the decorator's return value to avoid pyright reportUnusedFunction
|
||||
_ = before_request
|
||||
_ = add_trace_id_header
|
||||
|
||||
return dify_app
|
||||
|
||||
|
||||
@ -553,7 +553,10 @@ class LoggingConfig(BaseSettings):
|
||||
|
||||
LOG_FORMAT: str = Field(
|
||||
description="Format string for log messages",
|
||||
default="%(asctime)s.%(msecs)03d %(levelname)s [%(threadName)s] [%(filename)s:%(lineno)d] - %(message)s",
|
||||
default=(
|
||||
"%(asctime)s.%(msecs)03d %(levelname)s [%(threadName)s] "
|
||||
"[%(filename)s:%(lineno)d] %(trace_id)s - %(message)s"
|
||||
),
|
||||
)
|
||||
|
||||
LOG_DATEFORMAT: str | None = Field(
|
||||
|
||||
@ -324,10 +324,13 @@ class AppListApi(Resource):
|
||||
NodeType.TRIGGER_PLUGIN,
|
||||
}
|
||||
for workflow in draft_workflows:
|
||||
for _, node_data in workflow.walk_nodes():
|
||||
if node_data.get("type") in trigger_node_types:
|
||||
draft_trigger_app_ids.add(str(workflow.app_id))
|
||||
break
|
||||
try:
|
||||
for _, node_data in workflow.walk_nodes():
|
||||
if node_data.get("type") in trigger_node_types:
|
||||
draft_trigger_app_ids.add(str(workflow.app_id))
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
for app in app_pagination.items:
|
||||
app.has_draft_trigger = str(app.id) in draft_trigger_app_ids
|
||||
|
||||
@ -49,7 +49,6 @@ class CompletionConversationQuery(BaseConversationQuery):
|
||||
|
||||
|
||||
class ChatConversationQuery(BaseConversationQuery):
|
||||
message_count_gte: int | None = Field(default=None, ge=1, description="Minimum message count")
|
||||
sort_by: Literal["created_at", "-created_at", "updated_at", "-updated_at"] = Field(
|
||||
default="-updated_at", description="Sort field and direction"
|
||||
)
|
||||
@ -509,14 +508,6 @@ class ChatConversationApi(Resource):
|
||||
.having(func.count(MessageAnnotation.id) == 0)
|
||||
)
|
||||
|
||||
if args.message_count_gte and args.message_count_gte >= 1:
|
||||
query = (
|
||||
query.options(joinedload(Conversation.messages)) # type: ignore
|
||||
.join(Message, Message.conversation_id == Conversation.id)
|
||||
.group_by(Conversation.id)
|
||||
.having(func.count(Message.id) >= args.message_count_gte)
|
||||
)
|
||||
|
||||
if app_model.mode == AppMode.ADVANCED_CHAT:
|
||||
query = query.where(Conversation.invoke_from != InvokeFrom.DEBUGGER)
|
||||
|
||||
|
||||
@ -1004,11 +1004,6 @@ class RagPipelineRecommendedPluginApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self):
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument('type', type=str, location='args', required=False, default='all')
|
||||
args = parser.parse_args()
|
||||
type = args["type"]
|
||||
|
||||
rag_pipeline_service = RagPipelineService()
|
||||
recommended_plugins = rag_pipeline_service.get_recommended_plugins(type)
|
||||
recommended_plugins = rag_pipeline_service.get_recommended_plugins()
|
||||
return recommended_plugins
|
||||
|
||||
@ -316,18 +316,16 @@ def validate_and_get_api_token(scope: str | None = None):
|
||||
ApiToken.type == scope,
|
||||
)
|
||||
.values(last_used_at=current_time)
|
||||
.returning(ApiToken)
|
||||
)
|
||||
stmt = select(ApiToken).where(ApiToken.token == auth_token, ApiToken.type == scope)
|
||||
result = session.execute(update_stmt)
|
||||
api_token = result.scalar_one_or_none()
|
||||
api_token = session.scalar(stmt)
|
||||
|
||||
if hasattr(result, "rowcount") and result.rowcount > 0:
|
||||
session.commit()
|
||||
|
||||
if not api_token:
|
||||
stmt = select(ApiToken).where(ApiToken.token == auth_token, ApiToken.type == scope)
|
||||
api_token = session.scalar(stmt)
|
||||
if not api_token:
|
||||
raise Unauthorized("Access token is invalid")
|
||||
else:
|
||||
session.commit()
|
||||
raise Unauthorized("Access token is invalid")
|
||||
|
||||
return api_token
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
@ -55,6 +56,7 @@ from models import Account, EndUser
|
||||
from services.variable_truncator import BaseTruncator, DummyVariableTruncator, VariableTruncator
|
||||
|
||||
NodeExecutionId = NewType("NodeExecutionId", str)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@ -289,26 +291,30 @@ class WorkflowResponseConverter:
|
||||
),
|
||||
)
|
||||
|
||||
if event.node_type == NodeType.TOOL:
|
||||
response.data.extras["icon"] = ToolManager.get_tool_icon(
|
||||
tenant_id=self._application_generate_entity.app_config.tenant_id,
|
||||
provider_type=ToolProviderType(event.provider_type),
|
||||
provider_id=event.provider_id,
|
||||
)
|
||||
elif event.node_type == NodeType.DATASOURCE:
|
||||
manager = PluginDatasourceManager()
|
||||
provider_entity = manager.fetch_datasource_provider(
|
||||
self._application_generate_entity.app_config.tenant_id,
|
||||
event.provider_id,
|
||||
)
|
||||
response.data.extras["icon"] = provider_entity.declaration.identity.generate_datasource_icon_url(
|
||||
self._application_generate_entity.app_config.tenant_id
|
||||
)
|
||||
elif event.node_type == NodeType.TRIGGER_PLUGIN:
|
||||
response.data.extras["icon"] = TriggerManager.get_trigger_plugin_icon(
|
||||
self._application_generate_entity.app_config.tenant_id,
|
||||
event.provider_id,
|
||||
)
|
||||
try:
|
||||
if event.node_type == NodeType.TOOL:
|
||||
response.data.extras["icon"] = ToolManager.get_tool_icon(
|
||||
tenant_id=self._application_generate_entity.app_config.tenant_id,
|
||||
provider_type=ToolProviderType(event.provider_type),
|
||||
provider_id=event.provider_id,
|
||||
)
|
||||
elif event.node_type == NodeType.DATASOURCE:
|
||||
manager = PluginDatasourceManager()
|
||||
provider_entity = manager.fetch_datasource_provider(
|
||||
self._application_generate_entity.app_config.tenant_id,
|
||||
event.provider_id,
|
||||
)
|
||||
response.data.extras["icon"] = provider_entity.declaration.identity.generate_datasource_icon_url(
|
||||
self._application_generate_entity.app_config.tenant_id
|
||||
)
|
||||
elif event.node_type == NodeType.TRIGGER_PLUGIN:
|
||||
response.data.extras["icon"] = TriggerManager.get_trigger_plugin_icon(
|
||||
self._application_generate_entity.app_config.tenant_id,
|
||||
event.provider_id,
|
||||
)
|
||||
except Exception:
|
||||
# metadata fetch may fail, for example, the plugin daemon is down or plugin is uninstalled.
|
||||
logger.warning("failed to fetch icon for %s", event.provider_id)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@ -29,6 +29,7 @@ class SimpleModelProviderEntity(BaseModel):
|
||||
provider: str
|
||||
label: I18nObject
|
||||
icon_small: I18nObject | None = None
|
||||
icon_small_dark: I18nObject | None = None
|
||||
icon_large: I18nObject | None = None
|
||||
supported_model_types: list[ModelType]
|
||||
|
||||
@ -42,6 +43,7 @@ class SimpleModelProviderEntity(BaseModel):
|
||||
provider=provider_entity.provider,
|
||||
label=provider_entity.label,
|
||||
icon_small=provider_entity.icon_small,
|
||||
icon_small_dark=provider_entity.icon_small_dark,
|
||||
icon_large=provider_entity.icon_large,
|
||||
supported_model_types=provider_entity.supported_model_types,
|
||||
)
|
||||
|
||||
@ -99,6 +99,7 @@ class SimpleProviderEntity(BaseModel):
|
||||
provider: str
|
||||
label: I18nObject
|
||||
icon_small: I18nObject | None = None
|
||||
icon_small_dark: I18nObject | None = None
|
||||
icon_large: I18nObject | None = None
|
||||
supported_model_types: Sequence[ModelType]
|
||||
models: list[AIModelEntity] = []
|
||||
@ -124,7 +125,6 @@ class ProviderEntity(BaseModel):
|
||||
icon_small: I18nObject | None = None
|
||||
icon_large: I18nObject | None = None
|
||||
icon_small_dark: I18nObject | None = None
|
||||
icon_large_dark: I18nObject | None = None
|
||||
background: str | None = None
|
||||
help: ProviderHelpEntity | None = None
|
||||
supported_model_types: Sequence[ModelType]
|
||||
|
||||
@ -300,6 +300,14 @@ class ModelProviderFactory:
|
||||
file_name = provider_schema.icon_small.zh_Hans
|
||||
else:
|
||||
file_name = provider_schema.icon_small.en_US
|
||||
elif icon_type.lower() == "icon_small_dark":
|
||||
if not provider_schema.icon_small_dark:
|
||||
raise ValueError(f"Provider {provider} does not have small dark icon.")
|
||||
|
||||
if lang.lower() == "zh_hans":
|
||||
file_name = provider_schema.icon_small_dark.zh_Hans
|
||||
else:
|
||||
file_name = provider_schema.icon_small_dark.en_US
|
||||
else:
|
||||
if not provider_schema.icon_large:
|
||||
raise ValueError(f"Provider {provider} does not have large icon.")
|
||||
|
||||
@ -123,6 +123,16 @@ class ApiProviderAuthType(StrEnum):
|
||||
raise ValueError(f"invalid mode value '{value}', expected one of: {valid}")
|
||||
|
||||
|
||||
class ToolAuthType(StrEnum):
|
||||
"""
|
||||
Enum class for tool authentication type.
|
||||
Determines whether OAuth credentials are workspace-level or end-user-level.
|
||||
"""
|
||||
|
||||
WORKSPACE = "workspace"
|
||||
END_USER = "end_user"
|
||||
|
||||
|
||||
class ToolInvokeMessage(BaseModel):
|
||||
class TextMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
@ -203,7 +203,7 @@ class WorkflowTool(Tool):
|
||||
Resolve user object in both HTTP and worker contexts.
|
||||
|
||||
In HTTP context: dereference the current_user LocalProxy (can return Account or EndUser).
|
||||
In worker context: load Account from database by user_id (only returns Account, never EndUser).
|
||||
In worker context: load Account(knowledge pipeline) or EndUser(trigger) from database by user_id.
|
||||
|
||||
Returns:
|
||||
Account | EndUser | None: The resolved user object, or None if resolution fails.
|
||||
@ -224,24 +224,28 @@ class WorkflowTool(Tool):
|
||||
logger.warning("Failed to resolve user from request context: %s", e)
|
||||
return None
|
||||
|
||||
def _resolve_user_from_database(self, user_id: str) -> Account | None:
|
||||
def _resolve_user_from_database(self, user_id: str) -> Account | EndUser | None:
|
||||
"""
|
||||
Resolve user from database (worker/Celery context).
|
||||
"""
|
||||
|
||||
user_stmt = select(Account).where(Account.id == user_id)
|
||||
user = db.session.scalar(user_stmt)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
tenant_stmt = select(Tenant).where(Tenant.id == self.runtime.tenant_id)
|
||||
tenant = db.session.scalar(tenant_stmt)
|
||||
if not tenant:
|
||||
return None
|
||||
|
||||
user.current_tenant = tenant
|
||||
user_stmt = select(Account).where(Account.id == user_id)
|
||||
user = db.session.scalar(user_stmt)
|
||||
if user:
|
||||
user.current_tenant = tenant
|
||||
return user
|
||||
|
||||
return user
|
||||
end_user_stmt = select(EndUser).where(EndUser.id == user_id, EndUser.tenant_id == tenant.id)
|
||||
end_user = db.session.scalar(end_user_stmt)
|
||||
if end_user:
|
||||
return end_user
|
||||
|
||||
return None
|
||||
|
||||
def _get_workflow(self, app_id: str, version: str) -> Workflow:
|
||||
"""
|
||||
|
||||
@ -3,7 +3,7 @@ from typing import Any, Literal, Union
|
||||
from pydantic import BaseModel, field_validator
|
||||
from pydantic_core.core_schema import ValidationInfo
|
||||
|
||||
from core.tools.entities.tool_entities import ToolProviderType
|
||||
from core.tools.entities.tool_entities import ToolAuthType, ToolProviderType
|
||||
from core.workflow.nodes.base.entities import BaseNodeData
|
||||
|
||||
|
||||
@ -16,6 +16,7 @@ class ToolEntity(BaseModel):
|
||||
tool_configurations: dict[str, Any]
|
||||
credential_id: str | None = None
|
||||
plugin_unique_identifier: str | None = None # redundancy
|
||||
auth_type: ToolAuthType = ToolAuthType.WORKSPACE # OAuth authentication level
|
||||
|
||||
@field_validator("tool_configurations", mode="before")
|
||||
@classmethod
|
||||
|
||||
@ -6,6 +6,7 @@ BASE_CORS_HEADERS: tuple[str, ...] = ("Content-Type", HEADER_NAME_APP_CODE, HEAD
|
||||
SERVICE_API_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, "Authorization")
|
||||
AUTHENTICATED_HEADERS: tuple[str, ...] = (*SERVICE_API_HEADERS, HEADER_NAME_CSRF_TOKEN)
|
||||
FILES_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, HEADER_NAME_CSRF_TOKEN)
|
||||
EXPOSED_HEADERS: tuple[str, ...] = ("X-Version", "X-Env", "X-Trace-Id")
|
||||
|
||||
|
||||
def init_app(app: DifyApp):
|
||||
@ -25,6 +26,7 @@ def init_app(app: DifyApp):
|
||||
service_api_bp,
|
||||
allow_headers=list(SERVICE_API_HEADERS),
|
||||
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
|
||||
expose_headers=list(EXPOSED_HEADERS),
|
||||
)
|
||||
app.register_blueprint(service_api_bp)
|
||||
|
||||
@ -34,7 +36,7 @@ def init_app(app: DifyApp):
|
||||
supports_credentials=True,
|
||||
allow_headers=list(AUTHENTICATED_HEADERS),
|
||||
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
|
||||
expose_headers=["X-Version", "X-Env"],
|
||||
expose_headers=list(EXPOSED_HEADERS),
|
||||
)
|
||||
app.register_blueprint(web_bp)
|
||||
|
||||
@ -44,7 +46,7 @@ def init_app(app: DifyApp):
|
||||
supports_credentials=True,
|
||||
allow_headers=list(AUTHENTICATED_HEADERS),
|
||||
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
|
||||
expose_headers=["X-Version", "X-Env"],
|
||||
expose_headers=list(EXPOSED_HEADERS),
|
||||
)
|
||||
app.register_blueprint(console_app_bp)
|
||||
|
||||
@ -52,6 +54,7 @@ def init_app(app: DifyApp):
|
||||
files_bp,
|
||||
allow_headers=list(FILES_HEADERS),
|
||||
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
|
||||
expose_headers=list(EXPOSED_HEADERS),
|
||||
)
|
||||
app.register_blueprint(files_bp)
|
||||
|
||||
@ -63,5 +66,6 @@ def init_app(app: DifyApp):
|
||||
trigger_bp,
|
||||
allow_headers=["Content-Type", "Authorization", "X-App-Code"],
|
||||
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH", "HEAD"],
|
||||
expose_headers=list(EXPOSED_HEADERS),
|
||||
)
|
||||
app.register_blueprint(trigger_bp)
|
||||
|
||||
@ -7,6 +7,7 @@ from logging.handlers import RotatingFileHandler
|
||||
import flask
|
||||
|
||||
from configs import dify_config
|
||||
from core.helper.trace_id_helper import get_trace_id_from_otel_context
|
||||
from dify_app import DifyApp
|
||||
|
||||
|
||||
@ -76,7 +77,9 @@ class RequestIdFilter(logging.Filter):
|
||||
# the logging format. Note that we're checking if we're in a request
|
||||
# context, as we may want to log things before Flask is fully loaded.
|
||||
def filter(self, record):
|
||||
trace_id = get_trace_id_from_otel_context() or ""
|
||||
record.req_id = get_request_id() if flask.has_request_context() else ""
|
||||
record.trace_id = trace_id
|
||||
return True
|
||||
|
||||
|
||||
@ -84,6 +87,8 @@ class RequestIdFormatter(logging.Formatter):
|
||||
def format(self, record):
|
||||
if not hasattr(record, "req_id"):
|
||||
record.req_id = ""
|
||||
if not hasattr(record, "trace_id"):
|
||||
record.trace_id = ""
|
||||
return super().format(record)
|
||||
|
||||
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
import flask
|
||||
import werkzeug.http
|
||||
from flask import Flask
|
||||
from flask import Flask, g
|
||||
from flask.signals import request_finished, request_started
|
||||
|
||||
from configs import dify_config
|
||||
from core.helper.trace_id_helper import get_trace_id_from_otel_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -20,6 +22,9 @@ def _is_content_type_json(content_type: str) -> bool:
|
||||
|
||||
def _log_request_started(_sender, **_extra):
|
||||
"""Log the start of a request."""
|
||||
# Record start time for access logging
|
||||
g.__request_started_ts = time.perf_counter()
|
||||
|
||||
if not logger.isEnabledFor(logging.DEBUG):
|
||||
return
|
||||
|
||||
@ -42,8 +47,39 @@ def _log_request_started(_sender, **_extra):
|
||||
|
||||
|
||||
def _log_request_finished(_sender, response, **_extra):
|
||||
"""Log the end of a request."""
|
||||
if not logger.isEnabledFor(logging.DEBUG) or response is None:
|
||||
"""Log the end of a request.
|
||||
|
||||
Safe to call with or without an active Flask request context.
|
||||
"""
|
||||
if response is None:
|
||||
return
|
||||
|
||||
# Always emit a compact access line at INFO with trace_id so it can be grepped
|
||||
has_ctx = flask.has_request_context()
|
||||
start_ts = getattr(g, "__request_started_ts", None) if has_ctx else None
|
||||
duration_ms = None
|
||||
if start_ts is not None:
|
||||
duration_ms = round((time.perf_counter() - start_ts) * 1000, 3)
|
||||
|
||||
# Request attributes are available only when a request context exists
|
||||
if has_ctx:
|
||||
req_method = flask.request.method
|
||||
req_path = flask.request.path
|
||||
else:
|
||||
req_method = "-"
|
||||
req_path = "-"
|
||||
|
||||
trace_id = get_trace_id_from_otel_context() or response.headers.get("X-Trace-Id") or ""
|
||||
logger.info(
|
||||
"%s %s %s %s %s",
|
||||
req_method,
|
||||
req_path,
|
||||
getattr(response, "status_code", "-"),
|
||||
duration_ms if duration_ms is not None else "-",
|
||||
trace_id,
|
||||
)
|
||||
|
||||
if not logger.isEnabledFor(logging.DEBUG):
|
||||
return
|
||||
|
||||
if not _is_content_type_json(response.content_type):
|
||||
|
||||
@ -0,0 +1,71 @@
|
||||
"""add enduser authentication provider
|
||||
|
||||
Revision ID: a7b4e8f2c9d1
|
||||
Revises: fecff1c3da27
|
||||
Create Date: 2025-11-18 14:00:00.000000
|
||||
|
||||
"""
|
||||
import models as models
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a7b4e8f2c9d1"
|
||||
down_revision = "fecff1c3da27"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"tool_enduser_authentication_providers",
|
||||
sa.Column(
|
||||
"id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"name",
|
||||
sa.String(length=256),
|
||||
server_default="API KEY 1",
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("end_user_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("provider", sa.Text(), nullable=False),
|
||||
sa.Column("encrypted_credentials", sa.Text(), default="", nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
server_default=sa.func.current_timestamp(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
server_default=sa.func.current_timestamp(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"credential_type",
|
||||
sa.String(length=32),
|
||||
server_default="api-key",
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("expires_at", sa.BigInteger(), server_default=sa.text("-1"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"end_user_id",
|
||||
"provider",
|
||||
),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table("tool_enduser_authentication_providers")
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
@ -1,64 +0,0 @@
|
||||
"""Alter table pipeline_recommended_plugins add column type
|
||||
|
||||
Revision ID: 6bb0832495f0
|
||||
Revises: 7bb281b7a422
|
||||
Create Date: 2025-12-15 16:14:38.482072
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import models as models
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '6bb0832495f0'
|
||||
down_revision = '7bb281b7a422'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('app_triggers', schema=None) as batch_op:
|
||||
batch_op.alter_column('provider_name',
|
||||
existing_type=sa.VARCHAR(length=255),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text("''::character varying"))
|
||||
|
||||
with op.batch_alter_table('operation_logs', schema=None) as batch_op:
|
||||
batch_op.alter_column('content',
|
||||
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||
nullable=False)
|
||||
|
||||
with op.batch_alter_table('pipeline_recommended_plugins', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('type', sa.String(length=50), nullable=True))
|
||||
|
||||
with op.batch_alter_table('providers', schema=None) as batch_op:
|
||||
batch_op.alter_column('quota_used',
|
||||
existing_type=sa.BIGINT(),
|
||||
nullable=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('providers', schema=None) as batch_op:
|
||||
batch_op.alter_column('quota_used',
|
||||
existing_type=sa.BIGINT(),
|
||||
nullable=True)
|
||||
|
||||
with op.batch_alter_table('pipeline_recommended_plugins', schema=None) as batch_op:
|
||||
batch_op.drop_column('type')
|
||||
|
||||
with op.batch_alter_table('operation_logs', schema=None) as batch_op:
|
||||
batch_op.alter_column('content',
|
||||
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||
nullable=True)
|
||||
|
||||
with op.batch_alter_table('app_triggers', schema=None) as batch_op:
|
||||
batch_op.alter_column('provider_name',
|
||||
existing_type=sa.VARCHAR(length=255),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text("''::character varying"))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@ -80,6 +80,7 @@ from .task import CeleryTask, CeleryTaskSet
|
||||
from .tools import (
|
||||
ApiToolProvider,
|
||||
BuiltinToolProvider,
|
||||
EndUserAuthenticationProvider,
|
||||
ToolConversationVariables,
|
||||
ToolFile,
|
||||
ToolLabelBinding,
|
||||
@ -149,6 +150,7 @@ __all__ = [
|
||||
"DocumentSegment",
|
||||
"Embedding",
|
||||
"EndUser",
|
||||
"EndUserAuthenticationProvider",
|
||||
"ExternalKnowledgeApis",
|
||||
"ExternalKnowledgeBindings",
|
||||
"IconType",
|
||||
|
||||
@ -1458,7 +1458,6 @@ class PipelineRecommendedPlugin(TypeBase):
|
||||
)
|
||||
plugin_id: Mapped[str] = mapped_column(LongText, nullable=False)
|
||||
provider_name: Mapped[str] = mapped_column(LongText, nullable=False)
|
||||
type: Mapped[str] = mapped_column(sa.String(50), nullable=True)
|
||||
position: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0)
|
||||
active: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
|
||||
@ -9,9 +9,11 @@ from deprecated import deprecated
|
||||
from sqlalchemy import ForeignKey, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from core.plugin.entities.plugin_daemon import CredentialType
|
||||
from core.tools.entities.common_entities import I18nObject
|
||||
from core.tools.entities.tool_bundle import ApiToolBundle
|
||||
from core.tools.entities.tool_entities import ApiProviderSchemaType, WorkflowToolParameterConfiguration
|
||||
from libs.uuid_utils import uuidv7
|
||||
|
||||
from .base import TypeBase
|
||||
from .engine import db
|
||||
@ -115,6 +117,59 @@ class BuiltinToolProvider(TypeBase):
|
||||
return cast(dict[str, Any], json.loads(self.encrypted_credentials))
|
||||
|
||||
|
||||
class EndUserAuthenticationProvider(TypeBase):
|
||||
"""
|
||||
This table stores the authentication credentials for end users in tools.
|
||||
Mimics the BuiltinToolProvider structure but for end users instead of tenants.
|
||||
"""
|
||||
|
||||
__tablename__ = "tool_enduser_authentication_providers"
|
||||
__table_args__ = (
|
||||
sa.UniqueConstraint("end_user_id", "provider"),
|
||||
)
|
||||
|
||||
# id of the authentication provider
|
||||
id: Mapped[str] = mapped_column(StringUUID, primary_key=True, default=lambda: str(uuidv7()), init=False)
|
||||
# id of the tenant
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
# id of the end user
|
||||
end_user_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
# name of the tool provider
|
||||
provider: Mapped[str] = mapped_column(LongText, nullable=False)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(256),
|
||||
nullable=False,
|
||||
default="API KEY 1",
|
||||
)
|
||||
# encrypted credentials for the end user
|
||||
encrypted_credentials: Mapped[str] = mapped_column(LongText, nullable=False, default="")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
sa.DateTime,
|
||||
nullable=False,
|
||||
server_default=func.current_timestamp(),
|
||||
onupdate=func.current_timestamp(),
|
||||
init=False,
|
||||
)
|
||||
# credential type, e.g., "api-key", "oauth2"
|
||||
credential_type: Mapped[CredentialType] = mapped_column(
|
||||
String(32), nullable=False, default=CredentialType.API_KEY
|
||||
)
|
||||
# Unix timestamp in seconds since epoch (1970-01-01 UTC); -1 indicates no expiration
|
||||
expires_at: Mapped[int] = mapped_column(sa.BigInteger, nullable=False, default=-1)
|
||||
|
||||
@property
|
||||
def credentials(self) -> dict[str, Any]:
|
||||
if not self.encrypted_credentials:
|
||||
return {}
|
||||
try:
|
||||
return cast(dict[str, Any], json.loads(self.encrypted_credentials))
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
|
||||
class ApiToolProvider(TypeBase):
|
||||
"""
|
||||
The table stores the api providers.
|
||||
|
||||
@ -907,29 +907,19 @@ class WorkflowNodeExecutionModel(Base): # This model is expected to have `offlo
|
||||
@property
|
||||
def extras(self) -> dict[str, Any]:
|
||||
from core.tools.tool_manager import ToolManager
|
||||
from core.trigger.trigger_manager import TriggerManager
|
||||
|
||||
extras: dict[str, Any] = {}
|
||||
execution_metadata = self.execution_metadata_dict
|
||||
if execution_metadata:
|
||||
if self.node_type == NodeType.TOOL and "tool_info" in execution_metadata:
|
||||
tool_info: dict[str, Any] = execution_metadata["tool_info"]
|
||||
if self.execution_metadata_dict:
|
||||
if self.node_type == NodeType.TOOL and "tool_info" in self.execution_metadata_dict:
|
||||
tool_info: dict[str, Any] = self.execution_metadata_dict["tool_info"]
|
||||
extras["icon"] = ToolManager.get_tool_icon(
|
||||
tenant_id=self.tenant_id,
|
||||
provider_type=tool_info["provider_type"],
|
||||
provider_id=tool_info["provider_id"],
|
||||
)
|
||||
elif self.node_type == NodeType.DATASOURCE and "datasource_info" in execution_metadata:
|
||||
datasource_info = execution_metadata["datasource_info"]
|
||||
elif self.node_type == NodeType.DATASOURCE and "datasource_info" in self.execution_metadata_dict:
|
||||
datasource_info = self.execution_metadata_dict["datasource_info"]
|
||||
extras["icon"] = datasource_info.get("icon")
|
||||
elif self.node_type == NodeType.TRIGGER_PLUGIN and "trigger_info" in execution_metadata:
|
||||
trigger_info = execution_metadata["trigger_info"] or {}
|
||||
provider_id = trigger_info.get("provider_id")
|
||||
if provider_id:
|
||||
extras["icon"] = TriggerManager.get_trigger_plugin_icon(
|
||||
tenant_id=self.tenant_id,
|
||||
provider_id=provider_id,
|
||||
)
|
||||
return extras
|
||||
|
||||
def _get_offload_by_type(self, type_: ExecutionOffLoadType) -> Optional["WorkflowNodeExecutionOffload"]:
|
||||
|
||||
@ -10,6 +10,7 @@ from collections.abc import Sequence
|
||||
from typing import Any, Literal
|
||||
|
||||
import sqlalchemy as sa
|
||||
from redis.exceptions import LockNotOwnedError
|
||||
from sqlalchemy import exists, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import NotFound
|
||||
@ -1593,173 +1594,176 @@ class DocumentService:
|
||||
db.session.add(dataset_process_rule)
|
||||
db.session.flush()
|
||||
lock_name = f"add_document_lock_dataset_id_{dataset.id}"
|
||||
with redis_client.lock(lock_name, timeout=600):
|
||||
assert dataset_process_rule
|
||||
position = DocumentService.get_documents_position(dataset.id)
|
||||
document_ids = []
|
||||
duplicate_document_ids = []
|
||||
if knowledge_config.data_source.info_list.data_source_type == "upload_file":
|
||||
if not knowledge_config.data_source.info_list.file_info_list:
|
||||
raise ValueError("File source info is required")
|
||||
upload_file_list = knowledge_config.data_source.info_list.file_info_list.file_ids
|
||||
for file_id in upload_file_list:
|
||||
file = (
|
||||
db.session.query(UploadFile)
|
||||
.where(UploadFile.tenant_id == dataset.tenant_id, UploadFile.id == file_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
# raise error if file not found
|
||||
if not file:
|
||||
raise FileNotExistsError()
|
||||
|
||||
file_name = file.name
|
||||
data_source_info: dict[str, str | bool] = {
|
||||
"upload_file_id": file_id,
|
||||
}
|
||||
# check duplicate
|
||||
if knowledge_config.duplicate:
|
||||
document = (
|
||||
db.session.query(Document)
|
||||
.filter_by(
|
||||
dataset_id=dataset.id,
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
data_source_type="upload_file",
|
||||
enabled=True,
|
||||
name=file_name,
|
||||
)
|
||||
try:
|
||||
with redis_client.lock(lock_name, timeout=600):
|
||||
assert dataset_process_rule
|
||||
position = DocumentService.get_documents_position(dataset.id)
|
||||
document_ids = []
|
||||
duplicate_document_ids = []
|
||||
if knowledge_config.data_source.info_list.data_source_type == "upload_file":
|
||||
if not knowledge_config.data_source.info_list.file_info_list:
|
||||
raise ValueError("File source info is required")
|
||||
upload_file_list = knowledge_config.data_source.info_list.file_info_list.file_ids
|
||||
for file_id in upload_file_list:
|
||||
file = (
|
||||
db.session.query(UploadFile)
|
||||
.where(UploadFile.tenant_id == dataset.tenant_id, UploadFile.id == file_id)
|
||||
.first()
|
||||
)
|
||||
if document:
|
||||
document.dataset_process_rule_id = dataset_process_rule.id
|
||||
document.updated_at = naive_utc_now()
|
||||
document.created_from = created_from
|
||||
document.doc_form = knowledge_config.doc_form
|
||||
document.doc_language = knowledge_config.doc_language
|
||||
document.data_source_info = json.dumps(data_source_info)
|
||||
document.batch = batch
|
||||
document.indexing_status = "waiting"
|
||||
db.session.add(document)
|
||||
documents.append(document)
|
||||
duplicate_document_ids.append(document.id)
|
||||
continue
|
||||
document = DocumentService.build_document(
|
||||
dataset,
|
||||
dataset_process_rule.id,
|
||||
knowledge_config.data_source.info_list.data_source_type,
|
||||
knowledge_config.doc_form,
|
||||
knowledge_config.doc_language,
|
||||
data_source_info,
|
||||
created_from,
|
||||
position,
|
||||
account,
|
||||
file_name,
|
||||
batch,
|
||||
)
|
||||
db.session.add(document)
|
||||
db.session.flush()
|
||||
document_ids.append(document.id)
|
||||
documents.append(document)
|
||||
position += 1
|
||||
elif knowledge_config.data_source.info_list.data_source_type == "notion_import":
|
||||
notion_info_list = knowledge_config.data_source.info_list.notion_info_list # type: ignore
|
||||
if not notion_info_list:
|
||||
raise ValueError("No notion info list found.")
|
||||
exist_page_ids = []
|
||||
exist_document = {}
|
||||
documents = (
|
||||
db.session.query(Document)
|
||||
.filter_by(
|
||||
dataset_id=dataset.id,
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
data_source_type="notion_import",
|
||||
enabled=True,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
if documents:
|
||||
for document in documents:
|
||||
data_source_info = json.loads(document.data_source_info)
|
||||
exist_page_ids.append(data_source_info["notion_page_id"])
|
||||
exist_document[data_source_info["notion_page_id"]] = document.id
|
||||
for notion_info in notion_info_list:
|
||||
workspace_id = notion_info.workspace_id
|
||||
for page in notion_info.pages:
|
||||
if page.page_id not in exist_page_ids:
|
||||
data_source_info = {
|
||||
"credential_id": notion_info.credential_id,
|
||||
"notion_workspace_id": workspace_id,
|
||||
"notion_page_id": page.page_id,
|
||||
"notion_page_icon": page.page_icon.model_dump() if page.page_icon else None, # type: ignore
|
||||
"type": page.type,
|
||||
}
|
||||
# Truncate page name to 255 characters to prevent DB field length errors
|
||||
truncated_page_name = page.page_name[:255] if page.page_name else "nopagename"
|
||||
document = DocumentService.build_document(
|
||||
dataset,
|
||||
dataset_process_rule.id,
|
||||
knowledge_config.data_source.info_list.data_source_type,
|
||||
knowledge_config.doc_form,
|
||||
knowledge_config.doc_language,
|
||||
data_source_info,
|
||||
created_from,
|
||||
position,
|
||||
account,
|
||||
truncated_page_name,
|
||||
batch,
|
||||
)
|
||||
db.session.add(document)
|
||||
db.session.flush()
|
||||
document_ids.append(document.id)
|
||||
documents.append(document)
|
||||
position += 1
|
||||
else:
|
||||
exist_document.pop(page.page_id)
|
||||
# delete not selected documents
|
||||
if len(exist_document) > 0:
|
||||
clean_notion_document_task.delay(list(exist_document.values()), dataset.id)
|
||||
elif knowledge_config.data_source.info_list.data_source_type == "website_crawl":
|
||||
website_info = knowledge_config.data_source.info_list.website_info_list
|
||||
if not website_info:
|
||||
raise ValueError("No website info list found.")
|
||||
urls = website_info.urls
|
||||
for url in urls:
|
||||
data_source_info = {
|
||||
"url": url,
|
||||
"provider": website_info.provider,
|
||||
"job_id": website_info.job_id,
|
||||
"only_main_content": website_info.only_main_content,
|
||||
"mode": "crawl",
|
||||
}
|
||||
if len(url) > 255:
|
||||
document_name = url[:200] + "..."
|
||||
else:
|
||||
document_name = url
|
||||
document = DocumentService.build_document(
|
||||
dataset,
|
||||
dataset_process_rule.id,
|
||||
knowledge_config.data_source.info_list.data_source_type,
|
||||
knowledge_config.doc_form,
|
||||
knowledge_config.doc_language,
|
||||
data_source_info,
|
||||
created_from,
|
||||
position,
|
||||
account,
|
||||
document_name,
|
||||
batch,
|
||||
)
|
||||
db.session.add(document)
|
||||
db.session.flush()
|
||||
document_ids.append(document.id)
|
||||
documents.append(document)
|
||||
position += 1
|
||||
db.session.commit()
|
||||
|
||||
# trigger async task
|
||||
if document_ids:
|
||||
DocumentIndexingTaskProxy(dataset.tenant_id, dataset.id, document_ids).delay()
|
||||
if duplicate_document_ids:
|
||||
duplicate_document_indexing_task.delay(dataset.id, duplicate_document_ids)
|
||||
# raise error if file not found
|
||||
if not file:
|
||||
raise FileNotExistsError()
|
||||
|
||||
file_name = file.name
|
||||
data_source_info: dict[str, str | bool] = {
|
||||
"upload_file_id": file_id,
|
||||
}
|
||||
# check duplicate
|
||||
if knowledge_config.duplicate:
|
||||
document = (
|
||||
db.session.query(Document)
|
||||
.filter_by(
|
||||
dataset_id=dataset.id,
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
data_source_type="upload_file",
|
||||
enabled=True,
|
||||
name=file_name,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if document:
|
||||
document.dataset_process_rule_id = dataset_process_rule.id
|
||||
document.updated_at = naive_utc_now()
|
||||
document.created_from = created_from
|
||||
document.doc_form = knowledge_config.doc_form
|
||||
document.doc_language = knowledge_config.doc_language
|
||||
document.data_source_info = json.dumps(data_source_info)
|
||||
document.batch = batch
|
||||
document.indexing_status = "waiting"
|
||||
db.session.add(document)
|
||||
documents.append(document)
|
||||
duplicate_document_ids.append(document.id)
|
||||
continue
|
||||
document = DocumentService.build_document(
|
||||
dataset,
|
||||
dataset_process_rule.id,
|
||||
knowledge_config.data_source.info_list.data_source_type,
|
||||
knowledge_config.doc_form,
|
||||
knowledge_config.doc_language,
|
||||
data_source_info,
|
||||
created_from,
|
||||
position,
|
||||
account,
|
||||
file_name,
|
||||
batch,
|
||||
)
|
||||
db.session.add(document)
|
||||
db.session.flush()
|
||||
document_ids.append(document.id)
|
||||
documents.append(document)
|
||||
position += 1
|
||||
elif knowledge_config.data_source.info_list.data_source_type == "notion_import":
|
||||
notion_info_list = knowledge_config.data_source.info_list.notion_info_list # type: ignore
|
||||
if not notion_info_list:
|
||||
raise ValueError("No notion info list found.")
|
||||
exist_page_ids = []
|
||||
exist_document = {}
|
||||
documents = (
|
||||
db.session.query(Document)
|
||||
.filter_by(
|
||||
dataset_id=dataset.id,
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
data_source_type="notion_import",
|
||||
enabled=True,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
if documents:
|
||||
for document in documents:
|
||||
data_source_info = json.loads(document.data_source_info)
|
||||
exist_page_ids.append(data_source_info["notion_page_id"])
|
||||
exist_document[data_source_info["notion_page_id"]] = document.id
|
||||
for notion_info in notion_info_list:
|
||||
workspace_id = notion_info.workspace_id
|
||||
for page in notion_info.pages:
|
||||
if page.page_id not in exist_page_ids:
|
||||
data_source_info = {
|
||||
"credential_id": notion_info.credential_id,
|
||||
"notion_workspace_id": workspace_id,
|
||||
"notion_page_id": page.page_id,
|
||||
"notion_page_icon": page.page_icon.model_dump() if page.page_icon else None, # type: ignore
|
||||
"type": page.type,
|
||||
}
|
||||
# Truncate page name to 255 characters to prevent DB field length errors
|
||||
truncated_page_name = page.page_name[:255] if page.page_name else "nopagename"
|
||||
document = DocumentService.build_document(
|
||||
dataset,
|
||||
dataset_process_rule.id,
|
||||
knowledge_config.data_source.info_list.data_source_type,
|
||||
knowledge_config.doc_form,
|
||||
knowledge_config.doc_language,
|
||||
data_source_info,
|
||||
created_from,
|
||||
position,
|
||||
account,
|
||||
truncated_page_name,
|
||||
batch,
|
||||
)
|
||||
db.session.add(document)
|
||||
db.session.flush()
|
||||
document_ids.append(document.id)
|
||||
documents.append(document)
|
||||
position += 1
|
||||
else:
|
||||
exist_document.pop(page.page_id)
|
||||
# delete not selected documents
|
||||
if len(exist_document) > 0:
|
||||
clean_notion_document_task.delay(list(exist_document.values()), dataset.id)
|
||||
elif knowledge_config.data_source.info_list.data_source_type == "website_crawl":
|
||||
website_info = knowledge_config.data_source.info_list.website_info_list
|
||||
if not website_info:
|
||||
raise ValueError("No website info list found.")
|
||||
urls = website_info.urls
|
||||
for url in urls:
|
||||
data_source_info = {
|
||||
"url": url,
|
||||
"provider": website_info.provider,
|
||||
"job_id": website_info.job_id,
|
||||
"only_main_content": website_info.only_main_content,
|
||||
"mode": "crawl",
|
||||
}
|
||||
if len(url) > 255:
|
||||
document_name = url[:200] + "..."
|
||||
else:
|
||||
document_name = url
|
||||
document = DocumentService.build_document(
|
||||
dataset,
|
||||
dataset_process_rule.id,
|
||||
knowledge_config.data_source.info_list.data_source_type,
|
||||
knowledge_config.doc_form,
|
||||
knowledge_config.doc_language,
|
||||
data_source_info,
|
||||
created_from,
|
||||
position,
|
||||
account,
|
||||
document_name,
|
||||
batch,
|
||||
)
|
||||
db.session.add(document)
|
||||
db.session.flush()
|
||||
document_ids.append(document.id)
|
||||
documents.append(document)
|
||||
position += 1
|
||||
db.session.commit()
|
||||
|
||||
# trigger async task
|
||||
if document_ids:
|
||||
DocumentIndexingTaskProxy(dataset.tenant_id, dataset.id, document_ids).delay()
|
||||
if duplicate_document_ids:
|
||||
duplicate_document_indexing_task.delay(dataset.id, duplicate_document_ids)
|
||||
except LockNotOwnedError:
|
||||
pass
|
||||
|
||||
return documents, batch
|
||||
|
||||
@ -2699,50 +2703,55 @@ class SegmentService:
|
||||
# calc embedding use tokens
|
||||
tokens = embedding_model.get_text_embedding_num_tokens(texts=[content])[0]
|
||||
lock_name = f"add_segment_lock_document_id_{document.id}"
|
||||
with redis_client.lock(lock_name, timeout=600):
|
||||
max_position = (
|
||||
db.session.query(func.max(DocumentSegment.position))
|
||||
.where(DocumentSegment.document_id == document.id)
|
||||
.scalar()
|
||||
)
|
||||
segment_document = DocumentSegment(
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
dataset_id=document.dataset_id,
|
||||
document_id=document.id,
|
||||
index_node_id=doc_id,
|
||||
index_node_hash=segment_hash,
|
||||
position=max_position + 1 if max_position else 1,
|
||||
content=content,
|
||||
word_count=len(content),
|
||||
tokens=tokens,
|
||||
status="completed",
|
||||
indexing_at=naive_utc_now(),
|
||||
completed_at=naive_utc_now(),
|
||||
created_by=current_user.id,
|
||||
)
|
||||
if document.doc_form == "qa_model":
|
||||
segment_document.word_count += len(args["answer"])
|
||||
segment_document.answer = args["answer"]
|
||||
try:
|
||||
with redis_client.lock(lock_name, timeout=600):
|
||||
max_position = (
|
||||
db.session.query(func.max(DocumentSegment.position))
|
||||
.where(DocumentSegment.document_id == document.id)
|
||||
.scalar()
|
||||
)
|
||||
segment_document = DocumentSegment(
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
dataset_id=document.dataset_id,
|
||||
document_id=document.id,
|
||||
index_node_id=doc_id,
|
||||
index_node_hash=segment_hash,
|
||||
position=max_position + 1 if max_position else 1,
|
||||
content=content,
|
||||
word_count=len(content),
|
||||
tokens=tokens,
|
||||
status="completed",
|
||||
indexing_at=naive_utc_now(),
|
||||
completed_at=naive_utc_now(),
|
||||
created_by=current_user.id,
|
||||
)
|
||||
if document.doc_form == "qa_model":
|
||||
segment_document.word_count += len(args["answer"])
|
||||
segment_document.answer = args["answer"]
|
||||
|
||||
db.session.add(segment_document)
|
||||
# update document word count
|
||||
assert document.word_count is not None
|
||||
document.word_count += segment_document.word_count
|
||||
db.session.add(document)
|
||||
db.session.commit()
|
||||
|
||||
# save vector index
|
||||
try:
|
||||
VectorService.create_segments_vector([args["keywords"]], [segment_document], dataset, document.doc_form)
|
||||
except Exception as e:
|
||||
logger.exception("create segment index failed")
|
||||
segment_document.enabled = False
|
||||
segment_document.disabled_at = naive_utc_now()
|
||||
segment_document.status = "error"
|
||||
segment_document.error = str(e)
|
||||
db.session.add(segment_document)
|
||||
# update document word count
|
||||
assert document.word_count is not None
|
||||
document.word_count += segment_document.word_count
|
||||
db.session.add(document)
|
||||
db.session.commit()
|
||||
segment = db.session.query(DocumentSegment).where(DocumentSegment.id == segment_document.id).first()
|
||||
return segment
|
||||
|
||||
# save vector index
|
||||
try:
|
||||
VectorService.create_segments_vector(
|
||||
[args["keywords"]], [segment_document], dataset, document.doc_form
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("create segment index failed")
|
||||
segment_document.enabled = False
|
||||
segment_document.disabled_at = naive_utc_now()
|
||||
segment_document.status = "error"
|
||||
segment_document.error = str(e)
|
||||
db.session.commit()
|
||||
segment = db.session.query(DocumentSegment).where(DocumentSegment.id == segment_document.id).first()
|
||||
return segment
|
||||
except LockNotOwnedError:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def multi_create_segment(cls, segments: list, document: Document, dataset: Dataset):
|
||||
@ -2751,84 +2760,89 @@ class SegmentService:
|
||||
|
||||
lock_name = f"multi_add_segment_lock_document_id_{document.id}"
|
||||
increment_word_count = 0
|
||||
with redis_client.lock(lock_name, timeout=600):
|
||||
embedding_model = None
|
||||
if dataset.indexing_technique == "high_quality":
|
||||
model_manager = ModelManager()
|
||||
embedding_model = model_manager.get_model_instance(
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
provider=dataset.embedding_model_provider,
|
||||
model_type=ModelType.TEXT_EMBEDDING,
|
||||
model=dataset.embedding_model,
|
||||
try:
|
||||
with redis_client.lock(lock_name, timeout=600):
|
||||
embedding_model = None
|
||||
if dataset.indexing_technique == "high_quality":
|
||||
model_manager = ModelManager()
|
||||
embedding_model = model_manager.get_model_instance(
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
provider=dataset.embedding_model_provider,
|
||||
model_type=ModelType.TEXT_EMBEDDING,
|
||||
model=dataset.embedding_model,
|
||||
)
|
||||
max_position = (
|
||||
db.session.query(func.max(DocumentSegment.position))
|
||||
.where(DocumentSegment.document_id == document.id)
|
||||
.scalar()
|
||||
)
|
||||
max_position = (
|
||||
db.session.query(func.max(DocumentSegment.position))
|
||||
.where(DocumentSegment.document_id == document.id)
|
||||
.scalar()
|
||||
)
|
||||
pre_segment_data_list = []
|
||||
segment_data_list = []
|
||||
keywords_list = []
|
||||
position = max_position + 1 if max_position else 1
|
||||
for segment_item in segments:
|
||||
content = segment_item["content"]
|
||||
doc_id = str(uuid.uuid4())
|
||||
segment_hash = helper.generate_text_hash(content)
|
||||
tokens = 0
|
||||
if dataset.indexing_technique == "high_quality" and embedding_model:
|
||||
# calc embedding use tokens
|
||||
pre_segment_data_list = []
|
||||
segment_data_list = []
|
||||
keywords_list = []
|
||||
position = max_position + 1 if max_position else 1
|
||||
for segment_item in segments:
|
||||
content = segment_item["content"]
|
||||
doc_id = str(uuid.uuid4())
|
||||
segment_hash = helper.generate_text_hash(content)
|
||||
tokens = 0
|
||||
if dataset.indexing_technique == "high_quality" and embedding_model:
|
||||
# calc embedding use tokens
|
||||
if document.doc_form == "qa_model":
|
||||
tokens = embedding_model.get_text_embedding_num_tokens(
|
||||
texts=[content + segment_item["answer"]]
|
||||
)[0]
|
||||
else:
|
||||
tokens = embedding_model.get_text_embedding_num_tokens(texts=[content])[0]
|
||||
|
||||
segment_document = DocumentSegment(
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
dataset_id=document.dataset_id,
|
||||
document_id=document.id,
|
||||
index_node_id=doc_id,
|
||||
index_node_hash=segment_hash,
|
||||
position=position,
|
||||
content=content,
|
||||
word_count=len(content),
|
||||
tokens=tokens,
|
||||
keywords=segment_item.get("keywords", []),
|
||||
status="completed",
|
||||
indexing_at=naive_utc_now(),
|
||||
completed_at=naive_utc_now(),
|
||||
created_by=current_user.id,
|
||||
)
|
||||
if document.doc_form == "qa_model":
|
||||
tokens = embedding_model.get_text_embedding_num_tokens(
|
||||
texts=[content + segment_item["answer"]]
|
||||
)[0]
|
||||
segment_document.answer = segment_item["answer"]
|
||||
segment_document.word_count += len(segment_item["answer"])
|
||||
increment_word_count += segment_document.word_count
|
||||
db.session.add(segment_document)
|
||||
segment_data_list.append(segment_document)
|
||||
position += 1
|
||||
|
||||
pre_segment_data_list.append(segment_document)
|
||||
if "keywords" in segment_item:
|
||||
keywords_list.append(segment_item["keywords"])
|
||||
else:
|
||||
tokens = embedding_model.get_text_embedding_num_tokens(texts=[content])[0]
|
||||
|
||||
segment_document = DocumentSegment(
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
dataset_id=document.dataset_id,
|
||||
document_id=document.id,
|
||||
index_node_id=doc_id,
|
||||
index_node_hash=segment_hash,
|
||||
position=position,
|
||||
content=content,
|
||||
word_count=len(content),
|
||||
tokens=tokens,
|
||||
keywords=segment_item.get("keywords", []),
|
||||
status="completed",
|
||||
indexing_at=naive_utc_now(),
|
||||
completed_at=naive_utc_now(),
|
||||
created_by=current_user.id,
|
||||
)
|
||||
if document.doc_form == "qa_model":
|
||||
segment_document.answer = segment_item["answer"]
|
||||
segment_document.word_count += len(segment_item["answer"])
|
||||
increment_word_count += segment_document.word_count
|
||||
db.session.add(segment_document)
|
||||
segment_data_list.append(segment_document)
|
||||
position += 1
|
||||
|
||||
pre_segment_data_list.append(segment_document)
|
||||
if "keywords" in segment_item:
|
||||
keywords_list.append(segment_item["keywords"])
|
||||
else:
|
||||
keywords_list.append(None)
|
||||
# update document word count
|
||||
assert document.word_count is not None
|
||||
document.word_count += increment_word_count
|
||||
db.session.add(document)
|
||||
try:
|
||||
# save vector index
|
||||
VectorService.create_segments_vector(keywords_list, pre_segment_data_list, dataset, document.doc_form)
|
||||
except Exception as e:
|
||||
logger.exception("create segment index failed")
|
||||
for segment_document in segment_data_list:
|
||||
segment_document.enabled = False
|
||||
segment_document.disabled_at = naive_utc_now()
|
||||
segment_document.status = "error"
|
||||
segment_document.error = str(e)
|
||||
db.session.commit()
|
||||
return segment_data_list
|
||||
keywords_list.append(None)
|
||||
# update document word count
|
||||
assert document.word_count is not None
|
||||
document.word_count += increment_word_count
|
||||
db.session.add(document)
|
||||
try:
|
||||
# save vector index
|
||||
VectorService.create_segments_vector(
|
||||
keywords_list, pre_segment_data_list, dataset, document.doc_form
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("create segment index failed")
|
||||
for segment_document in segment_data_list:
|
||||
segment_document.enabled = False
|
||||
segment_document.disabled_at = naive_utc_now()
|
||||
segment_document.status = "error"
|
||||
segment_document.error = str(e)
|
||||
db.session.commit()
|
||||
return segment_data_list
|
||||
except LockNotOwnedError:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def update_segment(cls, args: SegmentUpdateArgs, segment: DocumentSegment, document: Document, dataset: Dataset):
|
||||
|
||||
@ -69,6 +69,7 @@ class ProviderResponse(BaseModel):
|
||||
label: I18nObject
|
||||
description: I18nObject | None = None
|
||||
icon_small: I18nObject | None = None
|
||||
icon_small_dark: I18nObject | None = None
|
||||
icon_large: I18nObject | None = None
|
||||
background: str | None = None
|
||||
help: ProviderHelpEntity | None = None
|
||||
@ -92,6 +93,11 @@ class ProviderResponse(BaseModel):
|
||||
self.icon_small = I18nObject(
|
||||
en_US=f"{url_prefix}/icon_small/en_US", zh_Hans=f"{url_prefix}/icon_small/zh_Hans"
|
||||
)
|
||||
if self.icon_small_dark is not None:
|
||||
self.icon_small_dark = I18nObject(
|
||||
en_US=f"{url_prefix}/icon_small_dark/en_US",
|
||||
zh_Hans=f"{url_prefix}/icon_small_dark/zh_Hans",
|
||||
)
|
||||
|
||||
if self.icon_large is not None:
|
||||
self.icon_large = I18nObject(
|
||||
@ -109,6 +115,7 @@ class ProviderWithModelsResponse(BaseModel):
|
||||
provider: str
|
||||
label: I18nObject
|
||||
icon_small: I18nObject | None = None
|
||||
icon_small_dark: I18nObject | None = None
|
||||
icon_large: I18nObject | None = None
|
||||
status: CustomConfigurationStatus
|
||||
models: list[ProviderModelWithStatusEntity]
|
||||
@ -123,6 +130,11 @@ class ProviderWithModelsResponse(BaseModel):
|
||||
en_US=f"{url_prefix}/icon_small/en_US", zh_Hans=f"{url_prefix}/icon_small/zh_Hans"
|
||||
)
|
||||
|
||||
if self.icon_small_dark is not None:
|
||||
self.icon_small_dark = I18nObject(
|
||||
en_US=f"{url_prefix}/icon_small_dark/en_US", zh_Hans=f"{url_prefix}/icon_small_dark/zh_Hans"
|
||||
)
|
||||
|
||||
if self.icon_large is not None:
|
||||
self.icon_large = I18nObject(
|
||||
en_US=f"{url_prefix}/icon_large/en_US", zh_Hans=f"{url_prefix}/icon_large/zh_Hans"
|
||||
@ -147,6 +159,11 @@ class SimpleProviderEntityResponse(SimpleProviderEntity):
|
||||
en_US=f"{url_prefix}/icon_small/en_US", zh_Hans=f"{url_prefix}/icon_small/zh_Hans"
|
||||
)
|
||||
|
||||
if self.icon_small_dark is not None:
|
||||
self.icon_small_dark = I18nObject(
|
||||
en_US=f"{url_prefix}/icon_small_dark/en_US", zh_Hans=f"{url_prefix}/icon_small_dark/zh_Hans"
|
||||
)
|
||||
|
||||
if self.icon_large is not None:
|
||||
self.icon_large = I18nObject(
|
||||
en_US=f"{url_prefix}/icon_large/en_US", zh_Hans=f"{url_prefix}/icon_large/zh_Hans"
|
||||
|
||||
@ -79,6 +79,7 @@ class ModelProviderService:
|
||||
label=provider_configuration.provider.label,
|
||||
description=provider_configuration.provider.description,
|
||||
icon_small=provider_configuration.provider.icon_small,
|
||||
icon_small_dark=provider_configuration.provider.icon_small_dark,
|
||||
icon_large=provider_configuration.provider.icon_large,
|
||||
background=provider_configuration.provider.background,
|
||||
help=provider_configuration.provider.help,
|
||||
@ -402,6 +403,7 @@ class ModelProviderService:
|
||||
provider=provider,
|
||||
label=first_model.provider.label,
|
||||
icon_small=first_model.provider.icon_small,
|
||||
icon_small_dark=first_model.provider.icon_small_dark,
|
||||
icon_large=first_model.provider.icon_large,
|
||||
status=CustomConfigurationStatus.ACTIVE,
|
||||
models=[
|
||||
|
||||
@ -1248,14 +1248,12 @@ class RagPipelineService:
|
||||
session.commit()
|
||||
return workflow_node_execution_db_model
|
||||
|
||||
def get_recommended_plugins(self, type: str) -> dict:
|
||||
def get_recommended_plugins(self) -> dict:
|
||||
# Query active recommended plugins
|
||||
query = db.session.query(PipelineRecommendedPlugin).where(PipelineRecommendedPlugin.active == True)
|
||||
if type and type != "all":
|
||||
query = query.where(PipelineRecommendedPlugin.type == type)
|
||||
|
||||
pipeline_recommended_plugins = (
|
||||
query.order_by(PipelineRecommendedPlugin.position.asc())
|
||||
db.session.query(PipelineRecommendedPlugin)
|
||||
.where(PipelineRecommendedPlugin.active == True)
|
||||
.order_by(PipelineRecommendedPlugin.position.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
@ -233,7 +233,7 @@ workflow:
|
||||
- value_selector:
|
||||
- iteration_node
|
||||
- output
|
||||
value_type: array[array[number]]
|
||||
value_type: array[number]
|
||||
variable: output
|
||||
selected: false
|
||||
title: End
|
||||
|
||||
@ -227,6 +227,7 @@ class TestModelProviderService:
|
||||
mock_provider_entity.label = {"en_US": "OpenAI", "zh_Hans": "OpenAI"}
|
||||
mock_provider_entity.description = {"en_US": "OpenAI provider", "zh_Hans": "OpenAI 提供商"}
|
||||
mock_provider_entity.icon_small = {"en_US": "icon_small.png", "zh_Hans": "icon_small.png"}
|
||||
mock_provider_entity.icon_small_dark = None
|
||||
mock_provider_entity.icon_large = {"en_US": "icon_large.png", "zh_Hans": "icon_large.png"}
|
||||
mock_provider_entity.background = "#FF6B6B"
|
||||
mock_provider_entity.help = None
|
||||
@ -300,6 +301,7 @@ class TestModelProviderService:
|
||||
mock_provider_entity_llm.label = {"en_US": "OpenAI", "zh_Hans": "OpenAI"}
|
||||
mock_provider_entity_llm.description = {"en_US": "OpenAI provider", "zh_Hans": "OpenAI 提供商"}
|
||||
mock_provider_entity_llm.icon_small = {"en_US": "icon_small.png", "zh_Hans": "icon_small.png"}
|
||||
mock_provider_entity_llm.icon_small_dark = None
|
||||
mock_provider_entity_llm.icon_large = {"en_US": "icon_large.png", "zh_Hans": "icon_large.png"}
|
||||
mock_provider_entity_llm.background = "#FF6B6B"
|
||||
mock_provider_entity_llm.help = None
|
||||
@ -313,6 +315,7 @@ class TestModelProviderService:
|
||||
mock_provider_entity_embedding.label = {"en_US": "Cohere", "zh_Hans": "Cohere"}
|
||||
mock_provider_entity_embedding.description = {"en_US": "Cohere provider", "zh_Hans": "Cohere 提供商"}
|
||||
mock_provider_entity_embedding.icon_small = {"en_US": "icon_small.png", "zh_Hans": "icon_small.png"}
|
||||
mock_provider_entity_embedding.icon_small_dark = None
|
||||
mock_provider_entity_embedding.icon_large = {"en_US": "icon_large.png", "zh_Hans": "icon_large.png"}
|
||||
mock_provider_entity_embedding.background = "#4ECDC4"
|
||||
mock_provider_entity_embedding.help = None
|
||||
@ -1023,6 +1026,7 @@ class TestModelProviderService:
|
||||
provider="openai",
|
||||
label={"en_US": "OpenAI", "zh_Hans": "OpenAI"},
|
||||
icon_small={"en_US": "icon_small.png", "zh_Hans": "icon_small.png"},
|
||||
icon_small_dark=None,
|
||||
icon_large={"en_US": "icon_large.png", "zh_Hans": "icon_large.png"},
|
||||
),
|
||||
model="gpt-3.5-turbo",
|
||||
@ -1040,6 +1044,7 @@ class TestModelProviderService:
|
||||
provider="openai",
|
||||
label={"en_US": "OpenAI", "zh_Hans": "OpenAI"},
|
||||
icon_small={"en_US": "icon_small.png", "zh_Hans": "icon_small.png"},
|
||||
icon_small_dark=None,
|
||||
icon_large={"en_US": "icon_large.png", "zh_Hans": "icon_large.png"},
|
||||
),
|
||||
model="gpt-4",
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
@ -214,3 +216,76 @@ def test_create_variable_message():
|
||||
assert message.message.variable_name == var_name
|
||||
assert message.message.variable_value == var_value
|
||||
assert message.message.stream is False
|
||||
|
||||
|
||||
def test_resolve_user_from_database_falls_back_to_end_user(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Ensure worker context can resolve EndUser when Account is missing."""
|
||||
|
||||
class StubSession:
|
||||
def __init__(self, results: list):
|
||||
self.results = results
|
||||
|
||||
def scalar(self, _stmt):
|
||||
return self.results.pop(0)
|
||||
|
||||
tenant = SimpleNamespace(id="tenant_id")
|
||||
end_user = SimpleNamespace(id="end_user_id", tenant_id="tenant_id")
|
||||
db_stub = SimpleNamespace(session=StubSession([tenant, None, end_user]))
|
||||
|
||||
monkeypatch.setattr("core.tools.workflow_as_tool.tool.db", db_stub)
|
||||
|
||||
entity = ToolEntity(
|
||||
identity=ToolIdentity(author="test", name="test tool", label=I18nObject(en_US="test tool"), provider="test"),
|
||||
parameters=[],
|
||||
description=None,
|
||||
has_runtime_parameters=False,
|
||||
)
|
||||
runtime = ToolRuntime(tenant_id="tenant_id", invoke_from=InvokeFrom.SERVICE_API)
|
||||
tool = WorkflowTool(
|
||||
workflow_app_id="",
|
||||
workflow_as_tool_id="",
|
||||
version="1",
|
||||
workflow_entities={},
|
||||
workflow_call_depth=1,
|
||||
entity=entity,
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
resolved_user = tool._resolve_user_from_database(user_id=end_user.id)
|
||||
|
||||
assert resolved_user is end_user
|
||||
|
||||
|
||||
def test_resolve_user_from_database_returns_none_when_no_tenant(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Return None if tenant cannot be found in worker context."""
|
||||
|
||||
class StubSession:
|
||||
def __init__(self, results: list):
|
||||
self.results = results
|
||||
|
||||
def scalar(self, _stmt):
|
||||
return self.results.pop(0)
|
||||
|
||||
db_stub = SimpleNamespace(session=StubSession([None]))
|
||||
monkeypatch.setattr("core.tools.workflow_as_tool.tool.db", db_stub)
|
||||
|
||||
entity = ToolEntity(
|
||||
identity=ToolIdentity(author="test", name="test tool", label=I18nObject(en_US="test tool"), provider="test"),
|
||||
parameters=[],
|
||||
description=None,
|
||||
has_runtime_parameters=False,
|
||||
)
|
||||
runtime = ToolRuntime(tenant_id="missing_tenant", invoke_from=InvokeFrom.SERVICE_API)
|
||||
tool = WorkflowTool(
|
||||
workflow_app_id="",
|
||||
workflow_as_tool_id="",
|
||||
version="1",
|
||||
workflow_entities={},
|
||||
workflow_call_depth=1,
|
||||
entity=entity,
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
resolved_user = tool._resolve_user_from_database(user_id="any")
|
||||
|
||||
assert resolved_user is None
|
||||
|
||||
@ -7,9 +7,31 @@ This module tests the iteration node's ability to:
|
||||
"""
|
||||
|
||||
from .test_database_utils import skip_if_database_unavailable
|
||||
from .test_mock_config import MockConfigBuilder, NodeMockConfig
|
||||
from .test_table_runner import TableTestRunner, WorkflowTestCase
|
||||
|
||||
|
||||
def _create_iteration_mock_config():
|
||||
"""Helper to create a mock config for iteration tests."""
|
||||
|
||||
def code_inner_handler(node):
|
||||
pool = node.graph_runtime_state.variable_pool
|
||||
item_seg = pool.get(["iteration_node", "item"])
|
||||
if item_seg is not None:
|
||||
item = item_seg.to_object()
|
||||
return {"result": [item, item * 2]}
|
||||
# This fallback is likely unreachable, but if it is,
|
||||
# it doesn't simulate iteration with different values as the comment suggests.
|
||||
return {"result": [1, 2]}
|
||||
|
||||
return (
|
||||
MockConfigBuilder()
|
||||
.with_node_output("code_node", {"result": [1, 2, 3]})
|
||||
.with_node_config(NodeMockConfig(node_id="code_inner_node", custom_handler=code_inner_handler))
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
@skip_if_database_unavailable()
|
||||
def test_iteration_with_flatten_output_enabled():
|
||||
"""
|
||||
@ -27,7 +49,8 @@ def test_iteration_with_flatten_output_enabled():
|
||||
inputs={},
|
||||
expected_outputs={"output": [1, 2, 2, 4, 3, 6]},
|
||||
description="Iteration with flatten_output=True flattens nested arrays",
|
||||
use_auto_mock=False, # Run code nodes directly
|
||||
use_auto_mock=True, # Use auto-mock to avoid sandbox service
|
||||
mock_config=_create_iteration_mock_config(),
|
||||
)
|
||||
|
||||
result = runner.run_test_case(test_case)
|
||||
@ -56,7 +79,8 @@ def test_iteration_with_flatten_output_disabled():
|
||||
inputs={},
|
||||
expected_outputs={"output": [[1, 2], [2, 4], [3, 6]]},
|
||||
description="Iteration with flatten_output=False preserves nested structure",
|
||||
use_auto_mock=False, # Run code nodes directly
|
||||
use_auto_mock=True, # Use auto-mock to avoid sandbox service
|
||||
mock_config=_create_iteration_mock_config(),
|
||||
)
|
||||
|
||||
result = runner.run_test_case(test_case)
|
||||
@ -81,14 +105,16 @@ def test_iteration_flatten_output_comparison():
|
||||
inputs={},
|
||||
expected_outputs={"output": [1, 2, 2, 4, 3, 6]},
|
||||
description="flatten_output=True: Flattened output",
|
||||
use_auto_mock=False, # Run code nodes directly
|
||||
use_auto_mock=True, # Use auto-mock to avoid sandbox service
|
||||
mock_config=_create_iteration_mock_config(),
|
||||
),
|
||||
WorkflowTestCase(
|
||||
fixture_path="iteration_flatten_output_disabled_workflow",
|
||||
inputs={},
|
||||
expected_outputs={"output": [[1, 2], [2, 4], [3, 6]]},
|
||||
description="flatten_output=False: Nested output",
|
||||
use_auto_mock=False, # Run code nodes directly
|
||||
use_auto_mock=True, # Use auto-mock to avoid sandbox service
|
||||
mock_config=_create_iteration_mock_config(),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@ -263,3 +263,62 @@ class TestResponseUnmodified:
|
||||
)
|
||||
assert response.text == _RESPONSE_NEEDLE
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestRequestFinishedInfoAccessLine:
|
||||
def test_info_access_log_includes_method_path_status_duration_trace_id(self, monkeypatch, caplog):
|
||||
"""Ensure INFO access line contains expected fields with computed duration and trace id."""
|
||||
app = _get_test_app()
|
||||
# Push a real request context so flask.request and g are available
|
||||
with app.test_request_context("/foo", method="GET"):
|
||||
# Seed start timestamp via the extension's own start hook and control perf_counter deterministically
|
||||
seq = iter([100.0, 100.123456])
|
||||
monkeypatch.setattr(ext_request_logging.time, "perf_counter", lambda: next(seq))
|
||||
# Provide a deterministic trace id
|
||||
monkeypatch.setattr(
|
||||
ext_request_logging,
|
||||
"get_trace_id_from_otel_context",
|
||||
lambda: "trace-xyz",
|
||||
)
|
||||
# Simulate request_started to record start timestamp on g
|
||||
ext_request_logging._log_request_started(app)
|
||||
|
||||
# Capture logs from the real logger at INFO level only (skip DEBUG branch)
|
||||
caplog.set_level(logging.INFO, logger=ext_request_logging.__name__)
|
||||
response = Response(json.dumps({"ok": True}), mimetype="application/json", status=200)
|
||||
_log_request_finished(app, response)
|
||||
|
||||
# Verify a single INFO record with the five fields in order
|
||||
info_records = [rec for rec in caplog.records if rec.levelno == logging.INFO]
|
||||
assert len(info_records) == 1
|
||||
msg = info_records[0].getMessage()
|
||||
# Expected format: METHOD PATH STATUS DURATION_MS TRACE_ID
|
||||
assert "GET" in msg
|
||||
assert "/foo" in msg
|
||||
assert "200" in msg
|
||||
assert "123.456" in msg # rounded to 3 decimals
|
||||
assert "trace-xyz" in msg
|
||||
|
||||
def test_info_access_log_uses_dash_without_start_timestamp(self, monkeypatch, caplog):
|
||||
app = _get_test_app()
|
||||
with app.test_request_context("/bar", method="POST"):
|
||||
# No g.__request_started_ts set -> duration should be '-'
|
||||
monkeypatch.setattr(
|
||||
ext_request_logging,
|
||||
"get_trace_id_from_otel_context",
|
||||
lambda: "tid-no-start",
|
||||
)
|
||||
caplog.set_level(logging.INFO, logger=ext_request_logging.__name__)
|
||||
response = Response("OK", mimetype="text/plain", status=204)
|
||||
_log_request_finished(app, response)
|
||||
|
||||
info_records = [rec for rec in caplog.records if rec.levelno == logging.INFO]
|
||||
assert len(info_records) == 1
|
||||
msg = info_records[0].getMessage()
|
||||
assert "POST" in msg
|
||||
assert "/bar" in msg
|
||||
assert "204" in msg
|
||||
# Duration placeholder
|
||||
# The fields are space separated; ensure a standalone '-' appears
|
||||
assert " - " in msg or msg.endswith(" -")
|
||||
assert "tid-no-start" in msg
|
||||
|
||||
@ -0,0 +1,177 @@
|
||||
import types
|
||||
from unittest.mock import Mock, create_autospec
|
||||
|
||||
import pytest
|
||||
from redis.exceptions import LockNotOwnedError
|
||||
|
||||
from models.account import Account
|
||||
from models.dataset import Dataset, Document
|
||||
from services.dataset_service import DocumentService, SegmentService
|
||||
|
||||
|
||||
class FakeLock:
|
||||
"""Lock that always fails on enter with LockNotOwnedError."""
|
||||
|
||||
def __enter__(self):
|
||||
raise LockNotOwnedError("simulated")
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
# Normal contextmanager signature; return False so exceptions propagate
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_current_user(monkeypatch):
|
||||
user = create_autospec(Account, instance=True)
|
||||
user.id = "user-1"
|
||||
user.current_tenant_id = "tenant-1"
|
||||
monkeypatch.setattr("services.dataset_service.current_user", user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_features(monkeypatch):
|
||||
"""Features.billing.enabled == False to skip quota logic."""
|
||||
features = types.SimpleNamespace(
|
||||
billing=types.SimpleNamespace(enabled=False, subscription=types.SimpleNamespace(plan="ENTERPRISE")),
|
||||
documents_upload_quota=types.SimpleNamespace(limit=10_000, size=0),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.dataset_service.FeatureService.get_features",
|
||||
lambda tenant_id: features,
|
||||
)
|
||||
return features
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_lock(monkeypatch):
|
||||
"""Patch redis_client.lock to always raise LockNotOwnedError on enter."""
|
||||
|
||||
def _fake_lock(name, timeout=None, *args, **kwargs):
|
||||
return FakeLock()
|
||||
|
||||
# DatasetService imports redis_client directly from extensions.ext_redis
|
||||
monkeypatch.setattr("services.dataset_service.redis_client.lock", _fake_lock)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Knowledge Pipeline document creation (save_document_with_dataset_id)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_save_document_with_dataset_id_ignores_lock_not_owned(
|
||||
monkeypatch,
|
||||
fake_current_user,
|
||||
fake_features,
|
||||
fake_lock,
|
||||
):
|
||||
# Arrange
|
||||
dataset = create_autospec(Dataset, instance=True)
|
||||
dataset.id = "ds-1"
|
||||
dataset.tenant_id = fake_current_user.current_tenant_id
|
||||
dataset.data_source_type = "upload_file"
|
||||
dataset.indexing_technique = "high_quality" # so we skip re-initialization branch
|
||||
|
||||
# Minimal knowledge_config stub that satisfies pre-lock code
|
||||
info_list = types.SimpleNamespace(data_source_type="upload_file")
|
||||
data_source = types.SimpleNamespace(info_list=info_list)
|
||||
knowledge_config = types.SimpleNamespace(
|
||||
doc_form="qa_model",
|
||||
original_document_id=None, # go into "new document" branch
|
||||
data_source=data_source,
|
||||
indexing_technique="high_quality",
|
||||
embedding_model=None,
|
||||
embedding_model_provider=None,
|
||||
retrieval_model=None,
|
||||
process_rule=None,
|
||||
duplicate=False,
|
||||
doc_language="en",
|
||||
)
|
||||
|
||||
account = fake_current_user
|
||||
|
||||
# Avoid touching real doc_form logic
|
||||
monkeypatch.setattr("services.dataset_service.DatasetService.check_doc_form", lambda *a, **k: None)
|
||||
# Avoid real DB interactions
|
||||
monkeypatch.setattr("services.dataset_service.db", Mock())
|
||||
|
||||
# Act: this would hit the redis lock, whose __enter__ raises LockNotOwnedError.
|
||||
# Our implementation should catch it and still return (documents, batch).
|
||||
documents, batch = DocumentService.save_document_with_dataset_id(
|
||||
dataset=dataset,
|
||||
knowledge_config=knowledge_config,
|
||||
account=account,
|
||||
)
|
||||
|
||||
# Assert
|
||||
# We mainly care that:
|
||||
# - No exception is raised
|
||||
# - The function returns a sensible tuple
|
||||
assert isinstance(documents, list)
|
||||
assert isinstance(batch, str)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Single-segment creation (add_segment)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_add_segment_ignores_lock_not_owned(
|
||||
monkeypatch,
|
||||
fake_current_user,
|
||||
fake_lock,
|
||||
):
|
||||
# Arrange
|
||||
dataset = create_autospec(Dataset, instance=True)
|
||||
dataset.id = "ds-1"
|
||||
dataset.tenant_id = fake_current_user.current_tenant_id
|
||||
dataset.indexing_technique = "economy" # skip embedding/token calculation branch
|
||||
|
||||
document = create_autospec(Document, instance=True)
|
||||
document.id = "doc-1"
|
||||
document.dataset_id = dataset.id
|
||||
document.word_count = 0
|
||||
document.doc_form = "qa_model"
|
||||
|
||||
# Minimal args required by add_segment
|
||||
args = {
|
||||
"content": "question text",
|
||||
"answer": "answer text",
|
||||
"keywords": ["k1", "k2"],
|
||||
}
|
||||
|
||||
# Avoid real DB operations
|
||||
db_mock = Mock()
|
||||
db_mock.session = Mock()
|
||||
monkeypatch.setattr("services.dataset_service.db", db_mock)
|
||||
monkeypatch.setattr("services.dataset_service.VectorService", Mock())
|
||||
|
||||
# Act
|
||||
result = SegmentService.create_segment(args=args, document=document, dataset=dataset)
|
||||
|
||||
# Assert
|
||||
# Under LockNotOwnedError except, add_segment should swallow the error and return None.
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Multi-segment creation (multi_create_segment)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_multi_create_segment_ignores_lock_not_owned(
|
||||
monkeypatch,
|
||||
fake_current_user,
|
||||
fake_lock,
|
||||
):
|
||||
# Arrange
|
||||
dataset = create_autospec(Dataset, instance=True)
|
||||
dataset.id = "ds-1"
|
||||
dataset.tenant_id = fake_current_user.current_tenant_id
|
||||
dataset.indexing_technique = "economy" # again, skip high_quality path
|
||||
|
||||
document = create_autospec(Document, instance=True)
|
||||
document.id = "doc-1"
|
||||
document.dataset_id = dataset.id
|
||||
document.word_count = 0
|
||||
document.doc_form = "qa_model"
|
||||
4623
api/uv.lock
generated
4623
api/uv.lock
generated
File diff suppressed because it is too large
Load Diff
@ -233,7 +233,7 @@ NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false
|
||||
|
||||
# Database type, supported values are `postgresql` and `mysql`
|
||||
DB_TYPE=postgresql
|
||||
|
||||
# For MySQL, only `root` user is supported for now
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=difyai123456
|
||||
DB_HOST=db_postgres
|
||||
@ -1076,24 +1076,10 @@ MAX_TREE_DEPTH=50
|
||||
# ------------------------------
|
||||
# Environment Variables for database Service
|
||||
# ------------------------------
|
||||
|
||||
# The name of the default postgres user.
|
||||
POSTGRES_USER=${DB_USERNAME}
|
||||
# The password for the default postgres user.
|
||||
POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
# The name of the default postgres database.
|
||||
POSTGRES_DB=${DB_DATABASE}
|
||||
# Postgres data directory
|
||||
PGDATA=/var/lib/postgresql/data/pgdata
|
||||
|
||||
# MySQL Default Configuration
|
||||
# The name of the default mysql user.
|
||||
MYSQL_USERNAME=${DB_USERNAME}
|
||||
# The password for the default mysql user.
|
||||
MYSQL_PASSWORD=${DB_PASSWORD}
|
||||
# The name of the default mysql database.
|
||||
MYSQL_DATABASE=${DB_DATABASE}
|
||||
# MySQL data directory
|
||||
MYSQL_HOST_VOLUME=./volumes/mysql/data
|
||||
|
||||
# ------------------------------
|
||||
|
||||
@ -139,9 +139,9 @@ services:
|
||||
- postgresql
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-difyai123456}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-dify}
|
||||
POSTGRES_USER: ${DB_USERNAME:-postgres}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-difyai123456}
|
||||
POSTGRES_DB: ${DB_DATABASE:-dify}
|
||||
PGDATA: ${PGDATA:-/var/lib/postgresql/data/pgdata}
|
||||
command: >
|
||||
postgres -c 'max_connections=${POSTGRES_MAX_CONNECTIONS:-100}'
|
||||
@ -161,7 +161,7 @@ services:
|
||||
"-h",
|
||||
"db_postgres",
|
||||
"-U",
|
||||
"${PGUSER:-postgres}",
|
||||
"${DB_USERNAME:-postgres}",
|
||||
"-d",
|
||||
"${DB_DATABASE:-dify}",
|
||||
]
|
||||
@ -176,8 +176,8 @@ services:
|
||||
- mysql
|
||||
restart: always
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_PASSWORD:-difyai123456}
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE:-dify}
|
||||
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-difyai123456}
|
||||
MYSQL_DATABASE: ${DB_DATABASE:-dify}
|
||||
command: >
|
||||
--max_connections=1000
|
||||
--innodb_buffer_pool_size=${MYSQL_INNODB_BUFFER_POOL_SIZE:-512M}
|
||||
@ -193,7 +193,7 @@ services:
|
||||
"ping",
|
||||
"-u",
|
||||
"root",
|
||||
"-p${MYSQL_PASSWORD:-difyai123456}",
|
||||
"-p${DB_PASSWORD:-difyai123456}",
|
||||
]
|
||||
interval: 1s
|
||||
timeout: 3s
|
||||
|
||||
@ -9,8 +9,8 @@ services:
|
||||
env_file:
|
||||
- ./middleware.env
|
||||
environment:
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-difyai123456}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-dify}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-difyai123456}
|
||||
POSTGRES_DB: ${DB_DATABASE:-dify}
|
||||
PGDATA: ${PGDATA:-/var/lib/postgresql/data/pgdata}
|
||||
command: >
|
||||
postgres -c 'max_connections=${POSTGRES_MAX_CONNECTIONS:-100}'
|
||||
@ -32,9 +32,9 @@ services:
|
||||
"-h",
|
||||
"db_postgres",
|
||||
"-U",
|
||||
"${PGUSER:-postgres}",
|
||||
"${DB_USERNAME:-postgres}",
|
||||
"-d",
|
||||
"${POSTGRES_DB:-dify}",
|
||||
"${DB_DATABASE:-dify}",
|
||||
]
|
||||
interval: 1s
|
||||
timeout: 3s
|
||||
@ -48,8 +48,8 @@ services:
|
||||
env_file:
|
||||
- ./middleware.env
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_PASSWORD:-difyai123456}
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE:-dify}
|
||||
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-difyai123456}
|
||||
MYSQL_DATABASE: ${DB_DATABASE:-dify}
|
||||
command: >
|
||||
--max_connections=1000
|
||||
--innodb_buffer_pool_size=${MYSQL_INNODB_BUFFER_POOL_SIZE:-512M}
|
||||
@ -67,7 +67,7 @@ services:
|
||||
"ping",
|
||||
"-u",
|
||||
"root",
|
||||
"-p${MYSQL_PASSWORD:-difyai123456}",
|
||||
"-p${DB_PASSWORD:-difyai123456}",
|
||||
]
|
||||
interval: 1s
|
||||
timeout: 3s
|
||||
|
||||
@ -455,13 +455,7 @@ x-shared-env: &shared-api-worker-env
|
||||
TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000}
|
||||
ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false}
|
||||
MAX_TREE_DEPTH: ${MAX_TREE_DEPTH:-50}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-${DB_USERNAME}}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-${DB_PASSWORD}}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-${DB_DATABASE}}
|
||||
PGDATA: ${PGDATA:-/var/lib/postgresql/data/pgdata}
|
||||
MYSQL_USERNAME: ${MYSQL_USERNAME:-${DB_USERNAME}}
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-${DB_PASSWORD}}
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE:-${DB_DATABASE}}
|
||||
MYSQL_HOST_VOLUME: ${MYSQL_HOST_VOLUME:-./volumes/mysql/data}
|
||||
SANDBOX_API_KEY: ${SANDBOX_API_KEY:-dify-sandbox}
|
||||
SANDBOX_GIN_MODE: ${SANDBOX_GIN_MODE:-release}
|
||||
@ -774,9 +768,9 @@ services:
|
||||
- postgresql
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-difyai123456}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-dify}
|
||||
POSTGRES_USER: ${DB_USERNAME:-postgres}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-difyai123456}
|
||||
POSTGRES_DB: ${DB_DATABASE:-dify}
|
||||
PGDATA: ${PGDATA:-/var/lib/postgresql/data/pgdata}
|
||||
command: >
|
||||
postgres -c 'max_connections=${POSTGRES_MAX_CONNECTIONS:-100}'
|
||||
@ -796,7 +790,7 @@ services:
|
||||
"-h",
|
||||
"db_postgres",
|
||||
"-U",
|
||||
"${PGUSER:-postgres}",
|
||||
"${DB_USERNAME:-postgres}",
|
||||
"-d",
|
||||
"${DB_DATABASE:-dify}",
|
||||
]
|
||||
@ -811,8 +805,8 @@ services:
|
||||
- mysql
|
||||
restart: always
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_PASSWORD:-difyai123456}
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE:-dify}
|
||||
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-difyai123456}
|
||||
MYSQL_DATABASE: ${DB_DATABASE:-dify}
|
||||
command: >
|
||||
--max_connections=1000
|
||||
--innodb_buffer_pool_size=${MYSQL_INNODB_BUFFER_POOL_SIZE:-512M}
|
||||
@ -828,7 +822,7 @@ services:
|
||||
"ping",
|
||||
"-u",
|
||||
"root",
|
||||
"-p${MYSQL_PASSWORD:-difyai123456}",
|
||||
"-p${DB_PASSWORD:-difyai123456}",
|
||||
]
|
||||
interval: 1s
|
||||
timeout: 3s
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
# Database Configuration
|
||||
# Database type, supported values are `postgresql` and `mysql`
|
||||
DB_TYPE=postgresql
|
||||
# For MySQL, only `root` user is supported for now
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=difyai123456
|
||||
DB_HOST=db_postgres
|
||||
@ -11,11 +12,6 @@ DB_PORT=5432
|
||||
DB_DATABASE=dify
|
||||
|
||||
# PostgreSQL Configuration
|
||||
POSTGRES_USER=${DB_USERNAME}
|
||||
# The password for the default postgres user.
|
||||
POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
# The name of the default postgres database.
|
||||
POSTGRES_DB=${DB_DATABASE}
|
||||
# postgres data directory
|
||||
PGDATA=/var/lib/postgresql/data/pgdata
|
||||
PGDATA_HOST_VOLUME=./volumes/db/data
|
||||
@ -65,11 +61,6 @@ POSTGRES_STATEMENT_TIMEOUT=0
|
||||
POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT=0
|
||||
|
||||
# MySQL Configuration
|
||||
MYSQL_USERNAME=${DB_USERNAME}
|
||||
# MySQL password
|
||||
MYSQL_PASSWORD=${DB_PASSWORD}
|
||||
# MySQL database name
|
||||
MYSQL_DATABASE=${DB_DATABASE}
|
||||
# MySQL data directory host volume
|
||||
MYSQL_HOST_VOLUME=./volumes/mysql/data
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
|
||||
import SwrInitializer from '@/app/components/swr-initializer'
|
||||
import { AppContextProvider } from '@/context/app-context'
|
||||
import GA, { GaType } from '@/app/components/base/ga'
|
||||
import AmplitudeProvider from '@/app/components/base/amplitude'
|
||||
import HeaderWrapper from '@/app/components/header/header-wrapper'
|
||||
import Header from '@/app/components/header'
|
||||
import { EventEmitterContextProvider } from '@/context/event-emitter'
|
||||
@ -18,6 +19,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<>
|
||||
<GA gaType={GaType.admin} />
|
||||
<AmplitudeProvider />
|
||||
<SwrInitializer>
|
||||
<AppContextProvider>
|
||||
<EventEmitterContextProvider>
|
||||
|
||||
@ -8,7 +8,7 @@ const PluginList = async () => {
|
||||
return (
|
||||
<PluginPage
|
||||
plugins={<PluginsPanel />}
|
||||
marketplace={<Marketplace locale={locale} pluginTypeSwitchClassName='top-[60px]' searchBoxAutoAnimate={false} showSearchParams={false} />}
|
||||
marketplace={<Marketplace locale={locale} pluginTypeSwitchClassName='top-[60px]' showSearchParams={false} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ import { useProviderContext } from '@/context/provider-context'
|
||||
import { LogOut01 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import PremiumBadge from '@/app/components/base/premium-badge'
|
||||
import { useLogout } from '@/service/use-common'
|
||||
import { resetUser } from '@/app/components/base/amplitude/utils'
|
||||
|
||||
export type IAppSelector = {
|
||||
isMobile: boolean
|
||||
@ -28,6 +29,7 @@ export default function AppSelector() {
|
||||
await logout()
|
||||
|
||||
localStorage.removeItem('setup_status')
|
||||
resetUser()
|
||||
// Tokens are now stored in cookies and cleared by backend
|
||||
|
||||
router.push('/signin')
|
||||
|
||||
@ -4,6 +4,7 @@ import Header from './header'
|
||||
import SwrInitor from '@/app/components/swr-initializer'
|
||||
import { AppContextProvider } from '@/context/app-context'
|
||||
import GA, { GaType } from '@/app/components/base/ga'
|
||||
import AmplitudeProvider from '@/app/components/base/amplitude'
|
||||
import HeaderWrapper from '@/app/components/header/header-wrapper'
|
||||
import { EventEmitterContextProvider } from '@/context/event-emitter'
|
||||
import { ProviderContextProvider } from '@/context/provider-context'
|
||||
@ -13,6 +14,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<>
|
||||
<GA gaType={GaType.admin} />
|
||||
<AmplitudeProvider />
|
||||
<SwrInitor>
|
||||
<AppContextProvider>
|
||||
<EventEmitterContextProvider>
|
||||
|
||||
@ -0,0 +1,142 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import Authorize from '@/app/components/plugins/plugin-auth/authorize'
|
||||
import Authorized from '@/app/components/plugins/plugin-auth/authorized'
|
||||
import { AuthCategory } from '@/app/components/plugins/plugin-auth'
|
||||
import { usePluginAuth } from '@/app/components/plugins/plugin-auth/hooks/use-plugin-auth'
|
||||
import type { Credential } from '@/app/components/plugins/plugin-auth/types'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { CollectionType } from '@/app/components/tools/types'
|
||||
|
||||
type GroupAuthControlProps = {
|
||||
providerId: string
|
||||
providerName: string
|
||||
providerType: CollectionType
|
||||
credentialId?: string
|
||||
onChange: (credentialId: string) => void
|
||||
}
|
||||
|
||||
const GroupAuthControl: FC<GroupAuthControlProps> = ({
|
||||
providerId,
|
||||
providerName,
|
||||
providerType,
|
||||
credentialId,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
isAuthorized,
|
||||
canOAuth,
|
||||
canApiKey,
|
||||
credentials,
|
||||
disabled,
|
||||
invalidPluginCredentialInfo,
|
||||
notAllowCustomCredential,
|
||||
} = usePluginAuth({
|
||||
provider: providerName,
|
||||
providerType,
|
||||
category: AuthCategory.tool,
|
||||
detail: { id: providerId, name: providerName, type: providerType } as any,
|
||||
}, true)
|
||||
|
||||
const extraAuthorizationItems: Credential[] = [
|
||||
{
|
||||
id: '__workspace_default__',
|
||||
name: t('plugin.auth.workspaceDefault'),
|
||||
provider: '',
|
||||
is_default: !credentialId,
|
||||
isWorkspaceDefault: true,
|
||||
},
|
||||
]
|
||||
|
||||
const handleAuthorizationItemClick = useCallback((id: string) => {
|
||||
onChange(id === '__workspace_default__' ? '' : id)
|
||||
}, [onChange])
|
||||
|
||||
const renderTrigger = useCallback((open?: boolean) => {
|
||||
let label = ''
|
||||
let removed = false
|
||||
let unavailable = false
|
||||
let color = 'green'
|
||||
if (!credentialId) {
|
||||
label = t('plugin.auth.workspaceDefault')
|
||||
}
|
||||
else {
|
||||
const credential = credentials.find(c => c.id === credentialId)
|
||||
label = credential ? credential.name : t('plugin.auth.authRemoved')
|
||||
removed = !credential
|
||||
unavailable = !!credential?.not_allowed_to_use && !credential?.from_enterprise
|
||||
if (removed)
|
||||
color = 'red'
|
||||
else if (unavailable)
|
||||
color = 'gray'
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
'h-9',
|
||||
open && 'bg-components-button-secondary-bg-hover',
|
||||
removed && 'text-text-destructive',
|
||||
)}
|
||||
variant='secondary'
|
||||
size='small'
|
||||
>
|
||||
<Indicator className='mr-2' color={color as any} />
|
||||
{label}
|
||||
{
|
||||
unavailable && t('plugin.auth.unavailable')
|
||||
}
|
||||
<RiArrowDownSLine className='ml-0.5 h-4 w-4' />
|
||||
</Button>
|
||||
)
|
||||
}, [credentialId, credentials, t])
|
||||
|
||||
if (!isAuthorized) {
|
||||
return (
|
||||
<Authorize
|
||||
pluginPayload={{
|
||||
provider: providerName,
|
||||
providerType,
|
||||
category: AuthCategory.tool,
|
||||
detail: { id: providerId, name: providerName, type: providerType } as any,
|
||||
}}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
disabled={disabled}
|
||||
onUpdate={invalidPluginCredentialInfo}
|
||||
notAllowCustomCredential={notAllowCustomCredential}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Authorized
|
||||
pluginPayload={{
|
||||
provider: providerName,
|
||||
providerType,
|
||||
category: AuthCategory.tool,
|
||||
detail: { id: providerId, name: providerName, type: providerType } as any,
|
||||
}}
|
||||
credentials={credentials}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
disabled={disabled}
|
||||
disableSetDefault
|
||||
onItemClick={handleAuthorizationItemClick}
|
||||
extraAuthorizationItems={extraAuthorizationItems}
|
||||
showItemSelectedIcon
|
||||
renderTrigger={renderTrigger}
|
||||
selectedCredentialId={credentialId || '__workspace_default__'}
|
||||
onUpdate={invalidPluginCredentialInfo}
|
||||
notAllowCustomCredential={notAllowCustomCredential}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(GroupAuthControl)
|
||||
@ -6,6 +6,7 @@ import { useContext } from 'use-context-selector'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { produce } from 'immer'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiDeleteBinLine,
|
||||
RiEqualizer2Line,
|
||||
RiInformation2Line,
|
||||
@ -24,7 +25,6 @@ import { type Collection, CollectionType } from '@/app/components/tools/types'
|
||||
import { MAX_TOOLS_NUM } from '@/config'
|
||||
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { DefaultToolIcon } from '@/app/components/base/icons/src/public/other'
|
||||
import cn from '@/utils/classnames'
|
||||
import ToolPicker from '@/app/components/workflow/block-selector/tool-picker'
|
||||
import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types'
|
||||
@ -32,8 +32,9 @@ import { canFindTool } from '@/utils'
|
||||
import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools } from '@/service/use-tools'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import { useMittContextSelector } from '@/context/mitt-context'
|
||||
import { DefaultToolIcon } from '@/app/components/base/icons/src/public/other'
|
||||
|
||||
type AgentToolWithMoreInfo = AgentTool & { icon: any; collection?: Collection } | null
|
||||
type AgentToolWithMoreInfo = (AgentTool & { icon: any; collection?: Collection; use_end_user_credentials?: boolean; end_user_credential_type?: string }) | null
|
||||
const AgentTools: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [isShowChooseTool, setIsShowChooseTool] = useState(false)
|
||||
@ -92,6 +93,13 @@ const AgentTools: FC = () => {
|
||||
}
|
||||
|
||||
const [isDeleting, setIsDeleting] = useState<number>(-1)
|
||||
const [expandedProviders, setExpandedProviders] = useState<Record<string, boolean>>({})
|
||||
const toggleProviderExpand = useCallback((providerId: string) => {
|
||||
setExpandedProviders(prev => ({
|
||||
...prev,
|
||||
[providerId]: !prev[providerId],
|
||||
}))
|
||||
}, [])
|
||||
const getToolValue = (tool: ToolDefaultValue) => {
|
||||
return {
|
||||
provider_id: tool.provider_id,
|
||||
@ -102,7 +110,9 @@ const AgentTools: FC = () => {
|
||||
tool_parameters: tool.params,
|
||||
notAuthor: !tool.is_team_authorization,
|
||||
enabled: true,
|
||||
}
|
||||
use_end_user_credentials: false,
|
||||
end_user_credential_type: '',
|
||||
} as any
|
||||
}
|
||||
const handleSelectTool = (tool: ToolDefaultValue) => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
@ -138,6 +148,34 @@ const AgentTools: FC = () => {
|
||||
formattingChangedDispatcher()
|
||||
}, [currentTool, modelConfig, setModelConfig, formattingChangedDispatcher])
|
||||
|
||||
const handleEndUserCredentialChange = useCallback((enabled: boolean) => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
const tool = (draft.agentConfig.tools).find((item: any) => item.provider_id === currentTool?.provider_id)
|
||||
if (tool)
|
||||
(tool as AgentTool).use_end_user_credentials = enabled
|
||||
})
|
||||
setCurrentTool({
|
||||
...currentTool,
|
||||
use_end_user_credentials: enabled,
|
||||
} as any)
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}, [currentTool, modelConfig, setModelConfig, formattingChangedDispatcher])
|
||||
|
||||
const handleEndUserCredentialTypeChange = useCallback((type: string) => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
const tool = (draft.agentConfig.tools).find((item: any) => item.provider_id === currentTool?.provider_id)
|
||||
if (tool)
|
||||
(tool as AgentTool).end_user_credential_type = type
|
||||
})
|
||||
setCurrentTool({
|
||||
...currentTool,
|
||||
end_user_credential_type: type,
|
||||
} as any)
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}, [currentTool, modelConfig, setModelConfig, formattingChangedDispatcher])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel
|
||||
@ -177,134 +215,307 @@ const AgentTools: FC = () => {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='grid grid-cols-1 flex-wrap items-center justify-between gap-1 2xl:grid-cols-2'>
|
||||
{tools.map((item: AgentTool & { icon: any; collection?: Collection }, index) => (
|
||||
<div key={index}
|
||||
className={cn(
|
||||
'cursor group relative flex w-full items-center justify-between rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-1.5 pr-2 shadow-xs last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm',
|
||||
isDeleting === index && 'border-state-destructive-border hover:bg-state-destructive-hover',
|
||||
)}
|
||||
<div className='space-y-2'>
|
||||
{Object.values(
|
||||
tools.reduce((acc, item, idx) => {
|
||||
const key = item.provider_id
|
||||
if (!acc[key]) {
|
||||
acc[key] = {
|
||||
providerId: item.provider_id,
|
||||
providerName: getProviderShowName(item) || '',
|
||||
icon: item.icon,
|
||||
providerType: item.provider_type,
|
||||
tools: [] as (AgentTool & { __index: number })[],
|
||||
}
|
||||
}
|
||||
acc[key].tools.push({ ...item, __index: idx })
|
||||
return acc
|
||||
}, {} as Record<string, { providerId: string; providerName: string; providerType: CollectionType; icon: any; tools: (AgentTool & { __index: number })[] }>),
|
||||
).map(group => (
|
||||
<div
|
||||
key={group.providerId}
|
||||
className='rounded-lg border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-2 shadow-xs'
|
||||
>
|
||||
<div className='flex w-0 grow items-center'>
|
||||
{item.isDeleted && <DefaultToolIcon className='h-5 w-5' />}
|
||||
{!item.isDeleted && (
|
||||
<div className={cn((item.notAuthor || !item.enabled) && 'shrink-0 opacity-50')}>
|
||||
{typeof item.icon === 'string' && <div className='h-5 w-5 rounded-md bg-cover bg-center' style={{ backgroundImage: `url(${item.icon})` }} />}
|
||||
{typeof item.icon !== 'string' && <AppIcon className='rounded-md' size='xs' icon={item.icon?.content} background={item.icon?.background} />}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
<div
|
||||
className='flex cursor-pointer items-center gap-2 px-1'
|
||||
onClick={() => toggleProviderExpand(group.providerId)}
|
||||
>
|
||||
<RiArrowDownSLine
|
||||
className={cn(
|
||||
'system-xs-regular ml-1.5 flex w-0 grow items-center truncate',
|
||||
(item.isDeleted || item.notAuthor || !item.enabled) ? 'opacity-50' : '',
|
||||
'h-4 w-4 shrink-0 text-text-tertiary transition-transform',
|
||||
!expandedProviders[group.providerId] && '-rotate-90',
|
||||
)}
|
||||
>
|
||||
<span className='system-xs-medium pr-1.5 text-text-secondary'>{getProviderShowName(item)}</span>
|
||||
<span className='text-text-tertiary'>{item.tool_label}</span>
|
||||
{!item.isDeleted && (
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<div className='w-[180px]'>
|
||||
<div className='mb-1.5 text-text-secondary'>{item.tool_name}</div>
|
||||
<div className='mb-1.5 text-text-tertiary'>{t('tools.toolNameUsageTip')}</div>
|
||||
<div className='cursor-pointer text-text-accent' onClick={() => copy(item.tool_name)}>{t('tools.copyToolName')}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='h-4 w-4'>
|
||||
<div className='ml-0.5 hidden group-hover:inline-block'>
|
||||
<RiInformation2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='ml-1 flex shrink-0 items-center'>
|
||||
{item.isDeleted && (
|
||||
<div className='mr-2 flex items-center'>
|
||||
<Tooltip
|
||||
popupContent={t('tools.toolRemoved')}
|
||||
>
|
||||
<div className='mr-1 cursor-pointer rounded-md p-1 hover:bg-black/5'>
|
||||
<AlertTriangle className='h-4 w-4 text-[#F79009]' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div
|
||||
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
|
||||
onClick={() => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
draft.agentConfig.tools.splice(index, 1)
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}}
|
||||
onMouseOver={() => setIsDeleting(index)}
|
||||
onMouseLeave={() => setIsDeleting(-1)}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</div>
|
||||
/>
|
||||
{typeof group.icon === 'string'
|
||||
? <div className='h-5 w-5 shrink-0 rounded-md bg-cover bg-center' style={{ backgroundImage: `url(${group.icon})` }} />
|
||||
: <AppIcon className='shrink-0 rounded-md' size='xs' icon={group.icon?.content} background={group.icon?.background} />}
|
||||
<div className='system-sm-semibold truncate text-text-secondary'>{group.providerName}</div>
|
||||
<div className='ml-auto flex shrink-0 items-center gap-2'>
|
||||
<div className='system-xs-regular text-text-tertiary'>
|
||||
{group.tools.filter(tool => tool.enabled).length}/{group.tools.length} {t('appDebug.agent.tools.enabled')}
|
||||
</div>
|
||||
)}
|
||||
{!item.isDeleted && (
|
||||
<div className='mr-2 hidden items-center gap-1 group-hover:flex'>
|
||||
{!item.notAuthor && (
|
||||
<Tooltip
|
||||
popupContent={t('tools.setBuiltInTools.infoAndSetting')}
|
||||
>
|
||||
<div className='cursor-pointer rounded-md p-1 hover:bg-black/5' onClick={() => {
|
||||
setCurrentTool(item)
|
||||
setIsShowSettingTool(true)
|
||||
}}>
|
||||
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div
|
||||
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
|
||||
onClick={() => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
draft.agentConfig.tools.splice(index, 1)
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
{group.tools.every(tool => tool.notAuthor) && (
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='small'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const first = group.tools[0]
|
||||
setCurrentTool(first as any)
|
||||
setIsShowSettingTool(true)
|
||||
}}
|
||||
onMouseOver={() => setIsDeleting(index)}
|
||||
onMouseLeave={() => setIsDeleting(-1)}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn(item.isDeleted && 'opacity-50')}>
|
||||
{!item.notAuthor && (
|
||||
<Switch
|
||||
defaultValue={item.isDeleted ? false : item.enabled}
|
||||
disabled={item.isDeleted}
|
||||
size='md'
|
||||
onChange={(enabled) => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
(draft.agentConfig.tools[index] as any).enabled = enabled
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}} />
|
||||
)}
|
||||
{item.notAuthor && (
|
||||
<Button variant='secondary' size='small' onClick={() => {
|
||||
setCurrentTool(item)
|
||||
setIsShowSettingTool(true)
|
||||
}}>
|
||||
{t('tools.notAuthorized')}
|
||||
<Indicator className='ml-2' color='orange' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn('space-y-1', expandedProviders[group.providerId] ? 'mt-1' : 'hidden')}>
|
||||
{group.tools.map(item => (
|
||||
<div
|
||||
key={`${item.provider_id}-${item.tool_name}`}
|
||||
className={cn(
|
||||
'group relative flex w-full items-center justify-between rounded-lg pl-[21px] pr-2 hover:bg-state-base-hover',
|
||||
isDeleting === item.__index && 'border border-state-destructive-border hover:bg-state-destructive-hover',
|
||||
)}
|
||||
>
|
||||
<div className='flex w-0 grow items-center'>
|
||||
<div
|
||||
className={cn(
|
||||
'system-xs-regular flex w-0 grow items-center truncate border-l-2 border-divider-subtle pl-4',
|
||||
(item.isDeleted || item.notAuthor || !item.enabled) ? 'opacity-50' : '',
|
||||
)}
|
||||
>
|
||||
<span className='system-xs-medium pr-1.5 text-text-secondary'>{item.tool_label}</span>
|
||||
<span className='text-text-tertiary'>{item.tool_name}</span>
|
||||
{!item.isDeleted && (
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<div className='w-[180px]'>
|
||||
<div className='mb-1.5 text-text-secondary'>{item.tool_name}</div>
|
||||
<div className='mb-1.5 text-text-tertiary'>{t('tools.toolNameUsageTip')}</div>
|
||||
<div className='cursor-pointer text-text-accent' onClick={() => copy(item.tool_name)}>{t('tools.copyToolName')}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='h-4 w-4'>
|
||||
<div className='ml-0.5 hidden group-hover:inline-block'>
|
||||
<RiInformation2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex shrink-0 items-center space-x-2'>
|
||||
{item.isDeleted && (
|
||||
<div className='mr-2 flex items-center'>
|
||||
<Tooltip
|
||||
popupContent={t('tools.toolRemoved')}
|
||||
>
|
||||
<div className='mr-1 cursor-pointer rounded-md p-1 hover:bg-black/5'>
|
||||
<AlertTriangle className='h-4 w-4 text-[#F79009]' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div
|
||||
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
|
||||
onClick={() => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
draft.agentConfig.tools.splice(item.__index, 1)
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}}
|
||||
onMouseOver={() => setIsDeleting(item.__index)}
|
||||
onMouseLeave={() => setIsDeleting(-1)}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!item.isDeleted && (
|
||||
<div className='pointer-events-none mr-2 flex items-center gap-1 opacity-0 transition-opacity duration-150 group-hover:pointer-events-auto group-hover:opacity-100'>
|
||||
{!item.notAuthor && (
|
||||
<Tooltip
|
||||
popupContent={t('tools.setBuiltInTools.infoAndSetting')}
|
||||
>
|
||||
<div className='cursor-pointer rounded-md p-1 hover:bg-black/5' onClick={() => {
|
||||
setCurrentTool(item as any)
|
||||
setIsShowSettingTool(true)
|
||||
}}>
|
||||
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div
|
||||
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
|
||||
onClick={() => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
draft.agentConfig.tools.splice(item.__index, 1)
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}}
|
||||
onMouseOver={() => setIsDeleting(item.__index)}
|
||||
onMouseLeave={() => setIsDeleting(-1)}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn(item.isDeleted && 'opacity-50')}>
|
||||
{!item.notAuthor && (
|
||||
<Switch
|
||||
defaultValue={item.isDeleted ? false : item.enabled}
|
||||
disabled={item.isDeleted}
|
||||
size='md'
|
||||
onChange={(enabled) => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
(draft.agentConfig.tools[item.__index] as any).enabled = enabled
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className='grid grid-cols-1 flex-wrap items-center justify-between gap-1 2xl:grid-cols-2'>
|
||||
{tools.map((item: AgentTool & { icon: any; collection?: Collection }, index) => (
|
||||
<div key={index}
|
||||
className={cn(
|
||||
'cursor group relative flex w-full items-center justify-between rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-1.5 pr-2 shadow-xs last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm',
|
||||
isDeleting === index && 'border-state-destructive-border hover:bg-state-destructive-hover',
|
||||
)}
|
||||
>
|
||||
<div className='flex w-0 grow items-center'>
|
||||
{item.isDeleted && <DefaultToolIcon className='h-5 w-5' />}
|
||||
{!item.isDeleted && (
|
||||
<div className={cn((item.notAuthor || !item.enabled) && 'shrink-0 opacity-50')}>
|
||||
{typeof item.icon === 'string' && <div className='h-5 w-5 rounded-md bg-cover bg-center' style={{ backgroundImage: `url(${item.icon})` }} />}
|
||||
{typeof item.icon !== 'string' && <AppIcon className='rounded-md' size='xs' icon={item.icon?.content} background={item.icon?.background} />}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'system-xs-regular ml-1.5 flex w-0 grow items-center truncate',
|
||||
(item.isDeleted || item.notAuthor || !item.enabled) ? 'opacity-50' : '',
|
||||
)}
|
||||
>
|
||||
<span className='system-xs-medium pr-1.5 text-text-secondary'>{getProviderShowName(item)}</span>
|
||||
<span className='text-text-tertiary'>{item.tool_label}</span>
|
||||
{!item.isDeleted && (
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<div className='w-[180px]'>
|
||||
<div className='mb-1.5 text-text-secondary'>{item.tool_name}</div>
|
||||
<div className='mb-1.5 text-text-tertiary'>{t('tools.toolNameUsageTip')}</div>
|
||||
<div className='cursor-pointer text-text-accent' onClick={() => copy(item.tool_name)}>{t('tools.copyToolName')}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='h-4 w-4'>
|
||||
<div className='ml-0.5 hidden group-hover:inline-block'>
|
||||
<RiInformation2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='ml-1 flex shrink-0 items-center'>
|
||||
{item.isDeleted && (
|
||||
<div className='mr-2 flex items-center'>
|
||||
<Tooltip
|
||||
popupContent={t('tools.toolRemoved')}
|
||||
>
|
||||
<div className='mr-1 cursor-pointer rounded-md p-1 hover:bg-black/5'>
|
||||
<AlertTriangle className='h-4 w-4 text-[#F79009]' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div
|
||||
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
|
||||
onClick={() => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
draft.agentConfig.tools.splice(index, 1)
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}}
|
||||
onMouseOver={() => setIsDeleting(index)}
|
||||
onMouseLeave={() => setIsDeleting(-1)}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!item.isDeleted && (
|
||||
<div className='mr-2 hidden items-center gap-1 group-hover:flex'>
|
||||
{!item.notAuthor && (
|
||||
<Tooltip
|
||||
popupContent={t('tools.setBuiltInTools.infoAndSetting')}
|
||||
needsDelay={false}
|
||||
>
|
||||
<div className='cursor-pointer rounded-md p-1 hover:bg-black/5' onClick={() => {
|
||||
setCurrentTool(item)
|
||||
setIsShowSettingTool(true)
|
||||
}}>
|
||||
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div
|
||||
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
|
||||
onClick={() => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
draft.agentConfig.tools.splice(index, 1)
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}}
|
||||
onMouseOver={() => setIsDeleting(index)}
|
||||
onMouseLeave={() => setIsDeleting(-1)}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn(item.isDeleted && 'opacity-50')}>
|
||||
{!item.notAuthor && (
|
||||
<Switch
|
||||
defaultValue={item.isDeleted ? false : item.enabled}
|
||||
disabled={item.isDeleted}
|
||||
size='md'
|
||||
onChange={(enabled) => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
(draft.agentConfig.tools[index] as any).enabled = enabled
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}} />
|
||||
)}
|
||||
{item.notAuthor && (
|
||||
<Button variant='secondary' size='small' onClick={() => {
|
||||
setCurrentTool(item)
|
||||
setIsShowSettingTool(true)
|
||||
}}>
|
||||
{t('tools.notAuthorized')}
|
||||
<Indicator className='ml-2' color='orange' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div >
|
||||
</Panel >
|
||||
</div>
|
||||
</Panel>
|
||||
{isShowSettingTool && (
|
||||
<SettingBuiltInTool
|
||||
toolName={currentTool?.tool_name as string}
|
||||
@ -315,6 +526,10 @@ const AgentTools: FC = () => {
|
||||
onHide={() => setIsShowSettingTool(false)}
|
||||
credentialId={currentTool?.credential_id}
|
||||
onAuthorizationItemClick={handleAuthorizationItemClick}
|
||||
useEndUserCredentialEnabled={currentTool?.use_end_user_credentials}
|
||||
endUserCredentialType={currentTool?.end_user_credential_type}
|
||||
onEndUserCredentialChange={handleEndUserCredentialChange}
|
||||
onEndUserCredentialTypeChange={handleEndUserCredentialTypeChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -42,6 +42,10 @@ type Props = {
|
||||
onSave?: (value: Record<string, any>) => void
|
||||
credentialId?: string
|
||||
onAuthorizationItemClick?: (id: string) => void
|
||||
useEndUserCredentialEnabled?: boolean
|
||||
endUserCredentialType?: string
|
||||
onEndUserCredentialChange?: (enabled: boolean) => void
|
||||
onEndUserCredentialTypeChange?: (type: string) => void
|
||||
}
|
||||
|
||||
const SettingBuiltInTool: FC<Props> = ({
|
||||
@ -56,6 +60,10 @@ const SettingBuiltInTool: FC<Props> = ({
|
||||
onSave,
|
||||
credentialId,
|
||||
onAuthorizationItemClick,
|
||||
useEndUserCredentialEnabled,
|
||||
endUserCredentialType,
|
||||
onEndUserCredentialChange,
|
||||
onEndUserCredentialTypeChange,
|
||||
}) => {
|
||||
const { locale } = useContext(I18n)
|
||||
const language = getLanguage(locale)
|
||||
@ -220,6 +228,10 @@ const SettingBuiltInTool: FC<Props> = ({
|
||||
}}
|
||||
credentialId={credentialId}
|
||||
onAuthorizationItemClick={onAuthorizationItemClick}
|
||||
useEndUserCredentialEnabled={useEndUserCredentialEnabled}
|
||||
endUserCredentialType={endUserCredentialType}
|
||||
onEndUserCredentialChange={onEndUserCredentialChange}
|
||||
onEndUserCredentialTypeChange={onEndUserCredentialTypeChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -28,6 +28,7 @@ import Input from '@/app/components/base/input'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { DSLImportMode } from '@/models/app'
|
||||
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
|
||||
type AppsProps = {
|
||||
onSuccess?: () => void
|
||||
@ -141,6 +142,15 @@ const Apps = ({
|
||||
icon_background,
|
||||
description,
|
||||
})
|
||||
|
||||
// Track app creation from template
|
||||
trackEvent('create_app_with_template', {
|
||||
app_mode: mode,
|
||||
template_id: currApp?.app.id,
|
||||
template_name: currApp?.app.name,
|
||||
description,
|
||||
})
|
||||
|
||||
setIsShowCreateModal(false)
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
|
||||
@ -30,6 +30,7 @@ import { getRedirection } from '@/utils/app-redirection'
|
||||
import FullScreenModal from '@/app/components/base/fullscreen-modal'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
|
||||
type CreateAppProps = {
|
||||
onSuccess: () => void
|
||||
@ -82,6 +83,13 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
|
||||
icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined,
|
||||
mode: appMode,
|
||||
})
|
||||
|
||||
// Track app creation success
|
||||
trackEvent('create_app', {
|
||||
app_mode: appMode,
|
||||
description,
|
||||
})
|
||||
|
||||
notify({ type: 'success', message: t('app.newApp.appCreated') })
|
||||
onSuccess()
|
||||
onClose()
|
||||
|
||||
@ -28,6 +28,7 @@ import { getRedirection } from '@/utils/app-redirection'
|
||||
import cn from '@/utils/classnames'
|
||||
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
|
||||
import { noop } from 'lodash-es'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
|
||||
type CreateFromDSLModalProps = {
|
||||
show: boolean
|
||||
@ -112,6 +113,13 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
|
||||
return
|
||||
const { id, status, app_id, app_mode, imported_dsl_version, current_dsl_version } = response
|
||||
if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
|
||||
// Track app creation from DSL import
|
||||
trackEvent('create_app_with_dsl', {
|
||||
app_mode,
|
||||
creation_method: currentTab === CreateFromDSLModalTab.FROM_FILE ? 'dsl_file' : 'dsl_url',
|
||||
has_warnings: status === DSLImportStatus.COMPLETED_WITH_WARNINGS,
|
||||
})
|
||||
|
||||
if (onSuccess)
|
||||
onSuccess()
|
||||
if (onClose)
|
||||
|
||||
@ -8,6 +8,7 @@ import quarterOfYear from 'dayjs/plugin/quarterOfYear'
|
||||
import type { QueryParam } from './index'
|
||||
import Chip from '@/app/components/base/chip'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { trackEvent } from '@/app/components/base/amplitude/utils'
|
||||
dayjs.extend(quarterOfYear)
|
||||
|
||||
const today = dayjs()
|
||||
@ -37,6 +38,9 @@ const Filter: FC<IFilterProps> = ({ queryParams, setQueryParams }: IFilterProps)
|
||||
value={queryParams.status || 'all'}
|
||||
onSelect={(item) => {
|
||||
setQueryParams({ ...queryParams, status: item.value as string })
|
||||
trackEvent('workflow_log_filter_status_selected', {
|
||||
workflow_log_filter_status: item.value as string,
|
||||
})
|
||||
}}
|
||||
onClear={() => setQueryParams({ ...queryParams, status: 'all' })}
|
||||
items={[{ value: 'all', name: 'All' },
|
||||
|
||||
46
web/app/components/base/amplitude/AmplitudeProvider.tsx
Normal file
46
web/app/components/base/amplitude/AmplitudeProvider.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect } from 'react'
|
||||
import * as amplitude from '@amplitude/analytics-browser'
|
||||
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
|
||||
export type IAmplitudeProps = {
|
||||
apiKey?: string
|
||||
sessionReplaySampleRate?: number
|
||||
}
|
||||
|
||||
const AmplitudeProvider: FC<IAmplitudeProps> = ({
|
||||
apiKey = process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY ?? '',
|
||||
sessionReplaySampleRate = 1,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
// Only enable in Saas edition
|
||||
if (!IS_CLOUD_EDITION)
|
||||
return
|
||||
|
||||
// Initialize Amplitude
|
||||
amplitude.init(apiKey, {
|
||||
defaultTracking: {
|
||||
sessions: true,
|
||||
pageViews: true,
|
||||
formInteractions: true,
|
||||
fileDownloads: true,
|
||||
},
|
||||
// Enable debug logs in development environment
|
||||
logLevel: amplitude.Types.LogLevel.Warn,
|
||||
})
|
||||
|
||||
// Add Session Replay plugin
|
||||
const sessionReplay = sessionReplayPlugin({
|
||||
sampleRate: sessionReplaySampleRate,
|
||||
})
|
||||
amplitude.add(sessionReplay)
|
||||
}, [])
|
||||
|
||||
// This is a client component that renders nothing
|
||||
return null
|
||||
}
|
||||
|
||||
export default React.memo(AmplitudeProvider)
|
||||
2
web/app/components/base/amplitude/index.ts
Normal file
2
web/app/components/base/amplitude/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { default } from './AmplitudeProvider'
|
||||
export { resetUser, setUserId, setUserProperties, trackEvent } from './utils'
|
||||
37
web/app/components/base/amplitude/utils.ts
Normal file
37
web/app/components/base/amplitude/utils.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import * as amplitude from '@amplitude/analytics-browser'
|
||||
|
||||
/**
|
||||
* Track custom event
|
||||
* @param eventName Event name
|
||||
* @param eventProperties Event properties (optional)
|
||||
*/
|
||||
export const trackEvent = (eventName: string, eventProperties?: Record<string, any>) => {
|
||||
amplitude.track(eventName, eventProperties)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user ID
|
||||
* @param userId User ID
|
||||
*/
|
||||
export const setUserId = (userId: string) => {
|
||||
amplitude.setUserId(userId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user properties
|
||||
* @param properties User properties
|
||||
*/
|
||||
export const setUserProperties = (properties: Record<string, any>) => {
|
||||
const identifyEvent = new amplitude.Identify()
|
||||
Object.entries(properties).forEach(([key, value]) => {
|
||||
identifyEvent.set(key, value)
|
||||
})
|
||||
amplitude.identify(identifyEvent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset user (e.g., when user logs out)
|
||||
*/
|
||||
export const resetUser = () => {
|
||||
amplitude.reset()
|
||||
}
|
||||
@ -24,6 +24,10 @@ import cn from '@/utils/classnames'
|
||||
import type { FileEntity } from '../../file-uploader/types'
|
||||
import { formatBooleanInputs } from '@/utils/model-config'
|
||||
import Avatar from '../../avatar'
|
||||
import ServiceConnectionPanel from '@/app/components/base/service-connection-panel'
|
||||
import type { AuthType, ServiceConnectionItem as ServiceConnectionItemType } from '@/app/components/base/service-connection-panel'
|
||||
import { Notion } from '@/app/components/base/icons/src/public/common'
|
||||
import { Google } from '@/app/components/base/icons/src/public/plugins'
|
||||
|
||||
const ChatWrapper = () => {
|
||||
const {
|
||||
@ -167,6 +171,53 @@ const ChatWrapper = () => {
|
||||
|
||||
const [collapsed, setCollapsed] = useState(!!currentConversationId)
|
||||
|
||||
// Demo: Service connection state
|
||||
const [serviceConnections, setServiceConnections] = useState<ServiceConnectionItemType[]>([
|
||||
{
|
||||
id: 'notion',
|
||||
name: 'Notion Page Search',
|
||||
icon: <Notion className="h-6 w-6" />,
|
||||
authType: 'oauth',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'gmail',
|
||||
name: 'Gmail Tools',
|
||||
icon: <img src="https://www.gstatic.com/images/branding/product/1x/gmail_2020q4_32dp.png" alt="Gmail" className="h-6 w-6" />,
|
||||
authType: 'oauth',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'youtube',
|
||||
name: 'YouTube Data Upload',
|
||||
icon: <img src="https://www.youtube.com/s/desktop/f506bd45/img/favicon_32x32.png" alt="YouTube" className="h-6 w-6" />,
|
||||
authType: 'oauth',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'google-serp',
|
||||
name: 'Google SerpApi Search',
|
||||
icon: <Google className="h-6 w-6" />,
|
||||
authType: 'api_key',
|
||||
status: 'pending',
|
||||
},
|
||||
])
|
||||
|
||||
const [showServiceConnection, setShowServiceConnection] = useState(true)
|
||||
|
||||
const handleServiceConnect = useCallback((serviceId: string, _authType: AuthType) => {
|
||||
// Demo: 模拟连接成功
|
||||
setServiceConnections(prev => prev.map(service =>
|
||||
service.id === serviceId
|
||||
? { ...service, status: 'connected' as const }
|
||||
: service,
|
||||
))
|
||||
}, [])
|
||||
|
||||
const handleServiceContinue = useCallback(() => {
|
||||
setShowServiceConnection(false)
|
||||
}, [])
|
||||
|
||||
const chatNode = useMemo(() => {
|
||||
if (allInputsHidden || !inputsForms.length)
|
||||
return null
|
||||
@ -253,6 +304,23 @@ const ChatWrapper = () => {
|
||||
/>
|
||||
: null
|
||||
|
||||
// 如果需要显示服务连接面板,则显示面板而非聊天界面
|
||||
if (showServiceConnection) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex h-full items-center justify-center overflow-auto bg-chatbot-bg',
|
||||
isMobile && 'px-4 py-8',
|
||||
)}>
|
||||
<ServiceConnectionPanel
|
||||
services={serviceConnections}
|
||||
onConnect={handleServiceConnect}
|
||||
onContinue={handleServiceContinue}
|
||||
className={cn(isMobile && 'max-w-full')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className='h-full overflow-hidden bg-chatbot-bg'
|
||||
|
||||
@ -11,7 +11,10 @@ import {
|
||||
RiThumbDownLine,
|
||||
RiThumbUpLine,
|
||||
} from '@remixicon/react'
|
||||
import type { ChatItem } from '../../types'
|
||||
import type {
|
||||
ChatItem,
|
||||
Feedback,
|
||||
} from '../../types'
|
||||
import { useChatContext } from '../context'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
@ -22,6 +25,7 @@ import ActionButton, { ActionButtonState } from '@/app/components/base/action-bu
|
||||
import NewAudioButton from '@/app/components/base/new-audio-button'
|
||||
import Modal from '@/app/components/base/modal/modal'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type OperationProps = {
|
||||
@ -66,8 +70,9 @@ const Operation: FC<OperationProps> = ({
|
||||
adminFeedback,
|
||||
agent_thoughts,
|
||||
} = item
|
||||
const [localFeedback, setLocalFeedback] = useState(config?.supportAnnotation ? adminFeedback : feedback)
|
||||
const [userLocalFeedback, setUserLocalFeedback] = useState(feedback)
|
||||
const [adminLocalFeedback, setAdminLocalFeedback] = useState(adminFeedback)
|
||||
const [feedbackTarget, setFeedbackTarget] = useState<'user' | 'admin'>('user')
|
||||
|
||||
// Separate feedback types for display
|
||||
const userFeedback = feedback
|
||||
@ -79,24 +84,68 @@ const Operation: FC<OperationProps> = ({
|
||||
return messageContent
|
||||
}, [agent_thoughts, messageContent])
|
||||
|
||||
const handleFeedback = async (rating: 'like' | 'dislike' | null, content?: string) => {
|
||||
const displayUserFeedback = userLocalFeedback ?? userFeedback
|
||||
|
||||
const hasUserFeedback = !!displayUserFeedback?.rating
|
||||
const hasAdminFeedback = !!adminLocalFeedback?.rating
|
||||
|
||||
const shouldShowUserFeedbackBar = !isOpeningStatement && config?.supportFeedback && !!onFeedback && !config?.supportAnnotation
|
||||
const shouldShowAdminFeedbackBar = !isOpeningStatement && config?.supportFeedback && !!onFeedback && !!config?.supportAnnotation
|
||||
|
||||
const userFeedbackLabel = t('appLog.table.header.userRate') || 'User feedback'
|
||||
const adminFeedbackLabel = t('appLog.table.header.adminRate') || 'Admin feedback'
|
||||
const feedbackTooltipClassName = 'max-w-[260px]'
|
||||
|
||||
const buildFeedbackTooltip = (feedbackData?: Feedback | null, label = userFeedbackLabel) => {
|
||||
if (!feedbackData?.rating)
|
||||
return label
|
||||
|
||||
const ratingLabel = feedbackData.rating === 'like'
|
||||
? (t('appLog.detail.operation.like') || 'like')
|
||||
: (t('appLog.detail.operation.dislike') || 'dislike')
|
||||
const feedbackText = feedbackData.content?.trim()
|
||||
|
||||
if (feedbackText)
|
||||
return `${label}: ${ratingLabel} - ${feedbackText}`
|
||||
|
||||
return `${label}: ${ratingLabel}`
|
||||
}
|
||||
|
||||
const handleFeedback = async (rating: 'like' | 'dislike' | null, content?: string, target: 'user' | 'admin' = 'user') => {
|
||||
if (!config?.supportFeedback || !onFeedback)
|
||||
return
|
||||
|
||||
await onFeedback?.(id, { rating, content })
|
||||
setLocalFeedback({ rating })
|
||||
|
||||
// Update admin feedback state separately if annotation is supported
|
||||
if (config?.supportAnnotation)
|
||||
setAdminLocalFeedback(rating ? { rating } : undefined)
|
||||
const nextFeedback = rating === null ? { rating: null } : { rating, content }
|
||||
|
||||
if (target === 'admin')
|
||||
setAdminLocalFeedback(nextFeedback)
|
||||
else
|
||||
setUserLocalFeedback(nextFeedback)
|
||||
}
|
||||
|
||||
const handleThumbsDown = () => {
|
||||
const handleLikeClick = (target: 'user' | 'admin') => {
|
||||
const currentRating = target === 'admin' ? adminLocalFeedback?.rating : displayUserFeedback?.rating
|
||||
if (currentRating === 'like') {
|
||||
handleFeedback(null, undefined, target)
|
||||
return
|
||||
}
|
||||
handleFeedback('like', undefined, target)
|
||||
}
|
||||
|
||||
const handleDislikeClick = (target: 'user' | 'admin') => {
|
||||
const currentRating = target === 'admin' ? adminLocalFeedback?.rating : displayUserFeedback?.rating
|
||||
if (currentRating === 'dislike') {
|
||||
handleFeedback(null, undefined, target)
|
||||
return
|
||||
}
|
||||
setFeedbackTarget(target)
|
||||
setIsShowFeedbackModal(true)
|
||||
}
|
||||
|
||||
const handleFeedbackSubmit = async () => {
|
||||
await handleFeedback('dislike', feedbackContent)
|
||||
await handleFeedback('dislike', feedbackContent, feedbackTarget)
|
||||
setFeedbackContent('')
|
||||
setIsShowFeedbackModal(false)
|
||||
}
|
||||
@ -116,12 +165,13 @@ const Operation: FC<OperationProps> = ({
|
||||
width += 26
|
||||
if (!isOpeningStatement && config?.supportAnnotation && config?.annotation_reply?.enabled)
|
||||
width += 26
|
||||
if (config?.supportFeedback && !localFeedback?.rating && onFeedback && !isOpeningStatement)
|
||||
width += 60 + 8
|
||||
if (config?.supportFeedback && localFeedback?.rating && onFeedback && !isOpeningStatement)
|
||||
width += 28 + 8
|
||||
if (shouldShowUserFeedbackBar)
|
||||
width += hasUserFeedback ? 28 + 8 : 60 + 8
|
||||
if (shouldShowAdminFeedbackBar)
|
||||
width += (hasAdminFeedback ? 28 : 60) + 8 + (hasUserFeedback ? 28 : 0)
|
||||
|
||||
return width
|
||||
}, [isOpeningStatement, showPromptLog, config?.text_to_speech?.enabled, config?.supportAnnotation, config?.annotation_reply?.enabled, config?.supportFeedback, localFeedback?.rating, onFeedback])
|
||||
}, [config?.annotation_reply?.enabled, config?.supportAnnotation, config?.text_to_speech?.enabled, hasAdminFeedback, hasUserFeedback, isOpeningStatement, shouldShowAdminFeedbackBar, shouldShowUserFeedbackBar, showPromptLog])
|
||||
|
||||
const positionRight = useMemo(() => operationWidth < maxSize, [operationWidth, maxSize])
|
||||
|
||||
@ -136,6 +186,110 @@ const Operation: FC<OperationProps> = ({
|
||||
)}
|
||||
style={(!hasWorkflowProcess && positionRight) ? { left: contentWidth + 8 } : {}}
|
||||
>
|
||||
{shouldShowUserFeedbackBar && (
|
||||
<div className={cn(
|
||||
'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm',
|
||||
hasUserFeedback ? 'flex' : 'hidden group-hover:flex',
|
||||
)}>
|
||||
{hasUserFeedback ? (
|
||||
<Tooltip
|
||||
popupContent={buildFeedbackTooltip(displayUserFeedback, userFeedbackLabel)}
|
||||
popupClassName={feedbackTooltipClassName}
|
||||
>
|
||||
<ActionButton
|
||||
state={displayUserFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Destructive}
|
||||
onClick={() => handleFeedback(null, undefined, 'user')}
|
||||
>
|
||||
{displayUserFeedback?.rating === 'like'
|
||||
? <RiThumbUpLine className='h-4 w-4' />
|
||||
: <RiThumbDownLine className='h-4 w-4' />}
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>
|
||||
<ActionButton
|
||||
state={displayUserFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Default}
|
||||
onClick={() => handleLikeClick('user')}
|
||||
>
|
||||
<RiThumbUpLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
state={displayUserFeedback?.rating === 'dislike' ? ActionButtonState.Destructive : ActionButtonState.Default}
|
||||
onClick={() => handleDislikeClick('user')}
|
||||
>
|
||||
<RiThumbDownLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{shouldShowAdminFeedbackBar && (
|
||||
<div className={cn(
|
||||
'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm',
|
||||
(hasAdminFeedback || hasUserFeedback) ? 'flex' : 'hidden group-hover:flex',
|
||||
)}>
|
||||
{/* User Feedback Display */}
|
||||
{displayUserFeedback?.rating && (
|
||||
<Tooltip
|
||||
popupContent={buildFeedbackTooltip(displayUserFeedback, userFeedbackLabel)}
|
||||
popupClassName={feedbackTooltipClassName}
|
||||
>
|
||||
{displayUserFeedback.rating === 'like' ? (
|
||||
<ActionButton state={ActionButtonState.Active}>
|
||||
<RiThumbUpLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
) : (
|
||||
<ActionButton state={ActionButtonState.Destructive}>
|
||||
<RiThumbDownLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Admin Feedback Controls */}
|
||||
{displayUserFeedback?.rating && <div className='mx-1 h-3 w-[0.5px] bg-components-actionbar-border' />}
|
||||
{hasAdminFeedback ? (
|
||||
<Tooltip
|
||||
popupContent={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)}
|
||||
popupClassName={feedbackTooltipClassName}
|
||||
>
|
||||
<ActionButton
|
||||
state={adminLocalFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Destructive}
|
||||
onClick={() => handleFeedback(null, undefined, 'admin')}
|
||||
>
|
||||
{adminLocalFeedback?.rating === 'like'
|
||||
? <RiThumbUpLine className='h-4 w-4' />
|
||||
: <RiThumbDownLine className='h-4 w-4' />}
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>
|
||||
<Tooltip
|
||||
popupContent={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)}
|
||||
popupClassName={feedbackTooltipClassName}
|
||||
>
|
||||
<ActionButton
|
||||
state={adminLocalFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Default}
|
||||
onClick={() => handleLikeClick('admin')}
|
||||
>
|
||||
<RiThumbUpLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
popupContent={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)}
|
||||
popupClassName={feedbackTooltipClassName}
|
||||
>
|
||||
<ActionButton
|
||||
state={adminLocalFeedback?.rating === 'dislike' ? ActionButtonState.Destructive : ActionButtonState.Default}
|
||||
onClick={() => handleDislikeClick('admin')}
|
||||
>
|
||||
<RiThumbDownLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showPromptLog && !isOpeningStatement && (
|
||||
<div className='hidden group-hover:block'>
|
||||
<Log logItem={item} />
|
||||
@ -174,69 +328,6 @@ const Operation: FC<OperationProps> = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isOpeningStatement && config?.supportFeedback && !localFeedback?.rating && onFeedback && (
|
||||
<div className='ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex'>
|
||||
{!localFeedback?.rating && (
|
||||
<>
|
||||
<ActionButton onClick={() => handleFeedback('like')}>
|
||||
<RiThumbUpLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
<ActionButton onClick={handleThumbsDown}>
|
||||
<RiThumbDownLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isOpeningStatement && config?.supportFeedback && onFeedback && (
|
||||
<div className='ml-1 flex items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm'>
|
||||
{/* User Feedback Display */}
|
||||
{userFeedback?.rating && (
|
||||
<div className='flex items-center'>
|
||||
<span className='mr-1 text-xs text-text-tertiary'>User</span>
|
||||
{userFeedback.rating === 'like' ? (
|
||||
<ActionButton state={ActionButtonState.Active} title={userFeedback.content ? `User liked this response: ${userFeedback.content}` : 'User liked this response'}>
|
||||
<RiThumbUpLine className='h-3 w-3' />
|
||||
</ActionButton>
|
||||
) : (
|
||||
<ActionButton state={ActionButtonState.Destructive} title={userFeedback.content ? `User disliked this response: ${userFeedback.content}` : 'User disliked this response'}>
|
||||
<RiThumbDownLine className='h-3 w-3' />
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin Feedback Controls */}
|
||||
{config?.supportAnnotation && (
|
||||
<div className='flex items-center'>
|
||||
{userFeedback?.rating && <div className='mx-1 h-3 w-[0.5px] bg-components-actionbar-border' />}
|
||||
{!adminLocalFeedback?.rating ? (
|
||||
<>
|
||||
<ActionButton onClick={() => handleFeedback('like')}>
|
||||
<RiThumbUpLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
<ActionButton onClick={handleThumbsDown}>
|
||||
<RiThumbDownLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{adminLocalFeedback.rating === 'like' ? (
|
||||
<ActionButton state={ActionButtonState.Active} onClick={() => handleFeedback(null)}>
|
||||
<RiThumbUpLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
) : (
|
||||
<ActionButton state={ActionButtonState.Destructive} onClick={() => handleFeedback(null)}>
|
||||
<RiThumbDownLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<EditReplyModal
|
||||
isShow={isShowReplyModal}
|
||||
|
||||
79
web/app/components/base/service-connection-panel/index.tsx
Normal file
79
web/app/components/base/service-connection-panel/index.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import { memo, useMemo } from 'react'
|
||||
import { RiArrowRightLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import ServiceItem from './service-item'
|
||||
import type { ServiceConnectionPanelProps } from './types'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
const ServiceConnectionPanel: FC<ServiceConnectionPanelProps> = ({
|
||||
title,
|
||||
description,
|
||||
services,
|
||||
onConnect,
|
||||
onContinue,
|
||||
continueDisabled,
|
||||
continueText,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const allConnected = useMemo(() => {
|
||||
return services.every(service => service.status === 'connected')
|
||||
}, [services])
|
||||
|
||||
const displayTitle = title || t('share.serviceConnection.title')
|
||||
const displayDescription = description || t('share.serviceConnection.description', { count: services.length })
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex w-full max-w-[600px] flex-col items-center',
|
||||
className,
|
||||
)}>
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="system-xl-semibold mb-1 text-text-primary">
|
||||
{displayTitle}
|
||||
</h2>
|
||||
<p className="system-sm-regular text-text-tertiary">
|
||||
{displayDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-2">
|
||||
{services.map(service => (
|
||||
<ServiceItem
|
||||
key={service.id}
|
||||
service={service}
|
||||
onConnect={onConnect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{onContinue && (
|
||||
<div className="mt-6 flex w-full justify-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={continueDisabled ?? !allConnected}
|
||||
onClick={onContinue}
|
||||
>
|
||||
{continueText || t('share.serviceConnection.continue')}
|
||||
<RiArrowRightLine className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ServiceConnectionPanel)
|
||||
|
||||
export { default as ServiceItem } from './service-item'
|
||||
export type {
|
||||
ServiceConnectionPanelProps,
|
||||
ServiceConnectionItem,
|
||||
AuthType,
|
||||
ServiceConnectionStatus,
|
||||
} from './types'
|
||||
@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import { memo } from 'react'
|
||||
import { RiAddLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import type { AuthType, ServiceConnectionItem } from './types'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type ServiceItemProps = {
|
||||
service: ServiceConnectionItem
|
||||
onConnect: (serviceId: string, authType: AuthType) => void
|
||||
}
|
||||
|
||||
const ServiceItem: FC<ServiceItemProps> = ({
|
||||
service,
|
||||
onConnect,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleConnect = () => {
|
||||
onConnect(service.id, service.authType)
|
||||
}
|
||||
|
||||
const getButtonText = () => {
|
||||
if (service.status === 'connected')
|
||||
return t('share.serviceConnection.connected')
|
||||
|
||||
if (service.authType === 'api_key')
|
||||
return t('share.serviceConnection.addApiKey')
|
||||
|
||||
return t('share.serviceConnection.connect')
|
||||
}
|
||||
|
||||
const isConnected = service.status === 'connected'
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex items-center justify-between gap-3 rounded-xl border border-components-panel-border-subtle bg-components-panel-bg px-4 py-3',
|
||||
'hover:border-components-panel-border hover:shadow-xs',
|
||||
'transition-all duration-200',
|
||||
)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center">
|
||||
{service.icon}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="system-sm-medium text-text-secondary">
|
||||
{service.name}
|
||||
</span>
|
||||
{service.description && (
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
{service.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant={isConnected ? 'secondary' : 'secondary-accent'}
|
||||
size="small"
|
||||
onClick={handleConnect}
|
||||
disabled={isConnected}
|
||||
>
|
||||
{!isConnected && <RiAddLine className="mr-0.5 h-3.5 w-3.5" />}
|
||||
{getButtonText()}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ServiceItem)
|
||||
25
web/app/components/base/service-connection-panel/types.ts
Normal file
25
web/app/components/base/service-connection-panel/types.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export type AuthType = 'oauth' | 'api_key'
|
||||
|
||||
export type ServiceConnectionStatus = 'pending' | 'connected' | 'error'
|
||||
|
||||
export type ServiceConnectionItem = {
|
||||
id: string
|
||||
name: string
|
||||
icon: ReactNode
|
||||
authType: AuthType
|
||||
status: ServiceConnectionStatus
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type ServiceConnectionPanelProps = {
|
||||
title?: string
|
||||
description?: string
|
||||
services: ServiceConnectionItem[]
|
||||
onConnect: (serviceId: string, authType: AuthType) => void
|
||||
onContinue?: () => void
|
||||
continueDisabled?: boolean
|
||||
continueText?: string
|
||||
className?: string
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import React, { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import {
|
||||
RiBook2Line,
|
||||
RiFileEditLine,
|
||||
@ -25,6 +25,8 @@ import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/con
|
||||
import { useEducationVerify } from '@/service/use-education'
|
||||
import { useModalContextSelector } from '@/context/modal-context'
|
||||
import { Enterprise, Professional, Sandbox, Team } from './assets'
|
||||
import { Loading } from '../../base/icons/src/public/thought'
|
||||
import { useUnmountedRef } from 'ahooks'
|
||||
|
||||
type Props = {
|
||||
loc: string
|
||||
@ -35,6 +37,7 @@ const PlanComp: FC<Props> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const path = usePathname()
|
||||
const { userProfile } = useAppContext()
|
||||
const { plan, enableEducationPlan, allowRefreshEducationVerify, isEducationAccount } = useProviderContext()
|
||||
const isAboutToExpire = allowRefreshEducationVerify
|
||||
@ -61,17 +64,24 @@ const PlanComp: FC<Props> = ({
|
||||
})()
|
||||
|
||||
const [showModal, setShowModal] = React.useState(false)
|
||||
const { mutateAsync } = useEducationVerify()
|
||||
const { mutateAsync, isPending } = useEducationVerify()
|
||||
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
|
||||
const unmountedRef = useUnmountedRef()
|
||||
const handleVerify = () => {
|
||||
if (isPending) return
|
||||
mutateAsync().then((res) => {
|
||||
localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
|
||||
if (unmountedRef.current) return
|
||||
router.push(`/education-apply?token=${res.token}`)
|
||||
setShowAccountSettingModal(null)
|
||||
}).catch(() => {
|
||||
setShowModal(true)
|
||||
})
|
||||
}
|
||||
useEffect(() => {
|
||||
// setShowAccountSettingModal would prevent navigation
|
||||
if (path.startsWith('/education-apply'))
|
||||
setShowAccountSettingModal(null)
|
||||
}, [path, setShowAccountSettingModal])
|
||||
return (
|
||||
<div className='relative rounded-2xl border-[0.5px] border-effects-highlight-lightmode-off bg-background-section-burn'>
|
||||
<div className='p-6 pb-2'>
|
||||
@ -96,9 +106,10 @@ const PlanComp: FC<Props> = ({
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center gap-1'>
|
||||
{enableEducationPlan && (!isEducationAccount || isAboutToExpire) && (
|
||||
<Button variant='ghost' onClick={handleVerify}>
|
||||
<Button variant='ghost' onClick={handleVerify} disabled={isPending} >
|
||||
<RiGraduationCapLine className='mr-1 h-4 w-4' />
|
||||
{t('education.toVerified')}
|
||||
{isPending && <Loading className='ml-1 animate-spin-slow' />}
|
||||
</Button>
|
||||
)}
|
||||
{(plan.type as any) !== SelfHostedPlan.enterprise && (
|
||||
|
||||
@ -1,24 +0,0 @@
|
||||
import type { CrawlResultItem } from '@/models/datasets'
|
||||
|
||||
const result: CrawlResultItem[] = [
|
||||
{
|
||||
title: 'Start the frontend Docker container separately',
|
||||
content: 'Markdown 1',
|
||||
description: 'Description 1',
|
||||
source_url: 'https://example.com/1',
|
||||
},
|
||||
{
|
||||
title: 'Advanced Tool Integration',
|
||||
content: 'Markdown 2',
|
||||
description: 'Description 2',
|
||||
source_url: 'https://example.com/2',
|
||||
},
|
||||
{
|
||||
title: 'Local Source Code Start | English | Dify',
|
||||
content: 'Markdown 3',
|
||||
description: 'Description 3',
|
||||
source_url: 'https://example.com/3',
|
||||
},
|
||||
]
|
||||
|
||||
export default result
|
||||
@ -34,6 +34,7 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { useLogout } from '@/service/use-common'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { resetUser } from '@/app/components/base/amplitude/utils'
|
||||
|
||||
export default function AppSelector() {
|
||||
const itemClassName = `
|
||||
@ -53,7 +54,7 @@ export default function AppSelector() {
|
||||
const { mutateAsync: logout } = useLogout()
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
|
||||
resetUser()
|
||||
localStorage.removeItem('setup_status')
|
||||
// Tokens are now stored in cookies and cleared by backend
|
||||
|
||||
|
||||
@ -1,39 +1,28 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
useMarketplacePlugins,
|
||||
useMarketplacePluginsByCollectionId,
|
||||
} from '@/app/components/plugins/marketplace/hooks'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { getMarketplacePluginsByCollectionId } from '@/app/components/plugins/marketplace/utils'
|
||||
|
||||
export const useMarketplaceAllPlugins = (providers: any[], searchText: string) => {
|
||||
const exclude = useMemo(() => {
|
||||
return providers.map(provider => provider.plugin_id)
|
||||
}, [providers])
|
||||
const [collectionPlugins, setCollectionPlugins] = useState<Plugin[]>([])
|
||||
|
||||
const {
|
||||
plugins: collectionPlugins = [],
|
||||
isLoading: isCollectionLoading,
|
||||
} = useMarketplacePluginsByCollectionId('__datasource-settings-pinned-datasources')
|
||||
const {
|
||||
plugins,
|
||||
queryPlugins,
|
||||
queryPluginsWithDebounced,
|
||||
isLoading,
|
||||
isLoading: isPluginsLoading,
|
||||
} = useMarketplacePlugins()
|
||||
|
||||
const getCollectionPlugins = useCallback(async () => {
|
||||
const collectionPlugins = await getMarketplacePluginsByCollectionId('__datasource-settings-pinned-datasources')
|
||||
|
||||
setCollectionPlugins(collectionPlugins)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
getCollectionPlugins()
|
||||
}, [getCollectionPlugins])
|
||||
|
||||
useEffect(() => {
|
||||
if (searchText) {
|
||||
queryPluginsWithDebounced({
|
||||
@ -75,6 +64,6 @@ export const useMarketplaceAllPlugins = (providers: any[], searchText: string) =
|
||||
|
||||
return {
|
||||
plugins: allPlugins,
|
||||
isLoading,
|
||||
isLoading: isCollectionLoading || isPluginsLoading,
|
||||
}
|
||||
}
|
||||
|
||||
@ -217,6 +217,7 @@ export type ModelProvider = {
|
||||
url: TypeWithI18N
|
||||
}
|
||||
icon_small: TypeWithI18N
|
||||
icon_small_dark?: TypeWithI18N
|
||||
icon_large: TypeWithI18N
|
||||
background?: string
|
||||
supported_model_types: ModelTypeEnum[]
|
||||
@ -255,6 +256,7 @@ export type Model = {
|
||||
provider: string
|
||||
icon_large: TypeWithI18N
|
||||
icon_small: TypeWithI18N
|
||||
icon_small_dark?: TypeWithI18N
|
||||
label: TypeWithI18N
|
||||
models: ModelItem[]
|
||||
status: ModelStatusEnum
|
||||
|
||||
@ -33,10 +33,9 @@ import {
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import {
|
||||
useMarketplacePlugins,
|
||||
useMarketplacePluginsByCollectionId,
|
||||
} from '@/app/components/plugins/marketplace/hooks'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { getMarketplacePluginsByCollectionId } from '@/app/components/plugins/marketplace/utils'
|
||||
import { useModalContextSelector } from '@/context/modal-context'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST } from './provider-added-card'
|
||||
@ -255,25 +254,17 @@ export const useMarketplaceAllPlugins = (providers: ModelProvider[], searchText:
|
||||
const exclude = useMemo(() => {
|
||||
return providers.map(provider => provider.provider.replace(/(.+)\/([^/]+)$/, '$1'))
|
||||
}, [providers])
|
||||
const [collectionPlugins, setCollectionPlugins] = useState<Plugin[]>([])
|
||||
|
||||
const {
|
||||
plugins: collectionPlugins = [],
|
||||
isLoading: isCollectionLoading,
|
||||
} = useMarketplacePluginsByCollectionId('__model-settings-pinned-models')
|
||||
const {
|
||||
plugins,
|
||||
queryPlugins,
|
||||
queryPluginsWithDebounced,
|
||||
isLoading,
|
||||
isLoading: isPluginsLoading,
|
||||
} = useMarketplacePlugins()
|
||||
|
||||
const getCollectionPlugins = useCallback(async () => {
|
||||
const collectionPlugins = await getMarketplacePluginsByCollectionId('__model-settings-pinned-models')
|
||||
|
||||
setCollectionPlugins(collectionPlugins)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
getCollectionPlugins()
|
||||
}, [getCollectionPlugins])
|
||||
|
||||
useEffect(() => {
|
||||
if (searchText) {
|
||||
queryPluginsWithDebounced({
|
||||
@ -315,7 +306,7 @@ export const useMarketplaceAllPlugins = (providers: ModelProvider[], searchText:
|
||||
|
||||
return {
|
||||
plugins: allPlugins,
|
||||
isLoading,
|
||||
isLoading: isCollectionLoading || isPluginsLoading,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -6,8 +6,10 @@ import type {
|
||||
import { useLanguage } from '../hooks'
|
||||
import { Group } from '@/app/components/base/icons/src/vender/other'
|
||||
import { OpenaiBlue, OpenaiTeal, OpenaiViolet, OpenaiYellow } from '@/app/components/base/icons/src/public/llm'
|
||||
import cn from '@/utils/classnames'
|
||||
import { renderI18nObject } from '@/i18n-config'
|
||||
import { Theme } from '@/types/app'
|
||||
import cn from '@/utils/classnames'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
|
||||
type ModelIconProps = {
|
||||
provider?: Model | ModelProvider
|
||||
@ -23,6 +25,7 @@ const ModelIcon: FC<ModelIconProps> = ({
|
||||
iconClassName,
|
||||
isDeprecated = false,
|
||||
}) => {
|
||||
const { theme } = useTheme()
|
||||
const language = useLanguage()
|
||||
if (provider?.provider && ['openai', 'langgenius/openai/openai'].includes(provider.provider) && modelName?.startsWith('o'))
|
||||
return <div className='flex items-center justify-center'><OpenaiYellow className={cn('h-5 w-5', className)} /></div>
|
||||
@ -36,7 +39,16 @@ const ModelIcon: FC<ModelIconProps> = ({
|
||||
if (provider?.icon_small) {
|
||||
return (
|
||||
<div className={cn('flex h-5 w-5 items-center justify-center', isDeprecated && 'opacity-50', className)}>
|
||||
<img alt='model-icon' src={renderI18nObject(provider.icon_small, language)} className={iconClassName} />
|
||||
<img
|
||||
alt='model-icon'
|
||||
src={renderI18nObject(
|
||||
theme === Theme.dark && provider.icon_small_dark
|
||||
? provider.icon_small_dark
|
||||
: provider.icon_small,
|
||||
language,
|
||||
)}
|
||||
className={iconClassName}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -40,7 +40,12 @@ const ProviderIcon: FC<ProviderIconProps> = ({
|
||||
<div className={cn('inline-flex items-center gap-2', className)}>
|
||||
<img
|
||||
alt='provider-icon'
|
||||
src={renderI18nObject(provider.icon_small, language)}
|
||||
src={renderI18nObject(
|
||||
theme === Theme.dark && provider.icon_small_dark
|
||||
? provider.icon_small_dark
|
||||
: provider.icon_small,
|
||||
language,
|
||||
)}
|
||||
className='h-6 w-6'
|
||||
/>
|
||||
<div className='system-md-semibold text-text-primary'>
|
||||
|
||||
@ -6,6 +6,8 @@ import { getLanguage } from '@/i18n-config/language'
|
||||
import cn from '@/utils/classnames'
|
||||
import { RiAlertFill } from '@remixicon/react'
|
||||
import React from 'react'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { Theme } from '@/types/app'
|
||||
import Partner from '../base/badges/partner'
|
||||
import Verified from '../base/badges/verified'
|
||||
import Icon from '../card/base/card-icon'
|
||||
@ -50,7 +52,9 @@ const Card = ({
|
||||
const locale = localeFromProps ? getLanguage(localeFromProps) : defaultLocale
|
||||
const { t } = useMixedTranslation(localeFromProps)
|
||||
const { categoriesMap } = useCategories(t, true)
|
||||
const { category, type, name, org, label, brief, icon, verified, badges = [] } = payload
|
||||
const { category, type, name, org, label, brief, icon, icon_dark, verified, badges = [] } = payload
|
||||
const { theme } = useTheme()
|
||||
const iconSrc = theme === Theme.dark && icon_dark ? icon_dark : icon
|
||||
const getLocalizedText = (obj: Record<string, string> | undefined) =>
|
||||
obj ? renderI18nObject(obj, locale) : ''
|
||||
const isPartner = badges.includes('partner')
|
||||
@ -71,7 +75,7 @@ const Card = ({
|
||||
{!hideCornerMark && <CornerMark text={categoriesMap[type === 'bundle' ? type : category]?.label} />}
|
||||
{/* Header */}
|
||||
<div className="flex">
|
||||
<Icon src={icon} installed={installed} installFailed={installFailed} />
|
||||
<Icon src={iconSrc} installed={installed} installFailed={installFailed} />
|
||||
<div className="ml-3 w-0 grow">
|
||||
<div className="flex h-5 items-center">
|
||||
<Title title={getLocalizedText(label)} />
|
||||
|
||||
@ -64,10 +64,12 @@ const InstallFromLocalPackage: React.FC<InstallFromLocalPackageProps> = ({
|
||||
uniqueIdentifier,
|
||||
} = result
|
||||
const icon = await getIconUrl(manifest!.icon)
|
||||
const iconDark = manifest.icon_dark ? await getIconUrl(manifest.icon_dark) : undefined
|
||||
setUniqueIdentifier(uniqueIdentifier)
|
||||
setManifest({
|
||||
...manifest,
|
||||
icon,
|
||||
icon_dark: iconDark,
|
||||
})
|
||||
setStep(InstallStep.readyToInstall)
|
||||
}, [getIconUrl])
|
||||
|
||||
@ -17,6 +17,7 @@ export const pluginManifestToCardPluginProps = (pluginManifest: PluginDeclaratio
|
||||
brief: pluginManifest.description,
|
||||
description: pluginManifest.description,
|
||||
icon: pluginManifest.icon,
|
||||
icon_dark: pluginManifest.icon_dark,
|
||||
verified: pluginManifest.verified,
|
||||
introduction: '',
|
||||
repository: '',
|
||||
|
||||
@ -2,3 +2,5 @@ export const DEFAULT_SORT = {
|
||||
sortBy: 'install_count',
|
||||
sortOrder: 'DESC',
|
||||
}
|
||||
|
||||
export const SCROLL_BOTTOM_THRESHOLD = 100
|
||||
|
||||
@ -41,8 +41,6 @@ import { useInstalledPluginList } from '@/service/use-plugins'
|
||||
import { debounce, noop } from 'lodash-es'
|
||||
|
||||
export type MarketplaceContextValue = {
|
||||
intersected: boolean
|
||||
setIntersected: (intersected: boolean) => void
|
||||
searchPluginText: string
|
||||
handleSearchPluginTextChange: (text: string) => void
|
||||
filterPluginTags: string[]
|
||||
@ -50,7 +48,7 @@ export type MarketplaceContextValue = {
|
||||
activePluginType: string
|
||||
handleActivePluginTypeChange: (type: string) => void
|
||||
page: number
|
||||
handlePageChange: (page: number) => void
|
||||
handlePageChange: () => void
|
||||
plugins?: Plugin[]
|
||||
pluginsTotal?: number
|
||||
resetPlugins: () => void
|
||||
@ -67,8 +65,6 @@ export type MarketplaceContextValue = {
|
||||
}
|
||||
|
||||
export const MarketplaceContext = createContext<MarketplaceContextValue>({
|
||||
intersected: true,
|
||||
setIntersected: noop,
|
||||
searchPluginText: '',
|
||||
handleSearchPluginTextChange: noop,
|
||||
filterPluginTags: [],
|
||||
@ -121,15 +117,12 @@ export const MarketplaceContextProvider = ({
|
||||
const hasValidTags = !!tagsFromSearchParams.length
|
||||
const hasValidCategory = getValidCategoryKeys(searchParams?.category)
|
||||
const categoryFromSearchParams = hasValidCategory || PLUGIN_TYPE_SEARCH_MAP.all
|
||||
const [intersected, setIntersected] = useState(true)
|
||||
const [searchPluginText, setSearchPluginText] = useState(queryFromSearchParams)
|
||||
const searchPluginTextRef = useRef(searchPluginText)
|
||||
const [filterPluginTags, setFilterPluginTags] = useState<string[]>(tagsFromSearchParams)
|
||||
const filterPluginTagsRef = useRef(filterPluginTags)
|
||||
const [activePluginType, setActivePluginType] = useState(categoryFromSearchParams)
|
||||
const activePluginTypeRef = useRef(activePluginType)
|
||||
const [page, setPage] = useState(1)
|
||||
const pageRef = useRef(page)
|
||||
const [sort, setSort] = useState(DEFAULT_SORT)
|
||||
const sortRef = useRef(sort)
|
||||
const {
|
||||
@ -149,7 +142,11 @@ export const MarketplaceContextProvider = ({
|
||||
queryPluginsWithDebounced,
|
||||
cancelQueryPluginsWithDebounced,
|
||||
isLoading: isPluginsLoading,
|
||||
fetchNextPage: fetchNextPluginsPage,
|
||||
hasNextPage: hasNextPluginsPage,
|
||||
page: pluginsPage,
|
||||
} = useMarketplacePlugins()
|
||||
const page = Math.max(pluginsPage || 0, 1)
|
||||
|
||||
useEffect(() => {
|
||||
if (queryFromSearchParams || hasValidTags || hasValidCategory) {
|
||||
@ -160,7 +157,6 @@ export const MarketplaceContextProvider = ({
|
||||
sortBy: sortRef.current.sortBy,
|
||||
sortOrder: sortRef.current.sortOrder,
|
||||
type: getMarketplaceListFilterType(activePluginTypeRef.current),
|
||||
page: pageRef.current,
|
||||
})
|
||||
const url = new URL(window.location.href)
|
||||
if (searchParams?.language)
|
||||
@ -221,7 +217,6 @@ export const MarketplaceContextProvider = ({
|
||||
sortOrder: sortRef.current.sortOrder,
|
||||
exclude,
|
||||
type: getMarketplaceListFilterType(activePluginTypeRef.current),
|
||||
page: pageRef.current,
|
||||
})
|
||||
}
|
||||
else {
|
||||
@ -233,7 +228,6 @@ export const MarketplaceContextProvider = ({
|
||||
sortOrder: sortRef.current.sortOrder,
|
||||
exclude,
|
||||
type: getMarketplaceListFilterType(activePluginTypeRef.current),
|
||||
page: pageRef.current,
|
||||
})
|
||||
}
|
||||
}, [exclude, queryPluginsWithDebounced, queryPlugins, handleUpdateSearchParams])
|
||||
@ -252,8 +246,6 @@ export const MarketplaceContextProvider = ({
|
||||
const handleSearchPluginTextChange = useCallback((text: string) => {
|
||||
setSearchPluginText(text)
|
||||
searchPluginTextRef.current = text
|
||||
setPage(1)
|
||||
pageRef.current = 1
|
||||
|
||||
handleQuery(true)
|
||||
}, [handleQuery])
|
||||
@ -261,8 +253,6 @@ export const MarketplaceContextProvider = ({
|
||||
const handleFilterPluginTagsChange = useCallback((tags: string[]) => {
|
||||
setFilterPluginTags(tags)
|
||||
filterPluginTagsRef.current = tags
|
||||
setPage(1)
|
||||
pageRef.current = 1
|
||||
|
||||
handleQuery()
|
||||
}, [handleQuery])
|
||||
@ -270,8 +260,6 @@ export const MarketplaceContextProvider = ({
|
||||
const handleActivePluginTypeChange = useCallback((type: string) => {
|
||||
setActivePluginType(type)
|
||||
activePluginTypeRef.current = type
|
||||
setPage(1)
|
||||
pageRef.current = 1
|
||||
|
||||
handleQuery()
|
||||
}, [handleQuery])
|
||||
@ -279,20 +267,14 @@ export const MarketplaceContextProvider = ({
|
||||
const handleSortChange = useCallback((sort: PluginsSort) => {
|
||||
setSort(sort)
|
||||
sortRef.current = sort
|
||||
setPage(1)
|
||||
pageRef.current = 1
|
||||
|
||||
handleQueryPlugins()
|
||||
}, [handleQueryPlugins])
|
||||
|
||||
const handlePageChange = useCallback(() => {
|
||||
if (pluginsTotal && plugins && pluginsTotal > plugins.length) {
|
||||
setPage(pageRef.current + 1)
|
||||
pageRef.current++
|
||||
|
||||
handleQueryPlugins()
|
||||
}
|
||||
}, [handleQueryPlugins, plugins, pluginsTotal])
|
||||
if (hasNextPluginsPage)
|
||||
fetchNextPluginsPage()
|
||||
}, [fetchNextPluginsPage, hasNextPluginsPage])
|
||||
|
||||
const handleMoreClick = useCallback((searchParams: SearchParamsFromCollection) => {
|
||||
setSearchPluginText(searchParams?.query || '')
|
||||
@ -305,9 +287,6 @@ export const MarketplaceContextProvider = ({
|
||||
sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy,
|
||||
sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder,
|
||||
}
|
||||
setPage(1)
|
||||
pageRef.current = 1
|
||||
|
||||
handleQueryPlugins()
|
||||
}, [handleQueryPlugins])
|
||||
|
||||
@ -316,8 +295,6 @@ export const MarketplaceContextProvider = ({
|
||||
return (
|
||||
<MarketplaceContext.Provider
|
||||
value={{
|
||||
intersected,
|
||||
setIntersected,
|
||||
searchPluginText,
|
||||
handleSearchPluginTextChange,
|
||||
filterPluginTags,
|
||||
|
||||
@ -3,6 +3,11 @@ import {
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import type {
|
||||
@ -16,39 +21,41 @@ import type {
|
||||
import {
|
||||
getFormattedPlugin,
|
||||
getMarketplaceCollectionsAndPlugins,
|
||||
getMarketplacePluginsByCollectionId,
|
||||
} from './utils'
|
||||
import { SCROLL_BOTTOM_THRESHOLD } from './constants'
|
||||
import i18n from '@/i18n-config/i18next-config'
|
||||
import {
|
||||
useMutationPluginsFromMarketplace,
|
||||
} from '@/service/use-plugins'
|
||||
import { postMarketplace } from '@/service/base'
|
||||
import type { PluginsFromMarketplaceResponse } from '@/app/components/plugins/types'
|
||||
|
||||
export const useMarketplaceCollectionsAndPlugins = () => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
const [marketplaceCollections, setMarketplaceCollections] = useState<MarketplaceCollection[]>()
|
||||
const [marketplaceCollectionPluginsMap, setMarketplaceCollectionPluginsMap] = useState<Record<string, Plugin[]>>()
|
||||
const [queryParams, setQueryParams] = useState<CollectionsAndPluginsSearchParams>()
|
||||
const [marketplaceCollectionsOverride, setMarketplaceCollections] = useState<MarketplaceCollection[]>()
|
||||
const [marketplaceCollectionPluginsMapOverride, setMarketplaceCollectionPluginsMap] = useState<Record<string, Plugin[]>>()
|
||||
|
||||
const queryMarketplaceCollectionsAndPlugins = useCallback(async (query?: CollectionsAndPluginsSearchParams) => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setIsSuccess(false)
|
||||
const { marketplaceCollections, marketplaceCollectionPluginsMap } = await getMarketplaceCollectionsAndPlugins(query)
|
||||
setIsLoading(false)
|
||||
setIsSuccess(true)
|
||||
setMarketplaceCollections(marketplaceCollections)
|
||||
setMarketplaceCollectionPluginsMap(marketplaceCollectionPluginsMap)
|
||||
}
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
catch (e) {
|
||||
setIsLoading(false)
|
||||
setIsSuccess(false)
|
||||
}
|
||||
const {
|
||||
data,
|
||||
isFetching,
|
||||
isSuccess,
|
||||
isPending,
|
||||
} = useQuery({
|
||||
queryKey: ['marketplaceCollectionsAndPlugins', queryParams],
|
||||
queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(queryParams, { signal }),
|
||||
enabled: queryParams !== undefined,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
gcTime: 1000 * 60 * 10,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const queryMarketplaceCollectionsAndPlugins = useCallback((query?: CollectionsAndPluginsSearchParams) => {
|
||||
setQueryParams(query ? { ...query } : {})
|
||||
}, [])
|
||||
const isLoading = !!queryParams && (isFetching || isPending)
|
||||
|
||||
return {
|
||||
marketplaceCollections,
|
||||
marketplaceCollections: marketplaceCollectionsOverride ?? data?.marketplaceCollections,
|
||||
setMarketplaceCollections,
|
||||
marketplaceCollectionPluginsMap,
|
||||
marketplaceCollectionPluginsMap: marketplaceCollectionPluginsMapOverride ?? data?.marketplaceCollectionPluginsMap,
|
||||
setMarketplaceCollectionPluginsMap,
|
||||
queryMarketplaceCollectionsAndPlugins,
|
||||
isLoading,
|
||||
@ -56,37 +63,128 @@ export const useMarketplaceCollectionsAndPlugins = () => {
|
||||
}
|
||||
}
|
||||
|
||||
export const useMarketplacePlugins = () => {
|
||||
export const useMarketplacePluginsByCollectionId = (
|
||||
collectionId?: string,
|
||||
query?: CollectionsAndPluginsSearchParams,
|
||||
) => {
|
||||
const {
|
||||
data,
|
||||
mutateAsync,
|
||||
reset,
|
||||
isFetching,
|
||||
isSuccess,
|
||||
isPending,
|
||||
} = useMutationPluginsFromMarketplace()
|
||||
} = useQuery({
|
||||
queryKey: ['marketplaceCollectionPlugins', collectionId, query],
|
||||
queryFn: ({ signal }) => {
|
||||
if (!collectionId)
|
||||
return Promise.resolve<Plugin[]>([])
|
||||
return getMarketplacePluginsByCollectionId(collectionId, query, { signal })
|
||||
},
|
||||
enabled: !!collectionId,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
gcTime: 1000 * 60 * 10,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const [prevPlugins, setPrevPlugins] = useState<Plugin[] | undefined>()
|
||||
return {
|
||||
plugins: data || [],
|
||||
isLoading: !!collectionId && (isFetching || isPending),
|
||||
isSuccess,
|
||||
}
|
||||
}
|
||||
|
||||
export const useMarketplacePlugins = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const [queryParams, setQueryParams] = useState<PluginsSearchParams>()
|
||||
|
||||
const normalizeParams = useCallback((pluginsSearchParams: PluginsSearchParams) => {
|
||||
const pageSize = pluginsSearchParams.pageSize || 40
|
||||
|
||||
return {
|
||||
...pluginsSearchParams,
|
||||
pageSize,
|
||||
}
|
||||
}, [])
|
||||
|
||||
const marketplacePluginsQuery = useInfiniteQuery({
|
||||
queryKey: ['marketplacePlugins', queryParams],
|
||||
queryFn: async ({ pageParam = 1, signal }) => {
|
||||
if (!queryParams) {
|
||||
return {
|
||||
plugins: [] as Plugin[],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 40,
|
||||
}
|
||||
}
|
||||
|
||||
const params = normalizeParams(queryParams)
|
||||
const {
|
||||
query,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
category,
|
||||
tags,
|
||||
exclude,
|
||||
type,
|
||||
pageSize,
|
||||
} = params
|
||||
const pluginOrBundle = type === 'bundle' ? 'bundles' : 'plugins'
|
||||
|
||||
try {
|
||||
const res = await postMarketplace<{ data: PluginsFromMarketplaceResponse }>(`/${pluginOrBundle}/search/advanced`, {
|
||||
body: {
|
||||
page: pageParam,
|
||||
page_size: pageSize,
|
||||
query,
|
||||
sort_by: sortBy,
|
||||
sort_order: sortOrder,
|
||||
category: category !== 'all' ? category : '',
|
||||
tags,
|
||||
exclude,
|
||||
type,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
const resPlugins = res.data.bundles || res.data.plugins || []
|
||||
|
||||
return {
|
||||
plugins: resPlugins.map(plugin => getFormattedPlugin(plugin)),
|
||||
total: res.data.total,
|
||||
page: pageParam,
|
||||
pageSize,
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return {
|
||||
plugins: [],
|
||||
total: 0,
|
||||
page: pageParam,
|
||||
pageSize,
|
||||
}
|
||||
}
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
const nextPage = lastPage.page + 1
|
||||
const loaded = lastPage.page * lastPage.pageSize
|
||||
return loaded < (lastPage.total || 0) ? nextPage : undefined
|
||||
},
|
||||
initialPageParam: 1,
|
||||
enabled: !!queryParams,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
gcTime: 1000 * 60 * 10,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const resetPlugins = useCallback(() => {
|
||||
reset()
|
||||
setPrevPlugins(undefined)
|
||||
}, [reset])
|
||||
setQueryParams(undefined)
|
||||
queryClient.removeQueries({
|
||||
queryKey: ['marketplacePlugins'],
|
||||
})
|
||||
}, [queryClient])
|
||||
|
||||
const handleUpdatePlugins = useCallback((pluginsSearchParams: PluginsSearchParams) => {
|
||||
mutateAsync(pluginsSearchParams).then((res) => {
|
||||
const currentPage = pluginsSearchParams.page || 1
|
||||
const resPlugins = res.data.bundles || res.data.plugins
|
||||
if (currentPage > 1) {
|
||||
setPrevPlugins(prevPlugins => [...(prevPlugins || []), ...resPlugins.map((plugin) => {
|
||||
return getFormattedPlugin(plugin)
|
||||
})])
|
||||
}
|
||||
else {
|
||||
setPrevPlugins(resPlugins.map((plugin) => {
|
||||
return getFormattedPlugin(plugin)
|
||||
}))
|
||||
}
|
||||
})
|
||||
}, [mutateAsync])
|
||||
setQueryParams(normalizeParams(pluginsSearchParams))
|
||||
}, [normalizeParams])
|
||||
|
||||
const { run: queryPluginsWithDebounced, cancel: cancelQueryPluginsWithDebounced } = useDebounceFn((pluginsSearchParams: PluginsSearchParams) => {
|
||||
handleUpdatePlugins(pluginsSearchParams)
|
||||
@ -94,14 +192,29 @@ export const useMarketplacePlugins = () => {
|
||||
wait: 500,
|
||||
})
|
||||
|
||||
const hasQuery = !!queryParams
|
||||
const hasData = marketplacePluginsQuery.data !== undefined
|
||||
const plugins = hasQuery && hasData
|
||||
? marketplacePluginsQuery.data.pages.flatMap(page => page.plugins)
|
||||
: undefined
|
||||
const total = hasQuery && hasData ? marketplacePluginsQuery.data.pages?.[0]?.total : undefined
|
||||
const isPluginsLoading = hasQuery && (
|
||||
marketplacePluginsQuery.isPending
|
||||
|| (marketplacePluginsQuery.isFetching && !marketplacePluginsQuery.data)
|
||||
)
|
||||
|
||||
return {
|
||||
plugins: prevPlugins,
|
||||
total: data?.data?.total,
|
||||
plugins,
|
||||
total,
|
||||
resetPlugins,
|
||||
queryPlugins: handleUpdatePlugins,
|
||||
queryPluginsWithDebounced,
|
||||
cancelQueryPluginsWithDebounced,
|
||||
isLoading: isPending,
|
||||
isLoading: isPluginsLoading,
|
||||
isFetchingNextPage: marketplacePluginsQuery.isFetchingNextPage,
|
||||
hasNextPage: marketplacePluginsQuery.hasNextPage,
|
||||
fetchNextPage: marketplacePluginsQuery.fetchNextPage,
|
||||
page: marketplacePluginsQuery.data?.pages?.length || (marketplacePluginsQuery.isPending && hasQuery ? 1 : 0),
|
||||
}
|
||||
}
|
||||
|
||||
@ -131,7 +244,7 @@ export const useMarketplaceContainerScroll = (
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
} = target
|
||||
if (scrollTop + clientHeight >= scrollHeight - 5 && scrollTop > 0)
|
||||
if (scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD && scrollTop > 0)
|
||||
callback()
|
||||
}, [callback])
|
||||
|
||||
@ -146,34 +259,3 @@ export const useMarketplaceContainerScroll = (
|
||||
}
|
||||
}, [handleScroll])
|
||||
}
|
||||
|
||||
export const useSearchBoxAutoAnimate = (searchBoxAutoAnimate?: boolean) => {
|
||||
const [searchBoxCanAnimate, setSearchBoxCanAnimate] = useState(true)
|
||||
|
||||
const handleSearchBoxCanAnimateChange = useCallback(() => {
|
||||
if (!searchBoxAutoAnimate) {
|
||||
const clientWidth = document.documentElement.clientWidth
|
||||
|
||||
if (clientWidth < 1400)
|
||||
setSearchBoxCanAnimate(false)
|
||||
else
|
||||
setSearchBoxCanAnimate(true)
|
||||
}
|
||||
}, [searchBoxAutoAnimate])
|
||||
|
||||
useEffect(() => {
|
||||
handleSearchBoxCanAnimateChange()
|
||||
}, [handleSearchBoxCanAnimateChange])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', handleSearchBoxCanAnimateChange)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleSearchBoxCanAnimateChange)
|
||||
}
|
||||
}, [handleSearchBoxCanAnimateChange])
|
||||
|
||||
return {
|
||||
searchBoxCanAnimate,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,37 +1,32 @@
|
||||
import { MarketplaceContextProvider } from './context'
|
||||
import Description from './description'
|
||||
import IntersectionLine from './intersection-line'
|
||||
import SearchBoxWrapper from './search-box/search-box-wrapper'
|
||||
import PluginTypeSwitch from './plugin-type-switch'
|
||||
import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper'
|
||||
import ListWrapper from './list/list-wrapper'
|
||||
import type { SearchParams } from './types'
|
||||
import type { MarketplaceCollection, SearchParams } from './types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { getMarketplaceCollectionsAndPlugins } from './utils'
|
||||
import { TanstackQueryInitializer } from '@/context/query-client'
|
||||
|
||||
type MarketplaceProps = {
|
||||
locale: string
|
||||
searchBoxAutoAnimate?: boolean
|
||||
showInstallButton?: boolean
|
||||
shouldExclude?: boolean
|
||||
searchParams?: SearchParams
|
||||
pluginTypeSwitchClassName?: string
|
||||
intersectionContainerId?: string
|
||||
scrollContainerId?: string
|
||||
showSearchParams?: boolean
|
||||
}
|
||||
const Marketplace = async ({
|
||||
locale,
|
||||
searchBoxAutoAnimate = true,
|
||||
showInstallButton = true,
|
||||
shouldExclude,
|
||||
searchParams,
|
||||
pluginTypeSwitchClassName,
|
||||
intersectionContainerId,
|
||||
scrollContainerId,
|
||||
showSearchParams = true,
|
||||
}: MarketplaceProps) => {
|
||||
let marketplaceCollections: any = []
|
||||
let marketplaceCollectionPluginsMap = {}
|
||||
let marketplaceCollections: MarketplaceCollection[] = []
|
||||
let marketplaceCollectionPluginsMap: Record<string, Plugin[]> = {}
|
||||
if (!shouldExclude) {
|
||||
const marketplaceCollectionsAndPluginsData = await getMarketplaceCollectionsAndPlugins()
|
||||
marketplaceCollections = marketplaceCollectionsAndPluginsData.marketplaceCollections
|
||||
@ -47,15 +42,9 @@ const Marketplace = async ({
|
||||
showSearchParams={showSearchParams}
|
||||
>
|
||||
<Description locale={locale} />
|
||||
<IntersectionLine intersectionContainerId={intersectionContainerId} />
|
||||
<SearchBoxWrapper
|
||||
<StickySearchAndSwitchWrapper
|
||||
locale={locale}
|
||||
searchBoxAutoAnimate={searchBoxAutoAnimate}
|
||||
/>
|
||||
<PluginTypeSwitch
|
||||
locale={locale}
|
||||
className={pluginTypeSwitchClassName}
|
||||
searchBoxAutoAnimate={searchBoxAutoAnimate}
|
||||
pluginTypeSwitchClassName={pluginTypeSwitchClassName}
|
||||
showSearchParams={showSearchParams}
|
||||
/>
|
||||
<ListWrapper
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useMarketplaceContext } from '@/app/components/plugins/marketplace/context'
|
||||
|
||||
export const useScrollIntersection = (
|
||||
anchorRef: React.RefObject<HTMLDivElement | null>,
|
||||
intersectionContainerId = 'marketplace-container',
|
||||
) => {
|
||||
const intersected = useMarketplaceContext(v => v.intersected)
|
||||
const setIntersected = useMarketplaceContext(v => v.setIntersected)
|
||||
|
||||
useEffect(() => {
|
||||
const container = document.getElementById(intersectionContainerId)
|
||||
let observer: IntersectionObserver | undefined
|
||||
if (container && anchorRef.current) {
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
const isIntersecting = entries[0].isIntersecting
|
||||
|
||||
if (isIntersecting && !intersected)
|
||||
setIntersected(true)
|
||||
|
||||
if (!isIntersecting && intersected)
|
||||
setIntersected(false)
|
||||
}, {
|
||||
root: container,
|
||||
})
|
||||
observer.observe(anchorRef.current)
|
||||
}
|
||||
return () => observer?.disconnect()
|
||||
}, [anchorRef, intersected, setIntersected, intersectionContainerId])
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useRef } from 'react'
|
||||
import { useScrollIntersection } from './hooks'
|
||||
|
||||
type IntersectionLineProps = {
|
||||
intersectionContainerId?: string
|
||||
}
|
||||
const IntersectionLine = ({
|
||||
intersectionContainerId,
|
||||
}: IntersectionLineProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useScrollIntersection(ref, intersectionContainerId)
|
||||
|
||||
return (
|
||||
<div ref={ref} className='mb-4 h-px shrink-0 bg-transparent'></div>
|
||||
)
|
||||
}
|
||||
|
||||
export default IntersectionLine
|
||||
@ -28,13 +28,20 @@ const ListWrapper = ({
|
||||
const isLoading = useMarketplaceContext(v => v.isLoading)
|
||||
const isSuccessCollections = useMarketplaceContext(v => v.isSuccessCollections)
|
||||
const handleQueryPlugins = useMarketplaceContext(v => v.handleQueryPlugins)
|
||||
const searchPluginText = useMarketplaceContext(v => v.searchPluginText)
|
||||
const filterPluginTags = useMarketplaceContext(v => v.filterPluginTags)
|
||||
const page = useMarketplaceContext(v => v.page)
|
||||
const handleMoreClick = useMarketplaceContext(v => v.handleMoreClick)
|
||||
|
||||
useEffect(() => {
|
||||
if (!marketplaceCollectionsFromClient?.length && isSuccessCollections)
|
||||
if (
|
||||
!marketplaceCollectionsFromClient?.length
|
||||
&& isSuccessCollections
|
||||
&& !searchPluginText
|
||||
&& !filterPluginTags.length
|
||||
)
|
||||
handleQueryPlugins()
|
||||
}, [handleQueryPlugins, marketplaceCollections, marketplaceCollectionsFromClient, isSuccessCollections])
|
||||
}, [handleQueryPlugins, marketplaceCollections, marketplaceCollectionsFromClient, isSuccessCollections, searchPluginText, filterPluginTags])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@ -12,10 +12,7 @@ import {
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { PluginCategoryEnum } from '../types'
|
||||
import { useMarketplaceContext } from './context'
|
||||
import {
|
||||
useMixedTranslation,
|
||||
useSearchBoxAutoAnimate,
|
||||
} from './hooks'
|
||||
import { useMixedTranslation } from './hooks'
|
||||
|
||||
export const PLUGIN_TYPE_SEARCH_MAP = {
|
||||
all: 'all',
|
||||
@ -30,19 +27,16 @@ export const PLUGIN_TYPE_SEARCH_MAP = {
|
||||
type PluginTypeSwitchProps = {
|
||||
locale?: string
|
||||
className?: string
|
||||
searchBoxAutoAnimate?: boolean
|
||||
showSearchParams?: boolean
|
||||
}
|
||||
const PluginTypeSwitch = ({
|
||||
locale,
|
||||
className,
|
||||
searchBoxAutoAnimate,
|
||||
showSearchParams,
|
||||
}: PluginTypeSwitchProps) => {
|
||||
const { t } = useMixedTranslation(locale)
|
||||
const activePluginType = useMarketplaceContext(s => s.activePluginType)
|
||||
const handleActivePluginTypeChange = useMarketplaceContext(s => s.handleActivePluginTypeChange)
|
||||
const { searchBoxCanAnimate } = useSearchBoxAutoAnimate(searchBoxAutoAnimate)
|
||||
|
||||
const options = [
|
||||
{
|
||||
@ -105,7 +99,6 @@ const PluginTypeSwitch = ({
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center justify-center space-x-2 bg-background-body py-3',
|
||||
searchBoxCanAnimate && 'sticky top-[56px] z-10',
|
||||
className,
|
||||
)}>
|
||||
{
|
||||
|
||||
@ -1,36 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import { useMarketplaceContext } from '../context'
|
||||
import {
|
||||
useMixedTranslation,
|
||||
useSearchBoxAutoAnimate,
|
||||
} from '../hooks'
|
||||
import { useMixedTranslation } from '../hooks'
|
||||
import SearchBox from './index'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type SearchBoxWrapperProps = {
|
||||
locale?: string
|
||||
searchBoxAutoAnimate?: boolean
|
||||
}
|
||||
const SearchBoxWrapper = ({
|
||||
locale,
|
||||
searchBoxAutoAnimate,
|
||||
}: SearchBoxWrapperProps) => {
|
||||
const { t } = useMixedTranslation(locale)
|
||||
const intersected = useMarketplaceContext(v => v.intersected)
|
||||
const searchPluginText = useMarketplaceContext(v => v.searchPluginText)
|
||||
const handleSearchPluginTextChange = useMarketplaceContext(v => v.handleSearchPluginTextChange)
|
||||
const filterPluginTags = useMarketplaceContext(v => v.filterPluginTags)
|
||||
const handleFilterPluginTagsChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange)
|
||||
const { searchBoxCanAnimate } = useSearchBoxAutoAnimate(searchBoxAutoAnimate)
|
||||
|
||||
return (
|
||||
<SearchBox
|
||||
wrapperClassName={cn(
|
||||
'z-[0] mx-auto w-[640px] shrink-0',
|
||||
searchBoxCanAnimate && 'sticky top-3 z-[11]',
|
||||
!intersected && searchBoxCanAnimate && 'w-[508px] transition-[width] duration-300',
|
||||
)}
|
||||
wrapperClassName='z-[11] mx-auto w-[640px] shrink-0'
|
||||
inputClassName='w-full'
|
||||
search={searchPluginText}
|
||||
onSearchChange={handleSearchPluginTextChange}
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import SearchBoxWrapper from './search-box/search-box-wrapper'
|
||||
import PluginTypeSwitch from './plugin-type-switch'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type StickySearchAndSwitchWrapperProps = {
|
||||
locale?: string
|
||||
pluginTypeSwitchClassName?: string
|
||||
showSearchParams?: boolean
|
||||
}
|
||||
|
||||
const StickySearchAndSwitchWrapper = ({
|
||||
locale,
|
||||
pluginTypeSwitchClassName,
|
||||
showSearchParams,
|
||||
}: StickySearchAndSwitchWrapperProps) => {
|
||||
const hasCustomTopClass = pluginTypeSwitchClassName?.includes('top-')
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-4 bg-background-body',
|
||||
hasCustomTopClass && 'sticky z-10',
|
||||
pluginTypeSwitchClassName,
|
||||
)}
|
||||
>
|
||||
<SearchBoxWrapper locale={locale} />
|
||||
<PluginTypeSwitch
|
||||
locale={locale}
|
||||
showSearchParams={showSearchParams}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StickySearchAndSwitchWrapper
|
||||
@ -13,6 +13,14 @@ import {
|
||||
} from '@/config'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
|
||||
type MarketplaceFetchOptions = {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
const getMarketplaceHeaders = () => new Headers({
|
||||
'X-Dify-Version': !IS_MARKETPLACE ? APP_VERSION : '999.0.0',
|
||||
})
|
||||
|
||||
export const getPluginIconInMarketplace = (plugin: Plugin) => {
|
||||
if (plugin.type === 'bundle')
|
||||
return `${MARKETPLACE_API_PREFIX}/bundles/${plugin.org}/${plugin.name}/icon`
|
||||
@ -46,20 +54,23 @@ export const getPluginDetailLinkInMarketplace = (plugin: Plugin) => {
|
||||
return `/plugins/${plugin.org}/${plugin.name}`
|
||||
}
|
||||
|
||||
export const getMarketplacePluginsByCollectionId = async (collectionId: string, query?: CollectionsAndPluginsSearchParams) => {
|
||||
let plugins: Plugin[]
|
||||
export const getMarketplacePluginsByCollectionId = async (
|
||||
collectionId: string,
|
||||
query?: CollectionsAndPluginsSearchParams,
|
||||
options?: MarketplaceFetchOptions,
|
||||
) => {
|
||||
let plugins: Plugin[] = []
|
||||
|
||||
try {
|
||||
const url = `${MARKETPLACE_API_PREFIX}/collections/${collectionId}/plugins`
|
||||
const headers = new Headers({
|
||||
'X-Dify-Version': !IS_MARKETPLACE ? APP_VERSION : '999.0.0',
|
||||
})
|
||||
const headers = getMarketplaceHeaders()
|
||||
const marketplaceCollectionPluginsData = await globalThis.fetch(
|
||||
url,
|
||||
{
|
||||
cache: 'no-store',
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal: options?.signal,
|
||||
body: JSON.stringify({
|
||||
category: query?.category,
|
||||
exclude: query?.exclude,
|
||||
@ -68,9 +79,7 @@ export const getMarketplacePluginsByCollectionId = async (collectionId: string,
|
||||
},
|
||||
)
|
||||
const marketplaceCollectionPluginsDataJson = await marketplaceCollectionPluginsData.json()
|
||||
plugins = marketplaceCollectionPluginsDataJson.data.plugins.map((plugin: Plugin) => {
|
||||
return getFormattedPlugin(plugin)
|
||||
})
|
||||
plugins = (marketplaceCollectionPluginsDataJson.data.plugins || []).map((plugin: Plugin) => getFormattedPlugin(plugin))
|
||||
}
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
catch (e) {
|
||||
@ -80,23 +89,31 @@ export const getMarketplacePluginsByCollectionId = async (collectionId: string,
|
||||
return plugins
|
||||
}
|
||||
|
||||
export const getMarketplaceCollectionsAndPlugins = async (query?: CollectionsAndPluginsSearchParams) => {
|
||||
let marketplaceCollections = [] as MarketplaceCollection[]
|
||||
let marketplaceCollectionPluginsMap = {} as Record<string, Plugin[]>
|
||||
export const getMarketplaceCollectionsAndPlugins = async (
|
||||
query?: CollectionsAndPluginsSearchParams,
|
||||
options?: MarketplaceFetchOptions,
|
||||
) => {
|
||||
let marketplaceCollections: MarketplaceCollection[] = []
|
||||
let marketplaceCollectionPluginsMap: Record<string, Plugin[]> = {}
|
||||
try {
|
||||
let marketplaceUrl = `${MARKETPLACE_API_PREFIX}/collections?page=1&page_size=100`
|
||||
if (query?.condition)
|
||||
marketplaceUrl += `&condition=${query.condition}`
|
||||
if (query?.type)
|
||||
marketplaceUrl += `&type=${query.type}`
|
||||
const headers = new Headers({
|
||||
'X-Dify-Version': !IS_MARKETPLACE ? APP_VERSION : '999.0.0',
|
||||
})
|
||||
const marketplaceCollectionsData = await globalThis.fetch(marketplaceUrl, { headers, cache: 'no-store' })
|
||||
const headers = getMarketplaceHeaders()
|
||||
const marketplaceCollectionsData = await globalThis.fetch(
|
||||
marketplaceUrl,
|
||||
{
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
signal: options?.signal,
|
||||
},
|
||||
)
|
||||
const marketplaceCollectionsDataJson = await marketplaceCollectionsData.json()
|
||||
marketplaceCollections = marketplaceCollectionsDataJson.data.collections
|
||||
marketplaceCollections = marketplaceCollectionsDataJson.data.collections || []
|
||||
await Promise.all(marketplaceCollections.map(async (collection: MarketplaceCollection) => {
|
||||
const plugins = await getMarketplacePluginsByCollectionId(collection.name, query)
|
||||
const plugins = await getMarketplacePluginsByCollectionId(collection.name, query, options)
|
||||
|
||||
marketplaceCollectionPluginsMap[collection.name] = plugins
|
||||
}))
|
||||
|
||||
@ -0,0 +1,116 @@
|
||||
import {
|
||||
memo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
RiAddLine,
|
||||
RiKey2Line,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AddOAuthButton from './authorize/add-oauth-button'
|
||||
import AddApiKeyButton from './authorize/add-api-key-button'
|
||||
import type { PluginPayload } from './types'
|
||||
import cn from '@/utils/classnames'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
export type CredentialConfigHeaderProps = {
|
||||
pluginPayload: PluginPayload
|
||||
canOAuth?: boolean
|
||||
canApiKey?: boolean
|
||||
hasOAuthClientConfigured?: boolean
|
||||
disabled?: boolean
|
||||
onCredentialAdded?: () => void
|
||||
onAddMenuOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
const CredentialConfigHeader = ({
|
||||
pluginPayload,
|
||||
canOAuth,
|
||||
canApiKey,
|
||||
hasOAuthClientConfigured,
|
||||
disabled,
|
||||
onCredentialAdded,
|
||||
onAddMenuOpenChange,
|
||||
}: CredentialConfigHeaderProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [showAddMenu, setShowAddMenu] = useState(false)
|
||||
|
||||
const handleAddMenuOpenChange = (open: boolean) => {
|
||||
setShowAddMenu(open)
|
||||
onAddMenuOpenChange?.(open)
|
||||
}
|
||||
|
||||
const addButtonDisabled = disabled || (!canOAuth && !canApiKey && !hasOAuthClientConfigured)
|
||||
|
||||
return (
|
||||
<div className='flex items-start justify-between gap-2'>
|
||||
<div className='flex items-start gap-2'>
|
||||
<RiKey2Line className='mt-0.5 h-4 w-4 text-text-tertiary' />
|
||||
<div className='space-y-0.5'>
|
||||
<div className='system-md-semibold text-text-primary'>
|
||||
{t('plugin.auth.configuredCredentials.title')}
|
||||
</div>
|
||||
<div className='system-xs-regular text-text-tertiary'>
|
||||
{t('plugin.auth.configuredCredentials.desc')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PortalToFollowElem
|
||||
open={showAddMenu}
|
||||
onOpenChange={handleAddMenuOpenChange}
|
||||
placement='bottom-end'
|
||||
offset={6}
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
className={cn(
|
||||
'flex h-9 w-9 items-center justify-center rounded-full bg-primary-600 text-white hover:bg-primary-700',
|
||||
addButtonDisabled && 'pointer-events-none opacity-50',
|
||||
)}
|
||||
onClick={() => handleAddMenuOpenChange(!showAddMenu)}
|
||||
>
|
||||
<RiAddLine className='h-5 w-5' />
|
||||
</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[120]'>
|
||||
<div className='w-[220px] rounded-xl border border-components-panel-border bg-components-panel-bg shadow-lg'>
|
||||
<div className='flex flex-col gap-1 p-1'>
|
||||
{canOAuth && (
|
||||
<AddOAuthButton
|
||||
pluginPayload={pluginPayload}
|
||||
buttonVariant='ghost'
|
||||
className='w-full justify-between bg-transparent text-text-primary hover:bg-transparent'
|
||||
buttonText={t('plugin.auth.addOAuth')}
|
||||
disabled={disabled}
|
||||
onUpdate={() => {
|
||||
setShowAddMenu(false)
|
||||
onCredentialAdded?.()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{canApiKey && (
|
||||
<AddApiKeyButton
|
||||
pluginPayload={pluginPayload}
|
||||
buttonVariant='ghost'
|
||||
buttonText={t('plugin.auth.addApi')}
|
||||
disabled={disabled}
|
||||
onUpdate={() => {
|
||||
setShowAddMenu(false)
|
||||
onCredentialAdded?.()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(CredentialConfigHeader)
|
||||
@ -0,0 +1,176 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiEqualizer2Line,
|
||||
RiKey2Line,
|
||||
RiUserStarLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AddOAuthButton from './authorize/add-oauth-button'
|
||||
import AddApiKeyButton from './authorize/add-api-key-button'
|
||||
import type { PluginPayload } from './types'
|
||||
import cn from '@/utils/classnames'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
export type EndUserCredentialSectionProps = {
|
||||
pluginPayload: PluginPayload
|
||||
canOAuth?: boolean
|
||||
canApiKey?: boolean
|
||||
disabled?: boolean
|
||||
useEndUserCredentialEnabled?: boolean
|
||||
endUserCredentialType?: string
|
||||
onEndUserCredentialChange?: (enabled: boolean) => void
|
||||
onEndUserCredentialTypeChange?: (type: string) => void
|
||||
onCredentialAdded?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const EndUserCredentialSection = ({
|
||||
pluginPayload,
|
||||
canOAuth,
|
||||
canApiKey,
|
||||
disabled,
|
||||
useEndUserCredentialEnabled,
|
||||
endUserCredentialType,
|
||||
onEndUserCredentialChange,
|
||||
onEndUserCredentialTypeChange,
|
||||
onCredentialAdded,
|
||||
className,
|
||||
}: EndUserCredentialSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [showEndUserTypeMenu, setShowEndUserTypeMenu] = useState(false)
|
||||
|
||||
const availableEndUserTypes = useMemo(() => {
|
||||
const list: { value: string; label: string; icon: ReactNode }[] = []
|
||||
if (canOAuth) {
|
||||
list.push({
|
||||
value: 'oauth2',
|
||||
label: t('plugin.auth.endUserCredentials.optionOAuth'),
|
||||
icon: <RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />,
|
||||
})
|
||||
}
|
||||
if (canApiKey) {
|
||||
list.push({
|
||||
value: 'api-key',
|
||||
label: t('plugin.auth.endUserCredentials.optionApiKey'),
|
||||
icon: <RiKey2Line className='h-4 w-4 text-text-tertiary' />,
|
||||
})
|
||||
}
|
||||
return list
|
||||
}, [canOAuth, canApiKey, t])
|
||||
|
||||
const endUserCredentialLabel = useMemo(() => {
|
||||
const found = availableEndUserTypes.find(item => item.value === endUserCredentialType)
|
||||
return found?.label || availableEndUserTypes[0]?.label || '-'
|
||||
}, [availableEndUserTypes, endUserCredentialType])
|
||||
|
||||
useEffect(() => {
|
||||
if (!useEndUserCredentialEnabled)
|
||||
return
|
||||
if (!availableEndUserTypes.length)
|
||||
return
|
||||
const isValid = availableEndUserTypes.some(item => item.value === endUserCredentialType)
|
||||
if (!isValid)
|
||||
onEndUserCredentialTypeChange?.(availableEndUserTypes[0].value)
|
||||
}, [useEndUserCredentialEnabled, endUserCredentialType, availableEndUserTypes, onEndUserCredentialTypeChange])
|
||||
|
||||
const handleSelectEndUserType = useCallback((value: string) => {
|
||||
onEndUserCredentialTypeChange?.(value)
|
||||
setShowEndUserTypeMenu(false)
|
||||
}, [onEndUserCredentialTypeChange])
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-start gap-3', className)}>
|
||||
<RiUserStarLine className='mt-0.5 h-4 w-4 shrink-0 text-text-tertiary' />
|
||||
<div className='flex-1 space-y-3'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='space-y-1'>
|
||||
<div className='system-sm-semibold text-text-primary'>
|
||||
{t('plugin.auth.endUserCredentials.title')}
|
||||
</div>
|
||||
<div className='system-xs-regular text-text-tertiary'>
|
||||
{t('plugin.auth.endUserCredentials.desc')}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
size='md'
|
||||
defaultValue={!!useEndUserCredentialEnabled}
|
||||
onChange={onEndUserCredentialChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
useEndUserCredentialEnabled && availableEndUserTypes.length > 0 && (
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='system-sm-semibold text-text-primary'>
|
||||
{t('plugin.auth.endUserCredentials.typeLabel')}
|
||||
</div>
|
||||
<PortalToFollowElem
|
||||
open={showEndUserTypeMenu}
|
||||
onOpenChange={setShowEndUserTypeMenu}
|
||||
placement='bottom-end'
|
||||
offset={6}
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
className='border-components-input-border flex h-9 min-w-[190px] items-center justify-between rounded-lg border bg-components-input-bg-normal px-3 text-left text-text-primary shadow-xs hover:bg-components-input-bg-hover'
|
||||
onClick={() => setShowEndUserTypeMenu(v => !v)}
|
||||
>
|
||||
<span className='system-sm-semibold'>{endUserCredentialLabel}</span>
|
||||
<RiArrowDownSLine className='h-4 w-4 text-text-tertiary' />
|
||||
</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[120]'>
|
||||
<div className='w-[220px] rounded-xl border border-components-panel-border bg-components-panel-bg shadow-lg'>
|
||||
<div className='flex flex-col gap-1 p-1'>
|
||||
{canOAuth && (
|
||||
<AddOAuthButton
|
||||
pluginPayload={pluginPayload}
|
||||
buttonVariant='ghost'
|
||||
className='w-full justify-between bg-transparent text-text-primary hover:bg-transparent'
|
||||
buttonText={t('plugin.auth.addOAuth')}
|
||||
disabled={disabled}
|
||||
onUpdate={() => {
|
||||
handleSelectEndUserType('oauth2')
|
||||
onCredentialAdded?.()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{canApiKey && (
|
||||
<AddApiKeyButton
|
||||
pluginPayload={pluginPayload}
|
||||
buttonVariant='ghost'
|
||||
buttonText={t('plugin.auth.addApi')}
|
||||
disabled={disabled}
|
||||
onUpdate={() => {
|
||||
handleSelectEndUserType('api-key')
|
||||
onCredentialAdded?.()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(EndUserCredentialSection)
|
||||
@ -13,6 +13,7 @@ export const usePluginAuth = (pluginPayload: PluginPayload, enable?: boolean) =>
|
||||
const canOAuth = data?.supported_credential_types.includes(CredentialTypeEnum.OAUTH2)
|
||||
const canApiKey = data?.supported_credential_types.includes(CredentialTypeEnum.API_KEY)
|
||||
const invalidPluginCredentialInfo = useInvalidPluginCredentialInfoHook(pluginPayload)
|
||||
const hasOAuthClientConfigured = data?.is_oauth_custom_client_enabled
|
||||
|
||||
return {
|
||||
isAuthorized,
|
||||
@ -22,5 +23,6 @@ export const usePluginAuth = (pluginPayload: PluginPayload, enable?: boolean) =>
|
||||
disabled: !isCurrentWorkspaceManager,
|
||||
notAllowCustomCredential: data?.allow_custom_token === false,
|
||||
invalidPluginCredentialInfo,
|
||||
hasOAuthClientConfigured: !!hasOAuthClientConfigured,
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,10 @@ export { default as PluginAuth } from './plugin-auth'
|
||||
export { default as Authorized } from './authorized'
|
||||
export { default as AuthorizedInNode } from './authorized-in-node'
|
||||
export { default as PluginAuthInAgent } from './plugin-auth-in-agent'
|
||||
export { default as CredentialConfigHeader } from './credential-config-header'
|
||||
export type { CredentialConfigHeaderProps } from './credential-config-header'
|
||||
export { default as EndUserCredentialSection } from './end-user-credential-section'
|
||||
export type { EndUserCredentialSectionProps } from './end-user-credential-section'
|
||||
export { usePluginAuth } from './hooks/use-plugin-auth'
|
||||
export { default as PluginAuthInDataSourceNode } from './plugin-auth-in-datasource-node'
|
||||
export { default as AuthorizedInDataSourceNode } from './authorized-in-data-source-node'
|
||||
|
||||
@ -3,10 +3,14 @@ import {
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Authorize from './authorize'
|
||||
import Authorized from './authorized'
|
||||
import CredentialConfigHeader from './credential-config-header'
|
||||
import EndUserCredentialSection from './end-user-credential-section'
|
||||
import type {
|
||||
Credential,
|
||||
PluginPayload,
|
||||
@ -20,11 +24,19 @@ type PluginAuthInAgentProps = {
|
||||
pluginPayload: PluginPayload
|
||||
credentialId?: string
|
||||
onAuthorizationItemClick?: (id: string) => void
|
||||
useEndUserCredentialEnabled?: boolean
|
||||
endUserCredentialType?: string
|
||||
onEndUserCredentialChange?: (enabled: boolean) => void
|
||||
onEndUserCredentialTypeChange?: (type: string) => void
|
||||
}
|
||||
const PluginAuthInAgent = ({
|
||||
pluginPayload,
|
||||
credentialId,
|
||||
onAuthorizationItemClick,
|
||||
useEndUserCredentialEnabled,
|
||||
endUserCredentialType,
|
||||
onEndUserCredentialChange,
|
||||
onEndUserCredentialTypeChange,
|
||||
}: PluginAuthInAgentProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
@ -36,8 +48,11 @@ const PluginAuthInAgent = ({
|
||||
disabled,
|
||||
invalidPluginCredentialInfo,
|
||||
notAllowCustomCredential,
|
||||
hasOAuthClientConfigured,
|
||||
} = usePluginAuth(pluginPayload, true)
|
||||
|
||||
const configuredDisabled = !!useEndUserCredentialEnabled
|
||||
|
||||
const extraAuthorizationItems: Credential[] = [
|
||||
{
|
||||
id: '__workspace_default__',
|
||||
@ -94,42 +109,87 @@ const PluginAuthInAgent = ({
|
||||
)
|
||||
}, [credentialId, credentials, t])
|
||||
|
||||
const shouldShowAuthorizeCard = !credentials.length && (canOAuth || canApiKey || hasOAuthClientConfigured)
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
!isAuthorized && (
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
disabled={disabled}
|
||||
onUpdate={invalidPluginCredentialInfo}
|
||||
notAllowCustomCredential={notAllowCustomCredential}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
isAuthorized && (
|
||||
<Authorized
|
||||
pluginPayload={pluginPayload}
|
||||
credentials={credentials}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
disabled={disabled}
|
||||
disableSetDefault
|
||||
onItemClick={handleAuthorizationItemClick}
|
||||
extraAuthorizationItems={extraAuthorizationItems}
|
||||
showItemSelectedIcon
|
||||
renderTrigger={renderTrigger}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
selectedCredentialId={credentialId || '__workspace_default__'}
|
||||
onUpdate={invalidPluginCredentialInfo}
|
||||
notAllowCustomCredential={notAllowCustomCredential}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</>
|
||||
<div className='border-components-panel-border bg-components-panel-bg'>
|
||||
<div className={cn(configuredDisabled && 'pointer-events-none opacity-50')}>
|
||||
<CredentialConfigHeader
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
hasOAuthClientConfigured={hasOAuthClientConfigured}
|
||||
disabled={disabled || configuredDisabled}
|
||||
onCredentialAdded={invalidPluginCredentialInfo}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn(configuredDisabled && 'pointer-events-none opacity-50')}>
|
||||
{
|
||||
!isAuthorized && shouldShowAuthorizeCard && (
|
||||
<div className='rounded-xl bg-background-section px-4 py-4'>
|
||||
<div className='flex w-full justify-center'>
|
||||
<div className='w-full max-w-[520px]'>
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
disabled={disabled || configuredDisabled}
|
||||
onUpdate={invalidPluginCredentialInfo}
|
||||
notAllowCustomCredential={notAllowCustomCredential}
|
||||
theme='secondary'
|
||||
showDivider={!!(canOAuth && canApiKey)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isAuthorized && !shouldShowAuthorizeCard && (
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
disabled={disabled || configuredDisabled}
|
||||
onUpdate={invalidPluginCredentialInfo}
|
||||
notAllowCustomCredential={notAllowCustomCredential}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
isAuthorized && (
|
||||
<Authorized
|
||||
pluginPayload={pluginPayload}
|
||||
credentials={credentials}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
disabled={disabled || configuredDisabled}
|
||||
disableSetDefault
|
||||
onItemClick={handleAuthorizationItemClick}
|
||||
extraAuthorizationItems={extraAuthorizationItems}
|
||||
showItemSelectedIcon
|
||||
renderTrigger={renderTrigger}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
selectedCredentialId={credentialId || '__workspace_default__'}
|
||||
onUpdate={invalidPluginCredentialInfo}
|
||||
notAllowCustomCredential={notAllowCustomCredential}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<EndUserCredentialSection
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
disabled={disabled}
|
||||
useEndUserCredentialEnabled={useEndUserCredentialEnabled}
|
||||
endUserCredentialType={endUserCredentialType}
|
||||
onEndUserCredentialChange={onEndUserCredentialChange}
|
||||
onEndUserCredentialTypeChange={onEndUserCredentialTypeChange}
|
||||
onCredentialAdded={invalidPluginCredentialInfo}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,26 @@
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
memo,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
RiAddLine,
|
||||
RiArrowDownSLine,
|
||||
RiKey2Line,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Authorize from './authorize'
|
||||
import Authorized from './authorized'
|
||||
import AddApiKeyButton from './authorize/add-api-key-button'
|
||||
import AddOAuthButton from './authorize/add-oauth-button'
|
||||
import EndUserCredentialSection from './end-user-credential-section'
|
||||
import Item from './authorized/item'
|
||||
import type { PluginPayload } from './types'
|
||||
import { usePluginAuth } from './hooks/use-plugin-auth'
|
||||
import cn from '@/utils/classnames'
|
||||
@ -9,12 +29,23 @@ type PluginAuthProps = {
|
||||
pluginPayload: PluginPayload
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
showConnectGuide?: boolean
|
||||
endUserCredentialEnabled?: boolean
|
||||
endUserCredentialType?: string
|
||||
onEndUserCredentialTypeChange?: (type: string) => void
|
||||
onEndUserCredentialChange?: (enabled: boolean) => void
|
||||
}
|
||||
const PluginAuth = ({
|
||||
pluginPayload,
|
||||
children,
|
||||
className,
|
||||
showConnectGuide,
|
||||
endUserCredentialEnabled,
|
||||
endUserCredentialType,
|
||||
onEndUserCredentialTypeChange,
|
||||
onEndUserCredentialChange,
|
||||
}: PluginAuthProps) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
isAuthorized,
|
||||
canOAuth,
|
||||
@ -23,12 +54,205 @@ const PluginAuth = ({
|
||||
disabled,
|
||||
invalidPluginCredentialInfo,
|
||||
notAllowCustomCredential,
|
||||
hasOAuthClientConfigured,
|
||||
} = usePluginAuth(pluginPayload, !!pluginPayload.provider)
|
||||
const shouldShowGuide = !!showConnectGuide
|
||||
const [showCredentialPanel, setShowCredentialPanel] = useState(false)
|
||||
const [showAddMenu, setShowAddMenu] = useState(false)
|
||||
const configuredDisabled = !!endUserCredentialEnabled
|
||||
const shouldShowAuthorizeCard = useMemo(() => {
|
||||
const hasCredential = credentials.length > 0
|
||||
const canAdd = canOAuth || canApiKey || hasOAuthClientConfigured
|
||||
return !hasCredential && canAdd
|
||||
}, [credentials.length, canOAuth, canApiKey, hasOAuthClientConfigured])
|
||||
const containerClassName = useMemo(() => {
|
||||
if (showConnectGuide)
|
||||
return className
|
||||
return !isAuthorized ? className : undefined
|
||||
}, [isAuthorized, className, showConnectGuide])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthorized)
|
||||
setShowCredentialPanel(false)
|
||||
}, [isAuthorized])
|
||||
|
||||
const credentialList = useMemo(() => {
|
||||
return (
|
||||
<div className={cn(!credentials.length ? 'mt-0' : 'mt-3')}>
|
||||
{
|
||||
credentials.length > 0
|
||||
? (
|
||||
<div className='space-y-1'>
|
||||
{credentials.map(credential => (
|
||||
<Item
|
||||
key={credential.id}
|
||||
credential={credential}
|
||||
disabled
|
||||
disableRename
|
||||
disableEdit
|
||||
disableDelete
|
||||
disableSetDefault
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}, [credentials, t])
|
||||
|
||||
const endUserSwitch = (
|
||||
<EndUserCredentialSection
|
||||
className='px-4 py-3'
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
disabled={disabled}
|
||||
useEndUserCredentialEnabled={endUserCredentialEnabled}
|
||||
endUserCredentialType={endUserCredentialType}
|
||||
onEndUserCredentialChange={onEndUserCredentialChange}
|
||||
onEndUserCredentialTypeChange={onEndUserCredentialTypeChange}
|
||||
onCredentialAdded={invalidPluginCredentialInfo}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn(!isAuthorized && className)}>
|
||||
<div className={cn(containerClassName)}>
|
||||
{
|
||||
!isAuthorized && (
|
||||
shouldShowGuide && (
|
||||
<PortalToFollowElem
|
||||
open={showCredentialPanel}
|
||||
onOpenChange={setShowCredentialPanel}
|
||||
placement='bottom-start'
|
||||
offset={8}
|
||||
triggerPopupSameWidth
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
className='flex w-full items-center justify-center gap-2 rounded-xl bg-primary-600 px-4 py-3 text-left text-white shadow-xs hover:bg-primary-700'
|
||||
onClick={() => setShowCredentialPanel(v => !v)}
|
||||
>
|
||||
<div className='system-sm-semibold text-white'>
|
||||
{t('plugin.auth.connectCredentials')}
|
||||
</div>
|
||||
<RiArrowDownSLine
|
||||
className={cn(
|
||||
'h-4 w-4 text-white transition-transform',
|
||||
showCredentialPanel && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[100]'>
|
||||
<div className='w-[420px] max-w-[calc(100vw-48px)] rounded-2xl border border-divider-subtle bg-components-panel-bg shadow-lg'>
|
||||
<div className='border-b border-divider-subtle px-3 py-3'>
|
||||
<div className='flex items-start justify-between gap-2'>
|
||||
<div className='flex items-start gap-2'>
|
||||
<RiKey2Line className='mt-0.5 h-4 w-4 text-text-tertiary' />
|
||||
<div className='space-y-0.5'>
|
||||
<div className='system-md-semibold text-text-primary'>
|
||||
{t('plugin.auth.configuredCredentials.title')}
|
||||
</div>
|
||||
<div className='system-xs-regular text-text-tertiary'>
|
||||
{t('plugin.auth.configuredCredentials.desc')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PortalToFollowElem
|
||||
open={showAddMenu}
|
||||
onOpenChange={setShowAddMenu}
|
||||
placement='bottom-end'
|
||||
offset={6}
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
className={cn(
|
||||
'flex h-9 w-9 items-center justify-center rounded-full bg-primary-600 text-white hover:bg-primary-700',
|
||||
configuredDisabled && 'pointer-events-none opacity-50',
|
||||
)}
|
||||
onClick={() => {
|
||||
setShowCredentialPanel(true)
|
||||
setShowAddMenu(v => !v)
|
||||
}}
|
||||
>
|
||||
<RiAddLine className='h-5 w-5' />
|
||||
</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[120]'>
|
||||
<div className='w-[220px] rounded-xl border border-components-panel-border bg-components-panel-bg shadow-lg'>
|
||||
<div className='flex flex-col gap-1 p-1'>
|
||||
{
|
||||
canOAuth && (
|
||||
<AddOAuthButton
|
||||
pluginPayload={pluginPayload}
|
||||
buttonVariant='ghost'
|
||||
className='w-full justify-between bg-transparent text-text-primary hover:bg-transparent'
|
||||
buttonText={t('plugin.auth.addOAuth')}
|
||||
disabled={disabled}
|
||||
onUpdate={() => {
|
||||
setShowAddMenu(false)
|
||||
invalidPluginCredentialInfo()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
canApiKey && (
|
||||
<AddApiKeyButton
|
||||
pluginPayload={pluginPayload}
|
||||
buttonVariant='ghost'
|
||||
buttonText={t('plugin.auth.addApi')}
|
||||
disabled={disabled}
|
||||
onUpdate={() => {
|
||||
setShowAddMenu(false)
|
||||
invalidPluginCredentialInfo()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</div>
|
||||
<div className={cn(configuredDisabled && 'pointer-events-none opacity-50')}>
|
||||
{credentialList}
|
||||
</div>
|
||||
{
|
||||
shouldShowAuthorizeCard && (
|
||||
<div className={cn(
|
||||
'mt-4 flex items-start gap-1.5 rounded-xl bg-background-section px-4 py-8',
|
||||
configuredDisabled && 'pointer-events-none opacity-50',
|
||||
)}>
|
||||
<div className='flex w-full justify-center'>
|
||||
<div className='w-full max-w-[520px]'>
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
disabled={disabled || configuredDisabled}
|
||||
onUpdate={invalidPluginCredentialInfo}
|
||||
notAllowCustomCredential={notAllowCustomCredential}
|
||||
theme='secondary'
|
||||
showDivider={!!(canOAuth && canApiKey)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{endUserSwitch}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
{
|
||||
!shouldShowGuide && !isAuthorized && (
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={canOAuth}
|
||||
|
||||
@ -28,9 +28,9 @@ import {
|
||||
RiHardDrive3Line,
|
||||
} from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { useTheme } from 'next-themes'
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import Verified from '../base/badges/verified'
|
||||
import { AutoUpdateLine } from '../../base/icons/src/vender/system'
|
||||
import DeprecationNotice from '../base/deprecation-notice'
|
||||
@ -86,7 +86,7 @@ const DetailHeader = ({
|
||||
alternative_plugin_id,
|
||||
} = detail
|
||||
|
||||
const { author, category, name, label, description, icon, verified, tool } = detail.declaration || detail
|
||||
const { author, category, name, label, description, icon, icon_dark, verified, tool } = detail.declaration || detail
|
||||
const isTool = category === PluginCategoryEnum.tool
|
||||
const providerBriefInfo = tool?.identity
|
||||
const providerKey = `${plugin_id}/${providerBriefInfo?.name}`
|
||||
@ -109,6 +109,11 @@ const DetailHeader = ({
|
||||
return false
|
||||
}, [isFromMarketplace, latest_version, version])
|
||||
|
||||
const iconFileName = theme === 'dark' && icon_dark ? icon_dark : icon
|
||||
const iconSrc = iconFileName
|
||||
? (iconFileName.startsWith('http') ? iconFileName : `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${tenant_id}&filename=${iconFileName}`)
|
||||
: ''
|
||||
|
||||
const detailUrl = useMemo(() => {
|
||||
if (isFromGitHub)
|
||||
return `https://github.com/${meta!.repo}`
|
||||
@ -214,7 +219,7 @@ const DetailHeader = ({
|
||||
<div className={cn('shrink-0 border-b border-divider-subtle bg-components-panel-bg p-4 pb-3', isReadmeView && 'border-b-0 bg-transparent p-0')}>
|
||||
<div className="flex">
|
||||
<div className={cn('overflow-hidden rounded-xl border border-components-panel-border-subtle', isReadmeView && 'bg-components-panel-bg')}>
|
||||
<Icon src={icon.startsWith('http') ? icon : `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${tenant_id}&filename=${icon}`} />
|
||||
<Icon src={iconSrc} />
|
||||
</div>
|
||||
<div className="ml-3 w-0 grow">
|
||||
<div className="flex h-5 items-center">
|
||||
|
||||
@ -210,6 +210,18 @@ const ToolSelector: FC<Props> = ({
|
||||
credential_id: id,
|
||||
} as any)
|
||||
}
|
||||
const handleEndUserCredentialChange = (enabled: boolean) => {
|
||||
onSelect({
|
||||
...value,
|
||||
use_end_user_credentials: enabled,
|
||||
} as any)
|
||||
}
|
||||
const handleEndUserCredentialTypeChange = (type: string) => {
|
||||
onSelect({
|
||||
...value,
|
||||
end_user_credential_type: type,
|
||||
} as any)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -323,6 +335,10 @@ const ToolSelector: FC<Props> = ({
|
||||
}}
|
||||
credentialId={value?.credential_id}
|
||||
onAuthorizationItemClick={handleAuthorizationItemClick}
|
||||
useEndUserCredentialEnabled={value?.use_end_user_credentials}
|
||||
endUserCredentialType={value?.end_user_credential_type}
|
||||
onEndUserCredentialChange={handleEndUserCredentialChange}
|
||||
onEndUserCredentialTypeChange={handleEndUserCredentialTypeChange}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -14,11 +14,11 @@ import {
|
||||
RiHardDrive3Line,
|
||||
RiLoginCircleLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { gte } from 'semver'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import Verified from '../base/badges/verified'
|
||||
import Badge from '../../base/badge'
|
||||
import { Github } from '../../base/icons/src/public/common'
|
||||
@ -58,7 +58,7 @@ const PluginItem: FC<Props> = ({
|
||||
status,
|
||||
deprecated_reason,
|
||||
} = plugin
|
||||
const { category, author, name, label, description, icon, verified, meta: declarationMeta } = plugin.declaration
|
||||
const { category, author, name, label, description, icon, icon_dark, verified, meta: declarationMeta } = plugin.declaration
|
||||
|
||||
const orgName = useMemo(() => {
|
||||
return [PluginSource.github, PluginSource.marketplace].includes(source) ? author : ''
|
||||
@ -84,6 +84,10 @@ const PluginItem: FC<Props> = ({
|
||||
const title = getValueFromI18nObject(label)
|
||||
const descriptionText = getValueFromI18nObject(description)
|
||||
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const iconFileName = theme === 'dark' && icon_dark ? icon_dark : icon
|
||||
const iconSrc = iconFileName
|
||||
? (iconFileName.startsWith('http') ? iconFileName : `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${tenant_id}&filename=${iconFileName}`)
|
||||
: ''
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -105,7 +109,7 @@ const PluginItem: FC<Props> = ({
|
||||
<div className='flex h-10 w-10 items-center justify-center overflow-hidden rounded-xl border-[1px] border-components-panel-border-subtle'>
|
||||
<img
|
||||
className='h-full w-full'
|
||||
src={`${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${tenant_id}&filename=${icon}`}
|
||||
src={iconSrc}
|
||||
alt={`plugin-${plugin_unique_identifier}-logo`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -71,6 +71,7 @@ export type PluginDeclaration = {
|
||||
version: string
|
||||
author: string
|
||||
icon: string
|
||||
icon_dark?: string
|
||||
name: string
|
||||
category: PluginCategoryEnum
|
||||
label: Record<Locale, string>
|
||||
@ -248,7 +249,7 @@ export type PluginInfoFromMarketPlace = {
|
||||
}
|
||||
|
||||
export type Plugin = {
|
||||
type: 'plugin' | 'bundle' | 'model' | 'extension' | 'tool' | 'agent_strategy'
|
||||
type: 'plugin' | 'bundle' | 'model' | 'extension' | 'tool' | 'agent_strategy' | 'datasource' | 'trigger'
|
||||
org: string
|
||||
author?: string
|
||||
name: string
|
||||
@ -257,6 +258,7 @@ export type Plugin = {
|
||||
latest_version: string
|
||||
latest_package_identifier: string
|
||||
icon: string
|
||||
icon_dark?: string
|
||||
verified: boolean
|
||||
label: Record<Locale, string>
|
||||
brief: Record<Locale, string>
|
||||
|
||||
22
web/app/components/react-scan.tsx
Normal file
22
web/app/components/react-scan.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { scan } from 'react-scan'
|
||||
import { useEffect } from 'react'
|
||||
import { IS_DEV } from '@/config'
|
||||
|
||||
export function ReactScan() {
|
||||
useEffect(() => {
|
||||
if (IS_DEV) {
|
||||
scan({
|
||||
enabled: true,
|
||||
// HACK: react-scan's getIsProduction() incorrectly detects Next.js dev as production
|
||||
// because Next.js devtools overlay uses production React build
|
||||
// Issue: https://github.com/aidenybai/react-scan/issues/402
|
||||
// TODO: remove this option after upstream fix
|
||||
dangerouslyForceRunInProduction: true,
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
@ -3,12 +3,12 @@ import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
useMarketplaceCollectionsAndPlugins,
|
||||
useMarketplacePlugins,
|
||||
} from '@/app/components/plugins/marketplace/hooks'
|
||||
import { SCROLL_BOTTOM_THRESHOLD } from '@/app/components/plugins/marketplace/constants'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { getMarketplaceListCondition } from '@/app/components/plugins/marketplace/utils'
|
||||
import { useAllToolProviders } from '@/service/use-tools'
|
||||
@ -31,10 +31,10 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin
|
||||
queryPlugins,
|
||||
queryPluginsWithDebounced,
|
||||
isLoading: isPluginsLoading,
|
||||
total: pluginsTotal,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
page: pluginsPage,
|
||||
} = useMarketplacePlugins()
|
||||
const [page, setPage] = useState(1)
|
||||
const pageRef = useRef(page)
|
||||
const searchPluginTextRef = useRef(searchPluginText)
|
||||
const filterPluginTagsRef = useRef(filterPluginTags)
|
||||
|
||||
@ -44,9 +44,6 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin
|
||||
}, [searchPluginText, filterPluginTags])
|
||||
useEffect(() => {
|
||||
if ((searchPluginText || filterPluginTags.length) && isSuccess) {
|
||||
setPage(1)
|
||||
pageRef.current = 1
|
||||
|
||||
if (searchPluginText) {
|
||||
queryPluginsWithDebounced({
|
||||
category: PluginCategoryEnum.tool,
|
||||
@ -54,7 +51,6 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin
|
||||
tags: filterPluginTags,
|
||||
exclude,
|
||||
type: 'plugin',
|
||||
page: pageRef.current,
|
||||
})
|
||||
return
|
||||
}
|
||||
@ -64,7 +60,6 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin
|
||||
tags: filterPluginTags,
|
||||
exclude,
|
||||
type: 'plugin',
|
||||
page: pageRef.current,
|
||||
})
|
||||
}
|
||||
else {
|
||||
@ -87,24 +82,13 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
} = target
|
||||
if (scrollTop + clientHeight >= scrollHeight - 5 && scrollTop > 0) {
|
||||
if (scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD && scrollTop > 0) {
|
||||
const searchPluginText = searchPluginTextRef.current
|
||||
const filterPluginTags = filterPluginTagsRef.current
|
||||
if (pluginsTotal && plugins && pluginsTotal > plugins.length && (!!searchPluginText || !!filterPluginTags.length)) {
|
||||
setPage(pageRef.current + 1)
|
||||
pageRef.current++
|
||||
|
||||
queryPlugins({
|
||||
category: PluginCategoryEnum.tool,
|
||||
query: searchPluginText,
|
||||
tags: filterPluginTags,
|
||||
exclude,
|
||||
type: 'plugin',
|
||||
page: pageRef.current,
|
||||
})
|
||||
}
|
||||
if (hasNextPage && (!!searchPluginText || !!filterPluginTags.length))
|
||||
fetchNextPage()
|
||||
}
|
||||
}, [exclude, plugins, pluginsTotal, queryPlugins])
|
||||
}, [exclude, fetchNextPage, hasNextPage, plugins, queryPlugins])
|
||||
|
||||
return {
|
||||
isLoading: isLoading || isPluginsLoading,
|
||||
@ -112,6 +96,6 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin
|
||||
marketplaceCollectionPluginsMap,
|
||||
plugins,
|
||||
handleScroll,
|
||||
page,
|
||||
page: Math.max(pluginsPage || 0, 1),
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,154 +0,0 @@
|
||||
const tools = [
|
||||
{
|
||||
author: 'Novice',
|
||||
name: 'NOTION_ADD_PAGE_CONTENT',
|
||||
label: {
|
||||
en_US: 'NOTION_ADD_PAGE_CONTENT',
|
||||
zh_Hans: 'NOTION_ADD_PAGE_CONTENT',
|
||||
pt_BR: 'NOTION_ADD_PAGE_CONTENT',
|
||||
ja_JP: 'NOTION_ADD_PAGE_CONTENT',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Adds a single content block to a notion page. multiple calls needed for multiple blocks. note: only supports adding to notion pages. blocks that can contain children: - page (any block type) - toggle (any nested content) - to-do (nested to-dos/blocks) - bulleted list (nested lists/blocks) - numbered list (nested lists/blocks) - callout (child blocks) - quote (nested blocks)',
|
||||
zh_Hans: 'Adds a single content block to a notion page. multiple calls needed for multiple blocks. note: only supports adding to notion pages. blocks that can contain children: - page (any block type) - toggle (any nested content) - to-do (nested to-dos/blocks) - bulleted list (nested lists/blocks) - numbered list (nested lists/blocks) - callout (child blocks) - quote (nested blocks)',
|
||||
pt_BR: 'Adds a single content block to a notion page. multiple calls needed for multiple blocks. note: only supports adding to notion pages. blocks that can contain children: - page (any block type) - toggle (any nested content) - to-do (nested to-dos/blocks) - bulleted list (nested lists/blocks) - numbered list (nested lists/blocks) - callout (child blocks) - quote (nested blocks)',
|
||||
ja_JP: 'Adds a single content block to a notion page. multiple calls needed for multiple blocks. note: only supports adding to notion pages. blocks that can contain children: - page (any block type) - toggle (any nested content) - to-do (nested to-dos/blocks) - bulleted list (nested lists/blocks) - numbered list (nested lists/blocks) - callout (child blocks) - quote (nested blocks)',
|
||||
},
|
||||
parameters: [
|
||||
{
|
||||
name: 'after',
|
||||
label: {
|
||||
en_US: 'after',
|
||||
zh_Hans: 'after',
|
||||
pt_BR: 'after',
|
||||
ja_JP: 'after',
|
||||
},
|
||||
placeholder: null,
|
||||
scope: null,
|
||||
auto_generate: null,
|
||||
template: null,
|
||||
required: false,
|
||||
default: null,
|
||||
min: null,
|
||||
max: null,
|
||||
precision: null,
|
||||
options: [],
|
||||
type: 'string',
|
||||
human_description: {
|
||||
en_US: 'The ID of the existing block that the new block should be appended after. If not provided, content will be appended at the end of the page.',
|
||||
zh_Hans: 'The ID of the existing block that the new block should be appended after. If not provided, content will be appended at the end of the page.',
|
||||
pt_BR: 'The ID of the existing block that the new block should be appended after. If not provided, content will be appended at the end of the page.',
|
||||
ja_JP: 'The ID of the existing block that the new block should be appended after. If not provided, content will be appended at the end of the page.',
|
||||
},
|
||||
form: 'llm',
|
||||
llm_description: 'The ID of the existing block that the new block should be appended after. If not provided, content will be appended at the end of the page.',
|
||||
},
|
||||
{
|
||||
name: 'content_block',
|
||||
label: {
|
||||
en_US: 'content_block',
|
||||
zh_Hans: 'content_block',
|
||||
pt_BR: 'content_block',
|
||||
ja_JP: 'content_block',
|
||||
},
|
||||
placeholder: null,
|
||||
scope: null,
|
||||
auto_generate: null,
|
||||
template: null,
|
||||
required: false,
|
||||
default: null,
|
||||
min: null,
|
||||
max: null,
|
||||
precision: null,
|
||||
options: [],
|
||||
type: 'string',
|
||||
human_description: {
|
||||
en_US: 'Child content to append to a page.',
|
||||
zh_Hans: 'Child content to append to a page.',
|
||||
pt_BR: 'Child content to append to a page.',
|
||||
ja_JP: 'Child content to append to a page.',
|
||||
},
|
||||
form: 'llm',
|
||||
llm_description: 'Child content to append to a page.',
|
||||
},
|
||||
{
|
||||
name: 'parent_block_id',
|
||||
label: {
|
||||
en_US: 'parent_block_id',
|
||||
zh_Hans: 'parent_block_id',
|
||||
pt_BR: 'parent_block_id',
|
||||
ja_JP: 'parent_block_id',
|
||||
},
|
||||
placeholder: null,
|
||||
scope: null,
|
||||
auto_generate: null,
|
||||
template: null,
|
||||
required: false,
|
||||
default: null,
|
||||
min: null,
|
||||
max: null,
|
||||
precision: null,
|
||||
options: [],
|
||||
type: 'string',
|
||||
human_description: {
|
||||
en_US: 'The ID of the page which the children will be added.',
|
||||
zh_Hans: 'The ID of the page which the children will be added.',
|
||||
pt_BR: 'The ID of the page which the children will be added.',
|
||||
ja_JP: 'The ID of the page which the children will be added.',
|
||||
},
|
||||
form: 'llm',
|
||||
llm_description: 'The ID of the page which the children will be added.',
|
||||
},
|
||||
],
|
||||
labels: [],
|
||||
output_schema: null,
|
||||
},
|
||||
]
|
||||
|
||||
export const listData = [
|
||||
{
|
||||
id: 'fdjklajfkljadslf111',
|
||||
author: 'KVOJJJin',
|
||||
name: 'GOGOGO',
|
||||
icon: 'https://cloud.dify.dev/console/api/workspaces/694cc430-fa36-4458-86a0-4a98c09c4684/model-providers/langgenius/openai/openai/icon_small/en_US',
|
||||
server_url: 'https://mcp.composio.dev/notion/****/abc',
|
||||
type: 'mcp',
|
||||
is_team_authorization: true,
|
||||
tools,
|
||||
update_elapsed_time: 1744793369,
|
||||
label: {
|
||||
en_US: 'GOGOGO',
|
||||
zh_Hans: 'GOGOGO',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'fdjklajfkljadslf222',
|
||||
author: 'KVOJJJin',
|
||||
name: 'GOGOGO2',
|
||||
icon: 'https://cloud.dify.dev/console/api/workspaces/694cc430-fa36-4458-86a0-4a98c09c4684/model-providers/langgenius/openai/openai/icon_small/en_US',
|
||||
server_url: 'https://mcp.composio.dev/notion/****/abc',
|
||||
type: 'mcp',
|
||||
is_team_authorization: false,
|
||||
tools: [],
|
||||
update_elapsed_time: 1744793369,
|
||||
label: {
|
||||
en_US: 'GOGOGO2',
|
||||
zh_Hans: 'GOGOGO2',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'fdjklajfkljadslf333',
|
||||
author: 'KVOJJJin',
|
||||
name: 'GOGOGO3',
|
||||
icon: 'https://cloud.dify.dev/console/api/workspaces/694cc430-fa36-4458-86a0-4a98c09c4684/model-providers/langgenius/openai/openai/icon_small/en_US',
|
||||
server_url: 'https://mcp.composio.dev/notion/****/abc',
|
||||
type: 'mcp',
|
||||
is_team_authorization: true,
|
||||
tools,
|
||||
update_elapsed_time: 1744793369,
|
||||
label: {
|
||||
en_US: 'GOGOGO3',
|
||||
zh_Hans: 'GOGOGO3',
|
||||
},
|
||||
},
|
||||
]
|
||||
@ -49,6 +49,7 @@ export type Collection = {
|
||||
author: string
|
||||
description: TypeWithI18N
|
||||
icon: string | Emoji
|
||||
icon_dark?: string | Emoji
|
||||
label: TypeWithI18N
|
||||
type: CollectionType | string
|
||||
team_credentials: Record<string, any>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import type { ToolWithProvider } from '../../types'
|
||||
import { BlockEnum } from '../../types'
|
||||
import type { ToolDefaultValue } from '../types'
|
||||
@ -10,9 +10,13 @@ import { useGetLanguage } from '@/context/i18n'
|
||||
import BlockIcon from '../../block-icon'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { Theme } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
|
||||
const normalizeProviderIcon = (icon: ToolWithProvider['icon']) => {
|
||||
const normalizeProviderIcon = (icon?: ToolWithProvider['icon']) => {
|
||||
if (!icon)
|
||||
return icon
|
||||
if (typeof icon === 'string' && basePath && icon.startsWith('/') && !icon.startsWith(`${basePath}/`))
|
||||
return `${basePath}${icon}`
|
||||
return icon
|
||||
@ -36,6 +40,20 @@ const ToolItem: FC<Props> = ({
|
||||
const { t } = useTranslation()
|
||||
|
||||
const language = useGetLanguage()
|
||||
const { theme } = useTheme()
|
||||
const normalizedIcon = useMemo<ToolWithProvider['icon']>(() => {
|
||||
return normalizeProviderIcon(provider.icon) ?? provider.icon
|
||||
}, [provider.icon])
|
||||
const normalizedIconDark = useMemo(() => {
|
||||
if (!provider.icon_dark)
|
||||
return undefined
|
||||
return normalizeProviderIcon(provider.icon_dark) ?? provider.icon_dark
|
||||
}, [provider.icon_dark])
|
||||
const providerIcon = useMemo(() => {
|
||||
if (theme === Theme.dark && normalizedIconDark)
|
||||
return normalizedIconDark
|
||||
return normalizedIcon
|
||||
}, [theme, normalizedIcon, normalizedIconDark])
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
@ -49,7 +67,7 @@ const ToolItem: FC<Props> = ({
|
||||
size='md'
|
||||
className='mb-2'
|
||||
type={BlockEnum.Tool}
|
||||
toolIcon={provider.icon}
|
||||
toolIcon={providerIcon}
|
||||
/>
|
||||
<div className='mb-1 text-sm leading-5 text-text-primary'>{payload.label[language]}</div>
|
||||
<div className='text-xs leading-[18px] text-text-secondary'>{payload.description[language]}</div>
|
||||
@ -73,7 +91,8 @@ const ToolItem: FC<Props> = ({
|
||||
provider_name: provider.name,
|
||||
plugin_id: provider.plugin_id,
|
||||
plugin_unique_identifier: provider.plugin_unique_identifier,
|
||||
provider_icon: normalizeProviderIcon(provider.icon),
|
||||
provider_icon: normalizedIcon,
|
||||
provider_icon_dark: normalizedIconDark,
|
||||
tool_name: payload.name,
|
||||
tool_label: payload.label[language],
|
||||
tool_description: payload.description[language],
|
||||
|
||||
@ -14,11 +14,15 @@ import ActionItem from './action-item'
|
||||
import BlockIcon from '../../block-icon'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useHover } from 'ahooks'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { Theme } from '@/types/app'
|
||||
import McpToolNotSupportTooltip from '../../nodes/_base/components/mcp-tool-not-support-tooltip'
|
||||
import { Mcp } from '@/app/components/base/icons/src/vender/other'
|
||||
import { basePath } from '@/utils/var'
|
||||
|
||||
const normalizeProviderIcon = (icon: ToolWithProvider['icon']) => {
|
||||
const normalizeProviderIcon = (icon?: ToolWithProvider['icon']) => {
|
||||
if (!icon)
|
||||
return icon
|
||||
if (typeof icon === 'string' && basePath && icon.startsWith('/') && !icon.startsWith(`${basePath}/`))
|
||||
return `${basePath}${icon}`
|
||||
return icon
|
||||
@ -59,6 +63,20 @@ const Tool: FC<Props> = ({
|
||||
const isHovering = useHover(ref)
|
||||
const isMCPTool = payload.type === CollectionType.mcp
|
||||
const isShowCanNotChooseMCPTip = !canChooseMCPTool && isMCPTool
|
||||
const { theme } = useTheme()
|
||||
const normalizedIcon = useMemo<ToolWithProvider['icon']>(() => {
|
||||
return normalizeProviderIcon(payload.icon) ?? payload.icon
|
||||
}, [payload.icon])
|
||||
const normalizedIconDark = useMemo(() => {
|
||||
if (!payload.icon_dark)
|
||||
return undefined
|
||||
return normalizeProviderIcon(payload.icon_dark) ?? payload.icon_dark
|
||||
}, [payload.icon_dark])
|
||||
const providerIcon = useMemo<ToolWithProvider['icon']>(() => {
|
||||
if (theme === Theme.dark && normalizedIconDark)
|
||||
return normalizedIconDark
|
||||
return normalizedIcon
|
||||
}, [theme, normalizedIcon, normalizedIconDark])
|
||||
const getIsDisabled = useCallback((tool: ToolType) => {
|
||||
if (!selectedTools || !selectedTools.length) return false
|
||||
return selectedTools.some(selectedTool => (selectedTool.provider_name === payload.name || selectedTool.provider_name === payload.id) && selectedTool.tool_name === tool.name)
|
||||
@ -95,7 +113,8 @@ const Tool: FC<Props> = ({
|
||||
provider_name: payload.name,
|
||||
plugin_id: payload.plugin_id,
|
||||
plugin_unique_identifier: payload.plugin_unique_identifier,
|
||||
provider_icon: normalizeProviderIcon(payload.icon),
|
||||
provider_icon: normalizedIcon,
|
||||
provider_icon_dark: normalizedIconDark,
|
||||
tool_name: tool.name,
|
||||
tool_label: tool.label[language],
|
||||
tool_description: tool.description[language],
|
||||
@ -177,7 +196,8 @@ const Tool: FC<Props> = ({
|
||||
provider_name: payload.name,
|
||||
plugin_id: payload.plugin_id,
|
||||
plugin_unique_identifier: payload.plugin_unique_identifier,
|
||||
provider_icon: normalizeProviderIcon(payload.icon),
|
||||
provider_icon: normalizedIcon,
|
||||
provider_icon_dark: normalizedIconDark,
|
||||
tool_name: tool.name,
|
||||
tool_label: tool.label[language],
|
||||
tool_description: tool.description[language],
|
||||
@ -192,7 +212,7 @@ const Tool: FC<Props> = ({
|
||||
<BlockIcon
|
||||
className='shrink-0'
|
||||
type={BlockEnum.Tool}
|
||||
toolIcon={payload.icon}
|
||||
toolIcon={providerIcon}
|
||||
/>
|
||||
<div className='ml-2 flex w-0 grow items-center text-sm text-text-primary'>
|
||||
<span className='max-w-[250px] truncate'>{notShowProvider ? actions[0]?.label[language] : payload.label[language]}</span>
|
||||
|
||||
@ -10,6 +10,17 @@ import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import type { TriggerDefaultValue, TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
|
||||
import TriggerPluginActionItem from './action-item'
|
||||
import { Theme } from '@/types/app'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { basePath } from '@/utils/var'
|
||||
|
||||
const normalizeProviderIcon = (icon?: TriggerWithProvider['icon']) => {
|
||||
if (!icon)
|
||||
return icon
|
||||
if (typeof icon === 'string' && basePath && icon.startsWith('/') && !icon.startsWith(`${basePath}/`))
|
||||
return `${basePath}${icon}`
|
||||
return icon
|
||||
}
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
@ -26,6 +37,7 @@ const TriggerPluginItem: FC<Props> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const language = useGetLanguage()
|
||||
const { theme } = useTheme()
|
||||
const notShowProvider = payload.type === CollectionType.workflow
|
||||
const actions = payload.events
|
||||
const hasAction = !notShowProvider
|
||||
@ -55,6 +67,23 @@ const TriggerPluginItem: FC<Props> = ({
|
||||
|
||||
return payload.author || ''
|
||||
}, [payload.author, payload.type, t])
|
||||
const normalizedIcon = useMemo<TriggerWithProvider['icon']>(() => {
|
||||
return normalizeProviderIcon(payload.icon) ?? payload.icon
|
||||
}, [payload.icon])
|
||||
const normalizedIconDark = useMemo(() => {
|
||||
if (!payload.icon_dark)
|
||||
return undefined
|
||||
return normalizeProviderIcon(payload.icon_dark) ?? payload.icon_dark
|
||||
}, [payload.icon_dark])
|
||||
const providerIcon = useMemo<TriggerWithProvider['icon']>(() => {
|
||||
if (theme === Theme.dark && normalizedIconDark)
|
||||
return normalizedIconDark
|
||||
return normalizedIcon
|
||||
}, [normalizedIcon, normalizedIconDark, theme])
|
||||
const providerWithResolvedIcon = useMemo(() => ({
|
||||
...payload,
|
||||
icon: providerIcon,
|
||||
}), [payload, providerIcon])
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -99,7 +128,7 @@ const TriggerPluginItem: FC<Props> = ({
|
||||
<BlockIcon
|
||||
className='shrink-0'
|
||||
type={BlockEnum.TriggerPlugin}
|
||||
toolIcon={payload.icon}
|
||||
toolIcon={providerIcon}
|
||||
/>
|
||||
<div className='ml-2 flex min-w-0 flex-1 items-center text-sm text-text-primary'>
|
||||
<span className='max-w-[200px] truncate'>{notShowProvider ? actions[0]?.label[language] : payload.label[language]}</span>
|
||||
@ -118,7 +147,7 @@ const TriggerPluginItem: FC<Props> = ({
|
||||
actions.map(action => (
|
||||
<TriggerPluginActionItem
|
||||
key={action.name}
|
||||
provider={payload}
|
||||
provider={providerWithResolvedIcon}
|
||||
payload={action}
|
||||
onSelect={onSelect}
|
||||
disabled={false}
|
||||
|
||||
@ -56,9 +56,12 @@ export type ToolDefaultValue = PluginCommonDefaultValue & {
|
||||
paramSchemas: Record<string, any>[]
|
||||
output_schema?: Record<string, any>
|
||||
credential_id?: string
|
||||
use_end_user_credentials?: boolean
|
||||
end_user_credential_type?: string
|
||||
meta?: PluginMeta
|
||||
plugin_id?: string
|
||||
provider_icon?: Collection['icon']
|
||||
provider_icon_dark?: Collection['icon']
|
||||
plugin_unique_identifier?: string
|
||||
}
|
||||
|
||||
@ -86,6 +89,8 @@ export type ToolValue = {
|
||||
enabled?: boolean
|
||||
extra?: Record<string, any>
|
||||
credential_id?: string
|
||||
use_end_user_credentials?: boolean
|
||||
end_user_credential_type?: string
|
||||
}
|
||||
|
||||
export type DataSourceItem = {
|
||||
|
||||
@ -15,6 +15,7 @@ import type { PluginTriggerNodeType } from '../nodes/trigger-plugin/types'
|
||||
import type { ToolNodeType } from '../nodes/tool/types'
|
||||
import type { DataSourceNodeType } from '../nodes/data-source/types'
|
||||
import type { TriggerWithProvider } from '../block-selector/types'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
|
||||
const isTriggerPluginNode = (data: Node['data']): data is PluginTriggerNodeType => data.type === BlockEnum.TriggerPlugin
|
||||
|
||||
@ -22,17 +23,30 @@ const isToolNode = (data: Node['data']): data is ToolNodeType => data.type === B
|
||||
|
||||
const isDataSourceNode = (data: Node['data']): data is DataSourceNodeType => data.type === BlockEnum.DataSource
|
||||
|
||||
type IconValue = ToolWithProvider['icon']
|
||||
|
||||
const resolveIconByTheme = (
|
||||
currentTheme: string | undefined,
|
||||
icon?: IconValue,
|
||||
iconDark?: IconValue,
|
||||
) => {
|
||||
if (currentTheme === 'dark' && iconDark)
|
||||
return iconDark
|
||||
return icon
|
||||
}
|
||||
|
||||
const findTriggerPluginIcon = (
|
||||
identifiers: (string | undefined)[],
|
||||
triggers: TriggerWithProvider[] | undefined,
|
||||
currentTheme?: string,
|
||||
) => {
|
||||
const targetTriggers = triggers || []
|
||||
for (const identifier of identifiers) {
|
||||
if (!identifier)
|
||||
continue
|
||||
const matched = targetTriggers.find(trigger => trigger.id === identifier || canFindTool(trigger.id, identifier))
|
||||
if (matched?.icon)
|
||||
return matched.icon
|
||||
if (matched)
|
||||
return resolveIconByTheme(currentTheme, matched.icon, matched.icon_dark)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@ -44,6 +58,7 @@ export const useToolIcon = (data?: Node['data']) => {
|
||||
const { data: mcpTools } = useAllMCPTools()
|
||||
const dataSourceList = useStore(s => s.dataSourceList)
|
||||
const { data: triggerPlugins } = useAllTriggerPlugins()
|
||||
const { theme } = useTheme()
|
||||
|
||||
const toolIcon = useMemo(() => {
|
||||
if (!data)
|
||||
@ -57,6 +72,7 @@ export const useToolIcon = (data?: Node['data']) => {
|
||||
data.provider_name,
|
||||
],
|
||||
triggerPlugins,
|
||||
theme,
|
||||
)
|
||||
if (icon)
|
||||
return icon
|
||||
@ -100,12 +116,16 @@ export const useToolIcon = (data?: Node['data']) => {
|
||||
return true
|
||||
return data.provider_name === toolWithProvider.name
|
||||
})
|
||||
if (matched?.icon)
|
||||
return matched.icon
|
||||
if (matched) {
|
||||
const icon = resolveIconByTheme(theme, matched.icon, matched.icon_dark)
|
||||
if (icon)
|
||||
return icon
|
||||
}
|
||||
}
|
||||
|
||||
if (data.provider_icon)
|
||||
return data.provider_icon
|
||||
const fallbackIcon = resolveIconByTheme(theme, data.provider_icon, data.provider_icon_dark)
|
||||
if (fallbackIcon)
|
||||
return fallbackIcon
|
||||
|
||||
return ''
|
||||
}
|
||||
@ -114,7 +134,7 @@ export const useToolIcon = (data?: Node['data']) => {
|
||||
return dataSourceList?.find(toolWithProvider => toolWithProvider.plugin_id === data.plugin_id)?.icon || ''
|
||||
|
||||
return ''
|
||||
}, [data, dataSourceList, buildInTools, customTools, workflowTools, mcpTools, triggerPlugins])
|
||||
}, [data, dataSourceList, buildInTools, customTools, workflowTools, mcpTools, triggerPlugins, theme])
|
||||
|
||||
return toolIcon
|
||||
}
|
||||
@ -126,6 +146,7 @@ export const useGetToolIcon = () => {
|
||||
const { data: mcpTools } = useAllMCPTools()
|
||||
const { data: triggerPlugins } = useAllTriggerPlugins()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { theme } = useTheme()
|
||||
|
||||
const getToolIcon = useCallback((data: Node['data']) => {
|
||||
const {
|
||||
@ -144,6 +165,7 @@ export const useGetToolIcon = () => {
|
||||
data.provider_name,
|
||||
],
|
||||
triggerPlugins,
|
||||
theme,
|
||||
)
|
||||
}
|
||||
|
||||
@ -182,12 +204,16 @@ export const useGetToolIcon = () => {
|
||||
return true
|
||||
return data.provider_name === toolWithProvider.name
|
||||
})
|
||||
if (matched?.icon)
|
||||
return matched.icon
|
||||
if (matched) {
|
||||
const icon = resolveIconByTheme(theme, matched.icon, matched.icon_dark)
|
||||
if (icon)
|
||||
return icon
|
||||
}
|
||||
}
|
||||
|
||||
if (data.provider_icon)
|
||||
return data.provider_icon
|
||||
const fallbackIcon = resolveIconByTheme(theme, data.provider_icon, data.provider_icon_dark)
|
||||
if (fallbackIcon)
|
||||
return fallbackIcon
|
||||
|
||||
return undefined
|
||||
}
|
||||
@ -196,7 +222,7 @@ export const useGetToolIcon = () => {
|
||||
return dataSourceList?.find(toolWithProvider => toolWithProvider.plugin_id === data.plugin_id)?.icon
|
||||
|
||||
return undefined
|
||||
}, [workflowStore, triggerPlugins, buildInTools, customTools, workflowTools, mcpTools])
|
||||
}, [workflowStore, triggerPlugins, buildInTools, customTools, workflowTools, mcpTools, theme])
|
||||
|
||||
return getToolIcon
|
||||
}
|
||||
|
||||
@ -325,6 +325,22 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
},
|
||||
})
|
||||
}, [handleNodeDataUpdateWithSyncDraft, id])
|
||||
const handleEndUserCredentialChange = useCallback((enabled: boolean) => {
|
||||
handleNodeDataUpdateWithSyncDraft({
|
||||
id,
|
||||
data: {
|
||||
use_end_user_credentials: enabled,
|
||||
},
|
||||
})
|
||||
}, [handleNodeDataUpdateWithSyncDraft, id])
|
||||
const handleEndUserCredentialTypeChange = useCallback((type: string) => {
|
||||
handleNodeDataUpdateWithSyncDraft({
|
||||
id,
|
||||
data: {
|
||||
end_user_credential_type: type,
|
||||
},
|
||||
})
|
||||
}, [handleNodeDataUpdateWithSyncDraft, id])
|
||||
|
||||
const { setShowAccountSettingModal } = useModalContext()
|
||||
|
||||
@ -520,6 +536,11 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
needsToolAuth && (
|
||||
<PluginAuth
|
||||
className='px-4 pb-2'
|
||||
showConnectGuide
|
||||
endUserCredentialEnabled={data.use_end_user_credentials}
|
||||
endUserCredentialType={data.end_user_credential_type}
|
||||
onEndUserCredentialChange={handleEndUserCredentialChange}
|
||||
onEndUserCredentialTypeChange={handleEndUserCredentialTypeChange}
|
||||
pluginPayload={{
|
||||
provider: currToolCollection?.name || '',
|
||||
providerType: currToolCollection?.type || '',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user