Compare commits
637
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c61df1942f | ||
|
|
0536549f73 | ||
|
|
d0956039e7 | ||
|
|
38eb04dc98 | ||
|
|
d2e1da269c | ||
|
|
1d3498f659 | ||
|
|
b8dea56198 | ||
|
|
e2becd6746 | ||
|
|
28a26f2d59 | ||
|
|
2566ab9105 | ||
|
|
8c7393ef46 | ||
|
|
b116faaf10 | ||
|
|
5a7a955210 | ||
|
|
3e4849d765 | ||
|
|
0c280ef708 | ||
|
|
282561a861 | ||
|
|
cbb4cc5d76 | ||
|
|
2d6babeeb4 | ||
|
|
1065a4840a | ||
|
|
b6aa5a7d69 | ||
|
|
949f930698 | ||
|
|
65a08ed7ab | ||
|
|
cc4d6db7c8 | ||
|
|
6b5d6dacb2 | ||
|
|
89bf75eba9 | ||
|
|
3a28868a6c | ||
|
|
4036515abe | ||
|
|
6c089cab66 | ||
|
|
818a71d637 | ||
|
|
3db107edc9 | ||
|
|
2677d90860 | ||
|
|
859756c4f6 | ||
|
|
295fb6e74a | ||
|
|
2326fb7a83 | ||
|
|
2d6eaf69f9 | ||
|
|
3e826c0000 | ||
|
|
b1b977e284 | ||
|
|
23648141c9 | ||
|
|
d6dee43c09 | ||
|
|
7efc887e32 | ||
|
|
8b346e69d9 | ||
|
|
ef7ff3356d | ||
|
|
7b5c0b5045 | ||
|
|
f00512dd5d | ||
|
|
e6ef774fd5 | ||
|
|
ce50c6cf1c | ||
|
|
7002512106 | ||
|
|
c3aebb8403 | ||
|
|
0baefa6163 | ||
|
|
7bcedcbaab | ||
|
|
791fc5819d | ||
|
|
2d09c4788d | ||
|
|
9bd5c2f8ec | ||
|
|
5e336c47fd | ||
|
|
be4c828214 | ||
|
|
ec450eb7f9 | ||
|
|
48e13f65dc | ||
|
|
38fc2a6574 | ||
|
|
ed8d3f3e8d | ||
|
|
0c8dec3315 | ||
|
|
38e831c1b3 | ||
|
|
1c5d62d98a | ||
|
|
6b4736bf78 | ||
|
|
c9503fd818 | ||
|
|
91a1df96cb | ||
|
|
5b2c5da945 | ||
|
|
b59ecea346 | ||
|
|
61c0948136 | ||
|
|
f746c7bdf2 | ||
|
|
2a3deee385 | ||
|
|
4b6803ba06 | ||
|
|
4c908c8f39 | ||
|
|
afec528f51 | ||
|
|
491061b8f4 | ||
|
|
8b1533438f | ||
|
|
ba924fc97b | ||
|
|
712e522220 | ||
|
|
33eebe8cfc | ||
|
|
2e1b11bdb2 | ||
|
|
d65a6b4810 | ||
|
|
44a91e344c | ||
|
|
0fec9af6a6 | ||
|
|
5e5113e08e | ||
|
|
73f9a9e7d6 | ||
|
|
48d23cd744 | ||
|
|
0b60bf6ef0 | ||
|
|
3b24d8d2d1 | ||
|
|
051ba99cd2 | ||
|
|
dc83e8aa09 | ||
|
|
77f8f2babb | ||
|
|
77d6c108e7 | ||
|
|
c2a5962023 | ||
|
|
d583b1b835 | ||
|
|
da00de6688 | ||
|
|
a633387e9b | ||
|
|
df389eba1c | ||
|
|
5cae61eb5a | ||
|
|
ba8e0681d5 | ||
|
|
de123a8695 | ||
|
|
21e5962f98 | ||
|
|
db60e649b8 | ||
|
|
e561788809 | ||
|
|
3cd6ef4464 | ||
|
|
39dc636b02 | ||
|
|
4f03b7193e | ||
|
|
0d921cd21d | ||
|
|
1a7e46368e | ||
|
|
8c8ad02a6f | ||
|
|
8f070f2190 | ||
|
|
c7641bb1ce | ||
|
|
97bb338e7d | ||
|
|
ceec00e172 | ||
|
|
d81444683b | ||
|
|
3627c1e720 | ||
|
|
ba5d8c1b29 | ||
|
|
26e50bb084 | ||
|
|
b351ab00a2 | ||
|
|
059d605a6d | ||
|
|
f8a4205ac4 | ||
|
|
25dd79fba7 | ||
|
|
0c8acd402a | ||
|
|
3e876e173a | ||
|
|
ae9c4244d6 | ||
|
|
3c7d6739b5 | ||
|
|
560195f9f4 | ||
|
|
9ce7464c79 | ||
|
|
f980d5d3be | ||
|
|
96122692cb | ||
|
|
b9c300d570 | ||
|
|
dfcc0f8863 | ||
|
|
f56ce9d3b1 | ||
|
|
bd25240123 | ||
|
|
0020aa8f59 | ||
|
|
e70e4fa41d | ||
|
|
881a9a1a08 | ||
|
|
a74e12809b | ||
|
+4 |
eaddd4a132 | ||
|
|
0c41d0bf51 | ||
|
|
90e281c8da | ||
|
|
90d638fba3 | ||
|
|
f5e9b02565 | ||
|
|
de15e5b449 | ||
|
+4 |
b6c7581a31 | ||
|
|
af8ffa0654 | ||
|
|
4d79b4a766 | ||
|
|
af21dc7df8 | ||
|
|
dc3f992e6e | ||
|
|
13a9359191 | ||
|
|
f07f9ee7a3 | ||
|
|
7396230223 | ||
|
|
ed14f58697 | ||
|
|
37714cb44e | ||
|
|
a5ce17009f | ||
|
|
b565a51ed9 | ||
|
|
6ca066983d | ||
|
|
40e040ca1a | ||
|
|
4289cb2634 | ||
|
|
c966e281d4 | ||
|
|
abb84f1c38 | ||
|
|
e507675860 | ||
|
|
3445469385 | ||
|
|
3193e8a712 | ||
|
|
e8af6a6b3b | ||
|
|
7f4fe4d064 | ||
|
|
a1f990584b | ||
|
|
54e51be665 | ||
|
|
0fea760143 | ||
|
|
c3eff6abdc | ||
|
|
c661d5c43a | ||
|
|
b665eaa015 | ||
|
|
b08665e598 | ||
|
|
883d757392 | ||
|
|
25df902ec4 | ||
|
|
5956dd79df | ||
|
|
70556d9386 | ||
|
|
9fa50774b4 | ||
|
|
731414a44f | ||
|
|
d42d08aa57 | ||
|
|
987b5f4bf4 | ||
|
|
665978a602 | ||
|
|
8baa864c35 | ||
|
|
53a22aa41b | ||
|
|
cf4d7afb9c | ||
|
|
e6b5923ff1 | ||
|
|
538093855b | ||
|
|
e6b8cbe657 | ||
|
|
af7d5e60b4 | ||
|
|
dbceb3067e | ||
|
|
425457cb16 | ||
|
|
e5bd18132c | ||
|
|
2f33867d07 | ||
|
|
fd71c56f16 | ||
|
|
e3c2116501 | ||
|
|
fb17339d89 | ||
|
|
9fd196642d | ||
|
|
98897a5379 | ||
|
|
5542329554 | ||
|
|
79332c0e5e | ||
|
|
50a55513d4 | ||
|
|
3bccdd6c9a | ||
|
|
76af80e332 | ||
|
|
7a880ae60c | ||
|
|
5bc0f9513b | ||
|
|
b77801ece9 | ||
|
|
7de92c598f | ||
|
|
693080aa12 | ||
|
|
25c388d0db | ||
|
|
b1722c8af9 | ||
|
|
b65a5fcd97 | ||
|
|
1c3cba281a | ||
|
|
800954f8ce | ||
|
|
f66a3c49c4 | ||
|
|
ef396ac84e | ||
|
|
7e7b27fdec | ||
|
|
9c90c1c455 | ||
|
|
b1df52b8ff | ||
|
|
e527b7c5f1 | ||
|
|
149b9d4c0f | ||
|
|
ef28a63ad3 | ||
|
|
e78558bc06 | ||
|
|
f63d7c4121 | ||
|
|
ef062fb397 | ||
|
|
a2ea7ca039 | ||
|
|
6876cd787b | ||
|
|
50a6892c3a | ||
|
|
1bcc7f78c7 | ||
|
|
2fd5b76ac1 | ||
|
|
62f42b3f24 | ||
|
|
2c58b424a1 | ||
|
|
381c518b23 | ||
|
|
ebf741114d | ||
|
|
648dde5e96 | ||
|
|
a3042e6332 | ||
|
|
e5fd3133f4 | ||
|
|
e1bbe57f9c | ||
|
|
d4783e8c14 | ||
|
|
736880e046 | ||
|
|
bd7a9b5fcf | ||
|
|
9a47bb2f80 | ||
|
|
d7ad2baf79 | ||
|
|
a951cc996b | ||
|
|
173e0d6f35 | ||
|
|
62bb830338 | ||
|
|
f7c6270f74 | ||
|
|
711fe6ba2c | ||
|
|
fbedb60371 | ||
|
|
974d2f1627 | ||
|
|
ed401728eb | ||
|
|
fc389a54c5 | ||
|
|
c8b372dba0 | ||
|
|
2333d75c56 | ||
|
|
2ef9a8a769 | ||
|
|
21ab9b9d8c | ||
|
|
79c1473378 | ||
|
|
93b8a74351 | ||
|
|
28185170b0 | ||
|
|
178883b4cc | ||
|
|
e9f9041b25 | ||
|
|
175290fa04 | ||
|
|
b0c4d8c541 | ||
|
|
0f643bca76 | ||
|
|
eeebedcfe8 | ||
|
|
2f682780fa | ||
|
|
ed83f5369e | ||
|
|
4ee1bd5f32 | ||
|
|
1c2bbed405 | ||
|
|
d573fc0e65 | ||
|
|
f8b249e649 | ||
|
|
fbcab757d5 | ||
|
|
c0e998ef6e | ||
|
|
84f25807db | ||
|
|
83b242be7b | ||
|
|
a12d740a5d | ||
|
|
3bbb014dc7 | ||
|
|
f040733e28 | ||
|
|
b0bf7ca486 | ||
|
|
14d83c8bac | ||
|
|
8b506dfa42 | ||
|
|
ac2258c2dc | ||
|
|
3c279edcf2 | ||
|
|
9ed8a5ed73 | ||
|
|
3d4ddf4a6f | ||
|
|
4e0273bb28 | ||
|
|
7056d2ae99 | ||
|
|
d8fbc00cb9 | ||
|
|
57c5f0ec87 | ||
|
|
e5bd80c719 | ||
|
|
25a33a454c | ||
|
|
bd30784b1d | ||
|
|
28fce0a890 | ||
|
|
e1eb582bea | ||
|
|
2042ee453b | ||
|
|
33c4e512f1 | ||
|
|
253e8a3f98 | ||
|
|
06b63d65d1 | ||
|
|
08f3133414 | ||
|
|
d412cddf39 | ||
|
|
671c5cdd84 | ||
|
|
554f060092 | ||
|
|
e243e8d8a3 | ||
|
|
1b935a367f | ||
|
|
2edd083a71 | ||
|
|
dd50a68bf2 | ||
|
|
e8dd3461e8 | ||
|
|
8dd4473432 | ||
|
|
b5bbbdd840 | ||
|
|
f0266e13c5 | ||
|
|
ae898652b2 | ||
|
|
c34f67495c | ||
|
|
815c536e05 | ||
|
|
fc64427ae1 | ||
|
|
11c518478e | ||
|
|
e823635ce1 | ||
|
|
98e74c8fde | ||
|
|
29bfa33d59 | ||
|
|
3ead0beeb1 | ||
|
|
2108c44c8b | ||
|
|
b0079e55b4 | ||
|
|
d9f54f8bd7 | ||
|
|
5a446f8200 | ||
|
|
f4d5e2f43d | ||
|
|
9121f24181 | ||
|
|
7dd507af04 | ||
|
|
3b9aad2ba7 | ||
|
|
ea9f74b581 | ||
|
|
e37aaa482d | ||
|
|
a3170f744c | ||
|
|
ced3780787 | ||
|
|
6faf26683c | ||
|
|
8ac9cbf733 | ||
|
|
098ed34469 | ||
|
|
6cf4d1002f | ||
|
|
a111d56ea3 | ||
|
|
8436470fcb | ||
|
|
17da0e4146 | ||
|
|
ea41e9ab4e | ||
|
|
5770b5feef | ||
|
|
b5259a3a85 | ||
|
|
596559efc9 | ||
|
|
b7b03f8594 | ||
|
|
61ef255809 | ||
|
|
08426376ac | ||
|
|
d0262c899e | ||
|
|
152433d88a | ||
|
|
dece58d1a5 | ||
|
|
70be474aac | ||
|
|
a852cbe7f2 | ||
|
|
7df38d35c1 | ||
|
|
ef29a5ee3d | ||
|
|
9a7fe7ef16 | ||
|
|
8c4ea5c898 | ||
|
|
d06bc2f2e1 | ||
|
|
534fea7104 | ||
|
|
bc2b9eec58 | ||
|
|
88c38ddeb3 | ||
|
|
602753b68a | ||
|
|
44ebfa3bb8 | ||
|
|
6bacf7f953 | ||
|
|
095962f13e | ||
|
|
0862fd74b0 | ||
|
|
e0139f91c8 | ||
|
|
64920ef648 | ||
|
|
7ba70869aa | ||
|
|
f67297688f | ||
|
|
0841b4c663 | ||
|
|
440602f52a | ||
|
|
510120410b | ||
|
|
4ef67fef3a | ||
|
|
45561bed9d | ||
|
|
7bd5e80323 | ||
|
|
7515eee0a8 | ||
|
|
452067db19 | ||
|
|
859920a81f | ||
|
|
34ce3cac70 | ||
|
|
12814b55d2 | ||
|
|
50206ae8a7 | ||
|
|
169184ac9b | ||
|
|
33bc58c9c2 | ||
|
|
65d66768c1 | ||
|
|
c960f7ae48 | ||
|
|
d5104a4268 | ||
|
|
9069c01f9c | ||
|
|
0ff41a1127 | ||
|
|
7192af41e4 | ||
|
|
5ec387b644 | ||
|
|
4be479fa06 | ||
|
|
e0d69204cd | ||
|
|
f2d6275da4 | ||
|
|
992ac38d0d | ||
|
|
f962e61315 | ||
|
|
b3aebb71ff | ||
|
|
98d3bcd079 | ||
|
|
1703df5c00 | ||
|
|
674495680d | ||
|
|
04f5fe5e38 | ||
|
|
1b7d0bd4e6 | ||
|
|
66183c1f0a | ||
|
|
130ad295d0 | ||
|
|
6612ba69b1 | ||
|
|
2dc015b360 | ||
|
|
2eb43b1e1f | ||
|
|
8633b2f1f7 | ||
|
|
c9f525a3b2 | ||
|
|
e224c77920 | ||
|
|
28b8215c9b | ||
|
|
98eedf14dc | ||
|
|
cd3ee5bd5d | ||
|
|
26e8f1f876 | ||
|
|
af55665ff2 | ||
|
|
bcd738d2e6 | ||
|
|
488fcd4f83 | ||
|
|
5d4d60bb95 | ||
|
|
7f4bf19186 | ||
|
|
07c573e52f | ||
|
|
660c7e4a43 | ||
|
|
5fafac0ca4 | ||
|
|
c41b62f47e | ||
|
|
f42c1b68a4 | ||
|
|
b90fe73c96 | ||
|
|
d19f47b458 | ||
|
|
86fd94767c | ||
|
|
d826ac7099 | ||
|
|
40e23ce8dc | ||
|
|
d50f096b14 | ||
|
|
1117b6e72d | ||
|
|
c5c5c71d15 | ||
|
|
a31c1d2c69 | ||
|
|
2352269ba9 | ||
|
|
985e71ebf4 | ||
|
|
4d57f04a26 | ||
|
|
ab3b305682 | ||
|
|
b8858708be | ||
|
|
0a6494abfb | ||
|
|
75b88a5416 | ||
|
|
e143dbce50 | ||
|
|
8ad131bb3b | ||
|
|
41eeb1f2e7 | ||
|
|
02c1bfc3e7 | ||
|
|
d042cbc62e | ||
|
|
03750b76ac | ||
|
|
1befd2a602 | ||
|
|
d1e33ba9ea | ||
|
|
7d793e12c8 | ||
|
|
1ce6e279f0 | ||
|
|
ec56f4e839 | ||
|
|
d5ababfed0 | ||
|
|
8225f98565 | ||
|
|
4c05316a7b | ||
|
|
66e588c8ca | ||
|
|
9a51c2f56a | ||
|
|
ee789db443 | ||
|
|
d360929af1 | ||
|
|
5f53748d07 | ||
|
|
e3cc4b83c8 | ||
|
|
b5acc8e392 | ||
|
|
f5ea61e93e | ||
|
|
a76a8876d1 | ||
|
|
be1f4b34f8 | ||
|
|
c19a822e1b | ||
|
|
8782787a9e | ||
|
|
4c6b8f9229 | ||
|
|
51dcf4ce84 | ||
|
|
27e484e7f8 | ||
|
|
9308287fea | ||
|
|
7ca5b726a2 | ||
|
|
0bdd1267fb | ||
|
|
3ea88dfc7f | ||
|
|
2275c5b1a3 | ||
|
|
1c7cf44af4 | ||
|
|
3325392cc5 | ||
|
|
fd2843b0fb | ||
|
|
1898a3f8a5 | ||
|
|
9c4f897b9a | ||
|
|
47b9d48f70 | ||
|
|
ce68f2cdc6 | ||
|
|
a8fa552b3a | ||
|
|
bd257777a0 | ||
|
|
e6715a2dbe | ||
|
|
8f46c9113c | ||
|
|
5aa4e23f54 | ||
|
|
5821511114 | ||
|
|
d6d9b04c41 | ||
|
|
540289e6c6 | ||
|
|
1d971d3240 | ||
|
|
02a9f0abca | ||
|
|
289f091bf9 | ||
|
|
1a4eb47e1d | ||
|
|
4c70bfa8b8 | ||
|
|
3a4756449a | ||
|
|
55b7ea04a7 | ||
|
|
ccfc8c6f15 | ||
|
|
4fb3fab82d | ||
|
|
3cea0dfb07 | ||
|
|
0d6db3a3f3 | ||
|
|
3d5a81bd30 | ||
|
|
208604a3a8 | ||
|
|
63bfba0bdb | ||
|
|
9948a51b14 | ||
|
|
0e0bb3582f | ||
|
|
546062d2cd | ||
|
|
aad0b3c157 | ||
|
|
4d4265f531 | ||
|
|
e138523123 | ||
|
|
a65e1f71b4 | ||
|
|
909c062ee1 | ||
|
|
f5322e45fc | ||
|
|
017f09f1e9 | ||
|
|
0ba66ab155 | ||
|
|
5cd267d755 | ||
|
|
d30946dabf | ||
|
|
b0e524213e | ||
|
|
b1adb5652e | ||
|
|
c825d5dcf6 | ||
|
|
2127d5850f | ||
|
|
ae9fcc2969 | ||
|
|
624db69f12 | ||
|
|
80a7843f45 | ||
|
|
cb55176612 | ||
|
|
5aa2524d33 | ||
|
|
2575a3a3ab | ||
|
|
f8f7b0ec1a | ||
|
|
d2ee486900 | ||
|
|
c44ddd9831 | ||
|
|
e645cbd8f8 | ||
|
|
485fc2c416 | ||
|
|
f09be969bb | ||
|
|
597a0b4d9f | ||
|
|
779cce3c61 | ||
|
|
b5d9a71cf9 | ||
|
|
c2af415450 | ||
|
|
89ce61cfea | ||
|
|
05c5327f47 | ||
|
|
3891c0a255 | ||
|
|
63b1d0c1ea | ||
|
|
75ed38fb3d | ||
|
|
63db9a7a2f | ||
|
|
19c80f0f0e | ||
|
|
c5a0bde3ec | ||
|
|
1261e5e5e8 | ||
|
|
e2ecd68556 | ||
|
|
bceb0eee9b | ||
|
|
173e818a62 | ||
|
|
84d8940dbf | ||
|
|
3e995e6a6d | ||
|
|
459c36f21b | ||
|
|
72adb5468c | ||
|
|
1194957fde | ||
|
|
68bd29eda2 | ||
|
|
f67a811f7f | ||
|
|
b9c122e7f4 | ||
|
|
396b39dff9 | ||
|
|
ac8bd12609 | ||
|
|
b55bef4438 | ||
|
|
2f9667de76 | ||
|
|
a7b6307d32 | ||
|
|
2883ad6764 | ||
|
|
0feff5b048 | ||
|
|
0bce6b35b4 | ||
|
|
89e23456f0 | ||
|
|
a39173c227 | ||
|
|
12e93d374f | ||
|
|
922f9242e4 | ||
|
|
7fc0a791a2 | ||
|
|
8d37116fec | ||
|
|
4b500f988d | ||
|
|
5ad906ea6a | ||
|
|
5b862a43e0 | ||
|
|
1e5cd69205 | ||
|
|
9081c46565 | ||
|
|
40b252be8c | ||
|
|
ba1357038a | ||
|
|
46d1f4c338 | ||
|
|
9c880dd650 | ||
|
|
01ba0e050f | ||
|
|
ccc4aae94e | ||
|
|
01242e13d7 | ||
|
|
938ee27e42 | ||
|
|
a101f72153 | ||
|
|
40642433d8 | ||
|
|
8979181d5e | ||
|
|
c17c6b5c35 | ||
|
|
e83a4090ac | ||
|
|
b71b9f80b9 | ||
|
|
ee87289917 | ||
|
|
5ad8c3e249 | ||
|
|
8b992513b8 | ||
|
|
eca0cdc7a9 | ||
|
|
779e6b8e0b | ||
|
|
c2428361c4 | ||
|
|
68e4d13f36 | ||
|
|
cb9f4bb100 | ||
|
|
8a398f3105 | ||
|
|
0f051d5886 | ||
|
|
e85d9a0d72 | ||
|
|
06dde4f503 | ||
|
|
83d4176785 | ||
|
|
c94951b2f8 | ||
|
|
a9cf8f6c5d | ||
|
|
64ddec0d67 | ||
|
|
da3b0caf5e | ||
|
|
4fedd43af5 | ||
|
|
a263f28e19 | ||
|
|
d53862f135 | ||
|
|
608958de1c | ||
|
|
7eb632eb34 | ||
|
|
33d4fd357c | ||
|
|
e55bd61c17 | ||
|
|
f2fc213d52 | ||
|
|
f814579ed2 | ||
|
|
71d299d0d3 | ||
|
|
e178451d04 | ||
|
|
9a6222f245 | ||
|
|
affe5ed30b | ||
|
|
4cc5401d7e | ||
|
|
36e840cd87 | ||
|
|
985b41c40b | ||
|
|
2e29ac2829 | ||
|
|
dbfb474eab | ||
|
|
d243de26ec | ||
|
|
894826771a | ||
|
|
a3386da5d6 | ||
|
|
318a3d0308 | ||
|
|
5bafb163cc | ||
|
|
52b1bc5b09 | ||
|
|
1873b22e96 | ||
|
|
9a8c853a2e | ||
|
|
e54383d0fe | ||
|
|
43c48ba4d7 | ||
|
|
8f9dbf269e | ||
|
|
cb9ee5903a | ||
|
|
cd406d2794 | ||
|
|
993a301468 | ||
|
|
399d3f8da5 | ||
|
|
f9d9ad7a38 | ||
|
|
2d29345f26 | ||
|
|
725f9e3dc4 | ||
|
|
4e1d060439 |
@@ -367,7 +367,7 @@ For each extraction:
|
|||||||
┌────────────────────────────────────────┐
|
┌────────────────────────────────────────┐
|
||||||
│ 1. Extract code │
|
│ 1. Extract code │
|
||||||
│ 2. Run: pnpm lint:fix │
|
│ 2. Run: pnpm lint:fix │
|
||||||
│ 3. Run: pnpm type-check:tsgo │
|
│ 3. Run: pnpm type-check │
|
||||||
│ 4. Run: pnpm test │
|
│ 4. Run: pnpm test │
|
||||||
│ 5. Test functionality manually │
|
│ 5. Test functionality manually │
|
||||||
│ 6. PASS? → Next extraction │
|
│ 6. PASS? → Next extraction │
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
name: e2e-cucumber-playwright
|
||||||
|
description: Write, update, or review Dify end-to-end tests under `e2e/` that use Cucumber, Gherkin, and Playwright. Use when the task involves `.feature` files, `features/step-definitions/`, `features/support/`, `DifyWorld`, scenario tags, locator/assertion choices, or E2E testing best practices for this repository.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Dify E2E Cucumber + Playwright
|
||||||
|
|
||||||
|
Use this skill for Dify's repository-level E2E suite in `e2e/`. Use [`e2e/AGENTS.md`](../../../e2e/AGENTS.md) as the canonical guide for local architecture and conventions, then apply Playwright/Cucumber best practices only where they fit the current suite.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Use this skill for `.feature` files, Cucumber step definitions, `DifyWorld`, hooks, tags, and E2E review work under `e2e/`.
|
||||||
|
- Do not use this skill for Vitest or React Testing Library work under `web/`; use `frontend-testing` instead.
|
||||||
|
- Do not use this skill for backend test or API review tasks under `api/`.
|
||||||
|
|
||||||
|
## Read Order
|
||||||
|
|
||||||
|
1. Read [`e2e/AGENTS.md`](../../../e2e/AGENTS.md) first.
|
||||||
|
2. Read only the files directly involved in the task:
|
||||||
|
- target `.feature` files under `e2e/features/`
|
||||||
|
- related step files under `e2e/features/step-definitions/`
|
||||||
|
- `e2e/features/support/hooks.ts` and `e2e/features/support/world.ts` when session lifecycle or shared state matters
|
||||||
|
- `e2e/scripts/run-cucumber.ts` and `e2e/cucumber.config.ts` when tags or execution flow matter
|
||||||
|
3. Read [`references/playwright-best-practices.md`](references/playwright-best-practices.md) only when locator, assertion, isolation, or waiting choices are involved.
|
||||||
|
4. Read [`references/cucumber-best-practices.md`](references/cucumber-best-practices.md) only when scenario wording, step granularity, tags, or expression design are involved.
|
||||||
|
5. Re-check official docs with Context7 before introducing a new Playwright or Cucumber pattern.
|
||||||
|
|
||||||
|
## Local Rules
|
||||||
|
|
||||||
|
- `e2e/` uses Cucumber for scenarios and Playwright as the browser layer.
|
||||||
|
- `DifyWorld` is the per-scenario context object. Type `this` as `DifyWorld` and use `async function`, not arrow functions.
|
||||||
|
- Keep glue organized by capability under `e2e/features/step-definitions/`; use `common/` only for broadly reusable steps.
|
||||||
|
- Browser session behavior comes from `features/support/hooks.ts`:
|
||||||
|
- default: authenticated session with shared storage state
|
||||||
|
- `@unauthenticated`: clean browser context
|
||||||
|
- `@authenticated`: readability/selective-run tag only unless implementation changes
|
||||||
|
- `@fresh`: only for `e2e:full*` flows
|
||||||
|
- Do not import Playwright Test runner patterns that bypass the current Cucumber + `DifyWorld` architecture unless the task is explicitly about changing that architecture.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Rebuild local context.
|
||||||
|
- Inspect the target feature area.
|
||||||
|
- Reuse an existing step when wording and behavior already match.
|
||||||
|
- Add a new step only for a genuinely new user action or assertion.
|
||||||
|
- Keep edits close to the current capability folder unless the step is broadly reusable.
|
||||||
|
2. Write behavior-first scenarios.
|
||||||
|
- Describe user-observable behavior, not DOM mechanics.
|
||||||
|
- Keep each scenario focused on one workflow or outcome.
|
||||||
|
- Keep scenarios independent and re-runnable.
|
||||||
|
3. Write step definitions in the local style.
|
||||||
|
- Keep one step to one user-visible action or one assertion.
|
||||||
|
- Prefer Cucumber Expressions such as `{string}` and `{int}`.
|
||||||
|
- Scope locators to stable containers when the page has repeated elements.
|
||||||
|
- Avoid page-object layers or extra helper abstractions unless repeated complexity clearly justifies them.
|
||||||
|
4. Use Playwright in the local style.
|
||||||
|
- Prefer user-facing locators: `getByRole`, `getByLabel`, `getByPlaceholder`, `getByText`, then `getByTestId` for explicit contracts.
|
||||||
|
- Use web-first `expect(...)` assertions.
|
||||||
|
- Do not use `waitForTimeout`, manual polling, or raw visibility checks when a locator action or retrying assertion already expresses the behavior.
|
||||||
|
5. Validate narrowly.
|
||||||
|
- Run the narrowest tagged scenario or flow that exercises the change.
|
||||||
|
- Run `pnpm -C e2e check`.
|
||||||
|
- Broaden verification only when the change affects hooks, tags, setup, or shared step semantics.
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
|
||||||
|
- Does the scenario describe behavior rather than implementation?
|
||||||
|
- Does it fit the current session model, tags, and `DifyWorld` usage?
|
||||||
|
- Should an existing step be reused instead of adding a new one?
|
||||||
|
- Are locators user-facing and assertions web-first?
|
||||||
|
- Does the change introduce hidden coupling across scenarios, tags, or instance state?
|
||||||
|
- Does it document or implement behavior that differs from the real hooks or configuration?
|
||||||
|
|
||||||
|
Lead findings with correctness, flake risk, and architecture drift.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [`references/playwright-best-practices.md`](references/playwright-best-practices.md)
|
||||||
|
- [`references/cucumber-best-practices.md`](references/cucumber-best-practices.md)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "E2E Cucumber + Playwright"
|
||||||
|
short_description: "Write and review Dify E2E scenarios."
|
||||||
|
default_prompt: "Use $e2e-cucumber-playwright to write or review a Dify E2E scenario under e2e/."
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Cucumber Best Practices For Dify E2E
|
||||||
|
|
||||||
|
Use this reference when writing or reviewing Gherkin scenarios, step definitions, parameter expressions, and step reuse in Dify's `e2e/` suite.
|
||||||
|
|
||||||
|
Official sources:
|
||||||
|
|
||||||
|
- https://cucumber.io/docs/guides/10-minute-tutorial/
|
||||||
|
- https://cucumber.io/docs/cucumber/step-definitions/
|
||||||
|
- https://cucumber.io/docs/cucumber/cucumber-expressions/
|
||||||
|
|
||||||
|
## What Matters Most
|
||||||
|
|
||||||
|
### 1. Treat scenarios as executable specifications
|
||||||
|
|
||||||
|
Cucumber scenarios should describe examples of behavior, not test implementation recipes.
|
||||||
|
|
||||||
|
Apply it like this:
|
||||||
|
|
||||||
|
- write what the user does and what should happen
|
||||||
|
- avoid UI-internal wording such as selector details, DOM structure, or component names
|
||||||
|
- keep language concrete enough that the scenario reads like living documentation
|
||||||
|
|
||||||
|
### 2. Keep scenarios focused
|
||||||
|
|
||||||
|
A scenario should usually prove one workflow or business outcome. If a scenario wanders across several unrelated behaviors, split it.
|
||||||
|
|
||||||
|
In Dify's suite, this means:
|
||||||
|
|
||||||
|
- one capability-focused scenario per feature path
|
||||||
|
- no long setup chains when existing bootstrap or reusable steps already cover them
|
||||||
|
- no hidden dependency on another scenario's side effects
|
||||||
|
|
||||||
|
### 3. Reuse steps, but only when behavior really matches
|
||||||
|
|
||||||
|
Good reuse reduces duplication. Bad reuse hides meaning.
|
||||||
|
|
||||||
|
Prefer reuse when:
|
||||||
|
|
||||||
|
- the user action is genuinely the same
|
||||||
|
- the expected outcome is genuinely the same
|
||||||
|
- the wording stays natural across features
|
||||||
|
|
||||||
|
Write a new step when:
|
||||||
|
|
||||||
|
- the behavior is materially different
|
||||||
|
- reusing the old wording would make the scenario misleading
|
||||||
|
- a supposedly generic step would become an implementation-detail wrapper
|
||||||
|
|
||||||
|
### 4. Prefer Cucumber Expressions
|
||||||
|
|
||||||
|
Use Cucumber Expressions for parameters unless regex is clearly necessary.
|
||||||
|
|
||||||
|
Common examples:
|
||||||
|
|
||||||
|
- `{string}` for labels, names, and visible text
|
||||||
|
- `{int}` for counts
|
||||||
|
- `{float}` for decimal values
|
||||||
|
- `{word}` only when the value is truly a single token
|
||||||
|
|
||||||
|
Keep expressions readable. If a step needs complicated parsing logic, first ask whether the scenario wording should be simpler.
|
||||||
|
|
||||||
|
### 5. Keep step definitions thin and meaningful
|
||||||
|
|
||||||
|
Step definitions are glue between Gherkin and automation, not a second abstraction language.
|
||||||
|
|
||||||
|
For Dify:
|
||||||
|
|
||||||
|
- type `this` as `DifyWorld`
|
||||||
|
- use `async function`
|
||||||
|
- keep each step to one user-visible action or assertion
|
||||||
|
- rely on `DifyWorld` and existing support code for shared context
|
||||||
|
- avoid leaking cross-scenario state
|
||||||
|
|
||||||
|
### 6. Use tags intentionally
|
||||||
|
|
||||||
|
Tags should communicate run scope or session semantics, not become ad hoc metadata.
|
||||||
|
|
||||||
|
In Dify's current suite:
|
||||||
|
|
||||||
|
- capability tags group related scenarios
|
||||||
|
- `@unauthenticated` changes session behavior
|
||||||
|
- `@authenticated` is descriptive/selective, not a behavior switch by itself
|
||||||
|
- `@fresh` belongs to reset/full-install flows only
|
||||||
|
|
||||||
|
If a proposed tag implies behavior, verify that hooks or runner configuration actually implement it.
|
||||||
|
|
||||||
|
## Review Questions
|
||||||
|
|
||||||
|
- Does the scenario read like a real example of product behavior?
|
||||||
|
- Are the steps behavior-oriented instead of implementation-oriented?
|
||||||
|
- Is a reused step still truthful in this feature?
|
||||||
|
- Is a new tag documenting real behavior, or inventing semantics that the suite does not implement?
|
||||||
|
- Would a new reader understand the outcome without opening the step-definition file?
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Playwright Best Practices For Dify E2E
|
||||||
|
|
||||||
|
Use this reference when writing or reviewing locator, assertion, isolation, or synchronization logic for Dify's Cucumber-based E2E suite.
|
||||||
|
|
||||||
|
Official sources:
|
||||||
|
|
||||||
|
- https://playwright.dev/docs/best-practices
|
||||||
|
- https://playwright.dev/docs/locators
|
||||||
|
- https://playwright.dev/docs/test-assertions
|
||||||
|
- https://playwright.dev/docs/browser-contexts
|
||||||
|
|
||||||
|
## What Matters Most
|
||||||
|
|
||||||
|
### 1. Keep scenarios isolated
|
||||||
|
|
||||||
|
Playwright's model is built around clean browser contexts so one test does not leak into another. In Dify's suite, that principle maps to per-scenario session setup in `features/support/hooks.ts` and `DifyWorld`.
|
||||||
|
|
||||||
|
Apply it like this:
|
||||||
|
|
||||||
|
- do not depend on another scenario having run first
|
||||||
|
- do not persist ad hoc scenario state outside `DifyWorld`
|
||||||
|
- do not couple ordinary scenarios to `@fresh` behavior
|
||||||
|
- when a flow needs special auth/session semantics, express that through the existing tag model or explicit hook changes
|
||||||
|
|
||||||
|
### 2. Prefer user-facing locators
|
||||||
|
|
||||||
|
Playwright recommends built-in locators that reflect what users perceive on the page.
|
||||||
|
|
||||||
|
Preferred order in this repository:
|
||||||
|
|
||||||
|
1. `getByRole`
|
||||||
|
2. `getByLabel`
|
||||||
|
3. `getByPlaceholder`
|
||||||
|
4. `getByText`
|
||||||
|
5. `getByTestId` when an explicit test contract is the most stable option
|
||||||
|
|
||||||
|
Avoid raw CSS/XPath selectors unless no stable user-facing contract exists and adding one is not practical.
|
||||||
|
|
||||||
|
Also remember:
|
||||||
|
|
||||||
|
- repeated content usually needs scoping to a stable container
|
||||||
|
- exact text matching is often too brittle when role/name or label already exists
|
||||||
|
- `getByTestId` is acceptable when semantics are weak but the contract is intentional
|
||||||
|
|
||||||
|
### 3. Use web-first assertions
|
||||||
|
|
||||||
|
Playwright assertions auto-wait and retry. Prefer them over manual state inspection.
|
||||||
|
|
||||||
|
Prefer:
|
||||||
|
|
||||||
|
- `await expect(page).toHaveURL(...)`
|
||||||
|
- `await expect(locator).toBeVisible()`
|
||||||
|
- `await expect(locator).toBeHidden()`
|
||||||
|
- `await expect(locator).toBeEnabled()`
|
||||||
|
- `await expect(locator).toHaveText(...)`
|
||||||
|
|
||||||
|
Avoid:
|
||||||
|
|
||||||
|
- `expect(await locator.isVisible()).toBe(true)`
|
||||||
|
- custom polling loops for DOM state
|
||||||
|
- `waitForTimeout` as synchronization
|
||||||
|
|
||||||
|
If a condition genuinely needs custom retry logic, use Playwright's polling/assertion tools deliberately and keep that choice local and explicit.
|
||||||
|
|
||||||
|
### 4. Let actions wait for actionability
|
||||||
|
|
||||||
|
Locator actions already wait for the element to be actionable. Do not preface every click/fill with extra timing logic unless the action needs a specific visible/ready assertion for clarity.
|
||||||
|
|
||||||
|
Good pattern:
|
||||||
|
|
||||||
|
- assert a meaningful visible state when that is part of the behavior
|
||||||
|
- then click/fill/select via locator APIs
|
||||||
|
|
||||||
|
Bad pattern:
|
||||||
|
|
||||||
|
- stack arbitrary waits before every action
|
||||||
|
- wait on unstable implementation details instead of the visible state the user cares about
|
||||||
|
|
||||||
|
### 5. Match debugging to the current suite
|
||||||
|
|
||||||
|
Playwright's wider ecosystem supports traces and rich debugging tools. Dify's current suite already captures:
|
||||||
|
|
||||||
|
- full-page screenshots
|
||||||
|
- page HTML
|
||||||
|
- console errors
|
||||||
|
- page errors
|
||||||
|
|
||||||
|
Use the existing artifact flow by default. If a task is specifically about improving diagnostics, confirm the change fits the current Cucumber architecture before importing broader Playwright tooling.
|
||||||
|
|
||||||
|
## Review Questions
|
||||||
|
|
||||||
|
- Would this locator survive DOM refactors that do not change user-visible behavior?
|
||||||
|
- Is this assertion using Playwright's retrying semantics?
|
||||||
|
- Is any explicit wait masking a real readiness problem?
|
||||||
|
- Does this code preserve per-scenario isolation?
|
||||||
|
- Is a new abstraction really needed, or does it bypass the existing `DifyWorld` + step-definition model?
|
||||||
@@ -64,7 +64,7 @@ export const useUpdateAccessMode = () => {
|
|||||||
|
|
||||||
// Component only adds UI behavior.
|
// Component only adds UI behavior.
|
||||||
updateAccessMode({ appId, mode }, {
|
updateAccessMode({ appId, mode }, {
|
||||||
onSuccess: () => Toast.notify({ type: 'success', message: '...' }),
|
onSuccess: () => toast.success('...'),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Avoid putting invalidation knowledge in the component.
|
// Avoid putting invalidation knowledge in the component.
|
||||||
@@ -114,10 +114,7 @@ try {
|
|||||||
router.push(`/orders/${order.id}`)
|
router.push(`/orders/${order.id}`)
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
Toast.notify({
|
toast.error(error instanceof Error ? error.message : 'Unknown error')
|
||||||
type: 'error',
|
|
||||||
message: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ When assigned to test a directory/path, test **ALL content** within that path:
|
|||||||
|
|
||||||
- ✅ **Import real project components** directly (including base components and siblings)
|
- ✅ **Import real project components** directly (including base components and siblings)
|
||||||
- ✅ **Only mock**: API services (`@/service/*`), `next/navigation`, complex context providers
|
- ✅ **Only mock**: API services (`@/service/*`), `next/navigation`, complex context providers
|
||||||
- ❌ **DO NOT mock** base components (`@/app/components/base/*`)
|
- ❌ **DO NOT mock** base components (`@/app/components/base/*`) or dify-ui primitives (`@langgenius/dify-ui/*`)
|
||||||
- ❌ **DO NOT mock** sibling/child components in the same directory
|
- ❌ **DO NOT mock** sibling/child components in the same directory
|
||||||
|
|
||||||
> See [Test Structure Template](#test-structure-template) for correct import/mock patterns.
|
> See [Test Structure Template](#test-structure-template) for correct import/mock patterns.
|
||||||
@@ -325,12 +325,12 @@ For more detailed information, refer to:
|
|||||||
### Reference Examples in Codebase
|
### Reference Examples in Codebase
|
||||||
|
|
||||||
- `web/utils/classnames.spec.ts` - Utility function tests
|
- `web/utils/classnames.spec.ts` - Utility function tests
|
||||||
- `web/app/components/base/button/index.spec.tsx` - Component tests
|
- `web/app/components/base/radio/__tests__/index.spec.tsx` - Component tests
|
||||||
- `web/__mocks__/provider-context.ts` - Mock factory example
|
- `web/__mocks__/provider-context.ts` - Mock factory example
|
||||||
|
|
||||||
### Project Configuration
|
### Project Configuration
|
||||||
|
|
||||||
- `web/vitest.config.ts` - Vitest configuration
|
- `web/vite.config.ts` - Vite/Vitest configuration
|
||||||
- `web/vitest.setup.ts` - Test environment setup
|
- `web/vitest.setup.ts` - Test environment setup
|
||||||
- `web/scripts/analyze-component.js` - Component analysis tool
|
- `web/scripts/analyze-component.js` - Component analysis tool
|
||||||
- Modules are not mocked automatically. Global mocks live in `web/vitest.setup.ts` (for example `react-i18next`, `next/image`); mock other modules like `ky` or `mime` locally in test files.
|
- Modules are not mocked automatically. Global mocks live in `web/vitest.setup.ts` (for example `react-i18next`, `next/image`); mock other modules like `ky` or `mime` locally in test files.
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ Use this checklist when generating or reviewing tests for Dify frontend componen
|
|||||||
|
|
||||||
### Integration vs Mocking
|
### Integration vs Mocking
|
||||||
|
|
||||||
- [ ] **DO NOT mock base components** (`Loading`, `Button`, `Tooltip`, etc.)
|
- [ ] **DO NOT mock base components or dify-ui primitives** (base `Loading`, `Input`, `Badge`; dify-ui `Button`, `Tooltip`, `Dialog`, etc.)
|
||||||
- [ ] Import real project components instead of mocking
|
- [ ] Import real project components instead of mocking
|
||||||
- [ ] Only mock: API calls, complex context providers, third-party libs with side effects
|
- [ ] Only mock: API calls, complex context providers, third-party libs with side effects
|
||||||
- [ ] Prefer integration testing when using single spec file
|
- [ ] Prefer integration testing when using single spec file
|
||||||
@@ -73,7 +73,7 @@ Use this checklist when generating or reviewing tests for Dify frontend componen
|
|||||||
|
|
||||||
### Mocks
|
### Mocks
|
||||||
|
|
||||||
- [ ] **DO NOT mock base components** (`@/app/components/base/*`)
|
- [ ] **DO NOT mock base components or dify-ui primitives** (`@/app/components/base/*` or `@langgenius/dify-ui/*`)
|
||||||
- [ ] `vi.clearAllMocks()` in `beforeEach` (not `afterEach`)
|
- [ ] `vi.clearAllMocks()` in `beforeEach` (not `afterEach`)
|
||||||
- [ ] Shared mock state reset in `beforeEach`
|
- [ ] Shared mock state reset in `beforeEach`
|
||||||
- [ ] i18n uses global mock (auto-loaded in `web/vitest.setup.ts`); only override locally for custom translations
|
- [ ] i18n uses global mock (auto-loaded in `web/vitest.setup.ts`); only override locally for custom translations
|
||||||
@@ -127,7 +127,7 @@ For the current file being tested:
|
|||||||
- [ ] Run full directory test: `pnpm test path/to/directory/`
|
- [ ] Run full directory test: `pnpm test path/to/directory/`
|
||||||
- [ ] Check coverage report: `pnpm test:coverage`
|
- [ ] Check coverage report: `pnpm test:coverage`
|
||||||
- [ ] Run `pnpm lint:fix` on all test files
|
- [ ] Run `pnpm lint:fix` on all test files
|
||||||
- [ ] Run `pnpm type-check:tsgo`
|
- [ ] Run `pnpm type-check`
|
||||||
|
|
||||||
## Common Issues to Watch
|
## Common Issues to Watch
|
||||||
|
|
||||||
|
|||||||
@@ -2,29 +2,27 @@
|
|||||||
|
|
||||||
## ⚠️ Important: What NOT to Mock
|
## ⚠️ Important: What NOT to Mock
|
||||||
|
|
||||||
### DO NOT Mock Base Components
|
### DO NOT Mock Base Components or dify-ui Primitives
|
||||||
|
|
||||||
**Never mock components from `@/app/components/base/`** such as:
|
**Never mock components from `@/app/components/base/` or from `@langgenius/dify-ui/*`** such as:
|
||||||
|
|
||||||
- `Loading`, `Spinner`
|
- Legacy base (`@/app/components/base/*`): `Loading`, `Spinner`, `Input`, `Badge`, `Tag`
|
||||||
- `Button`, `Input`, `Select`
|
- dify-ui primitives (`@langgenius/dify-ui/*`): `Button`, `Tooltip`, `Dialog`, `Popover`, `DropdownMenu`, `ContextMenu`, `Select`, `AlertDialog`, `Toast`
|
||||||
- `Tooltip`, `Modal`, `Dropdown`
|
|
||||||
- `Icon`, `Badge`, `Tag`
|
|
||||||
|
|
||||||
**Why?**
|
**Why?**
|
||||||
|
|
||||||
- Base components will have their own dedicated tests
|
- These components have their own dedicated tests
|
||||||
- Mocking them creates false positives (tests pass but real integration fails)
|
- Mocking them creates false positives (tests pass but real integration fails)
|
||||||
- Using real components tests actual integration behavior
|
- Using real components tests actual integration behavior
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// ❌ WRONG: Don't mock base components
|
// ❌ WRONG: Don't mock base components or dify-ui primitives
|
||||||
vi.mock('@/app/components/base/loading', () => () => <div>Loading</div>)
|
vi.mock('@/app/components/base/loading', () => () => <div>Loading</div>)
|
||||||
vi.mock('@/app/components/base/button', () => ({ children }: any) => <button>{children}</button>)
|
vi.mock('@langgenius/dify-ui/button', () => ({ Button: ({ children }: any) => <button>{children}</button> }))
|
||||||
|
|
||||||
// ✅ CORRECT: Import and use real base components
|
// ✅ CORRECT: Import and use the real components
|
||||||
import Loading from '@/app/components/base/loading'
|
import Loading from '@/app/components/base/loading'
|
||||||
import Button from '@/app/components/base/button'
|
import { Button } from '@langgenius/dify-ui/button'
|
||||||
// They will render normally in tests
|
// They will render normally in tests
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -319,7 +317,7 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
|
|||||||
|
|
||||||
### ✅ DO
|
### ✅ DO
|
||||||
|
|
||||||
1. **Use real base components** - Import from `@/app/components/base/` directly
|
1. **Use real base components and dify-ui primitives** - Import from `@/app/components/base/` or `@langgenius/dify-ui/*` directly
|
||||||
1. **Use real project components** - Prefer importing over mocking
|
1. **Use real project components** - Prefer importing over mocking
|
||||||
1. **Use real Zustand stores** - Set test state via `store.setState()`
|
1. **Use real Zustand stores** - Set test state via `store.setState()`
|
||||||
1. **Reset mocks in `beforeEach`**, not `afterEach`
|
1. **Reset mocks in `beforeEach`**, not `afterEach`
|
||||||
@@ -330,7 +328,7 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
|
|||||||
|
|
||||||
### ❌ DON'T
|
### ❌ DON'T
|
||||||
|
|
||||||
1. **Don't mock base components** (`Loading`, `Button`, `Tooltip`, etc.)
|
1. **Don't mock base components or dify-ui primitives** (`Loading`, `Input`, `Button`, `Tooltip`, `Dialog`, etc.)
|
||||||
1. **Don't mock Zustand store modules** - Use real stores with `setState()`
|
1. **Don't mock Zustand store modules** - Use real stores with `setState()`
|
||||||
1. Don't mock components you can import directly
|
1. Don't mock components you can import directly
|
||||||
1. Don't create overly simplified mocks that miss conditional logic
|
1. Don't create overly simplified mocks that miss conditional logic
|
||||||
@@ -342,7 +340,7 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
|
|||||||
```
|
```
|
||||||
Need to use a component in test?
|
Need to use a component in test?
|
||||||
│
|
│
|
||||||
├─ Is it from @/app/components/base/*?
|
├─ Is it from @/app/components/base/* or @langgenius/dify-ui/*?
|
||||||
│ └─ YES → Import real component, DO NOT mock
|
│ └─ YES → Import real component, DO NOT mock
|
||||||
│
|
│
|
||||||
├─ Is it a project component?
|
├─ Is it a project component?
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/e2e-cucumber-playwright
|
||||||
@@ -7,7 +7,7 @@ cd web && pnpm install
|
|||||||
pipx install uv
|
pipx install uv
|
||||||
|
|
||||||
echo "alias start-api=\"cd $WORKSPACE_ROOT/api && uv run python -m flask run --host 0.0.0.0 --port=5001 --debug\"" >> ~/.bashrc
|
echo "alias start-api=\"cd $WORKSPACE_ROOT/api && uv run python -m flask run --host 0.0.0.0 --port=5001 --debug\"" >> ~/.bashrc
|
||||||
echo "alias start-worker=\"cd $WORKSPACE_ROOT/api && uv run python -m celery -A app.celery worker -P threads -c 1 --loglevel INFO -Q dataset,dataset_summary,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention\"" >> ~/.bashrc
|
echo "alias start-worker=\"cd $WORKSPACE_ROOT/api && uv run python -m celery -A app.celery worker -P threads -c 1 --loglevel INFO -Q dataset,dataset_summary,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_publisher,trigger_refresh_executor,retention\"" >> ~/.bashrc
|
||||||
echo "alias start-web=\"cd $WORKSPACE_ROOT/web && pnpm dev:inspect\"" >> ~/.bashrc
|
echo "alias start-web=\"cd $WORKSPACE_ROOT/web && pnpm dev:inspect\"" >> ~/.bashrc
|
||||||
echo "alias start-web-prod=\"cd $WORKSPACE_ROOT/web && pnpm build && pnpm start\"" >> ~/.bashrc
|
echo "alias start-web-prod=\"cd $WORKSPACE_ROOT/web && pnpm build && pnpm start\"" >> ~/.bashrc
|
||||||
echo "alias start-containers=\"cd $WORKSPACE_ROOT/docker && docker-compose -f docker-compose.middleware.yaml -p dify --env-file middleware.env up -d\"" >> ~/.bashrc
|
echo "alias start-containers=\"cd $WORKSPACE_ROOT/docker && docker-compose -f docker-compose.middleware.yaml -p dify --env-file middleware.env up -d\"" >> ~/.bashrc
|
||||||
|
|||||||
@@ -1,106 +1,6 @@
|
|||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
updates:
|
updates:
|
||||||
- package-ecosystem: "pip"
|
|
||||||
directory: "/api"
|
|
||||||
open-pull-requests-limit: 10
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
groups:
|
|
||||||
flask:
|
|
||||||
patterns:
|
|
||||||
- "flask"
|
|
||||||
- "flask-*"
|
|
||||||
- "werkzeug"
|
|
||||||
- "gunicorn"
|
|
||||||
google:
|
|
||||||
patterns:
|
|
||||||
- "google-*"
|
|
||||||
- "googleapis-*"
|
|
||||||
opentelemetry:
|
|
||||||
patterns:
|
|
||||||
- "opentelemetry-*"
|
|
||||||
pydantic:
|
|
||||||
patterns:
|
|
||||||
- "pydantic"
|
|
||||||
- "pydantic-*"
|
|
||||||
llm:
|
|
||||||
patterns:
|
|
||||||
- "langfuse"
|
|
||||||
- "langsmith"
|
|
||||||
- "litellm"
|
|
||||||
- "mlflow*"
|
|
||||||
- "opik"
|
|
||||||
- "weave*"
|
|
||||||
- "arize*"
|
|
||||||
- "tiktoken"
|
|
||||||
- "transformers"
|
|
||||||
database:
|
|
||||||
patterns:
|
|
||||||
- "sqlalchemy"
|
|
||||||
- "psycopg2*"
|
|
||||||
- "psycogreen"
|
|
||||||
- "redis*"
|
|
||||||
- "alembic*"
|
|
||||||
storage:
|
|
||||||
patterns:
|
|
||||||
- "boto3*"
|
|
||||||
- "botocore*"
|
|
||||||
- "azure-*"
|
|
||||||
- "bce-*"
|
|
||||||
- "cos-python-*"
|
|
||||||
- "esdk-obs-*"
|
|
||||||
- "google-cloud-storage"
|
|
||||||
- "opendal"
|
|
||||||
- "oss2"
|
|
||||||
- "supabase*"
|
|
||||||
- "tos*"
|
|
||||||
vdb:
|
|
||||||
patterns:
|
|
||||||
- "alibabacloud*"
|
|
||||||
- "chromadb"
|
|
||||||
- "clickhouse-*"
|
|
||||||
- "clickzetta-*"
|
|
||||||
- "couchbase"
|
|
||||||
- "elasticsearch"
|
|
||||||
- "opensearch-py"
|
|
||||||
- "oracledb"
|
|
||||||
- "pgvect*"
|
|
||||||
- "pymilvus"
|
|
||||||
- "pymochow"
|
|
||||||
- "pyobvector"
|
|
||||||
- "qdrant-client"
|
|
||||||
- "intersystems-*"
|
|
||||||
- "tablestore"
|
|
||||||
- "tcvectordb"
|
|
||||||
- "tidb-vector"
|
|
||||||
- "upstash-*"
|
|
||||||
- "volcengine-*"
|
|
||||||
- "weaviate-*"
|
|
||||||
- "xinference-*"
|
|
||||||
- "mo-vector"
|
|
||||||
- "mysql-connector-*"
|
|
||||||
dev:
|
|
||||||
patterns:
|
|
||||||
- "coverage"
|
|
||||||
- "dotenv-linter"
|
|
||||||
- "faker"
|
|
||||||
- "lxml-stubs"
|
|
||||||
- "basedpyright"
|
|
||||||
- "ruff"
|
|
||||||
- "pytest*"
|
|
||||||
- "types-*"
|
|
||||||
- "boto3-stubs"
|
|
||||||
- "hypothesis"
|
|
||||||
- "pandas-stubs"
|
|
||||||
- "scipy-stubs"
|
|
||||||
- "import-linter"
|
|
||||||
- "celery-types"
|
|
||||||
- "mypy*"
|
|
||||||
- "pyrefly"
|
|
||||||
python-packages:
|
|
||||||
patterns:
|
|
||||||
- "*"
|
|
||||||
- package-ecosystem: "uv"
|
- package-ecosystem: "uv"
|
||||||
directory: "/api"
|
directory: "/api"
|
||||||
open-pull-requests-limit: 10
|
open-pull-requests-limit: 10
|
||||||
|
|||||||
+8
-1
@@ -1,3 +1,10 @@
|
|||||||
web:
|
web:
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file: 'web/**'
|
- any-glob-to-any-file:
|
||||||
|
- 'web/**'
|
||||||
|
- 'packages/**'
|
||||||
|
- 'package.json'
|
||||||
|
- 'pnpm-lock.yaml'
|
||||||
|
- 'pnpm-workspace.yaml'
|
||||||
|
- '.npmrc'
|
||||||
|
- '.nvmrc'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
|
<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
|
||||||
|
<!-- If this PR was created by an automated agent, add `From <Tool Name>` as the final line of the description. Example: `From Codex`. -->
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@
|
|||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
- [ ] This change requires a documentation update, included: [Dify Document](https://github.com/langgenius/dify-docs)
|
- [ ] This change requires a documentation update, included: [Dify Document](https://github.com/langgenius/dify-docs)
|
||||||
- [x] I understand that this PR may be closed in case there was no previous discussion or issues. (This doesn't apply to typos!)
|
- [ ] I understand that this PR may be closed in case there was no previous discussion or issues. (This doesn't apply to typos!)
|
||||||
- [x] I've added a test for each change that was introduced, and I tried as much as possible to make a single atomic change.
|
- [ ] I've added a test for each change that was introduced, and I tried as much as possible to make a single atomic change.
|
||||||
- [x] I've updated the documentation accordingly.
|
- [ ] I've updated the documentation accordingly.
|
||||||
- [x] I ran `make lint` and `make type-check` (backend) and `cd web && npx lint-staged` (frontend) to appease the lint gods
|
- [ ] I ran `make lint && make type-check` (backend) and `cd web && pnpm exec vp staged` (frontend) to appease the lint gods
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { execFileSync } from 'node:child_process'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const repoRoot = process.cwd()
|
||||||
|
const baseSha = process.env.BASE_SHA || ''
|
||||||
|
const headSha = process.env.HEAD_SHA || ''
|
||||||
|
const files = (process.env.CHANGED_FILES || '').split(/\s+/).filter(Boolean)
|
||||||
|
const outputPath = process.env.I18N_CHANGES_OUTPUT_PATH || '/tmp/i18n-changes.json'
|
||||||
|
|
||||||
|
const englishPath = fileStem => path.join(repoRoot, 'web', 'i18n', 'en-US', `${fileStem}.json`)
|
||||||
|
|
||||||
|
const readCurrentJson = (fileStem) => {
|
||||||
|
const filePath = englishPath(fileStem)
|
||||||
|
if (!fs.existsSync(filePath))
|
||||||
|
return null
|
||||||
|
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const readBaseJson = (fileStem) => {
|
||||||
|
if (!baseSha)
|
||||||
|
return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const relativePath = `web/i18n/en-US/${fileStem}.json`
|
||||||
|
const content = execFileSync('git', ['show', `${baseSha}:${relativePath}`], { encoding: 'utf8' })
|
||||||
|
return JSON.parse(content)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const compareJson = (beforeValue, afterValue) => JSON.stringify(beforeValue) === JSON.stringify(afterValue)
|
||||||
|
|
||||||
|
const changes = {}
|
||||||
|
|
||||||
|
for (const fileStem of files) {
|
||||||
|
const currentJson = readCurrentJson(fileStem)
|
||||||
|
const beforeJson = readBaseJson(fileStem) || {}
|
||||||
|
const afterJson = currentJson || {}
|
||||||
|
const added = {}
|
||||||
|
const updated = {}
|
||||||
|
const deleted = []
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(afterJson)) {
|
||||||
|
if (!(key in beforeJson)) {
|
||||||
|
added[key] = value
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!compareJson(beforeJson[key], value)) {
|
||||||
|
updated[key] = {
|
||||||
|
before: beforeJson[key],
|
||||||
|
after: value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Object.keys(beforeJson)) {
|
||||||
|
if (!(key in afterJson))
|
||||||
|
deleted.push(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
changes[fileStem] = {
|
||||||
|
fileDeleted: currentJson === null,
|
||||||
|
added,
|
||||||
|
updated,
|
||||||
|
deleted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
outputPath,
|
||||||
|
JSON.stringify({
|
||||||
|
baseSha,
|
||||||
|
headSha,
|
||||||
|
files,
|
||||||
|
changes,
|
||||||
|
})
|
||||||
|
)
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
name: Anti-Slop PR Check
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request_target:
|
|
||||||
types: [opened, edited, synchronize]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
pull-requests: write
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
anti-slop:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: peakoss/anti-slop@85daca1880e9e1af197fc06ea03349daf08f4202 # v0.2.1
|
|
||||||
with:
|
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
close-pr: false
|
|
||||||
failure-add-pr-labels: "needs-revision"
|
|
||||||
@@ -16,7 +16,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
api-unit:
|
api-unit:
|
||||||
name: API Unit Tests
|
name: API Unit Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
env:
|
env:
|
||||||
COVERAGE_FILE: coverage-unit
|
COVERAGE_FILE: coverage-unit
|
||||||
defaults:
|
defaults:
|
||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup UV and Python
|
- name: Setup UV and Python
|
||||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
@@ -54,7 +54,7 @@ jobs:
|
|||||||
run: uv run --project api bash dev/pytest/pytest_unit_tests.sh
|
run: uv run --project api bash dev/pytest/pytest_unit_tests.sh
|
||||||
|
|
||||||
- name: Upload unit coverage data
|
- name: Upload unit coverage data
|
||||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: api-coverage-unit
|
name: api-coverage-unit
|
||||||
path: coverage-unit
|
path: coverage-unit
|
||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
|
|
||||||
api-integration:
|
api-integration:
|
||||||
name: API Integration Tests
|
name: API Integration Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
env:
|
env:
|
||||||
COVERAGE_FILE: coverage-integration
|
COVERAGE_FILE: coverage-integration
|
||||||
STORAGE_TYPE: opendal
|
STORAGE_TYPE: opendal
|
||||||
@@ -84,7 +84,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup UV and Python
|
- name: Setup UV and Python
|
||||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
@@ -105,7 +105,7 @@ jobs:
|
|||||||
run: sh .github/workflows/expose_service_ports.sh
|
run: sh .github/workflows/expose_service_ports.sh
|
||||||
|
|
||||||
- name: Set up Sandbox
|
- name: Set up Sandbox
|
||||||
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
|
||||||
with:
|
with:
|
||||||
compose-file: |
|
compose-file: |
|
||||||
docker/docker-compose.middleware.yaml
|
docker/docker-compose.middleware.yaml
|
||||||
@@ -129,7 +129,7 @@ jobs:
|
|||||||
api/tests/test_containers_integration_tests
|
api/tests/test_containers_integration_tests
|
||||||
|
|
||||||
- name: Upload integration coverage data
|
- name: Upload integration coverage data
|
||||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: api-coverage-integration
|
name: api-coverage-integration
|
||||||
path: coverage-integration
|
path: coverage-integration
|
||||||
@@ -137,7 +137,7 @@ jobs:
|
|||||||
|
|
||||||
api-coverage:
|
api-coverage:
|
||||||
name: API Coverage
|
name: API Coverage
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
needs:
|
needs:
|
||||||
- api-unit
|
- api-unit
|
||||||
- api-integration
|
- api-integration
|
||||||
@@ -156,7 +156,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup UV and Python
|
- name: Setup UV and Python
|
||||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
autofix:
|
autofix:
|
||||||
if: github.repository == 'langgenius/dify'
|
if: github.repository == 'langgenius/dify'
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Complete merge group check
|
- name: Complete merge group check
|
||||||
if: github.event_name == 'merge_group'
|
if: github.event_name == 'merge_group'
|
||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
- name: Check Docker Compose inputs
|
- name: Check Docker Compose inputs
|
||||||
if: github.event_name != 'merge_group'
|
if: github.event_name != 'merge_group'
|
||||||
id: docker-compose-changes
|
id: docker-compose-changes
|
||||||
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
|
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
docker/generate_docker_compose
|
docker/generate_docker_compose
|
||||||
@@ -35,18 +35,20 @@ jobs:
|
|||||||
- name: Check web inputs
|
- name: Check web inputs
|
||||||
if: github.event_name != 'merge_group'
|
if: github.event_name != 'merge_group'
|
||||||
id: web-changes
|
id: web-changes
|
||||||
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
|
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
web/**
|
web/**
|
||||||
|
packages/**
|
||||||
package.json
|
package.json
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
pnpm-workspace.yaml
|
pnpm-workspace.yaml
|
||||||
|
.npmrc
|
||||||
.nvmrc
|
.nvmrc
|
||||||
- name: Check api inputs
|
- name: Check api inputs
|
||||||
if: github.event_name != 'merge_group'
|
if: github.event_name != 'merge_group'
|
||||||
id: api-changes
|
id: api-changes
|
||||||
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
|
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
api/**
|
api/**
|
||||||
@@ -56,7 +58,7 @@ jobs:
|
|||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
|
|
||||||
- if: github.event_name != 'merge_group'
|
- if: github.event_name != 'merge_group'
|
||||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||||
|
|
||||||
- name: Generate Docker Compose
|
- name: Generate Docker Compose
|
||||||
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'
|
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'
|
||||||
@@ -118,8 +120,7 @@ jobs:
|
|||||||
- name: ESLint autofix
|
- name: ESLint autofix
|
||||||
if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true'
|
if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true'
|
||||||
run: |
|
run: |
|
||||||
cd web
|
|
||||||
vp exec eslint --concurrency=2 --prune-suppressions --quiet || true
|
vp exec eslint --concurrency=2 --prune-suppressions --quiet || true
|
||||||
|
|
||||||
- if: github.event_name != 'merge_group'
|
- if: github.event_name != 'merge_group'
|
||||||
uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8 # v1.3.3
|
uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a # v1.3.4
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ${{ matrix.runs_on }}
|
runs-on: ${{ matrix.runs_on }}
|
||||||
if: github.repository == 'langgenius/dify'
|
if: github.repository == 'langgenius/dify'
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
@@ -35,28 +38,28 @@ jobs:
|
|||||||
build_context: "{{defaultContext}}:api"
|
build_context: "{{defaultContext}}:api"
|
||||||
file: "Dockerfile"
|
file: "Dockerfile"
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
runs_on: ubuntu-latest
|
runs_on: depot-ubuntu-24.04-4
|
||||||
- service_name: "build-api-arm64"
|
- service_name: "build-api-arm64"
|
||||||
image_name_env: "DIFY_API_IMAGE_NAME"
|
image_name_env: "DIFY_API_IMAGE_NAME"
|
||||||
artifact_context: "api"
|
artifact_context: "api"
|
||||||
build_context: "{{defaultContext}}:api"
|
build_context: "{{defaultContext}}:api"
|
||||||
file: "Dockerfile"
|
file: "Dockerfile"
|
||||||
platform: linux/arm64
|
platform: linux/arm64
|
||||||
runs_on: ubuntu-24.04-arm
|
runs_on: depot-ubuntu-24.04-4
|
||||||
- service_name: "build-web-amd64"
|
- service_name: "build-web-amd64"
|
||||||
image_name_env: "DIFY_WEB_IMAGE_NAME"
|
image_name_env: "DIFY_WEB_IMAGE_NAME"
|
||||||
artifact_context: "web"
|
artifact_context: "web"
|
||||||
build_context: "{{defaultContext}}"
|
build_context: "{{defaultContext}}"
|
||||||
file: "web/Dockerfile"
|
file: "web/Dockerfile"
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
runs_on: ubuntu-latest
|
runs_on: depot-ubuntu-24.04-4
|
||||||
- service_name: "build-web-arm64"
|
- service_name: "build-web-arm64"
|
||||||
image_name_env: "DIFY_WEB_IMAGE_NAME"
|
image_name_env: "DIFY_WEB_IMAGE_NAME"
|
||||||
artifact_context: "web"
|
artifact_context: "web"
|
||||||
build_context: "{{defaultContext}}"
|
build_context: "{{defaultContext}}"
|
||||||
file: "web/Dockerfile"
|
file: "web/Dockerfile"
|
||||||
platform: linux/arm64
|
platform: linux/arm64
|
||||||
runs_on: ubuntu-24.04-arm
|
runs_on: depot-ubuntu-24.04-4
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Prepare
|
- name: Prepare
|
||||||
@@ -65,13 +68,13 @@ jobs:
|
|||||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||||
with:
|
with:
|
||||||
username: ${{ env.DOCKERHUB_USER }}
|
username: ${{ env.DOCKERHUB_USER }}
|
||||||
password: ${{ env.DOCKERHUB_TOKEN }}
|
password: ${{ env.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Depot CLI
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
uses: depot/setup-action@v1
|
||||||
|
|
||||||
- name: Extract metadata for Docker
|
- name: Extract metadata for Docker
|
||||||
id: meta
|
id: meta
|
||||||
@@ -81,16 +84,15 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
uses: depot/build-push-action@v1
|
||||||
with:
|
with:
|
||||||
|
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||||
context: ${{ matrix.build_context }}
|
context: ${{ matrix.build_context }}
|
||||||
file: ${{ matrix.file }}
|
file: ${{ matrix.file }}
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
build-args: COMMIT_SHA=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
|
build-args: COMMIT_SHA=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
outputs: type=image,name=${{ env[matrix.image_name_env] }},push-by-digest=true,name-canonical=true,push=true
|
outputs: type=image,name=${{ env[matrix.image_name_env] }},push-by-digest=true,name-canonical=true,push=true
|
||||||
cache-from: type=gha,scope=${{ matrix.service_name }}
|
|
||||||
cache-to: type=gha,mode=max,scope=${{ matrix.service_name }}
|
|
||||||
|
|
||||||
- name: Export digest
|
- name: Export digest
|
||||||
env:
|
env:
|
||||||
@@ -101,16 +103,40 @@ jobs:
|
|||||||
touch "/tmp/digests/${sanitized_digest}"
|
touch "/tmp/digests/${sanitized_digest}"
|
||||||
|
|
||||||
- name: Upload digest
|
- name: Upload digest
|
||||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: digests-${{ matrix.artifact_context }}-${{ env.PLATFORM_PAIR }}
|
name: digests-${{ matrix.artifact_context }}-${{ env.PLATFORM_PAIR }}
|
||||||
path: /tmp/digests/*
|
path: /tmp/digests/*
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
|
fork-build-validate:
|
||||||
|
if: github.repository != 'langgenius/dify'
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- service_name: "validate-api-amd64"
|
||||||
|
build_context: "{{defaultContext}}:api"
|
||||||
|
file: "Dockerfile"
|
||||||
|
- service_name: "validate-web-amd64"
|
||||||
|
build_context: "{{defaultContext}}"
|
||||||
|
file: "web/Dockerfile"
|
||||||
|
steps:
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@98e3b2c9eab4f4f98a95c0c0a3ea5e5e672fd2a8 # v3.10.0
|
||||||
|
|
||||||
|
- name: Validate Docker image
|
||||||
|
uses: docker/build-push-action@5cd29d66b4a8d8e6f4d5dfe2e9329f0b1d446289 # v6.18.0
|
||||||
|
with:
|
||||||
|
push: false
|
||||||
|
context: ${{ matrix.build_context }}
|
||||||
|
file: ${{ matrix.file }}
|
||||||
|
platforms: linux/amd64
|
||||||
|
|
||||||
create-manifest:
|
create-manifest:
|
||||||
needs: build
|
needs: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
if: github.repository == 'langgenius/dify'
|
if: github.repository == 'langgenius/dify'
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -130,7 +156,7 @@ jobs:
|
|||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||||
with:
|
with:
|
||||||
username: ${{ env.DOCKERHUB_USER }}
|
username: ${{ env.DOCKERHUB_USER }}
|
||||||
password: ${{ env.DOCKERHUB_TOKEN }}
|
password: ${{ env.DOCKERHUB_TOKEN }}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
db-migration-test-postgres:
|
db-migration-test-postgres:
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup UV and Python
|
- name: Setup UV and Python
|
||||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
cp middleware.env.example middleware.env
|
cp middleware.env.example middleware.env
|
||||||
|
|
||||||
- name: Set up Middlewares
|
- name: Set up Middlewares
|
||||||
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
|
||||||
with:
|
with:
|
||||||
compose-file: |
|
compose-file: |
|
||||||
docker/docker-compose.middleware.yaml
|
docker/docker-compose.middleware.yaml
|
||||||
@@ -59,7 +59,7 @@ jobs:
|
|||||||
run: uv run --directory api flask upgrade-db
|
run: uv run --directory api flask upgrade-db
|
||||||
|
|
||||||
db-migration-test-mysql:
|
db-migration-test-mysql:
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -69,7 +69,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup UV and Python
|
- name: Setup UV and Python
|
||||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
@@ -94,7 +94,7 @@ jobs:
|
|||||||
sed -i 's/DB_USERNAME=postgres/DB_USERNAME=mysql/' middleware.env
|
sed -i 's/DB_USERNAME=postgres/DB_USERNAME=mysql/' middleware.env
|
||||||
|
|
||||||
- name: Set up Middlewares
|
- name: Set up Middlewares
|
||||||
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
|
||||||
with:
|
with:
|
||||||
compose-file: |
|
compose-file: |
|
||||||
docker/docker-compose.middleware.yaml
|
docker/docker-compose.middleware.yaml
|
||||||
@@ -110,6 +110,28 @@ jobs:
|
|||||||
sed -i 's/DB_PORT=5432/DB_PORT=3306/' .env
|
sed -i 's/DB_PORT=5432/DB_PORT=3306/' .env
|
||||||
sed -i 's/DB_USERNAME=postgres/DB_USERNAME=root/' .env
|
sed -i 's/DB_USERNAME=postgres/DB_USERNAME=root/' .env
|
||||||
|
|
||||||
|
# hoverkraft-tech/[email protected] only waits for `docker compose up -d`
|
||||||
|
# to return (container processes started); it does not wait on healthcheck
|
||||||
|
# status. mysql:8.0's first-time init takes 15-30s, so without an explicit
|
||||||
|
# wait the migration runs while InnoDB is still initialising and gets
|
||||||
|
# killed with "Lost connection during query". Poll a real SELECT until it
|
||||||
|
# succeeds.
|
||||||
|
- name: Wait for MySQL to accept queries
|
||||||
|
run: |
|
||||||
|
set +e
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if docker run --rm --network host mysql:8.0 \
|
||||||
|
mysql -h 127.0.0.1 -P 3306 -uroot -pdifyai123456 \
|
||||||
|
-e 'SELECT 1' >/dev/null 2>&1; then
|
||||||
|
echo "MySQL ready after ${i}s"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "MySQL not ready after 60s; dumping container logs:"
|
||||||
|
docker compose -f docker/docker-compose.middleware.yaml --profile mysql logs --tail=200 db_mysql
|
||||||
|
exit 1
|
||||||
|
|
||||||
- name: Run DB Migration
|
- name: Run DB Migration
|
||||||
env:
|
env:
|
||||||
DEBUG: true
|
DEBUG: true
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
if: |
|
if: |
|
||||||
github.event.workflow_run.conclusion == 'success' &&
|
github.event.workflow_run.conclusion == 'success' &&
|
||||||
github.event.workflow_run.head_branch == 'deploy/agent-dev'
|
github.event.workflow_run.head_branch == 'deploy/agent-dev'
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
if: |
|
if: |
|
||||||
github.event.workflow_run.conclusion == 'success' &&
|
github.event.workflow_run.conclusion == 'success' &&
|
||||||
github.event.workflow_run.head_branch == 'deploy/dev'
|
github.event.workflow_run.head_branch == 'deploy/dev'
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
if: |
|
if: |
|
||||||
github.event.workflow_run.conclusion == 'success' &&
|
github.event.workflow_run.conclusion == 'success' &&
|
||||||
github.event.workflow_run.head_branch == 'deploy/enterprise'
|
github.event.workflow_run.head_branch == 'deploy/enterprise'
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
if: |
|
if: |
|
||||||
github.event.workflow_run.conclusion == 'success' &&
|
github.event.workflow_run.conclusion == 'success' &&
|
||||||
github.event.workflow_run.head_branch == 'build/feat/hitl'
|
github.event.workflow_run.head_branch == 'build/feat/hitl'
|
||||||
|
|||||||
@@ -6,12 +6,7 @@ on:
|
|||||||
- "main"
|
- "main"
|
||||||
paths:
|
paths:
|
||||||
- api/Dockerfile
|
- api/Dockerfile
|
||||||
- web/docker/**
|
|
||||||
- web/Dockerfile
|
- web/Dockerfile
|
||||||
- package.json
|
|
||||||
- pnpm-lock.yaml
|
|
||||||
- pnpm-workspace.yaml
|
|
||||||
- .nvmrc
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: docker-build-${{ github.head_ref || github.run_id }}
|
group: docker-build-${{ github.head_ref || github.run_id }}
|
||||||
@@ -19,40 +14,69 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-docker:
|
build-docker:
|
||||||
|
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||||
runs-on: ${{ matrix.runs_on }}
|
runs-on: ${{ matrix.runs_on }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- service_name: "api-amd64"
|
- service_name: "api-amd64"
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
runs_on: ubuntu-latest
|
runs_on: depot-ubuntu-24.04-4
|
||||||
context: "{{defaultContext}}:api"
|
context: "{{defaultContext}}:api"
|
||||||
file: "Dockerfile"
|
file: "Dockerfile"
|
||||||
- service_name: "api-arm64"
|
- service_name: "api-arm64"
|
||||||
platform: linux/arm64
|
platform: linux/arm64
|
||||||
runs_on: ubuntu-24.04-arm
|
runs_on: depot-ubuntu-24.04-4
|
||||||
context: "{{defaultContext}}:api"
|
context: "{{defaultContext}}:api"
|
||||||
file: "Dockerfile"
|
file: "Dockerfile"
|
||||||
- service_name: "web-amd64"
|
- service_name: "web-amd64"
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
runs_on: ubuntu-latest
|
runs_on: depot-ubuntu-24.04-4
|
||||||
context: "{{defaultContext}}"
|
context: "{{defaultContext}}"
|
||||||
file: "web/Dockerfile"
|
file: "web/Dockerfile"
|
||||||
- service_name: "web-arm64"
|
- service_name: "web-arm64"
|
||||||
platform: linux/arm64
|
platform: linux/arm64
|
||||||
runs_on: ubuntu-24.04-arm
|
runs_on: depot-ubuntu-24.04-4
|
||||||
context: "{{defaultContext}}"
|
context: "{{defaultContext}}"
|
||||||
file: "web/Dockerfile"
|
file: "web/Dockerfile"
|
||||||
steps:
|
steps:
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Depot CLI
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
uses: depot/setup-action@v1
|
||||||
|
|
||||||
- name: Build Docker Image
|
- name: Build Docker Image
|
||||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
uses: depot/build-push-action@v1
|
||||||
with:
|
with:
|
||||||
|
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||||
push: false
|
push: false
|
||||||
context: ${{ matrix.context }}
|
context: ${{ matrix.context }}
|
||||||
file: ${{ matrix.file }}
|
file: ${{ matrix.file }}
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
build-docker-fork:
|
||||||
|
if: github.event.pull_request.head.repo.full_name != github.repository
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- service_name: "api-amd64"
|
||||||
|
context: "{{defaultContext}}:api"
|
||||||
|
file: "Dockerfile"
|
||||||
|
- service_name: "web-amd64"
|
||||||
|
context: "{{defaultContext}}"
|
||||||
|
file: "web/Dockerfile"
|
||||||
|
steps:
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@98e3b2c9eab4f4f98a95c0c0a3ea5e5e672fd2a8 # v3.10.0
|
||||||
|
|
||||||
|
- name: Build Docker Image
|
||||||
|
uses: docker/build-push-action@5cd29d66b4a8d8e6f4d5dfe2e9329f0b1d446289 # v6.18.0
|
||||||
|
with:
|
||||||
|
push: false
|
||||||
|
context: ${{ matrix.context }}
|
||||||
|
file: ${{ matrix.file }}
|
||||||
|
platforms: linux/amd64
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
|
- uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
pre_job:
|
pre_job:
|
||||||
name: Skip Duplicate Checks
|
name: Skip Duplicate Checks
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
outputs:
|
outputs:
|
||||||
should_skip: ${{ steps.skip_check.outputs.should_skip || 'false' }}
|
should_skip: ${{ steps.skip_check.outputs.should_skip || 'false' }}
|
||||||
steps:
|
steps:
|
||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
name: Check Changed Files
|
name: Check Changed Files
|
||||||
needs: pre_job
|
needs: pre_job
|
||||||
if: needs.pre_job.outputs.should_skip != 'true'
|
if: needs.pre_job.outputs.should_skip != 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
outputs:
|
outputs:
|
||||||
api-changed: ${{ steps.changes.outputs.api }}
|
api-changed: ${{ steps.changes.outputs.api }}
|
||||||
e2e-changed: ${{ steps.changes.outputs.e2e }}
|
e2e-changed: ${{ steps.changes.outputs.e2e }}
|
||||||
@@ -65,9 +65,11 @@ jobs:
|
|||||||
- 'docker/volumes/sandbox/conf/**'
|
- 'docker/volumes/sandbox/conf/**'
|
||||||
web:
|
web:
|
||||||
- 'web/**'
|
- 'web/**'
|
||||||
|
- 'packages/**'
|
||||||
- 'package.json'
|
- 'package.json'
|
||||||
- 'pnpm-lock.yaml'
|
- 'pnpm-lock.yaml'
|
||||||
- 'pnpm-workspace.yaml'
|
- 'pnpm-workspace.yaml'
|
||||||
|
- '.npmrc'
|
||||||
- '.nvmrc'
|
- '.nvmrc'
|
||||||
- '.github/workflows/web-tests.yml'
|
- '.github/workflows/web-tests.yml'
|
||||||
- '.github/actions/setup-web/**'
|
- '.github/actions/setup-web/**'
|
||||||
@@ -77,9 +79,11 @@ jobs:
|
|||||||
- 'api/uv.lock'
|
- 'api/uv.lock'
|
||||||
- 'e2e/**'
|
- 'e2e/**'
|
||||||
- 'web/**'
|
- 'web/**'
|
||||||
|
- 'packages/**'
|
||||||
- 'package.json'
|
- 'package.json'
|
||||||
- 'pnpm-lock.yaml'
|
- 'pnpm-lock.yaml'
|
||||||
- 'pnpm-workspace.yaml'
|
- 'pnpm-workspace.yaml'
|
||||||
|
- '.npmrc'
|
||||||
- '.nvmrc'
|
- '.nvmrc'
|
||||||
- 'docker/docker-compose.middleware.yaml'
|
- 'docker/docker-compose.middleware.yaml'
|
||||||
- 'docker/middleware.env.example'
|
- 'docker/middleware.env.example'
|
||||||
@@ -88,6 +92,7 @@ jobs:
|
|||||||
vdb:
|
vdb:
|
||||||
- 'api/core/rag/datasource/**'
|
- 'api/core/rag/datasource/**'
|
||||||
- 'api/tests/integration_tests/vdb/**'
|
- 'api/tests/integration_tests/vdb/**'
|
||||||
|
- 'api/providers/vdb/*/tests/**'
|
||||||
- '.github/workflows/vdb-tests.yml'
|
- '.github/workflows/vdb-tests.yml'
|
||||||
- '.github/workflows/expose_service_ports.sh'
|
- '.github/workflows/expose_service_ports.sh'
|
||||||
- 'docker/.env.example'
|
- 'docker/.env.example'
|
||||||
@@ -136,7 +141,7 @@ jobs:
|
|||||||
- pre_job
|
- pre_job
|
||||||
- check-changes
|
- check-changes
|
||||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.api-changed != 'true'
|
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.api-changed != 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Report skipped API tests
|
- name: Report skipped API tests
|
||||||
run: echo "No API-related changes detected; skipping API tests."
|
run: echo "No API-related changes detected; skipping API tests."
|
||||||
@@ -149,7 +154,7 @@ jobs:
|
|||||||
- check-changes
|
- check-changes
|
||||||
- api-tests-run
|
- api-tests-run
|
||||||
- api-tests-skip
|
- api-tests-skip
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Finalize API Tests status
|
- name: Finalize API Tests status
|
||||||
env:
|
env:
|
||||||
@@ -196,7 +201,7 @@ jobs:
|
|||||||
- pre_job
|
- pre_job
|
||||||
- check-changes
|
- check-changes
|
||||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.web-changed != 'true'
|
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.web-changed != 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Report skipped web tests
|
- name: Report skipped web tests
|
||||||
run: echo "No web-related changes detected; skipping web tests."
|
run: echo "No web-related changes detected; skipping web tests."
|
||||||
@@ -209,7 +214,7 @@ jobs:
|
|||||||
- check-changes
|
- check-changes
|
||||||
- web-tests-run
|
- web-tests-run
|
||||||
- web-tests-skip
|
- web-tests-skip
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Finalize Web Tests status
|
- name: Finalize Web Tests status
|
||||||
env:
|
env:
|
||||||
@@ -255,7 +260,7 @@ jobs:
|
|||||||
- pre_job
|
- pre_job
|
||||||
- check-changes
|
- check-changes
|
||||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed != 'true'
|
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed != 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Report skipped web full-stack e2e
|
- name: Report skipped web full-stack e2e
|
||||||
run: echo "No E2E-related changes detected; skipping web full-stack E2E."
|
run: echo "No E2E-related changes detected; skipping web full-stack E2E."
|
||||||
@@ -268,7 +273,7 @@ jobs:
|
|||||||
- check-changes
|
- check-changes
|
||||||
- web-e2e-run
|
- web-e2e-run
|
||||||
- web-e2e-skip
|
- web-e2e-skip
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Finalize Web Full-Stack E2E status
|
- name: Finalize Web Full-Stack E2E status
|
||||||
env:
|
env:
|
||||||
@@ -320,7 +325,7 @@ jobs:
|
|||||||
- pre_job
|
- pre_job
|
||||||
- check-changes
|
- check-changes
|
||||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.vdb-changed != 'true'
|
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.vdb-changed != 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Report skipped VDB tests
|
- name: Report skipped VDB tests
|
||||||
run: echo "No VDB-related changes detected; skipping VDB tests."
|
run: echo "No VDB-related changes detected; skipping VDB tests."
|
||||||
@@ -333,7 +338,7 @@ jobs:
|
|||||||
- check-changes
|
- check-changes
|
||||||
- vdb-tests-run
|
- vdb-tests-run
|
||||||
- vdb-tests-skip
|
- vdb-tests-skip
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Finalize VDB Tests status
|
- name: Finalize VDB Tests status
|
||||||
env:
|
env:
|
||||||
@@ -379,7 +384,7 @@ jobs:
|
|||||||
- pre_job
|
- pre_job
|
||||||
- check-changes
|
- check-changes
|
||||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.migration-changed != 'true'
|
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.migration-changed != 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Report skipped DB migration tests
|
- name: Report skipped DB migration tests
|
||||||
run: echo "No migration-related changes detected; skipping DB migration tests."
|
run: echo "No migration-related changes detected; skipping DB migration tests."
|
||||||
@@ -392,7 +397,7 @@ jobs:
|
|||||||
- check-changes
|
- check-changes
|
||||||
- db-migration-test-run
|
- db-migration-test-run
|
||||||
- db-migration-test-skip
|
- db-migration-test-skip
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Finalize DB Migration Test status
|
- name: Finalize DB Migration Test status
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ permissions: {}
|
|||||||
jobs:
|
jobs:
|
||||||
comment:
|
comment:
|
||||||
name: Comment PR with pyrefly diff
|
name: Comment PR with pyrefly diff
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
actions: read
|
actions: read
|
||||||
contents: read
|
contents: read
|
||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.pull_requests[0].head.repo.full_name != github.repository }}
|
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.pull_requests[0].head.repo.full_name != github.repository }}
|
||||||
steps:
|
steps:
|
||||||
- name: Download pyrefly diff artifact
|
- name: Download pyrefly diff artifact
|
||||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
script: |
|
script: |
|
||||||
@@ -49,7 +49,7 @@ jobs:
|
|||||||
run: unzip -o pyrefly_diff.zip
|
run: unzip -o pyrefly_diff.zip
|
||||||
|
|
||||||
- name: Post comment
|
- name: Post comment
|
||||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
script: |
|
script: |
|
||||||
@@ -76,13 +76,11 @@ jobs:
|
|||||||
diff += '\\n\\n... (truncated) ...';
|
diff += '\\n\\n... (truncated) ...';
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = diff.trim()
|
if (diff.trim()) {
|
||||||
? '### Pyrefly Diff\n<details>\n<summary>base → PR</summary>\n\n```diff\n' + diff + '\n```\n</details>'
|
await github.rest.issues.createComment({
|
||||||
: '### Pyrefly Diff\nNo changes detected.';
|
issue_number: prNumber,
|
||||||
|
owner: context.repo.owner,
|
||||||
await github.rest.issues.createComment({
|
repo: context.repo.repo,
|
||||||
issue_number: prNumber,
|
body: '### Pyrefly Diff\n<details>\n<summary>base → PR</summary>\n\n```diff\n' + diff + '\n```\n</details>',
|
||||||
owner: context.repo.owner,
|
});
|
||||||
repo: context.repo.repo,
|
}
|
||||||
body,
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
pyrefly-diff:
|
pyrefly-diff:
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
issues: write
|
issues: write
|
||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Python & UV
|
- name: Setup Python & UV
|
||||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ jobs:
|
|||||||
echo ${{ github.event.pull_request.number }} > pr_number.txt
|
echo ${{ github.event.pull_request.number }} > pr_number.txt
|
||||||
|
|
||||||
- name: Upload pyrefly diff
|
- name: Upload pyrefly diff
|
||||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: pyrefly_diff
|
name: pyrefly_diff
|
||||||
path: |
|
path: |
|
||||||
@@ -75,7 +75,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Comment PR with pyrefly diff
|
- name: Comment PR with pyrefly diff
|
||||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && steps.line_count_check.outputs.same == 'false' }}
|
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && steps.line_count_check.outputs.same == 'false' }}
|
||||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
script: |
|
script: |
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
name: Comment with Pyrefly Type Coverage
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows:
|
||||||
|
- Pyrefly Type Coverage
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
comment:
|
||||||
|
name: Comment PR with type coverage
|
||||||
|
runs-on: depot-ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.pull_requests[0].head.repo.full_name != github.repository }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout default branch (trusted code)
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
|
- name: Setup Python & UV
|
||||||
|
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --project api --dev
|
||||||
|
|
||||||
|
- name: Download type coverage artifact
|
||||||
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
script: |
|
||||||
|
const fs = require('fs');
|
||||||
|
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
run_id: ${{ github.event.workflow_run.id }},
|
||||||
|
});
|
||||||
|
const match = artifacts.data.artifacts.find((artifact) =>
|
||||||
|
artifact.name === 'pyrefly_type_coverage'
|
||||||
|
);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error('pyrefly_type_coverage artifact not found');
|
||||||
|
}
|
||||||
|
const download = await github.rest.actions.downloadArtifact({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
artifact_id: match.id,
|
||||||
|
archive_format: 'zip',
|
||||||
|
});
|
||||||
|
fs.writeFileSync('pyrefly_type_coverage.zip', Buffer.from(download.data));
|
||||||
|
|
||||||
|
- name: Unzip artifact
|
||||||
|
run: unzip -o pyrefly_type_coverage.zip
|
||||||
|
|
||||||
|
- name: Render coverage markdown from structured data
|
||||||
|
id: render
|
||||||
|
run: |
|
||||||
|
comment_body="$(uv run --directory api python libs/pyrefly_type_coverage.py \
|
||||||
|
--base base_report.json \
|
||||||
|
< pr_report.json)"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "### Pyrefly Type Coverage"
|
||||||
|
echo ""
|
||||||
|
echo "$comment_body"
|
||||||
|
} > /tmp/type_coverage_comment.md
|
||||||
|
|
||||||
|
- name: Post comment
|
||||||
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
script: |
|
||||||
|
const fs = require('fs');
|
||||||
|
const body = fs.readFileSync('/tmp/type_coverage_comment.md', { encoding: 'utf8' });
|
||||||
|
let prNumber = null;
|
||||||
|
try {
|
||||||
|
prNumber = parseInt(fs.readFileSync('pr_number.txt', { encoding: 'utf8' }), 10);
|
||||||
|
} catch (err) {
|
||||||
|
const prs = context.payload.workflow_run.pull_requests || [];
|
||||||
|
if (prs.length > 0 && prs[0].number) {
|
||||||
|
prNumber = prs[0].number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!prNumber) {
|
||||||
|
throw new Error('PR number not found in artifact or workflow_run payload');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing comment if one exists, otherwise create new
|
||||||
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
|
issue_number: prNumber,
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
});
|
||||||
|
const marker = '### Pyrefly Type Coverage';
|
||||||
|
const existing = comments.find(c => c.body.startsWith(marker));
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
comment_id: existing.id,
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
issue_number: prNumber,
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
name: Pyrefly Type Coverage
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'api/**/*.py'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pyrefly-type-coverage:
|
||||||
|
runs-on: depot-ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout PR branch
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Python & UV
|
||||||
|
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --project api --dev
|
||||||
|
|
||||||
|
- name: Run pyrefly report on PR branch
|
||||||
|
run: |
|
||||||
|
uv run --directory api --dev pyrefly report 2>/dev/null > /tmp/pyrefly_report_pr.tmp && \
|
||||||
|
mv /tmp/pyrefly_report_pr.tmp /tmp/pyrefly_report_pr.json || \
|
||||||
|
echo '{}' > /tmp/pyrefly_report_pr.json
|
||||||
|
|
||||||
|
- name: Save helper script from base branch
|
||||||
|
run: |
|
||||||
|
git show ${{ github.event.pull_request.base.sha }}:api/libs/pyrefly_type_coverage.py > /tmp/pyrefly_type_coverage.py 2>/dev/null \
|
||||||
|
|| cp api/libs/pyrefly_type_coverage.py /tmp/pyrefly_type_coverage.py
|
||||||
|
|
||||||
|
- name: Checkout base branch
|
||||||
|
run: git checkout ${{ github.base_ref }}
|
||||||
|
|
||||||
|
- name: Run pyrefly report on base branch
|
||||||
|
run: |
|
||||||
|
uv run --directory api --dev pyrefly report 2>/dev/null > /tmp/pyrefly_report_base.tmp && \
|
||||||
|
mv /tmp/pyrefly_report_base.tmp /tmp/pyrefly_report_base.json || \
|
||||||
|
echo '{}' > /tmp/pyrefly_report_base.json
|
||||||
|
|
||||||
|
- name: Generate coverage comparison
|
||||||
|
id: coverage
|
||||||
|
run: |
|
||||||
|
comment_body="$(uv run --directory api python /tmp/pyrefly_type_coverage.py \
|
||||||
|
--base /tmp/pyrefly_report_base.json \
|
||||||
|
< /tmp/pyrefly_report_pr.json)"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "### Pyrefly Type Coverage"
|
||||||
|
echo ""
|
||||||
|
echo "$comment_body"
|
||||||
|
} | tee -a "$GITHUB_STEP_SUMMARY" > /tmp/type_coverage_comment.md
|
||||||
|
|
||||||
|
# Save structured data for the fork-PR comment workflow
|
||||||
|
cp /tmp/pyrefly_report_pr.json pr_report.json
|
||||||
|
cp /tmp/pyrefly_report_base.json base_report.json
|
||||||
|
|
||||||
|
- name: Save PR number
|
||||||
|
run: |
|
||||||
|
echo ${{ github.event.pull_request.number }} > pr_number.txt
|
||||||
|
|
||||||
|
- name: Upload type coverage artifact
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: pyrefly_type_coverage
|
||||||
|
path: |
|
||||||
|
pr_report.json
|
||||||
|
base_report.json
|
||||||
|
pr_number.txt
|
||||||
|
|
||||||
|
- name: Comment PR with type coverage
|
||||||
|
if: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||||
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
script: |
|
||||||
|
const fs = require('fs');
|
||||||
|
const marker = '### Pyrefly Type Coverage';
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = fs.readFileSync('/tmp/type_coverage_comment.md', { encoding: 'utf8' });
|
||||||
|
} catch {
|
||||||
|
body = `${marker}\n\n_Coverage report unavailable._`;
|
||||||
|
}
|
||||||
|
const prNumber = context.payload.pull_request.number;
|
||||||
|
|
||||||
|
// Update existing comment if one exists, otherwise create new
|
||||||
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
|
issue_number: prNumber,
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
});
|
||||||
|
const existing = comments.find(c => c.body.startsWith(marker));
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
comment_id: existing.id,
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
issue_number: prNumber,
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ jobs:
|
|||||||
name: Validate PR title
|
name: Validate PR title
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: read
|
pull-requests: read
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Complete merge group check
|
- name: Complete merge group check
|
||||||
if: github.event_name == 'merge_group'
|
if: github.event_name == 'merge_group'
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
stale:
|
stale:
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
issues: write
|
issues: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
@@ -23,8 +23,8 @@ jobs:
|
|||||||
days-before-issue-stale: 15
|
days-before-issue-stale: 15
|
||||||
days-before-issue-close: 3
|
days-before-issue-close: 3
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
stale-issue-message: "Close due to it's no longer active, if you have any questions, you can reopen it."
|
stale-issue-message: "Closed due to inactivity. If you have any questions, you can reopen it."
|
||||||
stale-pr-message: "Close due to it's no longer active, if you have any questions, you can reopen it."
|
stale-pr-message: "Closed due to inactivity. If you have any questions, you can reopen it."
|
||||||
stale-issue-label: 'no-issue-activity'
|
stale-issue-label: 'no-issue-activity'
|
||||||
stale-pr-label: 'no-pr-activity'
|
stale-pr-label: 'no-pr-activity'
|
||||||
any-of-labels: 'duplicate,question,invalid,wontfix,no-issue-activity,no-pr-activity,enhancement,cant-reproduce,help-wanted'
|
any-of-labels: '🌚 invalid,🙋♂️ question,wont-fix,no-issue-activity,no-pr-activity,💪 enhancement,🤔 cant-reproduce,🙏 help wanted'
|
||||||
|
|||||||
+22
-16
@@ -15,7 +15,7 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
python-style:
|
python-style:
|
||||||
name: Python Style
|
name: Python Style
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Check changed files
|
- name: Check changed files
|
||||||
id: changed-files
|
id: changed-files
|
||||||
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
|
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
api/**
|
api/**
|
||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup UV and Python
|
- name: Setup UV and Python
|
||||||
if: steps.changed-files.outputs.any_changed == 'true'
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||||
with:
|
with:
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
@@ -57,7 +57,7 @@ jobs:
|
|||||||
|
|
||||||
web-style:
|
web-style:
|
||||||
name: Web Style
|
name: Web Style
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: ./web
|
working-directory: ./web
|
||||||
@@ -73,13 +73,17 @@ jobs:
|
|||||||
|
|
||||||
- name: Check changed files
|
- name: Check changed files
|
||||||
id: changed-files
|
id: changed-files
|
||||||
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
|
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
web/**
|
web/**
|
||||||
|
e2e/**
|
||||||
|
sdks/nodejs-client/**
|
||||||
|
packages/**
|
||||||
package.json
|
package.json
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
pnpm-workspace.yaml
|
pnpm-workspace.yaml
|
||||||
|
.npmrc
|
||||||
.nvmrc
|
.nvmrc
|
||||||
.github/workflows/style.yml
|
.github/workflows/style.yml
|
||||||
.github/actions/setup-web/**
|
.github/actions/setup-web/**
|
||||||
@@ -91,26 +95,28 @@ jobs:
|
|||||||
- name: Restore ESLint cache
|
- name: Restore ESLint cache
|
||||||
if: steps.changed-files.outputs.any_changed == 'true'
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
id: eslint-cache-restore
|
id: eslint-cache-restore
|
||||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: web/.eslintcache
|
path: .eslintcache
|
||||||
key: ${{ runner.os }}-web-eslint-${{ hashFiles('web/package.json', 'pnpm-lock.yaml', 'web/eslint.config.mjs', 'web/eslint.constants.mjs', 'web/plugins/eslint/**') }}-${{ github.sha }}
|
key: ${{ runner.os }}-eslint-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'web/eslint.config.mjs', 'web/eslint.constants.mjs', 'web/plugins/eslint/**') }}-${{ github.sha }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-web-eslint-${{ hashFiles('web/package.json', 'pnpm-lock.yaml', 'web/eslint.config.mjs', 'web/eslint.constants.mjs', 'web/plugins/eslint/**') }}-
|
${{ runner.os }}-eslint-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'web/eslint.config.mjs', 'web/eslint.constants.mjs', 'web/plugins/eslint/**') }}-
|
||||||
|
|
||||||
- name: Web style check
|
- name: Web style check
|
||||||
if: steps.changed-files.outputs.any_changed == 'true'
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
working-directory: ./web
|
working-directory: .
|
||||||
run: vp run lint:ci
|
run: vp run lint:ci
|
||||||
|
|
||||||
- name: Web tsslint
|
- name: Web tsslint
|
||||||
if: steps.changed-files.outputs.any_changed == 'true'
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
working-directory: ./web
|
working-directory: ./web
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
run: vp run lint:tss
|
run: vp run lint:tss
|
||||||
|
|
||||||
- name: Web type check
|
- name: Web type check
|
||||||
if: steps.changed-files.outputs.any_changed == 'true'
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
working-directory: ./web
|
working-directory: .
|
||||||
run: vp run type-check
|
run: vp run type-check
|
||||||
|
|
||||||
- name: Web dead code check
|
- name: Web dead code check
|
||||||
@@ -120,14 +126,14 @@ jobs:
|
|||||||
|
|
||||||
- name: Save ESLint cache
|
- name: Save ESLint cache
|
||||||
if: steps.changed-files.outputs.any_changed == 'true' && success() && steps.eslint-cache-restore.outputs.cache-hit != 'true'
|
if: steps.changed-files.outputs.any_changed == 'true' && success() && steps.eslint-cache-restore.outputs.cache-hit != 'true'
|
||||||
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: web/.eslintcache
|
path: .eslintcache
|
||||||
key: ${{ steps.eslint-cache-restore.outputs.cache-primary-key }}
|
key: ${{ steps.eslint-cache-restore.outputs.cache-primary-key }}
|
||||||
|
|
||||||
superlinter:
|
superlinter:
|
||||||
name: SuperLinter
|
name: SuperLinter
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -138,7 +144,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Check changed files
|
- name: Check changed files
|
||||||
id: changed-files
|
id: changed-files
|
||||||
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
|
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
**.sh
|
**.sh
|
||||||
@@ -149,7 +155,7 @@ jobs:
|
|||||||
.editorconfig
|
.editorconfig
|
||||||
|
|
||||||
- name: Super-linter
|
- name: Super-linter
|
||||||
uses: super-linter/super-linter/slim@61abc07d755095a68f4987d1c2c3d1d64408f1f9 # v8.5.0
|
uses: super-linter/super-linter/slim@9e863354e3ff62e0727d37183162c4a88873df41 # v8.6.0
|
||||||
if: steps.changed-files.outputs.any_changed == 'true'
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
env:
|
env:
|
||||||
BASH_SEVERITY: warning
|
BASH_SEVERITY: warning
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ on:
|
|||||||
- package.json
|
- package.json
|
||||||
- pnpm-lock.yaml
|
- pnpm-lock.yaml
|
||||||
- pnpm-workspace.yaml
|
- pnpm-workspace.yaml
|
||||||
|
- .npmrc
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: sdk-tests-${{ github.head_ref || github.run_id }}
|
group: sdk-tests-${{ github.head_ref || github.run_id }}
|
||||||
@@ -17,7 +18,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: unit test for Node.js SDK
|
name: unit test for Node.js SDK
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -29,7 +30,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: ''
|
cache: ''
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
translate:
|
translate:
|
||||||
if: github.repository == 'langgenius/dify'
|
if: github.repository == 'langgenius/dify'
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -68,89 +68,7 @@ jobs:
|
|||||||
" web/i18n-config/languages.ts | sed 's/[[:space:]]*$//')
|
" web/i18n-config/languages.ts | sed 's/[[:space:]]*$//')
|
||||||
|
|
||||||
generate_changes_json() {
|
generate_changes_json() {
|
||||||
node <<'NODE'
|
node .github/scripts/generate-i18n-changes.mjs
|
||||||
const { execFileSync } = require('node:child_process')
|
|
||||||
const fs = require('node:fs')
|
|
||||||
const path = require('node:path')
|
|
||||||
|
|
||||||
const repoRoot = process.cwd()
|
|
||||||
const baseSha = process.env.BASE_SHA || ''
|
|
||||||
const headSha = process.env.HEAD_SHA || ''
|
|
||||||
const files = (process.env.CHANGED_FILES || '').split(/\s+/).filter(Boolean)
|
|
||||||
|
|
||||||
const englishPath = fileStem => path.join(repoRoot, 'web', 'i18n', 'en-US', `${fileStem}.json`)
|
|
||||||
|
|
||||||
const readCurrentJson = (fileStem) => {
|
|
||||||
const filePath = englishPath(fileStem)
|
|
||||||
if (!fs.existsSync(filePath))
|
|
||||||
return null
|
|
||||||
|
|
||||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
|
||||||
}
|
|
||||||
|
|
||||||
const readBaseJson = (fileStem) => {
|
|
||||||
if (!baseSha)
|
|
||||||
return null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const relativePath = `web/i18n/en-US/${fileStem}.json`
|
|
||||||
const content = execFileSync('git', ['show', `${baseSha}:${relativePath}`], { encoding: 'utf8' })
|
|
||||||
return JSON.parse(content)
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const compareJson = (beforeValue, afterValue) => JSON.stringify(beforeValue) === JSON.stringify(afterValue)
|
|
||||||
|
|
||||||
const changes = {}
|
|
||||||
|
|
||||||
for (const fileStem of files) {
|
|
||||||
const currentJson = readCurrentJson(fileStem)
|
|
||||||
const beforeJson = readBaseJson(fileStem) || {}
|
|
||||||
const afterJson = currentJson || {}
|
|
||||||
const added = {}
|
|
||||||
const updated = {}
|
|
||||||
const deleted = []
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(afterJson)) {
|
|
||||||
if (!(key in beforeJson)) {
|
|
||||||
added[key] = value
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!compareJson(beforeJson[key], value)) {
|
|
||||||
updated[key] = {
|
|
||||||
before: beforeJson[key],
|
|
||||||
after: value,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const key of Object.keys(beforeJson)) {
|
|
||||||
if (!(key in afterJson))
|
|
||||||
deleted.push(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
changes[fileStem] = {
|
|
||||||
fileDeleted: currentJson === null,
|
|
||||||
added,
|
|
||||||
updated,
|
|
||||||
deleted,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
'/tmp/i18n-changes.json',
|
|
||||||
JSON.stringify({
|
|
||||||
baseSha,
|
|
||||||
headSha,
|
|
||||||
files,
|
|
||||||
changes,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
NODE
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if [ "${{ github.event_name }}" = "repository_dispatch" ]; then
|
if [ "${{ github.event_name }}" = "repository_dispatch" ]; then
|
||||||
@@ -240,7 +158,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run Claude Code for Translation Sync
|
- name: Run Claude Code for Translation Sync
|
||||||
if: steps.context.outputs.CHANGED_FILES != ''
|
if: steps.context.outputs.CHANGED_FILES != ''
|
||||||
uses: anthropics/claude-code-action@88c168b39e7e64da0286d812b6e9fbebb6708185 # v1.0.82
|
uses: anthropics/claude-code-action@567fe954a4527e81f132d87d1bdbcc94f7737434 # v1.0.107
|
||||||
with:
|
with:
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -270,7 +188,7 @@ jobs:
|
|||||||
Tool rules:
|
Tool rules:
|
||||||
- Use Read for repository files.
|
- Use Read for repository files.
|
||||||
- Use Edit for JSON updates.
|
- Use Edit for JSON updates.
|
||||||
- Use Bash only for `pnpm`.
|
- Use Bash only for `vp`.
|
||||||
- Do not use Bash for `git`, `gh`, or branch management.
|
- Do not use Bash for `git`, `gh`, or branch management.
|
||||||
|
|
||||||
Required execution plan:
|
Required execution plan:
|
||||||
@@ -292,7 +210,7 @@ jobs:
|
|||||||
- Read the current English JSON file for any file that still exists so wording, placeholders, and surrounding terminology stay accurate.
|
- Read the current English JSON file for any file that still exists so wording, placeholders, and surrounding terminology stay accurate.
|
||||||
- If `Structured change set available` is `false`, treat this as a scoped full sync and use the current English files plus scoped checks as the source of truth.
|
- If `Structured change set available` is `false`, treat this as a scoped full sync and use the current English files plus scoped checks as the source of truth.
|
||||||
4. Run a scoped pre-check before editing:
|
4. Run a scoped pre-check before editing:
|
||||||
- `pnpm --dir ${{ github.workspace }}/web run i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }}`
|
- `vp run dify-web#i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }}`
|
||||||
- Use this command as the source of truth for missing and extra keys inside the current scope.
|
- Use this command as the source of truth for missing and extra keys inside the current scope.
|
||||||
5. Apply translations.
|
5. Apply translations.
|
||||||
- For every target language and scoped file:
|
- For every target language and scoped file:
|
||||||
@@ -300,19 +218,19 @@ jobs:
|
|||||||
- If the locale file does not exist yet, create it with `Write` and then continue with `Edit` as needed.
|
- If the locale file does not exist yet, create it with `Write` and then continue with `Edit` as needed.
|
||||||
- ADD missing keys.
|
- ADD missing keys.
|
||||||
- UPDATE stale translations when the English value changed.
|
- UPDATE stale translations when the English value changed.
|
||||||
- DELETE removed keys. Prefer `pnpm --dir ${{ github.workspace }}/web run i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }} --auto-remove` for extra keys so deletions stay in scope.
|
- DELETE removed keys. Prefer `vp run dify-web#i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }} --auto-remove` for extra keys so deletions stay in scope.
|
||||||
- Preserve placeholders exactly: `{{variable}}`, `${variable}`, HTML tags, component tags, and variable names.
|
- Preserve placeholders exactly: `{{variable}}`, `${variable}`, HTML tags, component tags, and variable names.
|
||||||
- Match the existing terminology and register used by each locale.
|
- Match the existing terminology and register used by each locale.
|
||||||
- Prefer one Edit per file when stable, but prioritize correctness over batching.
|
- Prefer one Edit per file when stable, but prioritize correctness over batching.
|
||||||
6. Verify only the edited files.
|
6. Verify only the edited files.
|
||||||
- Run `pnpm --dir ${{ github.workspace }}/web lint:fix --quiet -- <relative edited i18n file paths>`
|
- Run `vp run dify-web#lint:fix --quiet -- <relative edited i18n file paths under web/>`
|
||||||
- Run `pnpm --dir ${{ github.workspace }}/web run i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }}`
|
- Run `vp run dify-web#i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }}`
|
||||||
- If verification fails, fix the remaining problems before continuing.
|
- If verification fails, fix the remaining problems before continuing.
|
||||||
7. Stop after the scoped locale files are updated and verification passes.
|
7. Stop after the scoped locale files are updated and verification passes.
|
||||||
- Do not create branches, commits, or pull requests.
|
- Do not create branches, commits, or pull requests.
|
||||||
claude_args: |
|
claude_args: |
|
||||||
--max-turns 120
|
--max-turns 120
|
||||||
--allowedTools "Read,Write,Edit,Bash(pnpm *),Bash(pnpm:*),Glob,Grep"
|
--allowedTools "Read,Write,Edit,Bash(vp *),Bash(vp:*),Glob,Grep"
|
||||||
|
|
||||||
- name: Prepare branch metadata
|
- name: Prepare branch metadata
|
||||||
id: pr_meta
|
id: pr_meta
|
||||||
@@ -354,6 +272,7 @@ jobs:
|
|||||||
- name: Create or update translation PR
|
- name: Create or update translation PR
|
||||||
if: steps.pr_meta.outputs.has_changes == 'true'
|
if: steps.pr_meta.outputs.has_changes == 'true'
|
||||||
env:
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
BRANCH_NAME: ${{ steps.pr_meta.outputs.branch_name }}
|
BRANCH_NAME: ${{ steps.pr_meta.outputs.branch_name }}
|
||||||
FILES_IN_SCOPE: ${{ steps.context.outputs.CHANGED_FILES }}
|
FILES_IN_SCOPE: ${{ steps.context.outputs.CHANGED_FILES }}
|
||||||
TARGET_LANGS: ${{ steps.context.outputs.TARGET_LANGS }}
|
TARGET_LANGS: ${{ steps.context.outputs.TARGET_LANGS }}
|
||||||
@@ -402,8 +321,8 @@ jobs:
|
|||||||
'',
|
'',
|
||||||
'## Verification',
|
'## Verification',
|
||||||
'',
|
'',
|
||||||
`- \`pnpm --dir web run i18n:check --file ${process.env.FILES_IN_SCOPE} --lang ${process.env.TARGET_LANGS}\``,
|
`- \`vp run dify-web#i18n:check --file ${process.env.FILES_IN_SCOPE} --lang ${process.env.TARGET_LANGS}\``,
|
||||||
`- \`pnpm --dir web lint:fix --quiet -- <edited i18n files>\``,
|
`- \`vp run dify-web#lint:fix --quiet -- <edited i18n files under web/>\``,
|
||||||
'',
|
'',
|
||||||
'## Notes',
|
'## Notes',
|
||||||
'',
|
'',
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
trigger:
|
trigger:
|
||||||
if: github.repository == 'langgenius/dify'
|
if: github.repository == 'langgenius/dify'
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -42,88 +42,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
export BASE_SHA HEAD_SHA CHANGED_FILES
|
export BASE_SHA HEAD_SHA CHANGED_FILES
|
||||||
node <<'NODE'
|
node .github/scripts/generate-i18n-changes.mjs
|
||||||
const { execFileSync } = require('node:child_process')
|
|
||||||
const fs = require('node:fs')
|
|
||||||
const path = require('node:path')
|
|
||||||
|
|
||||||
const repoRoot = process.cwd()
|
|
||||||
const baseSha = process.env.BASE_SHA || ''
|
|
||||||
const headSha = process.env.HEAD_SHA || ''
|
|
||||||
const files = (process.env.CHANGED_FILES || '').split(/\s+/).filter(Boolean)
|
|
||||||
|
|
||||||
const englishPath = fileStem => path.join(repoRoot, 'web', 'i18n', 'en-US', `${fileStem}.json`)
|
|
||||||
|
|
||||||
const readCurrentJson = (fileStem) => {
|
|
||||||
const filePath = englishPath(fileStem)
|
|
||||||
if (!fs.existsSync(filePath))
|
|
||||||
return null
|
|
||||||
|
|
||||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
|
||||||
}
|
|
||||||
|
|
||||||
const readBaseJson = (fileStem) => {
|
|
||||||
if (!baseSha)
|
|
||||||
return null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const relativePath = `web/i18n/en-US/${fileStem}.json`
|
|
||||||
const content = execFileSync('git', ['show', `${baseSha}:${relativePath}`], { encoding: 'utf8' })
|
|
||||||
return JSON.parse(content)
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const compareJson = (beforeValue, afterValue) => JSON.stringify(beforeValue) === JSON.stringify(afterValue)
|
|
||||||
|
|
||||||
const changes = {}
|
|
||||||
|
|
||||||
for (const fileStem of files) {
|
|
||||||
const beforeJson = readBaseJson(fileStem) || {}
|
|
||||||
const afterJson = readCurrentJson(fileStem) || {}
|
|
||||||
const added = {}
|
|
||||||
const updated = {}
|
|
||||||
const deleted = []
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(afterJson)) {
|
|
||||||
if (!(key in beforeJson)) {
|
|
||||||
added[key] = value
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!compareJson(beforeJson[key], value)) {
|
|
||||||
updated[key] = {
|
|
||||||
before: beforeJson[key],
|
|
||||||
after: value,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const key of Object.keys(beforeJson)) {
|
|
||||||
if (!(key in afterJson))
|
|
||||||
deleted.push(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
changes[fileStem] = {
|
|
||||||
fileDeleted: readCurrentJson(fileStem) === null,
|
|
||||||
added,
|
|
||||||
updated,
|
|
||||||
deleted,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
'/tmp/i18n-changes.json',
|
|
||||||
JSON.stringify({
|
|
||||||
baseSha,
|
|
||||||
headSha,
|
|
||||||
files,
|
|
||||||
changes,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
NODE
|
|
||||||
|
|
||||||
if [ -n "$CHANGED_FILES" ]; then
|
if [ -n "$CHANGED_FILES" ]; then
|
||||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||||
@@ -137,7 +56,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Trigger i18n sync workflow
|
- name: Trigger i18n sync workflow
|
||||||
if: steps.detect.outputs.has_changes == 'true'
|
if: steps.detect.outputs.has_changes == 'true'
|
||||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
env:
|
env:
|
||||||
BASE_SHA: ${{ steps.detect.outputs.base_sha }}
|
BASE_SHA: ${{ steps.detect.outputs.base_sha }}
|
||||||
HEAD_SHA: ${{ steps.detect.outputs.head_sha }}
|
HEAD_SHA: ${{ steps.detect.outputs.head_sha }}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
name: Full VDB Tests
|
name: Full VDB Tests
|
||||||
if: github.repository == 'langgenius/dify'
|
if: github.repository == 'langgenius/dify'
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version:
|
python-version:
|
||||||
@@ -36,7 +36,7 @@ jobs:
|
|||||||
remove_tool_cache: true
|
remove_tool_cache: true
|
||||||
|
|
||||||
- name: Setup UV and Python
|
- name: Setup UV and Python
|
||||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
@@ -65,7 +65,7 @@ jobs:
|
|||||||
# tiflash
|
# tiflash
|
||||||
|
|
||||||
- name: Set up Full Vector Store Matrix
|
- name: Set up Full Vector Store Matrix
|
||||||
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
|
||||||
with:
|
with:
|
||||||
compose-file: |
|
compose-file: |
|
||||||
docker/docker-compose.yaml
|
docker/docker-compose.yaml
|
||||||
@@ -89,7 +89,7 @@ jobs:
|
|||||||
cp api/tests/integration_tests/.env.example api/tests/integration_tests/.env
|
cp api/tests/integration_tests/.env.example api/tests/integration_tests/.env
|
||||||
|
|
||||||
# - name: Check VDB Ready (TiDB)
|
# - name: Check VDB Ready (TiDB)
|
||||||
# run: uv run --project api python api/tests/integration_tests/vdb/tidb_vector/check_tiflash_ready.py
|
# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py
|
||||||
|
|
||||||
- name: Test Vector Stores
|
- name: Test Vector Stores
|
||||||
run: uv run --project api bash dev/pytest/pytest_vdb.sh
|
run: uv run --project api bash dev/pytest/pytest_vdb.sh
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: VDB Smoke Tests
|
name: VDB Smoke Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version:
|
python-version:
|
||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
remove_tool_cache: true
|
remove_tool_cache: true
|
||||||
|
|
||||||
- name: Setup UV and Python
|
- name: Setup UV and Python
|
||||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
# tiflash
|
# tiflash
|
||||||
|
|
||||||
- name: Set up Vector Stores for Smoke Coverage
|
- name: Set up Vector Stores for Smoke Coverage
|
||||||
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
|
||||||
with:
|
with:
|
||||||
compose-file: |
|
compose-file: |
|
||||||
docker/docker-compose.yaml
|
docker/docker-compose.yaml
|
||||||
@@ -81,12 +81,12 @@ jobs:
|
|||||||
cp api/tests/integration_tests/.env.example api/tests/integration_tests/.env
|
cp api/tests/integration_tests/.env.example api/tests/integration_tests/.env
|
||||||
|
|
||||||
# - name: Check VDB Ready (TiDB)
|
# - name: Check VDB Ready (TiDB)
|
||||||
# run: uv run --project api python api/tests/integration_tests/vdb/tidb_vector/check_tiflash_ready.py
|
# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py
|
||||||
|
|
||||||
- name: Test Vector Stores
|
- name: Test Vector Stores
|
||||||
run: |
|
run: |
|
||||||
uv run --project api pytest --timeout "${PYTEST_TIMEOUT:-180}" \
|
uv run --project api pytest --timeout "${PYTEST_TIMEOUT:-180}" \
|
||||||
api/tests/integration_tests/vdb/chroma \
|
api/providers/vdb/vdb-chroma/tests/integration_tests \
|
||||||
api/tests/integration_tests/vdb/pgvector \
|
api/providers/vdb/vdb-pgvector/tests/integration_tests \
|
||||||
api/tests/integration_tests/vdb/qdrant \
|
api/providers/vdb/vdb-qdrant/tests/integration_tests \
|
||||||
api/tests/integration_tests/vdb/weaviate
|
api/providers/vdb/vdb-weaviate/tests/integration_tests
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Web Full-Stack E2E
|
name: Web Full-Stack E2E
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04-4
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
uses: ./.github/actions/setup-web
|
uses: ./.github/actions/setup-web
|
||||||
|
|
||||||
- name: Setup UV and Python
|
- name: Setup UV and Python
|
||||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
@@ -53,7 +53,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload Cucumber report
|
- name: Upload Cucumber report
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: cucumber-report
|
name: cucumber-report
|
||||||
path: e2e/cucumber-report
|
path: e2e/cucumber-report
|
||||||
@@ -61,7 +61,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload E2E logs
|
- name: Upload E2E logs
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: e2e-logs
|
name: e2e-logs
|
||||||
path: e2e/.logs
|
path: e2e/.logs
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Web Tests (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
name: Web Tests (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04-4
|
||||||
env:
|
env:
|
||||||
VITEST_COVERAGE_SCOPE: app-components
|
VITEST_COVERAGE_SCOPE: app-components
|
||||||
strategy:
|
strategy:
|
||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload blob report
|
- name: Upload blob report
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: blob-report-${{ matrix.shardIndex }}
|
name: blob-report-${{ matrix.shardIndex }}
|
||||||
path: web/.vitest-reports/*
|
path: web/.vitest-reports/*
|
||||||
@@ -54,7 +54,7 @@ jobs:
|
|||||||
name: Merge Test Reports
|
name: Merge Test Reports
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
needs: [test]
|
needs: [test]
|
||||||
runs-on: ubuntu-latest
|
runs-on: depot-ubuntu-24.04-4
|
||||||
env:
|
env:
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
defaults:
|
defaults:
|
||||||
@@ -89,3 +89,37 @@ jobs:
|
|||||||
flags: web
|
flags: web
|
||||||
env:
|
env:
|
||||||
CODECOV_TOKEN: ${{ env.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ env.CODECOV_TOKEN }}
|
||||||
|
|
||||||
|
dify-ui-test:
|
||||||
|
name: dify-ui Tests
|
||||||
|
runs-on: depot-ubuntu-24.04-4
|
||||||
|
env:
|
||||||
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
working-directory: ./packages/dify-ui
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup web environment
|
||||||
|
uses: ./.github/actions/setup-web
|
||||||
|
|
||||||
|
- name: Install Chromium for Browser Mode
|
||||||
|
run: vp exec playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Run dify-ui tests
|
||||||
|
run: vp test run --coverage --silent=passed-only
|
||||||
|
|
||||||
|
- name: Report coverage
|
||||||
|
if: ${{ env.CODECOV_TOKEN != '' }}
|
||||||
|
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||||
|
with:
|
||||||
|
directory: packages/dify-ui/coverage
|
||||||
|
flags: dify-ui
|
||||||
|
env:
|
||||||
|
CODECOV_TOKEN: ${{ env.CODECOV_TOKEN }}
|
||||||
|
|||||||
+8
-1
@@ -203,6 +203,7 @@ sdks/python-client/dify_client.egg-info
|
|||||||
|
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/launch.json.template
|
!.vscode/launch.json.template
|
||||||
|
!.vscode/settings.example.json
|
||||||
!.vscode/README.md
|
!.vscode/README.md
|
||||||
api/.vscode
|
api/.vscode
|
||||||
# vscode Code History Extension
|
# vscode Code History Extension
|
||||||
@@ -212,7 +213,7 @@ api/.vscode
|
|||||||
|
|
||||||
# pnpm
|
# pnpm
|
||||||
/.pnpm-store
|
/.pnpm-store
|
||||||
/node_modules
|
node_modules
|
||||||
.vite-hooks/_
|
.vite-hooks/_
|
||||||
|
|
||||||
# plugin migrate
|
# plugin migrate
|
||||||
@@ -236,9 +237,15 @@ scripts/stress-test/reports/
|
|||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
.serena/
|
.serena/
|
||||||
|
|
||||||
|
# vitest browser mode attachments (failure screenshots, traces, etc.)
|
||||||
|
.vitest-attachments/
|
||||||
|
**/__screenshots__/
|
||||||
|
|
||||||
# settings
|
# settings
|
||||||
*.local.json
|
*.local.json
|
||||||
*.local.md
|
*.local.md
|
||||||
|
|
||||||
# Code Agent Folder
|
# Code Agent Folder
|
||||||
.qoder/*
|
.qoder/*
|
||||||
|
|
||||||
|
.eslintcache
|
||||||
|
|||||||
+5
-60
@@ -56,64 +56,9 @@ if $api_modified; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if $web_modified; then
|
if $skip_web_checks; then
|
||||||
if $skip_web_checks; then
|
echo "Git operation in progress, skipping web checks"
|
||||||
echo "Git operation in progress, skipping web checks"
|
exit 0
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Running ESLint on web module"
|
|
||||||
|
|
||||||
if git diff --cached --quiet -- 'web/**/*.ts' 'web/**/*.tsx'; then
|
|
||||||
web_ts_modified=false
|
|
||||||
else
|
|
||||||
ts_diff_status=$?
|
|
||||||
if [ $ts_diff_status -eq 1 ]; then
|
|
||||||
web_ts_modified=true
|
|
||||||
else
|
|
||||||
echo "Unable to determine staged TypeScript changes (git exit code: $ts_diff_status)."
|
|
||||||
exit $ts_diff_status
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd ./web || exit 1
|
|
||||||
vp staged
|
|
||||||
|
|
||||||
if $web_ts_modified; then
|
|
||||||
echo "Running TypeScript type-check:tsgo"
|
|
||||||
if ! pnpm run type-check:tsgo; then
|
|
||||||
echo "Type check failed. Please run 'pnpm run type-check:tsgo' to fix the errors."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "No staged TypeScript changes detected, skipping type-check:tsgo"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Running unit tests check"
|
|
||||||
modified_files=$(git diff --cached --name-only -- utils | grep -v '\.spec\.ts$' || true)
|
|
||||||
|
|
||||||
if [ -n "$modified_files" ]; then
|
|
||||||
for file in $modified_files; do
|
|
||||||
test_file="${file%.*}.spec.ts"
|
|
||||||
echo "Checking for test file: $test_file"
|
|
||||||
|
|
||||||
# check if the test file exists
|
|
||||||
if [ -f "../$test_file" ]; then
|
|
||||||
echo "Detected changes in $file, running corresponding unit tests..."
|
|
||||||
pnpm run test "../$test_file"
|
|
||||||
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "Unit tests failed. Please fix the errors before committing."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Unit tests for $file passed."
|
|
||||||
else
|
|
||||||
echo "Warning: $file does not have a corresponding test file."
|
|
||||||
fi
|
|
||||||
|
|
||||||
done
|
|
||||||
echo "All unit tests for modified web/utils files have passed."
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd ../
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
vp staged
|
||||||
|
|||||||
Vendored
+2
-13
@@ -2,21 +2,10 @@
|
|||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "Python: Flask API",
|
"name": "Python: API (gevent)",
|
||||||
"type": "debugpy",
|
"type": "debugpy",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"module": "flask",
|
"program": "${workspaceFolder}/api/app.py",
|
||||||
"env": {
|
|
||||||
"FLASK_APP": "app.py",
|
|
||||||
"FLASK_ENV": "development"
|
|
||||||
},
|
|
||||||
"args": [
|
|
||||||
"run",
|
|
||||||
"--host=0.0.0.0",
|
|
||||||
"--port=5001",
|
|
||||||
"--no-debugger",
|
|
||||||
"--no-reload"
|
|
||||||
],
|
|
||||||
"jinja": true,
|
"jinja": true,
|
||||||
"justMyCode": true,
|
"justMyCode": true,
|
||||||
"cwd": "${workspaceFolder}/api",
|
"cwd": "${workspaceFolder}/api",
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
{
|
{
|
||||||
// Disable the default formatter, use eslint instead
|
"cucumber.features": [
|
||||||
"prettier.enable": false,
|
"e2e/features/**/*.feature",
|
||||||
"editor.formatOnSave": false,
|
],
|
||||||
|
"cucumber.glue": [
|
||||||
|
"e2e/features/**/*.ts",
|
||||||
|
],
|
||||||
|
|
||||||
|
"tailwindCSS.experimental.configFile": "web/app/styles/globals.css",
|
||||||
|
|
||||||
// Auto fix
|
// Auto fix
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll.eslint": "explicit",
|
"source.fixAll.eslint": "explicit",
|
||||||
"source.organizeImports": "never"
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Silent the stylistic rules in your IDE, but still auto fix them
|
// Silent the stylistic rules in your IDE, but still auto fix them
|
||||||
@@ -30,7 +30,7 @@ The codebase is split into:
|
|||||||
## Language Style
|
## Language Style
|
||||||
|
|
||||||
- **Python**: Keep type hints on functions and attributes, and implement relevant special methods (e.g., `__repr__`, `__str__`). Prefer `TypedDict` over `dict` or `Mapping` for type safety and better code documentation.
|
- **Python**: Keep type hints on functions and attributes, and implement relevant special methods (e.g., `__repr__`, `__str__`). Prefer `TypedDict` over `dict` or `Mapping` for type safety and better code documentation.
|
||||||
- **TypeScript**: Use the strict config, rely on ESLint (`pnpm lint:fix` preferred) plus `pnpm type-check:tsgo`, and avoid `any` types.
|
- **TypeScript**: Use the strict config, rely on ESLint (`pnpm lint:fix` preferred) plus `pnpm type-check`, and avoid `any` types.
|
||||||
|
|
||||||
## General Practices
|
## General Practices
|
||||||
|
|
||||||
|
|||||||
@@ -97,8 +97,3 @@ Feel free to reach out if you encounter any issues during the setup process.
|
|||||||
## Getting Help
|
## Getting Help
|
||||||
|
|
||||||
If you ever get stuck or get a burning question while contributing, simply shoot your queries our way via the related GitHub issue, or hop onto our [Discord](https://discord.gg/8Tpq4AcN9c) for a quick chat.
|
If you ever get stuck or get a burning question while contributing, simply shoot your queries our way via the related GitHub issue, or hop onto our [Discord](https://discord.gg/8Tpq4AcN9c) for a quick chat.
|
||||||
|
|
||||||
## Automated Agent Contributions
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> If you are an automated agent, just add 🤖🤖🤖 to the end of the PR title to opt-in.
|
|
||||||
|
|||||||
@@ -139,19 +139,6 @@ Star Dify on GitHub and be instantly notified of new releases.
|
|||||||
|
|
||||||
If you need to customize the configuration, please refer to the comments in our [.env.example](docker/.env.example) file and update the corresponding values in your `.env` file. Additionally, you might need to make adjustments to the `docker-compose.yaml` file itself, such as changing image versions, port mappings, or volume mounts, based on your specific deployment environment and requirements. After making any changes, please re-run `docker compose up -d`. You can find the full list of available environment variables [here](https://docs.dify.ai/getting-started/install-self-hosted/environments).
|
If you need to customize the configuration, please refer to the comments in our [.env.example](docker/.env.example) file and update the corresponding values in your `.env` file. Additionally, you might need to make adjustments to the `docker-compose.yaml` file itself, such as changing image versions, port mappings, or volume mounts, based on your specific deployment environment and requirements. After making any changes, please re-run `docker compose up -d`. You can find the full list of available environment variables [here](https://docs.dify.ai/getting-started/install-self-hosted/environments).
|
||||||
|
|
||||||
#### Customizing Suggested Questions
|
|
||||||
|
|
||||||
You can now customize the "Suggested Questions After Answer" feature to better fit your use case. For example, to generate longer, more technical questions:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# In your .env file
|
|
||||||
SUGGESTED_QUESTIONS_PROMPT='Please help me predict the five most likely technical follow-up questions a developer would ask. Focus on implementation details, best practices, and architecture considerations. Keep each question between 40-60 characters. Output must be JSON array: ["question1","question2","question3","question4","question5"]'
|
|
||||||
SUGGESTED_QUESTIONS_MAX_TOKENS=512
|
|
||||||
SUGGESTED_QUESTIONS_TEMPERATURE=0.3
|
|
||||||
```
|
|
||||||
|
|
||||||
See the [Suggested Questions Configuration Guide](docs/suggested-questions-configuration.md) for detailed examples and usage instructions.
|
|
||||||
|
|
||||||
### Metrics Monitoring with Grafana
|
### Metrics Monitoring with Grafana
|
||||||
|
|
||||||
Import the dashboard to Grafana, using Dify's PostgreSQL database as data source, to monitor metrics in granularity of apps, tenants, messages, and more.
|
Import the dashboard to Grafana, using Dify's PostgreSQL database as data source, to monitor metrics in granularity of apps, tenants, messages, and more.
|
||||||
@@ -160,7 +147,7 @@ Import the dashboard to Grafana, using Dify's PostgreSQL database as data source
|
|||||||
|
|
||||||
### Deployment with Kubernetes
|
### Deployment with Kubernetes
|
||||||
|
|
||||||
If you'd like to configure a highly-available setup, there are community-contributed [Helm Charts](https://helm.sh/) and YAML files which allow Dify to be deployed on Kubernetes.
|
If you'd like to configure a highly available setup, there are community-contributed [Helm Charts](https://helm.sh/) and YAML files which allow Dify to be deployed on Kubernetes.
|
||||||
|
|
||||||
- [Helm Chart by @LeoQuote](https://github.com/douban/charts/tree/master/charts/dify)
|
- [Helm Chart by @LeoQuote](https://github.com/douban/charts/tree/master/charts/dify)
|
||||||
- [Helm Chart by @BorisPolonsky](https://github.com/BorisPolonsky/dify-helm)
|
- [Helm Chart by @BorisPolonsky](https://github.com/BorisPolonsky/dify-helm)
|
||||||
|
|||||||
+19
-16
@@ -33,6 +33,9 @@ TRIGGER_URL=http://localhost:5001
|
|||||||
# The time in seconds after the signature is rejected
|
# The time in seconds after the signature is rejected
|
||||||
FILES_ACCESS_TIMEOUT=300
|
FILES_ACCESS_TIMEOUT=300
|
||||||
|
|
||||||
|
# Collaboration mode toggle
|
||||||
|
ENABLE_COLLABORATION_MODE=false
|
||||||
|
|
||||||
# Access token expiration time in minutes
|
# Access token expiration time in minutes
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||||
|
|
||||||
@@ -57,6 +60,9 @@ REDIS_SSL_CERTFILE=
|
|||||||
REDIS_SSL_KEYFILE=
|
REDIS_SSL_KEYFILE=
|
||||||
# Path to client private key file for SSL authentication
|
# Path to client private key file for SSL authentication
|
||||||
REDIS_DB=0
|
REDIS_DB=0
|
||||||
|
# Optional global prefix for Redis keys, topics, streams, and Celery Redis transport artifacts.
|
||||||
|
# Leave empty to preserve current unprefixed behavior.
|
||||||
|
REDIS_KEY_PREFIX=
|
||||||
|
|
||||||
# redis Sentinel configuration.
|
# redis Sentinel configuration.
|
||||||
REDIS_USE_SENTINEL=false
|
REDIS_USE_SENTINEL=false
|
||||||
@@ -71,6 +77,13 @@ REDIS_USE_CLUSTERS=false
|
|||||||
REDIS_CLUSTERS=
|
REDIS_CLUSTERS=
|
||||||
REDIS_CLUSTERS_PASSWORD=
|
REDIS_CLUSTERS_PASSWORD=
|
||||||
|
|
||||||
|
REDIS_RETRY_RETRIES=3
|
||||||
|
REDIS_RETRY_BACKOFF_BASE=1.0
|
||||||
|
REDIS_RETRY_BACKOFF_CAP=10.0
|
||||||
|
REDIS_SOCKET_TIMEOUT=5.0
|
||||||
|
REDIS_SOCKET_CONNECT_TIMEOUT=5.0
|
||||||
|
REDIS_HEALTH_CHECK_INTERVAL=30
|
||||||
|
|
||||||
# celery configuration
|
# celery configuration
|
||||||
CELERY_BROKER_URL=redis://:difyai123456@localhost:${REDIS_PORT}/1
|
CELERY_BROKER_URL=redis://:difyai123456@localhost:${REDIS_PORT}/1
|
||||||
CELERY_BACKEND=redis
|
CELERY_BACKEND=redis
|
||||||
@@ -102,6 +115,7 @@ S3_BUCKET_NAME=your-bucket-name
|
|||||||
S3_ACCESS_KEY=your-access-key
|
S3_ACCESS_KEY=your-access-key
|
||||||
S3_SECRET_KEY=your-secret-key
|
S3_SECRET_KEY=your-secret-key
|
||||||
S3_REGION=your-region
|
S3_REGION=your-region
|
||||||
|
S3_ADDRESS_STYLE=auto
|
||||||
|
|
||||||
# Workflow run and Conversation archive storage (S3-compatible)
|
# Workflow run and Conversation archive storage (S3-compatible)
|
||||||
ARCHIVE_STORAGE_ENABLED=false
|
ARCHIVE_STORAGE_ENABLED=false
|
||||||
@@ -645,6 +659,11 @@ INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y
|
|||||||
MARKETPLACE_ENABLED=true
|
MARKETPLACE_ENABLED=true
|
||||||
MARKETPLACE_API_URL=https://marketplace.dify.ai
|
MARKETPLACE_API_URL=https://marketplace.dify.ai
|
||||||
|
|
||||||
|
# Creators Platform configuration
|
||||||
|
CREATORS_PLATFORM_FEATURES_ENABLED=true
|
||||||
|
CREATORS_PLATFORM_API_URL=https://creators.dify.ai
|
||||||
|
CREATORS_PLATFORM_OAUTH_CLIENT_ID=
|
||||||
|
|
||||||
# Endpoint configuration
|
# Endpoint configuration
|
||||||
ENDPOINT_URL_TEMPLATE=http://localhost:5002/e/{hook_id}
|
ENDPOINT_URL_TEMPLATE=http://localhost:5002/e/{hook_id}
|
||||||
|
|
||||||
@@ -695,22 +714,6 @@ SWAGGER_UI_PATH=/swagger-ui.html
|
|||||||
# Set to false to export dataset IDs as plain text for easier cross-environment import
|
# Set to false to export dataset IDs as plain text for easier cross-environment import
|
||||||
DSL_EXPORT_ENCRYPT_DATASET_ID=true
|
DSL_EXPORT_ENCRYPT_DATASET_ID=true
|
||||||
|
|
||||||
# Suggested Questions After Answer Configuration
|
|
||||||
# These environment variables allow customization of the suggested questions feature
|
|
||||||
#
|
|
||||||
# Custom prompt for generating suggested questions (optional)
|
|
||||||
# If not set, uses the default prompt that generates 3 questions under 20 characters each
|
|
||||||
# Example: "Please help me predict the five most likely technical follow-up questions a developer would ask. Focus on implementation details, best practices, and architecture considerations. Keep each question between 40-60 characters. Output must be JSON array: [\"question1\",\"question2\",\"question3\",\"question4\",\"question5\"]"
|
|
||||||
# SUGGESTED_QUESTIONS_PROMPT=
|
|
||||||
|
|
||||||
# Maximum number of tokens for suggested questions generation (default: 256)
|
|
||||||
# Adjust this value for longer questions or more questions
|
|
||||||
# SUGGESTED_QUESTIONS_MAX_TOKENS=256
|
|
||||||
|
|
||||||
# Temperature for suggested questions generation (default: 0.0)
|
|
||||||
# Higher values (0.5-1.0) produce more creative questions, lower values (0.0-0.3) produce more focused questions
|
|
||||||
# SUGGESTED_QUESTIONS_TEMPERATURE=0
|
|
||||||
|
|
||||||
# Tenant isolated task queue configuration
|
# Tenant isolated task queue configuration
|
||||||
TENANT_ISOLATED_TASK_CONCURRENCY=1
|
TENANT_ISOLATED_TASK_CONCURRENCY=1
|
||||||
|
|
||||||
|
|||||||
+3
-22
@@ -69,8 +69,6 @@ ignore = [
|
|||||||
"FURB152", # math-constant
|
"FURB152", # math-constant
|
||||||
"UP007", # non-pep604-annotation
|
"UP007", # non-pep604-annotation
|
||||||
"UP032", # f-string
|
"UP032", # f-string
|
||||||
"UP045", # non-pep604-annotation-optional
|
|
||||||
"B005", # strip-with-multi-characters
|
|
||||||
"B006", # mutable-argument-default
|
"B006", # mutable-argument-default
|
||||||
"B007", # unused-loop-control-variable
|
"B007", # unused-loop-control-variable
|
||||||
"B026", # star-arg-unpacking-after-keyword-arg
|
"B026", # star-arg-unpacking-after-keyword-arg
|
||||||
@@ -84,7 +82,6 @@ ignore = [
|
|||||||
"SIM102", # collapsible-if
|
"SIM102", # collapsible-if
|
||||||
"SIM103", # needless-bool
|
"SIM103", # needless-bool
|
||||||
"SIM105", # suppressible-exception
|
"SIM105", # suppressible-exception
|
||||||
"SIM107", # return-in-try-except-finally
|
|
||||||
"SIM108", # if-else-block-instead-of-if-exp
|
"SIM108", # if-else-block-instead-of-if-exp
|
||||||
"SIM113", # enumerate-for-loop
|
"SIM113", # enumerate-for-loop
|
||||||
"SIM117", # multiple-with-statements
|
"SIM117", # multiple-with-statements
|
||||||
@@ -93,38 +90,22 @@ ignore = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[lint.per-file-ignores]
|
[lint.per-file-ignores]
|
||||||
"__init__.py" = [
|
|
||||||
"F401", # unused-import
|
|
||||||
"F811", # redefined-while-unused
|
|
||||||
]
|
|
||||||
"configs/*" = [
|
"configs/*" = [
|
||||||
"N802", # invalid-function-name
|
"N802", # invalid-function-name
|
||||||
]
|
]
|
||||||
"graphon/model_runtime/callbacks/base_callback.py" = ["T201"]
|
|
||||||
"core/workflow/callbacks/workflow_logging_callback.py" = ["T201"]
|
|
||||||
"libs/gmpy2_pkcs10aep_cipher.py" = [
|
"libs/gmpy2_pkcs10aep_cipher.py" = [
|
||||||
"N803", # invalid-argument-name
|
"N803", # invalid-argument-name
|
||||||
]
|
]
|
||||||
"tests/*" = [
|
"tests/*" = [
|
||||||
"F811", # redefined-while-unused
|
|
||||||
"T201", # allow print in tests,
|
"T201", # allow print in tests,
|
||||||
"S110", # allow ignoring exceptions in tests code (currently)
|
"S110", # allow ignoring exceptions in tests code (currently)
|
||||||
|
|
||||||
]
|
]
|
||||||
"controllers/console/explore/trial.py" = ["TID251"]
|
|
||||||
"controllers/console/human_input_form.py" = ["TID251"]
|
|
||||||
"controllers/web/human_input_form.py" = ["TID251"]
|
|
||||||
|
|
||||||
[lint.pyflakes]
|
|
||||||
allowed-unused-imports = [
|
|
||||||
"tests.integration_tests",
|
|
||||||
"tests.unit_tests",
|
|
||||||
]
|
|
||||||
|
|
||||||
[lint.flake8-tidy-imports]
|
|
||||||
|
|
||||||
[lint.flake8-tidy-imports.banned-api."flask_restx.reqparse"]
|
[lint.flake8-tidy-imports.banned-api."flask_restx.reqparse"]
|
||||||
msg = "Use Pydantic payload/query models instead of reqparse."
|
msg = "Use Pydantic payload/query models instead of reqparse."
|
||||||
|
|
||||||
[lint.flake8-tidy-imports.banned-api."flask_restx.reqparse.RequestParser"]
|
[lint.flake8-tidy-imports.banned-api."flask_restx.reqparse.RequestParser"]
|
||||||
msg = "Use Pydantic payload/query models instead of reqparse."
|
msg = "Use Pydantic payload/query models instead of reqparse."
|
||||||
|
|
||||||
|
[lint.isort]
|
||||||
|
known-first-party = ["graphon"]
|
||||||
Vendored
+5
-13
@@ -3,29 +3,21 @@
|
|||||||
"compounds": [
|
"compounds": [
|
||||||
{
|
{
|
||||||
"name": "Launch Flask and Celery",
|
"name": "Launch Flask and Celery",
|
||||||
"configurations": ["Python: Flask", "Python: Celery"]
|
"configurations": ["Python: API (gevent)", "Python: Celery"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "Python: Flask",
|
"name": "Python: API (gevent)",
|
||||||
"consoleName": "Flask",
|
"consoleName": "API",
|
||||||
"type": "debugpy",
|
"type": "debugpy",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"python": "${workspaceFolder}/.venv/bin/python",
|
"python": "${workspaceFolder}/.venv/bin/python",
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"envFile": ".env",
|
"envFile": ".env",
|
||||||
"module": "flask",
|
"program": "${workspaceFolder}/app.py",
|
||||||
"justMyCode": true,
|
"justMyCode": true,
|
||||||
"jinja": true,
|
"jinja": true
|
||||||
"env": {
|
|
||||||
"FLASK_APP": "app.py",
|
|
||||||
"GEVENT_SUPPORT": "True"
|
|
||||||
},
|
|
||||||
"args": [
|
|
||||||
"run",
|
|
||||||
"--port=5001"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Python: Celery",
|
"name": "Python: Celery",
|
||||||
|
|||||||
+2
-1
@@ -21,8 +21,9 @@ RUN apt-get update \
|
|||||||
# for building gmpy2
|
# for building gmpy2
|
||||||
libmpfr-dev libmpc-dev
|
libmpfr-dev libmpc-dev
|
||||||
|
|
||||||
# Install Python dependencies
|
# Install Python dependencies (workspace members under providers/vdb/)
|
||||||
COPY pyproject.toml uv.lock ./
|
COPY pyproject.toml uv.lock ./
|
||||||
|
COPY providers ./providers
|
||||||
RUN uv sync --locked --no-dev
|
RUN uv sync --locked --no-dev
|
||||||
|
|
||||||
# production stage
|
# production stage
|
||||||
|
|||||||
@@ -101,3 +101,11 @@ The scripts resolve paths relative to their location, so you can run them from a
|
|||||||
uv run ruff format ./ # Format code
|
uv run ruff format ./ # Format code
|
||||||
uv run basedpyright . # Type checking
|
uv run basedpyright . # Type checking
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Generate TS stub
|
||||||
|
|
||||||
|
```
|
||||||
|
uv run dev/generate_swagger_specs.py --output-dir openapi
|
||||||
|
```
|
||||||
|
|
||||||
|
use https://jsontotable.org/openapi-to-typescript to convert to typescript
|
||||||
|
|||||||
+27
-2
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from typing import TYPE_CHECKING, cast
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
@@ -9,17 +10,35 @@ if TYPE_CHECKING:
|
|||||||
celery: Celery
|
celery: Celery
|
||||||
|
|
||||||
|
|
||||||
|
HOST = "0.0.0.0"
|
||||||
|
PORT = 5001
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def is_db_command() -> bool:
|
def is_db_command() -> bool:
|
||||||
if len(sys.argv) > 1 and sys.argv[0].endswith("flask") and sys.argv[1] == "db":
|
if len(sys.argv) > 1 and sys.argv[0].endswith("flask") and sys.argv[1] == "db":
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def log_startup_banner(host: str, port: int) -> None:
|
||||||
|
debugger_attached = sys.gettrace() is not None
|
||||||
|
logger.info("Serving Dify API via gevent WebSocket server")
|
||||||
|
logger.info("Bound to http://%s:%s", host, port)
|
||||||
|
logger.info("Debugger attached: %s", "on" if debugger_attached else "off")
|
||||||
|
logger.info("Press CTRL+C to quit")
|
||||||
|
|
||||||
|
|
||||||
# create app
|
# create app
|
||||||
|
flask_app = None
|
||||||
|
socketio_app = None
|
||||||
|
|
||||||
if is_db_command():
|
if is_db_command():
|
||||||
from app_factory import create_migrations_app
|
from app_factory import create_migrations_app
|
||||||
|
|
||||||
app = create_migrations_app()
|
app = create_migrations_app()
|
||||||
|
socketio_app = app
|
||||||
|
flask_app = app
|
||||||
else:
|
else:
|
||||||
# Gunicorn and Celery handle monkey patching automatically in production by
|
# Gunicorn and Celery handle monkey patching automatically in production by
|
||||||
# specifying the `gevent` worker class. Manual monkey patching is not required here.
|
# specifying the `gevent` worker class. Manual monkey patching is not required here.
|
||||||
@@ -30,8 +49,14 @@ else:
|
|||||||
|
|
||||||
from app_factory import create_app
|
from app_factory import create_app
|
||||||
|
|
||||||
app = create_app()
|
socketio_app, flask_app = create_app()
|
||||||
|
app = flask_app
|
||||||
celery = cast("Celery", app.extensions["celery"])
|
celery = cast("Celery", app.extensions["celery"])
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(host="0.0.0.0", port=5001)
|
from gevent import pywsgi
|
||||||
|
from geventwebsocket.handler import WebSocketHandler # type: ignore[reportMissingTypeStubs]
|
||||||
|
|
||||||
|
log_startup_banner(HOST, PORT)
|
||||||
|
server = pywsgi.WSGIServer((HOST, PORT), socketio_app, handler_class=WebSocketHandler)
|
||||||
|
server.serve_forever()
|
||||||
|
|||||||
+8
-2
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
import socketio # type: ignore[reportMissingTypeStubs]
|
||||||
from flask import request
|
from flask import request
|
||||||
from opentelemetry.trace import get_current_span
|
from opentelemetry.trace import get_current_span
|
||||||
from opentelemetry.trace.span import INVALID_SPAN_ID, INVALID_TRACE_ID
|
from opentelemetry.trace.span import INVALID_SPAN_ID, INVALID_TRACE_ID
|
||||||
@@ -10,6 +11,7 @@ from contexts.wrapper import RecyclableContextVar
|
|||||||
from controllers.console.error import UnauthorizedAndForceLogout
|
from controllers.console.error import UnauthorizedAndForceLogout
|
||||||
from core.logging.context import init_request_context
|
from core.logging.context import init_request_context
|
||||||
from dify_app import DifyApp
|
from dify_app import DifyApp
|
||||||
|
from extensions.ext_socketio import sio
|
||||||
from services.enterprise.enterprise_service import EnterpriseService
|
from services.enterprise.enterprise_service import EnterpriseService
|
||||||
from services.feature_service import LicenseStatus
|
from services.feature_service import LicenseStatus
|
||||||
|
|
||||||
@@ -122,14 +124,18 @@ def create_flask_app_with_configs() -> DifyApp:
|
|||||||
return dify_app
|
return dify_app
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> DifyApp:
|
def create_app() -> tuple[socketio.WSGIApp, DifyApp]:
|
||||||
start_time = time.perf_counter()
|
start_time = time.perf_counter()
|
||||||
app = create_flask_app_with_configs()
|
app = create_flask_app_with_configs()
|
||||||
initialize_extensions(app)
|
initialize_extensions(app)
|
||||||
|
|
||||||
|
sio.app = app
|
||||||
|
socketio_app = socketio.WSGIApp(sio, app)
|
||||||
|
|
||||||
end_time = time.perf_counter()
|
end_time = time.perf_counter()
|
||||||
if dify_config.DEBUG:
|
if dify_config.DEBUG:
|
||||||
logger.info("Finished create_app (%s ms)", round((end_time - start_time) * 1000, 2))
|
logger.info("Finished create_app (%s ms)", round((end_time - start_time) * 1000, 2))
|
||||||
return app
|
return socketio_app, app
|
||||||
|
|
||||||
|
|
||||||
def initialize_extensions(app: DifyApp):
|
def initialize_extensions(app: DifyApp):
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# This module provides a lightweight Celery instance for use in Docker health checks.
|
||||||
|
# Unlike celery_entrypoint.py, this does NOT import app.py and therefore avoids
|
||||||
|
# initializing all Flask extensions (DB, Redis, storage, blueprints, etc.).
|
||||||
|
# Using this module keeps the health check fast and low-cost.
|
||||||
|
from celery import Celery
|
||||||
|
|
||||||
|
from configs import dify_config
|
||||||
|
from extensions.ext_celery import get_celery_broker_transport_options, get_celery_ssl_options
|
||||||
|
|
||||||
|
celery = Celery(broker=dify_config.CELERY_BROKER_URL)
|
||||||
|
|
||||||
|
broker_transport_options = get_celery_broker_transport_options()
|
||||||
|
if broker_transport_options:
|
||||||
|
celery.conf.update(broker_transport_options=broker_transport_options)
|
||||||
|
|
||||||
|
ssl_options = get_celery_ssl_options()
|
||||||
|
if ssl_options:
|
||||||
|
celery.conf.update(broker_use_ssl=ssl_options)
|
||||||
+34
-30
@@ -2,7 +2,7 @@ import base64
|
|||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from constants.languages import languages
|
from constants.languages import languages
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
@@ -25,30 +25,32 @@ def reset_password(email, new_password, password_confirm):
|
|||||||
return
|
return
|
||||||
normalized_email = email.strip().lower()
|
normalized_email = email.strip().lower()
|
||||||
|
|
||||||
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
|
account = AccountService.get_account_by_email_with_case_fallback(email.strip())
|
||||||
account = AccountService.get_account_by_email_with_case_fallback(email.strip(), session=session)
|
|
||||||
|
|
||||||
if not account:
|
if not account:
|
||||||
click.echo(click.style(f"Account not found for email: {email}", fg="red"))
|
click.echo(click.style(f"Account not found for email: {email}", fg="red"))
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
valid_password(new_password)
|
valid_password(new_password)
|
||||||
except:
|
except:
|
||||||
click.echo(click.style(f"Invalid password. Must match {password_pattern}", fg="red"))
|
click.echo(click.style(f"Invalid password. Must match {password_pattern}", fg="red"))
|
||||||
return
|
return
|
||||||
|
|
||||||
# generate password salt
|
# generate password salt
|
||||||
salt = secrets.token_bytes(16)
|
salt = secrets.token_bytes(16)
|
||||||
base64_salt = base64.b64encode(salt).decode()
|
base64_salt = base64.b64encode(salt).decode()
|
||||||
|
|
||||||
# encrypt password with salt
|
# encrypt password with salt
|
||||||
password_hashed = hash_password(new_password, salt)
|
password_hashed = hash_password(new_password, salt)
|
||||||
base64_password_hashed = base64.b64encode(password_hashed).decode()
|
base64_password_hashed = base64.b64encode(password_hashed).decode()
|
||||||
|
with Session(db.engine) as session:
|
||||||
|
account = session.merge(account)
|
||||||
account.password = base64_password_hashed
|
account.password = base64_password_hashed
|
||||||
account.password_salt = base64_salt
|
account.password_salt = base64_salt
|
||||||
AccountService.reset_login_error_rate_limit(normalized_email)
|
session.commit()
|
||||||
click.echo(click.style("Password reset successfully.", fg="green"))
|
AccountService.reset_login_error_rate_limit(normalized_email)
|
||||||
|
click.echo(click.style("Password reset successfully.", fg="green"))
|
||||||
|
|
||||||
|
|
||||||
@click.command("reset-email", help="Reset the account email.")
|
@click.command("reset-email", help="Reset the account email.")
|
||||||
@@ -65,21 +67,23 @@ def reset_email(email, new_email, email_confirm):
|
|||||||
return
|
return
|
||||||
normalized_new_email = new_email.strip().lower()
|
normalized_new_email = new_email.strip().lower()
|
||||||
|
|
||||||
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
|
account = AccountService.get_account_by_email_with_case_fallback(email.strip())
|
||||||
account = AccountService.get_account_by_email_with_case_fallback(email.strip(), session=session)
|
|
||||||
|
|
||||||
if not account:
|
if not account:
|
||||||
click.echo(click.style(f"Account not found for email: {email}", fg="red"))
|
click.echo(click.style(f"Account not found for email: {email}", fg="red"))
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
email_validate(normalized_new_email)
|
email_validate(normalized_new_email)
|
||||||
except:
|
except:
|
||||||
click.echo(click.style(f"Invalid email: {new_email}", fg="red"))
|
click.echo(click.style(f"Invalid email: {new_email}", fg="red"))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
with Session(db.engine) as session:
|
||||||
|
account = session.merge(account)
|
||||||
account.email = normalized_new_email
|
account.email = normalized_new_email
|
||||||
click.echo(click.style("Email updated successfully.", fg="green"))
|
session.commit()
|
||||||
|
click.echo(click.style("Email updated successfully.", fg="green"))
|
||||||
|
|
||||||
|
|
||||||
@click.command("create-tenant", help="Create account and tenant.")
|
@click.command("create-tenant", help="Create account and tenant.")
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from configs import dify_config
|
|||||||
from core.helper import encrypter
|
from core.helper import encrypter
|
||||||
from core.plugin.entities.plugin_daemon import CredentialType
|
from core.plugin.entities.plugin_daemon import CredentialType
|
||||||
from core.plugin.impl.plugin import PluginInstaller
|
from core.plugin.impl.plugin import PluginInstaller
|
||||||
from core.tools.utils.system_oauth_encryption import encrypt_system_oauth_params
|
from core.tools.utils.system_encryption import encrypt_system_params
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from models import Tenant
|
from models import Tenant
|
||||||
from models.oauth import DatasourceOauthParamConfig, DatasourceProvider
|
from models.oauth import DatasourceOauthParamConfig, DatasourceProvider
|
||||||
@@ -44,7 +44,7 @@ def setup_system_tool_oauth_client(provider, client_params):
|
|||||||
|
|
||||||
click.echo(click.style(f"Encrypting client params: {client_params}", fg="yellow"))
|
click.echo(click.style(f"Encrypting client params: {client_params}", fg="yellow"))
|
||||||
click.echo(click.style(f"Using SECRET_KEY: `{dify_config.SECRET_KEY}`", fg="yellow"))
|
click.echo(click.style(f"Using SECRET_KEY: `{dify_config.SECRET_KEY}`", fg="yellow"))
|
||||||
oauth_client_params = encrypt_system_oauth_params(client_params_dict)
|
oauth_client_params = encrypt_system_params(client_params_dict)
|
||||||
click.echo(click.style("Client params encrypted successfully.", fg="green"))
|
click.echo(click.style("Client params encrypted successfully.", fg="green"))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
click.echo(click.style(f"Error parsing client params: {str(e)}", fg="red"))
|
click.echo(click.style(f"Error parsing client params: {str(e)}", fg="red"))
|
||||||
@@ -94,7 +94,7 @@ def setup_system_trigger_oauth_client(provider, client_params):
|
|||||||
|
|
||||||
click.echo(click.style(f"Encrypting client params: {client_params}", fg="yellow"))
|
click.echo(click.style(f"Encrypting client params: {client_params}", fg="yellow"))
|
||||||
click.echo(click.style(f"Using SECRET_KEY: `{dify_config.SECRET_KEY}`", fg="yellow"))
|
click.echo(click.style(f"Using SECRET_KEY: `{dify_config.SECRET_KEY}`", fg="yellow"))
|
||||||
oauth_client_params = encrypt_system_oauth_params(client_params_dict)
|
oauth_client_params = encrypt_system_params(client_params_dict)
|
||||||
click.echo(click.style("Client params encrypted successfully.", fg="green"))
|
click.echo(click.style("Client params encrypted successfully.", fg="green"))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
click.echo(click.style(f"Error parsing client params: {str(e)}", fg="red"))
|
click.echo(click.style(f"Error parsing client params: {str(e)}", fg="red"))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import TypedDict
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -503,7 +503,19 @@ def _find_orphaned_draft_variables(batch_size: int = 1000) -> list[str]:
|
|||||||
return [row[0] for row in result]
|
return [row[0] for row in result]
|
||||||
|
|
||||||
|
|
||||||
def _count_orphaned_draft_variables() -> dict[str, Any]:
|
class _AppOrphanCounts(TypedDict):
|
||||||
|
variables: int
|
||||||
|
files: int
|
||||||
|
|
||||||
|
|
||||||
|
class OrphanedDraftVariableStatsDict(TypedDict):
|
||||||
|
total_orphaned_variables: int
|
||||||
|
total_orphaned_files: int
|
||||||
|
orphaned_app_count: int
|
||||||
|
orphaned_by_app: dict[str, _AppOrphanCounts]
|
||||||
|
|
||||||
|
|
||||||
|
def _count_orphaned_draft_variables() -> OrphanedDraftVariableStatsDict:
|
||||||
"""
|
"""
|
||||||
Count orphaned draft variables by app, including associated file counts.
|
Count orphaned draft variables by app, including associated file counts.
|
||||||
|
|
||||||
@@ -526,7 +538,7 @@ def _count_orphaned_draft_variables() -> dict[str, Any]:
|
|||||||
|
|
||||||
with db.engine.connect() as conn:
|
with db.engine.connect() as conn:
|
||||||
result = conn.execute(sa.text(variables_query))
|
result = conn.execute(sa.text(variables_query))
|
||||||
orphaned_by_app = {}
|
orphaned_by_app: dict[str, _AppOrphanCounts] = {}
|
||||||
total_files = 0
|
total_files = 0
|
||||||
|
|
||||||
for row in result:
|
for row in result:
|
||||||
|
|||||||
@@ -341,11 +341,10 @@ def add_qdrant_index(field: str):
|
|||||||
click.echo(click.style("No dataset collection bindings found.", fg="red"))
|
click.echo(click.style("No dataset collection bindings found.", fg="red"))
|
||||||
return
|
return
|
||||||
import qdrant_client
|
import qdrant_client
|
||||||
|
from dify_vdb_qdrant.qdrant_vector import PathQdrantParams, QdrantConfig
|
||||||
from qdrant_client.http.exceptions import UnexpectedResponse
|
from qdrant_client.http.exceptions import UnexpectedResponse
|
||||||
from qdrant_client.http.models import PayloadSchemaType
|
from qdrant_client.http.models import PayloadSchemaType
|
||||||
|
|
||||||
from core.rag.datasource.vdb.qdrant.qdrant_vector import PathQdrantParams, QdrantConfig
|
|
||||||
|
|
||||||
for binding in bindings:
|
for binding in bindings:
|
||||||
if dify_config.QDRANT_URL is None:
|
if dify_config.QDRANT_URL is None:
|
||||||
raise ValueError("Qdrant URL is required.")
|
raise ValueError("Qdrant URL is required.")
|
||||||
|
|||||||
@@ -287,6 +287,27 @@ class MarketplaceConfig(BaseSettings):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CreatorsPlatformConfig(BaseSettings):
|
||||||
|
"""
|
||||||
|
Configuration for Creators Platform integration
|
||||||
|
"""
|
||||||
|
|
||||||
|
CREATORS_PLATFORM_FEATURES_ENABLED: bool = Field(
|
||||||
|
description="Enable or disable Creators Platform features",
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
CREATORS_PLATFORM_API_URL: HttpUrl = Field(
|
||||||
|
description="Creators Platform API URL",
|
||||||
|
default=HttpUrl("https://creators.dify.ai"),
|
||||||
|
)
|
||||||
|
|
||||||
|
CREATORS_PLATFORM_OAUTH_CLIENT_ID: str = Field(
|
||||||
|
description="OAuth client ID for Creators Platform integration",
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class EndpointConfig(BaseSettings):
|
class EndpointConfig(BaseSettings):
|
||||||
"""
|
"""
|
||||||
Configuration for various application endpoints and URLs
|
Configuration for various application endpoints and URLs
|
||||||
@@ -1274,6 +1295,13 @@ class PositionConfig(BaseSettings):
|
|||||||
return {item.strip() for item in self.POSITION_TOOL_EXCLUDES.split(",") if item.strip() != ""}
|
return {item.strip() for item in self.POSITION_TOOL_EXCLUDES.split(",") if item.strip() != ""}
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationConfig(BaseSettings):
|
||||||
|
ENABLE_COLLABORATION_MODE: bool = Field(
|
||||||
|
description="Whether to enable collaboration mode features across the workspace",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LoginConfig(BaseSettings):
|
class LoginConfig(BaseSettings):
|
||||||
ENABLE_EMAIL_CODE_LOGIN: bool = Field(
|
ENABLE_EMAIL_CODE_LOGIN: bool = Field(
|
||||||
description="whether to enable email code login",
|
description="whether to enable email code login",
|
||||||
@@ -1372,6 +1400,7 @@ class FeatureConfig(
|
|||||||
AuthConfig, # Changed from OAuthConfig to AuthConfig
|
AuthConfig, # Changed from OAuthConfig to AuthConfig
|
||||||
BillingConfig,
|
BillingConfig,
|
||||||
CodeExecutionSandboxConfig,
|
CodeExecutionSandboxConfig,
|
||||||
|
CreatorsPlatformConfig,
|
||||||
TriggerConfig,
|
TriggerConfig,
|
||||||
AsyncWorkflowConfig,
|
AsyncWorkflowConfig,
|
||||||
PluginConfig,
|
PluginConfig,
|
||||||
@@ -1399,6 +1428,7 @@ class FeatureConfig(
|
|||||||
WorkflowConfig,
|
WorkflowConfig,
|
||||||
WorkflowNodeExecutionConfig,
|
WorkflowNodeExecutionConfig,
|
||||||
WorkspaceConfig,
|
WorkspaceConfig,
|
||||||
|
CollaborationConfig,
|
||||||
LoginConfig,
|
LoginConfig,
|
||||||
AccountConfig,
|
AccountConfig,
|
||||||
SwaggerUIConfig,
|
SwaggerUIConfig,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal, TypedDict
|
||||||
from urllib.parse import parse_qsl, quote_plus
|
from urllib.parse import parse_qsl, quote_plus
|
||||||
|
|
||||||
from pydantic import Field, NonNegativeFloat, NonNegativeInt, PositiveFloat, PositiveInt, computed_field
|
from pydantic import Field, NonNegativeFloat, NonNegativeInt, PositiveFloat, PositiveInt, computed_field
|
||||||
@@ -107,6 +107,17 @@ class KeywordStoreConfig(BaseSettings):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SQLAlchemyEngineOptionsDict(TypedDict):
|
||||||
|
pool_size: int
|
||||||
|
max_overflow: int
|
||||||
|
pool_recycle: int
|
||||||
|
pool_pre_ping: bool
|
||||||
|
connect_args: dict[str, str]
|
||||||
|
pool_use_lifo: bool
|
||||||
|
pool_reset_on_return: None
|
||||||
|
pool_timeout: int
|
||||||
|
|
||||||
|
|
||||||
class DatabaseConfig(BaseSettings):
|
class DatabaseConfig(BaseSettings):
|
||||||
# Database type selector
|
# Database type selector
|
||||||
DB_TYPE: Literal["postgresql", "mysql", "oceanbase", "seekdb"] = Field(
|
DB_TYPE: Literal["postgresql", "mysql", "oceanbase", "seekdb"] = Field(
|
||||||
@@ -149,6 +160,16 @@ class DatabaseConfig(BaseSettings):
|
|||||||
default="",
|
default="",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
DB_SESSION_TIMEZONE_OVERRIDE: str = Field(
|
||||||
|
description=(
|
||||||
|
"PostgreSQL session timezone override injected via startup options."
|
||||||
|
" Default is 'UTC' for out-of-the-box consistency."
|
||||||
|
" Set to empty string to disable app-level timezone injection, for example when using RDS Proxy"
|
||||||
|
" together with a database-side default timezone."
|
||||||
|
),
|
||||||
|
default="UTC",
|
||||||
|
)
|
||||||
|
|
||||||
@computed_field # type: ignore[prop-decorator]
|
@computed_field # type: ignore[prop-decorator]
|
||||||
@property
|
@property
|
||||||
def SQLALCHEMY_DATABASE_URI_SCHEME(self) -> str:
|
def SQLALCHEMY_DATABASE_URI_SCHEME(self) -> str:
|
||||||
@@ -209,21 +230,22 @@ class DatabaseConfig(BaseSettings):
|
|||||||
|
|
||||||
@computed_field # type: ignore[prop-decorator]
|
@computed_field # type: ignore[prop-decorator]
|
||||||
@property
|
@property
|
||||||
def SQLALCHEMY_ENGINE_OPTIONS(self) -> dict[str, Any]:
|
def SQLALCHEMY_ENGINE_OPTIONS(self) -> SQLAlchemyEngineOptionsDict:
|
||||||
# Parse DB_EXTRAS for 'options'
|
# Parse DB_EXTRAS for 'options'
|
||||||
db_extras_dict = dict(parse_qsl(self.DB_EXTRAS))
|
db_extras_dict = dict(parse_qsl(self.DB_EXTRAS))
|
||||||
options = db_extras_dict.get("options", "")
|
options = db_extras_dict.get("options", "")
|
||||||
connect_args = {}
|
connect_args: dict[str, str] = {}
|
||||||
# Use the dynamic SQLALCHEMY_DATABASE_URI_SCHEME property
|
# Use the dynamic SQLALCHEMY_DATABASE_URI_SCHEME property
|
||||||
if self.SQLALCHEMY_DATABASE_URI_SCHEME.startswith("postgresql"):
|
if self.SQLALCHEMY_DATABASE_URI_SCHEME.startswith("postgresql"):
|
||||||
timezone_opt = "-c timezone=UTC"
|
merged_options = options.strip()
|
||||||
if options:
|
session_timezone_override = self.DB_SESSION_TIMEZONE_OVERRIDE.strip()
|
||||||
merged_options = f"{options} {timezone_opt}"
|
if session_timezone_override:
|
||||||
else:
|
timezone_opt = f"-c timezone={session_timezone_override}"
|
||||||
merged_options = timezone_opt
|
merged_options = f"{merged_options} {timezone_opt}".strip() if merged_options else timezone_opt
|
||||||
connect_args = {"options": merged_options}
|
if merged_options:
|
||||||
|
connect_args = {"options": merged_options}
|
||||||
|
|
||||||
return {
|
result: SQLAlchemyEngineOptionsDict = {
|
||||||
"pool_size": self.SQLALCHEMY_POOL_SIZE,
|
"pool_size": self.SQLALCHEMY_POOL_SIZE,
|
||||||
"max_overflow": self.SQLALCHEMY_MAX_OVERFLOW,
|
"max_overflow": self.SQLALCHEMY_MAX_OVERFLOW,
|
||||||
"pool_recycle": self.SQLALCHEMY_POOL_RECYCLE,
|
"pool_recycle": self.SQLALCHEMY_POOL_RECYCLE,
|
||||||
@@ -233,6 +255,7 @@ class DatabaseConfig(BaseSettings):
|
|||||||
"pool_reset_on_return": None,
|
"pool_reset_on_return": None,
|
||||||
"pool_timeout": self.SQLALCHEMY_POOL_TIMEOUT,
|
"pool_timeout": self.SQLALCHEMY_POOL_TIMEOUT,
|
||||||
}
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
class CeleryConfig(DatabaseConfig):
|
class CeleryConfig(DatabaseConfig):
|
||||||
|
|||||||
+36
@@ -32,6 +32,11 @@ class RedisConfig(BaseSettings):
|
|||||||
default=0,
|
default=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
REDIS_KEY_PREFIX: str = Field(
|
||||||
|
description="Optional global prefix for Redis keys, topics, and transport artifacts",
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
|
||||||
REDIS_USE_SSL: bool = Field(
|
REDIS_USE_SSL: bool = Field(
|
||||||
description="Enable SSL/TLS for the Redis connection",
|
description="Enable SSL/TLS for the Redis connection",
|
||||||
default=False,
|
default=False,
|
||||||
@@ -117,6 +122,37 @@ class RedisConfig(BaseSettings):
|
|||||||
default=None,
|
default=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
REDIS_RETRY_RETRIES: NonNegativeInt = Field(
|
||||||
|
description="Maximum number of retries per Redis command on "
|
||||||
|
"transient failures (ConnectionError, TimeoutError, socket.timeout)",
|
||||||
|
default=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
REDIS_RETRY_BACKOFF_BASE: PositiveFloat = Field(
|
||||||
|
description="Base delay in seconds for exponential backoff between retries",
|
||||||
|
default=1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
REDIS_RETRY_BACKOFF_CAP: PositiveFloat = Field(
|
||||||
|
description="Maximum backoff delay in seconds between retries",
|
||||||
|
default=10.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
REDIS_SOCKET_TIMEOUT: PositiveFloat | None = Field(
|
||||||
|
description="Socket timeout in seconds for Redis read/write operations",
|
||||||
|
default=5.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
REDIS_SOCKET_CONNECT_TIMEOUT: PositiveFloat | None = Field(
|
||||||
|
description="Socket timeout in seconds for Redis connection establishment",
|
||||||
|
default=5.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
REDIS_HEALTH_CHECK_INTERVAL: NonNegativeInt = Field(
|
||||||
|
description="Interval in seconds between Redis connection health checks (0 to disable)",
|
||||||
|
default=30,
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator("REDIS_MAX_CONNECTIONS", mode="before")
|
@field_validator("REDIS_MAX_CONNECTIONS", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _empty_string_to_none_for_max_conns(cls, v):
|
def _empty_string_to_none_for_max_conns(cls, v):
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
from holo_search_sdk.types import BaseQuantizationType, DistanceType, TokenizerType
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
@@ -42,17 +41,17 @@ class HologresConfig(BaseSettings):
|
|||||||
default="public",
|
default="public",
|
||||||
)
|
)
|
||||||
|
|
||||||
HOLOGRES_TOKENIZER: TokenizerType = Field(
|
HOLOGRES_TOKENIZER: str = Field(
|
||||||
description="Tokenizer for full-text search index (e.g., 'jieba', 'ik', 'standard', 'simple').",
|
description="Tokenizer for full-text search index (e.g., 'jieba', 'ik', 'standard', 'simple').",
|
||||||
default="jieba",
|
default="jieba",
|
||||||
)
|
)
|
||||||
|
|
||||||
HOLOGRES_DISTANCE_METHOD: DistanceType = Field(
|
HOLOGRES_DISTANCE_METHOD: str = Field(
|
||||||
description="Distance method for vector index (e.g., 'Cosine', 'Euclidean', 'InnerProduct').",
|
description="Distance method for vector index (e.g., 'Cosine', 'Euclidean', 'InnerProduct').",
|
||||||
default="Cosine",
|
default="Cosine",
|
||||||
)
|
)
|
||||||
|
|
||||||
HOLOGRES_BASE_QUANTIZATION_TYPE: BaseQuantizationType = Field(
|
HOLOGRES_BASE_QUANTIZATION_TYPE: str = Field(
|
||||||
description="Base quantization type for vector index (e.g., 'rabitq', 'sq8', 'fp16', 'fp32').",
|
description="Base quantization type for vector index (e.g., 'rabitq', 'sq8', 'fp16', 'fp32').",
|
||||||
default="rabitq",
|
default="rabitq",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Configuration for InterSystems IRIS vector database."""
|
"""Configuration for InterSystems IRIS vector database."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import Field, PositiveInt, model_validator
|
from pydantic import Field, PositiveInt, model_validator
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
@@ -64,7 +66,7 @@ class IrisVectorConfig(BaseSettings):
|
|||||||
|
|
||||||
@model_validator(mode="before")
|
@model_validator(mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_config(cls, values: dict) -> dict:
|
def validate_config(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Validate IRIS configuration values.
|
"""Validate IRIS configuration values.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
CURRENT_APP_DSL_VERSION = "0.6.0"
|
||||||
@@ -10,7 +10,7 @@ import threading
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from collections.abc import Callable, Generator
|
from collections.abc import Callable, Generator
|
||||||
from contextlib import AbstractContextManager, contextmanager
|
from contextlib import AbstractContextManager, contextmanager
|
||||||
from typing import Any, Protocol, TypeVar, final, runtime_checkable
|
from typing import Any, Protocol, final, runtime_checkable
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@@ -188,8 +188,6 @@ class ExecutionContextBuilder:
|
|||||||
_capturer: Callable[[], IExecutionContext] | None = None
|
_capturer: Callable[[], IExecutionContext] | None = None
|
||||||
_tenant_context_providers: dict[tuple[str, str], Callable[[], BaseModel]] = {}
|
_tenant_context_providers: dict[tuple[str, str], Callable[[], BaseModel]] = {}
|
||||||
|
|
||||||
T = TypeVar("T", bound=BaseModel)
|
|
||||||
|
|
||||||
|
|
||||||
class ContextProviderNotFoundError(KeyError):
|
class ContextProviderNotFoundError(KeyError):
|
||||||
"""Raised when a tenant-scoped context provider is missing."""
|
"""Raised when a tenant-scoped context provider is missing."""
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from typing import Generic, TypeVar
|
|
||||||
|
|
||||||
T = TypeVar("T")
|
|
||||||
|
|
||||||
|
|
||||||
class HiddenValue:
|
class HiddenValue:
|
||||||
@@ -11,7 +8,7 @@ class HiddenValue:
|
|||||||
_default = HiddenValue()
|
_default = HiddenValue()
|
||||||
|
|
||||||
|
|
||||||
class RecyclableContextVar(Generic[T]):
|
class RecyclableContextVar[T]:
|
||||||
"""
|
"""
|
||||||
RecyclableContextVar is a wrapper around ContextVar
|
RecyclableContextVar is a wrapper around ContextVar
|
||||||
It's safe to use in gunicorn with thread recycling, but features like `reset` are not available for now
|
It's safe to use in gunicorn with thread recycling, but features like `reset` are not available for now
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
from typing import Any, Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
from libs.helper import UUIDStrOrEmpty
|
||||||
|
|
||||||
|
# --- Conversation schemas ---
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationRenamePayload(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
auto_generate: bool = False
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_name_requirement(self):
|
||||||
|
if not self.auto_generate:
|
||||||
|
if self.name is None or not self.name.strip():
|
||||||
|
raise ValueError("name is required when auto_generate is false")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
# --- Message schemas ---
|
||||||
|
|
||||||
|
|
||||||
|
class MessageListQuery(BaseModel):
|
||||||
|
conversation_id: UUIDStrOrEmpty = Field(description="Conversation UUID")
|
||||||
|
first_id: UUIDStrOrEmpty | None = Field(default=None, description="First message ID for pagination")
|
||||||
|
limit: int = Field(default=20, ge=1, le=100, description="Number of messages to return (1-100)")
|
||||||
|
|
||||||
|
|
||||||
|
class MessageFeedbackPayload(BaseModel):
|
||||||
|
rating: Literal["like", "dislike"] | None = None
|
||||||
|
content: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Saved message schemas ---
|
||||||
|
|
||||||
|
|
||||||
|
class SavedMessageListQuery(BaseModel):
|
||||||
|
last_id: UUIDStrOrEmpty | None = None
|
||||||
|
limit: int = Field(default=20, ge=1, le=100)
|
||||||
|
|
||||||
|
|
||||||
|
class SavedMessageCreatePayload(BaseModel):
|
||||||
|
message_id: UUIDStrOrEmpty
|
||||||
|
|
||||||
|
|
||||||
|
# --- Workflow schemas ---
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultBlockConfigQuery(BaseModel):
|
||||||
|
q: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowListQuery(BaseModel):
|
||||||
|
page: int = Field(default=1, ge=1, le=99999)
|
||||||
|
limit: int = Field(default=10, ge=1, le=100)
|
||||||
|
user_id: str | None = None
|
||||||
|
named_only: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowRunPayload(BaseModel):
|
||||||
|
inputs: dict[str, Any]
|
||||||
|
files: list[dict[str, Any]] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowUpdatePayload(BaseModel):
|
||||||
|
marked_name: str | None = Field(default=None, max_length=20)
|
||||||
|
marked_comment: str | None = Field(default=None, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Dataset schemas ---
|
||||||
|
|
||||||
|
|
||||||
|
DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS = 100
|
||||||
|
|
||||||
|
|
||||||
|
class ChildChunkCreatePayload(BaseModel):
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class ChildChunkUpdatePayload(BaseModel):
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentBatchDownloadZipPayload(BaseModel):
|
||||||
|
"""Request payload for bulk downloading documents as a zip archive."""
|
||||||
|
|
||||||
|
document_ids: list[UUID] = Field(..., min_length=1, max_length=DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS)
|
||||||
|
|
||||||
|
|
||||||
|
class MetadataUpdatePayload(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
# --- Audio schemas ---
|
||||||
|
|
||||||
|
|
||||||
|
class TextToAudioPayload(BaseModel):
|
||||||
|
message_id: str | None = Field(default=None, description="Message ID")
|
||||||
|
voice: str | None = Field(default=None, description="Voice to use for TTS")
|
||||||
|
text: str | None = Field(default=None, description="Text to convert to audio")
|
||||||
|
streaming: bool | None = Field(default=None, description="Enable streaming response")
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, TypeAlias
|
from typing import Any
|
||||||
|
|
||||||
from graphon.file import helpers as file_helpers
|
|
||||||
from pydantic import BaseModel, ConfigDict, computed_field
|
from pydantic import BaseModel, ConfigDict, computed_field
|
||||||
|
|
||||||
|
from graphon.file import helpers as file_helpers
|
||||||
from models.model import IconType
|
from models.model import IconType
|
||||||
|
|
||||||
JSONValue: TypeAlias = str | int | float | bool | None | dict[str, Any] | list[Any]
|
type JSONValue = str | int | float | bool | None | dict[str, Any] | list[Any]
|
||||||
JSONObject: TypeAlias = dict[str, Any]
|
type JSONObject = dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
class SystemParameters(BaseModel):
|
class SystemParameters(BaseModel):
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from pydantic import BaseModel, JsonValue
|
||||||
|
|
||||||
|
|
||||||
|
class HumanInputFormSubmitPayload(BaseModel):
|
||||||
|
inputs: dict[str, JsonValue]
|
||||||
|
action: str
|
||||||
@@ -65,6 +65,7 @@ from .app import (
|
|||||||
statistic,
|
statistic,
|
||||||
workflow,
|
workflow,
|
||||||
workflow_app_log,
|
workflow_app_log,
|
||||||
|
workflow_comment,
|
||||||
workflow_draft_variable,
|
workflow_draft_variable,
|
||||||
workflow_run,
|
workflow_run,
|
||||||
workflow_statistic,
|
workflow_statistic,
|
||||||
@@ -116,6 +117,7 @@ from .explore import (
|
|||||||
saved_message,
|
saved_message,
|
||||||
trial,
|
trial,
|
||||||
)
|
)
|
||||||
|
from .socketio import workflow as socketio_workflow # pyright: ignore[reportUnusedImport]
|
||||||
|
|
||||||
# Import tag controllers
|
# Import tag controllers
|
||||||
from .tag import tags
|
from .tag import tags
|
||||||
@@ -201,6 +203,7 @@ __all__ = [
|
|||||||
"saved_message",
|
"saved_message",
|
||||||
"setup",
|
"setup",
|
||||||
"site",
|
"site",
|
||||||
|
"socketio_workflow",
|
||||||
"spec",
|
"spec",
|
||||||
"statistic",
|
"statistic",
|
||||||
"tags",
|
"tags",
|
||||||
@@ -211,6 +214,7 @@ __all__ = [
|
|||||||
"website",
|
"website",
|
||||||
"workflow",
|
"workflow",
|
||||||
"workflow_app_log",
|
"workflow_app_log",
|
||||||
|
"workflow_comment",
|
||||||
"workflow_draft_variable",
|
"workflow_draft_variable",
|
||||||
"workflow_run",
|
"workflow_run",
|
||||||
"workflow_statistic",
|
"workflow_statistic",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import csv
|
|||||||
import io
|
import io
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import ParamSpec, TypeVar
|
from typing import cast
|
||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask_restx import Resource
|
from flask_restx import Resource
|
||||||
@@ -18,10 +18,7 @@ from core.db.session_factory import session_factory
|
|||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from libs.token import extract_access_token
|
from libs.token import extract_access_token
|
||||||
from models.model import App, ExporleBanner, InstalledApp, RecommendedApp, TrialApp
|
from models.model import App, ExporleBanner, InstalledApp, RecommendedApp, TrialApp
|
||||||
from services.billing_service import BillingService
|
from services.billing_service import BillingService, LangContentDict
|
||||||
|
|
||||||
P = ParamSpec("P")
|
|
||||||
R = TypeVar("R")
|
|
||||||
|
|
||||||
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
|
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
|
||||||
|
|
||||||
@@ -72,9 +69,9 @@ console_ns.schema_model(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def admin_required(view: Callable[P, R]):
|
def admin_required[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||||
@wraps(view)
|
@wraps(view)
|
||||||
def decorated(*args: P.args, **kwargs: P.kwargs):
|
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||||
if not dify_config.ADMIN_API_KEY:
|
if not dify_config.ADMIN_API_KEY:
|
||||||
raise Unauthorized("API key is invalid.")
|
raise Unauthorized("API key is invalid.")
|
||||||
|
|
||||||
@@ -332,7 +329,7 @@ class UpsertNotificationApi(Resource):
|
|||||||
def post(self):
|
def post(self):
|
||||||
payload = UpsertNotificationPayload.model_validate(console_ns.payload)
|
payload = UpsertNotificationPayload.model_validate(console_ns.payload)
|
||||||
result = BillingService.upsert_notification(
|
result = BillingService.upsert_notification(
|
||||||
contents=[c.model_dump() for c in payload.contents],
|
contents=[cast(LangContentDict, c.model_dump()) for c in payload.contents],
|
||||||
frequency=payload.frequency,
|
frequency=payload.frequency,
|
||||||
status=payload.status,
|
status=payload.status,
|
||||||
notification_id=payload.notification_id,
|
notification_id=payload.notification_id,
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
import flask_restx
|
import flask_restx
|
||||||
from flask_restx import Resource, fields, marshal_with
|
from flask_restx import Resource
|
||||||
from flask_restx._http import HTTPStatus
|
from flask_restx._http import HTTPStatus
|
||||||
|
from pydantic import field_validator
|
||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
from werkzeug.exceptions import Forbidden
|
from werkzeug.exceptions import Forbidden
|
||||||
|
|
||||||
|
from controllers.common.schema import register_schema_models
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from libs.helper import TimestampField
|
from fields.base import ResponseModel
|
||||||
from libs.login import current_account_with_tenant, login_required
|
from libs.login import current_account_with_tenant, login_required
|
||||||
from models.dataset import Dataset
|
from models.dataset import Dataset
|
||||||
from models.enums import ApiTokenType
|
from models.enums import ApiTokenType
|
||||||
@@ -16,21 +20,31 @@ from services.api_token_service import ApiTokenCache
|
|||||||
from . import console_ns
|
from . import console_ns
|
||||||
from .wraps import account_initialization_required, edit_permission_required, setup_required
|
from .wraps import account_initialization_required, edit_permission_required, setup_required
|
||||||
|
|
||||||
api_key_fields = {
|
|
||||||
"id": fields.String,
|
|
||||||
"type": fields.String,
|
|
||||||
"token": fields.String,
|
|
||||||
"last_used_at": TimestampField,
|
|
||||||
"created_at": TimestampField,
|
|
||||||
}
|
|
||||||
|
|
||||||
api_key_item_model = console_ns.model("ApiKeyItem", api_key_fields)
|
def _to_timestamp(value: datetime | int | None) -> int | None:
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return int(value.timestamp())
|
||||||
|
return value
|
||||||
|
|
||||||
api_key_list = {"data": fields.List(fields.Nested(api_key_item_model), attribute="items")}
|
|
||||||
|
|
||||||
api_key_list_model = console_ns.model(
|
class ApiKeyItem(ResponseModel):
|
||||||
"ApiKeyList", {"data": fields.List(fields.Nested(api_key_item_model), attribute="items")}
|
id: str
|
||||||
)
|
type: str
|
||||||
|
token: str
|
||||||
|
last_used_at: int | None = None
|
||||||
|
created_at: int | None = None
|
||||||
|
|
||||||
|
@field_validator("last_used_at", "created_at", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
||||||
|
return _to_timestamp(value)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeyList(ResponseModel):
|
||||||
|
data: list[ApiKeyItem]
|
||||||
|
|
||||||
|
|
||||||
|
register_schema_models(console_ns, ApiKeyItem, ApiKeyList)
|
||||||
|
|
||||||
|
|
||||||
def _get_resource(resource_id, tenant_id, resource_model):
|
def _get_resource(resource_id, tenant_id, resource_model):
|
||||||
@@ -54,7 +68,6 @@ class BaseApiKeyListResource(Resource):
|
|||||||
token_prefix: str | None = None
|
token_prefix: str | None = None
|
||||||
max_keys = 10
|
max_keys = 10
|
||||||
|
|
||||||
@marshal_with(api_key_list_model)
|
|
||||||
def get(self, resource_id):
|
def get(self, resource_id):
|
||||||
assert self.resource_id_field is not None, "resource_id_field must be set"
|
assert self.resource_id_field is not None, "resource_id_field must be set"
|
||||||
resource_id = str(resource_id)
|
resource_id = str(resource_id)
|
||||||
@@ -66,9 +79,8 @@ class BaseApiKeyListResource(Resource):
|
|||||||
ApiToken.type == self.resource_type, getattr(ApiToken, self.resource_id_field) == resource_id
|
ApiToken.type == self.resource_type, getattr(ApiToken, self.resource_id_field) == resource_id
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
return {"items": keys}
|
return ApiKeyList.model_validate({"data": keys}, from_attributes=True).model_dump(mode="json")
|
||||||
|
|
||||||
@marshal_with(api_key_item_model)
|
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def post(self, resource_id):
|
def post(self, resource_id):
|
||||||
assert self.resource_id_field is not None, "resource_id_field must be set"
|
assert self.resource_id_field is not None, "resource_id_field must be set"
|
||||||
@@ -100,7 +112,7 @@ class BaseApiKeyListResource(Resource):
|
|||||||
api_token.type = self.resource_type
|
api_token.type = self.resource_type
|
||||||
db.session.add(api_token)
|
db.session.add(api_token)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return api_token, 201
|
return ApiKeyItem.model_validate(api_token, from_attributes=True).model_dump(mode="json"), 201
|
||||||
|
|
||||||
|
|
||||||
class BaseApiKeyResource(Resource):
|
class BaseApiKeyResource(Resource):
|
||||||
@@ -147,7 +159,7 @@ class AppApiKeyListResource(BaseApiKeyListResource):
|
|||||||
@console_ns.doc("get_app_api_keys")
|
@console_ns.doc("get_app_api_keys")
|
||||||
@console_ns.doc(description="Get all API keys for an app")
|
@console_ns.doc(description="Get all API keys for an app")
|
||||||
@console_ns.doc(params={"resource_id": "App ID"})
|
@console_ns.doc(params={"resource_id": "App ID"})
|
||||||
@console_ns.response(200, "Success", api_key_list_model)
|
@console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__])
|
||||||
def get(self, resource_id): # type: ignore
|
def get(self, resource_id): # type: ignore
|
||||||
"""Get all API keys for an app"""
|
"""Get all API keys for an app"""
|
||||||
return super().get(resource_id)
|
return super().get(resource_id)
|
||||||
@@ -155,7 +167,7 @@ class AppApiKeyListResource(BaseApiKeyListResource):
|
|||||||
@console_ns.doc("create_app_api_key")
|
@console_ns.doc("create_app_api_key")
|
||||||
@console_ns.doc(description="Create a new API key for an app")
|
@console_ns.doc(description="Create a new API key for an app")
|
||||||
@console_ns.doc(params={"resource_id": "App ID"})
|
@console_ns.doc(params={"resource_id": "App ID"})
|
||||||
@console_ns.response(201, "API key created successfully", api_key_item_model)
|
@console_ns.response(201, "API key created successfully", console_ns.models[ApiKeyItem.__name__])
|
||||||
@console_ns.response(400, "Maximum keys exceeded")
|
@console_ns.response(400, "Maximum keys exceeded")
|
||||||
def post(self, resource_id): # type: ignore
|
def post(self, resource_id): # type: ignore
|
||||||
"""Create a new API key for an app"""
|
"""Create a new API key for an app"""
|
||||||
@@ -187,7 +199,7 @@ class DatasetApiKeyListResource(BaseApiKeyListResource):
|
|||||||
@console_ns.doc("get_dataset_api_keys")
|
@console_ns.doc("get_dataset_api_keys")
|
||||||
@console_ns.doc(description="Get all API keys for a dataset")
|
@console_ns.doc(description="Get all API keys for a dataset")
|
||||||
@console_ns.doc(params={"resource_id": "Dataset ID"})
|
@console_ns.doc(params={"resource_id": "Dataset ID"})
|
||||||
@console_ns.response(200, "Success", api_key_list_model)
|
@console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__])
|
||||||
def get(self, resource_id): # type: ignore
|
def get(self, resource_id): # type: ignore
|
||||||
"""Get all API keys for a dataset"""
|
"""Get all API keys for a dataset"""
|
||||||
return super().get(resource_id)
|
return super().get(resource_id)
|
||||||
@@ -195,7 +207,7 @@ class DatasetApiKeyListResource(BaseApiKeyListResource):
|
|||||||
@console_ns.doc("create_dataset_api_key")
|
@console_ns.doc("create_dataset_api_key")
|
||||||
@console_ns.doc(description="Create a new API key for a dataset")
|
@console_ns.doc(description="Create a new API key for a dataset")
|
||||||
@console_ns.doc(params={"resource_id": "Dataset ID"})
|
@console_ns.doc(params={"resource_id": "Dataset ID"})
|
||||||
@console_ns.response(201, "API key created successfully", api_key_item_model)
|
@console_ns.response(201, "API key created successfully", console_ns.models[ApiKeyItem.__name__])
|
||||||
@console_ns.response(400, "Maximum keys exceeded")
|
@console_ns.response(400, "Maximum keys exceeded")
|
||||||
def post(self, resource_id): # type: ignore
|
def post(self, resource_id): # type: ignore
|
||||||
"""Create a new API key for a dataset"""
|
"""Create a new API key for a dataset"""
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from pydantic import BaseModel, Field
|
|||||||
from controllers.console import console_ns
|
from controllers.console import console_ns
|
||||||
from controllers.console.wraps import account_initialization_required, setup_required
|
from controllers.console.wraps import account_initialization_required, setup_required
|
||||||
from libs.login import login_required
|
from libs.login import login_required
|
||||||
from services.advanced_prompt_template_service import AdvancedPromptTemplateService
|
from services.advanced_prompt_template_service import AdvancedPromptTemplateArgs, AdvancedPromptTemplateService
|
||||||
|
|
||||||
|
|
||||||
class AdvancedPromptTemplateQuery(BaseModel):
|
class AdvancedPromptTemplateQuery(BaseModel):
|
||||||
@@ -35,5 +35,10 @@ class AdvancedPromptTemplateList(Resource):
|
|||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
def get(self):
|
def get(self):
|
||||||
args = AdvancedPromptTemplateQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
args = AdvancedPromptTemplateQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
||||||
|
prompt_args: AdvancedPromptTemplateArgs = {
|
||||||
return AdvancedPromptTemplateService.get_prompt(args.model_dump())
|
"app_mode": args.app_mode,
|
||||||
|
"model_mode": args.model_mode,
|
||||||
|
"model_name": args.model_name,
|
||||||
|
"has_context": args.has_context,
|
||||||
|
}
|
||||||
|
return AdvancedPromptTemplateService.get_prompt(prompt_args)
|
||||||
|
|||||||
@@ -25,7 +25,13 @@ from fields.annotation_fields import (
|
|||||||
)
|
)
|
||||||
from libs.helper import uuid_value
|
from libs.helper import uuid_value
|
||||||
from libs.login import login_required
|
from libs.login import login_required
|
||||||
from services.annotation_service import AppAnnotationService
|
from services.annotation_service import (
|
||||||
|
AppAnnotationService,
|
||||||
|
EnableAnnotationArgs,
|
||||||
|
UpdateAnnotationArgs,
|
||||||
|
UpdateAnnotationSettingArgs,
|
||||||
|
UpsertAnnotationArgs,
|
||||||
|
)
|
||||||
|
|
||||||
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
|
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
|
||||||
|
|
||||||
@@ -120,7 +126,12 @@ class AnnotationReplyActionApi(Resource):
|
|||||||
args = AnnotationReplyPayload.model_validate(console_ns.payload)
|
args = AnnotationReplyPayload.model_validate(console_ns.payload)
|
||||||
match action:
|
match action:
|
||||||
case "enable":
|
case "enable":
|
||||||
result = AppAnnotationService.enable_app_annotation(args.model_dump(), app_id)
|
enable_args: EnableAnnotationArgs = {
|
||||||
|
"score_threshold": args.score_threshold,
|
||||||
|
"embedding_provider_name": args.embedding_provider_name,
|
||||||
|
"embedding_model_name": args.embedding_model_name,
|
||||||
|
}
|
||||||
|
result = AppAnnotationService.enable_app_annotation(enable_args, app_id)
|
||||||
case "disable":
|
case "disable":
|
||||||
result = AppAnnotationService.disable_app_annotation(app_id)
|
result = AppAnnotationService.disable_app_annotation(app_id)
|
||||||
return result, 200
|
return result, 200
|
||||||
@@ -161,7 +172,8 @@ class AppAnnotationSettingUpdateApi(Resource):
|
|||||||
|
|
||||||
args = AnnotationSettingUpdatePayload.model_validate(console_ns.payload)
|
args = AnnotationSettingUpdatePayload.model_validate(console_ns.payload)
|
||||||
|
|
||||||
result = AppAnnotationService.update_app_annotation_setting(app_id, annotation_setting_id, args.model_dump())
|
setting_args: UpdateAnnotationSettingArgs = {"score_threshold": args.score_threshold}
|
||||||
|
result = AppAnnotationService.update_app_annotation_setting(app_id, annotation_setting_id, setting_args)
|
||||||
return result, 200
|
return result, 200
|
||||||
|
|
||||||
|
|
||||||
@@ -237,8 +249,16 @@ class AnnotationApi(Resource):
|
|||||||
def post(self, app_id):
|
def post(self, app_id):
|
||||||
app_id = str(app_id)
|
app_id = str(app_id)
|
||||||
args = CreateAnnotationPayload.model_validate(console_ns.payload)
|
args = CreateAnnotationPayload.model_validate(console_ns.payload)
|
||||||
data = args.model_dump(exclude_none=True)
|
upsert_args: UpsertAnnotationArgs = {}
|
||||||
annotation = AppAnnotationService.up_insert_app_annotation_from_message(data, app_id)
|
if args.answer is not None:
|
||||||
|
upsert_args["answer"] = args.answer
|
||||||
|
if args.content is not None:
|
||||||
|
upsert_args["content"] = args.content
|
||||||
|
if args.message_id is not None:
|
||||||
|
upsert_args["message_id"] = args.message_id
|
||||||
|
if args.question is not None:
|
||||||
|
upsert_args["question"] = args.question
|
||||||
|
annotation = AppAnnotationService.up_insert_app_annotation_from_message(upsert_args, app_id)
|
||||||
return Annotation.model_validate(annotation, from_attributes=True).model_dump(mode="json")
|
return Annotation.model_validate(annotation, from_attributes=True).model_dump(mode="json")
|
||||||
|
|
||||||
@setup_required
|
@setup_required
|
||||||
@@ -315,9 +335,12 @@ class AnnotationUpdateDeleteApi(Resource):
|
|||||||
app_id = str(app_id)
|
app_id = str(app_id)
|
||||||
annotation_id = str(annotation_id)
|
annotation_id = str(annotation_id)
|
||||||
args = UpdateAnnotationPayload.model_validate(console_ns.payload)
|
args = UpdateAnnotationPayload.model_validate(console_ns.payload)
|
||||||
annotation = AppAnnotationService.update_app_annotation_directly(
|
update_args: UpdateAnnotationArgs = {}
|
||||||
args.model_dump(exclude_none=True), app_id, annotation_id
|
if args.answer is not None:
|
||||||
)
|
update_args["answer"] = args.answer
|
||||||
|
if args.question is not None:
|
||||||
|
update_args["question"] = args.question
|
||||||
|
annotation = AppAnnotationService.update_app_annotation_directly(update_args, app_id, annotation_id)
|
||||||
return Annotation.model_validate(annotation, from_attributes=True).model_dump(mode="json")
|
return Annotation.model_validate(annotation, from_attributes=True).model_dump(mode="json")
|
||||||
|
|
||||||
@setup_required
|
@setup_required
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Literal, TypeAlias
|
from typing import Any, Literal
|
||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask_restx import Resource
|
from flask_restx import Resource
|
||||||
from graphon.enums import WorkflowExecutionStatus
|
from pydantic import AliasChoices, BaseModel, Field, computed_field, field_validator
|
||||||
from graphon.file import helpers as file_helpers
|
|
||||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field, field_validator
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import Session
|
||||||
from werkzeug.exceptions import BadRequest
|
from werkzeug.exceptions import BadRequest
|
||||||
|
|
||||||
from controllers.common.helpers import FileInfo
|
from controllers.common.helpers import FileInfo
|
||||||
@@ -26,25 +24,27 @@ from controllers.console.wraps import (
|
|||||||
setup_required,
|
setup_required,
|
||||||
)
|
)
|
||||||
from core.ops.ops_trace_manager import OpsTraceManager
|
from core.ops.ops_trace_manager import OpsTraceManager
|
||||||
|
from core.rag.entities import PreProcessingRule, Rule, Segmentation
|
||||||
from core.rag.retrieval.retrieval_methods import RetrievalMethod
|
from core.rag.retrieval.retrieval_methods import RetrievalMethod
|
||||||
from core.trigger.constants import TRIGGER_NODE_TYPES
|
from core.trigger.constants import TRIGGER_NODE_TYPES
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
|
from fields.base import ResponseModel
|
||||||
|
from graphon.enums import WorkflowExecutionStatus
|
||||||
|
from libs.helper import build_icon_url
|
||||||
from libs.login import current_account_with_tenant, login_required
|
from libs.login import current_account_with_tenant, login_required
|
||||||
from models import App, DatasetPermissionEnum, Workflow
|
from models import App, DatasetPermissionEnum, Workflow
|
||||||
from models.model import IconType
|
from models.model import IconType
|
||||||
from services.app_dsl_service import AppDslService, ImportMode
|
from services.app_dsl_service import AppDslService
|
||||||
from services.app_service import AppService
|
from services.app_service import AppService
|
||||||
from services.enterprise.enterprise_service import EnterpriseService
|
from services.enterprise.enterprise_service import EnterpriseService
|
||||||
|
from services.entities.dsl_entities import ImportMode, ImportStatus
|
||||||
from services.entities.knowledge_entities.knowledge_entities import (
|
from services.entities.knowledge_entities.knowledge_entities import (
|
||||||
DataSource,
|
DataSource,
|
||||||
InfoList,
|
InfoList,
|
||||||
NotionIcon,
|
NotionIcon,
|
||||||
NotionInfo,
|
NotionInfo,
|
||||||
NotionPage,
|
NotionPage,
|
||||||
PreProcessingRule,
|
|
||||||
RerankingModel,
|
RerankingModel,
|
||||||
Rule,
|
|
||||||
Segmentation,
|
|
||||||
WebsiteInfo,
|
WebsiteInfo,
|
||||||
WeightKeywordSetting,
|
WeightKeywordSetting,
|
||||||
WeightModel,
|
WeightModel,
|
||||||
@@ -129,6 +129,7 @@ class AppNamePayload(BaseModel):
|
|||||||
|
|
||||||
class AppIconPayload(BaseModel):
|
class AppIconPayload(BaseModel):
|
||||||
icon: str | None = Field(default=None, description="Icon data")
|
icon: str | None = Field(default=None, description="Icon data")
|
||||||
|
icon_type: IconType | None = Field(default=None, description="Icon type")
|
||||||
icon_background: str | None = Field(default=None, description="Icon background color")
|
icon_background: str | None = Field(default=None, description="Icon background color")
|
||||||
|
|
||||||
|
|
||||||
@@ -152,17 +153,7 @@ class AppTracePayload(BaseModel):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
JSONValue: TypeAlias = Any
|
type JSONValue = Any
|
||||||
|
|
||||||
|
|
||||||
class ResponseModel(BaseModel):
|
|
||||||
model_config = ConfigDict(
|
|
||||||
from_attributes=True,
|
|
||||||
extra="ignore",
|
|
||||||
populate_by_name=True,
|
|
||||||
serialize_by_alias=True,
|
|
||||||
protected_namespaces=(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _to_timestamp(value: datetime | int | None) -> int | None:
|
def _to_timestamp(value: datetime | int | None) -> int | None:
|
||||||
@@ -171,15 +162,6 @@ def _to_timestamp(value: datetime | int | None) -> int | None:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _build_icon_url(icon_type: str | IconType | None, icon: str | None) -> str | None:
|
|
||||||
if icon is None or icon_type is None:
|
|
||||||
return None
|
|
||||||
icon_type_value = icon_type.value if isinstance(icon_type, IconType) else str(icon_type)
|
|
||||||
if icon_type_value.lower() != IconType.IMAGE:
|
|
||||||
return None
|
|
||||||
return file_helpers.get_signed_file_url(icon)
|
|
||||||
|
|
||||||
|
|
||||||
class Tag(ResponseModel):
|
class Tag(ResponseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
@@ -302,7 +284,7 @@ class Site(ResponseModel):
|
|||||||
@computed_field(return_type=str | None) # type: ignore
|
@computed_field(return_type=str | None) # type: ignore
|
||||||
@property
|
@property
|
||||||
def icon_url(self) -> str | None:
|
def icon_url(self) -> str | None:
|
||||||
return _build_icon_url(self.icon_type, self.icon)
|
return build_icon_url(self.icon_type, self.icon)
|
||||||
|
|
||||||
@field_validator("icon_type", mode="before")
|
@field_validator("icon_type", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -352,7 +334,7 @@ class AppPartial(ResponseModel):
|
|||||||
@computed_field(return_type=str | None) # type: ignore
|
@computed_field(return_type=str | None) # type: ignore
|
||||||
@property
|
@property
|
||||||
def icon_url(self) -> str | None:
|
def icon_url(self) -> str | None:
|
||||||
return _build_icon_url(self.icon_type, self.icon)
|
return build_icon_url(self.icon_type, self.icon)
|
||||||
|
|
||||||
@field_validator("created_at", "updated_at", mode="before")
|
@field_validator("created_at", "updated_at", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -400,7 +382,7 @@ class AppDetailWithSite(AppDetail):
|
|||||||
@computed_field(return_type=str | None) # type: ignore
|
@computed_field(return_type=str | None) # type: ignore
|
||||||
@property
|
@property
|
||||||
def icon_url(self) -> str | None:
|
def icon_url(self) -> str | None:
|
||||||
return _build_icon_url(self.icon_type, self.icon)
|
return build_icon_url(self.icon_type, self.icon)
|
||||||
|
|
||||||
|
|
||||||
class AppPagination(ResponseModel):
|
class AppPagination(ResponseModel):
|
||||||
@@ -642,7 +624,7 @@ class AppCopyApi(Resource):
|
|||||||
|
|
||||||
args = CopyAppPayload.model_validate(console_ns.payload or {})
|
args = CopyAppPayload.model_validate(console_ns.payload or {})
|
||||||
|
|
||||||
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
|
with Session(db.engine, expire_on_commit=False) as session:
|
||||||
import_service = AppDslService(session)
|
import_service = AppDslService(session)
|
||||||
yaml_content = import_service.export_dsl(app_model=app_model, include_secret=True)
|
yaml_content = import_service.export_dsl(app_model=app_model, include_secret=True)
|
||||||
result = import_service.import_app(
|
result = import_service.import_app(
|
||||||
@@ -655,6 +637,13 @@ class AppCopyApi(Resource):
|
|||||||
icon=args.icon,
|
icon=args.icon,
|
||||||
icon_background=args.icon_background,
|
icon_background=args.icon_background,
|
||||||
)
|
)
|
||||||
|
if result.status == ImportStatus.FAILED:
|
||||||
|
session.rollback()
|
||||||
|
return result.model_dump(mode="json"), 400
|
||||||
|
if result.status == ImportStatus.PENDING:
|
||||||
|
session.rollback()
|
||||||
|
return result.model_dump(mode="json"), 202
|
||||||
|
session.commit()
|
||||||
|
|
||||||
# Inherit web app permission from original app
|
# Inherit web app permission from original app
|
||||||
if result.app_id and FeatureService.get_system_features().webapp_auth.enabled:
|
if result.app_id and FeatureService.get_system_features().webapp_auth.enabled:
|
||||||
@@ -703,6 +692,32 @@ class AppExportApi(Resource):
|
|||||||
return payload.model_dump(mode="json")
|
return payload.model_dump(mode="json")
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/apps/<uuid:app_id>/publish-to-creators-platform")
|
||||||
|
class AppPublishToCreatorsPlatformApi(Resource):
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@get_app_model(mode=None)
|
||||||
|
@edit_permission_required
|
||||||
|
def post(self, app_model):
|
||||||
|
"""Publish app to Creators Platform"""
|
||||||
|
from configs import dify_config
|
||||||
|
from core.helper.creators import get_redirect_url, upload_dsl
|
||||||
|
|
||||||
|
if not dify_config.CREATORS_PLATFORM_FEATURES_ENABLED:
|
||||||
|
return {"error": "Creators Platform features are not enabled"}, 403
|
||||||
|
|
||||||
|
current_user, _ = current_account_with_tenant()
|
||||||
|
|
||||||
|
dsl_content = AppDslService.export_dsl(app_model=app_model, include_secret=False)
|
||||||
|
dsl_bytes = dsl_content.encode("utf-8")
|
||||||
|
|
||||||
|
claim_code = upload_dsl(dsl_bytes)
|
||||||
|
redirect_url = get_redirect_url(str(current_user.id), claim_code)
|
||||||
|
|
||||||
|
return {"redirect_url": redirect_url}
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/apps/<uuid:app_id>/name")
|
@console_ns.route("/apps/<uuid:app_id>/name")
|
||||||
class AppNameApi(Resource):
|
class AppNameApi(Resource):
|
||||||
@console_ns.doc("check_app_name")
|
@console_ns.doc("check_app_name")
|
||||||
@@ -741,7 +756,12 @@ class AppIconApi(Resource):
|
|||||||
args = AppIconPayload.model_validate(console_ns.payload or {})
|
args = AppIconPayload.model_validate(console_ns.payload or {})
|
||||||
|
|
||||||
app_service = AppService()
|
app_service = AppService()
|
||||||
app_model = app_service.update_app_icon(app_model, args.icon or "", args.icon_background or "")
|
app_model = app_service.update_app_icon(
|
||||||
|
app_model,
|
||||||
|
args.icon or "",
|
||||||
|
args.icon_background or "",
|
||||||
|
args.icon_type,
|
||||||
|
)
|
||||||
response_model = AppDetail.model_validate(app_model, from_attributes=True)
|
response_model = AppDetail.model_validate(app_model, from_attributes=True)
|
||||||
return response_model.model_dump(mode="json")
|
return response_model.model_dump(mode="json")
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from flask_restx import Resource, fields, marshal_with
|
from flask_restx import Resource
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from controllers.common.schema import register_schema_models
|
||||||
from controllers.console.app.wraps import get_app_model
|
from controllers.console.app.wraps import get_app_model
|
||||||
from controllers.console.wraps import (
|
from controllers.console.wraps import (
|
||||||
account_initialization_required,
|
account_initialization_required,
|
||||||
@@ -10,34 +11,15 @@ from controllers.console.wraps import (
|
|||||||
setup_required,
|
setup_required,
|
||||||
)
|
)
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from fields.app_fields import (
|
|
||||||
app_import_check_dependencies_fields,
|
|
||||||
app_import_fields,
|
|
||||||
leaked_dependency_fields,
|
|
||||||
)
|
|
||||||
from libs.login import current_account_with_tenant, login_required
|
from libs.login import current_account_with_tenant, login_required
|
||||||
from models.model import App
|
from models.model import App
|
||||||
from services.app_dsl_service import AppDslService, ImportStatus
|
from services.app_dsl_service import AppDslService, Import
|
||||||
from services.enterprise.enterprise_service import EnterpriseService
|
from services.enterprise.enterprise_service import EnterpriseService
|
||||||
|
from services.entities.dsl_entities import CheckDependenciesResult, ImportStatus
|
||||||
from services.feature_service import FeatureService
|
from services.feature_service import FeatureService
|
||||||
|
|
||||||
from .. import console_ns
|
from .. import console_ns
|
||||||
|
|
||||||
# Register models for flask_restx to avoid dict type issues in Swagger
|
|
||||||
# Register base model first
|
|
||||||
leaked_dependency_model = console_ns.model("LeakedDependency", leaked_dependency_fields)
|
|
||||||
|
|
||||||
app_import_model = console_ns.model("AppImport", app_import_fields)
|
|
||||||
|
|
||||||
# For nested models, need to replace nested dict with registered model
|
|
||||||
app_import_check_dependencies_fields_copy = app_import_check_dependencies_fields.copy()
|
|
||||||
app_import_check_dependencies_fields_copy["leaked_dependencies"] = fields.List(fields.Nested(leaked_dependency_model))
|
|
||||||
app_import_check_dependencies_model = console_ns.model(
|
|
||||||
"AppImportCheckDependencies", app_import_check_dependencies_fields_copy
|
|
||||||
)
|
|
||||||
|
|
||||||
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
|
|
||||||
|
|
||||||
|
|
||||||
class AppImportPayload(BaseModel):
|
class AppImportPayload(BaseModel):
|
||||||
mode: str = Field(..., description="Import mode")
|
mode: str = Field(..., description="Import mode")
|
||||||
@@ -51,18 +33,18 @@ class AppImportPayload(BaseModel):
|
|||||||
app_id: str | None = Field(None)
|
app_id: str | None = Field(None)
|
||||||
|
|
||||||
|
|
||||||
console_ns.schema_model(
|
register_schema_models(console_ns, AppImportPayload, Import, CheckDependenciesResult)
|
||||||
AppImportPayload.__name__, AppImportPayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/apps/imports")
|
@console_ns.route("/apps/imports")
|
||||||
class AppImportApi(Resource):
|
class AppImportApi(Resource):
|
||||||
@console_ns.expect(console_ns.models[AppImportPayload.__name__])
|
@console_ns.expect(console_ns.models[AppImportPayload.__name__])
|
||||||
|
@console_ns.response(200, "Import completed", console_ns.models[Import.__name__])
|
||||||
|
@console_ns.response(202, "Import pending confirmation", console_ns.models[Import.__name__])
|
||||||
|
@console_ns.response(400, "Import failed", console_ns.models[Import.__name__])
|
||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@marshal_with(app_import_model)
|
|
||||||
@cloud_edition_billing_resource_check("apps")
|
@cloud_edition_billing_resource_check("apps")
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def post(self):
|
def post(self):
|
||||||
@@ -70,8 +52,9 @@ class AppImportApi(Resource):
|
|||||||
current_user, _ = current_account_with_tenant()
|
current_user, _ = current_account_with_tenant()
|
||||||
args = AppImportPayload.model_validate(console_ns.payload)
|
args = AppImportPayload.model_validate(console_ns.payload)
|
||||||
|
|
||||||
# Create service with session
|
# AppDslService performs internal commits for some creation paths, so use a plain
|
||||||
with sessionmaker(db.engine).begin() as session:
|
# Session here instead of nesting it inside sessionmaker(...).begin().
|
||||||
|
with Session(db.engine, expire_on_commit=False) as session:
|
||||||
import_service = AppDslService(session)
|
import_service = AppDslService(session)
|
||||||
# Import app
|
# Import app
|
||||||
account = current_user
|
account = current_user
|
||||||
@@ -87,35 +70,45 @@ class AppImportApi(Resource):
|
|||||||
icon_background=args.icon_background,
|
icon_background=args.icon_background,
|
||||||
app_id=args.app_id,
|
app_id=args.app_id,
|
||||||
)
|
)
|
||||||
|
if result.status == ImportStatus.FAILED:
|
||||||
|
session.rollback()
|
||||||
|
else:
|
||||||
|
session.commit()
|
||||||
if result.app_id and FeatureService.get_system_features().webapp_auth.enabled:
|
if result.app_id and FeatureService.get_system_features().webapp_auth.enabled:
|
||||||
# update web app setting as private
|
# update web app setting as private
|
||||||
EnterpriseService.WebAppAuth.update_app_access_mode(result.app_id, "private")
|
EnterpriseService.WebAppAuth.update_app_access_mode(result.app_id, "private")
|
||||||
# Return appropriate status code based on result
|
# Return appropriate status code based on result
|
||||||
status = result.status
|
status = result.status
|
||||||
if status == ImportStatus.FAILED:
|
match status:
|
||||||
return result.model_dump(mode="json"), 400
|
case ImportStatus.FAILED:
|
||||||
elif status == ImportStatus.PENDING:
|
return result.model_dump(mode="json"), 400
|
||||||
return result.model_dump(mode="json"), 202
|
case ImportStatus.PENDING:
|
||||||
return result.model_dump(mode="json"), 200
|
return result.model_dump(mode="json"), 202
|
||||||
|
case ImportStatus.COMPLETED | ImportStatus.COMPLETED_WITH_WARNINGS:
|
||||||
|
return result.model_dump(mode="json"), 200
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/apps/imports/<string:import_id>/confirm")
|
@console_ns.route("/apps/imports/<string:import_id>/confirm")
|
||||||
class AppImportConfirmApi(Resource):
|
class AppImportConfirmApi(Resource):
|
||||||
|
@console_ns.response(200, "Import confirmed", console_ns.models[Import.__name__])
|
||||||
|
@console_ns.response(400, "Import failed", console_ns.models[Import.__name__])
|
||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@marshal_with(app_import_model)
|
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def post(self, import_id):
|
def post(self, import_id):
|
||||||
# Check user role first
|
# Check user role first
|
||||||
current_user, _ = current_account_with_tenant()
|
current_user, _ = current_account_with_tenant()
|
||||||
|
|
||||||
# Create service with session
|
with Session(db.engine, expire_on_commit=False) as session:
|
||||||
with sessionmaker(db.engine).begin() as session:
|
|
||||||
import_service = AppDslService(session)
|
import_service = AppDslService(session)
|
||||||
# Confirm import
|
# Confirm import
|
||||||
account = current_user
|
account = current_user
|
||||||
result = import_service.confirm_import(import_id=import_id, account=account)
|
result = import_service.confirm_import(import_id=import_id, account=account)
|
||||||
|
if result.status == ImportStatus.FAILED:
|
||||||
|
session.rollback()
|
||||||
|
else:
|
||||||
|
session.commit()
|
||||||
|
|
||||||
# Return appropriate status code based on result
|
# Return appropriate status code based on result
|
||||||
if result.status == ImportStatus.FAILED:
|
if result.status == ImportStatus.FAILED:
|
||||||
@@ -125,14 +118,14 @@ class AppImportConfirmApi(Resource):
|
|||||||
|
|
||||||
@console_ns.route("/apps/imports/<string:app_id>/check-dependencies")
|
@console_ns.route("/apps/imports/<string:app_id>/check-dependencies")
|
||||||
class AppImportCheckDependenciesApi(Resource):
|
class AppImportCheckDependenciesApi(Resource):
|
||||||
|
@console_ns.response(200, "Dependencies checked", console_ns.models[CheckDependenciesResult.__name__])
|
||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@get_app_model
|
@get_app_model
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@marshal_with(app_import_check_dependencies_model)
|
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def get(self, app_model: App):
|
def get(self, app_model: App):
|
||||||
with sessionmaker(db.engine).begin() as session:
|
with Session(db.engine, expire_on_commit=False) as session:
|
||||||
import_service = AppDslService(session)
|
import_service = AppDslService(session)
|
||||||
result = import_service.check_dependencies(app_model=app_model)
|
result = import_service.check_dependencies(app_model=app_model)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import logging
|
|||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask_restx import Resource, fields
|
from flask_restx import Resource, fields
|
||||||
from graphon.model_runtime.errors.invoke import InvokeError
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from werkzeug.exceptions import InternalServerError
|
from werkzeug.exceptions import InternalServerError
|
||||||
|
|
||||||
@@ -23,6 +22,7 @@ from controllers.console.app.error import (
|
|||||||
from controllers.console.app.wraps import get_app_model
|
from controllers.console.app.wraps import get_app_model
|
||||||
from controllers.console.wraps import account_initialization_required, setup_required
|
from controllers.console.wraps import account_initialization_required, setup_required
|
||||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||||
|
from graphon.model_runtime.errors.invoke import InvokeError
|
||||||
from libs.login import login_required
|
from libs.login import login_required
|
||||||
from models import App, AppMode
|
from models import App, AppMode
|
||||||
from services.audio_service import AudioService
|
from services.audio_service import AudioService
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ from typing import Any, Literal
|
|||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask_restx import Resource
|
from flask_restx import Resource
|
||||||
from graphon.model_runtime.errors.invoke import InvokeError
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from werkzeug.exceptions import InternalServerError, NotFound
|
from werkzeug.exceptions import InternalServerError, NotFound
|
||||||
|
|
||||||
@@ -27,6 +26,7 @@ from core.errors.error import (
|
|||||||
QuotaExceededError,
|
QuotaExceededError,
|
||||||
)
|
)
|
||||||
from core.helper.trace_id_helper import get_external_trace_id
|
from core.helper.trace_id_helper import get_external_trace_id
|
||||||
|
from graphon.model_runtime.errors.invoke import InvokeError
|
||||||
from libs import helper
|
from libs import helper
|
||||||
from libs.helper import uuid_value
|
from libs.helper import uuid_value
|
||||||
from libs.login import current_user, login_required
|
from libs.login import current_user, login_required
|
||||||
|
|||||||
@@ -2,20 +2,37 @@ from typing import Literal
|
|||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from flask import abort, request
|
from flask import abort, request
|
||||||
from flask_restx import Resource, fields, marshal_with
|
from flask_restx import Resource
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from sqlalchemy import func, or_
|
from sqlalchemy import func, or_
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from werkzeug.exceptions import NotFound
|
from werkzeug.exceptions import NotFound
|
||||||
|
|
||||||
|
from controllers.common.schema import register_schema_models
|
||||||
from controllers.console import console_ns
|
from controllers.console import console_ns
|
||||||
from controllers.console.app.wraps import get_app_model
|
from controllers.console.app.wraps import get_app_model
|
||||||
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
|
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
|
||||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from fields.raws import FilesContainedField
|
from fields.conversation_fields import (
|
||||||
|
Conversation as ConversationResponse,
|
||||||
|
)
|
||||||
|
from fields.conversation_fields import (
|
||||||
|
ConversationDetail as ConversationDetailResponse,
|
||||||
|
)
|
||||||
|
from fields.conversation_fields import (
|
||||||
|
ConversationMessageDetail as ConversationMessageDetailResponse,
|
||||||
|
)
|
||||||
|
from fields.conversation_fields import (
|
||||||
|
ConversationPagination as ConversationPaginationResponse,
|
||||||
|
)
|
||||||
|
from fields.conversation_fields import (
|
||||||
|
ConversationWithSummaryPagination as ConversationWithSummaryPaginationResponse,
|
||||||
|
)
|
||||||
|
from fields.conversation_fields import (
|
||||||
|
ResultResponse,
|
||||||
|
)
|
||||||
from libs.datetime_utils import naive_utc_now, parse_time_range
|
from libs.datetime_utils import naive_utc_now, parse_time_range
|
||||||
from libs.helper import TimestampField
|
|
||||||
from libs.login import current_account_with_tenant, login_required
|
from libs.login import current_account_with_tenant, login_required
|
||||||
from models import Conversation, EndUser, Message, MessageAnnotation
|
from models import Conversation, EndUser, Message, MessageAnnotation
|
||||||
from models.model import AppMode
|
from models.model import AppMode
|
||||||
@@ -62,267 +79,16 @@ console_ns.schema_model(
|
|||||||
ChatConversationQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
|
ChatConversationQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Register models for flask_restx to avoid dict type issues in Swagger
|
register_schema_models(
|
||||||
# Register in dependency order: base models first, then dependent models
|
console_ns,
|
||||||
|
CompletionConversationQuery,
|
||||||
# Base models
|
ChatConversationQuery,
|
||||||
simple_account_model = console_ns.model(
|
ConversationResponse,
|
||||||
"SimpleAccount",
|
ConversationPaginationResponse,
|
||||||
{
|
ConversationMessageDetailResponse,
|
||||||
"id": fields.String,
|
ConversationWithSummaryPaginationResponse,
|
||||||
"name": fields.String,
|
ConversationDetailResponse,
|
||||||
"email": fields.String,
|
ResultResponse,
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
feedback_stat_model = console_ns.model(
|
|
||||||
"FeedbackStat",
|
|
||||||
{
|
|
||||||
"like": fields.Integer,
|
|
||||||
"dislike": fields.Integer,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
status_count_model = console_ns.model(
|
|
||||||
"StatusCount",
|
|
||||||
{
|
|
||||||
"success": fields.Integer,
|
|
||||||
"failed": fields.Integer,
|
|
||||||
"partial_success": fields.Integer,
|
|
||||||
"paused": fields.Integer,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
message_file_model = console_ns.model(
|
|
||||||
"MessageFile",
|
|
||||||
{
|
|
||||||
"id": fields.String,
|
|
||||||
"filename": fields.String,
|
|
||||||
"type": fields.String,
|
|
||||||
"url": fields.String,
|
|
||||||
"mime_type": fields.String,
|
|
||||||
"size": fields.Integer,
|
|
||||||
"transfer_method": fields.String,
|
|
||||||
"belongs_to": fields.String(default="user"),
|
|
||||||
"upload_file_id": fields.String(default=None),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
agent_thought_model = console_ns.model(
|
|
||||||
"AgentThought",
|
|
||||||
{
|
|
||||||
"id": fields.String,
|
|
||||||
"chain_id": fields.String,
|
|
||||||
"message_id": fields.String,
|
|
||||||
"position": fields.Integer,
|
|
||||||
"thought": fields.String,
|
|
||||||
"tool": fields.String,
|
|
||||||
"tool_labels": fields.Raw,
|
|
||||||
"tool_input": fields.String,
|
|
||||||
"created_at": TimestampField,
|
|
||||||
"observation": fields.String,
|
|
||||||
"files": fields.List(fields.String),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
simple_model_config_model = console_ns.model(
|
|
||||||
"SimpleModelConfig",
|
|
||||||
{
|
|
||||||
"model": fields.Raw(attribute="model_dict"),
|
|
||||||
"pre_prompt": fields.String,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
model_config_model = console_ns.model(
|
|
||||||
"ModelConfig",
|
|
||||||
{
|
|
||||||
"opening_statement": fields.String,
|
|
||||||
"suggested_questions": fields.Raw,
|
|
||||||
"model": fields.Raw,
|
|
||||||
"user_input_form": fields.Raw,
|
|
||||||
"pre_prompt": fields.String,
|
|
||||||
"agent_mode": fields.Raw,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Models that depend on simple_account_model
|
|
||||||
feedback_model = console_ns.model(
|
|
||||||
"Feedback",
|
|
||||||
{
|
|
||||||
"rating": fields.String,
|
|
||||||
"content": fields.String,
|
|
||||||
"from_source": fields.String,
|
|
||||||
"from_end_user_id": fields.String,
|
|
||||||
"from_account": fields.Nested(simple_account_model, allow_null=True),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
annotation_model = console_ns.model(
|
|
||||||
"Annotation",
|
|
||||||
{
|
|
||||||
"id": fields.String,
|
|
||||||
"question": fields.String,
|
|
||||||
"content": fields.String,
|
|
||||||
"account": fields.Nested(simple_account_model, allow_null=True),
|
|
||||||
"created_at": TimestampField,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
annotation_hit_history_model = console_ns.model(
|
|
||||||
"AnnotationHitHistory",
|
|
||||||
{
|
|
||||||
"annotation_id": fields.String(attribute="id"),
|
|
||||||
"annotation_create_account": fields.Nested(simple_account_model, allow_null=True),
|
|
||||||
"created_at": TimestampField,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MessageTextField(fields.Raw):
|
|
||||||
def format(self, value):
|
|
||||||
return value[0]["text"] if value else ""
|
|
||||||
|
|
||||||
|
|
||||||
# Simple message detail model
|
|
||||||
simple_message_detail_model = console_ns.model(
|
|
||||||
"SimpleMessageDetail",
|
|
||||||
{
|
|
||||||
"inputs": FilesContainedField,
|
|
||||||
"query": fields.String,
|
|
||||||
"message": MessageTextField,
|
|
||||||
"answer": fields.String,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Message detail model that depends on multiple models
|
|
||||||
message_detail_model = console_ns.model(
|
|
||||||
"MessageDetail",
|
|
||||||
{
|
|
||||||
"id": fields.String,
|
|
||||||
"conversation_id": fields.String,
|
|
||||||
"inputs": FilesContainedField,
|
|
||||||
"query": fields.String,
|
|
||||||
"message": fields.Raw,
|
|
||||||
"message_tokens": fields.Integer,
|
|
||||||
"answer": fields.String(attribute="re_sign_file_url_answer"),
|
|
||||||
"answer_tokens": fields.Integer,
|
|
||||||
"provider_response_latency": fields.Float,
|
|
||||||
"from_source": fields.String,
|
|
||||||
"from_end_user_id": fields.String,
|
|
||||||
"from_account_id": fields.String,
|
|
||||||
"feedbacks": fields.List(fields.Nested(feedback_model)),
|
|
||||||
"workflow_run_id": fields.String,
|
|
||||||
"annotation": fields.Nested(annotation_model, allow_null=True),
|
|
||||||
"annotation_hit_history": fields.Nested(annotation_hit_history_model, allow_null=True),
|
|
||||||
"created_at": TimestampField,
|
|
||||||
"agent_thoughts": fields.List(fields.Nested(agent_thought_model)),
|
|
||||||
"message_files": fields.List(fields.Nested(message_file_model)),
|
|
||||||
"metadata": fields.Raw(attribute="message_metadata_dict"),
|
|
||||||
"status": fields.String,
|
|
||||||
"error": fields.String,
|
|
||||||
"parent_message_id": fields.String,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Conversation models
|
|
||||||
conversation_fields_model = console_ns.model(
|
|
||||||
"Conversation",
|
|
||||||
{
|
|
||||||
"id": fields.String,
|
|
||||||
"status": fields.String,
|
|
||||||
"from_source": fields.String,
|
|
||||||
"from_end_user_id": fields.String,
|
|
||||||
"from_end_user_session_id": fields.String(),
|
|
||||||
"from_account_id": fields.String,
|
|
||||||
"from_account_name": fields.String,
|
|
||||||
"read_at": TimestampField,
|
|
||||||
"created_at": TimestampField,
|
|
||||||
"updated_at": TimestampField,
|
|
||||||
"annotation": fields.Nested(annotation_model, allow_null=True),
|
|
||||||
"model_config": fields.Nested(simple_model_config_model),
|
|
||||||
"user_feedback_stats": fields.Nested(feedback_stat_model),
|
|
||||||
"admin_feedback_stats": fields.Nested(feedback_stat_model),
|
|
||||||
"message": fields.Nested(simple_message_detail_model, attribute="first_message"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
conversation_pagination_model = console_ns.model(
|
|
||||||
"ConversationPagination",
|
|
||||||
{
|
|
||||||
"page": fields.Integer,
|
|
||||||
"limit": fields.Integer(attribute="per_page"),
|
|
||||||
"total": fields.Integer,
|
|
||||||
"has_more": fields.Boolean(attribute="has_next"),
|
|
||||||
"data": fields.List(fields.Nested(conversation_fields_model), attribute="items"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
conversation_message_detail_model = console_ns.model(
|
|
||||||
"ConversationMessageDetail",
|
|
||||||
{
|
|
||||||
"id": fields.String,
|
|
||||||
"status": fields.String,
|
|
||||||
"from_source": fields.String,
|
|
||||||
"from_end_user_id": fields.String,
|
|
||||||
"from_account_id": fields.String,
|
|
||||||
"created_at": TimestampField,
|
|
||||||
"model_config": fields.Nested(model_config_model),
|
|
||||||
"message": fields.Nested(message_detail_model, attribute="first_message"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
conversation_with_summary_model = console_ns.model(
|
|
||||||
"ConversationWithSummary",
|
|
||||||
{
|
|
||||||
"id": fields.String,
|
|
||||||
"status": fields.String,
|
|
||||||
"from_source": fields.String,
|
|
||||||
"from_end_user_id": fields.String,
|
|
||||||
"from_end_user_session_id": fields.String,
|
|
||||||
"from_account_id": fields.String,
|
|
||||||
"from_account_name": fields.String,
|
|
||||||
"name": fields.String,
|
|
||||||
"summary": fields.String(attribute="summary_or_query"),
|
|
||||||
"read_at": TimestampField,
|
|
||||||
"created_at": TimestampField,
|
|
||||||
"updated_at": TimestampField,
|
|
||||||
"annotated": fields.Boolean,
|
|
||||||
"model_config": fields.Nested(simple_model_config_model),
|
|
||||||
"message_count": fields.Integer,
|
|
||||||
"user_feedback_stats": fields.Nested(feedback_stat_model),
|
|
||||||
"admin_feedback_stats": fields.Nested(feedback_stat_model),
|
|
||||||
"status_count": fields.Nested(status_count_model),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
conversation_with_summary_pagination_model = console_ns.model(
|
|
||||||
"ConversationWithSummaryPagination",
|
|
||||||
{
|
|
||||||
"page": fields.Integer,
|
|
||||||
"limit": fields.Integer(attribute="per_page"),
|
|
||||||
"total": fields.Integer,
|
|
||||||
"has_more": fields.Boolean(attribute="has_next"),
|
|
||||||
"data": fields.List(fields.Nested(conversation_with_summary_model), attribute="items"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
conversation_detail_model = console_ns.model(
|
|
||||||
"ConversationDetail",
|
|
||||||
{
|
|
||||||
"id": fields.String,
|
|
||||||
"status": fields.String,
|
|
||||||
"from_source": fields.String,
|
|
||||||
"from_end_user_id": fields.String,
|
|
||||||
"from_account_id": fields.String,
|
|
||||||
"created_at": TimestampField,
|
|
||||||
"updated_at": TimestampField,
|
|
||||||
"annotated": fields.Boolean,
|
|
||||||
"introduction": fields.String,
|
|
||||||
"model_config": fields.Nested(model_config_model),
|
|
||||||
"message_count": fields.Integer,
|
|
||||||
"user_feedback_stats": fields.Nested(feedback_stat_model),
|
|
||||||
"admin_feedback_stats": fields.Nested(feedback_stat_model),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -332,13 +98,12 @@ class CompletionConversationApi(Resource):
|
|||||||
@console_ns.doc(description="Get completion conversations with pagination and filtering")
|
@console_ns.doc(description="Get completion conversations with pagination and filtering")
|
||||||
@console_ns.doc(params={"app_id": "Application ID"})
|
@console_ns.doc(params={"app_id": "Application ID"})
|
||||||
@console_ns.expect(console_ns.models[CompletionConversationQuery.__name__])
|
@console_ns.expect(console_ns.models[CompletionConversationQuery.__name__])
|
||||||
@console_ns.response(200, "Success", conversation_pagination_model)
|
@console_ns.response(200, "Success", console_ns.models[ConversationPaginationResponse.__name__])
|
||||||
@console_ns.response(403, "Insufficient permissions")
|
@console_ns.response(403, "Insufficient permissions")
|
||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=AppMode.COMPLETION)
|
@get_app_model(mode=AppMode.COMPLETION)
|
||||||
@marshal_with(conversation_pagination_model)
|
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def get(self, app_model):
|
def get(self, app_model):
|
||||||
current_user, _ = current_account_with_tenant()
|
current_user, _ = current_account_with_tenant()
|
||||||
@@ -394,7 +159,9 @@ class CompletionConversationApi(Resource):
|
|||||||
|
|
||||||
conversations = db.paginate(query, page=args.page, per_page=args.limit, error_out=False)
|
conversations = db.paginate(query, page=args.page, per_page=args.limit, error_out=False)
|
||||||
|
|
||||||
return conversations
|
return ConversationPaginationResponse.model_validate(conversations, from_attributes=True).model_dump(
|
||||||
|
mode="json"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/apps/<uuid:app_id>/completion-conversations/<uuid:conversation_id>")
|
@console_ns.route("/apps/<uuid:app_id>/completion-conversations/<uuid:conversation_id>")
|
||||||
@@ -402,19 +169,19 @@ class CompletionConversationDetailApi(Resource):
|
|||||||
@console_ns.doc("get_completion_conversation")
|
@console_ns.doc("get_completion_conversation")
|
||||||
@console_ns.doc(description="Get completion conversation details with messages")
|
@console_ns.doc(description="Get completion conversation details with messages")
|
||||||
@console_ns.doc(params={"app_id": "Application ID", "conversation_id": "Conversation ID"})
|
@console_ns.doc(params={"app_id": "Application ID", "conversation_id": "Conversation ID"})
|
||||||
@console_ns.response(200, "Success", conversation_message_detail_model)
|
@console_ns.response(200, "Success", console_ns.models[ConversationMessageDetailResponse.__name__])
|
||||||
@console_ns.response(403, "Insufficient permissions")
|
@console_ns.response(403, "Insufficient permissions")
|
||||||
@console_ns.response(404, "Conversation not found")
|
@console_ns.response(404, "Conversation not found")
|
||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=AppMode.COMPLETION)
|
@get_app_model(mode=AppMode.COMPLETION)
|
||||||
@marshal_with(conversation_message_detail_model)
|
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def get(self, app_model, conversation_id):
|
def get(self, app_model, conversation_id):
|
||||||
conversation_id = str(conversation_id)
|
conversation_id = str(conversation_id)
|
||||||
|
return ConversationMessageDetailResponse.model_validate(
|
||||||
return _get_conversation(app_model, conversation_id)
|
_get_conversation(app_model, conversation_id), from_attributes=True
|
||||||
|
).model_dump(mode="json")
|
||||||
|
|
||||||
@console_ns.doc("delete_completion_conversation")
|
@console_ns.doc("delete_completion_conversation")
|
||||||
@console_ns.doc(description="Delete a completion conversation")
|
@console_ns.doc(description="Delete a completion conversation")
|
||||||
@@ -436,7 +203,7 @@ class CompletionConversationDetailApi(Resource):
|
|||||||
except ConversationNotExistsError:
|
except ConversationNotExistsError:
|
||||||
raise NotFound("Conversation Not Exists.")
|
raise NotFound("Conversation Not Exists.")
|
||||||
|
|
||||||
return {"result": "success"}, 204
|
return ResultResponse(result="success").model_dump(mode="json"), 204
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/apps/<uuid:app_id>/chat-conversations")
|
@console_ns.route("/apps/<uuid:app_id>/chat-conversations")
|
||||||
@@ -445,13 +212,12 @@ class ChatConversationApi(Resource):
|
|||||||
@console_ns.doc(description="Get chat conversations with pagination, filtering and summary")
|
@console_ns.doc(description="Get chat conversations with pagination, filtering and summary")
|
||||||
@console_ns.doc(params={"app_id": "Application ID"})
|
@console_ns.doc(params={"app_id": "Application ID"})
|
||||||
@console_ns.expect(console_ns.models[ChatConversationQuery.__name__])
|
@console_ns.expect(console_ns.models[ChatConversationQuery.__name__])
|
||||||
@console_ns.response(200, "Success", conversation_with_summary_pagination_model)
|
@console_ns.response(200, "Success", console_ns.models[ConversationWithSummaryPaginationResponse.__name__])
|
||||||
@console_ns.response(403, "Insufficient permissions")
|
@console_ns.response(403, "Insufficient permissions")
|
||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||||
@marshal_with(conversation_with_summary_pagination_model)
|
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def get(self, app_model):
|
def get(self, app_model):
|
||||||
current_user, _ = current_account_with_tenant()
|
current_user, _ = current_account_with_tenant()
|
||||||
@@ -546,7 +312,9 @@ class ChatConversationApi(Resource):
|
|||||||
|
|
||||||
conversations = db.paginate(query, page=args.page, per_page=args.limit, error_out=False)
|
conversations = db.paginate(query, page=args.page, per_page=args.limit, error_out=False)
|
||||||
|
|
||||||
return conversations
|
return ConversationWithSummaryPaginationResponse.model_validate(conversations, from_attributes=True).model_dump(
|
||||||
|
mode="json"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/apps/<uuid:app_id>/chat-conversations/<uuid:conversation_id>")
|
@console_ns.route("/apps/<uuid:app_id>/chat-conversations/<uuid:conversation_id>")
|
||||||
@@ -554,19 +322,19 @@ class ChatConversationDetailApi(Resource):
|
|||||||
@console_ns.doc("get_chat_conversation")
|
@console_ns.doc("get_chat_conversation")
|
||||||
@console_ns.doc(description="Get chat conversation details")
|
@console_ns.doc(description="Get chat conversation details")
|
||||||
@console_ns.doc(params={"app_id": "Application ID", "conversation_id": "Conversation ID"})
|
@console_ns.doc(params={"app_id": "Application ID", "conversation_id": "Conversation ID"})
|
||||||
@console_ns.response(200, "Success", conversation_detail_model)
|
@console_ns.response(200, "Success", console_ns.models[ConversationDetailResponse.__name__])
|
||||||
@console_ns.response(403, "Insufficient permissions")
|
@console_ns.response(403, "Insufficient permissions")
|
||||||
@console_ns.response(404, "Conversation not found")
|
@console_ns.response(404, "Conversation not found")
|
||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||||
@marshal_with(conversation_detail_model)
|
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def get(self, app_model, conversation_id):
|
def get(self, app_model, conversation_id):
|
||||||
conversation_id = str(conversation_id)
|
conversation_id = str(conversation_id)
|
||||||
|
return ConversationDetailResponse.model_validate(
|
||||||
return _get_conversation(app_model, conversation_id)
|
_get_conversation(app_model, conversation_id), from_attributes=True
|
||||||
|
).model_dump(mode="json")
|
||||||
|
|
||||||
@console_ns.doc("delete_chat_conversation")
|
@console_ns.doc("delete_chat_conversation")
|
||||||
@console_ns.doc(description="Delete a chat conversation")
|
@console_ns.doc(description="Delete a chat conversation")
|
||||||
@@ -588,7 +356,7 @@ class ChatConversationDetailApi(Resource):
|
|||||||
except ConversationNotExistsError:
|
except ConversationNotExistsError:
|
||||||
raise NotFound("Conversation Not Exists.")
|
raise NotFound("Conversation Not Exists.")
|
||||||
|
|
||||||
return {"result": "success"}, 204
|
return ResultResponse(result="success").model_dump(mode="json"), 204
|
||||||
|
|
||||||
|
|
||||||
def _get_conversation(app_model, conversation_id):
|
def _get_conversation(app_model, conversation_id):
|
||||||
|
|||||||
@@ -1,44 +1,86 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask_restx import Resource, fields, marshal_with
|
from flask_restx import Resource
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from controllers.common.schema import register_schema_models
|
||||||
from controllers.console import console_ns
|
from controllers.console import console_ns
|
||||||
from controllers.console.app.wraps import get_app_model
|
from controllers.console.app.wraps import get_app_model
|
||||||
from controllers.console.wraps import account_initialization_required, setup_required
|
from controllers.console.wraps import account_initialization_required, setup_required
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from fields.conversation_variable_fields import (
|
from fields._value_type_serializer import serialize_value_type
|
||||||
conversation_variable_fields,
|
from fields.base import ResponseModel
|
||||||
paginated_conversation_variable_fields,
|
|
||||||
)
|
|
||||||
from libs.login import login_required
|
from libs.login import login_required
|
||||||
from models import ConversationVariable
|
from models import ConversationVariable
|
||||||
from models.model import AppMode
|
from models.model import AppMode
|
||||||
|
|
||||||
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
|
|
||||||
|
|
||||||
|
|
||||||
class ConversationVariablesQuery(BaseModel):
|
class ConversationVariablesQuery(BaseModel):
|
||||||
conversation_id: str = Field(..., description="Conversation ID to filter variables")
|
conversation_id: str = Field(..., description="Conversation ID to filter variables")
|
||||||
|
|
||||||
|
|
||||||
console_ns.schema_model(
|
def _to_timestamp(value: datetime | int | None) -> int | None:
|
||||||
ConversationVariablesQuery.__name__,
|
if isinstance(value, datetime):
|
||||||
ConversationVariablesQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
|
return int(value.timestamp())
|
||||||
)
|
return value
|
||||||
|
|
||||||
# Register models for flask_restx to avoid dict type issues in Swagger
|
|
||||||
# Register base model first
|
|
||||||
conversation_variable_model = console_ns.model("ConversationVariable", conversation_variable_fields)
|
|
||||||
|
|
||||||
# For nested models, need to replace nested dict with registered model
|
class ConversationVariableResponse(ResponseModel):
|
||||||
paginated_conversation_variable_fields_copy = paginated_conversation_variable_fields.copy()
|
id: str
|
||||||
paginated_conversation_variable_fields_copy["data"] = fields.List(
|
name: str
|
||||||
fields.Nested(conversation_variable_model), attribute="data"
|
value_type: str
|
||||||
)
|
value: str | None = None
|
||||||
paginated_conversation_variable_model = console_ns.model(
|
description: str | None = None
|
||||||
"PaginatedConversationVariable", paginated_conversation_variable_fields_copy
|
created_at: int | None = None
|
||||||
|
updated_at: int | None = None
|
||||||
|
|
||||||
|
@field_validator("value_type", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_value_type(cls, value: Any) -> str:
|
||||||
|
exposed_type = getattr(value, "exposed_type", None)
|
||||||
|
if callable(exposed_type):
|
||||||
|
return str(exposed_type())
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
try:
|
||||||
|
return serialize_value_type(value)
|
||||||
|
except Exception:
|
||||||
|
return serialize_value_type({"value_type": value})
|
||||||
|
|
||||||
|
@field_validator("value", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_value(cls, value: Any | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
@field_validator("created_at", "updated_at", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
||||||
|
return _to_timestamp(value)
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedConversationVariableResponse(ResponseModel):
|
||||||
|
page: int
|
||||||
|
limit: int
|
||||||
|
total: int
|
||||||
|
has_more: bool
|
||||||
|
data: list[ConversationVariableResponse]
|
||||||
|
|
||||||
|
|
||||||
|
register_schema_models(
|
||||||
|
console_ns,
|
||||||
|
ConversationVariablesQuery,
|
||||||
|
ConversationVariableResponse,
|
||||||
|
PaginatedConversationVariableResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -48,12 +90,15 @@ class ConversationVariablesApi(Resource):
|
|||||||
@console_ns.doc(description="Get conversation variables for an application")
|
@console_ns.doc(description="Get conversation variables for an application")
|
||||||
@console_ns.doc(params={"app_id": "Application ID"})
|
@console_ns.doc(params={"app_id": "Application ID"})
|
||||||
@console_ns.expect(console_ns.models[ConversationVariablesQuery.__name__])
|
@console_ns.expect(console_ns.models[ConversationVariablesQuery.__name__])
|
||||||
@console_ns.response(200, "Conversation variables retrieved successfully", paginated_conversation_variable_model)
|
@console_ns.response(
|
||||||
|
200,
|
||||||
|
"Conversation variables retrieved successfully",
|
||||||
|
console_ns.models[PaginatedConversationVariableResponse.__name__],
|
||||||
|
)
|
||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=AppMode.ADVANCED_CHAT)
|
@get_app_model(mode=AppMode.ADVANCED_CHAT)
|
||||||
@marshal_with(paginated_conversation_variable_model)
|
|
||||||
def get(self, app_model):
|
def get(self, app_model):
|
||||||
args = ConversationVariablesQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
args = ConversationVariablesQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
||||||
|
|
||||||
@@ -72,17 +117,22 @@ class ConversationVariablesApi(Resource):
|
|||||||
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
|
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
|
||||||
rows = session.scalars(stmt).all()
|
rows = session.scalars(stmt).all()
|
||||||
|
|
||||||
return {
|
response = PaginatedConversationVariableResponse.model_validate(
|
||||||
"page": page,
|
{
|
||||||
"limit": page_size,
|
"page": page,
|
||||||
"total": len(rows),
|
"limit": page_size,
|
||||||
"has_more": False,
|
"total": len(rows),
|
||||||
"data": [
|
"has_more": False,
|
||||||
{
|
"data": [
|
||||||
"created_at": row.created_at,
|
ConversationVariableResponse.model_validate(
|
||||||
"updated_at": row.updated_at,
|
{
|
||||||
**row.to_variable().model_dump(),
|
"created_at": row.created_at,
|
||||||
}
|
"updated_at": row.updated_at,
|
||||||
for row in rows
|
**row.to_variable().model_dump(),
|
||||||
],
|
}
|
||||||
}
|
)
|
||||||
|
for row in rows
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return response.model_dump(mode="json")
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
|
||||||
from flask_restx import Resource
|
from flask_restx import Resource
|
||||||
from graphon.model_runtime.errors.invoke import InvokeError
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from controllers.console import console_ns
|
from controllers.console import console_ns
|
||||||
@@ -20,6 +19,7 @@ from core.helper.code_executor.python3.python3_code_provider import Python3CodeP
|
|||||||
from core.llm_generator.entities import RuleCodeGeneratePayload, RuleGeneratePayload, RuleStructuredOutputPayload
|
from core.llm_generator.entities import RuleCodeGeneratePayload, RuleGeneratePayload, RuleStructuredOutputPayload
|
||||||
from core.llm_generator.llm_generator import LLMGenerator
|
from core.llm_generator.llm_generator import LLMGenerator
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
|
from graphon.model_runtime.errors.invoke import InvokeError
|
||||||
from libs.login import current_account_with_tenant, login_required
|
from libs.login import current_account_with_tenant, login_required
|
||||||
from models import App
|
from models import App
|
||||||
from services.workflow_service import WorkflowService
|
from services.workflow_service import WorkflowService
|
||||||
|
|||||||
@@ -1,39 +1,68 @@
|
|||||||
import json
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from flask_restx import Resource, marshal_with
|
from flask_restx import Resource
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from werkzeug.exceptions import NotFound
|
from werkzeug.exceptions import NotFound
|
||||||
|
|
||||||
|
from controllers.common.schema import register_schema_models
|
||||||
from controllers.console import console_ns
|
from controllers.console import console_ns
|
||||||
from controllers.console.app.wraps import get_app_model
|
from controllers.console.app.wraps import get_app_model
|
||||||
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
|
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from fields.app_fields import app_server_fields
|
from fields.base import ResponseModel
|
||||||
from libs.login import current_account_with_tenant, login_required
|
from libs.login import current_account_with_tenant, login_required
|
||||||
from models.enums import AppMCPServerStatus
|
from models.enums import AppMCPServerStatus
|
||||||
from models.model import AppMCPServer
|
from models.model import AppMCPServer
|
||||||
|
|
||||||
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
|
|
||||||
|
|
||||||
# Register model for flask_restx to avoid dict type issues in Swagger
|
|
||||||
app_server_model = console_ns.model("AppServer", app_server_fields)
|
|
||||||
|
|
||||||
|
|
||||||
class MCPServerCreatePayload(BaseModel):
|
class MCPServerCreatePayload(BaseModel):
|
||||||
description: str | None = Field(default=None, description="Server description")
|
description: str | None = Field(default=None, description="Server description")
|
||||||
parameters: dict = Field(..., description="Server parameters configuration")
|
parameters: dict[str, Any] = Field(..., description="Server parameters configuration")
|
||||||
|
|
||||||
|
|
||||||
class MCPServerUpdatePayload(BaseModel):
|
class MCPServerUpdatePayload(BaseModel):
|
||||||
id: str = Field(..., description="Server ID")
|
id: str = Field(..., description="Server ID")
|
||||||
description: str | None = Field(default=None, description="Server description")
|
description: str | None = Field(default=None, description="Server description")
|
||||||
parameters: dict = Field(..., description="Server parameters configuration")
|
parameters: dict[str, Any] = Field(..., description="Server parameters configuration")
|
||||||
status: str | None = Field(default=None, description="Server status")
|
status: str | None = Field(default=None, description="Server status")
|
||||||
|
|
||||||
|
|
||||||
for model in (MCPServerCreatePayload, MCPServerUpdatePayload):
|
def _to_timestamp(value: datetime | int | None) -> int | None:
|
||||||
console_ns.schema_model(model.__name__, model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0))
|
if isinstance(value, datetime):
|
||||||
|
return int(value.timestamp())
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class AppMCPServerResponse(ResponseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
server_code: str
|
||||||
|
description: str
|
||||||
|
status: AppMCPServerStatus
|
||||||
|
parameters: dict[str, Any] | list[Any] | str
|
||||||
|
created_at: int | None = None
|
||||||
|
updated_at: int | None = None
|
||||||
|
|
||||||
|
@field_validator("parameters", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_parameters(cls, value: Any) -> Any:
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
return json.loads(value)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return value
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("created_at", "updated_at", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
||||||
|
return _to_timestamp(value)
|
||||||
|
|
||||||
|
|
||||||
|
register_schema_models(console_ns, MCPServerCreatePayload, MCPServerUpdatePayload, AppMCPServerResponse)
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/apps/<uuid:app_id>/server")
|
@console_ns.route("/apps/<uuid:app_id>/server")
|
||||||
@@ -41,27 +70,31 @@ class AppMCPServerController(Resource):
|
|||||||
@console_ns.doc("get_app_mcp_server")
|
@console_ns.doc("get_app_mcp_server")
|
||||||
@console_ns.doc(description="Get MCP server configuration for an application")
|
@console_ns.doc(description="Get MCP server configuration for an application")
|
||||||
@console_ns.doc(params={"app_id": "Application ID"})
|
@console_ns.doc(params={"app_id": "Application ID"})
|
||||||
@console_ns.response(200, "MCP server configuration retrieved successfully", app_server_model)
|
@console_ns.response(
|
||||||
|
200, "MCP server configuration retrieved successfully", console_ns.models[AppMCPServerResponse.__name__]
|
||||||
|
)
|
||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@setup_required
|
@setup_required
|
||||||
@get_app_model
|
@get_app_model
|
||||||
@marshal_with(app_server_model)
|
|
||||||
def get(self, app_model):
|
def get(self, app_model):
|
||||||
server = db.session.scalar(select(AppMCPServer).where(AppMCPServer.app_id == app_model.id).limit(1))
|
server = db.session.scalar(select(AppMCPServer).where(AppMCPServer.app_id == app_model.id).limit(1))
|
||||||
return server
|
if server is None:
|
||||||
|
return {}
|
||||||
|
return AppMCPServerResponse.model_validate(server, from_attributes=True).model_dump(mode="json")
|
||||||
|
|
||||||
@console_ns.doc("create_app_mcp_server")
|
@console_ns.doc("create_app_mcp_server")
|
||||||
@console_ns.doc(description="Create MCP server configuration for an application")
|
@console_ns.doc(description="Create MCP server configuration for an application")
|
||||||
@console_ns.doc(params={"app_id": "Application ID"})
|
@console_ns.doc(params={"app_id": "Application ID"})
|
||||||
@console_ns.expect(console_ns.models[MCPServerCreatePayload.__name__])
|
@console_ns.expect(console_ns.models[MCPServerCreatePayload.__name__])
|
||||||
@console_ns.response(201, "MCP server configuration created successfully", app_server_model)
|
@console_ns.response(
|
||||||
|
201, "MCP server configuration created successfully", console_ns.models[AppMCPServerResponse.__name__]
|
||||||
|
)
|
||||||
@console_ns.response(403, "Insufficient permissions")
|
@console_ns.response(403, "Insufficient permissions")
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model
|
@get_app_model
|
||||||
@login_required
|
@login_required
|
||||||
@setup_required
|
@setup_required
|
||||||
@marshal_with(app_server_model)
|
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def post(self, app_model):
|
def post(self, app_model):
|
||||||
_, current_tenant_id = current_account_with_tenant()
|
_, current_tenant_id = current_account_with_tenant()
|
||||||
@@ -82,20 +115,21 @@ class AppMCPServerController(Resource):
|
|||||||
)
|
)
|
||||||
db.session.add(server)
|
db.session.add(server)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return server
|
return AppMCPServerResponse.model_validate(server, from_attributes=True).model_dump(mode="json"), 201
|
||||||
|
|
||||||
@console_ns.doc("update_app_mcp_server")
|
@console_ns.doc("update_app_mcp_server")
|
||||||
@console_ns.doc(description="Update MCP server configuration for an application")
|
@console_ns.doc(description="Update MCP server configuration for an application")
|
||||||
@console_ns.doc(params={"app_id": "Application ID"})
|
@console_ns.doc(params={"app_id": "Application ID"})
|
||||||
@console_ns.expect(console_ns.models[MCPServerUpdatePayload.__name__])
|
@console_ns.expect(console_ns.models[MCPServerUpdatePayload.__name__])
|
||||||
@console_ns.response(200, "MCP server configuration updated successfully", app_server_model)
|
@console_ns.response(
|
||||||
|
200, "MCP server configuration updated successfully", console_ns.models[AppMCPServerResponse.__name__]
|
||||||
|
)
|
||||||
@console_ns.response(403, "Insufficient permissions")
|
@console_ns.response(403, "Insufficient permissions")
|
||||||
@console_ns.response(404, "Server not found")
|
@console_ns.response(404, "Server not found")
|
||||||
@get_app_model
|
@get_app_model
|
||||||
@login_required
|
@login_required
|
||||||
@setup_required
|
@setup_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@marshal_with(app_server_model)
|
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def put(self, app_model):
|
def put(self, app_model):
|
||||||
payload = MCPServerUpdatePayload.model_validate(console_ns.payload or {})
|
payload = MCPServerUpdatePayload.model_validate(console_ns.payload or {})
|
||||||
@@ -118,7 +152,7 @@ class AppMCPServerController(Resource):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValueError("Invalid status")
|
raise ValueError("Invalid status")
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return server
|
return AppMCPServerResponse.model_validate(server, from_attributes=True).model_dump(mode="json")
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/apps/<uuid:server_id>/server/refresh")
|
@console_ns.route("/apps/<uuid:server_id>/server/refresh")
|
||||||
@@ -126,13 +160,12 @@ class AppMCPServerRefreshController(Resource):
|
|||||||
@console_ns.doc("refresh_app_mcp_server")
|
@console_ns.doc("refresh_app_mcp_server")
|
||||||
@console_ns.doc(description="Refresh MCP server configuration and regenerate server code")
|
@console_ns.doc(description="Refresh MCP server configuration and regenerate server code")
|
||||||
@console_ns.doc(params={"server_id": "Server ID"})
|
@console_ns.doc(params={"server_id": "Server ID"})
|
||||||
@console_ns.response(200, "MCP server refreshed successfully", app_server_model)
|
@console_ns.response(200, "MCP server refreshed successfully", console_ns.models[AppMCPServerResponse.__name__])
|
||||||
@console_ns.response(403, "Insufficient permissions")
|
@console_ns.response(403, "Insufficient permissions")
|
||||||
@console_ns.response(404, "Server not found")
|
@console_ns.response(404, "Server not found")
|
||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@marshal_with(app_server_model)
|
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def get(self, server_id):
|
def get(self, server_id):
|
||||||
_, current_tenant_id = current_account_with_tenant()
|
_, current_tenant_id = current_account_with_tenant()
|
||||||
@@ -145,4 +178,4 @@ class AppMCPServerRefreshController(Resource):
|
|||||||
raise NotFound()
|
raise NotFound()
|
||||||
server.server_code = AppMCPServer.generate_server_code(16)
|
server.server_code = AppMCPServer.generate_server_code(16)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return server
|
return AppMCPServerResponse.model_validate(server, from_attributes=True).model_dump(mode="json")
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask_restx import Resource, fields, marshal_with
|
from flask_restx import Resource
|
||||||
from graphon.model_runtime.errors.invoke import InvokeError
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from sqlalchemy import exists, func, select
|
from sqlalchemy import exists, func, select
|
||||||
from werkzeug.exceptions import InternalServerError, NotFound
|
from werkzeug.exceptions import InternalServerError, NotFound
|
||||||
|
|
||||||
|
from controllers.common.controller_schemas import MessageFeedbackPayload as _MessageFeedbackPayloadBase
|
||||||
from controllers.common.schema import register_schema_models
|
from controllers.common.schema import register_schema_models
|
||||||
from controllers.console import console_ns
|
from controllers.console import console_ns
|
||||||
from controllers.console.app.error import (
|
from controllers.console.app.error import (
|
||||||
@@ -24,10 +25,22 @@ from controllers.console.wraps import (
|
|||||||
setup_required,
|
setup_required,
|
||||||
)
|
)
|
||||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||||
|
from core.entities.execution_extra_content import ExecutionExtraContentDomainModel
|
||||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from fields.raws import FilesContainedField
|
from fields.base import ResponseModel
|
||||||
from libs.helper import TimestampField, uuid_value
|
from fields.conversation_fields import (
|
||||||
|
AgentThought,
|
||||||
|
ConversationAnnotation,
|
||||||
|
ConversationAnnotationHitHistory,
|
||||||
|
Feedback,
|
||||||
|
JSONValue,
|
||||||
|
MessageFile,
|
||||||
|
format_files_contained,
|
||||||
|
to_timestamp,
|
||||||
|
)
|
||||||
|
from graphon.model_runtime.errors.invoke import InvokeError
|
||||||
|
from libs.helper import uuid_value
|
||||||
from libs.infinite_scroll_pagination import InfiniteScrollPagination
|
from libs.infinite_scroll_pagination import InfiniteScrollPagination
|
||||||
from libs.login import current_account_with_tenant, login_required
|
from libs.login import current_account_with_tenant, login_required
|
||||||
from models.enums import FeedbackFromSource, FeedbackRating
|
from models.enums import FeedbackFromSource, FeedbackRating
|
||||||
@@ -59,10 +72,8 @@ class ChatMessagesQuery(BaseModel):
|
|||||||
return uuid_value(value)
|
return uuid_value(value)
|
||||||
|
|
||||||
|
|
||||||
class MessageFeedbackPayload(BaseModel):
|
class MessageFeedbackPayload(_MessageFeedbackPayloadBase):
|
||||||
message_id: str = Field(..., description="Message ID")
|
message_id: str = Field(..., description="Message ID")
|
||||||
rating: Literal["like", "dislike"] | None = Field(default=None, description="Feedback rating")
|
|
||||||
content: str | None = Field(default=None, description="Feedback content")
|
|
||||||
|
|
||||||
@field_validator("message_id")
|
@field_validator("message_id")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -99,6 +110,51 @@ class SuggestedQuestionsResponse(BaseModel):
|
|||||||
data: list[str] = Field(description="Suggested question")
|
data: list[str] = Field(description="Suggested question")
|
||||||
|
|
||||||
|
|
||||||
|
class MessageDetailResponse(ResponseModel):
|
||||||
|
id: str
|
||||||
|
conversation_id: str
|
||||||
|
inputs: dict[str, JSONValue]
|
||||||
|
query: str
|
||||||
|
message: JSONValue | None = None
|
||||||
|
message_tokens: int | None = None
|
||||||
|
answer: str = Field(validation_alias="re_sign_file_url_answer")
|
||||||
|
answer_tokens: int | None = None
|
||||||
|
provider_response_latency: float | None = None
|
||||||
|
from_source: str
|
||||||
|
from_end_user_id: str | None = None
|
||||||
|
from_account_id: str | None = None
|
||||||
|
feedbacks: list[Feedback] = Field(default_factory=list)
|
||||||
|
workflow_run_id: str | None = None
|
||||||
|
annotation: ConversationAnnotation | None = None
|
||||||
|
annotation_hit_history: ConversationAnnotationHitHistory | None = None
|
||||||
|
created_at: int | None = None
|
||||||
|
agent_thoughts: list[AgentThought] = Field(default_factory=list)
|
||||||
|
message_files: list[MessageFile] = Field(default_factory=list)
|
||||||
|
extra_contents: list[ExecutionExtraContentDomainModel] = Field(default_factory=list)
|
||||||
|
metadata: JSONValue | None = Field(default=None, validation_alias="message_metadata_dict")
|
||||||
|
status: str
|
||||||
|
error: str | None = None
|
||||||
|
parent_message_id: str | None = None
|
||||||
|
|
||||||
|
@field_validator("inputs", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_inputs(cls, value: JSONValue) -> JSONValue:
|
||||||
|
return format_files_contained(value)
|
||||||
|
|
||||||
|
@field_validator("created_at", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_created_at(cls, value: datetime | int | None) -> int | None:
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return to_timestamp(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class MessageInfiniteScrollPaginationResponse(ResponseModel):
|
||||||
|
limit: int
|
||||||
|
has_more: bool
|
||||||
|
data: list[MessageDetailResponse]
|
||||||
|
|
||||||
|
|
||||||
register_schema_models(
|
register_schema_models(
|
||||||
console_ns,
|
console_ns,
|
||||||
ChatMessagesQuery,
|
ChatMessagesQuery,
|
||||||
@@ -106,124 +162,8 @@ register_schema_models(
|
|||||||
FeedbackExportQuery,
|
FeedbackExportQuery,
|
||||||
AnnotationCountResponse,
|
AnnotationCountResponse,
|
||||||
SuggestedQuestionsResponse,
|
SuggestedQuestionsResponse,
|
||||||
)
|
MessageDetailResponse,
|
||||||
|
MessageInfiniteScrollPaginationResponse,
|
||||||
# Register models for flask_restx to avoid dict type issues in Swagger
|
|
||||||
# Register in dependency order: base models first, then dependent models
|
|
||||||
|
|
||||||
# Base models
|
|
||||||
simple_account_model = console_ns.model(
|
|
||||||
"SimpleAccount",
|
|
||||||
{
|
|
||||||
"id": fields.String,
|
|
||||||
"name": fields.String,
|
|
||||||
"email": fields.String,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
message_file_model = console_ns.model(
|
|
||||||
"MessageFile",
|
|
||||||
{
|
|
||||||
"id": fields.String,
|
|
||||||
"filename": fields.String,
|
|
||||||
"type": fields.String,
|
|
||||||
"url": fields.String,
|
|
||||||
"mime_type": fields.String,
|
|
||||||
"size": fields.Integer,
|
|
||||||
"transfer_method": fields.String,
|
|
||||||
"belongs_to": fields.String(default="user"),
|
|
||||||
"upload_file_id": fields.String(default=None),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
agent_thought_model = console_ns.model(
|
|
||||||
"AgentThought",
|
|
||||||
{
|
|
||||||
"id": fields.String,
|
|
||||||
"chain_id": fields.String,
|
|
||||||
"message_id": fields.String,
|
|
||||||
"position": fields.Integer,
|
|
||||||
"thought": fields.String,
|
|
||||||
"tool": fields.String,
|
|
||||||
"tool_labels": fields.Raw,
|
|
||||||
"tool_input": fields.String,
|
|
||||||
"created_at": TimestampField,
|
|
||||||
"observation": fields.String,
|
|
||||||
"files": fields.List(fields.String),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Models that depend on simple_account_model
|
|
||||||
feedback_model = console_ns.model(
|
|
||||||
"Feedback",
|
|
||||||
{
|
|
||||||
"rating": fields.String,
|
|
||||||
"content": fields.String,
|
|
||||||
"from_source": fields.String,
|
|
||||||
"from_end_user_id": fields.String,
|
|
||||||
"from_account": fields.Nested(simple_account_model, allow_null=True),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
annotation_model = console_ns.model(
|
|
||||||
"Annotation",
|
|
||||||
{
|
|
||||||
"id": fields.String,
|
|
||||||
"question": fields.String,
|
|
||||||
"content": fields.String,
|
|
||||||
"account": fields.Nested(simple_account_model, allow_null=True),
|
|
||||||
"created_at": TimestampField,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
annotation_hit_history_model = console_ns.model(
|
|
||||||
"AnnotationHitHistory",
|
|
||||||
{
|
|
||||||
"annotation_id": fields.String(attribute="id"),
|
|
||||||
"annotation_create_account": fields.Nested(simple_account_model, allow_null=True),
|
|
||||||
"created_at": TimestampField,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Message detail model that depends on multiple models
|
|
||||||
message_detail_model = console_ns.model(
|
|
||||||
"MessageDetail",
|
|
||||||
{
|
|
||||||
"id": fields.String,
|
|
||||||
"conversation_id": fields.String,
|
|
||||||
"inputs": FilesContainedField,
|
|
||||||
"query": fields.String,
|
|
||||||
"message": fields.Raw,
|
|
||||||
"message_tokens": fields.Integer,
|
|
||||||
"answer": fields.String(attribute="re_sign_file_url_answer"),
|
|
||||||
"answer_tokens": fields.Integer,
|
|
||||||
"provider_response_latency": fields.Float,
|
|
||||||
"from_source": fields.String,
|
|
||||||
"from_end_user_id": fields.String,
|
|
||||||
"from_account_id": fields.String,
|
|
||||||
"feedbacks": fields.List(fields.Nested(feedback_model)),
|
|
||||||
"workflow_run_id": fields.String,
|
|
||||||
"annotation": fields.Nested(annotation_model, allow_null=True),
|
|
||||||
"annotation_hit_history": fields.Nested(annotation_hit_history_model, allow_null=True),
|
|
||||||
"created_at": TimestampField,
|
|
||||||
"agent_thoughts": fields.List(fields.Nested(agent_thought_model)),
|
|
||||||
"message_files": fields.List(fields.Nested(message_file_model)),
|
|
||||||
"extra_contents": fields.List(fields.Raw),
|
|
||||||
"metadata": fields.Raw(attribute="message_metadata_dict"),
|
|
||||||
"status": fields.String,
|
|
||||||
"error": fields.String,
|
|
||||||
"parent_message_id": fields.String,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Message infinite scroll pagination model
|
|
||||||
message_infinite_scroll_pagination_model = console_ns.model(
|
|
||||||
"MessageInfiniteScrollPagination",
|
|
||||||
{
|
|
||||||
"limit": fields.Integer,
|
|
||||||
"has_more": fields.Boolean,
|
|
||||||
"data": fields.List(fields.Nested(message_detail_model)),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -233,13 +173,12 @@ class ChatMessageListApi(Resource):
|
|||||||
@console_ns.doc(description="Get chat messages for a conversation with pagination")
|
@console_ns.doc(description="Get chat messages for a conversation with pagination")
|
||||||
@console_ns.doc(params={"app_id": "Application ID"})
|
@console_ns.doc(params={"app_id": "Application ID"})
|
||||||
@console_ns.expect(console_ns.models[ChatMessagesQuery.__name__])
|
@console_ns.expect(console_ns.models[ChatMessagesQuery.__name__])
|
||||||
@console_ns.response(200, "Success", message_infinite_scroll_pagination_model)
|
@console_ns.response(200, "Success", console_ns.models[MessageInfiniteScrollPaginationResponse.__name__])
|
||||||
@console_ns.response(404, "Conversation not found")
|
@console_ns.response(404, "Conversation not found")
|
||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@setup_required
|
@setup_required
|
||||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||||
@marshal_with(message_infinite_scroll_pagination_model)
|
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def get(self, app_model):
|
def get(self, app_model):
|
||||||
args = ChatMessagesQuery.model_validate(request.args.to_dict())
|
args = ChatMessagesQuery.model_validate(request.args.to_dict())
|
||||||
@@ -299,7 +238,10 @@ class ChatMessageListApi(Resource):
|
|||||||
history_messages = list(reversed(history_messages))
|
history_messages = list(reversed(history_messages))
|
||||||
attach_message_extra_contents(history_messages)
|
attach_message_extra_contents(history_messages)
|
||||||
|
|
||||||
return InfiniteScrollPagination(data=history_messages, limit=args.limit, has_more=has_more)
|
return MessageInfiniteScrollPaginationResponse.model_validate(
|
||||||
|
InfiniteScrollPagination(data=history_messages, limit=args.limit, has_more=has_more),
|
||||||
|
from_attributes=True,
|
||||||
|
).model_dump(mode="json")
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/apps/<uuid:app_id>/feedbacks")
|
@console_ns.route("/apps/<uuid:app_id>/feedbacks")
|
||||||
@@ -469,13 +411,12 @@ class MessageApi(Resource):
|
|||||||
@console_ns.doc("get_message")
|
@console_ns.doc("get_message")
|
||||||
@console_ns.doc(description="Get message details by ID")
|
@console_ns.doc(description="Get message details by ID")
|
||||||
@console_ns.doc(params={"app_id": "Application ID", "message_id": "Message ID"})
|
@console_ns.doc(params={"app_id": "Application ID", "message_id": "Message ID"})
|
||||||
@console_ns.response(200, "Message retrieved successfully", message_detail_model)
|
@console_ns.response(200, "Message retrieved successfully", console_ns.models[MessageDetailResponse.__name__])
|
||||||
@console_ns.response(404, "Message not found")
|
@console_ns.response(404, "Message not found")
|
||||||
@get_app_model
|
@get_app_model
|
||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@marshal_with(message_detail_model)
|
|
||||||
def get(self, app_model, message_id: str):
|
def get(self, app_model, message_id: str):
|
||||||
message_id = str(message_id)
|
message_id = str(message_id)
|
||||||
|
|
||||||
@@ -487,4 +428,4 @@ class MessageApi(Resource):
|
|||||||
raise NotFound("Message Not Exists.")
|
raise NotFound("Message Not Exists.")
|
||||||
|
|
||||||
attach_message_extra_contents([message])
|
attach_message_extra_contents([message])
|
||||||
return message
|
return MessageDetailResponse.model_validate(message, from_attributes=True).model_dump(mode="json")
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import json
|
import json
|
||||||
from typing import cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask_restx import Resource, fields
|
from flask_restx import Resource
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from controllers.common.schema import register_schema_models
|
||||||
from controllers.console import console_ns
|
from controllers.console import console_ns
|
||||||
from controllers.console.app.wraps import get_app_model
|
from controllers.console.app.wraps import get_app_model
|
||||||
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
|
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
|
||||||
@@ -18,30 +20,30 @@ from models.model import AppMode, AppModelConfig
|
|||||||
from services.app_model_config_service import AppModelConfigService
|
from services.app_model_config_service import AppModelConfigService
|
||||||
|
|
||||||
|
|
||||||
|
class ModelConfigRequest(BaseModel):
|
||||||
|
provider: str | None = Field(default=None, description="Model provider")
|
||||||
|
model: str | None = Field(default=None, description="Model name")
|
||||||
|
configs: dict[str, Any] | None = Field(default=None, description="Model configuration parameters")
|
||||||
|
opening_statement: str | None = Field(default=None, description="Opening statement")
|
||||||
|
suggested_questions: list[str] | None = Field(default=None, description="Suggested questions")
|
||||||
|
more_like_this: dict[str, Any] | None = Field(default=None, description="More like this configuration")
|
||||||
|
speech_to_text: dict[str, Any] | None = Field(default=None, description="Speech to text configuration")
|
||||||
|
text_to_speech: dict[str, Any] | None = Field(default=None, description="Text to speech configuration")
|
||||||
|
retrieval_model: dict[str, Any] | None = Field(default=None, description="Retrieval model configuration")
|
||||||
|
tools: list[dict[str, Any]] | None = Field(default=None, description="Available tools")
|
||||||
|
dataset_configs: dict[str, Any] | None = Field(default=None, description="Dataset configurations")
|
||||||
|
agent_mode: dict[str, Any] | None = Field(default=None, description="Agent mode configuration")
|
||||||
|
|
||||||
|
|
||||||
|
register_schema_models(console_ns, ModelConfigRequest)
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/apps/<uuid:app_id>/model-config")
|
@console_ns.route("/apps/<uuid:app_id>/model-config")
|
||||||
class ModelConfigResource(Resource):
|
class ModelConfigResource(Resource):
|
||||||
@console_ns.doc("update_app_model_config")
|
@console_ns.doc("update_app_model_config")
|
||||||
@console_ns.doc(description="Update application model configuration")
|
@console_ns.doc(description="Update application model configuration")
|
||||||
@console_ns.doc(params={"app_id": "Application ID"})
|
@console_ns.doc(params={"app_id": "Application ID"})
|
||||||
@console_ns.expect(
|
@console_ns.expect(console_ns.models[ModelConfigRequest.__name__])
|
||||||
console_ns.model(
|
|
||||||
"ModelConfigRequest",
|
|
||||||
{
|
|
||||||
"provider": fields.String(description="Model provider"),
|
|
||||||
"model": fields.String(description="Model name"),
|
|
||||||
"configs": fields.Raw(description="Model configuration parameters"),
|
|
||||||
"opening_statement": fields.String(description="Opening statement"),
|
|
||||||
"suggested_questions": fields.List(fields.String(), description="Suggested questions"),
|
|
||||||
"more_like_this": fields.Raw(description="More like this configuration"),
|
|
||||||
"speech_to_text": fields.Raw(description="Speech to text configuration"),
|
|
||||||
"text_to_speech": fields.Raw(description="Text to speech configuration"),
|
|
||||||
"retrieval_model": fields.Raw(description="Retrieval model configuration"),
|
|
||||||
"tools": fields.List(fields.Raw(), description="Available tools"),
|
|
||||||
"dataset_configs": fields.Raw(description="Dataset configurations"),
|
|
||||||
"agent_mode": fields.Raw(description="Agent mode configuration"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@console_ns.response(200, "Model configuration updated successfully")
|
@console_ns.response(200, "Model configuration updated successfully")
|
||||||
@console_ns.response(400, "Invalid configuration")
|
@console_ns.response(400, "Invalid configuration")
|
||||||
@console_ns.response(404, "App not found")
|
@console_ns.response(404, "App not found")
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from flask_restx import Resource, marshal_with
|
from flask_restx import Resource
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from werkzeug.exceptions import NotFound
|
from werkzeug.exceptions import NotFound
|
||||||
|
|
||||||
from constants.languages import supported_language
|
from constants.languages import supported_language
|
||||||
|
from controllers.common.schema import register_schema_models
|
||||||
from controllers.console import console_ns
|
from controllers.console import console_ns
|
||||||
from controllers.console.app.wraps import get_app_model
|
from controllers.console.app.wraps import get_app_model
|
||||||
from controllers.console.wraps import (
|
from controllers.console.wraps import (
|
||||||
@@ -15,13 +16,11 @@ from controllers.console.wraps import (
|
|||||||
setup_required,
|
setup_required,
|
||||||
)
|
)
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from fields.app_fields import app_site_fields
|
from fields.base import ResponseModel
|
||||||
from libs.datetime_utils import naive_utc_now
|
from libs.datetime_utils import naive_utc_now
|
||||||
from libs.login import current_account_with_tenant, login_required
|
from libs.login import current_account_with_tenant, login_required
|
||||||
from models import Site
|
from models import Site
|
||||||
|
|
||||||
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
|
|
||||||
|
|
||||||
|
|
||||||
class AppSiteUpdatePayload(BaseModel):
|
class AppSiteUpdatePayload(BaseModel):
|
||||||
title: str | None = Field(default=None)
|
title: str | None = Field(default=None)
|
||||||
@@ -49,13 +48,26 @@ class AppSiteUpdatePayload(BaseModel):
|
|||||||
return supported_language(value)
|
return supported_language(value)
|
||||||
|
|
||||||
|
|
||||||
console_ns.schema_model(
|
class AppSiteResponse(ResponseModel):
|
||||||
AppSiteUpdatePayload.__name__,
|
app_id: str
|
||||||
AppSiteUpdatePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
|
access_token: str | None = Field(default=None, validation_alias="code")
|
||||||
)
|
code: str | None = None
|
||||||
|
title: str
|
||||||
|
icon: str | None = None
|
||||||
|
icon_background: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
default_language: str
|
||||||
|
customize_domain: str | None = None
|
||||||
|
copyright: str | None = None
|
||||||
|
privacy_policy: str | None = None
|
||||||
|
custom_disclaimer: str | None = None
|
||||||
|
customize_token_strategy: str
|
||||||
|
prompt_public: bool
|
||||||
|
show_workflow_steps: bool
|
||||||
|
use_icon_as_answer_icon: bool
|
||||||
|
|
||||||
# Register model for flask_restx to avoid dict type issues in Swagger
|
|
||||||
app_site_model = console_ns.model("AppSite", app_site_fields)
|
register_schema_models(console_ns, AppSiteUpdatePayload, AppSiteResponse)
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/apps/<uuid:app_id>/site")
|
@console_ns.route("/apps/<uuid:app_id>/site")
|
||||||
@@ -64,7 +76,7 @@ class AppSite(Resource):
|
|||||||
@console_ns.doc(description="Update application site configuration")
|
@console_ns.doc(description="Update application site configuration")
|
||||||
@console_ns.doc(params={"app_id": "Application ID"})
|
@console_ns.doc(params={"app_id": "Application ID"})
|
||||||
@console_ns.expect(console_ns.models[AppSiteUpdatePayload.__name__])
|
@console_ns.expect(console_ns.models[AppSiteUpdatePayload.__name__])
|
||||||
@console_ns.response(200, "Site configuration updated successfully", app_site_model)
|
@console_ns.response(200, "Site configuration updated successfully", console_ns.models[AppSiteResponse.__name__])
|
||||||
@console_ns.response(403, "Insufficient permissions")
|
@console_ns.response(403, "Insufficient permissions")
|
||||||
@console_ns.response(404, "App not found")
|
@console_ns.response(404, "App not found")
|
||||||
@setup_required
|
@setup_required
|
||||||
@@ -72,7 +84,6 @@ class AppSite(Resource):
|
|||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model
|
@get_app_model
|
||||||
@marshal_with(app_site_model)
|
|
||||||
def post(self, app_model):
|
def post(self, app_model):
|
||||||
args = AppSiteUpdatePayload.model_validate(console_ns.payload or {})
|
args = AppSiteUpdatePayload.model_validate(console_ns.payload or {})
|
||||||
current_user, _ = current_account_with_tenant()
|
current_user, _ = current_account_with_tenant()
|
||||||
@@ -106,7 +117,7 @@ class AppSite(Resource):
|
|||||||
site.updated_at = naive_utc_now()
|
site.updated_at = naive_utc_now()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return site
|
return AppSiteResponse.model_validate(site, from_attributes=True).model_dump(mode="json")
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/apps/<uuid:app_id>/site/access-token-reset")
|
@console_ns.route("/apps/<uuid:app_id>/site/access-token-reset")
|
||||||
@@ -114,7 +125,7 @@ class AppSiteAccessTokenReset(Resource):
|
|||||||
@console_ns.doc("reset_app_site_access_token")
|
@console_ns.doc("reset_app_site_access_token")
|
||||||
@console_ns.doc(description="Reset access token for application site")
|
@console_ns.doc(description="Reset access token for application site")
|
||||||
@console_ns.doc(params={"app_id": "Application ID"})
|
@console_ns.doc(params={"app_id": "Application ID"})
|
||||||
@console_ns.response(200, "Access token reset successfully", app_site_model)
|
@console_ns.response(200, "Access token reset successfully", console_ns.models[AppSiteResponse.__name__])
|
||||||
@console_ns.response(403, "Insufficient permissions (admin/owner required)")
|
@console_ns.response(403, "Insufficient permissions (admin/owner required)")
|
||||||
@console_ns.response(404, "App or site not found")
|
@console_ns.response(404, "App or site not found")
|
||||||
@setup_required
|
@setup_required
|
||||||
@@ -122,7 +133,6 @@ class AppSiteAccessTokenReset(Resource):
|
|||||||
@is_admin_or_owner_required
|
@is_admin_or_owner_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model
|
@get_app_model
|
||||||
@marshal_with(app_site_model)
|
|
||||||
def post(self, app_model):
|
def post(self, app_model):
|
||||||
current_user, _ = current_account_with_tenant()
|
current_user, _ = current_account_with_tenant()
|
||||||
site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1))
|
site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1))
|
||||||
@@ -135,4 +145,4 @@ class AppSiteAccessTokenReset(Resource):
|
|||||||
site.updated_at = naive_utc_now()
|
site.updated_at = naive_utc_now()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return site
|
return AppSiteResponse.model_validate(site, from_attributes=True).model_dump(mode="json")
|
||||||
|
|||||||
@@ -4,16 +4,13 @@ from collections.abc import Sequence
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from flask import abort, request
|
from flask import abort, request
|
||||||
from flask_restx import Resource, fields, marshal_with
|
from flask_restx import Resource, fields, marshal, marshal_with
|
||||||
from graphon.enums import NodeType
|
|
||||||
from graphon.file import File
|
|
||||||
from graphon.graph_engine.manager import GraphEngineManager
|
|
||||||
from graphon.model_runtime.utils.encoders import jsonable_encoder
|
|
||||||
from pydantic import BaseModel, Field, ValidationError, field_validator
|
from pydantic import BaseModel, Field, ValidationError, field_validator
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound
|
from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound
|
||||||
|
|
||||||
import services
|
import services
|
||||||
|
from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload
|
||||||
from controllers.console import console_ns
|
from controllers.console import console_ns
|
||||||
from controllers.console.app.error import ConversationCompletedError, DraftWorkflowNotExist, DraftWorkflowNotSync
|
from controllers.console.app.error import ConversationCompletedError, DraftWorkflowNotExist, DraftWorkflowNotSync
|
||||||
from controllers.console.app.workflow_run import workflow_run_node_execution_model
|
from controllers.console.app.workflow_run import workflow_run_node_execution_model
|
||||||
@@ -38,7 +35,13 @@ from extensions.ext_database import db
|
|||||||
from extensions.ext_redis import redis_client
|
from extensions.ext_redis import redis_client
|
||||||
from factories import file_factory, variable_factory
|
from factories import file_factory, variable_factory
|
||||||
from fields.member_fields import simple_account_fields
|
from fields.member_fields import simple_account_fields
|
||||||
|
from fields.online_user_fields import online_user_list_fields
|
||||||
from fields.workflow_fields import workflow_fields, workflow_pagination_fields
|
from fields.workflow_fields import workflow_fields, workflow_pagination_fields
|
||||||
|
from graphon.enums import NodeType
|
||||||
|
from graphon.file import File
|
||||||
|
from graphon.file import helpers as file_helpers
|
||||||
|
from graphon.graph_engine.manager import GraphEngineManager
|
||||||
|
from graphon.model_runtime.utils.encoders import jsonable_encoder
|
||||||
from libs import helper
|
from libs import helper
|
||||||
from libs.datetime_utils import naive_utc_now
|
from libs.datetime_utils import naive_utc_now
|
||||||
from libs.helper import TimestampField, uuid_value
|
from libs.helper import TimestampField, uuid_value
|
||||||
@@ -46,6 +49,7 @@ from libs.login import current_account_with_tenant, login_required
|
|||||||
from models import App
|
from models import App
|
||||||
from models.model import AppMode
|
from models.model import AppMode
|
||||||
from models.workflow import Workflow
|
from models.workflow import Workflow
|
||||||
|
from repositories.workflow_collaboration_repository import WORKFLOW_ONLINE_USERS_PREFIX
|
||||||
from services.app_generate_service import AppGenerateService
|
from services.app_generate_service import AppGenerateService
|
||||||
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
|
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
|
||||||
from services.errors.llm import InvokeRateLimitError
|
from services.errors.llm import InvokeRateLimitError
|
||||||
@@ -56,6 +60,7 @@ _file_access_controller = DatabaseFileAccessController()
|
|||||||
LISTENING_RETRY_IN = 2000
|
LISTENING_RETRY_IN = 2000
|
||||||
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
|
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
|
||||||
RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE = "source workflow must be published"
|
RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE = "source workflow must be published"
|
||||||
|
MAX_WORKFLOW_ONLINE_USERS_QUERY_IDS = 50
|
||||||
|
|
||||||
# Register models for flask_restx to avoid dict type issues in Swagger
|
# Register models for flask_restx to avoid dict type issues in Swagger
|
||||||
# Register in dependency order: base models first, then dependent models
|
# Register in dependency order: base models first, then dependent models
|
||||||
@@ -142,10 +147,6 @@ class PublishWorkflowPayload(BaseModel):
|
|||||||
marked_comment: str | None = Field(default=None, max_length=100)
|
marked_comment: str | None = Field(default=None, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
class DefaultBlockConfigQuery(BaseModel):
|
|
||||||
q: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ConvertToWorkflowPayload(BaseModel):
|
class ConvertToWorkflowPayload(BaseModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
icon_type: str | None = None
|
icon_type: str | None = None
|
||||||
@@ -153,16 +154,12 @@ class ConvertToWorkflowPayload(BaseModel):
|
|||||||
icon_background: str | None = None
|
icon_background: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class WorkflowListQuery(BaseModel):
|
class WorkflowFeaturesPayload(BaseModel):
|
||||||
page: int = Field(default=1, ge=1, le=99999)
|
features: dict[str, Any] = Field(..., description="Workflow feature configuration")
|
||||||
limit: int = Field(default=10, ge=1, le=100)
|
|
||||||
user_id: str | None = None
|
|
||||||
named_only: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowUpdatePayload(BaseModel):
|
class WorkflowOnlineUsersQuery(BaseModel):
|
||||||
marked_name: str | None = Field(default=None, max_length=20)
|
app_ids: str = Field(..., description="Comma-separated app IDs")
|
||||||
marked_comment: str | None = Field(default=None, max_length=100)
|
|
||||||
|
|
||||||
|
|
||||||
class DraftWorkflowTriggerRunPayload(BaseModel):
|
class DraftWorkflowTriggerRunPayload(BaseModel):
|
||||||
@@ -188,6 +185,8 @@ reg(DefaultBlockConfigQuery)
|
|||||||
reg(ConvertToWorkflowPayload)
|
reg(ConvertToWorkflowPayload)
|
||||||
reg(WorkflowListQuery)
|
reg(WorkflowListQuery)
|
||||||
reg(WorkflowUpdatePayload)
|
reg(WorkflowUpdatePayload)
|
||||||
|
reg(WorkflowFeaturesPayload)
|
||||||
|
reg(WorkflowOnlineUsersQuery)
|
||||||
reg(DraftWorkflowTriggerRunPayload)
|
reg(DraftWorkflowTriggerRunPayload)
|
||||||
reg(DraftWorkflowTriggerRunAllPayload)
|
reg(DraftWorkflowTriggerRunAllPayload)
|
||||||
|
|
||||||
@@ -946,6 +945,32 @@ class ConvertToWorkflowApi(Resource):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/features")
|
||||||
|
class WorkflowFeaturesApi(Resource):
|
||||||
|
"""Update draft workflow features."""
|
||||||
|
|
||||||
|
@console_ns.expect(console_ns.models[WorkflowFeaturesPayload.__name__])
|
||||||
|
@console_ns.doc("update_workflow_features")
|
||||||
|
@console_ns.doc(description="Update draft workflow features")
|
||||||
|
@console_ns.doc(params={"app_id": "Application ID"})
|
||||||
|
@console_ns.response(200, "Workflow features updated successfully")
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||||
|
@edit_permission_required
|
||||||
|
def post(self, app_model: App):
|
||||||
|
current_user, _ = current_account_with_tenant()
|
||||||
|
|
||||||
|
args = WorkflowFeaturesPayload.model_validate(console_ns.payload or {})
|
||||||
|
features = args.features
|
||||||
|
|
||||||
|
workflow_service = WorkflowService()
|
||||||
|
workflow_service.update_draft_workflow_features(app_model=app_model, features=features, account=current_user)
|
||||||
|
|
||||||
|
return {"result": "success"}
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/apps/<uuid:app_id>/workflows")
|
@console_ns.route("/apps/<uuid:app_id>/workflows")
|
||||||
class PublishedAllWorkflowApi(Resource):
|
class PublishedAllWorkflowApi(Resource):
|
||||||
@console_ns.expect(console_ns.models[WorkflowListQuery.__name__])
|
@console_ns.expect(console_ns.models[WorkflowListQuery.__name__])
|
||||||
@@ -957,7 +982,6 @@ class PublishedAllWorkflowApi(Resource):
|
|||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||||
@marshal_with(workflow_pagination_model)
|
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def get(self, app_model: App):
|
def get(self, app_model: App):
|
||||||
"""
|
"""
|
||||||
@@ -985,9 +1009,10 @@ class PublishedAllWorkflowApi(Resource):
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
named_only=named_only,
|
named_only=named_only,
|
||||||
)
|
)
|
||||||
|
serialized_workflows = marshal(workflows, workflow_fields_copy)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"items": workflows,
|
"items": serialized_workflows,
|
||||||
"page": page,
|
"page": page,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"has_more": has_more,
|
"has_more": has_more,
|
||||||
@@ -1355,3 +1380,62 @@ class DraftWorkflowTriggerRunAllApi(Resource):
|
|||||||
"status": "error",
|
"status": "error",
|
||||||
}
|
}
|
||||||
), 400
|
), 400
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/apps/workflows/online-users")
|
||||||
|
class WorkflowOnlineUsersApi(Resource):
|
||||||
|
@console_ns.expect(console_ns.models[WorkflowOnlineUsersQuery.__name__])
|
||||||
|
@console_ns.doc("get_workflow_online_users")
|
||||||
|
@console_ns.doc(description="Get workflow online users")
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@marshal_with(online_user_list_fields)
|
||||||
|
def get(self):
|
||||||
|
args = WorkflowOnlineUsersQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
||||||
|
|
||||||
|
app_ids = list(dict.fromkeys(app_id.strip() for app_id in args.app_ids.split(",") if app_id.strip()))
|
||||||
|
if len(app_ids) > MAX_WORKFLOW_ONLINE_USERS_QUERY_IDS:
|
||||||
|
raise BadRequest(f"Maximum {MAX_WORKFLOW_ONLINE_USERS_QUERY_IDS} app_ids are allowed per request.")
|
||||||
|
|
||||||
|
if not app_ids:
|
||||||
|
return {"data": []}
|
||||||
|
|
||||||
|
_, current_tenant_id = current_account_with_tenant()
|
||||||
|
workflow_service = WorkflowService()
|
||||||
|
accessible_app_ids = workflow_service.get_accessible_app_ids(app_ids, current_tenant_id)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for app_id in app_ids:
|
||||||
|
if app_id not in accessible_app_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
users_json = redis_client.hgetall(f"{WORKFLOW_ONLINE_USERS_PREFIX}{app_id}")
|
||||||
|
|
||||||
|
users = []
|
||||||
|
for _, user_info_json in users_json.items():
|
||||||
|
try:
|
||||||
|
user_info = json.loads(user_info_json)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not isinstance(user_info, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
avatar = user_info.get("avatar")
|
||||||
|
if isinstance(avatar, str) and avatar and not avatar.startswith(("http://", "https://")):
|
||||||
|
try:
|
||||||
|
user_info["avatar"] = file_helpers.get_signed_file_url(avatar)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to sign workflow online user avatar; using original value. "
|
||||||
|
"app_id=%s avatar=%s error=%s",
|
||||||
|
app_id,
|
||||||
|
avatar,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
users.append(user_info)
|
||||||
|
results.append({"app_id": app_id, "users": users})
|
||||||
|
|
||||||
|
return {"data": results}
|
||||||
|
|||||||
@@ -1,27 +1,26 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from dateutil.parser import isoparse
|
from dateutil.parser import isoparse
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask_restx import Resource, marshal_with
|
from flask_restx import Resource
|
||||||
from graphon.enums import WorkflowExecutionStatus
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from controllers.common.schema import register_schema_models
|
||||||
from controllers.console import console_ns
|
from controllers.console import console_ns
|
||||||
from controllers.console.app.wraps import get_app_model
|
from controllers.console.app.wraps import get_app_model
|
||||||
from controllers.console.wraps import account_initialization_required, setup_required
|
from controllers.console.wraps import account_initialization_required, setup_required
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from fields.workflow_app_log_fields import (
|
from fields.base import ResponseModel
|
||||||
build_workflow_app_log_pagination_model,
|
from fields.end_user_fields import SimpleEndUser
|
||||||
build_workflow_archived_log_pagination_model,
|
from fields.member_fields import SimpleAccount
|
||||||
)
|
from graphon.enums import WorkflowExecutionStatus
|
||||||
from libs.login import login_required
|
from libs.login import login_required
|
||||||
from models import App
|
from models import App
|
||||||
from models.model import AppMode
|
from models.model import AppMode
|
||||||
from services.workflow_app_service import WorkflowAppService
|
from services.workflow_app_service import WorkflowAppService
|
||||||
|
|
||||||
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowAppLogQuery(BaseModel):
|
class WorkflowAppLogQuery(BaseModel):
|
||||||
keyword: str | None = Field(default=None, description="Search keyword for filtering logs")
|
keyword: str | None = Field(default=None, description="Search keyword for filtering logs")
|
||||||
@@ -58,13 +57,113 @@ class WorkflowAppLogQuery(BaseModel):
|
|||||||
raise ValueError("Invalid boolean value for detail")
|
raise ValueError("Invalid boolean value for detail")
|
||||||
|
|
||||||
|
|
||||||
console_ns.schema_model(
|
class WorkflowRunForLogResponse(ResponseModel):
|
||||||
WorkflowAppLogQuery.__name__, WorkflowAppLogQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
|
id: str
|
||||||
)
|
version: str | None = None
|
||||||
|
status: str | None = None
|
||||||
|
triggered_from: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
elapsed_time: float | None = None
|
||||||
|
total_tokens: int | None = None
|
||||||
|
total_steps: int | None = None
|
||||||
|
created_at: int | None = None
|
||||||
|
finished_at: int | None = None
|
||||||
|
exceptions_count: int | None = None
|
||||||
|
|
||||||
# Register model for flask_restx to avoid dict type issues in Swagger
|
@field_validator("status", mode="before")
|
||||||
workflow_app_log_pagination_model = build_workflow_app_log_pagination_model(console_ns)
|
@classmethod
|
||||||
workflow_archived_log_pagination_model = build_workflow_archived_log_pagination_model(console_ns)
|
def _normalize_status(cls, value: Any) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
return str(getattr(value, "value", value))
|
||||||
|
|
||||||
|
@field_validator("created_at", "finished_at", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return int(value.timestamp())
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowRunForArchivedLogResponse(ResponseModel):
|
||||||
|
id: str
|
||||||
|
status: str | None = None
|
||||||
|
triggered_from: str | None = None
|
||||||
|
elapsed_time: float | None = None
|
||||||
|
total_tokens: int | None = None
|
||||||
|
|
||||||
|
@field_validator("status", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_status(cls, value: Any) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
return str(getattr(value, "value", value))
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowAppLogPartialResponse(ResponseModel):
|
||||||
|
id: str
|
||||||
|
workflow_run: WorkflowRunForLogResponse | None = None
|
||||||
|
details: Any = None
|
||||||
|
created_from: str | None = None
|
||||||
|
created_by_role: str | None = None
|
||||||
|
created_by_account: SimpleAccount | None = None
|
||||||
|
created_by_end_user: SimpleEndUser | None = None
|
||||||
|
created_at: int | None = None
|
||||||
|
|
||||||
|
@field_validator("created_at", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return int(value.timestamp())
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowArchivedLogPartialResponse(ResponseModel):
|
||||||
|
id: str
|
||||||
|
workflow_run: WorkflowRunForArchivedLogResponse | None = None
|
||||||
|
trigger_metadata: Any = None
|
||||||
|
created_by_account: SimpleAccount | None = None
|
||||||
|
created_by_end_user: SimpleEndUser | None = None
|
||||||
|
created_at: int | None = None
|
||||||
|
|
||||||
|
@field_validator("created_at", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return int(value.timestamp())
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowAppLogPaginationResponse(ResponseModel):
|
||||||
|
page: int
|
||||||
|
limit: int
|
||||||
|
total: int
|
||||||
|
has_more: bool
|
||||||
|
data: list[WorkflowAppLogPartialResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowArchivedLogPaginationResponse(ResponseModel):
|
||||||
|
page: int
|
||||||
|
limit: int
|
||||||
|
total: int
|
||||||
|
has_more: bool
|
||||||
|
data: list[WorkflowArchivedLogPartialResponse]
|
||||||
|
|
||||||
|
|
||||||
|
register_schema_models(
|
||||||
|
console_ns,
|
||||||
|
WorkflowAppLogQuery,
|
||||||
|
WorkflowRunForLogResponse,
|
||||||
|
WorkflowRunForArchivedLogResponse,
|
||||||
|
WorkflowAppLogPartialResponse,
|
||||||
|
WorkflowArchivedLogPartialResponse,
|
||||||
|
WorkflowAppLogPaginationResponse,
|
||||||
|
WorkflowArchivedLogPaginationResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/apps/<uuid:app_id>/workflow-app-logs")
|
@console_ns.route("/apps/<uuid:app_id>/workflow-app-logs")
|
||||||
@@ -73,12 +172,15 @@ class WorkflowAppLogApi(Resource):
|
|||||||
@console_ns.doc(description="Get workflow application execution logs")
|
@console_ns.doc(description="Get workflow application execution logs")
|
||||||
@console_ns.doc(params={"app_id": "Application ID"})
|
@console_ns.doc(params={"app_id": "Application ID"})
|
||||||
@console_ns.expect(console_ns.models[WorkflowAppLogQuery.__name__])
|
@console_ns.expect(console_ns.models[WorkflowAppLogQuery.__name__])
|
||||||
@console_ns.response(200, "Workflow app logs retrieved successfully", workflow_app_log_pagination_model)
|
@console_ns.response(
|
||||||
|
200,
|
||||||
|
"Workflow app logs retrieved successfully",
|
||||||
|
console_ns.models[WorkflowAppLogPaginationResponse.__name__],
|
||||||
|
)
|
||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=[AppMode.WORKFLOW])
|
@get_app_model(mode=[AppMode.WORKFLOW])
|
||||||
@marshal_with(workflow_app_log_pagination_model)
|
|
||||||
def get(self, app_model: App):
|
def get(self, app_model: App):
|
||||||
"""
|
"""
|
||||||
Get workflow app logs
|
Get workflow app logs
|
||||||
@@ -87,7 +189,7 @@ class WorkflowAppLogApi(Resource):
|
|||||||
|
|
||||||
# get paginate workflow app logs
|
# get paginate workflow app logs
|
||||||
workflow_app_service = WorkflowAppService()
|
workflow_app_service = WorkflowAppService()
|
||||||
with sessionmaker(db.engine).begin() as session:
|
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
|
||||||
workflow_app_log_pagination = workflow_app_service.get_paginate_workflow_app_logs(
|
workflow_app_log_pagination = workflow_app_service.get_paginate_workflow_app_logs(
|
||||||
session=session,
|
session=session,
|
||||||
app_model=app_model,
|
app_model=app_model,
|
||||||
@@ -102,7 +204,9 @@ class WorkflowAppLogApi(Resource):
|
|||||||
created_by_account=args.created_by_account,
|
created_by_account=args.created_by_account,
|
||||||
)
|
)
|
||||||
|
|
||||||
return workflow_app_log_pagination
|
return WorkflowAppLogPaginationResponse.model_validate(
|
||||||
|
workflow_app_log_pagination, from_attributes=True
|
||||||
|
).model_dump(mode="json")
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/apps/<uuid:app_id>/workflow-archived-logs")
|
@console_ns.route("/apps/<uuid:app_id>/workflow-archived-logs")
|
||||||
@@ -111,12 +215,15 @@ class WorkflowArchivedLogApi(Resource):
|
|||||||
@console_ns.doc(description="Get workflow archived execution logs")
|
@console_ns.doc(description="Get workflow archived execution logs")
|
||||||
@console_ns.doc(params={"app_id": "Application ID"})
|
@console_ns.doc(params={"app_id": "Application ID"})
|
||||||
@console_ns.expect(console_ns.models[WorkflowAppLogQuery.__name__])
|
@console_ns.expect(console_ns.models[WorkflowAppLogQuery.__name__])
|
||||||
@console_ns.response(200, "Workflow archived logs retrieved successfully", workflow_archived_log_pagination_model)
|
@console_ns.response(
|
||||||
|
200,
|
||||||
|
"Workflow archived logs retrieved successfully",
|
||||||
|
console_ns.models[WorkflowArchivedLogPaginationResponse.__name__],
|
||||||
|
)
|
||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=[AppMode.WORKFLOW])
|
@get_app_model(mode=[AppMode.WORKFLOW])
|
||||||
@marshal_with(workflow_archived_log_pagination_model)
|
|
||||||
def get(self, app_model: App):
|
def get(self, app_model: App):
|
||||||
"""
|
"""
|
||||||
Get workflow archived logs
|
Get workflow archived logs
|
||||||
@@ -124,7 +231,7 @@ class WorkflowArchivedLogApi(Resource):
|
|||||||
args = WorkflowAppLogQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
args = WorkflowAppLogQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
||||||
|
|
||||||
workflow_app_service = WorkflowAppService()
|
workflow_app_service = WorkflowAppService()
|
||||||
with sessionmaker(db.engine).begin() as session:
|
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
|
||||||
workflow_app_log_pagination = workflow_app_service.get_paginate_workflow_archive_logs(
|
workflow_app_log_pagination = workflow_app_service.get_paginate_workflow_archive_logs(
|
||||||
session=session,
|
session=session,
|
||||||
app_model=app_model,
|
app_model=app_model,
|
||||||
@@ -132,4 +239,6 @@ class WorkflowArchivedLogApi(Resource):
|
|||||||
limit=args.limit,
|
limit=args.limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
return workflow_app_log_pagination
|
return WorkflowArchivedLogPaginationResponse.model_validate(
|
||||||
|
workflow_app_log_pagination, from_attributes=True
|
||||||
|
).model_dump(mode="json")
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from flask_restx import Resource, marshal_with
|
||||||
|
from pydantic import BaseModel, Field, TypeAdapter
|
||||||
|
|
||||||
|
from controllers.common.schema import register_schema_models
|
||||||
|
from controllers.console import console_ns
|
||||||
|
from controllers.console.app.wraps import get_app_model
|
||||||
|
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
|
||||||
|
from fields.member_fields import AccountWithRole
|
||||||
|
from fields.workflow_comment_fields import (
|
||||||
|
workflow_comment_basic_fields,
|
||||||
|
workflow_comment_create_fields,
|
||||||
|
workflow_comment_detail_fields,
|
||||||
|
workflow_comment_reply_create_fields,
|
||||||
|
workflow_comment_reply_update_fields,
|
||||||
|
workflow_comment_resolve_fields,
|
||||||
|
workflow_comment_update_fields,
|
||||||
|
)
|
||||||
|
from libs.login import current_user, login_required
|
||||||
|
from models import App
|
||||||
|
from services.account_service import TenantService
|
||||||
|
from services.workflow_comment_service import WorkflowCommentService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowCommentCreatePayload(BaseModel):
|
||||||
|
content: str = Field(..., description="Comment content")
|
||||||
|
position_x: float = Field(..., description="Comment X position")
|
||||||
|
position_y: float = Field(..., description="Comment Y position")
|
||||||
|
mentioned_user_ids: list[str] = Field(default_factory=list, description="Mentioned user IDs")
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowCommentUpdatePayload(BaseModel):
|
||||||
|
content: str = Field(..., description="Comment content")
|
||||||
|
position_x: float | None = Field(default=None, description="Comment X position")
|
||||||
|
position_y: float | None = Field(default=None, description="Comment Y position")
|
||||||
|
mentioned_user_ids: list[str] | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Mentioned user IDs. Omit to keep existing mentions.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowCommentReplyPayload(BaseModel):
|
||||||
|
content: str = Field(..., description="Reply content")
|
||||||
|
mentioned_user_ids: list[str] = Field(default_factory=list, description="Mentioned user IDs")
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowCommentMentionUsersPayload(BaseModel):
|
||||||
|
users: list[AccountWithRole]
|
||||||
|
|
||||||
|
|
||||||
|
for model in (
|
||||||
|
WorkflowCommentCreatePayload,
|
||||||
|
WorkflowCommentUpdatePayload,
|
||||||
|
WorkflowCommentReplyPayload,
|
||||||
|
):
|
||||||
|
console_ns.schema_model(model.__name__, model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0))
|
||||||
|
register_schema_models(console_ns, AccountWithRole, WorkflowCommentMentionUsersPayload)
|
||||||
|
|
||||||
|
workflow_comment_basic_model = console_ns.model("WorkflowCommentBasic", workflow_comment_basic_fields)
|
||||||
|
workflow_comment_detail_model = console_ns.model("WorkflowCommentDetail", workflow_comment_detail_fields)
|
||||||
|
workflow_comment_create_model = console_ns.model("WorkflowCommentCreate", workflow_comment_create_fields)
|
||||||
|
workflow_comment_update_model = console_ns.model("WorkflowCommentUpdate", workflow_comment_update_fields)
|
||||||
|
workflow_comment_resolve_model = console_ns.model("WorkflowCommentResolve", workflow_comment_resolve_fields)
|
||||||
|
workflow_comment_reply_create_model = console_ns.model(
|
||||||
|
"WorkflowCommentReplyCreate", workflow_comment_reply_create_fields
|
||||||
|
)
|
||||||
|
workflow_comment_reply_update_model = console_ns.model(
|
||||||
|
"WorkflowCommentReplyUpdate", workflow_comment_reply_update_fields
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/apps/<uuid:app_id>/workflow/comments")
|
||||||
|
class WorkflowCommentListApi(Resource):
|
||||||
|
"""API for listing and creating workflow comments."""
|
||||||
|
|
||||||
|
@console_ns.doc("list_workflow_comments")
|
||||||
|
@console_ns.doc(description="Get all comments for a workflow")
|
||||||
|
@console_ns.doc(params={"app_id": "Application ID"})
|
||||||
|
@console_ns.response(200, "Comments retrieved successfully", workflow_comment_basic_model)
|
||||||
|
@login_required
|
||||||
|
@setup_required
|
||||||
|
@account_initialization_required
|
||||||
|
@get_app_model()
|
||||||
|
@marshal_with(workflow_comment_basic_model, envelope="data")
|
||||||
|
def get(self, app_model: App):
|
||||||
|
"""Get all comments for a workflow."""
|
||||||
|
comments = WorkflowCommentService.get_comments(tenant_id=current_user.current_tenant_id, app_id=app_model.id)
|
||||||
|
|
||||||
|
return comments
|
||||||
|
|
||||||
|
@console_ns.doc("create_workflow_comment")
|
||||||
|
@console_ns.doc(description="Create a new workflow comment")
|
||||||
|
@console_ns.doc(params={"app_id": "Application ID"})
|
||||||
|
@console_ns.expect(console_ns.models[WorkflowCommentCreatePayload.__name__])
|
||||||
|
@console_ns.response(201, "Comment created successfully", workflow_comment_create_model)
|
||||||
|
@login_required
|
||||||
|
@setup_required
|
||||||
|
@account_initialization_required
|
||||||
|
@get_app_model()
|
||||||
|
@marshal_with(workflow_comment_create_model)
|
||||||
|
@edit_permission_required
|
||||||
|
def post(self, app_model: App):
|
||||||
|
"""Create a new workflow comment."""
|
||||||
|
payload = WorkflowCommentCreatePayload.model_validate(console_ns.payload or {})
|
||||||
|
|
||||||
|
result = WorkflowCommentService.create_comment(
|
||||||
|
tenant_id=current_user.current_tenant_id,
|
||||||
|
app_id=app_model.id,
|
||||||
|
created_by=current_user.id,
|
||||||
|
content=payload.content,
|
||||||
|
position_x=payload.position_x,
|
||||||
|
position_y=payload.position_y,
|
||||||
|
mentioned_user_ids=payload.mentioned_user_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result, 201
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/apps/<uuid:app_id>/workflow/comments/<string:comment_id>")
|
||||||
|
class WorkflowCommentDetailApi(Resource):
|
||||||
|
"""API for managing individual workflow comments."""
|
||||||
|
|
||||||
|
@console_ns.doc("get_workflow_comment")
|
||||||
|
@console_ns.doc(description="Get a specific workflow comment")
|
||||||
|
@console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"})
|
||||||
|
@console_ns.response(200, "Comment retrieved successfully", workflow_comment_detail_model)
|
||||||
|
@login_required
|
||||||
|
@setup_required
|
||||||
|
@account_initialization_required
|
||||||
|
@get_app_model()
|
||||||
|
@marshal_with(workflow_comment_detail_model)
|
||||||
|
def get(self, app_model: App, comment_id: str):
|
||||||
|
"""Get a specific workflow comment."""
|
||||||
|
comment = WorkflowCommentService.get_comment(
|
||||||
|
tenant_id=current_user.current_tenant_id, app_id=app_model.id, comment_id=comment_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return comment
|
||||||
|
|
||||||
|
@console_ns.doc("update_workflow_comment")
|
||||||
|
@console_ns.doc(description="Update a workflow comment")
|
||||||
|
@console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"})
|
||||||
|
@console_ns.expect(console_ns.models[WorkflowCommentUpdatePayload.__name__])
|
||||||
|
@console_ns.response(200, "Comment updated successfully", workflow_comment_update_model)
|
||||||
|
@login_required
|
||||||
|
@setup_required
|
||||||
|
@account_initialization_required
|
||||||
|
@get_app_model()
|
||||||
|
@marshal_with(workflow_comment_update_model)
|
||||||
|
@edit_permission_required
|
||||||
|
def put(self, app_model: App, comment_id: str):
|
||||||
|
"""Update a workflow comment."""
|
||||||
|
payload = WorkflowCommentUpdatePayload.model_validate(console_ns.payload or {})
|
||||||
|
|
||||||
|
result = WorkflowCommentService.update_comment(
|
||||||
|
tenant_id=current_user.current_tenant_id,
|
||||||
|
app_id=app_model.id,
|
||||||
|
comment_id=comment_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
content=payload.content,
|
||||||
|
position_x=payload.position_x,
|
||||||
|
position_y=payload.position_y,
|
||||||
|
mentioned_user_ids=payload.mentioned_user_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@console_ns.doc("delete_workflow_comment")
|
||||||
|
@console_ns.doc(description="Delete a workflow comment")
|
||||||
|
@console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"})
|
||||||
|
@console_ns.response(204, "Comment deleted successfully")
|
||||||
|
@login_required
|
||||||
|
@setup_required
|
||||||
|
@account_initialization_required
|
||||||
|
@get_app_model()
|
||||||
|
@edit_permission_required
|
||||||
|
def delete(self, app_model: App, comment_id: str):
|
||||||
|
"""Delete a workflow comment."""
|
||||||
|
WorkflowCommentService.delete_comment(
|
||||||
|
tenant_id=current_user.current_tenant_id,
|
||||||
|
app_id=app_model.id,
|
||||||
|
comment_id=comment_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"result": "success"}, 204
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/apps/<uuid:app_id>/workflow/comments/<string:comment_id>/resolve")
|
||||||
|
class WorkflowCommentResolveApi(Resource):
|
||||||
|
"""API for resolving and reopening workflow comments."""
|
||||||
|
|
||||||
|
@console_ns.doc("resolve_workflow_comment")
|
||||||
|
@console_ns.doc(description="Resolve a workflow comment")
|
||||||
|
@console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"})
|
||||||
|
@console_ns.response(200, "Comment resolved successfully", workflow_comment_resolve_model)
|
||||||
|
@login_required
|
||||||
|
@setup_required
|
||||||
|
@account_initialization_required
|
||||||
|
@get_app_model()
|
||||||
|
@marshal_with(workflow_comment_resolve_model)
|
||||||
|
@edit_permission_required
|
||||||
|
def post(self, app_model: App, comment_id: str):
|
||||||
|
"""Resolve a workflow comment."""
|
||||||
|
comment = WorkflowCommentService.resolve_comment(
|
||||||
|
tenant_id=current_user.current_tenant_id,
|
||||||
|
app_id=app_model.id,
|
||||||
|
comment_id=comment_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return comment
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/apps/<uuid:app_id>/workflow/comments/<string:comment_id>/replies")
|
||||||
|
class WorkflowCommentReplyApi(Resource):
|
||||||
|
"""API for managing comment replies."""
|
||||||
|
|
||||||
|
@console_ns.doc("create_workflow_comment_reply")
|
||||||
|
@console_ns.doc(description="Add a reply to a workflow comment")
|
||||||
|
@console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"})
|
||||||
|
@console_ns.expect(console_ns.models[WorkflowCommentReplyPayload.__name__])
|
||||||
|
@console_ns.response(201, "Reply created successfully", workflow_comment_reply_create_model)
|
||||||
|
@login_required
|
||||||
|
@setup_required
|
||||||
|
@account_initialization_required
|
||||||
|
@get_app_model()
|
||||||
|
@marshal_with(workflow_comment_reply_create_model)
|
||||||
|
@edit_permission_required
|
||||||
|
def post(self, app_model: App, comment_id: str):
|
||||||
|
"""Add a reply to a workflow comment."""
|
||||||
|
# Validate comment access first
|
||||||
|
WorkflowCommentService.validate_comment_access(
|
||||||
|
comment_id=comment_id, tenant_id=current_user.current_tenant_id, app_id=app_model.id
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = WorkflowCommentReplyPayload.model_validate(console_ns.payload or {})
|
||||||
|
|
||||||
|
result = WorkflowCommentService.create_reply(
|
||||||
|
comment_id=comment_id,
|
||||||
|
content=payload.content,
|
||||||
|
created_by=current_user.id,
|
||||||
|
mentioned_user_ids=payload.mentioned_user_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result, 201
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/apps/<uuid:app_id>/workflow/comments/<string:comment_id>/replies/<string:reply_id>")
|
||||||
|
class WorkflowCommentReplyDetailApi(Resource):
|
||||||
|
"""API for managing individual comment replies."""
|
||||||
|
|
||||||
|
@console_ns.doc("update_workflow_comment_reply")
|
||||||
|
@console_ns.doc(description="Update a comment reply")
|
||||||
|
@console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID", "reply_id": "Reply ID"})
|
||||||
|
@console_ns.expect(console_ns.models[WorkflowCommentReplyPayload.__name__])
|
||||||
|
@console_ns.response(200, "Reply updated successfully", workflow_comment_reply_update_model)
|
||||||
|
@login_required
|
||||||
|
@setup_required
|
||||||
|
@account_initialization_required
|
||||||
|
@get_app_model()
|
||||||
|
@marshal_with(workflow_comment_reply_update_model)
|
||||||
|
@edit_permission_required
|
||||||
|
def put(self, app_model: App, comment_id: str, reply_id: str):
|
||||||
|
"""Update a comment reply."""
|
||||||
|
# Validate comment access first
|
||||||
|
WorkflowCommentService.validate_comment_access(
|
||||||
|
comment_id=comment_id, tenant_id=current_user.current_tenant_id, app_id=app_model.id
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = WorkflowCommentReplyPayload.model_validate(console_ns.payload or {})
|
||||||
|
|
||||||
|
reply = WorkflowCommentService.update_reply(
|
||||||
|
tenant_id=current_user.current_tenant_id,
|
||||||
|
app_id=app_model.id,
|
||||||
|
comment_id=comment_id,
|
||||||
|
reply_id=reply_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
content=payload.content,
|
||||||
|
mentioned_user_ids=payload.mentioned_user_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
return reply
|
||||||
|
|
||||||
|
@console_ns.doc("delete_workflow_comment_reply")
|
||||||
|
@console_ns.doc(description="Delete a comment reply")
|
||||||
|
@console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID", "reply_id": "Reply ID"})
|
||||||
|
@console_ns.response(204, "Reply deleted successfully")
|
||||||
|
@login_required
|
||||||
|
@setup_required
|
||||||
|
@account_initialization_required
|
||||||
|
@get_app_model()
|
||||||
|
@edit_permission_required
|
||||||
|
def delete(self, app_model: App, comment_id: str, reply_id: str):
|
||||||
|
"""Delete a comment reply."""
|
||||||
|
# Validate comment access first
|
||||||
|
WorkflowCommentService.validate_comment_access(
|
||||||
|
comment_id=comment_id, tenant_id=current_user.current_tenant_id, app_id=app_model.id
|
||||||
|
)
|
||||||
|
|
||||||
|
WorkflowCommentService.delete_reply(
|
||||||
|
tenant_id=current_user.current_tenant_id,
|
||||||
|
app_id=app_model.id,
|
||||||
|
comment_id=comment_id,
|
||||||
|
reply_id=reply_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"result": "success"}, 204
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/apps/<uuid:app_id>/workflow/comments/mention-users")
|
||||||
|
class WorkflowCommentMentionUsersApi(Resource):
|
||||||
|
"""API for getting mentionable users for workflow comments."""
|
||||||
|
|
||||||
|
@console_ns.doc("workflow_comment_mention_users")
|
||||||
|
@console_ns.doc(description="Get all users in current tenant for mentions")
|
||||||
|
@console_ns.doc(params={"app_id": "Application ID"})
|
||||||
|
@console_ns.response(
|
||||||
|
200, "Mentionable users retrieved successfully", console_ns.models[WorkflowCommentMentionUsersPayload.__name__]
|
||||||
|
)
|
||||||
|
@login_required
|
||||||
|
@setup_required
|
||||||
|
@account_initialization_required
|
||||||
|
@get_app_model()
|
||||||
|
def get(self, app_model: App):
|
||||||
|
"""Get all users in current tenant for mentions."""
|
||||||
|
members = TenantService.get_tenant_members(current_user.current_tenant)
|
||||||
|
users = TypeAdapter(list[AccountWithRole]).validate_python(members, from_attributes=True)
|
||||||
|
response = WorkflowCommentMentionUsersPayload(users=users)
|
||||||
|
return response.model_dump(mode="json"), 200
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import Any, NoReturn, ParamSpec, TypeVar
|
from typing import Any, TypedDict
|
||||||
|
|
||||||
from flask import Response, request
|
from flask import Response, request
|
||||||
from flask_restx import Resource, fields, marshal, marshal_with
|
from flask_restx import Resource, fields, marshal, marshal_with
|
||||||
from graphon.file import helpers as file_helpers
|
|
||||||
from graphon.variables.segment_group import SegmentGroup
|
|
||||||
from graphon.variables.segments import ArrayFileSegment, FileSegment, Segment
|
|
||||||
from graphon.variables.types import SegmentType
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
@@ -22,8 +18,13 @@ from controllers.web.error import InvalidArgumentError, NotFoundError
|
|||||||
from core.app.file_access import DatabaseFileAccessController
|
from core.app.file_access import DatabaseFileAccessController
|
||||||
from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
|
from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
|
from factories import variable_factory
|
||||||
from factories.file_factory import build_from_mapping, build_from_mappings
|
from factories.file_factory import build_from_mapping, build_from_mappings
|
||||||
from factories.variable_factory import build_segment_with_type
|
from factories.variable_factory import build_segment_with_type
|
||||||
|
from graphon.file import helpers as file_helpers
|
||||||
|
from graphon.variables.segment_group import SegmentGroup
|
||||||
|
from graphon.variables.segments import ArrayFileSegment, FileSegment, Segment
|
||||||
|
from graphon.variables.types import SegmentType
|
||||||
from libs.login import current_user, login_required
|
from libs.login import current_user, login_required
|
||||||
from models import App, AppMode
|
from models import App, AppMode
|
||||||
from models.workflow import WorkflowDraftVariable
|
from models.workflow import WorkflowDraftVariable
|
||||||
@@ -45,6 +46,16 @@ class WorkflowDraftVariableUpdatePayload(BaseModel):
|
|||||||
value: Any | None = Field(default=None, description="Variable value")
|
value: Any | None = Field(default=None, description="Variable value")
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationVariableUpdatePayload(BaseModel):
|
||||||
|
conversation_variables: list[dict[str, Any]] = Field(
|
||||||
|
..., description="Conversation variables for the draft workflow"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EnvironmentVariableUpdatePayload(BaseModel):
|
||||||
|
environment_variables: list[dict[str, Any]] = Field(..., description="Environment variables for the draft workflow")
|
||||||
|
|
||||||
|
|
||||||
console_ns.schema_model(
|
console_ns.schema_model(
|
||||||
WorkflowDraftVariableListQuery.__name__,
|
WorkflowDraftVariableListQuery.__name__,
|
||||||
WorkflowDraftVariableListQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
|
WorkflowDraftVariableListQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
|
||||||
@@ -53,6 +64,14 @@ console_ns.schema_model(
|
|||||||
WorkflowDraftVariableUpdatePayload.__name__,
|
WorkflowDraftVariableUpdatePayload.__name__,
|
||||||
WorkflowDraftVariableUpdatePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
|
WorkflowDraftVariableUpdatePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
|
||||||
)
|
)
|
||||||
|
console_ns.schema_model(
|
||||||
|
ConversationVariableUpdatePayload.__name__,
|
||||||
|
ConversationVariableUpdatePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
|
||||||
|
)
|
||||||
|
console_ns.schema_model(
|
||||||
|
EnvironmentVariableUpdatePayload.__name__,
|
||||||
|
EnvironmentVariableUpdatePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _convert_values_to_json_serializable_object(value: Segment):
|
def _convert_values_to_json_serializable_object(value: Segment):
|
||||||
@@ -83,10 +102,17 @@ def _serialize_var_value(variable: WorkflowDraftVariable):
|
|||||||
|
|
||||||
def _serialize_variable_type(workflow_draft_var: WorkflowDraftVariable) -> str:
|
def _serialize_variable_type(workflow_draft_var: WorkflowDraftVariable) -> str:
|
||||||
value_type = workflow_draft_var.value_type
|
value_type = workflow_draft_var.value_type
|
||||||
return value_type.exposed_type().value
|
return str(value_type.exposed_type())
|
||||||
|
|
||||||
|
|
||||||
def _serialize_full_content(variable: WorkflowDraftVariable) -> dict | None:
|
class FullContentDict(TypedDict):
|
||||||
|
size_bytes: int | None
|
||||||
|
value_type: str
|
||||||
|
length: int | None
|
||||||
|
download_url: str
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_full_content(variable: WorkflowDraftVariable) -> FullContentDict | None:
|
||||||
"""Serialize full_content information for large variables."""
|
"""Serialize full_content information for large variables."""
|
||||||
if not variable.is_truncated():
|
if not variable.is_truncated():
|
||||||
return None
|
return None
|
||||||
@@ -94,12 +120,13 @@ def _serialize_full_content(variable: WorkflowDraftVariable) -> dict | None:
|
|||||||
variable_file = variable.variable_file
|
variable_file = variable.variable_file
|
||||||
assert variable_file is not None
|
assert variable_file is not None
|
||||||
|
|
||||||
return {
|
result: FullContentDict = {
|
||||||
"size_bytes": variable_file.size,
|
"size_bytes": variable_file.size,
|
||||||
"value_type": variable_file.value_type.exposed_type().value,
|
"value_type": str(variable_file.value_type.exposed_type()),
|
||||||
"length": variable_file.length,
|
"length": variable_file.length,
|
||||||
"download_url": file_helpers.get_signed_file_url(variable_file.upload_file_id, as_attachment=True),
|
"download_url": file_helpers.get_signed_file_url(variable_file.upload_file_id, as_attachment=True),
|
||||||
}
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _ensure_variable_access(
|
def _ensure_variable_access(
|
||||||
@@ -192,11 +219,8 @@ workflow_draft_variable_list_model = console_ns.model(
|
|||||||
"WorkflowDraftVariableList", workflow_draft_variable_list_fields_copy
|
"WorkflowDraftVariableList", workflow_draft_variable_list_fields_copy
|
||||||
)
|
)
|
||||||
|
|
||||||
P = ParamSpec("P")
|
|
||||||
R = TypeVar("R")
|
|
||||||
|
|
||||||
|
def _api_prerequisite[**P, R](f: Callable[P, R]) -> Callable[P, R | Response]:
|
||||||
def _api_prerequisite(f: Callable[P, R]):
|
|
||||||
"""Common prerequisites for all draft workflow variable APIs.
|
"""Common prerequisites for all draft workflow variable APIs.
|
||||||
|
|
||||||
It ensures the following conditions are satisfied:
|
It ensures the following conditions are satisfied:
|
||||||
@@ -213,7 +237,7 @@ def _api_prerequisite(f: Callable[P, R]):
|
|||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def wrapper(*args: P.args, **kwargs: P.kwargs):
|
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | Response:
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
@@ -270,7 +294,7 @@ class WorkflowVariableCollectionApi(Resource):
|
|||||||
return Response("", 204)
|
return Response("", 204)
|
||||||
|
|
||||||
|
|
||||||
def validate_node_id(node_id: str) -> NoReturn | None:
|
def validate_node_id(node_id: str) -> None:
|
||||||
if node_id in [
|
if node_id in [
|
||||||
CONVERSATION_VARIABLE_NODE_ID,
|
CONVERSATION_VARIABLE_NODE_ID,
|
||||||
SYSTEM_VARIABLE_NODE_ID,
|
SYSTEM_VARIABLE_NODE_ID,
|
||||||
@@ -285,7 +309,6 @@ def validate_node_id(node_id: str) -> NoReturn | None:
|
|||||||
raise InvalidArgumentError(
|
raise InvalidArgumentError(
|
||||||
f"invalid node_id, please use correspond api for conversation and system variables, node_id={node_id}",
|
f"invalid node_id, please use correspond api for conversation and system variables, node_id={node_id}",
|
||||||
)
|
)
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/variables")
|
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/variables")
|
||||||
@@ -388,24 +411,27 @@ class VariableApi(Resource):
|
|||||||
|
|
||||||
new_value = None
|
new_value = None
|
||||||
if raw_value is not None:
|
if raw_value is not None:
|
||||||
if variable.value_type == SegmentType.FILE:
|
match variable.value_type:
|
||||||
if not isinstance(raw_value, dict):
|
case SegmentType.FILE:
|
||||||
raise InvalidArgumentError(description=f"expected dict for file, got {type(raw_value)}")
|
if not isinstance(raw_value, dict):
|
||||||
raw_value = build_from_mapping(
|
raise InvalidArgumentError(description=f"expected dict for file, got {type(raw_value)}")
|
||||||
mapping=raw_value,
|
raw_value = build_from_mapping(
|
||||||
tenant_id=app_model.tenant_id,
|
mapping=raw_value,
|
||||||
access_controller=_file_access_controller,
|
tenant_id=app_model.tenant_id,
|
||||||
)
|
access_controller=_file_access_controller,
|
||||||
elif variable.value_type == SegmentType.ARRAY_FILE:
|
)
|
||||||
if not isinstance(raw_value, list):
|
case SegmentType.ARRAY_FILE:
|
||||||
raise InvalidArgumentError(description=f"expected list for files, got {type(raw_value)}")
|
if not isinstance(raw_value, list):
|
||||||
if len(raw_value) > 0 and not isinstance(raw_value[0], dict):
|
raise InvalidArgumentError(description=f"expected list for files, got {type(raw_value)}")
|
||||||
raise InvalidArgumentError(description=f"expected dict for files[0], got {type(raw_value)}")
|
if len(raw_value) > 0 and not isinstance(raw_value[0], dict):
|
||||||
raw_value = build_from_mappings(
|
raise InvalidArgumentError(description=f"expected dict for files[0], got {type(raw_value)}")
|
||||||
mappings=raw_value,
|
raw_value = build_from_mappings(
|
||||||
tenant_id=app_model.tenant_id,
|
mappings=raw_value,
|
||||||
access_controller=_file_access_controller,
|
tenant_id=app_model.tenant_id,
|
||||||
)
|
access_controller=_file_access_controller,
|
||||||
|
)
|
||||||
|
case _:
|
||||||
|
pass
|
||||||
new_value = build_segment_with_type(variable.value_type, raw_value)
|
new_value = build_segment_with_type(variable.value_type, raw_value)
|
||||||
draft_var_srv.update_variable(variable, name=new_name, value=new_value)
|
draft_var_srv.update_variable(variable, name=new_name, value=new_value)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -503,6 +529,34 @@ class ConversationVariableCollectionApi(Resource):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
return _get_variable_list(app_model, CONVERSATION_VARIABLE_NODE_ID)
|
return _get_variable_list(app_model, CONVERSATION_VARIABLE_NODE_ID)
|
||||||
|
|
||||||
|
@console_ns.expect(console_ns.models[ConversationVariableUpdatePayload.__name__])
|
||||||
|
@console_ns.doc("update_conversation_variables")
|
||||||
|
@console_ns.doc(description="Update conversation variables for workflow draft")
|
||||||
|
@console_ns.doc(params={"app_id": "Application ID"})
|
||||||
|
@console_ns.response(200, "Conversation variables updated successfully")
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@edit_permission_required
|
||||||
|
@get_app_model(mode=AppMode.ADVANCED_CHAT)
|
||||||
|
def post(self, app_model: App):
|
||||||
|
payload = ConversationVariableUpdatePayload.model_validate(console_ns.payload or {})
|
||||||
|
|
||||||
|
workflow_service = WorkflowService()
|
||||||
|
|
||||||
|
conversation_variables_list = payload.conversation_variables
|
||||||
|
conversation_variables = [
|
||||||
|
variable_factory.build_conversation_variable_from_mapping(obj) for obj in conversation_variables_list
|
||||||
|
]
|
||||||
|
|
||||||
|
workflow_service.update_draft_workflow_conversation_variables(
|
||||||
|
app_model=app_model,
|
||||||
|
account=current_user,
|
||||||
|
conversation_variables=conversation_variables,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"result": "success"}
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/system-variables")
|
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/system-variables")
|
||||||
class SystemVariableCollectionApi(Resource):
|
class SystemVariableCollectionApi(Resource):
|
||||||
@@ -544,7 +598,7 @@ class EnvironmentVariableCollectionApi(Resource):
|
|||||||
"name": v.name,
|
"name": v.name,
|
||||||
"description": v.description,
|
"description": v.description,
|
||||||
"selector": v.selector,
|
"selector": v.selector,
|
||||||
"value_type": v.value_type.exposed_type().value,
|
"value_type": str(v.value_type.exposed_type()),
|
||||||
"value": v.value,
|
"value": v.value,
|
||||||
# Do not track edited for env vars.
|
# Do not track edited for env vars.
|
||||||
"edited": False,
|
"edited": False,
|
||||||
@@ -554,3 +608,31 @@ class EnvironmentVariableCollectionApi(Resource):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return {"items": env_vars_list}
|
return {"items": env_vars_list}
|
||||||
|
|
||||||
|
@console_ns.expect(console_ns.models[EnvironmentVariableUpdatePayload.__name__])
|
||||||
|
@console_ns.doc("update_environment_variables")
|
||||||
|
@console_ns.doc(description="Update environment variables for workflow draft")
|
||||||
|
@console_ns.doc(params={"app_id": "Application ID"})
|
||||||
|
@console_ns.response(200, "Environment variables updated successfully")
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@edit_permission_required
|
||||||
|
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||||
|
def post(self, app_model: App):
|
||||||
|
payload = EnvironmentVariableUpdatePayload.model_validate(console_ns.payload or {})
|
||||||
|
|
||||||
|
workflow_service = WorkflowService()
|
||||||
|
|
||||||
|
environment_variables_list = payload.environment_variables
|
||||||
|
environment_variables = [
|
||||||
|
variable_factory.build_environment_variable_from_mapping(obj) for obj in environment_variables_list
|
||||||
|
]
|
||||||
|
|
||||||
|
workflow_service.update_draft_workflow_environment_variables(
|
||||||
|
app_model=app_model,
|
||||||
|
account=current_user,
|
||||||
|
environment_variables=environment_variables,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"result": "success"}
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ from typing import Literal, TypedDict, cast
|
|||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask_restx import Resource, fields, marshal_with
|
from flask_restx import Resource, fields, marshal_with
|
||||||
from graphon.entities.pause_reason import HumanInputRequired
|
|
||||||
from graphon.enums import WorkflowExecutionStatus
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
@@ -28,6 +26,8 @@ from fields.workflow_run_fields import (
|
|||||||
workflow_run_node_execution_list_fields,
|
workflow_run_node_execution_list_fields,
|
||||||
workflow_run_pagination_fields,
|
workflow_run_pagination_fields,
|
||||||
)
|
)
|
||||||
|
from graphon.entities.pause_reason import HumanInputRequired
|
||||||
|
from graphon.enums import WorkflowExecutionStatus
|
||||||
from libs.archive_storage import ArchiveStorageNotConfiguredError, get_archive_storage
|
from libs.archive_storage import ArchiveStorageNotConfiguredError, get_archive_storage
|
||||||
from libs.custom_inputs import time_duration
|
from libs.custom_inputs import time_duration
|
||||||
from libs.helper import uuid_value
|
from libs.helper import uuid_value
|
||||||
@@ -36,7 +36,7 @@ from models import Account, App, AppMode, EndUser, WorkflowArchiveLog, WorkflowR
|
|||||||
from models.workflow import WorkflowRun
|
from models.workflow import WorkflowRun
|
||||||
from repositories.factory import DifyAPIRepositoryFactory
|
from repositories.factory import DifyAPIRepositoryFactory
|
||||||
from services.retention.workflow_run.constants import ARCHIVE_BUNDLE_NAME
|
from services.retention.workflow_run.constants import ARCHIVE_BUNDLE_NAME
|
||||||
from services.workflow_run_service import WorkflowRunService
|
from services.workflow_run_service import WorkflowRunListArgs, WorkflowRunService
|
||||||
|
|
||||||
|
|
||||||
def _build_backstage_input_url(form_token: str | None) -> str | None:
|
def _build_backstage_input_url(form_token: str | None) -> str | None:
|
||||||
@@ -214,7 +214,11 @@ class AdvancedChatAppWorkflowRunListApi(Resource):
|
|||||||
Get advanced chat app workflow run list
|
Get advanced chat app workflow run list
|
||||||
"""
|
"""
|
||||||
args_model = WorkflowRunListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
args_model = WorkflowRunListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
||||||
args = args_model.model_dump(exclude_none=True)
|
args: WorkflowRunListArgs = {"limit": args_model.limit}
|
||||||
|
if args_model.last_id is not None:
|
||||||
|
args["last_id"] = args_model.last_id
|
||||||
|
if args_model.status is not None:
|
||||||
|
args["status"] = args_model.status
|
||||||
|
|
||||||
# Default to DEBUGGING if not specified
|
# Default to DEBUGGING if not specified
|
||||||
triggered_from = (
|
triggered_from = (
|
||||||
@@ -356,7 +360,11 @@ class WorkflowRunListApi(Resource):
|
|||||||
Get workflow run list
|
Get workflow run list
|
||||||
"""
|
"""
|
||||||
args_model = WorkflowRunListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
args_model = WorkflowRunListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
||||||
args = args_model.model_dump(exclude_none=True)
|
args: WorkflowRunListArgs = {"limit": args_model.limit}
|
||||||
|
if args_model.last_id is not None:
|
||||||
|
args["last_id"] = args_model.last_id
|
||||||
|
if args_model.status is not None:
|
||||||
|
args["status"] = args_model.status
|
||||||
|
|
||||||
# Default to DEBUGGING for workflow if not specified (backward compatibility)
|
# Default to DEBUGGING for workflow if not specified (backward compatibility)
|
||||||
triggered_from = (
|
triggered_from = (
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask_restx import Resource, fields, marshal_with
|
from flask_restx import Resource
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, field_validator
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
from werkzeug.exceptions import NotFound
|
from werkzeug.exceptions import NotFound
|
||||||
|
|
||||||
from configs import dify_config
|
from configs import dify_config
|
||||||
from controllers.common.schema import get_or_create_model
|
from controllers.common.schema import register_schema_models
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from fields.workflow_trigger_fields import trigger_fields, triggers_list_fields, webhook_trigger_fields
|
from fields.base import ResponseModel
|
||||||
from libs.login import current_user, login_required
|
from libs.login import current_user, login_required
|
||||||
from models.enums import AppTriggerStatus
|
from models.enums import AppTriggerStatus
|
||||||
from models.model import Account, App, AppMode
|
from models.model import Account, App, AppMode
|
||||||
@@ -21,15 +22,6 @@ from ..app.wraps import get_app_model
|
|||||||
from ..wraps import account_initialization_required, edit_permission_required, setup_required
|
from ..wraps import account_initialization_required, edit_permission_required, setup_required
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
|
|
||||||
|
|
||||||
trigger_model = get_or_create_model("WorkflowTrigger", trigger_fields)
|
|
||||||
|
|
||||||
triggers_list_fields_copy = triggers_list_fields.copy()
|
|
||||||
triggers_list_fields_copy["data"] = fields.List(fields.Nested(trigger_model))
|
|
||||||
triggers_list_model = get_or_create_model("WorkflowTriggerList", triggers_list_fields_copy)
|
|
||||||
|
|
||||||
webhook_trigger_model = get_or_create_model("WebhookTrigger", webhook_trigger_fields)
|
|
||||||
|
|
||||||
|
|
||||||
class Parser(BaseModel):
|
class Parser(BaseModel):
|
||||||
@@ -41,10 +33,52 @@ class ParserEnable(BaseModel):
|
|||||||
enable_trigger: bool
|
enable_trigger: bool
|
||||||
|
|
||||||
|
|
||||||
console_ns.schema_model(Parser.__name__, Parser.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0))
|
class WorkflowTriggerResponse(ResponseModel):
|
||||||
|
id: str
|
||||||
|
trigger_type: str
|
||||||
|
title: str
|
||||||
|
node_id: str
|
||||||
|
provider_name: str
|
||||||
|
icon: str
|
||||||
|
status: str
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
console_ns.schema_model(
|
@field_validator("id", "trigger_type", "title", "node_id", "provider_name", "icon", "status", mode="before")
|
||||||
ParserEnable.__name__, ParserEnable.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
|
@classmethod
|
||||||
|
def _normalize_string_fields(cls, value: object) -> str:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowTriggerListResponse(ResponseModel):
|
||||||
|
data: list[WorkflowTriggerResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookTriggerResponse(ResponseModel):
|
||||||
|
id: str
|
||||||
|
webhook_id: str
|
||||||
|
webhook_url: str
|
||||||
|
webhook_debug_url: str
|
||||||
|
node_id: str
|
||||||
|
created_at: datetime | None = None
|
||||||
|
|
||||||
|
@field_validator("id", "webhook_id", "webhook_url", "webhook_debug_url", "node_id", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_string_fields(cls, value: object) -> str:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
register_schema_models(
|
||||||
|
console_ns,
|
||||||
|
Parser,
|
||||||
|
ParserEnable,
|
||||||
|
WorkflowTriggerResponse,
|
||||||
|
WorkflowTriggerListResponse,
|
||||||
|
WebhookTriggerResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -57,28 +91,28 @@ class WebhookTriggerApi(Resource):
|
|||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=AppMode.WORKFLOW)
|
@get_app_model(mode=AppMode.WORKFLOW)
|
||||||
@marshal_with(webhook_trigger_model)
|
@console_ns.response(200, "Success", console_ns.models[WebhookTriggerResponse.__name__])
|
||||||
def get(self, app_model: App):
|
def get(self, app_model: App):
|
||||||
"""Get webhook trigger for a node"""
|
"""Get webhook trigger for a node"""
|
||||||
args = Parser.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
args = Parser.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
||||||
|
|
||||||
node_id = args.node_id
|
node_id = args.node_id
|
||||||
|
|
||||||
with sessionmaker(db.engine).begin() as session:
|
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
|
||||||
# Get webhook trigger for this app and node
|
# Get webhook trigger for this app and node
|
||||||
webhook_trigger = (
|
webhook_trigger = session.scalar(
|
||||||
session.query(WorkflowWebhookTrigger)
|
select(WorkflowWebhookTrigger)
|
||||||
.where(
|
.where(
|
||||||
WorkflowWebhookTrigger.app_id == app_model.id,
|
WorkflowWebhookTrigger.app_id == app_model.id,
|
||||||
WorkflowWebhookTrigger.node_id == node_id,
|
WorkflowWebhookTrigger.node_id == node_id,
|
||||||
)
|
)
|
||||||
.first()
|
.limit(1)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not webhook_trigger:
|
if not webhook_trigger:
|
||||||
raise NotFound("Webhook trigger not found for this node")
|
raise NotFound("Webhook trigger not found for this node")
|
||||||
|
|
||||||
return webhook_trigger
|
return WebhookTriggerResponse.model_validate(webhook_trigger, from_attributes=True).model_dump(mode="json")
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/apps/<uuid:app_id>/triggers")
|
@console_ns.route("/apps/<uuid:app_id>/triggers")
|
||||||
@@ -89,13 +123,13 @@ class AppTriggersApi(Resource):
|
|||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=AppMode.WORKFLOW)
|
@get_app_model(mode=AppMode.WORKFLOW)
|
||||||
@marshal_with(triggers_list_model)
|
@console_ns.response(200, "Success", console_ns.models[WorkflowTriggerListResponse.__name__])
|
||||||
def get(self, app_model: App):
|
def get(self, app_model: App):
|
||||||
"""Get app triggers list"""
|
"""Get app triggers list"""
|
||||||
assert isinstance(current_user, Account)
|
assert isinstance(current_user, Account)
|
||||||
assert current_user.current_tenant_id is not None
|
assert current_user.current_tenant_id is not None
|
||||||
|
|
||||||
with sessionmaker(db.engine).begin() as session:
|
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
|
||||||
# Get all triggers for this app using select API
|
# Get all triggers for this app using select API
|
||||||
triggers = (
|
triggers = (
|
||||||
session.execute(
|
session.execute(
|
||||||
@@ -118,7 +152,9 @@ class AppTriggersApi(Resource):
|
|||||||
else:
|
else:
|
||||||
trigger.icon = "" # type: ignore
|
trigger.icon = "" # type: ignore
|
||||||
|
|
||||||
return {"data": triggers}
|
return WorkflowTriggerListResponse.model_validate({"data": triggers}, from_attributes=True).model_dump(
|
||||||
|
mode="json"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/apps/<uuid:app_id>/trigger-enable")
|
@console_ns.route("/apps/<uuid:app_id>/trigger-enable")
|
||||||
@@ -129,7 +165,7 @@ class AppTriggerEnableApi(Resource):
|
|||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
@get_app_model(mode=AppMode.WORKFLOW)
|
@get_app_model(mode=AppMode.WORKFLOW)
|
||||||
@marshal_with(trigger_model)
|
@console_ns.response(200, "Success", console_ns.models[WorkflowTriggerResponse.__name__])
|
||||||
def post(self, app_model: App):
|
def post(self, app_model: App):
|
||||||
"""Update app trigger (enable/disable)"""
|
"""Update app trigger (enable/disable)"""
|
||||||
args = ParserEnable.model_validate(console_ns.payload)
|
args = ParserEnable.model_validate(console_ns.payload)
|
||||||
@@ -160,4 +196,4 @@ class AppTriggerEnableApi(Resource):
|
|||||||
else:
|
else:
|
||||||
trigger.icon = "" # type: ignore
|
trigger.icon = "" # type: ignore
|
||||||
|
|
||||||
return trigger
|
return WorkflowTriggerResponse.model_validate(trigger, from_attributes=True).model_dump(mode="json")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import ParamSpec, TypeVar, Union
|
from typing import overload
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
@@ -9,11 +9,6 @@ from extensions.ext_database import db
|
|||||||
from libs.login import current_account_with_tenant
|
from libs.login import current_account_with_tenant
|
||||||
from models import App, AppMode
|
from models import App, AppMode
|
||||||
|
|
||||||
P = ParamSpec("P")
|
|
||||||
R = TypeVar("R")
|
|
||||||
P1 = ParamSpec("P1")
|
|
||||||
R1 = TypeVar("R1")
|
|
||||||
|
|
||||||
|
|
||||||
def _load_app_model(app_id: str) -> App | None:
|
def _load_app_model(app_id: str) -> App | None:
|
||||||
_, current_tenant_id = current_account_with_tenant()
|
_, current_tenant_id = current_account_with_tenant()
|
||||||
@@ -28,10 +23,30 @@ def _load_app_model_with_trial(app_id: str) -> App | None:
|
|||||||
return app_model
|
return app_model
|
||||||
|
|
||||||
|
|
||||||
def get_app_model(view: Callable[P, R] | None = None, *, mode: Union[AppMode, list[AppMode], None] = None):
|
@overload
|
||||||
def decorator(view_func: Callable[P1, R1]):
|
def get_app_model[**P, R](
|
||||||
|
view: Callable[P, R],
|
||||||
|
*,
|
||||||
|
mode: AppMode | list[AppMode] | None = None,
|
||||||
|
) -> Callable[P, R]: ...
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def get_app_model[**P, R](
|
||||||
|
view: None = None,
|
||||||
|
*,
|
||||||
|
mode: AppMode | list[AppMode] | None = None,
|
||||||
|
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
|
||||||
|
|
||||||
|
|
||||||
|
def get_app_model[**P, R](
|
||||||
|
view: Callable[P, R] | None = None,
|
||||||
|
*,
|
||||||
|
mode: AppMode | list[AppMode] | None = None,
|
||||||
|
) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]:
|
||||||
|
def decorator(view_func: Callable[P, R]) -> Callable[P, R]:
|
||||||
@wraps(view_func)
|
@wraps(view_func)
|
||||||
def decorated_view(*args: P1.args, **kwargs: P1.kwargs):
|
def decorated_view(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||||
if not kwargs.get("app_id"):
|
if not kwargs.get("app_id"):
|
||||||
raise ValueError("missing app_id in path parameters")
|
raise ValueError("missing app_id in path parameters")
|
||||||
|
|
||||||
@@ -69,10 +84,30 @@ def get_app_model(view: Callable[P, R] | None = None, *, mode: Union[AppMode, li
|
|||||||
return decorator(view)
|
return decorator(view)
|
||||||
|
|
||||||
|
|
||||||
def get_app_model_with_trial(view: Callable[P, R] | None = None, *, mode: Union[AppMode, list[AppMode], None] = None):
|
@overload
|
||||||
def decorator(view_func: Callable[P, R]):
|
def get_app_model_with_trial[**P, R](
|
||||||
|
view: Callable[P, R],
|
||||||
|
*,
|
||||||
|
mode: AppMode | list[AppMode] | None = None,
|
||||||
|
) -> Callable[P, R]: ...
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def get_app_model_with_trial[**P, R](
|
||||||
|
view: None = None,
|
||||||
|
*,
|
||||||
|
mode: AppMode | list[AppMode] | None = None,
|
||||||
|
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
|
||||||
|
|
||||||
|
|
||||||
|
def get_app_model_with_trial[**P, R](
|
||||||
|
view: Callable[P, R] | None = None,
|
||||||
|
*,
|
||||||
|
mode: AppMode | list[AppMode] | None = None,
|
||||||
|
) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]:
|
||||||
|
def decorator(view_func: Callable[P, R]) -> Callable[P, R]:
|
||||||
@wraps(view_func)
|
@wraps(view_func)
|
||||||
def decorated_view(*args: P.args, **kwargs: P.kwargs):
|
def decorated_view(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||||
if not kwargs.get("app_id"):
|
if not kwargs.get("app_id"):
|
||||||
raise ValueError("missing app_id in path parameters")
|
raise ValueError("missing app_id in path parameters")
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask_restx import Resource, fields
|
from flask_restx import Resource
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
from constants.languages import supported_language
|
from constants.languages import supported_language
|
||||||
|
from controllers.common.schema import register_schema_models
|
||||||
from controllers.console import console_ns
|
from controllers.console import console_ns
|
||||||
from controllers.console.error import AlreadyActivateError
|
from controllers.console.error import AlreadyActivateError
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
@@ -11,8 +14,6 @@ from libs.helper import EmailStr, timezone
|
|||||||
from models import AccountStatus
|
from models import AccountStatus
|
||||||
from services.account_service import RegisterService
|
from services.account_service import RegisterService
|
||||||
|
|
||||||
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
|
|
||||||
|
|
||||||
|
|
||||||
class ActivateCheckQuery(BaseModel):
|
class ActivateCheckQuery(BaseModel):
|
||||||
workspace_id: str | None = Field(default=None)
|
workspace_id: str | None = Field(default=None)
|
||||||
@@ -39,8 +40,16 @@ class ActivatePayload(BaseModel):
|
|||||||
return timezone(value)
|
return timezone(value)
|
||||||
|
|
||||||
|
|
||||||
for model in (ActivateCheckQuery, ActivatePayload):
|
class ActivationCheckResponse(BaseModel):
|
||||||
console_ns.schema_model(model.__name__, model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0))
|
is_valid: bool = Field(description="Whether token is valid")
|
||||||
|
data: dict[str, Any] | None = Field(default=None, description="Activation data if valid")
|
||||||
|
|
||||||
|
|
||||||
|
class ActivationResponse(BaseModel):
|
||||||
|
result: str = Field(description="Operation result")
|
||||||
|
|
||||||
|
|
||||||
|
register_schema_models(console_ns, ActivateCheckQuery, ActivatePayload, ActivationCheckResponse, ActivationResponse)
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/activate/check")
|
@console_ns.route("/activate/check")
|
||||||
@@ -51,13 +60,7 @@ class ActivateCheckApi(Resource):
|
|||||||
@console_ns.response(
|
@console_ns.response(
|
||||||
200,
|
200,
|
||||||
"Success",
|
"Success",
|
||||||
console_ns.model(
|
console_ns.models[ActivationCheckResponse.__name__],
|
||||||
"ActivationCheckResponse",
|
|
||||||
{
|
|
||||||
"is_valid": fields.Boolean(description="Whether token is valid"),
|
|
||||||
"data": fields.Raw(description="Activation data if valid"),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
def get(self):
|
def get(self):
|
||||||
args = ActivateCheckQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
args = ActivateCheckQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
||||||
@@ -95,12 +98,7 @@ class ActivateApi(Resource):
|
|||||||
@console_ns.response(
|
@console_ns.response(
|
||||||
200,
|
200,
|
||||||
"Account activated successfully",
|
"Account activated successfully",
|
||||||
console_ns.model(
|
console_ns.models[ActivationResponse.__name__],
|
||||||
"ActivationResponse",
|
|
||||||
{
|
|
||||||
"result": fields.String(description="Operation result"),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
@console_ns.response(400, "Already activated or invalid token")
|
@console_ns.response(400, "Already activated or invalid token")
|
||||||
def post(self):
|
def post(self):
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from flask import request
|
from flask import request
|
||||||
from flask_restx import Resource
|
from flask_restx import Resource
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
from configs import dify_config
|
from configs import dify_config
|
||||||
from constants.languages import languages
|
from constants.languages import languages
|
||||||
@@ -14,7 +13,6 @@ from controllers.console.auth.error import (
|
|||||||
InvalidTokenError,
|
InvalidTokenError,
|
||||||
PasswordMismatchError,
|
PasswordMismatchError,
|
||||||
)
|
)
|
||||||
from extensions.ext_database import db
|
|
||||||
from libs.helper import EmailStr, extract_remote_ip
|
from libs.helper import EmailStr, extract_remote_ip
|
||||||
from libs.password import valid_password
|
from libs.password import valid_password
|
||||||
from models import Account
|
from models import Account
|
||||||
@@ -73,8 +71,7 @@ class EmailRegisterSendEmailApi(Resource):
|
|||||||
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(normalized_email):
|
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(normalized_email):
|
||||||
raise AccountInFreezeError()
|
raise AccountInFreezeError()
|
||||||
|
|
||||||
with sessionmaker(db.engine).begin() as session:
|
account = AccountService.get_account_by_email_with_case_fallback(args.email)
|
||||||
account = AccountService.get_account_by_email_with_case_fallback(args.email, session=session)
|
|
||||||
token = AccountService.send_email_register_email(email=normalized_email, account=account, language=language)
|
token = AccountService.send_email_register_email(email=normalized_email, account=account, language=language)
|
||||||
return {"result": "success", "data": token}
|
return {"result": "success", "data": token}
|
||||||
|
|
||||||
@@ -145,17 +142,16 @@ class EmailRegisterResetApi(Resource):
|
|||||||
email = register_data.get("email", "")
|
email = register_data.get("email", "")
|
||||||
normalized_email = email.lower()
|
normalized_email = email.lower()
|
||||||
|
|
||||||
with sessionmaker(db.engine).begin() as session:
|
account = AccountService.get_account_by_email_with_case_fallback(email)
|
||||||
account = AccountService.get_account_by_email_with_case_fallback(email, session=session)
|
|
||||||
|
|
||||||
if account:
|
if account:
|
||||||
raise EmailAlreadyInUseError()
|
raise EmailAlreadyInUseError()
|
||||||
else:
|
else:
|
||||||
account = self._create_new_account(normalized_email, args.password_confirm)
|
account = self._create_new_account(normalized_email, args.password_confirm)
|
||||||
if not account:
|
if not account:
|
||||||
raise AccountNotFoundError()
|
raise AccountNotFoundError()
|
||||||
token_pair = AccountService.login(account=account, ip_address=extract_remote_ip(request))
|
token_pair = AccountService.login(account=account, ip_address=extract_remote_ip(request))
|
||||||
AccountService.reset_login_error_rate_limit(normalized_email)
|
AccountService.reset_login_error_rate_limit(normalized_email)
|
||||||
|
|
||||||
return {"result": "success", "data": token_pair.model_dump()}
|
return {"result": "success", "data": token_pair.model_dump()}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import secrets
|
|||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask_restx import Resource
|
from flask_restx import Resource
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
from controllers.common.schema import register_schema_models
|
from controllers.common.schema import register_schema_models
|
||||||
from controllers.console import console_ns
|
from controllers.console import console_ns
|
||||||
@@ -20,35 +19,18 @@ from controllers.console.wraps import email_password_login_enabled, setup_requir
|
|||||||
from events.tenant_event import tenant_was_created
|
from events.tenant_event import tenant_was_created
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from libs.helper import EmailStr, extract_remote_ip
|
from libs.helper import EmailStr, extract_remote_ip
|
||||||
from libs.password import hash_password, valid_password
|
from libs.password import hash_password
|
||||||
from services.account_service import AccountService, TenantService
|
from services.account_service import AccountService, TenantService
|
||||||
|
from services.entities.auth_entities import (
|
||||||
|
ForgotPasswordCheckPayload,
|
||||||
|
ForgotPasswordResetPayload,
|
||||||
|
ForgotPasswordSendPayload,
|
||||||
|
)
|
||||||
from services.feature_service import FeatureService
|
from services.feature_service import FeatureService
|
||||||
|
|
||||||
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
|
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
|
||||||
|
|
||||||
|
|
||||||
class ForgotPasswordSendPayload(BaseModel):
|
|
||||||
email: EmailStr = Field(...)
|
|
||||||
language: str | None = Field(default=None)
|
|
||||||
|
|
||||||
|
|
||||||
class ForgotPasswordCheckPayload(BaseModel):
|
|
||||||
email: EmailStr = Field(...)
|
|
||||||
code: str = Field(...)
|
|
||||||
token: str = Field(...)
|
|
||||||
|
|
||||||
|
|
||||||
class ForgotPasswordResetPayload(BaseModel):
|
|
||||||
token: str = Field(...)
|
|
||||||
new_password: str = Field(...)
|
|
||||||
password_confirm: str = Field(...)
|
|
||||||
|
|
||||||
@field_validator("new_password", "password_confirm")
|
|
||||||
@classmethod
|
|
||||||
def validate_password(cls, value: str) -> str:
|
|
||||||
return valid_password(value)
|
|
||||||
|
|
||||||
|
|
||||||
class ForgotPasswordEmailResponse(BaseModel):
|
class ForgotPasswordEmailResponse(BaseModel):
|
||||||
result: str = Field(description="Operation result")
|
result: str = Field(description="Operation result")
|
||||||
data: str | None = Field(default=None, description="Reset token")
|
data: str | None = Field(default=None, description="Reset token")
|
||||||
@@ -102,8 +84,7 @@ class ForgotPasswordSendEmailApi(Resource):
|
|||||||
else:
|
else:
|
||||||
language = "en-US"
|
language = "en-US"
|
||||||
|
|
||||||
with sessionmaker(db.engine).begin() as session:
|
account = AccountService.get_account_by_email_with_case_fallback(args.email)
|
||||||
account = AccountService.get_account_by_email_with_case_fallback(args.email, session=session)
|
|
||||||
|
|
||||||
token = AccountService.send_reset_password_email(
|
token = AccountService.send_reset_password_email(
|
||||||
account=account,
|
account=account,
|
||||||
@@ -201,17 +182,18 @@ class ForgotPasswordResetApi(Resource):
|
|||||||
password_hashed = hash_password(args.new_password, salt)
|
password_hashed = hash_password(args.new_password, salt)
|
||||||
|
|
||||||
email = reset_data.get("email", "")
|
email = reset_data.get("email", "")
|
||||||
with sessionmaker(db.engine).begin() as session:
|
account = AccountService.get_account_by_email_with_case_fallback(email)
|
||||||
account = AccountService.get_account_by_email_with_case_fallback(email, session=session)
|
|
||||||
|
|
||||||
if account:
|
if account:
|
||||||
self._update_existing_account(account, password_hashed, salt, session)
|
account = db.session.merge(account)
|
||||||
else:
|
self._update_existing_account(account, password_hashed, salt)
|
||||||
raise AccountNotFound()
|
db.session.commit()
|
||||||
|
else:
|
||||||
|
raise AccountNotFound()
|
||||||
|
|
||||||
return {"result": "success"}
|
return {"result": "success"}
|
||||||
|
|
||||||
def _update_existing_account(self, account, password_hashed, salt, session):
|
def _update_existing_account(self, account, password_hashed, salt):
|
||||||
# Update existing account credentials
|
# Update existing account credentials
|
||||||
account.password = base64.b64encode(password_hashed).decode()
|
account.password = base64.b64encode(password_hashed).decode()
|
||||||
account.password_salt = base64.b64encode(salt).decode()
|
account.password_salt = base64.b64encode(salt).decode()
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from typing import Any
|
import logging
|
||||||
|
|
||||||
import flask_login
|
import flask_login
|
||||||
from flask import make_response, request
|
from flask import make_response, request
|
||||||
from flask_restx import Resource
|
from flask_restx import Resource
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from werkzeug.exceptions import Unauthorized
|
||||||
|
|
||||||
import services
|
import services
|
||||||
from configs import dify_config
|
from configs import dify_config
|
||||||
@@ -42,18 +43,18 @@ from libs.token import (
|
|||||||
set_csrf_token_to_cookie,
|
set_csrf_token_to_cookie,
|
||||||
set_refresh_token_to_cookie,
|
set_refresh_token_to_cookie,
|
||||||
)
|
)
|
||||||
from services.account_service import AccountService, RegisterService, TenantService
|
from services.account_service import AccountService, InvitationDetailDict, RegisterService, TenantService
|
||||||
from services.billing_service import BillingService
|
from services.billing_service import BillingService
|
||||||
|
from services.entities.auth_entities import LoginFailureReason, LoginPayloadBase
|
||||||
from services.errors.account import AccountRegisterError
|
from services.errors.account import AccountRegisterError
|
||||||
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
|
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
|
||||||
from services.feature_service import FeatureService
|
from services.feature_service import FeatureService
|
||||||
|
|
||||||
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
|
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class LoginPayload(BaseModel):
|
class LoginPayload(LoginPayloadBase):
|
||||||
email: EmailStr = Field(..., description="Email address")
|
|
||||||
password: str = Field(..., description="Password")
|
|
||||||
remember_me: bool = Field(default=False, description="Remember me flag")
|
remember_me: bool = Field(default=False, description="Remember me flag")
|
||||||
invite_token: str | None = Field(default=None, description="Invitation token")
|
invite_token: str | None = Field(default=None, description="Invitation token")
|
||||||
|
|
||||||
@@ -94,14 +95,16 @@ class LoginApi(Resource):
|
|||||||
normalized_email = request_email.lower()
|
normalized_email = request_email.lower()
|
||||||
|
|
||||||
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(normalized_email):
|
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(normalized_email):
|
||||||
|
_log_console_login_failure(email=normalized_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
|
||||||
raise AccountInFreezeError()
|
raise AccountInFreezeError()
|
||||||
|
|
||||||
is_login_error_rate_limit = AccountService.is_login_error_rate_limit(normalized_email)
|
is_login_error_rate_limit = AccountService.is_login_error_rate_limit(normalized_email)
|
||||||
if is_login_error_rate_limit:
|
if is_login_error_rate_limit:
|
||||||
|
_log_console_login_failure(email=normalized_email, reason=LoginFailureReason.LOGIN_RATE_LIMITED)
|
||||||
raise EmailPasswordLoginLimitError()
|
raise EmailPasswordLoginLimitError()
|
||||||
|
|
||||||
invite_token = args.invite_token
|
invite_token = args.invite_token
|
||||||
invitation_data: dict[str, Any] | None = None
|
invitation_data: InvitationDetailDict | None = None
|
||||||
if invite_token:
|
if invite_token:
|
||||||
invitation_data = RegisterService.get_invitation_with_case_fallback(None, request_email, invite_token)
|
invitation_data = RegisterService.get_invitation_with_case_fallback(None, request_email, invite_token)
|
||||||
if invitation_data is None:
|
if invitation_data is None:
|
||||||
@@ -113,14 +116,20 @@ class LoginApi(Resource):
|
|||||||
invitee_email = data.get("email") if data else None
|
invitee_email = data.get("email") if data else None
|
||||||
invitee_email_normalized = invitee_email.lower() if isinstance(invitee_email, str) else invitee_email
|
invitee_email_normalized = invitee_email.lower() if isinstance(invitee_email, str) else invitee_email
|
||||||
if invitee_email_normalized != normalized_email:
|
if invitee_email_normalized != normalized_email:
|
||||||
|
_log_console_login_failure(
|
||||||
|
email=normalized_email,
|
||||||
|
reason=LoginFailureReason.INVALID_INVITATION_EMAIL,
|
||||||
|
)
|
||||||
raise InvalidEmailError()
|
raise InvalidEmailError()
|
||||||
account = _authenticate_account_with_case_fallback(
|
account = _authenticate_account_with_case_fallback(
|
||||||
request_email, normalized_email, args.password, invite_token
|
request_email, normalized_email, args.password, invite_token
|
||||||
)
|
)
|
||||||
except services.errors.account.AccountLoginError:
|
except services.errors.account.AccountLoginError:
|
||||||
|
_log_console_login_failure(email=normalized_email, reason=LoginFailureReason.ACCOUNT_BANNED)
|
||||||
raise AccountBannedError()
|
raise AccountBannedError()
|
||||||
except services.errors.account.AccountPasswordError as exc:
|
except services.errors.account.AccountPasswordError as exc:
|
||||||
AccountService.add_login_error_rate_limit(normalized_email)
|
AccountService.add_login_error_rate_limit(normalized_email)
|
||||||
|
_log_console_login_failure(email=normalized_email, reason=LoginFailureReason.INVALID_CREDENTIALS)
|
||||||
raise AuthenticationFailedError() from exc
|
raise AuthenticationFailedError() from exc
|
||||||
# SELF_HOSTED only have one workspace
|
# SELF_HOSTED only have one workspace
|
||||||
tenants = TenantService.get_join_tenants(account)
|
tenants = TenantService.get_join_tenants(account)
|
||||||
@@ -243,20 +252,27 @@ class EmailCodeLoginApi(Resource):
|
|||||||
|
|
||||||
token_data = AccountService.get_email_code_login_data(args.token)
|
token_data = AccountService.get_email_code_login_data(args.token)
|
||||||
if token_data is None:
|
if token_data is None:
|
||||||
|
_log_console_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE_TOKEN)
|
||||||
raise InvalidTokenError()
|
raise InvalidTokenError()
|
||||||
|
|
||||||
token_email = token_data.get("email")
|
token_email = token_data.get("email")
|
||||||
normalized_token_email = token_email.lower() if isinstance(token_email, str) else token_email
|
normalized_token_email = token_email.lower() if isinstance(token_email, str) else token_email
|
||||||
if normalized_token_email != user_email:
|
if normalized_token_email != user_email:
|
||||||
|
_log_console_login_failure(email=user_email, reason=LoginFailureReason.EMAIL_CODE_EMAIL_MISMATCH)
|
||||||
raise InvalidEmailError()
|
raise InvalidEmailError()
|
||||||
|
|
||||||
if token_data["code"] != args.code:
|
if token_data["code"] != args.code:
|
||||||
|
_log_console_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE)
|
||||||
raise EmailCodeError()
|
raise EmailCodeError()
|
||||||
|
|
||||||
AccountService.revoke_email_code_login_token(args.token)
|
AccountService.revoke_email_code_login_token(args.token)
|
||||||
try:
|
try:
|
||||||
account = _get_account_with_case_fallback(original_email)
|
account = _get_account_with_case_fallback(original_email)
|
||||||
|
except Unauthorized as exc:
|
||||||
|
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_BANNED)
|
||||||
|
raise AccountBannedError() from exc
|
||||||
except AccountRegisterError:
|
except AccountRegisterError:
|
||||||
|
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
|
||||||
raise AccountInFreezeError()
|
raise AccountInFreezeError()
|
||||||
if account:
|
if account:
|
||||||
tenants = TenantService.get_join_tenants(account)
|
tenants = TenantService.get_join_tenants(account)
|
||||||
@@ -282,6 +298,7 @@ class EmailCodeLoginApi(Resource):
|
|||||||
except WorkSpaceNotAllowedCreateError:
|
except WorkSpaceNotAllowedCreateError:
|
||||||
raise NotAllowedCreateWorkspace()
|
raise NotAllowedCreateWorkspace()
|
||||||
except AccountRegisterError:
|
except AccountRegisterError:
|
||||||
|
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
|
||||||
raise AccountInFreezeError()
|
raise AccountInFreezeError()
|
||||||
except WorkspacesLimitExceededError:
|
except WorkspacesLimitExceededError:
|
||||||
raise WorkspacesLimitExceeded()
|
raise WorkspacesLimitExceeded()
|
||||||
@@ -339,3 +356,12 @@ def _authenticate_account_with_case_fallback(
|
|||||||
if original_email == normalized_email:
|
if original_email == normalized_email:
|
||||||
raise
|
raise
|
||||||
return AccountService.authenticate(normalized_email, password, invite_token)
|
return AccountService.authenticate(normalized_email, password, invite_token)
|
||||||
|
|
||||||
|
|
||||||
|
def _log_console_login_failure(*, email: str, reason: LoginFailureReason) -> None:
|
||||||
|
logger.warning(
|
||||||
|
"Console login failed: email=%s reason=%s ip_address=%s",
|
||||||
|
email,
|
||||||
|
reason,
|
||||||
|
extract_remote_ip(request),
|
||||||
|
)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import urllib.parse
|
|||||||
import httpx
|
import httpx
|
||||||
from flask import current_app, redirect, request
|
from flask import current_app, redirect, request
|
||||||
from flask_restx import Resource
|
from flask_restx import Resource
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
from werkzeug.exceptions import Unauthorized
|
from werkzeug.exceptions import Unauthorized
|
||||||
|
|
||||||
from configs import dify_config
|
from configs import dify_config
|
||||||
@@ -180,8 +179,7 @@ def _get_account_by_openid_or_email(provider: str, user_info: OAuthUserInfo) ->
|
|||||||
account: Account | None = Account.get_by_openid(provider, user_info.id)
|
account: Account | None = Account.get_by_openid(provider, user_info.id)
|
||||||
|
|
||||||
if not account:
|
if not account:
|
||||||
with sessionmaker(db.engine).begin() as session:
|
account = AccountService.get_account_by_email_with_case_fallback(user_info.email)
|
||||||
account = AccountService.get_account_by_email_with_case_fallback(user_info.email, session=session)
|
|
||||||
|
|
||||||
return account
|
return account
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user