Compare commits
599
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f582c1b2f | ||
|
|
f734c35443 | ||
|
|
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 | ||
|
|
391007d02e | ||
|
|
e41965061c | ||
|
|
2b9eb06555 | ||
|
|
31f7752ba9 | ||
|
|
b23ea0397a | ||
|
|
c51cd42cb4 | ||
|
|
09ee8ea1f5 | ||
|
|
beda78e911 | ||
|
|
42d7623cc6 | ||
|
|
4bd388669a | ||
|
|
324b47507c | ||
|
|
d2baacdd4b | ||
|
|
57f358a96b | ||
|
|
19530e880a | ||
|
|
dbdbb098d5 | ||
|
|
2c8b47ce44 | ||
|
|
cf50d7c7b5 | ||
|
|
d9a0665b2c | ||
|
|
b818cc0766 | ||
|
|
90f94be2b3 | ||
|
|
24111facdd | ||
|
|
424d34a9c0 | ||
|
|
fbd2d31624 | ||
|
|
b54a0dc1e4 | ||
|
|
f27d669f87 | ||
|
|
fcf04629d3 | ||
|
|
6b0c6d0cde | ||
|
|
1063e021f2 | ||
|
|
303f548408 | ||
|
|
cc68f0e640 | ||
|
|
9b7b432e08 | ||
|
|
88863609e9 | ||
|
|
adc6c6c13b | ||
|
|
2de818530b | ||
|
|
7e4754392d | ||
|
|
01c857a67a | ||
|
|
2c2cc72150 | ||
|
|
f7b78b08fd | ||
|
|
f0e6f11c1c | ||
|
|
a19243068b | ||
|
|
323c51e095 | ||
|
|
bbc3f90928 | ||
|
|
1344c3b280 | ||
|
|
5897b28355 | ||
|
|
15aa8071f8 | ||
|
|
097095a69b | ||
|
|
daebe26089 | ||
|
|
c58170f5b8 | ||
|
|
3a7885819d | ||
|
|
5fc4dfaf7b | ||
|
|
953bcc33b1 | ||
|
|
bc14ad6a8f | ||
|
|
cc89b57c1f | ||
|
|
623c8ae803 | ||
|
|
dede190be2 | ||
|
|
a1513f06c3 | ||
|
|
3c7180bfd5 | ||
|
|
51f6ca2bed | ||
|
|
ae9a16a397 | ||
|
|
52a4bea88f | ||
|
|
1aaba80211 | ||
|
|
944db46d4f | ||
|
|
456684dfc3 | ||
|
|
40fa0f365c | ||
|
|
2cb71ad443 | ||
|
|
8a277da278 | ||
|
|
7dd802201f | ||
|
|
79b952ea08 | ||
|
|
397165a524 | ||
|
|
dc3f13991a | ||
|
|
8ef657531e | ||
|
|
b40a4c27d3 | ||
|
|
f0be15ded8 | ||
|
|
7fc161f781 | ||
|
|
2b54d205fe | ||
|
|
1940d05e64 | ||
|
|
905288423f | ||
|
|
62376f507b | ||
|
|
51c8dad753 | ||
|
|
540906fb8a | ||
|
|
b642f5c3e5 | ||
|
|
b36b077d42 | ||
|
|
fe9c2b0e4b | ||
|
|
548cadacff | ||
|
|
a1171877a4 | ||
|
|
f06cc339cc | ||
|
|
6bf8982559 | ||
|
|
364d7ebc40 | ||
|
|
7cc81e9a43 | ||
|
|
3409c519e2 | ||
|
|
5851b42af3 | ||
|
|
c5eae67ac9 | ||
|
|
865ee473ce | ||
|
|
08e8145975 | ||
|
|
ec0f20de03 | ||
|
|
40591a7c50 | ||
|
|
32d394d65b | ||
|
|
66fab8722c | ||
|
|
5a8a68cab8 | ||
|
|
689761bfcb | ||
|
|
2394e45ec7 | ||
|
|
01e6a3a9d9 | ||
|
|
07f4950cb3 | ||
|
|
368896d84d | ||
|
|
408f650b0c | ||
|
|
7c2e1fa3e2 | ||
|
|
1da66b9a8c | ||
|
|
4953762f4e | ||
|
|
97764c4a57 | ||
|
|
2ea85d3ba2 | ||
|
|
1f11300175 | ||
|
|
f317db525f | ||
|
|
3fa0538f72 | ||
|
|
fcfc96ca05 | ||
|
|
69c2b422de | ||
|
|
496baa9335 | ||
|
|
e8657cc3de | ||
|
|
e08c06cbc3 | ||
|
|
8ca54ddf94 | ||
|
|
3e073404cc | ||
|
|
0acabf5f73 | ||
|
|
38285aa1ac | ||
|
|
5341cd015b | ||
|
|
c32eebf57d | ||
|
|
554ba6b8f3 | ||
|
|
a69b8c1e96 | ||
|
|
6f3fcf2276 | ||
|
|
3df4bba280 | ||
|
|
7c0d2e1d98 | ||
|
|
a9336b74fd | ||
|
|
518937b87f | ||
|
|
e6ab9abf19 | ||
|
|
87a25e326c | ||
|
|
baf7d2c7c0 | ||
|
|
22dd0aa20c | ||
|
|
52e7492cbc | ||
|
|
7e9d00a5a6 | ||
|
|
ff9cf6c7a4 | ||
|
|
56593f20b0 | ||
|
|
b7b9b003c9 | ||
|
|
59639ca9b2 | ||
|
|
66b8c42a25 |
@@ -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',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/e2e-cucumber-playwright
|
||||||
@@ -36,7 +36,6 @@
|
|||||||
/api/core/workflow/graph/ @laipz8200 @QuantumGhost
|
/api/core/workflow/graph/ @laipz8200 @QuantumGhost
|
||||||
/api/core/workflow/graph_events/ @laipz8200 @QuantumGhost
|
/api/core/workflow/graph_events/ @laipz8200 @QuantumGhost
|
||||||
/api/core/workflow/node_events/ @laipz8200 @QuantumGhost
|
/api/core/workflow/node_events/ @laipz8200 @QuantumGhost
|
||||||
/api/dify_graph/model_runtime/ @laipz8200 @QuantumGhost
|
|
||||||
|
|
||||||
# Backend - Workflow - Nodes (Agent, Iteration, Loop, LLM)
|
# Backend - Workflow - Nodes (Agent, Iteration, Loop, LLM)
|
||||||
/api/core/workflow/nodes/agent/ @Nov1c444
|
/api/core/workflow/nodes/agent/ @Nov1c444
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ runs:
|
|||||||
- name: Setup Vite+
|
- name: Setup Vite+
|
||||||
uses: voidzero-dev/setup-vp@20553a7a7429c429a74894104a2835d7fed28a72 # v1.3.0
|
uses: voidzero-dev/setup-vp@20553a7a7429c429a74894104a2835d7fed28a72 # v1.3.0
|
||||||
with:
|
with:
|
||||||
working-directory: web
|
|
||||||
node-version-file: .nvmrc
|
node-version-file: .nvmrc
|
||||||
cache: true
|
cache: true
|
||||||
run-install: true
|
run-install: true
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
)
|
||||||
+127
-14
@@ -14,18 +14,17 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
api-unit:
|
||||||
name: API Tests
|
name: API Unit Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
COVERAGE_FILE: coverage-unit
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
shell: bash
|
shell: bash
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version:
|
python-version:
|
||||||
- "3.11"
|
|
||||||
- "3.12"
|
- "3.12"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -36,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@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
@@ -51,6 +50,52 @@ jobs:
|
|||||||
- name: Run dify config tests
|
- name: Run dify config tests
|
||||||
run: uv run --project api dev/pytest/pytest_config_tests.py
|
run: uv run --project api dev/pytest/pytest_config_tests.py
|
||||||
|
|
||||||
|
- name: Run Unit Tests
|
||||||
|
run: uv run --project api bash dev/pytest/pytest_unit_tests.sh
|
||||||
|
|
||||||
|
- name: Upload unit coverage data
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: api-coverage-unit
|
||||||
|
path: coverage-unit
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
api-integration:
|
||||||
|
name: API Integration Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
COVERAGE_FILE: coverage-integration
|
||||||
|
STORAGE_TYPE: opendal
|
||||||
|
OPENDAL_SCHEME: fs
|
||||||
|
OPENDAL_FS_ROOT: /tmp/dify-storage
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version:
|
||||||
|
- "3.12"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup UV and Python
|
||||||
|
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
cache-dependency-glob: api/uv.lock
|
||||||
|
|
||||||
|
- name: Check UV lockfile
|
||||||
|
run: uv lock --project api --check
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --project api --dev
|
||||||
|
|
||||||
- name: Set up dotenvs
|
- name: Set up dotenvs
|
||||||
run: |
|
run: |
|
||||||
cp docker/.env.example docker/.env
|
cp docker/.env.example docker/.env
|
||||||
@@ -74,23 +119,91 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
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: Run API Tests
|
- name: Run Integration Tests
|
||||||
env:
|
|
||||||
STORAGE_TYPE: opendal
|
|
||||||
OPENDAL_SCHEME: fs
|
|
||||||
OPENDAL_FS_ROOT: /tmp/dify-storage
|
|
||||||
run: |
|
run: |
|
||||||
uv run --project api pytest \
|
uv run --project api pytest \
|
||||||
-n auto \
|
-n auto \
|
||||||
--timeout "${PYTEST_TIMEOUT:-180}" \
|
--timeout "${PYTEST_TIMEOUT:-180}" \
|
||||||
api/tests/integration_tests/workflow \
|
api/tests/integration_tests/workflow \
|
||||||
api/tests/integration_tests/tools \
|
api/tests/integration_tests/tools \
|
||||||
api/tests/test_containers_integration_tests \
|
api/tests/test_containers_integration_tests
|
||||||
api/tests/unit_tests
|
|
||||||
|
- name: Upload integration coverage data
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: api-coverage-integration
|
||||||
|
path: coverage-integration
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
api-coverage:
|
||||||
|
name: API Coverage
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- api-unit
|
||||||
|
- api-integration
|
||||||
|
env:
|
||||||
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
COVERAGE_FILE: .coverage
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup UV and Python
|
||||||
|
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
python-version: "3.12"
|
||||||
|
cache-dependency-glob: api/uv.lock
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --project api --dev
|
||||||
|
|
||||||
|
- name: Download coverage data
|
||||||
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
|
with:
|
||||||
|
path: coverage-data
|
||||||
|
pattern: api-coverage-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Combine coverage
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "### API Coverage" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "Merged backend coverage report generated for Codecov project status." >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
unit_coverage="$(find coverage-data -type f -name coverage-unit -print -quit)"
|
||||||
|
integration_coverage="$(find coverage-data -type f -name coverage-integration -print -quit)"
|
||||||
|
: "${unit_coverage:?coverage-unit artifact not found}"
|
||||||
|
: "${integration_coverage:?coverage-integration artifact not found}"
|
||||||
|
|
||||||
|
report_file="$(mktemp)"
|
||||||
|
uv run --project api coverage combine "$unit_coverage" "$integration_coverage"
|
||||||
|
uv run --project api coverage report --show-missing | tee "$report_file"
|
||||||
|
echo "Summary: \`$(tail -n 1 "$report_file")\`" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "<details><summary>Coverage report</summary>"
|
||||||
|
echo ""
|
||||||
|
echo '```'
|
||||||
|
cat "$report_file"
|
||||||
|
echo '```'
|
||||||
|
echo "</details>"
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
uv run --project api coverage xml -o coverage.xml
|
||||||
|
|
||||||
- name: Report coverage
|
- name: Report coverage
|
||||||
if: ${{ env.CODECOV_TOKEN != '' && matrix.python-version == '3.12' }}
|
if: ${{ env.CODECOV_TOKEN != '' }}
|
||||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||||
with:
|
with:
|
||||||
files: ./coverage.xml
|
files: ./coverage.xml
|
||||||
disable_search: true
|
disable_search: true
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ name: autofix.ci
|
|||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: ["main"]
|
branches: ["main"]
|
||||||
|
merge_group:
|
||||||
|
branches: ["main"]
|
||||||
|
types: [checks_requested]
|
||||||
push:
|
push:
|
||||||
branches: ["main"]
|
branches: ["main"]
|
||||||
permissions:
|
permissions:
|
||||||
@@ -12,9 +15,15 @@ jobs:
|
|||||||
if: github.repository == 'langgenius/dify'
|
if: github.repository == 'langgenius/dify'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- name: Complete merge group check
|
||||||
|
if: github.event_name == 'merge_group'
|
||||||
|
run: echo "autofix.ci updates pull request branches, not merge group refs."
|
||||||
|
|
||||||
|
- if: github.event_name != 'merge_group'
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Check Docker Compose inputs
|
- name: Check Docker Compose inputs
|
||||||
|
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@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
|
||||||
with:
|
with:
|
||||||
@@ -24,30 +33,40 @@ jobs:
|
|||||||
docker/docker-compose-template.yaml
|
docker/docker-compose-template.yaml
|
||||||
docker/docker-compose.yaml
|
docker/docker-compose.yaml
|
||||||
- name: Check web inputs
|
- name: Check web inputs
|
||||||
|
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@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
web/**
|
web/**
|
||||||
|
packages/**
|
||||||
|
package.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
pnpm-workspace.yaml
|
||||||
|
.npmrc
|
||||||
|
.nvmrc
|
||||||
- name: Check api inputs
|
- name: Check api inputs
|
||||||
|
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@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
api/**
|
api/**
|
||||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
- if: github.event_name != 'merge_group'
|
||||||
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
|
|
||||||
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
- if: github.event_name != 'merge_group'
|
||||||
|
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||||
|
|
||||||
- name: Generate Docker Compose
|
- name: Generate Docker Compose
|
||||||
if: steps.docker-compose-changes.outputs.any_changed == 'true'
|
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'
|
||||||
run: |
|
run: |
|
||||||
cd docker
|
cd docker
|
||||||
./generate_docker_compose
|
./generate_docker_compose
|
||||||
|
|
||||||
- if: steps.api-changes.outputs.any_changed == 'true'
|
- if: github.event_name != 'merge_group' && steps.api-changes.outputs.any_changed == 'true'
|
||||||
run: |
|
run: |
|
||||||
cd api
|
cd api
|
||||||
uv sync --dev
|
uv sync --dev
|
||||||
@@ -59,13 +78,13 @@ jobs:
|
|||||||
uv run ruff format ..
|
uv run ruff format ..
|
||||||
|
|
||||||
- name: count migration progress
|
- name: count migration progress
|
||||||
if: steps.api-changes.outputs.any_changed == 'true'
|
if: github.event_name != 'merge_group' && steps.api-changes.outputs.any_changed == 'true'
|
||||||
run: |
|
run: |
|
||||||
cd api
|
cd api
|
||||||
./cnt_base.sh
|
./cnt_base.sh
|
||||||
|
|
||||||
- name: ast-grep
|
- name: ast-grep
|
||||||
if: steps.api-changes.outputs.any_changed == 'true'
|
if: github.event_name != 'merge_group' && steps.api-changes.outputs.any_changed == 'true'
|
||||||
run: |
|
run: |
|
||||||
# ast-grep exits 1 if no matches are found; allow idempotent runs.
|
# ast-grep exits 1 if no matches are found; allow idempotent runs.
|
||||||
uvx --from ast-grep-cli ast-grep --pattern 'db.session.query($WHATEVER).filter($HERE)' --rewrite 'db.session.query($WHATEVER).where($HERE)' -l py --update-all || true
|
uvx --from ast-grep-cli ast-grep --pattern 'db.session.query($WHATEVER).filter($HERE)' --rewrite 'db.session.query($WHATEVER).where($HERE)' -l py --update-all || true
|
||||||
@@ -95,13 +114,14 @@ jobs:
|
|||||||
find . -name "*.py.bak" -type f -delete
|
find . -name "*.py.bak" -type f -delete
|
||||||
|
|
||||||
- name: Setup web environment
|
- name: Setup web environment
|
||||||
if: steps.web-changes.outputs.any_changed == 'true'
|
if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true'
|
||||||
uses: ./.github/actions/setup-web
|
uses: ./.github/actions/setup-web
|
||||||
|
|
||||||
- name: ESLint autofix
|
- name: ESLint autofix
|
||||||
if: steps.web-changes.outputs.any_changed == 'true'
|
if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true'
|
||||||
run: |
|
run: |
|
||||||
cd web
|
cd web
|
||||||
vp exec eslint --concurrency=2 --prune-suppressions --quiet || true
|
vp exec eslint --concurrency=2 --prune-suppressions --quiet || true
|
||||||
|
|
||||||
- uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8 # v1.3.3
|
- if: github.event_name != 'merge_group'
|
||||||
|
uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8 # v1.3.3
|
||||||
|
|||||||
@@ -24,27 +24,39 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ${{ matrix.platform == 'linux/arm64' && 'arm64_runner' || 'ubuntu-latest' }}
|
runs-on: ${{ matrix.runs_on }}
|
||||||
if: github.repository == 'langgenius/dify'
|
if: github.repository == 'langgenius/dify'
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- service_name: "build-api-amd64"
|
- service_name: "build-api-amd64"
|
||||||
image_name_env: "DIFY_API_IMAGE_NAME"
|
image_name_env: "DIFY_API_IMAGE_NAME"
|
||||||
context: "api"
|
artifact_context: "api"
|
||||||
|
build_context: "{{defaultContext}}:api"
|
||||||
|
file: "Dockerfile"
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
|
runs_on: ubuntu-latest
|
||||||
- service_name: "build-api-arm64"
|
- service_name: "build-api-arm64"
|
||||||
image_name_env: "DIFY_API_IMAGE_NAME"
|
image_name_env: "DIFY_API_IMAGE_NAME"
|
||||||
context: "api"
|
artifact_context: "api"
|
||||||
|
build_context: "{{defaultContext}}:api"
|
||||||
|
file: "Dockerfile"
|
||||||
platform: linux/arm64
|
platform: linux/arm64
|
||||||
|
runs_on: ubuntu-24.04-arm
|
||||||
- service_name: "build-web-amd64"
|
- service_name: "build-web-amd64"
|
||||||
image_name_env: "DIFY_WEB_IMAGE_NAME"
|
image_name_env: "DIFY_WEB_IMAGE_NAME"
|
||||||
context: "web"
|
artifact_context: "web"
|
||||||
|
build_context: "{{defaultContext}}"
|
||||||
|
file: "web/Dockerfile"
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
|
runs_on: ubuntu-latest
|
||||||
- service_name: "build-web-arm64"
|
- service_name: "build-web-arm64"
|
||||||
image_name_env: "DIFY_WEB_IMAGE_NAME"
|
image_name_env: "DIFY_WEB_IMAGE_NAME"
|
||||||
context: "web"
|
artifact_context: "web"
|
||||||
|
build_context: "{{defaultContext}}"
|
||||||
|
file: "web/Dockerfile"
|
||||||
platform: linux/arm64
|
platform: linux/arm64
|
||||||
|
runs_on: ubuntu-24.04-arm
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Prepare
|
- name: Prepare
|
||||||
@@ -53,14 +65,11 @@ 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 QEMU
|
|
||||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||||
|
|
||||||
@@ -72,9 +81,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||||
with:
|
with:
|
||||||
context: "{{defaultContext}}:${{ matrix.context }}"
|
context: ${{ matrix.build_context }}
|
||||||
|
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 }}
|
||||||
@@ -91,9 +101,9 @@ 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.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
|
||||||
@@ -120,7 +130,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 }}
|
||||||
|
|||||||
@@ -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@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
@@ -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@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|||||||
@@ -14,35 +14,40 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-docker:
|
build-docker:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ${{ matrix.runs_on }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- service_name: "api-amd64"
|
- service_name: "api-amd64"
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
context: "api"
|
runs_on: ubuntu-latest
|
||||||
|
context: "{{defaultContext}}:api"
|
||||||
|
file: "Dockerfile"
|
||||||
- service_name: "api-arm64"
|
- service_name: "api-arm64"
|
||||||
platform: linux/arm64
|
platform: linux/arm64
|
||||||
context: "api"
|
runs_on: ubuntu-24.04-arm
|
||||||
|
context: "{{defaultContext}}:api"
|
||||||
|
file: "Dockerfile"
|
||||||
- service_name: "web-amd64"
|
- service_name: "web-amd64"
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
context: "web"
|
runs_on: ubuntu-latest
|
||||||
|
context: "{{defaultContext}}"
|
||||||
|
file: "web/Dockerfile"
|
||||||
- service_name: "web-arm64"
|
- service_name: "web-arm64"
|
||||||
platform: linux/arm64
|
platform: linux/arm64
|
||||||
context: "web"
|
runs_on: ubuntu-24.04-arm
|
||||||
|
context: "{{defaultContext}}"
|
||||||
|
file: "web/Dockerfile"
|
||||||
steps:
|
steps:
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||||
|
|
||||||
- name: Build Docker Image
|
- name: Build Docker Image
|
||||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||||
with:
|
with:
|
||||||
push: false
|
push: false
|
||||||
context: "{{defaultContext}}:${{ matrix.context }}"
|
context: ${{ matrix.context }}
|
||||||
file: "${{ matrix.file }}"
|
file: ${{ matrix.file }}
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|||||||
+365
-17
@@ -3,10 +3,14 @@ name: Main CI Pipeline
|
|||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: ["main"]
|
branches: ["main"]
|
||||||
|
merge_group:
|
||||||
|
branches: ["main"]
|
||||||
|
types: [checks_requested]
|
||||||
push:
|
push:
|
||||||
branches: ["main"]
|
branches: ["main"]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
actions: write
|
||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
checks: write
|
checks: write
|
||||||
@@ -17,12 +21,28 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
pre_job:
|
||||||
|
name: Skip Duplicate Checks
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
should_skip: ${{ steps.skip_check.outputs.should_skip || 'false' }}
|
||||||
|
steps:
|
||||||
|
- id: skip_check
|
||||||
|
continue-on-error: true
|
||||||
|
uses: fkirc/skip-duplicate-actions@f75f66ce1886f00957d99748a42c724f4330bdcf # v5.3.1
|
||||||
|
with:
|
||||||
|
cancel_others: 'true'
|
||||||
|
concurrent_skipping: same_content_newer
|
||||||
|
|
||||||
# Check which paths were changed to determine which tests to run
|
# Check which paths were changed to determine which tests to run
|
||||||
check-changes:
|
check-changes:
|
||||||
name: Check Changed Files
|
name: Check Changed Files
|
||||||
|
needs: pre_job
|
||||||
|
if: needs.pre_job.outputs.should_skip != 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
api-changed: ${{ steps.changes.outputs.api }}
|
api-changed: ${{ steps.changes.outputs.api }}
|
||||||
|
e2e-changed: ${{ steps.changes.outputs.e2e }}
|
||||||
web-changed: ${{ steps.changes.outputs.web }}
|
web-changed: ${{ steps.changes.outputs.web }}
|
||||||
vdb-changed: ${{ steps.changes.outputs.vdb }}
|
vdb-changed: ${{ steps.changes.outputs.vdb }}
|
||||||
migration-changed: ${{ steps.changes.outputs.migration }}
|
migration-changed: ${{ steps.changes.outputs.migration }}
|
||||||
@@ -34,49 +54,377 @@ jobs:
|
|||||||
filters: |
|
filters: |
|
||||||
api:
|
api:
|
||||||
- 'api/**'
|
- 'api/**'
|
||||||
- 'docker/**'
|
|
||||||
- '.github/workflows/api-tests.yml'
|
- '.github/workflows/api-tests.yml'
|
||||||
|
- '.github/workflows/expose_service_ports.sh'
|
||||||
|
- 'docker/.env.example'
|
||||||
|
- 'docker/middleware.env.example'
|
||||||
|
- 'docker/docker-compose.middleware.yaml'
|
||||||
|
- 'docker/docker-compose-template.yaml'
|
||||||
|
- 'docker/generate_docker_compose'
|
||||||
|
- 'docker/ssrf_proxy/**'
|
||||||
|
- 'docker/volumes/sandbox/conf/**'
|
||||||
web:
|
web:
|
||||||
- 'web/**'
|
- 'web/**'
|
||||||
|
- 'packages/**'
|
||||||
|
- 'package.json'
|
||||||
|
- 'pnpm-lock.yaml'
|
||||||
|
- 'pnpm-workspace.yaml'
|
||||||
|
- '.npmrc'
|
||||||
|
- '.nvmrc'
|
||||||
- '.github/workflows/web-tests.yml'
|
- '.github/workflows/web-tests.yml'
|
||||||
- '.github/actions/setup-web/**'
|
- '.github/actions/setup-web/**'
|
||||||
|
e2e:
|
||||||
|
- 'api/**'
|
||||||
|
- 'api/pyproject.toml'
|
||||||
|
- 'api/uv.lock'
|
||||||
|
- 'e2e/**'
|
||||||
|
- 'web/**'
|
||||||
|
- 'packages/**'
|
||||||
|
- 'package.json'
|
||||||
|
- 'pnpm-lock.yaml'
|
||||||
|
- 'pnpm-workspace.yaml'
|
||||||
|
- '.npmrc'
|
||||||
|
- '.nvmrc'
|
||||||
|
- 'docker/docker-compose.middleware.yaml'
|
||||||
|
- 'docker/middleware.env.example'
|
||||||
|
- '.github/workflows/web-e2e.yml'
|
||||||
|
- '.github/actions/setup-web/**'
|
||||||
vdb:
|
vdb:
|
||||||
- 'api/core/rag/datasource/**'
|
- 'api/core/rag/datasource/**'
|
||||||
- 'docker/**'
|
- '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'
|
||||||
|
- 'docker/.env.example'
|
||||||
|
- 'docker/middleware.env.example'
|
||||||
|
- 'docker/docker-compose.yaml'
|
||||||
|
- 'docker/docker-compose-template.yaml'
|
||||||
|
- 'docker/generate_docker_compose'
|
||||||
|
- 'docker/certbot/**'
|
||||||
|
- 'docker/couchbase-server/**'
|
||||||
|
- 'docker/elasticsearch/**'
|
||||||
|
- 'docker/iris/**'
|
||||||
|
- 'docker/nginx/**'
|
||||||
|
- 'docker/pgvector/**'
|
||||||
|
- 'docker/ssrf_proxy/**'
|
||||||
|
- 'docker/startupscripts/**'
|
||||||
|
- 'docker/tidb/**'
|
||||||
|
- 'docker/volumes/**'
|
||||||
- 'api/uv.lock'
|
- 'api/uv.lock'
|
||||||
- 'api/pyproject.toml'
|
- 'api/pyproject.toml'
|
||||||
migration:
|
migration:
|
||||||
- 'api/migrations/**'
|
- 'api/migrations/**'
|
||||||
|
- 'api/.env.example'
|
||||||
- '.github/workflows/db-migration-test.yml'
|
- '.github/workflows/db-migration-test.yml'
|
||||||
|
- '.github/workflows/expose_service_ports.sh'
|
||||||
|
- 'docker/.env.example'
|
||||||
|
- 'docker/middleware.env.example'
|
||||||
|
- 'docker/docker-compose.middleware.yaml'
|
||||||
|
- 'docker/docker-compose-template.yaml'
|
||||||
|
- 'docker/generate_docker_compose'
|
||||||
|
- 'docker/ssrf_proxy/**'
|
||||||
|
- 'docker/volumes/sandbox/conf/**'
|
||||||
|
|
||||||
# Run tests in parallel
|
# Run tests in parallel while always emitting stable required checks.
|
||||||
api-tests:
|
api-tests-run:
|
||||||
name: API Tests
|
name: Run API Tests
|
||||||
needs: check-changes
|
needs:
|
||||||
if: needs.check-changes.outputs.api-changed == 'true'
|
- pre_job
|
||||||
|
- check-changes
|
||||||
|
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.api-changed == 'true'
|
||||||
uses: ./.github/workflows/api-tests.yml
|
uses: ./.github/workflows/api-tests.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
web-tests:
|
api-tests-skip:
|
||||||
name: Web Tests
|
name: Skip API Tests
|
||||||
needs: check-changes
|
needs:
|
||||||
if: needs.check-changes.outputs.web-changed == 'true'
|
- pre_job
|
||||||
|
- check-changes
|
||||||
|
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.api-changed != 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Report skipped API tests
|
||||||
|
run: echo "No API-related changes detected; skipping API tests."
|
||||||
|
|
||||||
|
api-tests:
|
||||||
|
name: API Tests
|
||||||
|
if: ${{ always() }}
|
||||||
|
needs:
|
||||||
|
- pre_job
|
||||||
|
- check-changes
|
||||||
|
- api-tests-run
|
||||||
|
- api-tests-skip
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Finalize API Tests status
|
||||||
|
env:
|
||||||
|
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||||
|
TESTS_CHANGED: ${{ needs.check-changes.outputs.api-changed }}
|
||||||
|
RUN_RESULT: ${{ needs.api-tests-run.result }}
|
||||||
|
SKIP_RESULT: ${{ needs.api-tests-skip.result }}
|
||||||
|
run: |
|
||||||
|
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||||
|
echo "API tests were skipped because this workflow run duplicated a successful or newer run."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||||
|
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||||
|
echo "API tests ran successfully."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "API tests were required but finished with result: $RUN_RESULT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||||
|
echo "API tests were skipped because no API-related files changed."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "API tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
web-tests-run:
|
||||||
|
name: Run Web Tests
|
||||||
|
needs:
|
||||||
|
- pre_job
|
||||||
|
- check-changes
|
||||||
|
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.web-changed == 'true'
|
||||||
uses: ./.github/workflows/web-tests.yml
|
uses: ./.github/workflows/web-tests.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
|
web-tests-skip:
|
||||||
|
name: Skip Web Tests
|
||||||
|
needs:
|
||||||
|
- pre_job
|
||||||
|
- check-changes
|
||||||
|
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.web-changed != 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Report skipped web tests
|
||||||
|
run: echo "No web-related changes detected; skipping web tests."
|
||||||
|
|
||||||
|
web-tests:
|
||||||
|
name: Web Tests
|
||||||
|
if: ${{ always() }}
|
||||||
|
needs:
|
||||||
|
- pre_job
|
||||||
|
- check-changes
|
||||||
|
- web-tests-run
|
||||||
|
- web-tests-skip
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Finalize Web Tests status
|
||||||
|
env:
|
||||||
|
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||||
|
TESTS_CHANGED: ${{ needs.check-changes.outputs.web-changed }}
|
||||||
|
RUN_RESULT: ${{ needs.web-tests-run.result }}
|
||||||
|
SKIP_RESULT: ${{ needs.web-tests-skip.result }}
|
||||||
|
run: |
|
||||||
|
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||||
|
echo "Web tests were skipped because this workflow run duplicated a successful or newer run."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||||
|
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||||
|
echo "Web tests ran successfully."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Web tests were required but finished with result: $RUN_RESULT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||||
|
echo "Web tests were skipped because no web-related files changed."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Web tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
web-e2e-run:
|
||||||
|
name: Run Web Full-Stack E2E
|
||||||
|
needs:
|
||||||
|
- pre_job
|
||||||
|
- check-changes
|
||||||
|
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed == 'true'
|
||||||
|
uses: ./.github/workflows/web-e2e.yml
|
||||||
|
|
||||||
|
web-e2e-skip:
|
||||||
|
name: Skip Web Full-Stack E2E
|
||||||
|
needs:
|
||||||
|
- pre_job
|
||||||
|
- check-changes
|
||||||
|
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed != 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Report skipped web full-stack e2e
|
||||||
|
run: echo "No E2E-related changes detected; skipping web full-stack E2E."
|
||||||
|
|
||||||
|
web-e2e:
|
||||||
|
name: Web Full-Stack E2E
|
||||||
|
if: ${{ always() }}
|
||||||
|
needs:
|
||||||
|
- pre_job
|
||||||
|
- check-changes
|
||||||
|
- web-e2e-run
|
||||||
|
- web-e2e-skip
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Finalize Web Full-Stack E2E status
|
||||||
|
env:
|
||||||
|
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||||
|
TESTS_CHANGED: ${{ needs.check-changes.outputs.e2e-changed }}
|
||||||
|
RUN_RESULT: ${{ needs.web-e2e-run.result }}
|
||||||
|
SKIP_RESULT: ${{ needs.web-e2e-skip.result }}
|
||||||
|
run: |
|
||||||
|
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||||
|
echo "Web full-stack E2E was skipped because this workflow run duplicated a successful or newer run."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||||
|
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||||
|
echo "Web full-stack E2E ran successfully."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Web full-stack E2E was required but finished with result: $RUN_RESULT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||||
|
echo "Web full-stack E2E was skipped because no E2E-related files changed."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Web full-stack E2E was not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||||
|
exit 1
|
||||||
|
|
||||||
style-check:
|
style-check:
|
||||||
name: Style Check
|
name: Style Check
|
||||||
|
needs: pre_job
|
||||||
|
if: needs.pre_job.outputs.should_skip != 'true'
|
||||||
uses: ./.github/workflows/style.yml
|
uses: ./.github/workflows/style.yml
|
||||||
|
|
||||||
|
vdb-tests-run:
|
||||||
|
name: Run VDB Tests
|
||||||
|
needs:
|
||||||
|
- pre_job
|
||||||
|
- check-changes
|
||||||
|
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.vdb-changed == 'true'
|
||||||
|
uses: ./.github/workflows/vdb-tests.yml
|
||||||
|
|
||||||
|
vdb-tests-skip:
|
||||||
|
name: Skip VDB Tests
|
||||||
|
needs:
|
||||||
|
- pre_job
|
||||||
|
- check-changes
|
||||||
|
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.vdb-changed != 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Report skipped VDB tests
|
||||||
|
run: echo "No VDB-related changes detected; skipping VDB tests."
|
||||||
|
|
||||||
vdb-tests:
|
vdb-tests:
|
||||||
name: VDB Tests
|
name: VDB Tests
|
||||||
needs: check-changes
|
if: ${{ always() }}
|
||||||
if: needs.check-changes.outputs.vdb-changed == 'true'
|
needs:
|
||||||
uses: ./.github/workflows/vdb-tests.yml
|
- pre_job
|
||||||
|
- check-changes
|
||||||
|
- vdb-tests-run
|
||||||
|
- vdb-tests-skip
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Finalize VDB Tests status
|
||||||
|
env:
|
||||||
|
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||||
|
TESTS_CHANGED: ${{ needs.check-changes.outputs.vdb-changed }}
|
||||||
|
RUN_RESULT: ${{ needs.vdb-tests-run.result }}
|
||||||
|
SKIP_RESULT: ${{ needs.vdb-tests-skip.result }}
|
||||||
|
run: |
|
||||||
|
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||||
|
echo "VDB tests were skipped because this workflow run duplicated a successful or newer run."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||||
|
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||||
|
echo "VDB tests ran successfully."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "VDB tests were required but finished with result: $RUN_RESULT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||||
|
echo "VDB tests were skipped because no VDB-related files changed."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "VDB tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
db-migration-test-run:
|
||||||
|
name: Run DB Migration Test
|
||||||
|
needs:
|
||||||
|
- pre_job
|
||||||
|
- check-changes
|
||||||
|
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.migration-changed == 'true'
|
||||||
|
uses: ./.github/workflows/db-migration-test.yml
|
||||||
|
|
||||||
|
db-migration-test-skip:
|
||||||
|
name: Skip DB Migration Test
|
||||||
|
needs:
|
||||||
|
- pre_job
|
||||||
|
- check-changes
|
||||||
|
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.migration-changed != 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Report skipped DB migration tests
|
||||||
|
run: echo "No migration-related changes detected; skipping DB migration tests."
|
||||||
|
|
||||||
db-migration-test:
|
db-migration-test:
|
||||||
name: DB Migration Test
|
name: DB Migration Test
|
||||||
needs: check-changes
|
if: ${{ always() }}
|
||||||
if: needs.check-changes.outputs.migration-changed == 'true'
|
needs:
|
||||||
uses: ./.github/workflows/db-migration-test.yml
|
- pre_job
|
||||||
|
- check-changes
|
||||||
|
- db-migration-test-run
|
||||||
|
- db-migration-test-skip
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Finalize DB Migration Test status
|
||||||
|
env:
|
||||||
|
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||||
|
TESTS_CHANGED: ${{ needs.check-changes.outputs.migration-changed }}
|
||||||
|
RUN_RESULT: ${{ needs.db-migration-test-run.result }}
|
||||||
|
SKIP_RESULT: ${{ needs.db-migration-test-skip.result }}
|
||||||
|
run: |
|
||||||
|
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||||
|
echo "DB migration tests were skipped because this workflow run duplicated a successful or newer run."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||||
|
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||||
|
echo "DB migration tests ran successfully."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "DB migration tests were required but finished with result: $RUN_RESULT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||||
|
echo "DB migration tests were skipped because no migration-related files changed."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "DB migration tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||||
|
exit 1
|
||||||
|
|||||||
@@ -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: |
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Python & UV
|
- name: Setup Python & UV
|
||||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
|
|
||||||
@@ -50,12 +50,23 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
diff -u /tmp/pyrefly_base.txt /tmp/pyrefly_pr.txt > pyrefly_diff.txt || true
|
diff -u /tmp/pyrefly_base.txt /tmp/pyrefly_pr.txt > pyrefly_diff.txt || true
|
||||||
|
|
||||||
|
- name: Check if line counts match
|
||||||
|
id: line_count_check
|
||||||
|
run: |
|
||||||
|
base_lines=$(wc -l < /tmp/pyrefly_base.txt)
|
||||||
|
pr_lines=$(wc -l < /tmp/pyrefly_pr.txt)
|
||||||
|
if [ "$base_lines" -eq "$pr_lines" ]; then
|
||||||
|
echo "same=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "same=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Save PR number
|
- name: Save PR number
|
||||||
run: |
|
run: |
|
||||||
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: |
|
||||||
@@ -63,8 +74,8 @@ jobs:
|
|||||||
pr_number.txt
|
pr_number.txt
|
||||||
|
|
||||||
- name: Comment PR with pyrefly diff
|
- name: Comment PR with pyrefly diff
|
||||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
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: ubuntu-latest
|
||||||
|
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@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.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: ubuntu-latest
|
||||||
|
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@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,6 +7,9 @@ on:
|
|||||||
- edited
|
- edited
|
||||||
- reopened
|
- reopened
|
||||||
- synchronize
|
- synchronize
|
||||||
|
merge_group:
|
||||||
|
branches: ["main"]
|
||||||
|
types: [checks_requested]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
@@ -15,7 +18,11 @@ jobs:
|
|||||||
pull-requests: read
|
pull-requests: read
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
- name: Complete merge group check
|
||||||
|
if: github.event_name == 'merge_group'
|
||||||
|
run: echo "Semantic PR title validation is handled on pull requests."
|
||||||
- name: Check title
|
- name: Check title
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||||
with:
|
with:
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
@@ -49,7 +49,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run Type Checks
|
- name: Run Type Checks
|
||||||
if: steps.changed-files.outputs.any_changed == 'true'
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
run: make type-check
|
run: make type-check-core
|
||||||
|
|
||||||
- name: Dotenv check
|
- name: Dotenv check
|
||||||
if: steps.changed-files.outputs.any_changed == 'true'
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
@@ -77,6 +77,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
web/**
|
web/**
|
||||||
|
packages/**
|
||||||
|
package.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
pnpm-workspace.yaml
|
||||||
|
.npmrc
|
||||||
|
.nvmrc
|
||||||
.github/workflows/style.yml
|
.github/workflows/style.yml
|
||||||
.github/actions/setup-web/**
|
.github/actions/setup-web/**
|
||||||
|
|
||||||
@@ -90,9 +96,9 @@ jobs:
|
|||||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||||
with:
|
with:
|
||||||
path: web/.eslintcache
|
path: web/.eslintcache
|
||||||
key: ${{ runner.os }}-web-eslint-${{ hashFiles('web/package.json', 'web/pnpm-lock.yaml', 'web/eslint.config.mjs', 'web/eslint.constants.mjs', 'web/plugins/eslint/**') }}-${{ github.sha }}
|
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 }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-web-eslint-${{ hashFiles('web/package.json', 'web/pnpm-lock.yaml', 'web/eslint.config.mjs', 'web/eslint.constants.mjs', 'web/plugins/eslint/**') }}-
|
${{ runner.os }}-web-eslint-${{ hashFiles('web/package.json', 'pnpm-lock.yaml', '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'
|
||||||
@@ -145,7 +151,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
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ on:
|
|||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
- sdks/**
|
- sdks/**
|
||||||
|
- package.json
|
||||||
|
- pnpm-lock.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 }}
|
||||||
|
|||||||
@@ -1,26 +1,24 @@
|
|||||||
name: Translate i18n Files with Claude Code
|
name: Translate i18n Files with Claude Code
|
||||||
|
|
||||||
# Note: claude-code-action doesn't support push events directly.
|
# Note: claude-code-action doesn't support push events directly.
|
||||||
# Push events are handled by trigger-i18n-sync.yml which sends repository_dispatch.
|
# Push events are bridged by trigger-i18n-sync.yml via repository_dispatch.
|
||||||
# See: https://github.com/langgenius/dify/issues/30743
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
repository_dispatch:
|
repository_dispatch:
|
||||||
types: [i18n-sync]
|
types: [i18n-sync]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
files:
|
files:
|
||||||
description: 'Specific files to translate (space-separated, e.g., "app common"). Leave empty for all files.'
|
description: 'Specific files to translate (space-separated, e.g., "app common"). Required for full mode; leave empty in incremental mode to use en-US files changed since HEAD~1.'
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
languages:
|
languages:
|
||||||
description: 'Specific languages to translate (space-separated, e.g., "zh-Hans ja-JP"). Leave empty for all supported languages.'
|
description: 'Specific languages to translate (space-separated, e.g., "zh-Hans ja-JP"). Leave empty for all supported target languages except en-US.'
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
mode:
|
mode:
|
||||||
description: 'Sync mode: incremental (only changes) or full (re-check all keys)'
|
description: 'Sync mode: incremental (compare with previous en-US revision) or full (sync all keys in scope)'
|
||||||
required: false
|
required: false
|
||||||
default: 'incremental'
|
default: incremental
|
||||||
type: choice
|
type: choice
|
||||||
options:
|
options:
|
||||||
- incremental
|
- incremental
|
||||||
@@ -30,11 +28,15 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: translate-i18n-${{ github.event_name }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
translate:
|
translate:
|
||||||
if: github.repository == 'langgenius/dify'
|
if: github.repository == 'langgenius/dify'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 120
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
@@ -51,380 +53,293 @@ jobs:
|
|||||||
- name: Setup web environment
|
- name: Setup web environment
|
||||||
uses: ./.github/actions/setup-web
|
uses: ./.github/actions/setup-web
|
||||||
|
|
||||||
- name: Detect changed files and generate diff
|
- name: Prepare sync context
|
||||||
id: detect_changes
|
id: context
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
DEFAULT_TARGET_LANGS=$(awk "
|
||||||
# Manual trigger
|
/value: '/ {
|
||||||
if [ -n "${{ github.event.inputs.files }}" ]; then
|
value=\$2
|
||||||
echo "CHANGED_FILES=${{ github.event.inputs.files }}" >> $GITHUB_OUTPUT
|
gsub(/[',]/, \"\", value)
|
||||||
else
|
}
|
||||||
# Get all JSON files in en-US directory
|
/supported: true/ && value != \"en-US\" {
|
||||||
files=$(ls web/i18n/en-US/*.json 2>/dev/null | xargs -n1 basename | sed 's/.json$//' | tr '\n' ' ')
|
printf \"%s \", value
|
||||||
echo "CHANGED_FILES=$files" >> $GITHUB_OUTPUT
|
}
|
||||||
fi
|
" web/i18n-config/languages.ts | sed 's/[[:space:]]*$//')
|
||||||
echo "TARGET_LANGS=${{ github.event.inputs.languages }}" >> $GITHUB_OUTPUT
|
|
||||||
echo "SYNC_MODE=${{ github.event.inputs.mode || 'incremental' }}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
# For manual trigger with incremental mode, get diff from last commit
|
generate_changes_json() {
|
||||||
# For full mode, we'll do a complete check anyway
|
node .github/scripts/generate-i18n-changes.mjs
|
||||||
if [ "${{ github.event.inputs.mode }}" == "full" ]; then
|
}
|
||||||
echo "Full mode: will check all keys" > /tmp/i18n-diff.txt
|
|
||||||
echo "DIFF_AVAILABLE=false" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
git diff HEAD~1..HEAD -- 'web/i18n/en-US/*.json' > /tmp/i18n-diff.txt 2>/dev/null || echo "" > /tmp/i18n-diff.txt
|
|
||||||
if [ -s /tmp/i18n-diff.txt ]; then
|
|
||||||
echo "DIFF_AVAILABLE=true" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "DIFF_AVAILABLE=false" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
elif [ "${{ github.event_name }}" == "repository_dispatch" ]; then
|
|
||||||
# Triggered by push via trigger-i18n-sync.yml workflow
|
|
||||||
# Validate required payload fields
|
|
||||||
if [ -z "${{ github.event.client_payload.changed_files }}" ]; then
|
|
||||||
echo "Error: repository_dispatch payload missing required 'changed_files' field" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "CHANGED_FILES=${{ github.event.client_payload.changed_files }}" >> $GITHUB_OUTPUT
|
|
||||||
echo "TARGET_LANGS=" >> $GITHUB_OUTPUT
|
|
||||||
echo "SYNC_MODE=${{ github.event.client_payload.sync_mode || 'incremental' }}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
# Decode the base64-encoded diff from the trigger workflow
|
if [ "${{ github.event_name }}" = "repository_dispatch" ]; then
|
||||||
if [ -n "${{ github.event.client_payload.diff_base64 }}" ]; then
|
BASE_SHA="${{ github.event.client_payload.base_sha }}"
|
||||||
if ! echo "${{ github.event.client_payload.diff_base64 }}" | base64 -d > /tmp/i18n-diff.txt 2>&1; then
|
HEAD_SHA="${{ github.event.client_payload.head_sha }}"
|
||||||
echo "Warning: Failed to decode base64 diff payload" >&2
|
CHANGED_FILES="${{ github.event.client_payload.changed_files }}"
|
||||||
echo "" > /tmp/i18n-diff.txt
|
TARGET_LANGS="$DEFAULT_TARGET_LANGS"
|
||||||
echo "DIFF_AVAILABLE=false" >> $GITHUB_OUTPUT
|
SYNC_MODE="${{ github.event.client_payload.sync_mode || 'incremental' }}"
|
||||||
elif [ -s /tmp/i18n-diff.txt ]; then
|
|
||||||
echo "DIFF_AVAILABLE=true" >> $GITHUB_OUTPUT
|
if [ -n "${{ github.event.client_payload.changes_base64 }}" ]; then
|
||||||
else
|
printf '%s' '${{ github.event.client_payload.changes_base64 }}' | base64 -d > /tmp/i18n-changes.json
|
||||||
echo "DIFF_AVAILABLE=false" >> $GITHUB_OUTPUT
|
CHANGES_AVAILABLE="true"
|
||||||
fi
|
CHANGES_SOURCE="embedded"
|
||||||
|
elif [ -n "$BASE_SHA" ] && [ -n "$CHANGED_FILES" ]; then
|
||||||
|
export BASE_SHA HEAD_SHA CHANGED_FILES
|
||||||
|
generate_changes_json
|
||||||
|
CHANGES_AVAILABLE="true"
|
||||||
|
CHANGES_SOURCE="recomputed"
|
||||||
else
|
else
|
||||||
echo "" > /tmp/i18n-diff.txt
|
printf '%s' '{"baseSha":"","headSha":"","files":[],"changes":{}}' > /tmp/i18n-changes.json
|
||||||
echo "DIFF_AVAILABLE=false" >> $GITHUB_OUTPUT
|
CHANGES_AVAILABLE="false"
|
||||||
|
CHANGES_SOURCE="unavailable"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "Unsupported event type: ${{ github.event_name }}"
|
BASE_SHA=""
|
||||||
exit 1
|
HEAD_SHA=$(git rev-parse HEAD)
|
||||||
|
if [ -n "${{ github.event.inputs.languages }}" ]; then
|
||||||
|
TARGET_LANGS="${{ github.event.inputs.languages }}"
|
||||||
|
else
|
||||||
|
TARGET_LANGS="$DEFAULT_TARGET_LANGS"
|
||||||
|
fi
|
||||||
|
SYNC_MODE="${{ github.event.inputs.mode || 'incremental' }}"
|
||||||
|
if [ -n "${{ github.event.inputs.files }}" ]; then
|
||||||
|
CHANGED_FILES="${{ github.event.inputs.files }}"
|
||||||
|
elif [ "$SYNC_MODE" = "incremental" ]; then
|
||||||
|
BASE_SHA=$(git rev-parse HEAD~1 2>/dev/null || true)
|
||||||
|
if [ -n "$BASE_SHA" ]; then
|
||||||
|
CHANGED_FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- 'web/i18n/en-US/*.json' 2>/dev/null | sed -n 's@^.*/@@p' | sed 's/\.json$//' | tr '\n' ' ' | sed 's/[[:space:]]*$//')
|
||||||
|
else
|
||||||
|
CHANGED_FILES=$(find web/i18n/en-US -maxdepth 1 -type f -name '*.json' -print | sed -n 's@^.*/@@p' | sed 's/\.json$//' | sort | tr '\n' ' ' | sed 's/[[:space:]]*$//')
|
||||||
|
fi
|
||||||
|
elif [ "$SYNC_MODE" = "full" ]; then
|
||||||
|
echo "workflow_dispatch full mode requires the files input to stay within CI limits." >&2
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
CHANGED_FILES=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$SYNC_MODE" = "incremental" ] && [ -n "$CHANGED_FILES" ]; then
|
||||||
|
export BASE_SHA HEAD_SHA CHANGED_FILES
|
||||||
|
generate_changes_json
|
||||||
|
CHANGES_AVAILABLE="true"
|
||||||
|
CHANGES_SOURCE="local"
|
||||||
|
else
|
||||||
|
printf '%s' '{"baseSha":"","headSha":"","files":[],"changes":{}}' > /tmp/i18n-changes.json
|
||||||
|
CHANGES_AVAILABLE="false"
|
||||||
|
CHANGES_SOURCE="unavailable"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Truncate diff if too large (keep first 50KB)
|
FILE_ARGS=""
|
||||||
if [ -f /tmp/i18n-diff.txt ]; then
|
if [ -n "$CHANGED_FILES" ]; then
|
||||||
head -c 50000 /tmp/i18n-diff.txt > /tmp/i18n-diff-truncated.txt
|
FILE_ARGS="--file $CHANGED_FILES"
|
||||||
mv /tmp/i18n-diff-truncated.txt /tmp/i18n-diff.txt
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Detected files: $(cat $GITHUB_OUTPUT | grep CHANGED_FILES || echo 'none')"
|
LANG_ARGS=""
|
||||||
|
if [ -n "$TARGET_LANGS" ]; then
|
||||||
|
LANG_ARGS="--lang $TARGET_LANGS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "DEFAULT_TARGET_LANGS=$DEFAULT_TARGET_LANGS"
|
||||||
|
echo "BASE_SHA=$BASE_SHA"
|
||||||
|
echo "HEAD_SHA=$HEAD_SHA"
|
||||||
|
echo "CHANGED_FILES=$CHANGED_FILES"
|
||||||
|
echo "TARGET_LANGS=$TARGET_LANGS"
|
||||||
|
echo "SYNC_MODE=$SYNC_MODE"
|
||||||
|
echo "CHANGES_AVAILABLE=$CHANGES_AVAILABLE"
|
||||||
|
echo "CHANGES_SOURCE=$CHANGES_SOURCE"
|
||||||
|
echo "FILE_ARGS=$FILE_ARGS"
|
||||||
|
echo "LANG_ARGS=$LANG_ARGS"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
echo "Files: ${CHANGED_FILES:-<none>}"
|
||||||
|
echo "Languages: ${TARGET_LANGS:-<none>}"
|
||||||
|
echo "Mode: $SYNC_MODE"
|
||||||
|
|
||||||
- name: Run Claude Code for Translation Sync
|
- name: Run Claude Code for Translation Sync
|
||||||
if: steps.detect_changes.outputs.CHANGED_FILES != ''
|
if: steps.context.outputs.CHANGED_FILES != ''
|
||||||
uses: anthropics/claude-code-action@ff9acae5886d41a99ed4ec14b7dc147d55834722 # v1.0.77
|
uses: anthropics/claude-code-action@b47fd721da662d48c5680e154ad16a73ed74d2e0 # v1.0.93
|
||||||
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 }}
|
||||||
# Allow github-actions bot to trigger this workflow via repository_dispatch
|
|
||||||
# See: https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
|
||||||
allowed_bots: 'github-actions[bot]'
|
allowed_bots: 'github-actions[bot]'
|
||||||
|
show_full_output: ${{ github.event_name == 'workflow_dispatch' }}
|
||||||
prompt: |
|
prompt: |
|
||||||
You are a professional i18n synchronization engineer for the Dify project.
|
You are the i18n sync agent for the Dify repository.
|
||||||
Your task is to keep all language translations in sync with the English source (en-US).
|
Your job is to keep translations synchronized with the English source files under `${{ github.workspace }}/web/i18n/en-US/`.
|
||||||
|
|
||||||
## CRITICAL TOOL RESTRICTIONS
|
Use absolute paths at all times:
|
||||||
- Use **Read** tool to read files (NOT cat or bash)
|
- Repo root: `${{ github.workspace }}`
|
||||||
- Use **Edit** tool to modify JSON files (NOT node, jq, or bash scripts)
|
- Web directory: `${{ github.workspace }}/web`
|
||||||
- Use **Bash** ONLY for: git commands, gh commands, pnpm commands
|
- Language config: `${{ github.workspace }}/web/i18n-config/languages.ts`
|
||||||
- Run bash commands ONE BY ONE, never combine with && or ||
|
|
||||||
- NEVER use `$()` command substitution - it's not supported. Split into separate commands instead.
|
|
||||||
|
|
||||||
## WORKING DIRECTORY & ABSOLUTE PATHS
|
Inputs:
|
||||||
Claude Code sandbox working directory may vary. Always use absolute paths:
|
- Files in scope: `${{ steps.context.outputs.CHANGED_FILES }}`
|
||||||
- For pnpm: `pnpm --dir ${{ github.workspace }}/web <command>`
|
- Target languages: `${{ steps.context.outputs.TARGET_LANGS }}`
|
||||||
- For git: `git -C ${{ github.workspace }} <command>`
|
- Sync mode: `${{ steps.context.outputs.SYNC_MODE }}`
|
||||||
- For gh: `gh --repo ${{ github.repository }} <command>`
|
- Base SHA: `${{ steps.context.outputs.BASE_SHA }}`
|
||||||
- For file paths: `${{ github.workspace }}/web/i18n/`
|
- Head SHA: `${{ steps.context.outputs.HEAD_SHA }}`
|
||||||
|
- Scoped file args: `${{ steps.context.outputs.FILE_ARGS }}`
|
||||||
|
- Scoped language args: `${{ steps.context.outputs.LANG_ARGS }}`
|
||||||
|
- Structured change set available: `${{ steps.context.outputs.CHANGES_AVAILABLE }}`
|
||||||
|
- Structured change set source: `${{ steps.context.outputs.CHANGES_SOURCE }}`
|
||||||
|
- Structured change set file: `/tmp/i18n-changes.json`
|
||||||
|
|
||||||
## EFFICIENCY RULES
|
Tool rules:
|
||||||
- **ONE Edit per language file** - batch all key additions into a single Edit
|
- Use Read for repository files.
|
||||||
- Insert new keys at the beginning of JSON (after `{`), lint:fix will sort them
|
- Use Edit for JSON updates.
|
||||||
- Translate ALL keys for a language mentally first, then do ONE Edit
|
- Use Bash only for `vp`.
|
||||||
|
- Do not use Bash for `git`, `gh`, or branch management.
|
||||||
## Context
|
|
||||||
- Changed/target files: ${{ steps.detect_changes.outputs.CHANGED_FILES }}
|
|
||||||
- Target languages (empty means all supported): ${{ steps.detect_changes.outputs.TARGET_LANGS }}
|
|
||||||
- Sync mode: ${{ steps.detect_changes.outputs.SYNC_MODE }}
|
|
||||||
- Translation files are located in: ${{ github.workspace }}/web/i18n/{locale}/{filename}.json
|
|
||||||
- Language configuration is in: ${{ github.workspace }}/web/i18n-config/languages.ts
|
|
||||||
- Git diff is available: ${{ steps.detect_changes.outputs.DIFF_AVAILABLE }}
|
|
||||||
|
|
||||||
## CRITICAL DESIGN: Verify First, Then Sync
|
|
||||||
|
|
||||||
You MUST follow this three-phase approach:
|
|
||||||
|
|
||||||
═══════════════════════════════════════════════════════════════
|
|
||||||
║ PHASE 1: VERIFY - Analyze and Generate Change Report ║
|
|
||||||
═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
### Step 1.1: Analyze Git Diff (for incremental mode)
|
|
||||||
Use the Read tool to read `/tmp/i18n-diff.txt` to see the git diff.
|
|
||||||
|
|
||||||
Parse the diff to categorize changes:
|
|
||||||
- Lines with `+` (not `+++`): Added or modified values
|
|
||||||
- Lines with `-` (not `---`): Removed or old values
|
|
||||||
- Identify specific keys for each category:
|
|
||||||
* ADD: Keys that appear only in `+` lines (new keys)
|
|
||||||
* UPDATE: Keys that appear in both `-` and `+` lines (value changed)
|
|
||||||
* DELETE: Keys that appear only in `-` lines (removed keys)
|
|
||||||
|
|
||||||
### Step 1.2: Read Language Configuration
|
|
||||||
Use the Read tool to read `${{ github.workspace }}/web/i18n-config/languages.ts`.
|
|
||||||
Extract all languages with `supported: true`.
|
|
||||||
|
|
||||||
### Step 1.3: Run i18n:check for Each Language
|
|
||||||
```bash
|
|
||||||
pnpm --dir ${{ github.workspace }}/web install --frozen-lockfile
|
|
||||||
```
|
|
||||||
```bash
|
|
||||||
pnpm --dir ${{ github.workspace }}/web run i18n:check
|
|
||||||
```
|
|
||||||
|
|
||||||
This will report:
|
|
||||||
- Missing keys (need to ADD)
|
|
||||||
- Extra keys (need to DELETE)
|
|
||||||
|
|
||||||
### Step 1.4: Generate Change Report
|
|
||||||
|
|
||||||
Create a structured report identifying:
|
|
||||||
```
|
|
||||||
╔══════════════════════════════════════════════════════════════╗
|
|
||||||
║ I18N SYNC CHANGE REPORT ║
|
|
||||||
╠══════════════════════════════════════════════════════════════╣
|
|
||||||
║ Files to process: [list] ║
|
|
||||||
║ Languages to sync: [list] ║
|
|
||||||
╠══════════════════════════════════════════════════════════════╣
|
|
||||||
║ ADD (New Keys): ║
|
|
||||||
║ - [filename].[key]: "English value" ║
|
|
||||||
║ ... ║
|
|
||||||
╠══════════════════════════════════════════════════════════════╣
|
|
||||||
║ UPDATE (Modified Keys - MUST re-translate): ║
|
|
||||||
║ - [filename].[key]: "Old value" → "New value" ║
|
|
||||||
║ ... ║
|
|
||||||
╠══════════════════════════════════════════════════════════════╣
|
|
||||||
║ DELETE (Extra Keys): ║
|
|
||||||
║ - [language]/[filename].[key] ║
|
|
||||||
║ ... ║
|
|
||||||
╚══════════════════════════════════════════════════════════════╝
|
|
||||||
```
|
|
||||||
|
|
||||||
**IMPORTANT**: For UPDATE detection, compare git diff to find keys where
|
|
||||||
the English value changed. These MUST be re-translated even if target
|
|
||||||
language already has a translation (it's now stale!).
|
|
||||||
|
|
||||||
═══════════════════════════════════════════════════════════════
|
|
||||||
║ PHASE 2: SYNC - Execute Changes Based on Report ║
|
|
||||||
═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
### Step 2.1: Process ADD Operations (BATCH per language file)
|
|
||||||
|
|
||||||
**CRITICAL WORKFLOW for efficiency:**
|
|
||||||
1. First, translate ALL new keys for ALL languages mentally
|
|
||||||
2. Then, for EACH language file, do ONE Edit operation:
|
|
||||||
- Read the file once
|
|
||||||
- Insert ALL new keys at the beginning (right after the opening `{`)
|
|
||||||
- Don't worry about alphabetical order - lint:fix will sort them later
|
|
||||||
|
|
||||||
Example Edit (adding 3 keys to zh-Hans/app.json):
|
|
||||||
```
|
|
||||||
old_string: '{\n "accessControl"'
|
|
||||||
new_string: '{\n "newKey1": "translation1",\n "newKey2": "translation2",\n "newKey3": "translation3",\n "accessControl"'
|
|
||||||
```
|
|
||||||
|
|
||||||
**IMPORTANT**:
|
|
||||||
- ONE Edit per language file (not one Edit per key!)
|
|
||||||
- Always use the Edit tool. NEVER use bash scripts, node, or jq.
|
|
||||||
|
|
||||||
### Step 2.2: Process UPDATE Operations
|
|
||||||
|
|
||||||
**IMPORTANT: Special handling for zh-Hans and ja-JP**
|
|
||||||
If zh-Hans or ja-JP files were ALSO modified in the same push:
|
|
||||||
- Run: `git -C ${{ github.workspace }} diff HEAD~1 --name-only` and check for zh-Hans or ja-JP files
|
|
||||||
- If found, it means someone manually translated them. Apply these rules:
|
|
||||||
|
|
||||||
1. **Missing keys**: Still ADD them (completeness required)
|
|
||||||
2. **Existing translations**: Compare with the NEW English value:
|
|
||||||
- If translation is **completely wrong** or **unrelated** → Update it
|
|
||||||
- If translation is **roughly correct** (captures the meaning) → Keep it, respect manual work
|
|
||||||
- When in doubt, **keep the manual translation**
|
|
||||||
|
|
||||||
Example:
|
|
||||||
- English changed: "Save" → "Save Changes"
|
|
||||||
- Manual translation: "保存更改" → Keep it (correct meaning)
|
|
||||||
- Manual translation: "删除" → Update it (completely wrong)
|
|
||||||
|
|
||||||
For other languages:
|
|
||||||
Use Edit tool to replace the old value with the new translation.
|
|
||||||
You can batch multiple updates in one Edit if they are adjacent.
|
|
||||||
|
|
||||||
### Step 2.3: Process DELETE Operations
|
|
||||||
For extra keys reported by i18n:check:
|
|
||||||
- Run: `pnpm --dir ${{ github.workspace }}/web run i18n:check --auto-remove`
|
|
||||||
- Or manually remove from target language JSON files
|
|
||||||
|
|
||||||
## Translation Guidelines
|
|
||||||
|
|
||||||
- PRESERVE all placeholders exactly as-is:
|
|
||||||
- `{{variable}}` - Mustache interpolation
|
|
||||||
- `${variable}` - Template literal
|
|
||||||
- `<tag>content</tag>` - HTML tags
|
|
||||||
- `_one`, `_other` - Pluralization suffixes (these are KEY suffixes, not values)
|
|
||||||
|
|
||||||
**CRITICAL: Variable names and tag names MUST stay in English - NEVER translate them**
|
|
||||||
|
|
||||||
✅ CORRECT examples:
|
|
||||||
- English: "{{count}} items" → Japanese: "{{count}} 個のアイテム"
|
|
||||||
- English: "{{name}} updated" → Korean: "{{name}} 업데이트됨"
|
|
||||||
- English: "<email>{{email}}</email>" → Chinese: "<email>{{email}}</email>"
|
|
||||||
- English: "<CustomLink>Marketplace</CustomLink>" → Japanese: "<CustomLink>マーケットプレイス</CustomLink>"
|
|
||||||
|
|
||||||
❌ WRONG examples (NEVER do this - will break the application):
|
|
||||||
- "{{count}}" → "{{カウント}}" ❌ (variable name translated to Japanese)
|
|
||||||
- "{{name}}" → "{{이름}}" ❌ (variable name translated to Korean)
|
|
||||||
- "{{email}}" → "{{邮箱}}" ❌ (variable name translated to Chinese)
|
|
||||||
- "<email>" → "<メール>" ❌ (tag name translated)
|
|
||||||
- "<CustomLink>" → "<自定义链接>" ❌ (component name translated)
|
|
||||||
|
|
||||||
- Use appropriate language register (formal/informal) based on existing translations
|
|
||||||
- Match existing translation style in each language
|
|
||||||
- Technical terms: check existing conventions per language
|
|
||||||
- For CJK languages: no spaces between characters unless necessary
|
|
||||||
- For RTL languages (ar-TN, fa-IR): ensure proper text handling
|
|
||||||
|
|
||||||
## Output Format Requirements
|
|
||||||
- Alphabetical key ordering (if original file uses it)
|
|
||||||
- 2-space indentation
|
|
||||||
- Trailing newline at end of file
|
|
||||||
- Valid JSON (use proper escaping for special characters)
|
|
||||||
|
|
||||||
═══════════════════════════════════════════════════════════════
|
|
||||||
║ PHASE 3: RE-VERIFY - Confirm All Issues Resolved ║
|
|
||||||
═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
### Step 3.1: Run Lint Fix (IMPORTANT!)
|
|
||||||
```bash
|
|
||||||
pnpm --dir ${{ github.workspace }}/web lint:fix --quiet -- 'i18n/**/*.json'
|
|
||||||
```
|
|
||||||
This ensures:
|
|
||||||
- JSON keys are sorted alphabetically (jsonc/sort-keys rule)
|
|
||||||
- Valid i18n keys (dify-i18n/valid-i18n-keys rule)
|
|
||||||
- No extra keys (dify-i18n/no-extra-keys rule)
|
|
||||||
|
|
||||||
### Step 3.2: Run Final i18n Check
|
|
||||||
```bash
|
|
||||||
pnpm --dir ${{ github.workspace }}/web run i18n:check
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3.3: Fix Any Remaining Issues
|
|
||||||
If check reports issues:
|
|
||||||
- Go back to PHASE 2 for unresolved items
|
|
||||||
- Repeat until check passes
|
|
||||||
|
|
||||||
### Step 3.4: Generate Final Summary
|
|
||||||
```
|
|
||||||
╔══════════════════════════════════════════════════════════════╗
|
|
||||||
║ SYNC COMPLETED SUMMARY ║
|
|
||||||
╠══════════════════════════════════════════════════════════════╣
|
|
||||||
║ Language │ Added │ Updated │ Deleted │ Status ║
|
|
||||||
╠══════════════════════════════════════════════════════════════╣
|
|
||||||
║ zh-Hans │ 5 │ 2 │ 1 │ ✓ Complete ║
|
|
||||||
║ ja-JP │ 5 │ 2 │ 1 │ ✓ Complete ║
|
|
||||||
║ ... │ ... │ ... │ ... │ ... ║
|
|
||||||
╠══════════════════════════════════════════════════════════════╣
|
|
||||||
║ i18n:check │ PASSED - All keys in sync ║
|
|
||||||
╚══════════════════════════════════════════════════════════════╝
|
|
||||||
```
|
|
||||||
|
|
||||||
## Mode-Specific Behavior
|
|
||||||
|
|
||||||
**SYNC_MODE = "incremental"** (default):
|
|
||||||
- Focus on keys identified from git diff
|
|
||||||
- Also check i18n:check output for any missing/extra keys
|
|
||||||
- Efficient for small changes
|
|
||||||
|
|
||||||
**SYNC_MODE = "full"**:
|
|
||||||
- Compare ALL keys between en-US and each language
|
|
||||||
- Run i18n:check to identify all discrepancies
|
|
||||||
- Use for first-time sync or fixing historical issues
|
|
||||||
|
|
||||||
## Important Notes
|
|
||||||
|
|
||||||
1. Always run i18n:check BEFORE and AFTER making changes
|
|
||||||
2. The check script is the source of truth for missing/extra keys
|
|
||||||
3. For UPDATE scenario: git diff is the source of truth for changed values
|
|
||||||
4. Create a single commit with all translation changes
|
|
||||||
5. If any translation fails, continue with others and report failures
|
|
||||||
|
|
||||||
═══════════════════════════════════════════════════════════════
|
|
||||||
║ PHASE 4: COMMIT AND CREATE PR ║
|
|
||||||
═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
After all translations are complete and verified:
|
|
||||||
|
|
||||||
### Step 4.1: Check for changes
|
|
||||||
```bash
|
|
||||||
git -C ${{ github.workspace }} status --porcelain
|
|
||||||
```
|
|
||||||
|
|
||||||
If there are changes:
|
|
||||||
|
|
||||||
### Step 4.2: Create a new branch and commit
|
|
||||||
Run these git commands ONE BY ONE (not combined with &&).
|
|
||||||
**IMPORTANT**: Do NOT use `$()` command substitution. Use two separate commands:
|
|
||||||
|
|
||||||
1. First, get the timestamp:
|
|
||||||
```bash
|
|
||||||
date +%Y%m%d-%H%M%S
|
|
||||||
```
|
|
||||||
(Note the output, e.g., "20260115-143052")
|
|
||||||
|
|
||||||
2. Then create branch using the timestamp value:
|
|
||||||
```bash
|
|
||||||
git -C ${{ github.workspace }} checkout -b chore/i18n-sync-20260115-143052
|
|
||||||
```
|
|
||||||
(Replace "20260115-143052" with the actual timestamp from step 1)
|
|
||||||
|
|
||||||
3. Stage changes:
|
|
||||||
```bash
|
|
||||||
git -C ${{ github.workspace }} add web/i18n/
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Commit:
|
|
||||||
```bash
|
|
||||||
git -C ${{ github.workspace }} commit -m "chore(i18n): sync translations with en-US - Mode: ${{ steps.detect_changes.outputs.SYNC_MODE }}"
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Push:
|
|
||||||
```bash
|
|
||||||
git -C ${{ github.workspace }} push origin HEAD
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4.3: Create Pull Request
|
|
||||||
```bash
|
|
||||||
gh pr create --repo ${{ github.repository }} --title "chore(i18n): sync translations with en-US" --body "## Summary
|
|
||||||
|
|
||||||
This PR was automatically generated to sync i18n translation files.
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Mode: ${{ steps.detect_changes.outputs.SYNC_MODE }}
|
|
||||||
- Files processed: ${{ steps.detect_changes.outputs.CHANGED_FILES }}
|
|
||||||
|
|
||||||
### Verification
|
|
||||||
- [x] \`i18n:check\` passed
|
|
||||||
- [x] \`lint:fix\` applied
|
|
||||||
|
|
||||||
🤖 Generated with Claude Code GitHub Action" --base main
|
|
||||||
```
|
|
||||||
|
|
||||||
|
Required execution plan:
|
||||||
|
1. Resolve target languages.
|
||||||
|
- Use the provided `Target languages` value as the source of truth.
|
||||||
|
- If it is unexpectedly empty, read `${{ github.workspace }}/web/i18n-config/languages.ts` and use every language with `supported: true` except `en-US`.
|
||||||
|
2. Stay strictly in scope.
|
||||||
|
- Only process the files listed in `Files in scope`.
|
||||||
|
- Only process the resolved target languages, never `en-US`.
|
||||||
|
- Do not touch unrelated i18n files.
|
||||||
|
- Do not modify `${{ github.workspace }}/web/i18n/en-US/`.
|
||||||
|
3. Resolve source changes.
|
||||||
|
- If `Structured change set available` is `true`, read `/tmp/i18n-changes.json` and use it as the source of truth for file-level and key-level changes.
|
||||||
|
- For each file entry:
|
||||||
|
- `added` contains new English keys that need translations.
|
||||||
|
- `updated` contains stale keys whose English source changed; re-translate using the `after` value.
|
||||||
|
- `deleted` contains keys that should be removed from locale files.
|
||||||
|
- `fileDeleted: true` means the English file no longer exists; remove the matching locale file if present.
|
||||||
|
- 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.
|
||||||
|
4. Run a scoped pre-check before editing:
|
||||||
|
- `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.
|
||||||
|
5. Apply translations.
|
||||||
|
- For every target language and scoped file:
|
||||||
|
- If `fileDeleted` is `true`, remove the locale file if it exists and skip the rest of that file.
|
||||||
|
- If the locale file does not exist yet, create it with `Write` and then continue with `Edit` as needed.
|
||||||
|
- ADD missing keys.
|
||||||
|
- UPDATE stale translations when the English value changed.
|
||||||
|
- 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.
|
||||||
|
- Match the existing terminology and register used by each locale.
|
||||||
|
- Prefer one Edit per file when stable, but prioritize correctness over batching.
|
||||||
|
6. Verify only the edited files.
|
||||||
|
- Run `vp run dify-web#lint:fix --quiet -- <relative edited i18n file paths under web/>`
|
||||||
|
- 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.
|
||||||
|
7. Stop after the scoped locale files are updated and verification passes.
|
||||||
|
- Do not create branches, commits, or pull requests.
|
||||||
claude_args: |
|
claude_args: |
|
||||||
--max-turns 150
|
--max-turns 120
|
||||||
--allowedTools "Read,Write,Edit,Bash(git *),Bash(git:*),Bash(gh *),Bash(gh:*),Bash(pnpm *),Bash(pnpm:*),Bash(date *),Bash(date:*),Glob,Grep"
|
--allowedTools "Read,Write,Edit,Bash(vp *),Bash(vp:*),Glob,Grep"
|
||||||
|
|
||||||
|
- name: Prepare branch metadata
|
||||||
|
id: pr_meta
|
||||||
|
if: steps.context.outputs.CHANGED_FILES != ''
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [ -z "$(git -C "${{ github.workspace }}" status --porcelain -- web/i18n/)" ]; then
|
||||||
|
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCOPE_HASH=$(printf '%s|%s|%s' "${{ steps.context.outputs.CHANGED_FILES }}" "${{ steps.context.outputs.TARGET_LANGS }}" "${{ steps.context.outputs.SYNC_MODE }}" | sha256sum | cut -c1-8)
|
||||||
|
HEAD_SHORT=$(printf '%s' "${{ steps.context.outputs.HEAD_SHA }}" | cut -c1-12)
|
||||||
|
BRANCH_NAME="chore/i18n-sync-${HEAD_SHORT}-${SCOPE_HASH}"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "has_changes=true"
|
||||||
|
echo "branch_name=$BRANCH_NAME"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Commit translation changes
|
||||||
|
if: steps.pr_meta.outputs.has_changes == 'true'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
git -C "${{ github.workspace }}" checkout -B "${{ steps.pr_meta.outputs.branch_name }}"
|
||||||
|
git -C "${{ github.workspace }}" add web/i18n/
|
||||||
|
git -C "${{ github.workspace }}" commit -m "chore(i18n): sync translations with en-US"
|
||||||
|
|
||||||
|
- name: Push translation branch
|
||||||
|
if: steps.pr_meta.outputs.has_changes == 'true'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if git -C "${{ github.workspace }}" ls-remote --exit-code --heads origin "${{ steps.pr_meta.outputs.branch_name }}" >/dev/null 2>&1; then
|
||||||
|
git -C "${{ github.workspace }}" push --force-with-lease origin "${{ steps.pr_meta.outputs.branch_name }}"
|
||||||
|
else
|
||||||
|
git -C "${{ github.workspace }}" push --set-upstream origin "${{ steps.pr_meta.outputs.branch_name }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create or update translation PR
|
||||||
|
if: steps.pr_meta.outputs.has_changes == 'true'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
BRANCH_NAME: ${{ steps.pr_meta.outputs.branch_name }}
|
||||||
|
FILES_IN_SCOPE: ${{ steps.context.outputs.CHANGED_FILES }}
|
||||||
|
TARGET_LANGS: ${{ steps.context.outputs.TARGET_LANGS }}
|
||||||
|
SYNC_MODE: ${{ steps.context.outputs.SYNC_MODE }}
|
||||||
|
CHANGES_SOURCE: ${{ steps.context.outputs.CHANGES_SOURCE }}
|
||||||
|
BASE_SHA: ${{ steps.context.outputs.BASE_SHA }}
|
||||||
|
HEAD_SHA: ${{ steps.context.outputs.HEAD_SHA }}
|
||||||
|
REPO_NAME: ${{ github.repository }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
PR_BODY_FILE=/tmp/i18n-pr-body.md
|
||||||
|
LANG_COUNT=$(printf '%s\n' "$TARGET_LANGS" | wc -w | tr -d ' ')
|
||||||
|
if [ "$LANG_COUNT" = "0" ]; then
|
||||||
|
LANG_COUNT="0"
|
||||||
|
fi
|
||||||
|
export LANG_COUNT
|
||||||
|
|
||||||
|
node <<'NODE' > "$PR_BODY_FILE"
|
||||||
|
const fs = require('node:fs')
|
||||||
|
|
||||||
|
const changesPath = '/tmp/i18n-changes.json'
|
||||||
|
const changes = fs.existsSync(changesPath)
|
||||||
|
? JSON.parse(fs.readFileSync(changesPath, 'utf8'))
|
||||||
|
: { changes: {} }
|
||||||
|
|
||||||
|
const filesInScope = (process.env.FILES_IN_SCOPE || '').split(/\s+/).filter(Boolean)
|
||||||
|
const lines = [
|
||||||
|
'## Summary',
|
||||||
|
'',
|
||||||
|
`- **Files synced**: \`${process.env.FILES_IN_SCOPE || '<none>'}\``,
|
||||||
|
`- **Languages updated**: ${process.env.TARGET_LANGS || '<none>'} (${process.env.LANG_COUNT} languages)`,
|
||||||
|
`- **Sync mode**: ${process.env.SYNC_MODE}${process.env.BASE_SHA ? ` (base: \`${process.env.BASE_SHA.slice(0, 10)}\`, head: \`${process.env.HEAD_SHA.slice(0, 10)}\`)` : ` (head: \`${process.env.HEAD_SHA.slice(0, 10)}\`)`}`,
|
||||||
|
'',
|
||||||
|
'### Key changes',
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const fileName of filesInScope) {
|
||||||
|
const fileChange = changes.changes?.[fileName] || { added: {}, updated: {}, deleted: [], fileDeleted: false }
|
||||||
|
const addedKeys = Object.keys(fileChange.added || {})
|
||||||
|
const updatedKeys = Object.keys(fileChange.updated || {})
|
||||||
|
const deletedKeys = fileChange.deleted || []
|
||||||
|
lines.push(`- \`${fileName}\`: +${addedKeys.length} / ~${updatedKeys.length} / -${deletedKeys.length}${fileChange.fileDeleted ? ' (file deleted in en-US)' : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
'',
|
||||||
|
'## Verification',
|
||||||
|
'',
|
||||||
|
`- \`vp run dify-web#i18n:check --file ${process.env.FILES_IN_SCOPE} --lang ${process.env.TARGET_LANGS}\``,
|
||||||
|
`- \`vp run dify-web#lint:fix --quiet -- <edited i18n files under web/>\``,
|
||||||
|
'',
|
||||||
|
'## Notes',
|
||||||
|
'',
|
||||||
|
'- This PR was generated from structured en-US key changes produced by `trigger-i18n-sync.yml`.',
|
||||||
|
`- Structured change source: ${process.env.CHANGES_SOURCE || 'unknown'}.`,
|
||||||
|
'- Branch name is deterministic for the head SHA and scope, so reruns update the same PR instead of opening duplicates.',
|
||||||
|
'',
|
||||||
|
'🤖 Generated with [Claude Code](https://claude.com/claude-code)'
|
||||||
|
)
|
||||||
|
|
||||||
|
process.stdout.write(lines.join('\n'))
|
||||||
|
NODE
|
||||||
|
|
||||||
|
EXISTING_PR_NUMBER=$(gh pr list --repo "$REPO_NAME" --head "$BRANCH_NAME" --state open --json number --jq '.[0].number')
|
||||||
|
|
||||||
|
if [ -n "$EXISTING_PR_NUMBER" ] && [ "$EXISTING_PR_NUMBER" != "null" ]; then
|
||||||
|
gh pr edit "$EXISTING_PR_NUMBER" --repo "$REPO_NAME" --title "chore(i18n): sync translations with en-US" --body-file "$PR_BODY_FILE"
|
||||||
|
else
|
||||||
|
gh pr create --repo "$REPO_NAME" --head "$BRANCH_NAME" --base main --title "chore(i18n): sync translations with en-US" --body-file "$PR_BODY_FILE"
|
||||||
|
fi
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
name: Trigger i18n Sync on Push
|
name: Trigger i18n Sync on Push
|
||||||
|
|
||||||
# This workflow bridges the push event to repository_dispatch
|
|
||||||
# because claude-code-action doesn't support push events directly.
|
|
||||||
# See: https://github.com/langgenius/dify/issues/30743
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
@@ -13,6 +9,10 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: trigger-i18n-sync-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
trigger:
|
trigger:
|
||||||
if: github.repository == 'langgenius/dify'
|
if: github.repository == 'langgenius/dify'
|
||||||
@@ -25,42 +25,66 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Detect changed files and generate diff
|
- name: Detect changed files and build structured change set
|
||||||
id: detect
|
id: detect
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
BEFORE_SHA="${{ github.event.before }}"
|
BASE_SHA="${{ github.event.before }}"
|
||||||
# Handle edge case: force push may have null/zero SHA
|
if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
|
||||||
if [ -z "$BEFORE_SHA" ] || [ "$BEFORE_SHA" = "0000000000000000000000000000000000000000" ]; then
|
BASE_SHA=$(git rev-parse HEAD~1 2>/dev/null || true)
|
||||||
BEFORE_SHA="HEAD~1"
|
|
||||||
fi
|
fi
|
||||||
|
HEAD_SHA="${{ github.sha }}"
|
||||||
|
|
||||||
# Detect changed i18n files
|
if [ -n "$BASE_SHA" ]; then
|
||||||
changed=$(git diff --name-only "$BEFORE_SHA" "${{ github.sha }}" -- 'web/i18n/en-US/*.json' 2>/dev/null | xargs -n1 basename 2>/dev/null | sed 's/.json$//' | tr '\n' ' ' || echo "")
|
CHANGED_FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- 'web/i18n/en-US/*.json' 2>/dev/null | sed -n 's@^.*/@@p' | sed 's/\.json$//' | tr '\n' ' ' | sed 's/[[:space:]]*$//')
|
||||||
echo "changed_files=$changed" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
# Generate diff for context
|
|
||||||
git diff "$BEFORE_SHA" "${{ github.sha }}" -- 'web/i18n/en-US/*.json' > /tmp/i18n-diff.txt 2>/dev/null || echo "" > /tmp/i18n-diff.txt
|
|
||||||
|
|
||||||
# Truncate if too large (keep first 50KB to match receiving workflow)
|
|
||||||
head -c 50000 /tmp/i18n-diff.txt > /tmp/i18n-diff-truncated.txt
|
|
||||||
mv /tmp/i18n-diff-truncated.txt /tmp/i18n-diff.txt
|
|
||||||
|
|
||||||
# Base64 encode the diff for safe JSON transport (portable, single-line)
|
|
||||||
diff_base64=$(base64 < /tmp/i18n-diff.txt | tr -d '\n')
|
|
||||||
echo "diff_base64=$diff_base64" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
if [ -n "$changed" ]; then
|
|
||||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "Detected changed files: $changed"
|
|
||||||
else
|
else
|
||||||
echo "has_changes=false" >> $GITHUB_OUTPUT
|
CHANGED_FILES=$(find web/i18n/en-US -maxdepth 1 -type f -name '*.json' -print | sed -n 's@^.*/@@p' | sed 's/\.json$//' | sort | tr '\n' ' ' | sed 's/[[:space:]]*$//')
|
||||||
echo "No i18n changes detected"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
export BASE_SHA HEAD_SHA CHANGED_FILES
|
||||||
|
node .github/scripts/generate-i18n-changes.mjs
|
||||||
|
|
||||||
|
if [ -n "$CHANGED_FILES" ]; then
|
||||||
|
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "base_sha=$BASE_SHA" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "changed_files=$CHANGED_FILES" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- 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: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
|
env:
|
||||||
|
BASE_SHA: ${{ steps.detect.outputs.base_sha }}
|
||||||
|
HEAD_SHA: ${{ steps.detect.outputs.head_sha }}
|
||||||
|
CHANGED_FILES: ${{ steps.detect.outputs.changed_files }}
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
event-type: i18n-sync
|
script: |
|
||||||
client-payload: '{"changed_files": "${{ steps.detect.outputs.changed_files }}", "diff_base64": "${{ steps.detect.outputs.diff_base64 }}", "sync_mode": "incremental", "trigger_sha": "${{ github.sha }}"}'
|
const fs = require('fs')
|
||||||
|
|
||||||
|
const changesJson = fs.readFileSync('/tmp/i18n-changes.json', 'utf8')
|
||||||
|
const changesBase64 = Buffer.from(changesJson).toString('base64')
|
||||||
|
const maxEmbeddedChangesChars = 48000
|
||||||
|
const changesEmbedded = changesBase64.length <= maxEmbeddedChangesChars
|
||||||
|
|
||||||
|
if (!changesEmbedded) {
|
||||||
|
console.log(`Structured change set too large to embed safely (${changesBase64.length} chars). Downstream workflow will regenerate it from git history.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await github.rest.repos.createDispatchEvent({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
event_type: 'i18n-sync',
|
||||||
|
client_payload: {
|
||||||
|
changed_files: process.env.CHANGED_FILES,
|
||||||
|
changes_base64: changesEmbedded ? changesBase64 : '',
|
||||||
|
changes_embedded: changesEmbedded,
|
||||||
|
sync_mode: 'incremental',
|
||||||
|
base_sha: process.env.BASE_SHA,
|
||||||
|
head_sha: process.env.HEAD_SHA,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
name: Run Full VDB Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 3 * * 1'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: vdb-tests-full-${{ github.ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Full VDB Tests
|
||||||
|
if: github.repository == 'langgenius/dify'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version:
|
||||||
|
- "3.12"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Free Disk Space
|
||||||
|
uses: endersonmenezes/free-disk-space@7901478139cff6e9d44df5972fd8ab8fcade4db1 # v3.2.2
|
||||||
|
with:
|
||||||
|
remove_dotnet: true
|
||||||
|
remove_haskell: true
|
||||||
|
remove_tool_cache: true
|
||||||
|
|
||||||
|
- name: Setup UV and Python
|
||||||
|
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
cache-dependency-glob: api/uv.lock
|
||||||
|
|
||||||
|
- name: Check UV lockfile
|
||||||
|
run: uv lock --project api --check
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --project api --dev
|
||||||
|
|
||||||
|
- name: Set up dotenvs
|
||||||
|
run: |
|
||||||
|
cp docker/.env.example docker/.env
|
||||||
|
cp docker/middleware.env.example docker/middleware.env
|
||||||
|
|
||||||
|
- name: Expose Service Ports
|
||||||
|
run: sh .github/workflows/expose_service_ports.sh
|
||||||
|
|
||||||
|
# - name: Set up Vector Store (TiDB)
|
||||||
|
# uses: hoverkraft-tech/compose-action@v2.0.2
|
||||||
|
# with:
|
||||||
|
# compose-file: docker/tidb/docker-compose.yaml
|
||||||
|
# services: |
|
||||||
|
# tidb
|
||||||
|
# tiflash
|
||||||
|
|
||||||
|
- name: Set up Full Vector Store Matrix
|
||||||
|
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
||||||
|
with:
|
||||||
|
compose-file: |
|
||||||
|
docker/docker-compose.yaml
|
||||||
|
services: |
|
||||||
|
weaviate
|
||||||
|
qdrant
|
||||||
|
couchbase-server
|
||||||
|
etcd
|
||||||
|
minio
|
||||||
|
milvus-standalone
|
||||||
|
pgvecto-rs
|
||||||
|
pgvector
|
||||||
|
chroma
|
||||||
|
elasticsearch
|
||||||
|
oceanbase
|
||||||
|
|
||||||
|
- name: setup test config
|
||||||
|
run: |
|
||||||
|
echo $(pwd)
|
||||||
|
ls -lah .
|
||||||
|
cp api/tests/integration_tests/.env.example api/tests/integration_tests/.env
|
||||||
|
|
||||||
|
# - name: Check VDB Ready (TiDB)
|
||||||
|
# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py
|
||||||
|
|
||||||
|
- name: Test Vector Stores
|
||||||
|
run: uv run --project api bash dev/pytest/pytest_vdb.sh
|
||||||
@@ -1,20 +1,22 @@
|
|||||||
name: Run VDB Tests
|
name: Run VDB Smoke Tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: vdb-tests-${{ github.head_ref || github.run_id }}
|
group: vdb-tests-${{ github.head_ref || github.run_id }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: VDB Tests
|
name: VDB Smoke Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version:
|
python-version:
|
||||||
- "3.11"
|
|
||||||
- "3.12"
|
- "3.12"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -31,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@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
@@ -59,23 +61,18 @@ jobs:
|
|||||||
# tidb
|
# tidb
|
||||||
# tiflash
|
# tiflash
|
||||||
|
|
||||||
- name: Set up Vector Stores (Weaviate, Qdrant, PGVector, Milvus, PgVecto-RS, Chroma, MyScale, ElasticSearch, Couchbase, OceanBase)
|
- name: Set up Vector Stores for Smoke Coverage
|
||||||
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
||||||
with:
|
with:
|
||||||
compose-file: |
|
compose-file: |
|
||||||
docker/docker-compose.yaml
|
docker/docker-compose.yaml
|
||||||
services: |
|
services: |
|
||||||
|
db_postgres
|
||||||
|
redis
|
||||||
weaviate
|
weaviate
|
||||||
qdrant
|
qdrant
|
||||||
couchbase-server
|
|
||||||
etcd
|
|
||||||
minio
|
|
||||||
milvus-standalone
|
|
||||||
pgvecto-rs
|
|
||||||
pgvector
|
pgvector
|
||||||
chroma
|
chroma
|
||||||
elasticsearch
|
|
||||||
oceanbase
|
|
||||||
|
|
||||||
- name: setup test config
|
- name: setup test config
|
||||||
run: |
|
run: |
|
||||||
@@ -84,7 +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: uv run --project api bash dev/pytest/pytest_vdb.sh
|
run: |
|
||||||
|
uv run --project api pytest --timeout "${PYTEST_TIMEOUT:-180}" \
|
||||||
|
api/providers/vdb/vdb-chroma/tests/integration_tests \
|
||||||
|
api/providers/vdb/vdb-pgvector/tests/integration_tests \
|
||||||
|
api/providers/vdb/vdb-qdrant/tests/integration_tests \
|
||||||
|
api/providers/vdb/vdb-weaviate/tests/integration_tests
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
name: Web Full-Stack E2E
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: web-e2e-${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Web Full-Stack E2E
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup web dependencies
|
||||||
|
uses: ./.github/actions/setup-web
|
||||||
|
|
||||||
|
- name: Setup UV and Python
|
||||||
|
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
python-version: "3.12"
|
||||||
|
cache-dependency-glob: api/uv.lock
|
||||||
|
|
||||||
|
- name: Install API dependencies
|
||||||
|
run: uv sync --project api --dev
|
||||||
|
|
||||||
|
- name: Install Playwright browser
|
||||||
|
working-directory: ./e2e
|
||||||
|
run: vp run e2e:install
|
||||||
|
|
||||||
|
- name: Run isolated source-api and built-web Cucumber E2E tests
|
||||||
|
working-directory: ./e2e
|
||||||
|
env:
|
||||||
|
E2E_ADMIN_EMAIL: e2e-admin@example.com
|
||||||
|
E2E_ADMIN_NAME: E2E Admin
|
||||||
|
E2E_ADMIN_PASSWORD: E2eAdmin12345
|
||||||
|
E2E_FORCE_WEB_BUILD: "1"
|
||||||
|
E2E_INIT_PASSWORD: E2eInit12345
|
||||||
|
run: vp run e2e:full
|
||||||
|
|
||||||
|
- name: Upload Cucumber report
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: cucumber-report
|
||||||
|
path: e2e/cucumber-report
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Upload E2E logs
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: e2e-logs
|
||||||
|
path: e2e/.logs
|
||||||
|
retention-days: 7
|
||||||
@@ -22,8 +22,8 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
shardIndex: [1, 2, 3, 4, 5, 6]
|
shardIndex: [1, 2, 3, 4]
|
||||||
shardTotal: [6]
|
shardTotal: [4]
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -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/*
|
||||||
@@ -66,7 +66,6 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup web environment
|
- name: Setup web environment
|
||||||
@@ -84,40 +83,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Report coverage
|
- name: Report coverage
|
||||||
if: ${{ env.CODECOV_TOKEN != '' }}
|
if: ${{ env.CODECOV_TOKEN != '' }}
|
||||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||||
with:
|
with:
|
||||||
directory: web/coverage
|
directory: web/coverage
|
||||||
flags: web
|
flags: web
|
||||||
env:
|
env:
|
||||||
CODECOV_TOKEN: ${{ env.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ env.CODECOV_TOKEN }}
|
||||||
|
|
||||||
web-build:
|
|
||||||
name: Web Build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: ./web
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Check changed files
|
|
||||||
id: changed-files
|
|
||||||
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
|
|
||||||
with:
|
|
||||||
files: |
|
|
||||||
web/**
|
|
||||||
.github/workflows/web-tests.yml
|
|
||||||
.github/actions/setup-web/**
|
|
||||||
|
|
||||||
- name: Setup web environment
|
|
||||||
if: steps.changed-files.outputs.any_changed == 'true'
|
|
||||||
uses: ./.github/actions/setup-web
|
|
||||||
|
|
||||||
- name: Web build check
|
|
||||||
if: steps.changed-files.outputs.any_changed == 'true'
|
|
||||||
working-directory: ./web
|
|
||||||
run: vp run build
|
|
||||||
|
|||||||
+3
-1
@@ -212,6 +212,8 @@ api/.vscode
|
|||||||
|
|
||||||
# pnpm
|
# pnpm
|
||||||
/.pnpm-store
|
/.pnpm-store
|
||||||
|
node_modules
|
||||||
|
.vite-hooks/_
|
||||||
|
|
||||||
# plugin migrate
|
# plugin migrate
|
||||||
plugins.jsonl
|
plugins.jsonl
|
||||||
@@ -239,4 +241,4 @@ scripts/stress-test/reports/
|
|||||||
*.local.md
|
*.local.md
|
||||||
|
|
||||||
# Code Agent Folder
|
# Code Agent Folder
|
||||||
.qoder/*
|
.qoder/*
|
||||||
|
|||||||
Regular → Executable
+7
-27
@@ -77,42 +77,22 @@ if $web_modified; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
cd ./web || exit 1
|
cd ./web || exit 1
|
||||||
lint-staged
|
vp staged
|
||||||
|
|
||||||
if $web_ts_modified; then
|
if $web_ts_modified; then
|
||||||
echo "Running TypeScript type-check:tsgo"
|
echo "Running TypeScript type-check:tsgo"
|
||||||
if ! pnpm run type-check:tsgo; then
|
if ! npm run type-check:tsgo; then
|
||||||
echo "Type check failed. Please run 'pnpm run type-check:tsgo' to fix the errors."
|
echo "Type check failed. Please run 'npm run type-check:tsgo' to fix the errors."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "No staged TypeScript changes detected, skipping type-check:tsgo"
|
echo "No staged TypeScript changes detected, skipping type-check:tsgo"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Running unit tests check"
|
echo "Running knip"
|
||||||
modified_files=$(git diff --cached --name-only -- utils | grep -v '\.spec\.ts$' || true)
|
if ! npm run knip; then
|
||||||
|
echo "Knip check failed. Please run 'npm run knip' to fix the errors."
|
||||||
if [ -n "$modified_files" ]; then
|
exit 1
|
||||||
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
|
fi
|
||||||
|
|
||||||
cd ../
|
cd ../
|
||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ prepare-docker:
|
|||||||
# Step 2: Prepare web environment
|
# Step 2: Prepare web environment
|
||||||
prepare-web:
|
prepare-web:
|
||||||
@echo "🌐 Setting up web environment..."
|
@echo "🌐 Setting up web environment..."
|
||||||
@cp -n web/.env.example web/.env 2>/dev/null || echo "Web .env already exists"
|
@cp -n web/.env.example web/.env.local 2>/dev/null || echo "Web .env.local already exists"
|
||||||
@cd web && pnpm install
|
@pnpm install
|
||||||
@echo "✅ Web environment prepared (not started)"
|
@echo "✅ Web environment prepared (not started)"
|
||||||
|
|
||||||
# Step 3: Prepare API environment
|
# Step 3: Prepare API environment
|
||||||
@@ -74,6 +74,12 @@ type-check:
|
|||||||
@uv --directory api run mypy --exclude-gitignore --exclude 'tests/' --exclude 'migrations/' --check-untyped-defs --disable-error-code=import-untyped .
|
@uv --directory api run mypy --exclude-gitignore --exclude 'tests/' --exclude 'migrations/' --check-untyped-defs --disable-error-code=import-untyped .
|
||||||
@echo "✅ Type checks complete"
|
@echo "✅ Type checks complete"
|
||||||
|
|
||||||
|
type-check-core:
|
||||||
|
@echo "📝 Running core type checks (basedpyright + mypy)..."
|
||||||
|
@./dev/basedpyright-check $(PATH_TO_CHECK)
|
||||||
|
@uv --directory api run mypy --exclude-gitignore --exclude 'tests/' --exclude 'migrations/' --check-untyped-defs --disable-error-code=import-untyped .
|
||||||
|
@echo "✅ Core type checks complete"
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@echo "🧪 Running backend unit tests..."
|
@echo "🧪 Running backend unit tests..."
|
||||||
@if [ -n "$(TARGET_TESTS)" ]; then \
|
@if [ -n "$(TARGET_TESTS)" ]; then \
|
||||||
@@ -87,7 +93,7 @@ test:
|
|||||||
# Build Docker images
|
# Build Docker images
|
||||||
build-web:
|
build-web:
|
||||||
@echo "Building web Docker image: $(WEB_IMAGE):$(VERSION)..."
|
@echo "Building web Docker image: $(WEB_IMAGE):$(VERSION)..."
|
||||||
docker build -t $(WEB_IMAGE):$(VERSION) ./web
|
docker build -f web/Dockerfile -t $(WEB_IMAGE):$(VERSION) .
|
||||||
@echo "Web Docker image built successfully: $(WEB_IMAGE):$(VERSION)"
|
@echo "Web Docker image built successfully: $(WEB_IMAGE):$(VERSION)"
|
||||||
|
|
||||||
build-api:
|
build-api:
|
||||||
@@ -133,6 +139,7 @@ help:
|
|||||||
@echo " make check - Check code with ruff"
|
@echo " make check - Check code with ruff"
|
||||||
@echo " make lint - Format, fix, and lint code (ruff, imports, dotenv)"
|
@echo " make lint - Format, fix, and lint code (ruff, imports, dotenv)"
|
||||||
@echo " make type-check - Run type checks (basedpyright, pyrefly, mypy)"
|
@echo " make type-check - Run type checks (basedpyright, pyrefly, mypy)"
|
||||||
|
@echo " make type-check-core - Run core type checks (basedpyright, mypy)"
|
||||||
@echo " make test - Run backend unit tests (or TARGET_TESTS=./api/tests/<target_tests>)"
|
@echo " make test - Run backend unit tests (or TARGET_TESTS=./api/tests/<target_tests>)"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Docker Build Targets:"
|
@echo "Docker Build Targets:"
|
||||||
|
|||||||
@@ -53,7 +53,11 @@
|
|||||||
<a href="./docs/tr-TR/README.md"><img alt="Türkçe README" src="https://img.shields.io/badge/Türkçe-d9d9d9"></a>
|
<a href="./docs/tr-TR/README.md"><img alt="Türkçe README" src="https://img.shields.io/badge/Türkçe-d9d9d9"></a>
|
||||||
<a href="./docs/vi-VN/README.md"><img alt="README Tiếng Việt" src="https://img.shields.io/badge/Ti%E1%BA%BFng%20Vi%E1%BB%87t-d9d9d9"></a>
|
<a href="./docs/vi-VN/README.md"><img alt="README Tiếng Việt" src="https://img.shields.io/badge/Ti%E1%BA%BFng%20Vi%E1%BB%87t-d9d9d9"></a>
|
||||||
<a href="./docs/de-DE/README.md"><img alt="README in Deutsch" src="https://img.shields.io/badge/German-d9d9d9"></a>
|
<a href="./docs/de-DE/README.md"><img alt="README in Deutsch" src="https://img.shields.io/badge/German-d9d9d9"></a>
|
||||||
|
<a href="./docs/it-IT/README.md"><img alt="README in Italiano" src="https://img.shields.io/badge/Italiano-d9d9d9"></a>
|
||||||
|
<a href="./docs/pt-BR/README.md"><img alt="README em Português do Brasil" src="https://img.shields.io/badge/Portugu%C3%AAs%20do%20Brasil-d9d9d9"></a>
|
||||||
|
<a href="./docs/sl-SI/README.md"><img alt="README Slovenščina" src="https://img.shields.io/badge/Sloven%C5%A1%C4%8Dina-d9d9d9"></a>
|
||||||
<a href="./docs/bn-BD/README.md"><img alt="README in বাংলা" src="https://img.shields.io/badge/বাংলা-d9d9d9"></a>
|
<a href="./docs/bn-BD/README.md"><img alt="README in বাংলা" src="https://img.shields.io/badge/বাংলা-d9d9d9"></a>
|
||||||
|
<a href="./docs/hi-IN/README.md"><img alt="README in हिन्दी" src="https://img.shields.io/badge/Hindi-d9d9d9"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Dify is an open-source LLM app development platform. Its intuitive interface combines AI workflow, RAG pipeline, agent capabilities, model management, observability features (including [Opik](https://www.comet.com/docs/opik/integrations/dify), [Langfuse](https://docs.langfuse.com), and [Arize Phoenix](https://docs.arize.com/phoenix)) and more, letting you quickly go from prototype to production. Here's a list of the core features:
|
Dify is an open-source LLM app development platform. Its intuitive interface combines AI workflow, RAG pipeline, agent capabilities, model management, observability features (including [Opik](https://www.comet.com/docs/opik/integrations/dify), [Langfuse](https://docs.langfuse.com), and [Arize Phoenix](https://docs.arize.com/phoenix)) and more, letting you quickly go from prototype to production. Here's a list of the core features:
|
||||||
|
|||||||
+13
-1
@@ -57,6 +57,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 +74,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 +112,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
|
||||||
@@ -127,7 +138,8 @@ ALIYUN_OSS_AUTH_VERSION=v1
|
|||||||
ALIYUN_OSS_REGION=your-region
|
ALIYUN_OSS_REGION=your-region
|
||||||
# Don't start with '/'. OSS doesn't support leading slash in object names.
|
# Don't start with '/'. OSS doesn't support leading slash in object names.
|
||||||
ALIYUN_OSS_PATH=your-path
|
ALIYUN_OSS_PATH=your-path
|
||||||
ALIYUN_CLOUDBOX_ID=your-cloudbox-id
|
# Optional CloudBox ID for Aliyun OSS, DO NOT enable it if you are not using CloudBox.
|
||||||
|
#ALIYUN_CLOUDBOX_ID=your-cloudbox-id
|
||||||
|
|
||||||
# Google Storage configuration
|
# Google Storage configuration
|
||||||
GOOGLE_STORAGE_BUCKET_NAME=your-bucket-name
|
GOOGLE_STORAGE_BUCKET_NAME=your-bucket-name
|
||||||
|
|||||||
+4
-192
@@ -1,202 +1,14 @@
|
|||||||
[importlinter]
|
[importlinter]
|
||||||
root_packages =
|
root_packages =
|
||||||
core
|
core
|
||||||
dify_graph
|
constants
|
||||||
|
context
|
||||||
configs
|
configs
|
||||||
controllers
|
controllers
|
||||||
extensions
|
extensions
|
||||||
|
factories
|
||||||
|
libs
|
||||||
models
|
models
|
||||||
tasks
|
tasks
|
||||||
services
|
services
|
||||||
include_external_packages = True
|
include_external_packages = True
|
||||||
|
|
||||||
[importlinter:contract:workflow]
|
|
||||||
name = Workflow
|
|
||||||
type=layers
|
|
||||||
layers =
|
|
||||||
graph_engine
|
|
||||||
graph_events
|
|
||||||
graph
|
|
||||||
nodes
|
|
||||||
node_events
|
|
||||||
runtime
|
|
||||||
entities
|
|
||||||
containers =
|
|
||||||
dify_graph
|
|
||||||
ignore_imports =
|
|
||||||
dify_graph.nodes.base.node -> dify_graph.graph_events
|
|
||||||
dify_graph.nodes.iteration.iteration_node -> dify_graph.graph_events
|
|
||||||
dify_graph.nodes.loop.loop_node -> dify_graph.graph_events
|
|
||||||
|
|
||||||
dify_graph.nodes.iteration.iteration_node -> dify_graph.graph_engine
|
|
||||||
dify_graph.nodes.loop.loop_node -> dify_graph.graph_engine
|
|
||||||
# TODO(QuantumGhost): fix the import violation later
|
|
||||||
dify_graph.entities.pause_reason -> dify_graph.nodes.human_input.entities
|
|
||||||
|
|
||||||
[importlinter:contract:workflow-infrastructure-dependencies]
|
|
||||||
name = Workflow Infrastructure Dependencies
|
|
||||||
type = forbidden
|
|
||||||
source_modules =
|
|
||||||
dify_graph
|
|
||||||
forbidden_modules =
|
|
||||||
extensions.ext_database
|
|
||||||
extensions.ext_redis
|
|
||||||
allow_indirect_imports = True
|
|
||||||
ignore_imports =
|
|
||||||
dify_graph.nodes.llm.node -> extensions.ext_database
|
|
||||||
dify_graph.model_runtime.model_providers.__base.ai_model -> extensions.ext_redis
|
|
||||||
dify_graph.model_runtime.model_providers.model_provider_factory -> extensions.ext_redis
|
|
||||||
|
|
||||||
[importlinter:contract:workflow-external-imports]
|
|
||||||
name = Workflow External Imports
|
|
||||||
type = forbidden
|
|
||||||
source_modules =
|
|
||||||
dify_graph
|
|
||||||
forbidden_modules =
|
|
||||||
configs
|
|
||||||
controllers
|
|
||||||
extensions
|
|
||||||
models
|
|
||||||
services
|
|
||||||
tasks
|
|
||||||
core.agent
|
|
||||||
core.app
|
|
||||||
core.base
|
|
||||||
core.callback_handler
|
|
||||||
core.datasource
|
|
||||||
core.db
|
|
||||||
core.entities
|
|
||||||
core.errors
|
|
||||||
core.extension
|
|
||||||
core.external_data_tool
|
|
||||||
core.file
|
|
||||||
core.helper
|
|
||||||
core.hosting_configuration
|
|
||||||
core.indexing_runner
|
|
||||||
core.llm_generator
|
|
||||||
core.logging
|
|
||||||
core.mcp
|
|
||||||
core.memory
|
|
||||||
core.moderation
|
|
||||||
core.ops
|
|
||||||
core.plugin
|
|
||||||
core.prompt
|
|
||||||
core.provider_manager
|
|
||||||
core.rag
|
|
||||||
core.repositories
|
|
||||||
core.schemas
|
|
||||||
core.tools
|
|
||||||
core.trigger
|
|
||||||
core.variables
|
|
||||||
ignore_imports =
|
|
||||||
dify_graph.nodes.llm.llm_utils -> core.model_manager
|
|
||||||
dify_graph.nodes.llm.protocols -> core.model_manager
|
|
||||||
dify_graph.nodes.llm.llm_utils -> dify_graph.model_runtime.model_providers.__base.large_language_model
|
|
||||||
dify_graph.nodes.llm.node -> core.tools.signature
|
|
||||||
dify_graph.nodes.tool.tool_node -> core.callback_handler.workflow_tool_callback_handler
|
|
||||||
dify_graph.nodes.tool.tool_node -> core.tools.tool_engine
|
|
||||||
dify_graph.nodes.tool.tool_node -> core.tools.tool_manager
|
|
||||||
dify_graph.nodes.parameter_extractor.parameter_extractor_node -> core.prompt.advanced_prompt_transform
|
|
||||||
dify_graph.nodes.parameter_extractor.parameter_extractor_node -> core.prompt.simple_prompt_transform
|
|
||||||
dify_graph.nodes.parameter_extractor.parameter_extractor_node -> dify_graph.model_runtime.model_providers.__base.large_language_model
|
|
||||||
dify_graph.nodes.question_classifier.question_classifier_node -> core.prompt.simple_prompt_transform
|
|
||||||
dify_graph.nodes.parameter_extractor.parameter_extractor_node -> core.model_manager
|
|
||||||
dify_graph.nodes.question_classifier.question_classifier_node -> core.model_manager
|
|
||||||
dify_graph.nodes.tool.tool_node -> core.tools.utils.message_transformer
|
|
||||||
dify_graph.nodes.llm.node -> core.llm_generator.output_parser.errors
|
|
||||||
dify_graph.nodes.llm.node -> core.llm_generator.output_parser.structured_output
|
|
||||||
dify_graph.nodes.llm.node -> core.model_manager
|
|
||||||
dify_graph.nodes.llm.entities -> core.prompt.entities.advanced_prompt_entities
|
|
||||||
dify_graph.nodes.llm.node -> core.prompt.entities.advanced_prompt_entities
|
|
||||||
dify_graph.nodes.llm.node -> core.prompt.utils.prompt_message_util
|
|
||||||
dify_graph.nodes.parameter_extractor.entities -> core.prompt.entities.advanced_prompt_entities
|
|
||||||
dify_graph.nodes.parameter_extractor.parameter_extractor_node -> core.prompt.entities.advanced_prompt_entities
|
|
||||||
dify_graph.nodes.parameter_extractor.parameter_extractor_node -> core.prompt.utils.prompt_message_util
|
|
||||||
dify_graph.nodes.question_classifier.entities -> core.prompt.entities.advanced_prompt_entities
|
|
||||||
dify_graph.nodes.question_classifier.question_classifier_node -> core.prompt.utils.prompt_message_util
|
|
||||||
dify_graph.nodes.llm.node -> models.dataset
|
|
||||||
dify_graph.nodes.llm.file_saver -> core.tools.signature
|
|
||||||
dify_graph.nodes.llm.file_saver -> core.tools.tool_file_manager
|
|
||||||
dify_graph.nodes.tool.tool_node -> core.tools.errors
|
|
||||||
dify_graph.nodes.llm.node -> extensions.ext_database
|
|
||||||
dify_graph.nodes.llm.node -> models.model
|
|
||||||
dify_graph.nodes.tool.tool_node -> services
|
|
||||||
dify_graph.model_runtime.model_providers.__base.ai_model -> configs
|
|
||||||
dify_graph.model_runtime.model_providers.__base.ai_model -> extensions.ext_redis
|
|
||||||
dify_graph.model_runtime.model_providers.__base.large_language_model -> configs
|
|
||||||
dify_graph.model_runtime.model_providers.__base.text_embedding_model -> core.entities.embedding_type
|
|
||||||
dify_graph.model_runtime.model_providers.model_provider_factory -> configs
|
|
||||||
dify_graph.model_runtime.model_providers.model_provider_factory -> extensions.ext_redis
|
|
||||||
dify_graph.model_runtime.model_providers.model_provider_factory -> models.provider_ids
|
|
||||||
|
|
||||||
[importlinter:contract:rsc]
|
|
||||||
name = RSC
|
|
||||||
type = layers
|
|
||||||
layers =
|
|
||||||
graph_engine
|
|
||||||
response_coordinator
|
|
||||||
containers =
|
|
||||||
dify_graph.graph_engine
|
|
||||||
|
|
||||||
[importlinter:contract:worker]
|
|
||||||
name = Worker
|
|
||||||
type = layers
|
|
||||||
layers =
|
|
||||||
graph_engine
|
|
||||||
worker
|
|
||||||
containers =
|
|
||||||
dify_graph.graph_engine
|
|
||||||
|
|
||||||
[importlinter:contract:graph-engine-architecture]
|
|
||||||
name = Graph Engine Architecture
|
|
||||||
type = layers
|
|
||||||
layers =
|
|
||||||
graph_engine
|
|
||||||
orchestration
|
|
||||||
command_processing
|
|
||||||
event_management
|
|
||||||
error_handler
|
|
||||||
graph_traversal
|
|
||||||
graph_state_manager
|
|
||||||
worker_management
|
|
||||||
domain
|
|
||||||
containers =
|
|
||||||
dify_graph.graph_engine
|
|
||||||
|
|
||||||
[importlinter:contract:domain-isolation]
|
|
||||||
name = Domain Model Isolation
|
|
||||||
type = forbidden
|
|
||||||
source_modules =
|
|
||||||
dify_graph.graph_engine.domain
|
|
||||||
forbidden_modules =
|
|
||||||
dify_graph.graph_engine.worker_management
|
|
||||||
dify_graph.graph_engine.command_channels
|
|
||||||
dify_graph.graph_engine.layers
|
|
||||||
dify_graph.graph_engine.protocols
|
|
||||||
|
|
||||||
[importlinter:contract:worker-management]
|
|
||||||
name = Worker Management
|
|
||||||
type = forbidden
|
|
||||||
source_modules =
|
|
||||||
dify_graph.graph_engine.worker_management
|
|
||||||
forbidden_modules =
|
|
||||||
dify_graph.graph_engine.orchestration
|
|
||||||
dify_graph.graph_engine.command_processing
|
|
||||||
dify_graph.graph_engine.event_management
|
|
||||||
|
|
||||||
|
|
||||||
[importlinter:contract:graph-traversal-components]
|
|
||||||
name = Graph Traversal Components
|
|
||||||
type = layers
|
|
||||||
layers =
|
|
||||||
edge_processor
|
|
||||||
skip_propagator
|
|
||||||
containers =
|
|
||||||
dify_graph.graph_engine.graph_traversal
|
|
||||||
|
|
||||||
[importlinter:contract:command-channels]
|
|
||||||
name = Command Channels Independence
|
|
||||||
type = independence
|
|
||||||
modules =
|
|
||||||
dify_graph.graph_engine.command_channels.in_memory_channel
|
|
||||||
dify_graph.graph_engine.command_channels.redis_channel
|
|
||||||
|
|||||||
@@ -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,35 +90,16 @@ 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
|
||||||
]
|
]
|
||||||
"dify_graph/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."
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ The scripts resolve paths relative to their location, so you can run them from a
|
|||||||
./dev/start-web
|
./dev/start-web
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`./dev/setup` and `./dev/start-web` install JavaScript dependencies through the repository root workspace, so you do not need a separate `cd web && pnpm install` step.
|
||||||
|
|
||||||
1. Set up your application by visiting `http://localhost:3000`.
|
1. Set up your application by visiting `http://localhost:3000`.
|
||||||
|
|
||||||
1. Start the worker service (async and scheduler tasks, runs from `api`).
|
1. Start the worker service (async and scheduler tasks, runs from `api`).
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ def initialize_extensions(app: DifyApp):
|
|||||||
ext_commands,
|
ext_commands,
|
||||||
ext_compress,
|
ext_compress,
|
||||||
ext_database,
|
ext_database,
|
||||||
|
ext_enterprise_telemetry,
|
||||||
ext_fastopenapi,
|
ext_fastopenapi,
|
||||||
ext_forward_refs,
|
ext_forward_refs,
|
||||||
ext_hosting_provider,
|
ext_hosting_provider,
|
||||||
@@ -193,6 +194,7 @@ def initialize_extensions(app: DifyApp):
|
|||||||
ext_commands,
|
ext_commands,
|
||||||
ext_fastopenapi,
|
ext_fastopenapi,
|
||||||
ext_otel,
|
ext_otel,
|
||||||
|
ext_enterprise_telemetry,
|
||||||
ext_request_logging,
|
ext_request_logging,
|
||||||
ext_session_factory,
|
ext_session_factory,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
-33
@@ -2,7 +2,6 @@ import base64
|
|||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
from constants.languages import languages
|
from constants.languages import languages
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
@@ -25,30 +24,31 @@ 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()
|
||||||
account.password = base64_password_hashed
|
account = db.session.merge(account)
|
||||||
account.password_salt = base64_salt
|
account.password = base64_password_hashed
|
||||||
AccountService.reset_login_error_rate_limit(normalized_email)
|
account.password_salt = base64_salt
|
||||||
click.echo(click.style("Password reset successfully.", fg="green"))
|
db.session.commit()
|
||||||
|
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 +65,22 @@ 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
|
||||||
|
|
||||||
account.email = normalized_new_email
|
account = db.session.merge(account)
|
||||||
click.echo(click.style("Email updated successfully.", fg="green"))
|
account.email = normalized_new_email
|
||||||
|
db.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.")
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, Settings
|
|||||||
from libs.file_utils import search_file_upwards
|
from libs.file_utils import search_file_upwards
|
||||||
|
|
||||||
from .deploy import DeploymentConfig
|
from .deploy import DeploymentConfig
|
||||||
from .enterprise import EnterpriseFeatureConfig
|
from .enterprise import EnterpriseFeatureConfig, EnterpriseTelemetryConfig
|
||||||
from .extra import ExtraServiceConfig
|
from .extra import ExtraServiceConfig
|
||||||
from .feature import FeatureConfig
|
from .feature import FeatureConfig
|
||||||
from .middleware import MiddlewareConfig
|
from .middleware import MiddlewareConfig
|
||||||
@@ -73,6 +73,8 @@ class DifyConfig(
|
|||||||
# Enterprise feature configs
|
# Enterprise feature configs
|
||||||
# **Before using, please contact business@dify.ai by email to inquire about licensing matters.**
|
# **Before using, please contact business@dify.ai by email to inquire about licensing matters.**
|
||||||
EnterpriseFeatureConfig,
|
EnterpriseFeatureConfig,
|
||||||
|
# Enterprise telemetry configs
|
||||||
|
EnterpriseTelemetryConfig,
|
||||||
):
|
):
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
# read from dotenv format config file
|
# read from dotenv format config file
|
||||||
|
|||||||
@@ -22,3 +22,52 @@ class EnterpriseFeatureConfig(BaseSettings):
|
|||||||
ENTERPRISE_REQUEST_TIMEOUT: int = Field(
|
ENTERPRISE_REQUEST_TIMEOUT: int = Field(
|
||||||
ge=1, description="Maximum timeout in seconds for enterprise requests", default=5
|
ge=1, description="Maximum timeout in seconds for enterprise requests", default=5
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EnterpriseTelemetryConfig(BaseSettings):
|
||||||
|
"""
|
||||||
|
Configuration for enterprise telemetry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ENTERPRISE_TELEMETRY_ENABLED: bool = Field(
|
||||||
|
description="Enable enterprise telemetry collection (also requires ENTERPRISE_ENABLED=true).",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
ENTERPRISE_OTLP_ENDPOINT: str = Field(
|
||||||
|
description="Enterprise OTEL collector endpoint.",
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
|
||||||
|
ENTERPRISE_OTLP_HEADERS: str = Field(
|
||||||
|
description="Auth headers for OTLP export (key=value,key2=value2).",
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
|
||||||
|
ENTERPRISE_OTLP_PROTOCOL: str = Field(
|
||||||
|
description="OTLP protocol: 'http' or 'grpc' (default: http).",
|
||||||
|
default="http",
|
||||||
|
)
|
||||||
|
|
||||||
|
ENTERPRISE_OTLP_API_KEY: str = Field(
|
||||||
|
description="Bearer token for enterprise OTLP export authentication.",
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
|
||||||
|
ENTERPRISE_INCLUDE_CONTENT: bool = Field(
|
||||||
|
description="Include input/output content in traces (privacy toggle).",
|
||||||
|
# Setting the default value to False to avoid accidentally log PII data in traces.
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
ENTERPRISE_SERVICE_NAME: str = Field(
|
||||||
|
description="Service name for OTEL resource.",
|
||||||
|
default="dify",
|
||||||
|
)
|
||||||
|
|
||||||
|
ENTERPRISE_OTEL_SAMPLING_RATE: float = Field(
|
||||||
|
description="Sampling rate for enterprise traces (0.0 to 1.0, default 1.0 = 100%).",
|
||||||
|
default=1.0,
|
||||||
|
ge=0.0,
|
||||||
|
le=1.0,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+23
-14
@@ -7,15 +7,16 @@ UUID_NIL = "00000000-0000-0000-0000-000000000000"
|
|||||||
|
|
||||||
DEFAULT_FILE_NUMBER_LIMITS = 3
|
DEFAULT_FILE_NUMBER_LIMITS = 3
|
||||||
|
|
||||||
IMAGE_EXTENSIONS = convert_to_lower_and_upper_set({"jpg", "jpeg", "png", "webp", "gif", "svg"})
|
_IMAGE_EXTENSION_BASE: frozenset[str] = frozenset(("jpg", "jpeg", "png", "webp", "gif", "svg"))
|
||||||
|
_VIDEO_EXTENSION_BASE: frozenset[str] = frozenset(("mp4", "mov", "mpeg", "webm"))
|
||||||
|
_AUDIO_EXTENSION_BASE: frozenset[str] = frozenset(("mp3", "m4a", "wav", "amr", "mpga"))
|
||||||
|
|
||||||
VIDEO_EXTENSIONS = convert_to_lower_and_upper_set({"mp4", "mov", "mpeg", "webm"})
|
IMAGE_EXTENSIONS: frozenset[str] = frozenset(convert_to_lower_and_upper_set(_IMAGE_EXTENSION_BASE))
|
||||||
|
VIDEO_EXTENSIONS: frozenset[str] = frozenset(convert_to_lower_and_upper_set(_VIDEO_EXTENSION_BASE))
|
||||||
|
AUDIO_EXTENSIONS: frozenset[str] = frozenset(convert_to_lower_and_upper_set(_AUDIO_EXTENSION_BASE))
|
||||||
|
|
||||||
AUDIO_EXTENSIONS = convert_to_lower_and_upper_set({"mp3", "m4a", "wav", "amr", "mpga"})
|
_UNSTRUCTURED_DOCUMENT_EXTENSION_BASE: frozenset[str] = frozenset(
|
||||||
|
(
|
||||||
_doc_extensions: set[str]
|
|
||||||
if dify_config.ETL_TYPE == "Unstructured":
|
|
||||||
_doc_extensions = {
|
|
||||||
"txt",
|
"txt",
|
||||||
"markdown",
|
"markdown",
|
||||||
"md",
|
"md",
|
||||||
@@ -35,11 +36,10 @@ if dify_config.ETL_TYPE == "Unstructured":
|
|||||||
"pptx",
|
"pptx",
|
||||||
"xml",
|
"xml",
|
||||||
"epub",
|
"epub",
|
||||||
}
|
)
|
||||||
if dify_config.UNSTRUCTURED_API_URL:
|
)
|
||||||
_doc_extensions.add("ppt")
|
_DEFAULT_DOCUMENT_EXTENSION_BASE: frozenset[str] = frozenset(
|
||||||
else:
|
(
|
||||||
_doc_extensions = {
|
|
||||||
"txt",
|
"txt",
|
||||||
"markdown",
|
"markdown",
|
||||||
"md",
|
"md",
|
||||||
@@ -53,8 +53,17 @@ else:
|
|||||||
"csv",
|
"csv",
|
||||||
"vtt",
|
"vtt",
|
||||||
"properties",
|
"properties",
|
||||||
}
|
)
|
||||||
DOCUMENT_EXTENSIONS: set[str] = convert_to_lower_and_upper_set(_doc_extensions)
|
)
|
||||||
|
|
||||||
|
_doc_extensions: set[str]
|
||||||
|
if dify_config.ETL_TYPE == "Unstructured":
|
||||||
|
_doc_extensions = set(_UNSTRUCTURED_DOCUMENT_EXTENSION_BASE)
|
||||||
|
if dify_config.UNSTRUCTURED_API_URL:
|
||||||
|
_doc_extensions.add("ppt")
|
||||||
|
else:
|
||||||
|
_doc_extensions = set(_DEFAULT_DOCUMENT_EXTENSION_BASE)
|
||||||
|
DOCUMENT_EXTENSIONS: frozenset[str] = frozenset(convert_to_lower_and_upper_set(_doc_extensions))
|
||||||
|
|
||||||
# console
|
# console
|
||||||
COOKIE_NAME_ACCESS_TOKEN = "access_token"
|
COOKIE_NAME_ACCESS_TOKEN = "access_token"
|
||||||
|
|||||||
+22
-60
@@ -1,74 +1,36 @@
|
|||||||
"""
|
"""
|
||||||
Core Context - Framework-agnostic context management.
|
Application-layer context adapters.
|
||||||
|
|
||||||
This module provides context management that is independent of any specific
|
Concrete execution-context implementations live here so `graphon` only
|
||||||
web framework. Framework-specific implementations register their context
|
depends on injected context managers rather than framework state capture.
|
||||||
capture functions at application initialization time.
|
|
||||||
|
|
||||||
This ensures the workflow layer remains completely decoupled from Flask
|
|
||||||
or any other web framework.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import contextvars
|
from context.execution_context import (
|
||||||
from collections.abc import Callable
|
AppContext,
|
||||||
|
ContextProviderNotFoundError,
|
||||||
from dify_graph.context.execution_context import (
|
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
|
ExecutionContextBuilder,
|
||||||
IExecutionContext,
|
IExecutionContext,
|
||||||
NullAppContext,
|
NullAppContext,
|
||||||
|
capture_current_context,
|
||||||
|
read_context,
|
||||||
|
register_context,
|
||||||
|
register_context_capturer,
|
||||||
|
reset_context_provider,
|
||||||
)
|
)
|
||||||
|
from context.models import SandboxContext
|
||||||
# Global capturer function - set by framework-specific modules
|
|
||||||
_capturer: Callable[[], IExecutionContext] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def register_context_capturer(capturer: Callable[[], IExecutionContext]) -> None:
|
|
||||||
"""
|
|
||||||
Register a context capture function.
|
|
||||||
|
|
||||||
This should be called by framework-specific modules (e.g., Flask)
|
|
||||||
during application initialization.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
capturer: Function that captures current context and returns IExecutionContext
|
|
||||||
"""
|
|
||||||
global _capturer
|
|
||||||
_capturer = capturer
|
|
||||||
|
|
||||||
|
|
||||||
def capture_current_context() -> IExecutionContext:
|
|
||||||
"""
|
|
||||||
Capture current execution context.
|
|
||||||
|
|
||||||
This function uses the registered context capturer. If no capturer
|
|
||||||
is registered, it returns a minimal context with only contextvars
|
|
||||||
(suitable for non-framework environments like tests or standalone scripts).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
IExecutionContext with captured context
|
|
||||||
"""
|
|
||||||
if _capturer is None:
|
|
||||||
# No framework registered - return minimal context
|
|
||||||
return ExecutionContext(
|
|
||||||
app_context=NullAppContext(),
|
|
||||||
context_vars=contextvars.copy_context(),
|
|
||||||
)
|
|
||||||
|
|
||||||
return _capturer()
|
|
||||||
|
|
||||||
|
|
||||||
def reset_context_provider() -> None:
|
|
||||||
"""
|
|
||||||
Reset the context capturer.
|
|
||||||
|
|
||||||
This is primarily useful for testing to ensure a clean state.
|
|
||||||
"""
|
|
||||||
global _capturer
|
|
||||||
_capturer = None
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"AppContext",
|
||||||
|
"ContextProviderNotFoundError",
|
||||||
|
"ExecutionContext",
|
||||||
|
"ExecutionContextBuilder",
|
||||||
|
"IExecutionContext",
|
||||||
|
"NullAppContext",
|
||||||
|
"SandboxContext",
|
||||||
"capture_current_context",
|
"capture_current_context",
|
||||||
|
"read_context",
|
||||||
|
"register_context",
|
||||||
"register_context_capturer",
|
"register_context_capturer",
|
||||||
"reset_context_provider",
|
"reset_context_provider",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
Execution Context - Abstracted context management for workflow execution.
|
Application-layer execution context adapters.
|
||||||
|
|
||||||
|
Concrete context capture lives outside `graphon` so the graph package only
|
||||||
|
consumes injected context managers when it needs to preserve thread-local state.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import contextvars
|
import contextvars
|
||||||
@@ -7,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
|
||||||
|
|
||||||
@@ -16,33 +19,33 @@ class AppContext(ABC):
|
|||||||
"""
|
"""
|
||||||
Abstract application context interface.
|
Abstract application context interface.
|
||||||
|
|
||||||
This abstraction allows workflow execution to work with or without Flask
|
Application adapters can implement this to restore framework-specific state
|
||||||
by providing a common interface for application context management.
|
such as Flask app context around worker execution.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_config(self, key: str, default: Any = None) -> Any:
|
def get_config(self, key: str, default: Any = None) -> Any:
|
||||||
"""Get configuration value by key."""
|
"""Get configuration value by key."""
|
||||||
pass
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_extension(self, name: str) -> Any:
|
def get_extension(self, name: str) -> Any:
|
||||||
"""Get Flask extension by name (e.g., 'db', 'cache')."""
|
"""Get application extension by name."""
|
||||||
pass
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def enter(self) -> AbstractContextManager[None]:
|
def enter(self) -> AbstractContextManager[None]:
|
||||||
"""Enter the application context."""
|
"""Enter the application context."""
|
||||||
pass
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class IExecutionContext(Protocol):
|
class IExecutionContext(Protocol):
|
||||||
"""
|
"""
|
||||||
Protocol for execution context.
|
Protocol for enterable execution context objects.
|
||||||
|
|
||||||
This protocol defines the interface that all execution contexts must implement,
|
Concrete implementations may carry extra framework state, but callers only
|
||||||
allowing both ExecutionContext and FlaskExecutionContext to be used interchangeably.
|
depend on standard context-manager behavior plus optional user metadata.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __enter__(self) -> "IExecutionContext":
|
def __enter__(self) -> "IExecutionContext":
|
||||||
@@ -62,14 +65,10 @@ class IExecutionContext(Protocol):
|
|||||||
@final
|
@final
|
||||||
class ExecutionContext:
|
class ExecutionContext:
|
||||||
"""
|
"""
|
||||||
Execution context for workflow execution in worker threads.
|
Generic execution context used by application-layer adapters.
|
||||||
|
|
||||||
This class encapsulates all context needed for workflow execution:
|
It restores captured `contextvars` and optionally enters an application
|
||||||
- Application context (Flask app or standalone)
|
context before the worker executes graph logic.
|
||||||
- Context variables for Python contextvars
|
|
||||||
- User information (optional)
|
|
||||||
|
|
||||||
It is designed to be serializable and passable to worker threads.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -78,14 +77,6 @@ class ExecutionContext:
|
|||||||
context_vars: contextvars.Context | None = None,
|
context_vars: contextvars.Context | None = None,
|
||||||
user: Any = None,
|
user: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
|
||||||
Initialize execution context.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
app_context: Application context (Flask or standalone)
|
|
||||||
context_vars: Python contextvars to preserve
|
|
||||||
user: User object (optional)
|
|
||||||
"""
|
|
||||||
self._app_context = app_context
|
self._app_context = app_context
|
||||||
self._context_vars = context_vars
|
self._context_vars = context_vars
|
||||||
self._user = user
|
self._user = user
|
||||||
@@ -98,27 +89,21 @@ class ExecutionContext:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def context_vars(self) -> contextvars.Context | None:
|
def context_vars(self) -> contextvars.Context | None:
|
||||||
"""Get context variables."""
|
"""Get captured context variables."""
|
||||||
return self._context_vars
|
return self._context_vars
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def user(self) -> Any:
|
def user(self) -> Any:
|
||||||
"""Get user object."""
|
"""Get captured user object."""
|
||||||
return self._user
|
return self._user
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def enter(self) -> Generator[None, None, None]:
|
def enter(self) -> Generator[None, None, None]:
|
||||||
"""
|
"""Enter this execution context."""
|
||||||
Enter this execution context.
|
|
||||||
|
|
||||||
This is a convenience method that creates a context manager.
|
|
||||||
"""
|
|
||||||
# Restore context variables if provided
|
|
||||||
if self._context_vars:
|
if self._context_vars:
|
||||||
for var, val in self._context_vars.items():
|
for var, val in self._context_vars.items():
|
||||||
var.set(val)
|
var.set(val)
|
||||||
|
|
||||||
# Enter app context if available
|
|
||||||
if self._app_context is not None:
|
if self._app_context is not None:
|
||||||
with self._app_context.enter():
|
with self._app_context.enter():
|
||||||
yield
|
yield
|
||||||
@@ -141,18 +126,10 @@ class ExecutionContext:
|
|||||||
|
|
||||||
class NullAppContext(AppContext):
|
class NullAppContext(AppContext):
|
||||||
"""
|
"""
|
||||||
Null implementation of AppContext for non-Flask environments.
|
Null application context for non-framework environments.
|
||||||
|
|
||||||
This is used when running without Flask (e.g., in tests or standalone mode).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, config: dict[str, Any] | None = None) -> None:
|
def __init__(self, config: dict[str, Any] | None = None) -> None:
|
||||||
"""
|
|
||||||
Initialize null app context.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config: Optional configuration dictionary
|
|
||||||
"""
|
|
||||||
self._config = config or {}
|
self._config = config or {}
|
||||||
self._extensions: dict[str, Any] = {}
|
self._extensions: dict[str, Any] = {}
|
||||||
|
|
||||||
@@ -165,7 +142,7 @@ class NullAppContext(AppContext):
|
|||||||
return self._extensions.get(name)
|
return self._extensions.get(name)
|
||||||
|
|
||||||
def set_extension(self, name: str, extension: Any) -> None:
|
def set_extension(self, name: str, extension: Any) -> None:
|
||||||
"""Set extension by name."""
|
"""Register an extension for tests or standalone execution."""
|
||||||
self._extensions[name] = extension
|
self._extensions[name] = extension
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
@@ -176,9 +153,7 @@ class NullAppContext(AppContext):
|
|||||||
|
|
||||||
class ExecutionContextBuilder:
|
class ExecutionContextBuilder:
|
||||||
"""
|
"""
|
||||||
Builder for creating ExecutionContext instances.
|
Builder for creating `ExecutionContext` instances.
|
||||||
|
|
||||||
This provides a fluent API for building execution contexts.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
@@ -211,63 +186,40 @@ class ExecutionContextBuilder:
|
|||||||
|
|
||||||
|
|
||||||
_capturer: Callable[[], IExecutionContext] | None = None
|
_capturer: Callable[[], IExecutionContext] | None = None
|
||||||
|
|
||||||
# Tenant-scoped providers using tuple keys for clarity and constant-time lookup.
|
|
||||||
# Key mapping:
|
|
||||||
# (name, tenant_id) -> provider
|
|
||||||
# - name: namespaced identifier (recommend prefixing, e.g. "workflow.sandbox")
|
|
||||||
# - tenant_id: tenant identifier string
|
|
||||||
# Value:
|
|
||||||
# provider: Callable[[], BaseModel] returning the typed context value
|
|
||||||
# Type-safety note:
|
|
||||||
# - This registry cannot enforce that all providers for a given name return the same BaseModel type.
|
|
||||||
# - Implementors SHOULD provide typed wrappers around register/read (like Go's context best practice),
|
|
||||||
# e.g. def register_sandbox_ctx(tenant_id: str, p: Callable[[], SandboxContext]) and
|
|
||||||
# def read_sandbox_ctx(tenant_id: str) -> SandboxContext.
|
|
||||||
_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 for a given (name, tenant_id)."""
|
"""Raised when a tenant-scoped context provider is missing."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def register_context_capturer(capturer: Callable[[], IExecutionContext]) -> None:
|
def register_context_capturer(capturer: Callable[[], IExecutionContext]) -> None:
|
||||||
"""Register a single enterable execution context capturer (e.g., Flask)."""
|
"""Register an enterable execution context capturer."""
|
||||||
global _capturer
|
global _capturer
|
||||||
_capturer = capturer
|
_capturer = capturer
|
||||||
|
|
||||||
|
|
||||||
def register_context(name: str, tenant_id: str, provider: Callable[[], BaseModel]) -> None:
|
def register_context(name: str, tenant_id: str, provider: Callable[[], BaseModel]) -> None:
|
||||||
"""Register a tenant-specific provider for a named context.
|
"""Register a tenant-specific provider for a named context."""
|
||||||
|
|
||||||
Tip: use a namespaced "name" (e.g., "workflow.sandbox") to avoid key collisions.
|
|
||||||
Consider adding a typed wrapper for this registration in your feature module.
|
|
||||||
"""
|
|
||||||
_tenant_context_providers[(name, tenant_id)] = provider
|
_tenant_context_providers[(name, tenant_id)] = provider
|
||||||
|
|
||||||
|
|
||||||
def read_context(name: str, *, tenant_id: str) -> BaseModel:
|
def read_context(name: str, *, tenant_id: str) -> BaseModel:
|
||||||
"""
|
"""Read a context value for a specific tenant."""
|
||||||
Read a context value for a specific tenant.
|
provider = _tenant_context_providers.get((name, tenant_id))
|
||||||
|
if provider is None:
|
||||||
Raises KeyError if the provider for (name, tenant_id) is not registered.
|
|
||||||
"""
|
|
||||||
prov = _tenant_context_providers.get((name, tenant_id))
|
|
||||||
if prov is None:
|
|
||||||
raise ContextProviderNotFoundError(f"Context provider '{name}' not registered for tenant '{tenant_id}'")
|
raise ContextProviderNotFoundError(f"Context provider '{name}' not registered for tenant '{tenant_id}'")
|
||||||
return prov()
|
return provider()
|
||||||
|
|
||||||
|
|
||||||
def capture_current_context() -> IExecutionContext:
|
def capture_current_context() -> IExecutionContext:
|
||||||
"""
|
"""
|
||||||
Capture current execution context from the calling environment.
|
Capture current execution context from the calling environment.
|
||||||
|
|
||||||
If a capturer is registered (e.g., Flask), use it. Otherwise, return a minimal
|
If no framework adapter is registered, return a minimal context that only
|
||||||
context with NullAppContext + copy of current contextvars.
|
restores `contextvars`.
|
||||||
"""
|
"""
|
||||||
if _capturer is None:
|
if _capturer is None:
|
||||||
return ExecutionContext(
|
return ExecutionContext(
|
||||||
@@ -278,7 +230,22 @@ def capture_current_context() -> IExecutionContext:
|
|||||||
|
|
||||||
|
|
||||||
def reset_context_provider() -> None:
|
def reset_context_provider() -> None:
|
||||||
"""Reset the capturer and all tenant-scoped context providers (primarily for tests)."""
|
"""Reset the capturer and tenant-scoped providers."""
|
||||||
global _capturer
|
global _capturer
|
||||||
_capturer = None
|
_capturer = None
|
||||||
_tenant_context_providers.clear()
|
_tenant_context_providers.clear()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AppContext",
|
||||||
|
"ContextProviderNotFoundError",
|
||||||
|
"ExecutionContext",
|
||||||
|
"ExecutionContextBuilder",
|
||||||
|
"IExecutionContext",
|
||||||
|
"NullAppContext",
|
||||||
|
"capture_current_context",
|
||||||
|
"read_context",
|
||||||
|
"register_context",
|
||||||
|
"register_context_capturer",
|
||||||
|
"reset_context_provider",
|
||||||
|
]
|
||||||
@@ -10,11 +10,7 @@ from typing import Any, final
|
|||||||
|
|
||||||
from flask import Flask, current_app, g
|
from flask import Flask, current_app, g
|
||||||
|
|
||||||
from dify_graph.context import register_context_capturer
|
from context.execution_context import AppContext, IExecutionContext, register_context_capturer
|
||||||
from dify_graph.context.execution_context import (
|
|
||||||
AppContext,
|
|
||||||
IExecutionContext,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@final
|
@final
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from contexts.wrapper import RecyclableContextVar
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from core.datasource.__base.datasource_provider import DatasourcePluginProviderController
|
from core.datasource.__base.datasource_provider import DatasourcePluginProviderController
|
||||||
from core.plugin.entities.plugin_daemon import PluginModelProviderEntity
|
|
||||||
from core.tools.plugin_tool.provider import PluginToolProviderController
|
from core.tools.plugin_tool.provider import PluginToolProviderController
|
||||||
from core.trigger.provider import PluginTriggerProviderController
|
from core.trigger.provider import PluginTriggerProviderController
|
||||||
|
|
||||||
@@ -20,14 +19,6 @@ plugin_tool_providers: RecyclableContextVar[dict[str, "PluginToolProviderControl
|
|||||||
|
|
||||||
plugin_tool_providers_lock: RecyclableContextVar[Lock] = RecyclableContextVar(ContextVar("plugin_tool_providers_lock"))
|
plugin_tool_providers_lock: RecyclableContextVar[Lock] = RecyclableContextVar(ContextVar("plugin_tool_providers_lock"))
|
||||||
|
|
||||||
plugin_model_providers: RecyclableContextVar[list["PluginModelProviderEntity"] | None] = RecyclableContextVar(
|
|
||||||
ContextVar("plugin_model_providers")
|
|
||||||
)
|
|
||||||
|
|
||||||
plugin_model_providers_lock: RecyclableContextVar[Lock] = RecyclableContextVar(
|
|
||||||
ContextVar("plugin_model_providers_lock")
|
|
||||||
)
|
|
||||||
|
|
||||||
datasource_plugin_providers: RecyclableContextVar[dict[str, "DatasourcePluginProviderController"]] = (
|
datasource_plugin_providers: RecyclableContextVar[dict[str, "DatasourcePluginProviderController"]] = (
|
||||||
RecyclableContextVar(ContextVar("datasource_plugin_providers"))
|
RecyclableContextVar(ContextVar("datasource_plugin_providers"))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 dify_graph.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):
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ from urllib.parse import quote
|
|||||||
|
|
||||||
from flask import Response
|
from flask import Response
|
||||||
|
|
||||||
HTML_MIME_TYPES = frozenset({"text/html", "application/xhtml+xml"})
|
HTML_MIME_TYPES: frozenset[str] = frozenset(("text/html", "application/xhtml+xml"))
|
||||||
HTML_EXTENSIONS = frozenset({"html", "htm"})
|
HTML_EXTENSIONS: frozenset[str] = frozenset(("html", "htm"))
|
||||||
|
|
||||||
|
|
||||||
def _normalize_mime_type(mime_type: str | None) -> str:
|
def _normalize_mime_type(mime_type: str | None) -> str:
|
||||||
|
|||||||
@@ -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 Session
|
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,25 +20,35 @@ 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):
|
||||||
with Session(db.engine) as session:
|
with sessionmaker(db.engine).begin() as session:
|
||||||
resource = session.execute(
|
resource = session.execute(
|
||||||
select(resource_model).filter_by(id=resource_id, tenant_id=tenant_id)
|
select(resource_model).filter_by(id=resource_id, tenant_id=tenant_id)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
@@ -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,11 +1,12 @@
|
|||||||
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 pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field, field_validator
|
from graphon.enums import WorkflowExecutionStatus
|
||||||
|
from pydantic import AliasChoices, BaseModel, Field, computed_field, field_validator
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from werkzeug.exceptions import BadRequest
|
from werkzeug.exceptions import BadRequest
|
||||||
@@ -24,27 +25,26 @@ 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 dify_graph.enums import WorkflowExecutionStatus
|
|
||||||
from dify_graph.file import helpers as file_helpers
|
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
|
from fields.base import ResponseModel
|
||||||
|
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,
|
||||||
@@ -152,17 +152,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 +161,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 +283,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 +333,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 +381,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 +623,7 @@ class AppCopyApi(Resource):
|
|||||||
|
|
||||||
args = CopyAppPayload.model_validate(console_ns.payload or {})
|
args = CopyAppPayload.model_validate(console_ns.payload or {})
|
||||||
|
|
||||||
with Session(db.engine) 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 +636,12 @@ 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()
|
session.commit()
|
||||||
|
|
||||||
# Inherit web app permission from original app
|
# Inherit web app permission from original app
|
||||||
|
|||||||
@@ -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 Session
|
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 Session(db.engine) 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,37 +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,
|
||||||
)
|
)
|
||||||
session.commit()
|
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 Session(db.engine) 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)
|
||||||
session.commit()
|
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:
|
||||||
@@ -127,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 Session(db.engine) 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,6 +2,7 @@ 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
|
||||||
|
|
||||||
@@ -22,7 +23,6 @@ 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 dify_graph.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,6 +3,7 @@ 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
|
||||||
|
|
||||||
@@ -26,7 +27,6 @@ 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 dify_graph.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
|
||||||
|
|||||||
@@ -1,44 +1,86 @@
|
|||||||
from flask import request
|
from __future__ import annotations
|
||||||
from flask_restx import Resource, fields, marshal_with
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import request
|
||||||
|
from flask_restx import Resource
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
from sqlalchemy import select
|
||||||
|
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().value)
|
||||||
|
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
|
||||||
|
|
||||||
@@ -69,20 +114,25 @@ class ConversationVariablesApi(Resource):
|
|||||||
page_size = 100
|
page_size = 100
|
||||||
stmt = stmt.limit(page_size).offset((page - 1) * page_size)
|
stmt = stmt.limit(page_size).offset((page - 1) * page_size)
|
||||||
|
|
||||||
with Session(db.engine) 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,6 +1,7 @@
|
|||||||
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
|
||||||
@@ -18,7 +19,6 @@ from core.helper.code_executor.javascript.javascript_code_provider import Javasc
|
|||||||
from core.helper.code_executor.python3.python3_code_provider import Python3CodeProvider
|
from core.helper.code_executor.python3.python3_code_provider import Python3CodeProvider
|
||||||
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 dify_graph.model_runtime.errors.invoke import InvokeError
|
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
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
|
||||||
|
|||||||
@@ -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
|
def _to_timestamp(value: datetime | int | None) -> int | None:
|
||||||
app_server_model = console_ns.model("AppServer", app_server_fields)
|
if isinstance(value, datetime):
|
||||||
|
return int(value.timestamp())
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
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):
|
class AppMCPServerResponse(ResponseModel):
|
||||||
console_ns.schema_model(model.__name__, model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0))
|
id: str
|
||||||
|
name: str
|
||||||
|
server_code: str
|
||||||
|
description: str
|
||||||
|
status: str
|
||||||
|
parameters: dict[str, Any] | list[Any] | str
|
||||||
|
created_at: int | None = None
|
||||||
|
updated_at: int | None = None
|
||||||
|
|
||||||
|
@field_validator("parameters", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _parse_json_string(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,27 @@ 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, "Server configuration", 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(200, "Server created", 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 +111,19 @@ 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")
|
||||||
|
|
||||||
@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, "Server updated", 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 +146,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 +154,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, "Server refreshed", 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 +172,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,12 +1,15 @@
|
|||||||
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 (
|
||||||
@@ -23,11 +26,21 @@ 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 dify_graph.model_runtime.errors.invoke import InvokeError
|
|
||||||
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 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")
|
||||||
@@ -88,6 +90,7 @@ class ModelConfigResource(Resource):
|
|||||||
tenant_id=current_tenant_id,
|
tenant_id=current_tenant_id,
|
||||||
app_id=app_model.id,
|
app_id=app_model.id,
|
||||||
agent_tool=agent_tool_entity,
|
agent_tool=agent_tool_entity,
|
||||||
|
user_id=current_user.id,
|
||||||
)
|
)
|
||||||
manager = ToolParameterConfigurationManager(
|
manager = ToolParameterConfigurationManager(
|
||||||
tenant_id=current_tenant_id,
|
tenant_id=current_tenant_id,
|
||||||
@@ -127,6 +130,7 @@ class ModelConfigResource(Resource):
|
|||||||
tenant_id=current_tenant_id,
|
tenant_id=current_tenant_id,
|
||||||
app_id=app_model.id,
|
app_id=app_model.id,
|
||||||
agent_tool=agent_tool_entity,
|
agent_tool=agent_tool_entity,
|
||||||
|
user_id=current_user.id,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -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,12 +4,17 @@ 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 pydantic import BaseModel, Field, field_validator
|
from graphon.enums import NodeType
|
||||||
from sqlalchemy.orm import Session
|
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 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
|
||||||
@@ -20,6 +25,7 @@ from core.app.app_config.features.file_upload.manager import FileUploadConfigMan
|
|||||||
from core.app.apps.base_app_queue_manager import AppQueueManager
|
from core.app.apps.base_app_queue_manager import AppQueueManager
|
||||||
from core.app.apps.workflow.app_generator import SKIP_PREPARE_USER_INPUTS_KEY
|
from core.app.apps.workflow.app_generator import SKIP_PREPARE_USER_INPUTS_KEY
|
||||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||||
|
from core.app.file_access import DatabaseFileAccessController
|
||||||
from core.helper.trace_id_helper import get_external_trace_id
|
from core.helper.trace_id_helper import get_external_trace_id
|
||||||
from core.plugin.impl.exc import PluginInvokeError
|
from core.plugin.impl.exc import PluginInvokeError
|
||||||
from core.trigger.constants import TRIGGER_SCHEDULE_NODE_TYPE
|
from core.trigger.constants import TRIGGER_SCHEDULE_NODE_TYPE
|
||||||
@@ -29,10 +35,6 @@ from core.trigger.debug.event_selectors import (
|
|||||||
create_event_poller,
|
create_event_poller,
|
||||||
select_trigger_debug_events,
|
select_trigger_debug_events,
|
||||||
)
|
)
|
||||||
from dify_graph.enums import NodeType
|
|
||||||
from dify_graph.file.models import File
|
|
||||||
from dify_graph.graph_engine.manager import GraphEngineManager
|
|
||||||
from dify_graph.model_runtime.utils.encoders import jsonable_encoder
|
|
||||||
from extensions.ext_database import db
|
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
|
||||||
@@ -51,6 +53,7 @@ from services.errors.llm import InvokeRateLimitError
|
|||||||
from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService
|
from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
_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"
|
||||||
@@ -140,10 +143,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
|
||||||
@@ -151,18 +150,6 @@ class ConvertToWorkflowPayload(BaseModel):
|
|||||||
icon_background: str | None = None
|
icon_background: 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 WorkflowUpdatePayload(BaseModel):
|
|
||||||
marked_name: str | None = Field(default=None, max_length=20)
|
|
||||||
marked_comment: str | None = Field(default=None, max_length=100)
|
|
||||||
|
|
||||||
|
|
||||||
class DraftWorkflowTriggerRunPayload(BaseModel):
|
class DraftWorkflowTriggerRunPayload(BaseModel):
|
||||||
node_id: str
|
node_id: str
|
||||||
|
|
||||||
@@ -204,6 +191,7 @@ def _parse_file(workflow: Workflow, files: list[dict] | None = None) -> Sequence
|
|||||||
mappings=files,
|
mappings=files,
|
||||||
tenant_id=workflow.tenant_id,
|
tenant_id=workflow.tenant_id,
|
||||||
config=file_extra_config,
|
config=file_extra_config,
|
||||||
|
access_controller=_file_access_controller,
|
||||||
)
|
)
|
||||||
return file_objs
|
return file_objs
|
||||||
|
|
||||||
@@ -265,22 +253,18 @@ class DraftWorkflowApi(Resource):
|
|||||||
|
|
||||||
content_type = request.headers.get("Content-Type", "")
|
content_type = request.headers.get("Content-Type", "")
|
||||||
|
|
||||||
payload_data: dict[str, Any] | None = None
|
|
||||||
if "application/json" in content_type:
|
if "application/json" in content_type:
|
||||||
payload_data = request.get_json(silent=True)
|
payload_data = request.get_json(silent=True)
|
||||||
if not isinstance(payload_data, dict):
|
if not isinstance(payload_data, dict):
|
||||||
return {"message": "Invalid JSON data"}, 400
|
return {"message": "Invalid JSON data"}, 400
|
||||||
|
args_model = SyncDraftWorkflowPayload.model_validate(payload_data)
|
||||||
elif "text/plain" in content_type:
|
elif "text/plain" in content_type:
|
||||||
try:
|
try:
|
||||||
payload_data = json.loads(request.data.decode("utf-8"))
|
args_model = SyncDraftWorkflowPayload.model_validate_json(request.data)
|
||||||
except json.JSONDecodeError:
|
except (ValueError, ValidationError):
|
||||||
return {"message": "Invalid JSON data"}, 400
|
|
||||||
if not isinstance(payload_data, dict):
|
|
||||||
return {"message": "Invalid JSON data"}, 400
|
return {"message": "Invalid JSON data"}, 400
|
||||||
else:
|
else:
|
||||||
abort(415)
|
abort(415)
|
||||||
|
|
||||||
args_model = SyncDraftWorkflowPayload.model_validate(payload_data)
|
|
||||||
args = args_model.model_dump()
|
args = args_model.model_dump()
|
||||||
workflow_service = WorkflowService()
|
workflow_service = WorkflowService()
|
||||||
|
|
||||||
@@ -837,7 +821,7 @@ class PublishedWorkflowApi(Resource):
|
|||||||
args = PublishWorkflowPayload.model_validate(console_ns.payload or {})
|
args = PublishWorkflowPayload.model_validate(console_ns.payload or {})
|
||||||
|
|
||||||
workflow_service = WorkflowService()
|
workflow_service = WorkflowService()
|
||||||
with Session(db.engine) as session:
|
with sessionmaker(db.engine).begin() as session:
|
||||||
workflow = workflow_service.publish_workflow(
|
workflow = workflow_service.publish_workflow(
|
||||||
session=session,
|
session=session,
|
||||||
app_model=app_model,
|
app_model=app_model,
|
||||||
@@ -855,8 +839,6 @@ class PublishedWorkflowApi(Resource):
|
|||||||
|
|
||||||
workflow_created_at = TimestampField().format(workflow.created_at)
|
workflow_created_at = TimestampField().format(workflow.created_at)
|
||||||
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"result": "success",
|
"result": "success",
|
||||||
"created_at": workflow_created_at,
|
"created_at": workflow_created_at,
|
||||||
@@ -960,7 +942,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):
|
||||||
"""
|
"""
|
||||||
@@ -979,7 +960,7 @@ class PublishedAllWorkflowApi(Resource):
|
|||||||
raise Forbidden()
|
raise Forbidden()
|
||||||
|
|
||||||
workflow_service = WorkflowService()
|
workflow_service = WorkflowService()
|
||||||
with Session(db.engine) as session:
|
with sessionmaker(db.engine).begin() as session:
|
||||||
workflows, has_more = workflow_service.get_all_published_workflow(
|
workflows, has_more = workflow_service.get_all_published_workflow(
|
||||||
session=session,
|
session=session,
|
||||||
app_model=app_model,
|
app_model=app_model,
|
||||||
@@ -988,9 +969,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,
|
||||||
@@ -1069,7 +1051,7 @@ class WorkflowByIdApi(Resource):
|
|||||||
workflow_service = WorkflowService()
|
workflow_service = WorkflowService()
|
||||||
|
|
||||||
# Create a session and manage the transaction
|
# Create a session and manage the transaction
|
||||||
with Session(db.engine, expire_on_commit=False) as session:
|
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
|
||||||
workflow = workflow_service.update_workflow(
|
workflow = workflow_service.update_workflow(
|
||||||
session=session,
|
session=session,
|
||||||
workflow_id=workflow_id,
|
workflow_id=workflow_id,
|
||||||
@@ -1081,9 +1063,6 @@ class WorkflowByIdApi(Resource):
|
|||||||
if not workflow:
|
if not workflow:
|
||||||
raise NotFound("Workflow not found")
|
raise NotFound("Workflow not found")
|
||||||
|
|
||||||
# Commit the transaction in the controller
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
return workflow
|
return workflow
|
||||||
|
|
||||||
@setup_required
|
@setup_required
|
||||||
@@ -1098,13 +1077,11 @@ class WorkflowByIdApi(Resource):
|
|||||||
workflow_service = WorkflowService()
|
workflow_service = WorkflowService()
|
||||||
|
|
||||||
# Create a session and manage the transaction
|
# Create a session and manage the transaction
|
||||||
with Session(db.engine) as session:
|
with sessionmaker(db.engine).begin() as session:
|
||||||
try:
|
try:
|
||||||
workflow_service.delete_workflow(
|
workflow_service.delete_workflow(
|
||||||
session=session, workflow_id=workflow_id, tenant_id=app_model.tenant_id
|
session=session, workflow_id=workflow_id, tenant_id=app_model.tenant_id
|
||||||
)
|
)
|
||||||
# Commit the transaction in the controller
|
|
||||||
session.commit()
|
|
||||||
except WorkflowInUseError as e:
|
except WorkflowInUseError as e:
|
||||||
abort(400, description=str(e))
|
abort(400, description=str(e))
|
||||||
except DraftWorkflowDeletionError as e:
|
except DraftWorkflowDeletionError as e:
|
||||||
|
|||||||
@@ -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 Session
|
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 dify_graph.enums import WorkflowExecutionStatus
|
|
||||||
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 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 Session(db.engine) 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 Session(db.engine) 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")
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
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 Session
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from controllers.console import console_ns
|
from controllers.console import console_ns
|
||||||
from controllers.console.app.error import (
|
from controllers.console.app.error import (
|
||||||
@@ -15,11 +19,8 @@ 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, edit_permission_required, setup_required
|
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
|
||||||
from controllers.web.error import InvalidArgumentError, NotFoundError
|
from controllers.web.error import InvalidArgumentError, NotFoundError
|
||||||
from dify_graph.constants import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
|
from core.app.file_access import DatabaseFileAccessController
|
||||||
from dify_graph.file import helpers as file_helpers
|
from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
|
||||||
from dify_graph.variables.segment_group import SegmentGroup
|
|
||||||
from dify_graph.variables.segments import ArrayFileSegment, FileSegment, Segment
|
|
||||||
from dify_graph.variables.types import SegmentType
|
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
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
|
||||||
@@ -30,6 +31,7 @@ from services.workflow_draft_variable_service import WorkflowDraftVariableList,
|
|||||||
from services.workflow_service import WorkflowService
|
from services.workflow_service import WorkflowService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
_file_access_controller = DatabaseFileAccessController()
|
||||||
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
|
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
|
||||||
|
|
||||||
|
|
||||||
@@ -84,7 +86,14 @@ def _serialize_variable_type(workflow_draft_var: WorkflowDraftVariable) -> str:
|
|||||||
return value_type.exposed_type().value
|
return value_type.exposed_type().value
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
@@ -92,12 +101,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": variable_file.value_type.exposed_type().value,
|
||||||
"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(
|
||||||
@@ -190,11 +200,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:
|
||||||
@@ -211,7 +218,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
|
||||||
@@ -242,7 +249,7 @@ class WorkflowVariableCollectionApi(Resource):
|
|||||||
raise DraftWorkflowNotExist()
|
raise DraftWorkflowNotExist()
|
||||||
|
|
||||||
# fetch draft workflow by app_model
|
# fetch draft workflow by app_model
|
||||||
with Session(bind=db.engine, expire_on_commit=False) as session:
|
with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session:
|
||||||
draft_var_srv = WorkflowDraftVariableService(
|
draft_var_srv = WorkflowDraftVariableService(
|
||||||
session=session,
|
session=session,
|
||||||
)
|
)
|
||||||
@@ -268,7 +275,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,
|
||||||
@@ -283,7 +290,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")
|
||||||
@@ -296,7 +302,7 @@ class NodeVariableCollectionApi(Resource):
|
|||||||
@marshal_with(workflow_draft_variable_list_model)
|
@marshal_with(workflow_draft_variable_list_model)
|
||||||
def get(self, app_model: App, node_id: str):
|
def get(self, app_model: App, node_id: str):
|
||||||
validate_node_id(node_id)
|
validate_node_id(node_id)
|
||||||
with Session(bind=db.engine, expire_on_commit=False) as session:
|
with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session:
|
||||||
draft_var_srv = WorkflowDraftVariableService(
|
draft_var_srv = WorkflowDraftVariableService(
|
||||||
session=session,
|
session=session,
|
||||||
)
|
)
|
||||||
@@ -386,16 +392,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(mapping=raw_value, tenant_id=app_model.tenant_id)
|
raise InvalidArgumentError(description=f"expected dict for file, got {type(raw_value)}")
|
||||||
elif variable.value_type == SegmentType.ARRAY_FILE:
|
raw_value = build_from_mapping(
|
||||||
if not isinstance(raw_value, list):
|
mapping=raw_value,
|
||||||
raise InvalidArgumentError(description=f"expected list for files, got {type(raw_value)}")
|
tenant_id=app_model.tenant_id,
|
||||||
if len(raw_value) > 0 and not isinstance(raw_value[0], dict):
|
access_controller=_file_access_controller,
|
||||||
raise InvalidArgumentError(description=f"expected dict for files[0], got {type(raw_value)}")
|
)
|
||||||
raw_value = build_from_mappings(mappings=raw_value, tenant_id=app_model.tenant_id)
|
case SegmentType.ARRAY_FILE:
|
||||||
|
if not isinstance(raw_value, list):
|
||||||
|
raise InvalidArgumentError(description=f"expected list for files, got {type(raw_value)}")
|
||||||
|
if len(raw_value) > 0 and not isinstance(raw_value[0], dict):
|
||||||
|
raise InvalidArgumentError(description=f"expected dict for files[0], got {type(raw_value)}")
|
||||||
|
raw_value = build_from_mappings(
|
||||||
|
mappings=raw_value,
|
||||||
|
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()
|
||||||
@@ -455,7 +472,7 @@ class VariableResetApi(Resource):
|
|||||||
|
|
||||||
|
|
||||||
def _get_variable_list(app_model: App, node_id) -> WorkflowDraftVariableList:
|
def _get_variable_list(app_model: App, node_id) -> WorkflowDraftVariableList:
|
||||||
with Session(bind=db.engine, expire_on_commit=False) as session:
|
with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session:
|
||||||
draft_var_srv = WorkflowDraftVariableService(
|
draft_var_srv = WorkflowDraftVariableService(
|
||||||
session=session,
|
session=session,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Literal, cast
|
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
|
||||||
@@ -12,8 +14,7 @@ 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 controllers.web.error import NotFoundError
|
from controllers.web.error import NotFoundError
|
||||||
from dify_graph.entities.pause_reason import HumanInputRequired
|
from core.workflow.human_input_forms import load_form_tokens_by_form_id as _load_form_tokens_by_form_id
|
||||||
from dify_graph.enums import WorkflowExecutionStatus
|
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from fields.end_user_fields import simple_end_user_fields
|
from fields.end_user_fields import simple_end_user_fields
|
||||||
from fields.member_fields import simple_account_fields
|
from fields.member_fields import simple_account_fields
|
||||||
@@ -35,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:
|
||||||
@@ -172,6 +173,23 @@ console_ns.schema_model(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HumanInputPauseTypeResponse(TypedDict):
|
||||||
|
type: Literal["human_input"]
|
||||||
|
form_id: str
|
||||||
|
backstage_input_url: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class PausedNodeResponse(TypedDict):
|
||||||
|
node_id: str
|
||||||
|
node_title: str
|
||||||
|
pause_type: HumanInputPauseTypeResponse
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowPauseDetailsResponse(TypedDict):
|
||||||
|
paused_at: str | None
|
||||||
|
paused_nodes: list[PausedNodeResponse]
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/apps/<uuid:app_id>/advanced-chat/workflow-runs")
|
@console_ns.route("/apps/<uuid:app_id>/advanced-chat/workflow-runs")
|
||||||
class AdvancedChatAppWorkflowRunListApi(Resource):
|
class AdvancedChatAppWorkflowRunListApi(Resource):
|
||||||
@console_ns.doc("get_advanced_chat_workflow_runs")
|
@console_ns.doc("get_advanced_chat_workflow_runs")
|
||||||
@@ -196,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 = (
|
||||||
@@ -338,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 = (
|
||||||
@@ -489,18 +515,22 @@ class ConsoleWorkflowPauseDetailsApi(Resource):
|
|||||||
# Check if workflow is suspended
|
# Check if workflow is suspended
|
||||||
is_paused = workflow_run.status == WorkflowExecutionStatus.PAUSED
|
is_paused = workflow_run.status == WorkflowExecutionStatus.PAUSED
|
||||||
if not is_paused:
|
if not is_paused:
|
||||||
return {
|
empty_response: WorkflowPauseDetailsResponse = {
|
||||||
"paused_at": None,
|
"paused_at": None,
|
||||||
"paused_nodes": [],
|
"paused_nodes": [],
|
||||||
}, 200
|
}
|
||||||
|
return empty_response, 200
|
||||||
|
|
||||||
pause_entity = workflow_run_repo.get_workflow_pause(workflow_run_id)
|
pause_entity = workflow_run_repo.get_workflow_pause(workflow_run_id)
|
||||||
pause_reasons = pause_entity.get_pause_reasons() if pause_entity else []
|
pause_reasons = pause_entity.get_pause_reasons() if pause_entity else []
|
||||||
|
form_tokens_by_form_id = _load_form_tokens_by_form_id(
|
||||||
|
[reason.form_id for reason in pause_reasons if isinstance(reason, HumanInputRequired)]
|
||||||
|
)
|
||||||
|
|
||||||
# Build response
|
# Build response
|
||||||
paused_at = pause_entity.paused_at if pause_entity else None
|
paused_at = pause_entity.paused_at if pause_entity else None
|
||||||
paused_nodes = []
|
paused_nodes: list[PausedNodeResponse] = []
|
||||||
response = {
|
response: WorkflowPauseDetailsResponse = {
|
||||||
"paused_at": paused_at.isoformat() + "Z" if paused_at else None,
|
"paused_at": paused_at.isoformat() + "Z" if paused_at else None,
|
||||||
"paused_nodes": paused_nodes,
|
"paused_nodes": paused_nodes,
|
||||||
}
|
}
|
||||||
@@ -514,7 +544,9 @@ class ConsoleWorkflowPauseDetailsApi(Resource):
|
|||||||
"pause_type": {
|
"pause_type": {
|
||||||
"type": "human_input",
|
"type": "human_input",
|
||||||
"form_id": reason.form_id,
|
"form_id": reason.form_id,
|
||||||
"backstage_input_url": _build_backstage_input_url(reason.form_token),
|
"backstage_input_url": _build_backstage_input_url(
|
||||||
|
form_tokens_by_form_id.get(reason.form_id)
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 Session
|
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 Session(db.engine) 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 Session(db.engine) 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)
|
||||||
@@ -137,7 +173,7 @@ class AppTriggerEnableApi(Resource):
|
|||||||
assert current_user.current_tenant_id is not None
|
assert current_user.current_tenant_id is not None
|
||||||
|
|
||||||
trigger_id = args.trigger_id
|
trigger_id = args.trigger_id
|
||||||
with Session(db.engine) as session:
|
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
|
||||||
# Find the trigger using select
|
# Find the trigger using select
|
||||||
trigger = session.execute(
|
trigger = session.execute(
|
||||||
select(AppTrigger).where(
|
select(AppTrigger).where(
|
||||||
@@ -153,9 +189,6 @@ class AppTriggerEnableApi(Resource):
|
|||||||
# Update status based on enable_trigger boolean
|
# Update status based on enable_trigger boolean
|
||||||
trigger.status = AppTriggerStatus.ENABLED if args.enable_trigger else AppTriggerStatus.DISABLED
|
trigger.status = AppTriggerStatus.ENABLED if args.enable_trigger else AppTriggerStatus.DISABLED
|
||||||
|
|
||||||
session.commit()
|
|
||||||
session.refresh(trigger)
|
|
||||||
|
|
||||||
# Add computed icon field
|
# Add computed icon field
|
||||||
url_prefix = dify_config.CONSOLE_API_URL + "/console/api/workspaces/current/tool-provider/builtin/"
|
url_prefix = dify_config.CONSOLE_API_URL + "/console/api/workspaces/current/tool-provider/builtin/"
|
||||||
if trigger.trigger_type == "trigger-plugin":
|
if trigger.trigger_type == "trigger-plugin":
|
||||||
@@ -163,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
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import Concatenate, ParamSpec, TypeVar
|
from typing import Concatenate
|
||||||
|
|
||||||
from flask import jsonify, request
|
from flask import jsonify, request
|
||||||
|
from flask.typing import ResponseReturnValue
|
||||||
from flask_restx import Resource
|
from flask_restx import Resource
|
||||||
|
from graphon.model_runtime.utils.encoders import jsonable_encoder
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from werkzeug.exceptions import BadRequest, NotFound
|
from werkzeug.exceptions import BadRequest, NotFound
|
||||||
|
|
||||||
from controllers.console.wraps import account_initialization_required, setup_required
|
from controllers.console.wraps import account_initialization_required, setup_required
|
||||||
from dify_graph.model_runtime.utils.encoders import jsonable_encoder
|
|
||||||
from libs.login import current_account_with_tenant, login_required
|
from libs.login import current_account_with_tenant, login_required
|
||||||
from models import Account
|
from models import Account
|
||||||
from models.model import OAuthProviderApp
|
from models.model import OAuthProviderApp
|
||||||
@@ -16,10 +17,6 @@ from services.oauth_server import OAUTH_ACCESS_TOKEN_EXPIRES_IN, OAuthGrantType,
|
|||||||
|
|
||||||
from .. import console_ns
|
from .. import console_ns
|
||||||
|
|
||||||
P = ParamSpec("P")
|
|
||||||
R = TypeVar("R")
|
|
||||||
T = TypeVar("T")
|
|
||||||
|
|
||||||
|
|
||||||
class OAuthClientPayload(BaseModel):
|
class OAuthClientPayload(BaseModel):
|
||||||
client_id: str
|
client_id: str
|
||||||
@@ -39,9 +36,11 @@ class OAuthTokenRequest(BaseModel):
|
|||||||
refresh_token: str | None = None
|
refresh_token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def oauth_server_client_id_required(view: Callable[Concatenate[T, OAuthProviderApp, P], R]):
|
def oauth_server_client_id_required[T, **P, R](
|
||||||
|
view: Callable[Concatenate[T, OAuthProviderApp, P], R],
|
||||||
|
) -> Callable[Concatenate[T, P], R]:
|
||||||
@wraps(view)
|
@wraps(view)
|
||||||
def decorated(self: T, *args: P.args, **kwargs: P.kwargs):
|
def decorated(self: T, *args: P.args, **kwargs: P.kwargs) -> R:
|
||||||
json_data = request.get_json()
|
json_data = request.get_json()
|
||||||
if json_data is None:
|
if json_data is None:
|
||||||
raise BadRequest("client_id is required")
|
raise BadRequest("client_id is required")
|
||||||
@@ -58,9 +57,13 @@ def oauth_server_client_id_required(view: Callable[Concatenate[T, OAuthProviderA
|
|||||||
return decorated
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
def oauth_server_access_token_required(view: Callable[Concatenate[T, OAuthProviderApp, Account, P], R]):
|
def oauth_server_access_token_required[T, **P, R](
|
||||||
|
view: Callable[Concatenate[T, OAuthProviderApp, Account, P], R],
|
||||||
|
) -> Callable[Concatenate[T, OAuthProviderApp, P], R | ResponseReturnValue]:
|
||||||
@wraps(view)
|
@wraps(view)
|
||||||
def decorated(self: T, oauth_provider_app: OAuthProviderApp, *args: P.args, **kwargs: P.kwargs):
|
def decorated(
|
||||||
|
self: T, oauth_provider_app: OAuthProviderApp, *args: P.args, **kwargs: P.kwargs
|
||||||
|
) -> R | ResponseReturnValue:
|
||||||
if not isinstance(oauth_provider_app, OAuthProviderApp):
|
if not isinstance(oauth_provider_app, OAuthProviderApp):
|
||||||
raise BadRequest("Invalid oauth_provider_app")
|
raise BadRequest("Invalid oauth_provider_app")
|
||||||
|
|
||||||
|
|||||||
@@ -2,18 +2,17 @@ import base64
|
|||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
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 pydantic import BaseModel, Field
|
||||||
from werkzeug.exceptions import BadRequest
|
from werkzeug.exceptions import BadRequest
|
||||||
|
|
||||||
|
from controllers.common.schema import register_schema_models
|
||||||
from controllers.console import console_ns
|
from controllers.console import console_ns
|
||||||
from controllers.console.wraps import account_initialization_required, only_edition_cloud, setup_required
|
from controllers.console.wraps import account_initialization_required, only_edition_cloud, setup_required
|
||||||
from enums.cloud_plan import CloudPlan
|
from enums.cloud_plan import CloudPlan
|
||||||
from libs.login import current_account_with_tenant, login_required
|
from libs.login import current_account_with_tenant, login_required
|
||||||
from services.billing_service import BillingService
|
from services.billing_service import BillingService
|
||||||
|
|
||||||
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
|
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionQuery(BaseModel):
|
class SubscriptionQuery(BaseModel):
|
||||||
plan: Literal[CloudPlan.PROFESSIONAL, CloudPlan.TEAM] = Field(..., description="Subscription plan")
|
plan: Literal[CloudPlan.PROFESSIONAL, CloudPlan.TEAM] = Field(..., description="Subscription plan")
|
||||||
@@ -24,8 +23,7 @@ class PartnerTenantsPayload(BaseModel):
|
|||||||
click_id: str = Field(..., description="Click Id from partner referral link")
|
click_id: str = Field(..., description="Click Id from partner referral link")
|
||||||
|
|
||||||
|
|
||||||
for model in (SubscriptionQuery, PartnerTenantsPayload):
|
register_schema_models(console_ns, SubscriptionQuery, PartnerTenantsPayload)
|
||||||
console_ns.schema_model(model.__name__, model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0))
|
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/billing/subscription")
|
@console_ns.route("/billing/subscription")
|
||||||
@@ -36,7 +34,7 @@ class Subscription(Resource):
|
|||||||
@only_edition_cloud
|
@only_edition_cloud
|
||||||
def get(self):
|
def get(self):
|
||||||
current_user, current_tenant_id = current_account_with_tenant()
|
current_user, current_tenant_id = current_account_with_tenant()
|
||||||
args = SubscriptionQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
args = SubscriptionQuery.model_validate(request.args.to_dict(flat=True))
|
||||||
BillingService.is_tenant_owner_or_admin(current_user)
|
BillingService.is_tenant_owner_or_admin(current_user)
|
||||||
return BillingService.get_subscription(args.plan, args.interval, current_user.email, current_tenant_id)
|
return BillingService.get_subscription(args.plan, args.interval, current_user.email, current_tenant_id)
|
||||||
|
|
||||||
@@ -58,12 +56,7 @@ class PartnerTenants(Resource):
|
|||||||
@console_ns.doc("sync_partner_tenants_bindings")
|
@console_ns.doc("sync_partner_tenants_bindings")
|
||||||
@console_ns.doc(description="Sync partner tenants bindings")
|
@console_ns.doc(description="Sync partner tenants bindings")
|
||||||
@console_ns.doc(params={"partner_key": "Partner key"})
|
@console_ns.doc(params={"partner_key": "Partner key"})
|
||||||
@console_ns.expect(
|
@console_ns.expect(console_ns.models[PartnerTenantsPayload.__name__])
|
||||||
console_ns.model(
|
|
||||||
"SyncPartnerTenantsBindingsRequest",
|
|
||||||
{"click_id": fields.String(required=True, description="Click Id from partner referral link")},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@console_ns.response(200, "Tenants synced to partner successfully")
|
@console_ns.response(200, "Tenants synced to partner successfully")
|
||||||
@console_ns.response(400, "Invalid partner information")
|
@console_ns.response(400, "Invalid partner information")
|
||||||
@setup_required
|
@setup_required
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class ComplianceApi(Resource):
|
|||||||
@only_edition_cloud
|
@only_edition_cloud
|
||||||
def get(self):
|
def get(self):
|
||||||
current_user, current_tenant_id = current_account_with_tenant()
|
current_user, current_tenant_id = current_account_with_tenant()
|
||||||
args = ComplianceDownloadQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
args = ComplianceDownloadQuery.model_validate(request.args.to_dict(flat=True))
|
||||||
|
|
||||||
ip_address = extract_remote_ip(request)
|
ip_address = extract_remote_ip(request)
|
||||||
device_info = request.headers.get("User-Agent", "Unknown device")
|
device_info = request.headers.get("User-Agent", "Unknown device")
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from flask import request
|
|||||||
from flask_restx import Resource, fields, marshal_with
|
from flask_restx import Resource, fields, marshal_with
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import sessionmaker
|
||||||
from werkzeug.exceptions import NotFound
|
from werkzeug.exceptions import NotFound
|
||||||
|
|
||||||
from controllers.common.schema import get_or_create_model, register_schema_model
|
from controllers.common.schema import get_or_create_model, register_schema_model
|
||||||
@@ -158,10 +158,13 @@ class DataSourceApi(Resource):
|
|||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
def patch(self, binding_id, action: Literal["enable", "disable"]):
|
def patch(self, binding_id, action: Literal["enable", "disable"]):
|
||||||
|
_, current_tenant_id = current_account_with_tenant()
|
||||||
binding_id = str(binding_id)
|
binding_id = str(binding_id)
|
||||||
with Session(db.engine) as session:
|
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
|
||||||
data_source_binding = session.execute(
|
data_source_binding = session.execute(
|
||||||
select(DataSourceOauthBinding).filter_by(id=binding_id)
|
select(DataSourceOauthBinding).where(
|
||||||
|
DataSourceOauthBinding.id == binding_id, DataSourceOauthBinding.tenant_id == current_tenant_id
|
||||||
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if data_source_binding is None:
|
if data_source_binding is None:
|
||||||
raise NotFound("Data source binding not found.")
|
raise NotFound("Data source binding not found.")
|
||||||
@@ -211,7 +214,7 @@ class DataSourceNotionListApi(Resource):
|
|||||||
if not credential:
|
if not credential:
|
||||||
raise NotFound("Credential not found.")
|
raise NotFound("Credential not found.")
|
||||||
exist_page_ids = []
|
exist_page_ids = []
|
||||||
with Session(db.engine) as session:
|
with sessionmaker(db.engine).begin() as session:
|
||||||
# import notion in the exist dataset
|
# import notion in the exist dataset
|
||||||
if query.dataset_id:
|
if query.dataset_id:
|
||||||
dataset = DatasetService.get_dataset(query.dataset_id)
|
dataset = DatasetService.get_dataset(query.dataset_id)
|
||||||
@@ -221,11 +224,11 @@ class DataSourceNotionListApi(Resource):
|
|||||||
raise ValueError("Dataset is not notion type.")
|
raise ValueError("Dataset is not notion type.")
|
||||||
|
|
||||||
documents = session.scalars(
|
documents = session.scalars(
|
||||||
select(Document).filter_by(
|
select(Document).where(
|
||||||
dataset_id=query.dataset_id,
|
Document.dataset_id == query.dataset_id,
|
||||||
tenant_id=current_tenant_id,
|
Document.tenant_id == current_tenant_id,
|
||||||
data_source_type="notion_import",
|
Document.data_source_type == "notion_import",
|
||||||
enabled=True,
|
Document.enabled.is_(True),
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
if documents:
|
if documents:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from typing import Any, cast
|
|||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask_restx import Resource, fields, marshal, marshal_with
|
from flask_restx import Resource, fields, marshal, marshal_with
|
||||||
|
from graphon.model_runtime.entities.model_entities import ModelType
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from werkzeug.exceptions import Forbidden, NotFound
|
from werkzeug.exceptions import Forbidden, NotFound
|
||||||
@@ -10,10 +11,7 @@ import services
|
|||||||
from configs import dify_config
|
from configs import dify_config
|
||||||
from controllers.common.schema import get_or_create_model, register_schema_models
|
from controllers.common.schema import get_or_create_model, register_schema_models
|
||||||
from controllers.console import console_ns
|
from controllers.console import console_ns
|
||||||
from controllers.console.apikey import (
|
from controllers.console.apikey import ApiKeyItem, ApiKeyList
|
||||||
api_key_item_model,
|
|
||||||
api_key_list_model,
|
|
||||||
)
|
|
||||||
from controllers.console.app.error import ProviderNotInitializeError
|
from controllers.console.app.error import ProviderNotInitializeError
|
||||||
from controllers.console.datasets.error import DatasetInUseError, DatasetNameDuplicateError, IndexingEstimateError
|
from controllers.console.datasets.error import DatasetInUseError, DatasetNameDuplicateError, IndexingEstimateError
|
||||||
from controllers.console.wraps import (
|
from controllers.console.wraps import (
|
||||||
@@ -25,13 +23,12 @@ from controllers.console.wraps import (
|
|||||||
)
|
)
|
||||||
from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError
|
from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError
|
||||||
from core.indexing_runner import IndexingRunner
|
from core.indexing_runner import IndexingRunner
|
||||||
from core.provider_manager import ProviderManager
|
from core.plugin.impl.model_runtime_factory import create_plugin_provider_manager
|
||||||
from core.rag.datasource.vdb.vector_type import VectorType
|
from core.rag.datasource.vdb.vector_type import VectorType
|
||||||
from core.rag.extractor.entity.datasource_type import DatasourceType
|
from core.rag.extractor.entity.datasource_type import DatasourceType
|
||||||
from core.rag.extractor.entity.extract_setting import ExtractSetting, NotionInfo, WebsiteInfo
|
from core.rag.extractor.entity.extract_setting import ExtractSetting, NotionInfo, WebsiteInfo
|
||||||
from core.rag.index_processor.constant.index_type import IndexTechniqueType
|
from core.rag.index_processor.constant.index_type import IndexTechniqueType
|
||||||
from core.rag.retrieval.retrieval_methods import RetrievalMethod
|
from core.rag.retrieval.retrieval_methods import RetrievalMethod
|
||||||
from dify_graph.model_runtime.entities.model_entities import ModelType
|
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from fields.app_fields import app_detail_kernel_fields, related_app_list
|
from fields.app_fields import app_detail_kernel_fields, related_app_list
|
||||||
from fields.dataset_fields import (
|
from fields.dataset_fields import (
|
||||||
@@ -332,7 +329,7 @@ class DatasetListApi(Resource):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# check embedding setting
|
# check embedding setting
|
||||||
provider_manager = ProviderManager()
|
provider_manager = create_plugin_provider_manager(tenant_id=current_tenant_id)
|
||||||
configurations = provider_manager.get_configurations(tenant_id=current_tenant_id)
|
configurations = provider_manager.get_configurations(tenant_id=current_tenant_id)
|
||||||
|
|
||||||
embedding_models = configurations.get_models(model_type=ModelType.TEXT_EMBEDDING, only_active=True)
|
embedding_models = configurations.get_models(model_type=ModelType.TEXT_EMBEDDING, only_active=True)
|
||||||
@@ -446,7 +443,7 @@ class DatasetApi(Resource):
|
|||||||
data.update({"partial_member_list": part_users_list})
|
data.update({"partial_member_list": part_users_list})
|
||||||
|
|
||||||
# check embedding setting
|
# check embedding setting
|
||||||
provider_manager = ProviderManager()
|
provider_manager = create_plugin_provider_manager(tenant_id=current_tenant_id)
|
||||||
configurations = provider_manager.get_configurations(tenant_id=current_tenant_id)
|
configurations = provider_manager.get_configurations(tenant_id=current_tenant_id)
|
||||||
|
|
||||||
embedding_models = configurations.get_models(model_type=ModelType.TEXT_EMBEDDING, only_active=True)
|
embedding_models = configurations.get_models(model_type=ModelType.TEXT_EMBEDDING, only_active=True)
|
||||||
@@ -785,23 +782,23 @@ class DatasetApiKeyApi(Resource):
|
|||||||
|
|
||||||
@console_ns.doc("get_dataset_api_keys")
|
@console_ns.doc("get_dataset_api_keys")
|
||||||
@console_ns.doc(description="Get dataset API keys")
|
@console_ns.doc(description="Get dataset API keys")
|
||||||
@console_ns.response(200, "API keys retrieved successfully", api_key_list_model)
|
@console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__])
|
||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@marshal_with(api_key_list_model)
|
|
||||||
def get(self):
|
def get(self):
|
||||||
_, current_tenant_id = current_account_with_tenant()
|
_, current_tenant_id = current_account_with_tenant()
|
||||||
keys = db.session.scalars(
|
keys = db.session.scalars(
|
||||||
select(ApiToken).where(ApiToken.type == self.resource_type, ApiToken.tenant_id == current_tenant_id)
|
select(ApiToken).where(ApiToken.type == self.resource_type, ApiToken.tenant_id == current_tenant_id)
|
||||||
).all()
|
).all()
|
||||||
return {"items": keys}
|
return ApiKeyList.model_validate({"data": keys}, from_attributes=True).model_dump(mode="json")
|
||||||
|
|
||||||
|
@console_ns.response(200, "API key created successfully", console_ns.models[ApiKeyItem.__name__])
|
||||||
|
@console_ns.response(400, "Maximum keys exceeded")
|
||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@is_admin_or_owner_required
|
@is_admin_or_owner_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@marshal_with(api_key_item_model)
|
|
||||||
def post(self):
|
def post(self):
|
||||||
_, current_tenant_id = current_account_with_tenant()
|
_, current_tenant_id = current_account_with_tenant()
|
||||||
|
|
||||||
@@ -828,7 +825,7 @@ class DatasetApiKeyApi(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, 200
|
return ApiKeyItem.model_validate(api_token, from_attributes=True).model_dump(mode="json"), 200
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/datasets/api-keys/<uuid:api_key_id>")
|
@console_ns.route("/datasets/api-keys/<uuid:api_key_id>")
|
||||||
|
|||||||
@@ -4,16 +4,18 @@ from argparse import ArgumentTypeError
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from contextlib import ExitStack
|
from contextlib import ExitStack
|
||||||
from typing import Any, Literal, cast
|
from typing import Any, Literal, cast
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from flask import request, send_file
|
from flask import request, send_file
|
||||||
from flask_restx import Resource, fields, marshal, marshal_with
|
from flask_restx import Resource, fields, marshal, marshal_with
|
||||||
|
from graphon.model_runtime.entities.model_entities import ModelType
|
||||||
|
from graphon.model_runtime.errors.invoke import InvokeAuthorizationError
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import asc, desc, func, select
|
from sqlalchemy import asc, desc, func, select
|
||||||
from werkzeug.exceptions import Forbidden, NotFound
|
from werkzeug.exceptions import Forbidden, NotFound
|
||||||
|
|
||||||
import services
|
import services
|
||||||
|
from controllers.common.controller_schemas import DocumentBatchDownloadZipPayload
|
||||||
from controllers.common.schema import get_or_create_model, register_schema_models
|
from controllers.common.schema import get_or_create_model, register_schema_models
|
||||||
from controllers.console import console_ns
|
from controllers.console import console_ns
|
||||||
from core.errors.error import (
|
from core.errors.error import (
|
||||||
@@ -28,8 +30,6 @@ from core.plugin.impl.exc import PluginDaemonClientSideError
|
|||||||
from core.rag.extractor.entity.datasource_type import DatasourceType
|
from core.rag.extractor.entity.datasource_type import DatasourceType
|
||||||
from core.rag.extractor.entity.extract_setting import ExtractSetting, NotionInfo, WebsiteInfo
|
from core.rag.extractor.entity.extract_setting import ExtractSetting, NotionInfo, WebsiteInfo
|
||||||
from core.rag.index_processor.constant.index_type import IndexTechniqueType
|
from core.rag.index_processor.constant.index_type import IndexTechniqueType
|
||||||
from dify_graph.model_runtime.entities.model_entities import ModelType
|
|
||||||
from dify_graph.model_runtime.errors.invoke import InvokeAuthorizationError
|
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from fields.dataset_fields import dataset_fields
|
from fields.dataset_fields import dataset_fields
|
||||||
from fields.document_fields import (
|
from fields.document_fields import (
|
||||||
@@ -71,9 +71,6 @@ from ..wraps import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# NOTE: Keep constants near the top of the module for discoverability.
|
|
||||||
DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS = 100
|
|
||||||
|
|
||||||
|
|
||||||
# Register models for flask_restx to avoid dict type issues in Swagger
|
# Register models for flask_restx to avoid dict type issues in Swagger
|
||||||
dataset_model = get_or_create_model("Dataset", dataset_fields)
|
dataset_model = get_or_create_model("Dataset", dataset_fields)
|
||||||
@@ -110,12 +107,6 @@ class GenerateSummaryPayload(BaseModel):
|
|||||||
document_list: list[str]
|
document_list: list[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 DocumentDatasetListParam(BaseModel):
|
class DocumentDatasetListParam(BaseModel):
|
||||||
page: int = Field(1, title="Page", description="Page number.")
|
page: int = Field(1, title="Page", description="Page number.")
|
||||||
limit: int = Field(20, title="Limit", description="Page size.")
|
limit: int = Field(20, title="Limit", description="Page size.")
|
||||||
@@ -280,7 +271,7 @@ class DatasetDocumentListApi(Resource):
|
|||||||
except services.errors.account.NoPermissionError as e:
|
except services.errors.account.NoPermissionError as e:
|
||||||
raise Forbidden(str(e))
|
raise Forbidden(str(e))
|
||||||
|
|
||||||
query = select(Document).filter_by(dataset_id=str(dataset_id), tenant_id=current_tenant_id)
|
query = select(Document).where(Document.dataset_id == str(dataset_id), Document.tenant_id == current_tenant_id)
|
||||||
|
|
||||||
if status:
|
if status:
|
||||||
query = DocumentService.apply_display_status_filter(query, status)
|
query = DocumentService.apply_display_status_filter(query, status)
|
||||||
@@ -454,7 +445,7 @@ class DatasetInitApi(Resource):
|
|||||||
if knowledge_config.embedding_model is None or knowledge_config.embedding_model_provider is None:
|
if knowledge_config.embedding_model is None or knowledge_config.embedding_model_provider is None:
|
||||||
raise ValueError("embedding model and embedding model provider are required for high quality indexing.")
|
raise ValueError("embedding model and embedding model provider are required for high quality indexing.")
|
||||||
try:
|
try:
|
||||||
model_manager = ModelManager()
|
model_manager = ModelManager.for_tenant(tenant_id=current_tenant_id)
|
||||||
model_manager.get_model_instance(
|
model_manager.get_model_instance(
|
||||||
tenant_id=current_tenant_id,
|
tenant_id=current_tenant_id,
|
||||||
provider=knowledge_config.embedding_model_provider,
|
provider=knowledge_config.embedding_model_provider,
|
||||||
@@ -1035,7 +1026,7 @@ class DocumentMetadataApi(DocumentResource):
|
|||||||
|
|
||||||
if not isinstance(doc_metadata, dict):
|
if not isinstance(doc_metadata, dict):
|
||||||
raise ValueError("doc_metadata must be a dictionary.")
|
raise ValueError("doc_metadata must be a dictionary.")
|
||||||
metadata_schema: dict = cast(dict, DocumentService.DOCUMENT_METADATA_SCHEMA[doc_type])
|
metadata_schema: dict[str, Any] = cast(dict[str, Any], DocumentService.DOCUMENT_METADATA_SCHEMA[doc_type])
|
||||||
|
|
||||||
document.doc_metadata = {}
|
document.doc_metadata = {}
|
||||||
if doc_type == "others":
|
if doc_type == "others":
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import uuid
|
|||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask_restx import Resource, marshal
|
from flask_restx import Resource, marshal
|
||||||
|
from graphon.model_runtime.entities.model_entities import ModelType
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import String, cast, func, or_, select
|
from sqlalchemy import String, cast, func, or_, select
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
@@ -9,6 +10,7 @@ from werkzeug.exceptions import Forbidden, NotFound
|
|||||||
|
|
||||||
import services
|
import services
|
||||||
from configs import dify_config
|
from configs import dify_config
|
||||||
|
from controllers.common.controller_schemas import ChildChunkCreatePayload, ChildChunkUpdatePayload
|
||||||
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 ProviderNotInitializeError
|
from controllers.console.app.error import ProviderNotInitializeError
|
||||||
@@ -27,7 +29,6 @@ from controllers.console.wraps import (
|
|||||||
from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError
|
from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError
|
||||||
from core.model_manager import ModelManager
|
from core.model_manager import ModelManager
|
||||||
from core.rag.index_processor.constant.index_type import IndexTechniqueType
|
from core.rag.index_processor.constant.index_type import IndexTechniqueType
|
||||||
from dify_graph.model_runtime.entities.model_entities import ModelType
|
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from extensions.ext_redis import redis_client
|
from extensions.ext_redis import redis_client
|
||||||
from fields.segment_fields import child_chunk_fields, segment_fields
|
from fields.segment_fields import child_chunk_fields, segment_fields
|
||||||
@@ -82,14 +83,6 @@ class BatchImportPayload(BaseModel):
|
|||||||
upload_file_id: str
|
upload_file_id: str
|
||||||
|
|
||||||
|
|
||||||
class ChildChunkCreatePayload(BaseModel):
|
|
||||||
content: str
|
|
||||||
|
|
||||||
|
|
||||||
class ChildChunkUpdatePayload(BaseModel):
|
|
||||||
content: str
|
|
||||||
|
|
||||||
|
|
||||||
class ChildChunkBatchUpdatePayload(BaseModel):
|
class ChildChunkBatchUpdatePayload(BaseModel):
|
||||||
chunks: list[ChildChunkUpdateArgs]
|
chunks: list[ChildChunkUpdateArgs]
|
||||||
|
|
||||||
@@ -283,7 +276,7 @@ class DatasetDocumentSegmentApi(Resource):
|
|||||||
if dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY:
|
if dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY:
|
||||||
# check embedding model setting
|
# check embedding model setting
|
||||||
try:
|
try:
|
||||||
model_manager = ModelManager()
|
model_manager = ModelManager.for_tenant(tenant_id=current_tenant_id)
|
||||||
model_manager.get_model_instance(
|
model_manager.get_model_instance(
|
||||||
tenant_id=current_tenant_id,
|
tenant_id=current_tenant_id,
|
||||||
provider=dataset.embedding_model_provider,
|
provider=dataset.embedding_model_provider,
|
||||||
@@ -336,7 +329,7 @@ class DatasetDocumentSegmentAddApi(Resource):
|
|||||||
# check embedding model setting
|
# check embedding model setting
|
||||||
if dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY:
|
if dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY:
|
||||||
try:
|
try:
|
||||||
model_manager = ModelManager()
|
model_manager = ModelManager.for_tenant(tenant_id=current_tenant_id)
|
||||||
model_manager.get_model_instance(
|
model_manager.get_model_instance(
|
||||||
tenant_id=current_tenant_id,
|
tenant_id=current_tenant_id,
|
||||||
provider=dataset.embedding_model_provider,
|
provider=dataset.embedding_model_provider,
|
||||||
@@ -387,7 +380,7 @@ class DatasetDocumentSegmentUpdateApi(Resource):
|
|||||||
if dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY:
|
if dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY:
|
||||||
# check embedding model setting
|
# check embedding model setting
|
||||||
try:
|
try:
|
||||||
model_manager = ModelManager()
|
model_manager = ModelManager.for_tenant(tenant_id=current_tenant_id)
|
||||||
model_manager.get_model_instance(
|
model_manager.get_model_instance(
|
||||||
tenant_id=current_tenant_id,
|
tenant_id=current_tenant_id,
|
||||||
provider=dataset.embedding_model_provider,
|
provider=dataset.embedding_model_provider,
|
||||||
@@ -572,7 +565,7 @@ class ChildChunkAddApi(Resource):
|
|||||||
# check embedding model setting
|
# check embedding model setting
|
||||||
if dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY:
|
if dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY:
|
||||||
try:
|
try:
|
||||||
model_manager = ModelManager()
|
model_manager = ModelManager.for_tenant(tenant_id=current_tenant_id)
|
||||||
model_manager.get_model_instance(
|
model_manager.get_model_instance(
|
||||||
tenant_id=current_tenant_id,
|
tenant_id=current_tenant_id,
|
||||||
provider=dataset.embedding_model_provider,
|
provider=dataset.embedding_model_provider,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from libs.login import current_account_with_tenant, login_required
|
|||||||
from services.dataset_service import DatasetService
|
from services.dataset_service import DatasetService
|
||||||
from services.external_knowledge_service import ExternalDatasetService
|
from services.external_knowledge_service import ExternalDatasetService
|
||||||
from services.hit_testing_service import HitTestingService
|
from services.hit_testing_service import HitTestingService
|
||||||
from services.knowledge_service import ExternalDatasetTestService
|
from services.knowledge_service import BedrockRetrievalSetting, ExternalDatasetTestService
|
||||||
|
|
||||||
|
|
||||||
def _build_dataset_detail_model():
|
def _build_dataset_detail_model():
|
||||||
@@ -86,7 +86,7 @@ class ExternalHitTestingPayload(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class BedrockRetrievalPayload(BaseModel):
|
class BedrockRetrievalPayload(BaseModel):
|
||||||
retrieval_setting: dict[str, object]
|
retrieval_setting: "BedrockRetrievalSetting"
|
||||||
query: str
|
query: str
|
||||||
knowledge_id: str
|
knowledge_id: str
|
||||||
|
|
||||||
@@ -173,8 +173,11 @@ class ExternalApiTemplateApi(Resource):
|
|||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
def get(self, external_knowledge_api_id):
|
def get(self, external_knowledge_api_id):
|
||||||
|
_, current_tenant_id = current_account_with_tenant()
|
||||||
external_knowledge_api_id = str(external_knowledge_api_id)
|
external_knowledge_api_id = str(external_knowledge_api_id)
|
||||||
external_knowledge_api = ExternalDatasetService.get_external_knowledge_api(external_knowledge_api_id)
|
external_knowledge_api = ExternalDatasetService.get_external_knowledge_api(
|
||||||
|
external_knowledge_api_id, current_tenant_id
|
||||||
|
)
|
||||||
if external_knowledge_api is None:
|
if external_knowledge_api is None:
|
||||||
raise NotFound("API template not found.")
|
raise NotFound("API template not found.")
|
||||||
|
|
||||||
@@ -224,10 +227,11 @@ class ExternalApiUseCheckApi(Resource):
|
|||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
def get(self, external_knowledge_api_id):
|
def get(self, external_knowledge_api_id):
|
||||||
|
_, current_tenant_id = current_account_with_tenant()
|
||||||
external_knowledge_api_id = str(external_knowledge_api_id)
|
external_knowledge_api_id = str(external_knowledge_api_id)
|
||||||
|
|
||||||
external_knowledge_api_is_using, count = ExternalDatasetService.external_knowledge_api_use_check(
|
external_knowledge_api_is_using, count = ExternalDatasetService.external_knowledge_api_use_check(
|
||||||
external_knowledge_api_id
|
external_knowledge_api_id, current_tenant_id
|
||||||
)
|
)
|
||||||
return {"is_using": external_knowledge_api_is_using, "count": count}, 200
|
return {"is_using": external_knowledge_api_is_using, "count": count}, 200
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
from flask_restx import Resource, fields
|
from __future__ import annotations
|
||||||
|
|
||||||
from controllers.common.schema import register_schema_model
|
from datetime import datetime
|
||||||
from fields.hit_testing_fields import (
|
from typing import Any
|
||||||
child_chunk_fields,
|
|
||||||
document_fields,
|
from flask_restx import Resource
|
||||||
files_fields,
|
from pydantic import Field, field_validator
|
||||||
hit_testing_record_fields,
|
|
||||||
segment_fields,
|
from controllers.common.schema import register_schema_models
|
||||||
)
|
from fields.base import ResponseModel
|
||||||
from libs.login import login_required
|
from libs.login import login_required
|
||||||
|
|
||||||
from .. import console_ns
|
from .. import console_ns
|
||||||
@@ -18,39 +18,92 @@ from ..wraps import (
|
|||||||
setup_required,
|
setup_required,
|
||||||
)
|
)
|
||||||
|
|
||||||
register_schema_model(console_ns, HitTestingPayload)
|
|
||||||
|
def _to_timestamp(value: datetime | int | None) -> int | None:
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return int(value.timestamp())
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_model(model_name: str, field_def):
|
class HitTestingDocument(ResponseModel):
|
||||||
"""Get or create a flask_restx model to avoid dict type issues in Swagger."""
|
id: str | None = None
|
||||||
existing = console_ns.models.get(model_name)
|
data_source_type: str | None = None
|
||||||
if existing is None:
|
name: str | None = None
|
||||||
existing = console_ns.model(model_name, field_def)
|
doc_type: str | None = None
|
||||||
return existing
|
doc_metadata: Any | None = None
|
||||||
|
|
||||||
|
|
||||||
# Register models for flask_restx to avoid dict type issues in Swagger
|
class HitTestingSegment(ResponseModel):
|
||||||
document_model = _get_or_create_model("HitTestingDocument", document_fields)
|
id: str | None = None
|
||||||
|
position: int | None = None
|
||||||
|
document_id: str | None = None
|
||||||
|
content: str | None = None
|
||||||
|
sign_content: str | None = None
|
||||||
|
answer: str | None = None
|
||||||
|
word_count: int | None = None
|
||||||
|
tokens: int | None = None
|
||||||
|
keywords: list[str] = Field(default_factory=list)
|
||||||
|
index_node_id: str | None = None
|
||||||
|
index_node_hash: str | None = None
|
||||||
|
hit_count: int | None = None
|
||||||
|
enabled: bool | None = None
|
||||||
|
disabled_at: int | None = None
|
||||||
|
disabled_by: str | None = None
|
||||||
|
status: str | None = None
|
||||||
|
created_by: str | None = None
|
||||||
|
created_at: int | None = None
|
||||||
|
indexing_at: int | None = None
|
||||||
|
completed_at: int | None = None
|
||||||
|
error: str | None = None
|
||||||
|
stopped_at: int | None = None
|
||||||
|
document: HitTestingDocument | None = None
|
||||||
|
|
||||||
segment_fields_copy = segment_fields.copy()
|
@field_validator("disabled_at", "created_at", "indexing_at", "completed_at", "stopped_at", mode="before")
|
||||||
segment_fields_copy["document"] = fields.Nested(document_model)
|
@classmethod
|
||||||
segment_model = _get_or_create_model("HitTestingSegment", segment_fields_copy)
|
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
||||||
|
return _to_timestamp(value)
|
||||||
|
|
||||||
child_chunk_model = _get_or_create_model("HitTestingChildChunk", child_chunk_fields)
|
|
||||||
files_model = _get_or_create_model("HitTestingFile", files_fields)
|
|
||||||
|
|
||||||
hit_testing_record_fields_copy = hit_testing_record_fields.copy()
|
class HitTestingChildChunk(ResponseModel):
|
||||||
hit_testing_record_fields_copy["segment"] = fields.Nested(segment_model)
|
id: str | None = None
|
||||||
hit_testing_record_fields_copy["child_chunks"] = fields.List(fields.Nested(child_chunk_model))
|
content: str | None = None
|
||||||
hit_testing_record_fields_copy["files"] = fields.List(fields.Nested(files_model))
|
position: int | None = None
|
||||||
hit_testing_record_model = _get_or_create_model("HitTestingRecord", hit_testing_record_fields_copy)
|
score: float | None = None
|
||||||
|
|
||||||
# Response model for hit testing API
|
|
||||||
hit_testing_response_fields = {
|
class HitTestingFile(ResponseModel):
|
||||||
"query": fields.String,
|
id: str | None = None
|
||||||
"records": fields.List(fields.Nested(hit_testing_record_model)),
|
name: str | None = None
|
||||||
}
|
size: int | None = None
|
||||||
hit_testing_response_model = _get_or_create_model("HitTestingResponse", hit_testing_response_fields)
|
extension: str | None = None
|
||||||
|
mime_type: str | None = None
|
||||||
|
source_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HitTestingRecord(ResponseModel):
|
||||||
|
segment: HitTestingSegment | None = None
|
||||||
|
child_chunks: list[HitTestingChildChunk] = Field(default_factory=list)
|
||||||
|
score: float | None = None
|
||||||
|
tsne_position: Any | None = None
|
||||||
|
files: list[HitTestingFile] = Field(default_factory=list)
|
||||||
|
summary: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HitTestingResponse(ResponseModel):
|
||||||
|
query: str
|
||||||
|
records: list[HitTestingRecord] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
register_schema_models(
|
||||||
|
console_ns,
|
||||||
|
HitTestingPayload,
|
||||||
|
HitTestingDocument,
|
||||||
|
HitTestingSegment,
|
||||||
|
HitTestingChildChunk,
|
||||||
|
HitTestingFile,
|
||||||
|
HitTestingRecord,
|
||||||
|
HitTestingResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/datasets/<uuid:dataset_id>/hit-testing")
|
@console_ns.route("/datasets/<uuid:dataset_id>/hit-testing")
|
||||||
@@ -59,7 +112,11 @@ class HitTestingApi(Resource, DatasetsHitTestingBase):
|
|||||||
@console_ns.doc(description="Test dataset knowledge retrieval")
|
@console_ns.doc(description="Test dataset knowledge retrieval")
|
||||||
@console_ns.doc(params={"dataset_id": "Dataset ID"})
|
@console_ns.doc(params={"dataset_id": "Dataset ID"})
|
||||||
@console_ns.expect(console_ns.models[HitTestingPayload.__name__])
|
@console_ns.expect(console_ns.models[HitTestingPayload.__name__])
|
||||||
@console_ns.response(200, "Hit testing completed successfully", model=hit_testing_response_model)
|
@console_ns.response(
|
||||||
|
200,
|
||||||
|
"Hit testing completed successfully",
|
||||||
|
model=console_ns.models[HitTestingResponse.__name__],
|
||||||
|
)
|
||||||
@console_ns.response(404, "Dataset not found")
|
@console_ns.response(404, "Dataset not found")
|
||||||
@console_ns.response(400, "Invalid parameters")
|
@console_ns.response(400, "Invalid parameters")
|
||||||
@setup_required
|
@setup_required
|
||||||
@@ -74,4 +131,4 @@ class HitTestingApi(Resource, DatasetsHitTestingBase):
|
|||||||
args = payload.model_dump(exclude_none=True)
|
args = payload.model_dump(exclude_none=True)
|
||||||
self.hit_testing_args_check(args)
|
self.hit_testing_args_check(args)
|
||||||
|
|
||||||
return self.perform_hit_testing(dataset, args)
|
return HitTestingResponse.model_validate(self.perform_hit_testing(dataset, args)).model_dump(mode="json")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import logging
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from flask_restx import marshal
|
from flask_restx import marshal
|
||||||
|
from graphon.model_runtime.errors.invoke import InvokeError
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
|
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
|
||||||
|
|
||||||
@@ -19,7 +20,6 @@ from core.errors.error import (
|
|||||||
ProviderTokenNotInitError,
|
ProviderTokenNotInitError,
|
||||||
QuotaExceededError,
|
QuotaExceededError,
|
||||||
)
|
)
|
||||||
from dify_graph.model_runtime.errors.invoke import InvokeError
|
|
||||||
from fields.hit_testing_fields import hit_testing_record_fields
|
from fields.hit_testing_fields import hit_testing_record_fields
|
||||||
from libs.login import current_user
|
from libs.login import current_user
|
||||||
from models.account import Account
|
from models.account import Account
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user