Compare commits
697 Commits
codex/refa
...
refactor/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5e6621b22 | ||
|
|
1385cdde4d | ||
|
|
fc7193cf04 | ||
|
|
59021616d4 | ||
|
|
ab0e92b893 | ||
|
|
b0b495d243 | ||
|
|
6536ffc422 | ||
|
|
b7570ce4b7 | ||
|
|
907bc2558b | ||
|
|
95a6a14ed5 | ||
|
|
be7d11ca1d | ||
|
|
8129555642 | ||
|
|
f28d993607 | ||
|
|
0fc01fee3f | ||
|
|
bc716020c7 | ||
|
|
3cd1fc098c | ||
|
|
7664ad7149 | ||
|
|
b1eafb0d82 | ||
|
|
a1a10f17e3 | ||
|
|
6ae40b1f15 | ||
|
|
e577c007a0 | ||
|
|
02855848ea | ||
|
|
98090d60b3 | ||
|
|
59ee8878f9 | ||
|
|
82d86dcf98 | ||
|
|
183741b5f7 | ||
|
|
de3f3714ae | ||
|
|
415e999cef | ||
|
|
c25b439d8b | ||
|
|
324ee5f036 | ||
|
|
8707d0c82f | ||
|
|
4d9bda7422 | ||
|
|
88c64a8db9 | ||
|
|
a5445f2424 | ||
|
|
334a3043bc | ||
|
|
93dae7d8a0 | ||
|
|
ea98d38c3d | ||
|
|
18ac946e2b | ||
|
|
baf629dd36 | ||
|
|
a3dbf9293a | ||
|
|
3f982e3dc8 | ||
|
|
6bc7f01261 | ||
|
|
c4ea406586 | ||
|
|
7978352262 | ||
|
|
980a9d4ed4 | ||
|
|
d337c59916 | ||
|
|
c45e5386fb | ||
|
|
d6e4f48526 | ||
|
|
27c2f058fe | ||
|
|
8e064ffe98 | ||
|
|
fe39211aac | ||
|
|
770b83d9cc | ||
|
|
566b8857dd | ||
|
|
3ed28dce33 | ||
|
|
fdca3e2a1a | ||
|
|
6411d6235c | ||
|
|
1977355dd1 | ||
|
|
520e5e2beb | ||
|
|
5cbc42cd38 | ||
|
|
765e6338d2 | ||
|
|
62221bf8b8 | ||
|
|
8f2ff3a8fb | ||
|
|
f92f7e92c3 | ||
|
|
72458d5750 | ||
|
|
ce801f4445 | ||
|
|
57144b0094 | ||
|
|
b71dcb8253 | ||
|
|
762019a160 | ||
|
|
39f06b6f05 | ||
|
|
d0a86cfd4e | ||
|
|
336dd0b8c0 | ||
|
|
6da1b473e6 | ||
|
|
f021ee4dc0 | ||
|
|
280f81757d | ||
|
|
1952cff091 | ||
|
|
f8f71fdc68 | ||
|
|
9398482b53 | ||
|
|
5db79a77e4 | ||
|
|
02222e8fce | ||
|
|
5a5b9c6fec | ||
|
|
d25e2b51fc | ||
|
|
c6ca395042 | ||
|
|
7167d7d662 | ||
|
|
9e07c2dac6 | ||
|
|
6a00c23e49 | ||
|
|
750810ea81 | ||
|
|
b70ad7d424 | ||
|
|
ad86351cc1 | ||
|
|
8b8031710b | ||
|
|
94dff4dddc | ||
|
|
17ae89ff22 | ||
|
|
fce29e6f6a | ||
|
|
6130f081e0 | ||
|
|
8894b09c3b | ||
|
|
bf64390d7c | ||
|
|
884ece570e | ||
|
|
9d396524b9 | ||
|
|
8b3c2ee274 | ||
|
|
02f9b3ca7c | ||
|
|
09054169c4 | ||
|
|
0f3cde8120 | ||
|
|
2bdcbe96c5 | ||
|
|
3824323fe7 | ||
|
|
1264d29a4f | ||
|
|
f8dd276e2c | ||
|
|
37988de678 | ||
|
|
92110e1982 | ||
|
|
d8e7a97b25 | ||
|
|
e6fc0a8ba8 | ||
|
|
28e9a52ea5 | ||
|
|
efd90de70a | ||
|
|
aa208d2dd7 | ||
|
|
11bb82c731 | ||
|
|
78c85ae7c1 | ||
|
|
4c414988ee | ||
|
|
3bc4ac44e5 | ||
|
|
94fdda18e2 | ||
|
|
0b11d34d4b | ||
|
|
ff5d431efd | ||
|
|
97b5d8092c | ||
|
|
f7d6cd1afe | ||
|
|
1971450ad8 | ||
|
|
c8606db16b | ||
|
|
654324c0d9 | ||
|
|
650f8880ea | ||
|
|
f7e71ca9e1 | ||
|
|
290acf1b73 | ||
|
|
458c96d5ba | ||
|
|
2a554ab2d1 | ||
|
|
3ce262de05 | ||
|
|
3ea5f38e92 | ||
|
|
8c25190713 | ||
|
|
9c16c86597 | ||
|
|
d84088ef74 | ||
|
|
a38ddd9d02 | ||
|
|
d94204ec33 | ||
|
|
1db542c706 | ||
|
|
15021ad350 | ||
|
|
59383e88c2 | ||
|
|
05b00574aa | ||
|
|
5aac67f570 | ||
|
|
1be72af661 | ||
|
|
1ccac4792c | ||
|
|
d67a8323c5 | ||
|
|
ef20a16a2f | ||
|
|
1efc71c624 | ||
|
|
54298abf2d | ||
|
|
735d814dc0 | ||
|
|
3e12f0b9fb | ||
|
|
85b4626ac6 | ||
|
|
8266d741e8 | ||
|
|
3765a41b8c | ||
|
|
6a1b23935f | ||
|
|
bf54dd7814 | ||
|
|
1aa7ee29a1 | ||
|
|
a9319c86ee | ||
|
|
5a6191b96b | ||
|
|
ce31cdc585 | ||
|
|
32981687dd | ||
|
|
58d71bf9c9 | ||
|
|
82520c144f | ||
|
|
0d05eae517 | ||
|
|
c8e29d9a7d | ||
|
|
638dfd77a4 | ||
|
|
0047522b0d | ||
|
|
ac23430aca | ||
|
|
06482af260 | ||
|
|
30e6069851 | ||
|
|
021d5ceae7 | ||
|
|
73dd8739ef | ||
|
|
f5aaa50a30 | ||
|
|
8c9f248520 | ||
|
|
6e0e32848e | ||
|
|
6ba29b3f0c | ||
|
|
119e6af2a9 | ||
|
|
c563528a2d | ||
|
|
c4ace15afd | ||
|
|
d5a8ace1c4 | ||
|
|
86f8b847e9 | ||
|
|
1c6f0a0388 | ||
|
|
c937fcb21a | ||
|
|
0a1ce09dfb | ||
|
|
b9ac4d3cb1 | ||
|
|
e4cd2c72ad | ||
|
|
dfe396fb64 | ||
|
|
6b5952425d | ||
|
|
1299d99b2d | ||
|
|
36dfc5abd3 | ||
|
|
c7520d7c71 | ||
|
|
798a238e63 | ||
|
|
db5121828e | ||
|
|
ed184a6632 | ||
|
|
92016aa139 | ||
|
|
09a855dcef | ||
|
|
a6fe278662 | ||
|
|
960018253b | ||
|
|
8abff1b0b1 | ||
|
|
eb7a1829fe | ||
|
|
9ce92d8605 | ||
|
|
ddcdbb2d14 | ||
|
|
18d3990ae6 | ||
|
|
03e4bc50a2 | ||
|
|
370efc493c | ||
|
|
cf236aed8a | ||
|
|
48315be2b1 | ||
|
|
a9035a3264 | ||
|
|
39ce9f68fc | ||
|
|
ed9d3801ba | ||
|
|
f3bb73b671 | ||
|
|
bc845ea748 | ||
|
|
1afac21690 | ||
|
|
dd2e691ea8 | ||
|
|
f360a7bf94 | ||
|
|
caf7e90ca5 | ||
|
|
1e501cd75b | ||
|
|
45cb385d77 | ||
|
|
679514d40a | ||
|
|
9643dcacce | ||
|
|
fcb380044c | ||
|
|
d4ed92cb0a | ||
|
|
12f259ecae | ||
|
|
d9ca69d34f | ||
|
|
ebb0f12cd0 | ||
|
|
667d66c4dc | ||
|
|
ef92cb7c65 | ||
|
|
c9ea15a968 | ||
|
|
b56d30b366 | ||
|
|
8d6128957f | ||
|
|
e25ca611d8 | ||
|
|
44aec257bf | ||
|
|
2be3c3d69e | ||
|
|
09b199b280 | ||
|
|
b9764c4779 | ||
|
|
ff3cf5681f | ||
|
|
cfae2515fd | ||
|
|
b5982927de | ||
|
|
28a4859218 | ||
|
|
4f8a4c20fd | ||
|
|
21d540f760 | ||
|
|
fef28097cc | ||
|
|
3de20e0ba8 | ||
|
|
703f6d1780 | ||
|
|
2e1620c1ac | ||
|
|
4847447635 | ||
|
|
7ce751ffe8 | ||
|
|
7cb9aa2d3b | ||
|
|
3ab8476d2d | ||
|
|
643e2d66bf | ||
|
|
693e1b0fc5 | ||
|
|
70a513fc62 | ||
|
|
bec4ef6ac7 | ||
|
|
0024a845f7 | ||
|
|
0ab88ef36a | ||
|
|
7b3d97cb42 | ||
|
|
429edf4d82 | ||
|
|
5a9b6cb239 | ||
|
|
bda6bf3fee | ||
|
|
24287bcb0c | ||
|
|
3f7e6cdcb4 | ||
|
|
ea6c15b68e | ||
|
|
77998e41e4 | ||
|
|
aed1672366 | ||
|
|
11c922b5d4 | ||
|
|
ccd0f4b15d | ||
|
|
ab34f9ae09 | ||
|
|
9385e583ff | ||
|
|
5018f8aa8a | ||
|
|
ed45016475 | ||
|
|
9d6c846a49 | ||
|
|
8bdf702f73 | ||
|
|
c62947f1f1 | ||
|
|
24f5e2ac69 | ||
|
|
e18310e52e | ||
|
|
07119e3f00 | ||
|
|
a9b8c84e9b | ||
|
|
35a4533889 | ||
|
|
3a8fbcbd02 | ||
|
|
23150c2319 | ||
|
|
d6147456e3 | ||
|
|
7d5c68c2e1 | ||
|
|
385e8662bb | ||
|
|
6cb96adfbe | ||
|
|
373f33fd3b | ||
|
|
faca558bf5 | ||
|
|
b38d907ede | ||
|
|
0c3d08f355 | ||
|
|
a52adef203 | ||
|
|
3b05b5e553 | ||
|
|
92978f6d9f | ||
|
|
6fe87aaca5 | ||
|
|
37a927bf63 | ||
|
|
44e89d8844 | ||
|
|
7b1339a156 | ||
|
|
2ff3f7b533 | ||
|
|
34fdac2113 | ||
|
|
7d635d9f7e | ||
|
|
0ce41bfea1 | ||
|
|
92b93c9d37 | ||
|
|
4692741aa0 | ||
|
|
4ca0a10088 | ||
|
|
6ce0976265 | ||
|
|
5827ce2adf | ||
|
|
1d79b87307 | ||
|
|
9d8dacd0c8 | ||
|
|
720e1fab9b | ||
|
|
ca6f4e11f9 | ||
|
|
a5e284e6b1 | ||
|
|
3d08b2f89a | ||
|
|
43e59d3bc5 | ||
|
|
edc805c453 | ||
|
|
0ca99312c1 | ||
|
|
785ca4b915 | ||
|
|
c7b5a455f5 | ||
|
|
0c698744ab | ||
|
|
e425e18fca | ||
|
|
f4e50f3c8a | ||
|
|
aba3f9c1d6 | ||
|
|
979c8cc34b | ||
|
|
502418f707 | ||
|
|
ccfb47d2c5 | ||
|
|
1bb9183af9 | ||
|
|
de7e43a7ef | ||
|
|
b09691e5d2 | ||
|
|
6f70ca8d26 | ||
|
|
a7a30b20af | ||
|
|
cbd78c0073 | ||
|
|
97a3039a2d | ||
|
|
005edb7d47 | ||
|
|
b034368b3b | ||
|
|
43b6d8e32e | ||
|
|
248df5dd25 | ||
|
|
f97fc01c2f | ||
|
|
a6ace29e1a | ||
|
|
013b328555 | ||
|
|
d7b1531431 | ||
|
|
63acefa6d0 | ||
|
|
e2f6418fe4 | ||
|
|
2daa69c92e | ||
|
|
72b6a4e6ed | ||
|
|
b16a24f084 | ||
|
|
6e890524db | ||
|
|
e27eb51f8a | ||
|
|
2155adff5a | ||
|
|
c2c1876484 | ||
|
|
48f338e649 | ||
|
|
b4e13ec83e | ||
|
|
11f830a17b | ||
|
|
5550ec8ebf | ||
|
|
ea59256349 | ||
|
|
0f1536585e | ||
|
|
602a646dbd | ||
|
|
54ffbc7f0f | ||
|
|
ae20d64304 | ||
|
|
ee997d00fa | ||
|
|
98c2ffeec7 | ||
|
|
8e1c259a66 | ||
|
|
ea3fd4c093 | ||
|
|
930f4e6c26 | ||
|
|
356e3a8ab6 | ||
|
|
532489babd | ||
|
|
6fa9c14490 | ||
|
|
d0149c3f94 | ||
|
|
2f6e782215 | ||
|
|
414f211cde | ||
|
|
f1b48a93a9 | ||
|
|
8874f3c80b | ||
|
|
297fdc7cb8 | ||
|
|
73b15fc562 | ||
|
|
90a6b8c998 | ||
|
|
8d18931125 | ||
|
|
7444f96a1d | ||
|
|
8a33a9adb5 | ||
|
|
b78a3462c9 | ||
|
|
c38fb16efa | ||
|
|
2400c6c59d | ||
|
|
324ddb361d | ||
|
|
4f81005662 | ||
|
|
f2dfa462e1 | ||
|
|
14bc28ffbe | ||
|
|
5afce305e7 | ||
|
|
beecf5730a | ||
|
|
eeda983b17 | ||
|
|
e2e3521874 | ||
|
|
303ce70ac4 | ||
|
|
5fde1d096e | ||
|
|
3bcd97b643 | ||
|
|
20fdbcf36f | ||
|
|
c7f4422333 | ||
|
|
da6ca23b50 | ||
|
|
b064a13aa4 | ||
|
|
45e2d81be4 | ||
|
|
fe41e80f33 | ||
|
|
a2ac8ad106 | ||
|
|
bd4cfd5a7a | ||
|
|
0e975f6305 | ||
|
|
c94846ee2a | ||
|
|
9ce463fc24 | ||
|
|
5a0a12cb44 | ||
|
|
ba61ab4f9f | ||
|
|
891627b210 | ||
|
|
ba5e7d76fd | ||
|
|
809a6cff1e | ||
|
|
32c11fd06e | ||
|
|
04d2d9ff19 | ||
|
|
05499b9011 | ||
|
|
ca32e87683 | ||
|
|
122368f5e0 | ||
|
|
c267e73ca6 | ||
|
|
c649b84bf6 | ||
|
|
09f8f43d20 | ||
|
|
50d6c19ef4 | ||
|
|
6d89557355 | ||
|
|
30c304c6a7 | ||
|
|
76a91eb433 | ||
|
|
394b43376a | ||
|
|
02f0e8cffa | ||
|
|
3f0a9c5060 | ||
|
|
ce1aed8df5 | ||
|
|
c3d090fe4e | ||
|
|
d92cf5d198 | ||
|
|
ccf4d25321 | ||
|
|
4e02075f8d | ||
|
|
0f358f5bb3 | ||
|
|
a575ae5ef2 | ||
|
|
2aaac4dadb | ||
|
|
bec3890689 | ||
|
|
e3ab88f6de | ||
|
|
a679ee5644 | ||
|
|
96809473fc | ||
|
|
5ef8becd25 | ||
|
|
4249a0d052 | ||
|
|
64587eae43 | ||
|
|
4ce681d42f | ||
|
|
59c6a470bb | ||
|
|
b02072baee | ||
|
|
f6a472d948 | ||
|
|
45be4620fe | ||
|
|
08993dd622 | ||
|
|
219596c4df | ||
|
|
facb60abfe | ||
|
|
141cbf9832 | ||
|
|
8fb5dd8587 | ||
|
|
1651bcc774 | ||
|
|
c2e68335d4 | ||
|
|
177edaa1c6 | ||
|
|
edb32fba92 | ||
|
|
959cacdc00 | ||
|
|
582c73bc3b | ||
|
|
6eca353804 | ||
|
|
d43685bc79 | ||
|
|
56ebccf14d | ||
|
|
d31a7d47bf | ||
|
|
8031ea6d73 | ||
|
|
dfac3e524e | ||
|
|
fc7b51634d | ||
|
|
96bfcf20cd | ||
|
|
557fbec45f | ||
|
|
eaa9d877f1 | ||
|
|
b537fed907 | ||
|
|
b6319c819c | ||
|
|
b6694d462f | ||
|
|
ea6770c7a1 | ||
|
|
b0c5172e10 | ||
|
|
62a544a146 | ||
|
|
eec092f93d | ||
|
|
e0fd3b264a | ||
|
|
17c8ff6a33 | ||
|
|
c425e4cdd5 | ||
|
|
de9b456e82 | ||
|
|
62d06a3c73 | ||
|
|
09f20b7a14 | ||
|
|
0a1e52598f | ||
|
|
640bf56859 | ||
|
|
a958c2cadb | ||
|
|
f058e36084 | ||
|
|
9f364339cc | ||
|
|
3acc9470d4 | ||
|
|
9ddbc23b16 | ||
|
|
c903e93e8b | ||
|
|
0b36a0bbc0 | ||
|
|
4e44ade07f | ||
|
|
35132d6892 | ||
|
|
6edc8ba147 | ||
|
|
12b3b62a7f | ||
|
|
58653f2cdb | ||
|
|
ef176f130b | ||
|
|
c36161a73f | ||
|
|
4b189777c4 | ||
|
|
d08dc6162c | ||
|
|
b5e05516f4 | ||
|
|
9f048cd89b | ||
|
|
78dfce3b63 | ||
|
|
97d34ae6be | ||
|
|
ea0ee680dc | ||
|
|
9acdcbe2f8 | ||
|
|
0f63a05d04 | ||
|
|
3cd23d4ef8 | ||
|
|
e2852524be | ||
|
|
f8e05ebfc0 | ||
|
|
0ac005eb98 | ||
|
|
755c154b1f | ||
|
|
e3b3165e9a | ||
|
|
bf044cf19c | ||
|
|
1cf8db8395 | ||
|
|
c1bde25bcc | ||
|
|
8238684334 | ||
|
|
c9c7fb9962 | ||
|
|
b9248690b0 | ||
|
|
36dfc6c216 | ||
|
|
7a6f380f2c | ||
|
|
84e348c52b | ||
|
|
79b8d7a797 | ||
|
|
fb30dd2d58 | ||
|
|
a0ca3d2e81 | ||
|
|
204a906de8 | ||
|
|
146f402a8c | ||
|
|
d6f32a0267 | ||
|
|
288766171d | ||
|
|
7ab368f4a4 | ||
|
|
d0e2723e45 | ||
|
|
c085142e66 | ||
|
|
c647cfd6a3 | ||
|
|
29a4897e73 | ||
|
|
8df220c2b3 | ||
|
|
6e80f42f38 | ||
|
|
211344d0b0 | ||
|
|
6b5c04e7d3 | ||
|
|
6ea89dc7cb | ||
|
|
ff46c3d7d7 | ||
|
|
2f969afaf2 | ||
|
|
fbd38753fa | ||
|
|
2d1fb61885 | ||
|
|
7e3381e8ad | ||
|
|
190780e125 | ||
|
|
1f96da5e6d | ||
|
|
a8d5679a1c | ||
|
|
5bacb68115 | ||
|
|
af2fac75db | ||
|
|
616736e0f1 | ||
|
|
94323c0838 | ||
|
|
223998bd48 | ||
|
|
b3d770a69b | ||
|
|
9fb51b7e67 | ||
|
|
67366dd35c | ||
|
|
71b31e17b0 | ||
|
|
312bc4912d | ||
|
|
3925b5fa1b | ||
|
|
d4d30148bd | ||
|
|
12e6838403 | ||
|
|
489772908b | ||
|
|
6a37e1538a | ||
|
|
eeaaf11a51 | ||
|
|
6899c7720f | ||
|
|
51a007e84e | ||
|
|
6d185b6f40 | ||
|
|
7358c0f735 | ||
|
|
01460f681e | ||
|
|
750fce7e52 | ||
|
|
216d528f1b | ||
|
|
0ee89c1302 | ||
|
|
af382be837 | ||
|
|
d813edb945 | ||
|
|
9041251da5 | ||
|
|
9e04e4f5c2 | ||
|
|
28ba7033fa | ||
|
|
fbaf23dc8d | ||
|
|
9cc1f68364 | ||
|
|
059c9ce0b6 | ||
|
|
668fb4c14a | ||
|
|
4c06bcf000 | ||
|
|
7aba539e82 | ||
|
|
ef8544b173 | ||
|
|
bee269afe8 | ||
|
|
324b0f97ff | ||
|
|
7352944dec | ||
|
|
78c7fb9c5c | ||
|
|
8598af2a17 | ||
|
|
df07a0a1de | ||
|
|
a64a24b39c | ||
|
|
cd0e88c680 | ||
|
|
08008f6305 | ||
|
|
e3812cf72b | ||
|
|
4175d062f4 | ||
|
|
84f9b6530e | ||
|
|
3d02a12eb6 | ||
|
|
c1e94f8ee7 | ||
|
|
7fffd51535 | ||
|
|
a68e0bbc6f | ||
|
|
dd3cf53044 | ||
|
|
500e37e2fd | ||
|
|
44c5f1c011 | ||
|
|
7162450e83 | ||
|
|
3f62f3cd05 | ||
|
|
15483f1592 | ||
|
|
4c7b870062 | ||
|
|
48451f5adf | ||
|
|
f785af3cec | ||
|
|
aa5e8a5eff | ||
|
|
6a9efa8ffd | ||
|
|
4eb9a24997 | ||
|
|
b206e691ca | ||
|
|
3f354c2b06 | ||
|
|
8ae9d0ad8e | ||
|
|
0f583cfe75 | ||
|
|
df250423d4 | ||
|
|
b8934b743c | ||
|
|
976d061f9b | ||
|
|
96655436f4 | ||
|
|
648806bc9c | ||
|
|
64ebfe2235 | ||
|
|
ca4d7ff1f2 | ||
|
|
2917d1d51c | ||
|
|
c79b9821fa | ||
|
|
6422ef2b57 | ||
|
|
509ea9afc4 | ||
|
|
1828cc6a10 | ||
|
|
8d5ff67d05 | ||
|
|
ec63a26511 | ||
|
|
c2b3b89445 | ||
|
|
5967bd8e08 | ||
|
|
7e7548cd18 | ||
|
|
1ba3aa718e | ||
|
|
c7b1660650 | ||
|
|
f5e1f1590f | ||
|
|
09d0cca5b9 | ||
|
|
d8736be194 | ||
|
|
7f15f506f8 | ||
|
|
6aa3fa5193 | ||
|
|
c849be5e52 | ||
|
|
b3b3088a17 | ||
|
|
72fad39913 | ||
|
|
e12563a4b4 | ||
|
|
e8afed13cc | ||
|
|
7a72bb3571 | ||
|
|
08f30717ad | ||
|
|
2bdfbb9e61 | ||
|
|
06e757a9e9 | ||
|
|
7b84d4daca | ||
|
|
5e0d705a7f | ||
|
|
30ca836e03 | ||
|
|
b391dc56a0 | ||
|
|
606fac6e1c | ||
|
|
ed36ee5133 | ||
|
|
c35242731f | ||
|
|
d2d07d0345 | ||
|
|
8b908d6150 | ||
|
|
1017a76b4c | ||
|
|
9c5d76c299 | ||
|
|
83987b8eec | ||
|
|
cbd71699de | ||
|
|
5d58a25e52 | ||
|
|
764d9b5fbe | ||
|
|
5ffea6d30e | ||
|
|
2796210e18 | ||
|
|
1b2c71ac75 | ||
|
|
cc4bf22d07 | ||
|
|
7295d9306f | ||
|
|
2a66b49bc7 | ||
|
|
c944a25585 | ||
|
|
2067836851 | ||
|
|
6f95a071b5 | ||
|
|
a543fd3656 | ||
|
|
d94c4415af | ||
|
|
abbed23c64 | ||
|
|
5de4e5b3c0 | ||
|
|
1e17a55e79 | ||
|
|
465dab2eed | ||
|
|
532e0a77cc | ||
|
|
47835e6b25 | ||
|
|
11c537dc1e | ||
|
|
e4420227b5 | ||
|
|
f9e44fe86e | ||
|
|
b256e211dc | ||
|
|
2a34c7a714 | ||
|
|
04132b51f0 | ||
|
|
908f6ada4e | ||
|
|
e7da213f93 | ||
|
|
e7f789f185 | ||
|
|
f8d381e6c5 | ||
|
|
c29425c0fc | ||
|
|
612f9ee669 | ||
|
|
81a38df814 | ||
|
|
e9eb130731 | ||
|
|
4edaecc04d | ||
|
|
fc8d0a0187 | ||
|
|
96960499ef | ||
|
|
c2687c16b3 | ||
|
|
883944d289 | ||
|
|
a7156a0702 | ||
|
|
dee9d27af8 | ||
|
|
e06f0ee315 | ||
|
|
7522ae14b2 | ||
|
|
e09eee8fd1 | ||
|
|
b692ddd80c | ||
|
|
5ebb389d26 | ||
|
|
fff6f7cf2f | ||
|
|
516fd12b8d |
@@ -7,12 +7,14 @@ description: Use when writing, changing, or reviewing Cucumber and Playwright te
|
||||
|
||||
`e2e/AGENTS.md` owns the suite architecture, lifecycle, commands, tags, generated-client boundaries, fixtures, and cleanup contracts. Read the nearest feature-scoped `AGENTS.md` when one exists. This skill adds no parallel package policy.
|
||||
|
||||
Cucumber owns scenario and hook execution and reporting. Playwright provides browser automation, contexts, pages, locators, actions, assertions, request, and tracing APIs; protocol exceptions remain with the package-defined owner. Their timeout domains remain separate, and importing from `@playwright/test` does not make Playwright Test runner configuration, fixtures, or retries applicable to this suite.
|
||||
|
||||
## Topic Routing
|
||||
|
||||
Read only the bundled reference required by the change:
|
||||
|
||||
- Locator, assertion, isolation, or waiting decisions: [`references/playwright-best-practices.md`][playwright]
|
||||
- Scenario wording, step granularity, expressions, or tag design: [`references/cucumber-best-practices.md`][cucumber]
|
||||
- Scenario wording, step granularity, expressions, World state, hook visibility, or tag design: [`references/cucumber-best-practices.md`][cucumber]
|
||||
|
||||
Check current official Playwright or Cucumber documentation before introducing a framework pattern that local code and references do not already establish.
|
||||
|
||||
@@ -23,7 +25,7 @@ Check current official Playwright or Cucumber documentation before introducing a
|
||||
3. Read the target scenario, matching step definitions, and lifecycle files only when session or shared state matters.
|
||||
4. Reuse an existing step when wording and behavior match; add one coherent scenario or step when they do not.
|
||||
5. Keep browser actions and assertions at the public user boundary; keep setup, seed, polling, and cleanup at their package-defined owners.
|
||||
6. Run the narrowest tagged scenario and package checks documented in `e2e/AGENTS.md`; broaden only for shared hooks, tags, or support changes.
|
||||
6. For changes, run the narrowest tagged scenario and package checks documented in `e2e/AGENTS.md`; broaden only for shared hooks, tags, or support changes.
|
||||
|
||||
For review requests, lead with reproducible correctness failures, flake sources, or demonstrated architecture drift. Report the behavior verified and any external-runtime, browser, or environment gap.
|
||||
|
||||
|
||||
@@ -4,15 +4,18 @@ Use this reference when writing or reviewing Gherkin scenarios, step definitions
|
||||
|
||||
Official sources:
|
||||
|
||||
- https://cucumber.io/docs/guides/10-minute-tutorial
|
||||
- https://cucumber.io/docs/cucumber/step-definitions
|
||||
- https://cucumber.io/docs/bdd/better-gherkin
|
||||
- https://cucumber.io/docs/gherkin/reference
|
||||
- https://cucumber.io/docs/gherkin/step-organization
|
||||
- https://cucumber.io/docs/cucumber/state
|
||||
- https://cucumber.io/docs/cucumber/cucumber-expressions
|
||||
- https://github.com/cucumber/cucumber-js/blob/main/docs/support_files/world.md
|
||||
|
||||
## What Matters Most
|
||||
|
||||
### 1. Treat scenarios as executable specifications
|
||||
|
||||
Cucumber scenarios should describe examples of behavior, not test implementation recipes.
|
||||
Cucumber scenarios should usually describe behavior declaratively, not reproduce an interaction script.
|
||||
|
||||
Apply it like this:
|
||||
|
||||
@@ -20,11 +23,15 @@ Apply it like this:
|
||||
- avoid UI-internal wording such as selector details, DOM structure, or component names
|
||||
- keep language concrete enough that the scenario reads like living documentation
|
||||
|
||||
Procedural detail is appropriate when the interaction mechanism is the behavior under test, such as keyboard navigation, focus movement, or a required multi-actor sequence.
|
||||
|
||||
### 2. Keep scenarios focused
|
||||
|
||||
A scenario should usually prove one workflow or business outcome. If a scenario wanders across several unrelated behaviors, split it.
|
||||
A scenario should prove one business rule or coherent outcome. Cucumber's three-to-five-step recommendation is a review heuristic, not a hard limit: when a scenario is longer, check for multiple outcomes, incidental setup, or UI procedure that can move behind a domain step.
|
||||
|
||||
Keep each scenario centered on one coherent outcome. Avoid hidden dependencies on another scenario's side effects, and keep unavoidable setup outside the behavior narrative unless the precondition matters to the specification.
|
||||
Use `Given` for initial context, `When` for the event, and `Then` for the expected outcome. Restarting those phases is a prompt to review the narrative, not an automatic failure. Avoid hidden dependencies on another scenario's side effects.
|
||||
|
||||
Use `Rule` when several examples illustrate one named business rule. Use a short `Background` only for shared context the reader needs to understand; do not hide fixture preparation, runtime readiness, or long setup there.
|
||||
|
||||
### 3. Reuse steps, but only when behavior really matches
|
||||
|
||||
@@ -45,6 +52,8 @@ Write a new step when:
|
||||
|
||||
Do not optimize for a low step count by making vague steps. Optimize for a small set of truthful, domain-owned steps.
|
||||
|
||||
Step definitions share one global matching namespace regardless of their directory. Organize them by domain capability and avoid feature-coupled glue or expressions broad enough to overlap unrelated behavior.
|
||||
|
||||
### 4. Prefer Cucumber Expressions
|
||||
|
||||
Use Cucumber Expressions for parameters unless regex is clearly necessary.
|
||||
@@ -62,18 +71,24 @@ Use regex for a bounded natural-language alternative only when it keeps Gherkin
|
||||
|
||||
### 5. Keep step definitions thin and meaningful
|
||||
|
||||
Step definitions are glue between Gherkin and automation, not a second abstraction language.
|
||||
Step definitions are glue between Gherkin and automation, not a second abstraction language. A step should express one coherent domain action or outcome; it may call several implementation helpers when that keeps UI mechanics out of the specification.
|
||||
|
||||
Keep each step to one user-visible action or assertion. In JavaScript and TypeScript, use `async function` when the step reads Cucumber World state because Cucumber binds `this`; do not leak state across scenarios through module globals.
|
||||
Cucumber creates a new World for each scenario. Keep scenario state there rather than in module globals. Cucumber.js supports a `world` proxy for arrow functions, but this suite consistently uses typed `async function (this: DifyWorld, ...)` so ownership and TypeScript discovery remain explicit.
|
||||
|
||||
Hooks are invisible to feature readers. Keep them for low-level browser lifecycle, cleanup, and diagnostics; express business-relevant context in Gherkin.
|
||||
|
||||
### 6. Use tags intentionally
|
||||
|
||||
Tags should communicate selection or execution intent, not become ad hoc metadata. A tag does not change runtime behavior unless configuration or hooks implement it.
|
||||
Tags may communicate stable capability grouping, selection, fixture dependency, or conditional-hook intent. A tag changes runtime behavior only when a runner, seed profile, or hook owns that meaning.
|
||||
|
||||
## Review Questions
|
||||
|
||||
- Does the scenario read like a real example of product behavior?
|
||||
- Does it prove one outcome, or combine several independent phases?
|
||||
- Are the steps behavior-oriented instead of implementation-oriented?
|
||||
- Is procedural wording necessary to the behavior being tested?
|
||||
- Is a reused step still truthful in this feature?
|
||||
- Could a new expression overlap an existing step in the global namespace?
|
||||
- Would `Rule` clarify the business rule, or would `Background` hide important setup?
|
||||
- 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?
|
||||
|
||||
@@ -6,8 +6,12 @@ Official sources:
|
||||
|
||||
- https://playwright.dev/docs/best-practices
|
||||
- https://playwright.dev/docs/locators
|
||||
- https://playwright.dev/docs/actionability
|
||||
- https://playwright.dev/docs/test-assertions
|
||||
- https://playwright.dev/docs/test-timeouts
|
||||
- https://playwright.dev/docs/browser-contexts
|
||||
- https://playwright.dev/docs/events
|
||||
- https://playwright.dev/docs/trace-viewer
|
||||
|
||||
## What Matters Most
|
||||
|
||||
@@ -21,28 +25,22 @@ Apply it like this:
|
||||
- keep scenario state in the runner's scenario-owned context rather than module globals
|
||||
- model special authentication or session setup through explicit per-scenario fixtures rather than shared mutable state
|
||||
|
||||
### 2. Prefer user-facing locators
|
||||
### 2. Select locators by user contract
|
||||
|
||||
Playwright recommends built-in locators that reflect what users perceive on the page.
|
||||
Prefer built-in locators that match how the target is exposed to users:
|
||||
|
||||
Preferred order:
|
||||
- interactive controls: role and accessible name
|
||||
- form controls: associated label; use placeholder when it is the relevant stable contract, especially when no label exists
|
||||
- non-interactive content: visible text or the relevant text alternative
|
||||
- elements without a meaningful user-facing contract: an intentional test id
|
||||
|
||||
1. `getByRole`
|
||||
2. `getByLabel`
|
||||
3. `getByPlaceholder`
|
||||
4. `getByText`
|
||||
5. `getByTestId` when an explicit test contract is the most stable option
|
||||
This is a semantic choice, not a fixed ranking. Do not add incorrect roles or accessible names solely to satisfy a locator. When the product element should have user-facing semantics, fix that contract instead.
|
||||
|
||||
Avoid raw CSS/XPath selectors unless no stable user-facing contract exists and adding one is not practical.
|
||||
|
||||
Also remember:
|
||||
Locators are strict for single-element actions. Scope to a stable region or use `filter({ has, hasText })` to make the intended target unique. Treat `.first()`, `.last()`, or `.nth()` as a review signal: the positional choice should be intentional and stable, not merely silence ambiguity.
|
||||
|
||||
- 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
|
||||
- when a real UI region, card, status, or icon lacks an accessible name, prefer adding that semantic contract in product code before falling back to `getByTestId`
|
||||
|
||||
### 3. Use web-first assertions
|
||||
### 3. Use web-first assertions with the right timeout owner
|
||||
|
||||
Playwright assertions auto-wait and retry. Prefer them over manual state inspection.
|
||||
|
||||
@@ -64,6 +62,8 @@ If a condition genuinely needs custom retry logic, use Playwright's polling/asse
|
||||
|
||||
Use `expect.poll` for non-DOM truth such as API state, backend eventual consistency, generated resources, or captured browser events. For DOM state, use locator assertions so Playwright can apply actionability and web-first retry semantics.
|
||||
|
||||
Cucumber step and hook timeouts, Playwright locator/action timeouts, and Playwright assertion timeouts are separate budgets. `browserContext.setDefaultTimeout()` does not change the default five-second assertion timeout. Put an explicit longer assertion timeout only on the readiness owner that needs it; do not raise an outer timeout to mask a shorter inner failure.
|
||||
|
||||
### 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.
|
||||
@@ -77,15 +77,20 @@ Bad pattern:
|
||||
|
||||
- stack arbitrary waits before every action
|
||||
- wait on unstable implementation details instead of the visible state the user cares about
|
||||
- use `force: true` to bypass a real hit-target, overlay, or disabled-state failure
|
||||
|
||||
When synchronizing on a one-shot popup, download, request, or response, create the wait before triggering the action, then await the event. A scenario-owned listener may instead capture events for a later assertion. Do not use `networkidle` as an application-readiness assertion; wait for the user-visible state or owned backend contract.
|
||||
|
||||
### 5. Match debugging to the active harness
|
||||
|
||||
Playwright supports traces, screenshots, page snapshots, and browser logs. Configure artifact capture at the runner boundary instead of adding parallel diagnostics to individual scenarios.
|
||||
Playwright supports traces, screenshots, page snapshots, and browser logs. Configure artifact capture in Cucumber hooks instead of adding parallel diagnostics to individual scenarios. If tracing is introduced, use `browserContext.tracing`; Playwright Test options such as `trace: 'on-first-retry'`, projects, workers, fixtures, reporters, and retries do not configure this harness.
|
||||
|
||||
## Review Questions
|
||||
|
||||
- Would this locator survive DOM refactors that do not change user-visible behavior?
|
||||
- Is a positional locator expressing product order, or hiding an ambiguous match?
|
||||
- Is this assertion using Playwright's retrying semantics?
|
||||
- Is any explicit wait masking a real readiness problem?
|
||||
- Does an explicit timeout belong to the condition that is actually slow?
|
||||
- Was an event wait registered before the action that emits it?
|
||||
- Does this code preserve per-scenario isolation?
|
||||
- Is a new abstraction really needed, or does it bypass the runner's scenario-owned context and lifecycle?
|
||||
|
||||
@@ -27,7 +27,7 @@ Read only the packs matched by the diff:
|
||||
- Stable Dify runtime invariants in the named paths: [`references/dify-invariants.md`][dify-invariants]
|
||||
- General TypeScript or styling quality not owned above: [`references/code-quality.md`][code-quality]
|
||||
|
||||
Read `packages/dify-ui/README.md`, `packages/dify-ui/AGENTS.md`, `web/docs/overlay.md`, or `web/docs/test.md` only when the reviewed code falls under that contract. Check current official documentation when local code and bundled references do not settle a framework, browser, or accessibility behavior.
|
||||
Read `packages/dify-ui/README.md`, `packages/dify-ui/AGENTS.md`, `packages/dify-ui/docs/overlays.md`, or `web/docs/test.md` only when the reviewed code falls under that contract. Check current official documentation when local code and bundled references do not settle a framework, browser, or accessibility behavior.
|
||||
|
||||
## Severity And Output
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ Flag effects that:
|
||||
- Transform props/state for rendering.
|
||||
- Copy one state value into another representing the same concept.
|
||||
- Handle user actions that belong in event handlers.
|
||||
- Reset state from props when a keyed reset, stable ID, or render-time derivation would work.
|
||||
- Reset local state from props or visibility when derivation, a stable semantic identity, or the intended mounted owner already expresses the lifecycle.
|
||||
- Fetch data that belongs in framework APIs or TanStack Query.
|
||||
|
||||
If an effect remains, it must synchronize with a named external system: browser API, subscription, timer, analytics-on-visibility, non-React widget, or imperative DOM integration.
|
||||
@@ -71,11 +71,24 @@ If an effect remains, it must synchronize with a named external system: browser
|
||||
Flag:
|
||||
|
||||
- Storing derived booleans, disabled flags, default tabs, or loading labels that can be calculated from current query/feature state.
|
||||
- Per-session state held by a longer-lived visibility coordinator and cleared through an open-state Effect or a generated key when the primitive's mounted-content lifecycle already matches the intended state lifetime.
|
||||
- A DOM field mirrored into competing prop, default, and React state sources when editing does not require those sources to synchronize.
|
||||
- Local state used to fake server data or generated contract fields.
|
||||
- UI state persisted to localStorage when it is live app state.
|
||||
- Feature-local mock shells wired to unrelated existing APIs before the real API is confirmed.
|
||||
|
||||
Prefer render-time derivation. Keep true local state for user choices, transient input, controlled popups, and feature UI state that has no server source.
|
||||
Review state lifetime before its storage mechanism. For a hidden surface, distinguish the
|
||||
visibility coordinator from mounted content. State private to one mounted session belongs in that
|
||||
content owner; promote it only when the draft must survive that content owner's unmount or another
|
||||
owner coordinates it. A stable semantic identity key may create a new snapshot when the represented
|
||||
identity changes; a generated key is not a routine reset command.
|
||||
|
||||
Prefer render-time derivation. Keep true local state for user choices, transient input, controlled
|
||||
popups, and feature UI state that has no server source. Submit-only DOM fields may remain
|
||||
uncontrolled; use local controlled state when React must own the current value to drive rendering
|
||||
or coordination. Observing change events or tracking a derived fact such as dirty state does not
|
||||
require mirroring the field value. Do not flag controlled state by itself without a concrete
|
||||
competing-source, stale-state, or ownership defect.
|
||||
|
||||
## Navigation
|
||||
|
||||
|
||||
@@ -1,141 +1,29 @@
|
||||
# Dify UI Rules
|
||||
# Dify UI Review Routing
|
||||
|
||||
Use these rules whenever a review touches `packages/dify-ui/` or code consuming `@langgenius/dify-ui/*`.
|
||||
Use this reference when a review touches `packages/dify-ui/` or consumes
|
||||
`@langgenius/dify-ui/*`. It routes to owner documentation; it does not redefine package
|
||||
contracts.
|
||||
|
||||
Before finalizing findings for those files, read the current local docs that apply:
|
||||
Read `packages/dify-ui/AGENTS.md` and the primitive implementation first, then only the matching
|
||||
owner:
|
||||
|
||||
- `packages/dify-ui/README.md`
|
||||
- `packages/dify-ui/AGENTS.md`
|
||||
- `web/docs/overlay.md` for floating UI
|
||||
- `packages/dify-ui/src/<primitive>/index.tsx` for the primitive being changed or consumed
|
||||
| Review area | Canonical owner |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------- |
|
||||
| Package boundary and document routing | `packages/dify-ui/AGENTS.md` |
|
||||
| Imports, exports, public types, and anatomy | `packages/dify-ui/docs/authoring.md` |
|
||||
| Button or icon-only actions | `packages/dify-ui/src/button/README.md`, `packages/dify-ui/src/icon-button/README.md` |
|
||||
| Compound inputs | `packages/dify-ui/src/input-group/README.md` |
|
||||
| Forms and field semantics | `packages/dify-ui/docs/forms.md` |
|
||||
| Selection and typed values | `packages/dify-ui/docs/selection.md` |
|
||||
| Portals, layers, and floating surfaces | `packages/dify-ui/docs/overlays.md` |
|
||||
| Tailwind and radius tokens | `packages/dify-ui/docs/styling.md` |
|
||||
| Package tests and Storybook | `packages/dify-ui/docs/testing.md` |
|
||||
|
||||
## Package Boundary
|
||||
For consumer code under `web/`, also read `web/AGENTS.md` for application-owned reuse policy and
|
||||
`packages/dify-ui/README.md` for the available public subpaths.
|
||||
|
||||
Flag in `packages/dify-ui`:
|
||||
|
||||
- Imports from `web/`.
|
||||
- Dependencies on Next.js, i18n, ky, Jotai, Zustand, TanStack Query, oRPC, or business APIs.
|
||||
- Business-specific component behavior that belongs in `web/`.
|
||||
- Multiple unrelated primitives in one component folder.
|
||||
|
||||
`packages/dify-ui` is a primitive layer: Base UI headless components + `cva` + `cn` + Dify design tokens.
|
||||
|
||||
## Imports And Exports
|
||||
|
||||
Flag:
|
||||
|
||||
- Consumer imports from `@langgenius/dify-ui` without a subpath.
|
||||
- Missing `package.json#exports` entry for a new primitive.
|
||||
- Internal package imports using workspace subpaths instead of relative paths.
|
||||
- Exported props using internal-only types that consumers cannot import from the component subpath.
|
||||
- Canonical primitive boundaries or their associated public types using a redundant `Root` suffix when no higher-level convenience component exists in the same subpath.
|
||||
|
||||
Consumers use subpath exports such as `@langgenius/dify-ui/button`.
|
||||
|
||||
Canonical boundaries use the primitive name and matching public types (`Select` / `SelectProps`). Keep `Root` only to distinguish a low-level anatomy root from a higher-level convenience component (`CheckboxRoot` / `Checkbox`); implementation aliases should still show their Base UI source (`BaseSelect.Root.Props`).
|
||||
|
||||
## Props And State
|
||||
|
||||
Flag:
|
||||
|
||||
- Flattened props where related values need a discriminated union, such as `value` / `defaultValue`, `multiple` / `value`, or `clearable` / `onChange`.
|
||||
- React state used only to mirror Base UI state for class names.
|
||||
- JavaScript conditional class logic for visual states that the Dify UI/Base UI primitive already exposes through `data-*` attributes or CSS variables.
|
||||
- Controlled props added when uncontrolled DOM state or CSS variables would be enough.
|
||||
- Thin wrappers that rename Base UI parts without adding semantics.
|
||||
- Generic Base UI selection primitives wrapped without preserving their value generics, such as `Select.Root<Value, Multiple>`, `RadioGroup<Value>`, or `Radio.Root<Value>`.
|
||||
- Shared select/radio option components that type selected values as `string` while callers pass enums, unions, booleans, numbers, objects, or nullable placeholder values.
|
||||
|
||||
Prefer Base UI/Dify UI data attributes and CSS variables for visual state: `data-open`, `data-checked`, `data-disabled`, `data-highlighted`, `data-popup-open`, `group-data-*`, `peer-data-*`, `has-[:focus-visible]`, and primitive CSS variables such as anchor width or transform origin. Use JS conditional classes for product/business state that the primitive does not expose.
|
||||
|
||||
For non-string `Select` and `RadioGroup` values, prefer explicit domain generics at the root and at child value carriers. JSX children do not inherit the parent generic, so `RadioGroup<PromptMode>` should compose with `Radio<PromptMode>`, `RadioItem<PromptMode>`, or option values from a typed collection. For `Select`, prefer the Base UI `items` collection pattern for typed value-to-label rendering, and flag string coercion helpers used only to recover display labels.
|
||||
|
||||
## Forms
|
||||
|
||||
Flag:
|
||||
|
||||
- Form-like UI using unrelated `Input` and `Button` pieces without a submit boundary.
|
||||
- Text-like fields not composed through `Field`, `FieldLabel`, and `FieldControl` when using Dify UI form semantics.
|
||||
- Select fields using `FieldLabel` instead of `SelectLabel`.
|
||||
- Slider fields using a generic label instead of `SliderLabel`.
|
||||
- Checkbox/radio groups missing `Fieldset` and `FieldsetLegend`.
|
||||
- Field errors or descriptions rendered without `FieldDescription` / `FieldError` relationships.
|
||||
|
||||
`Form` is the submit boundary. Dify UI form primitives are not a form state-management framework; business validation and schema-driven behavior belong in `web/`.
|
||||
|
||||
## Overlay Contract
|
||||
|
||||
Flag:
|
||||
|
||||
- Legacy web overlay imports in new or modified code.
|
||||
- Manual portals around Dify UI overlay primitives.
|
||||
- Call-site `z-*` overrides on overlays.
|
||||
- Missing root `isolation: isolate` assumptions when debugging overlay stacking.
|
||||
- Repeated backdrop, z-index, or portal chrome at call sites.
|
||||
- Tooltip used for infotips, long text, or interactive content.
|
||||
|
||||
All Dify UI body-portalled overlays use `z-50`. Toast uses `z-60`. DOM order handles stacking between overlays.
|
||||
|
||||
## Primitive Selection
|
||||
|
||||
Flag:
|
||||
|
||||
- `Tabs` used for simple mode/filter/view selection where `SegmentedControl` is the semantic primitive.
|
||||
- `SegmentedControl` used where `tablist` / `tabpanel` semantics are required.
|
||||
- `Select` used for searchable or free-form input.
|
||||
- `Combobox` used for unrestricted search text where no selected option is remembered.
|
||||
- `Autocomplete` used for closed-list selection.
|
||||
- Tooltip or PreviewCard used for content that must be reachable on touch or by screen readers.
|
||||
|
||||
Use:
|
||||
|
||||
- `Autocomplete` for free-form text with optional suggestions.
|
||||
- `Combobox` for searchable selected values from a collection.
|
||||
- `Select` for closed, scannable option sets.
|
||||
- `Popover` for infotips, help text, rich content, or interactions.
|
||||
|
||||
## Bad Usage Patterns To Flag
|
||||
|
||||
Flag:
|
||||
|
||||
- Manually recreating UI behavior or chrome already owned by `@langgenius/dify-ui/*` or `web/app/components/base/*`, such as buttons, inputs, toggle groups, popovers, dropdown menus, alert dialogs, switches, avatars, scroll areas, toasts, borders, focus states, disabled states, segmented controls, or existing feature components.
|
||||
- Styling a raw Base UI primitive directly in `web/` when a Dify UI primitive exists.
|
||||
- Wrapping a Dify UI primitive in a feature component that hides its label, error, disabled, or focus contract.
|
||||
- Replacing a semantic primitive with a generic `div` plus classes to match a screenshot.
|
||||
- Using `Tooltip` because it is visually convenient when the content is actually help text or needs touch access.
|
||||
- Adding a `z-*` override to make a child popup appear over a parent dialog.
|
||||
- Adding a new app-level wrapper around Dialog, Drawer, Popover, Select, or Combobox that repeats portal/backdrop/positioner logic.
|
||||
- Using dify-ui `Input` as a drop-in replacement for legacy inputs that include search, clear, copy, unit, localized placeholder, or number normalization behavior.
|
||||
- Building a form row from loose text and controls instead of the matching Field/Form primitives.
|
||||
- Adding component state only to style `data-open`, `data-checked`, `data-disabled`, or highlighted states that Base UI already exposes.
|
||||
- Passing booleans down only so children can toggle classes already expressible with primitive `data-*` selectors.
|
||||
|
||||
## Tokens, Radius, And Styling
|
||||
|
||||
Flag:
|
||||
|
||||
- `radius-*` class names.
|
||||
- Custom Tailwind `borderRadius` extension for Figma radius values.
|
||||
- Generic colors where semantic Dify tokens exist.
|
||||
- Hardcoded design values where Dify tokens, component variants, or documented Figma radius mappings exist.
|
||||
- `!` important modifiers used to fight primitive styles instead of fixing the variant, selector, or component composition.
|
||||
- Manual class strings that duplicate primitive variants.
|
||||
- `min-w-(--anchor-width)` on picker popups when it defeats viewport clamping.
|
||||
|
||||
Use the Figma radius mapping from `packages/dify-ui/README.md`; for example `--radius/sm` maps to `rounded-md`, and `--radius/md` maps to `rounded-lg`.
|
||||
|
||||
Use `!` only for a tightly scoped compatibility override after confirming the primitive API, data attributes, and selector structure cannot express the state.
|
||||
|
||||
## Focus Details
|
||||
|
||||
Flag focus rings attached to the wrong element. For example, Base UI `Slider.Thumb` focuses an internal `input[type=range]`, so the visible thumb wrapper needs `has-[:focus-visible]` rather than direct wrapper `focus-visible`.
|
||||
|
||||
## Custom SVG Icons
|
||||
|
||||
Flag:
|
||||
|
||||
- New generated React icon components or JSON files under `web/app/components/base/icons/src/...` for custom SVG icons.
|
||||
- Custom SVG icons consumed outside the Tailwind `i-custom-*` icon class pipeline.
|
||||
- Generated `packages/iconify-collections/custom-*/icons.json` diffs where unrelated existing icons lost or changed intrinsic `width` or `height`.
|
||||
|
||||
New custom SVG icons belong in `packages/iconify-collections/assets/...`. Regenerate with `pnpm --filter @dify/iconify-collections generate`, validate with `pnpm --filter @dify/iconify-collections check:dimensions`, and consume the generated icon with Tailwind `i-custom-*` classes.
|
||||
Treat the implementation, public types, tests, and stories as evidence for the documented
|
||||
contract. If they disagree, identify the actual owner before reporting a finding. Read current
|
||||
official Base UI documentation and installed type declarations for upstream-derived behavior.
|
||||
Report only a reproducible contract violation or observable defect, not a preference inferred from
|
||||
this routing file.
|
||||
|
||||
@@ -9,6 +9,7 @@ Flag missing coverage when a change alters a reachable contract such as:
|
||||
- User interaction, navigation, form submission, validation, or permissions.
|
||||
- Query or mutation behavior, URL state, persistence, or one-shot signals.
|
||||
- Loading, error, empty, and recovery states that users can encounter.
|
||||
- A hidden surface whose close-and-reopen behavior changes whether in-progress state resets or persists.
|
||||
- Accessibility-critical labels, keyboard flow, focus, disabled state, or overlay behavior.
|
||||
- A regression-prone business rule or bug fix that can be reproduced through a public boundary.
|
||||
|
||||
|
||||
@@ -13,4 +13,4 @@ description: Use when writing or changing Vitest or React Testing Library tests
|
||||
4. Run the focused spec before the affected suite and relevant static checks.
|
||||
5. Report the behavior verified and any remaining browser, visual, or end-to-end risk.
|
||||
|
||||
Recommend deleting low-value tests as readily as adding missing behavior coverage. Use `web/docs/test.md` for policy and Web commands; use the `packages/dify-ui/README.md` Development section for Dify UI commands.
|
||||
Recommend deleting low-value tests as readily as adding missing behavior coverage. Use `web/docs/test.md` for policy and Web commands; use `packages/dify-ui/docs/testing.md` for Dify UI commands.
|
||||
|
||||
@@ -9,10 +9,11 @@ Use this skill to route component architecture decisions to its bundled referenc
|
||||
|
||||
## First Decisions
|
||||
|
||||
| Question | Default | Promote only when |
|
||||
| Question | Default | Choose differently when |
|
||||
| --- | --- | --- |
|
||||
| Where should code live? | In the product workflow, route, or feature owner. | Several verticals need the same stable contract. |
|
||||
| Who owns state and handlers? | The lowest visual owner that consumes them. | A parent coordinates one workflow or consistent snapshot. |
|
||||
| Who owns state and handlers? | The lowest owner that consumes them and whose lifetime matches the state. | Another owner coordinates the value or it must survive the local owner's unmount. |
|
||||
| Should React control a value? | Leave submit-only DOM fields uncontrolled. | The workflow must own the current value to drive rendering or coordination. |
|
||||
| Should state enter Jotai? | Keep component and form state local. | Siblings need one source of truth or scoped workflow persistence. |
|
||||
| Who owns URL state? | Next.js route APIs and `nuqs`. | Atoms require a read-only route-identity bridge. |
|
||||
| Who owns remote state? | TanStack Query at the lowest consumer. | Atom state drives the query or shared derivations consume it. |
|
||||
@@ -24,15 +25,15 @@ Use this skill to route component architecture decisions to its bundled referenc
|
||||
- Component moves, module boundaries, props, types, or owner placement: read [`references/ownership.md`][ownership].
|
||||
- Jotai, form drafts, route identity, URL state, or persistence: read [`references/state.md`][state].
|
||||
- Generated contracts, nullable API data, Query, mutations, SSR, auth, or workspace state: read [`references/data.md`][data].
|
||||
- Hotkeys, focus, dialogs, menus, popovers, or other secondary surfaces: read [`references/interactions.md`][interactions] and the overlay guide it references when applicable.
|
||||
- Hotkeys, focus, dialogs, menus, popovers, or other secondary surfaces: read [`references/interactions.md`][interactions] and the overlay guide it references when applicable. Also read [`references/state.md`][state] when the surface owns a draft or other local session state.
|
||||
- Effects, navigation, memoization, preloading, or render cost: read [`references/runtime.md`][runtime].
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Identify the behavior owner and the public contract being changed.
|
||||
1. Identify the behavior owner, the required state lifetime, and the public contract being changed.
|
||||
2. Read the nearby implementation, tests, and only the routed skill references.
|
||||
3. Implement one coherent vertical slice. Do not expand into equivalent patterns elsewhere unless the current contract cannot be completed without them.
|
||||
4. Verify observable behavior at the narrowest sufficient boundary, then run the checks documented by the owning package: `web/docs/test.md` or `web/docs/lint.md` for Web, and the `packages/dify-ui/README.md` Development section for Dify UI.
|
||||
4. Verify observable behavior at the narrowest sufficient boundary, then run the checks documented by the owning package: `web/docs/test.md` or `web/docs/lint.md` for Web, and `packages/dify-ui/docs/testing.md` for Dify UI.
|
||||
|
||||
[data]: references/data.md
|
||||
[interactions]: references/interactions.md
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Component Interactions And Overlays
|
||||
|
||||
Read this document when a change involves application hotkeys, focus, dialogs, menus, popovers, or other secondary surfaces. Overlay primitive selection and layering are owned by the [overlay guide].
|
||||
Read this document when a change involves application hotkeys, focus, dialogs, menus, popovers, or other secondary surfaces. Overlay primitive selection and layering are owned by the [overlay contract].
|
||||
|
||||
## Focus And Semantics
|
||||
|
||||
@@ -20,12 +20,13 @@ Read this document when a change involves application hotkeys, focus, dialogs, m
|
||||
|
||||
## Secondary Surfaces
|
||||
|
||||
- Follow `web/docs/overlay.md` for primitive choice. Dify UI primitives are the default, with package-approved Web wrappers such as `Infotip` where the overlay guide allows them.
|
||||
- Follow the [overlay contract] for primitive choice and shared mechanics. The nearest consumer `AGENTS.md` owns application-specific composite reuse policy.
|
||||
- Separate behavior ownership from placement ownership: the action may own trigger, open state, and menu content while the caller owns slots, offsets, and alignment.
|
||||
- Keep menu and dialog surfaces as siblings when a menu command opens a dialog. Mount the dialog outside popup content.
|
||||
- Mount controlled overlays unconditionally unless unmounting is required for performance or reset semantics. Prefer keyed or owner-local reset over conditional wrappers.
|
||||
- Put query and mutation work inside dialog or alert-dialog content when it should mount only after opening.
|
||||
- Prefer uncontrolled roots when the primitive can own open state. Use controlled state only for business coordination, analytics, cleanup, or explicit reset behavior.
|
||||
- Do not add manual portals or call-site z-index escalation. Fix ownership and stacking structure at the shared boundary.
|
||||
- Keep overlay open-state ownership separate from content-session ownership. A controlled root does not require controlled fields or root-owned drafts.
|
||||
- Match transient state to the primitive's content mount lifecycle. State below an unmounting content boundary gets a fresh instance after unmount; intentionally kept-mounted content needs an explicit persistence or reset policy.
|
||||
- Keep a controlled overlay root at its coordination owner so the primitive can complete exit transitions, focus restoration, and detached-handle behavior. Do not conditionally remove the root to reset content state, and use keys only for stable semantic identity.
|
||||
- Place query subscriptions and mutation observers at the owner whose lifetime matches when they should run. Mounted-session work may belong inside content; work that must start or stop exactly with `open` needs an explicit open-state condition.
|
||||
- Prefer primitive-owned open state unless another owner must observe or coordinate it. Analytics callbacks and local cleanup alone do not require a controlled root.
|
||||
|
||||
[overlay guide]: ../../../../web/docs/overlay.md
|
||||
[overlay contract]: ../../../../packages/dify-ui/docs/overlays.md
|
||||
|
||||
@@ -12,8 +12,8 @@ Read this document when adding, moving, splitting, or refactoring React componen
|
||||
|
||||
## Component Ownership
|
||||
|
||||
- Put state, data access, loading, empty, error, and handlers in the lowest visual owner that uses them.
|
||||
- Keep coordination in a parent only when it needs one consistent snapshot or coordinates submission, shared selection, batch behavior, navigation, or cross-section loading and errors.
|
||||
- Put state, data access, loading, empty, error, and handlers in the lowest owner that uses them and whose mounted lifetime matches the required persistence.
|
||||
- Keep coordination in a parent only when it needs one consistent snapshot, the value must intentionally survive the local owner's unmount, or the parent coordinates submission, shared selection, batch behavior, navigation, or cross-section loading and errors.
|
||||
- Repeated TanStack Query calls in siblings are acceptable when each sibling independently consumes the data; the cache already deduplicates requests.
|
||||
- Pass stable domain identity across boundaries. Do not pass raw server data together with separately derived flags for the same concept.
|
||||
- One pass-through prop layer is acceptable. Repeated forwarding means ownership should move closer to the consumer or into feature-scoped shared state.
|
||||
@@ -23,7 +23,8 @@ Read this document when adding, moving, splitting, or refactoring React componen
|
||||
## Boundaries
|
||||
|
||||
- State-heavy wizards, drawers, modals, and secondary workflows can form a small vertical surface with an entrypoint, optional feature-local state, and shallow owners matching real visual regions.
|
||||
- The entrypoint owns route integration, provider wiring, close behavior, and mounting. Composition owners handle workflow branches; the closest visual owner handles section branches.
|
||||
- The entrypoint owns route integration, provider wiring, placement, and open-state coordination. A content or session owner keeps state scoped to that mounted surface.
|
||||
- Judge hook lifetime by the component that declares the hook and the primitive's mount contract, not only by where its rendered controls appear in JSX.
|
||||
- Separate hidden dialogs, dropdowns, and popovers into small local owners when their content obscures the parent flow.
|
||||
- Keep cohesive forms, menu bodies, and one-off helpers local unless they have their own state, reuse, or semantic boundary.
|
||||
- Avoid wrapper components and wrapper DOM that only rename props, pass children through, or hide the real primitive. A wrapper must own behavior, validation, state, accessibility, layout, or library integration.
|
||||
|
||||
@@ -4,9 +4,10 @@ Read this document when a change introduces Effects, navigation side effects, me
|
||||
|
||||
## Effects
|
||||
|
||||
- Keep render pure: do not read or write `ref.current` during render except for predictable null-guarded lazy initialization. Update interaction-owned refs in event handlers, synchronize external-system refs after commit, and use state or derivation for rendered values.
|
||||
- Use Effects only to synchronize with a named external system such as a browser API, subscription, timer, analytics integration, non-React widget, or imperative DOM API.
|
||||
- Do not use Effects to transform render state, handle user actions, copy query data, reset state from props, or fetch data owned by framework APIs or TanStack Query.
|
||||
- Initialize query-backed forms with keyed remounts or surface-entry hydration instead of copying data through Effects.
|
||||
- Initialize query-backed form sessions after their defaults are available instead of copying data through Effects. Use a stable semantic identity key when the represented identity changes; use the intended surface lifecycle for per-session reset.
|
||||
|
||||
## Navigation
|
||||
|
||||
|
||||
@@ -9,10 +9,12 @@ Read this document when a change involves Jotai, form drafts, route identity, sh
|
||||
- Keep server and cache state in TanStack Query. Use existing feature stores for complex, high-frequency interaction state such as workflow canvas drag, resize, and runtime panels.
|
||||
- Use feature-owned storage only for low-frequency client preferences, dismissed notices, and UI defaults. Live application state does not belong in local storage.
|
||||
|
||||
## Forms
|
||||
## Forms And Sessions
|
||||
|
||||
- Prefer uncontrolled Dify UI form and field controls when values are only read at submit time. Initialize query-backed defaults with `defaultValue` and keyed remounts.
|
||||
- Promote form values to atoms only when another owner reacts to in-progress values, the draft must survive scoped unmounting, or several workflow steps edit the same draft.
|
||||
- Keep form state in the narrowest owner whose lifetime matches the draft. A draft scoped to one mounted surface belongs to that content or session owner; a draft that must survive its current owner's unmount belongs to an explicit longer-lived feature owner.
|
||||
- Prefer uncontrolled fields when values are only read at submit time. Use local controlled state only when React must own the current value to drive dependent UI or linked fields; track derived facts such as dirty state without mirroring the field value. Controlledness does not decide whether a draft is local or persisted.
|
||||
- For query-backed defaults, establish the form session after the required defaults are available. `defaultValue` initializes the current mount; a stable semantic identity key may create a fresh snapshot when the represented identity changes. Do not use a generated key as a routine reset command.
|
||||
- Promote drafts beyond the session only when another owner reacts to in-progress values, several workflow steps share one draft, or the draft must intentionally survive unmounting. Start with the lowest shared React owner; use feature-scoped atoms only when their coordination or persistence contract is needed.
|
||||
- Keep validation, source priority, fallback behavior, dirty checks, and payload assembly in the workflow that owns submission.
|
||||
|
||||
## Route And URL State
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"nodeGypDependencies": true,
|
||||
"version": "lts"
|
||||
"version": "24.20.0"
|
||||
},
|
||||
"ghcr.io/devcontainers-extra/features/npm-package:1": {
|
||||
"package": "typescript",
|
||||
@@ -46,4 +46,4 @@
|
||||
// Configure tool-specific properties.
|
||||
// "customizations": {},
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,3 +37,7 @@ indent_size = 2
|
||||
[package.json]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
# Oxlint writes its generated suppression baseline without a final newline.
|
||||
[oxlint-suppressions.json]
|
||||
insert_final_newline = false
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
have_fun: false
|
||||
memory_config:
|
||||
disabled: false
|
||||
code_review:
|
||||
disable: true
|
||||
comment_severity_threshold: MEDIUM
|
||||
max_review_comments: -1
|
||||
pull_request_opened:
|
||||
help: false
|
||||
summary: false
|
||||
code_review: false
|
||||
include_drafts: false
|
||||
ignore_patterns: []
|
||||
7
.github/CODEOWNERS
vendored
7
.github/CODEOWNERS
vendored
@@ -28,6 +28,7 @@
|
||||
|
||||
# E2E
|
||||
/e2e/ @lyzno1
|
||||
/.github/workflows/web-e2e.yml @lyzno1
|
||||
|
||||
# Backend (default owner, more specific rules below will override)
|
||||
/api/ @QuantumGhost
|
||||
@@ -245,7 +246,7 @@
|
||||
|
||||
# Frontend - Billing and Education
|
||||
/web/app/components/billing/ @iamjoel @zxhlyh
|
||||
/web/app/education-apply/ @iamjoel @zxhlyh
|
||||
/web/app/education/ @iamjoel @zxhlyh
|
||||
|
||||
# Frontend - Workspace
|
||||
/web/app/components/header/account-dropdown/workplace-selector/ @iamjoel @zxhlyh
|
||||
@@ -267,5 +268,9 @@
|
||||
/web/app/auth/refresh/route.ts @iamjoel @lyzno1
|
||||
/web/service/server.ts @iamjoel @lyzno1
|
||||
|
||||
# Frontend - Browser Mode Tests
|
||||
/web/app/**/*.browser.spec.ts @lyzno1
|
||||
/web/app/**/*.browser.spec.tsx @lyzno1
|
||||
|
||||
# Docker
|
||||
/docker/* @laipz8200
|
||||
|
||||
4
.github/actions/setup-web/action.yml
vendored
4
.github/actions/setup-web/action.yml
vendored
@@ -9,8 +9,8 @@ runs:
|
||||
with:
|
||||
run_install: false
|
||||
- name: Setup Vite+
|
||||
uses: voidzero-dev/setup-vp@143f5f385f39b1b753ffed1a01ad443811855c8b # v1.16.1
|
||||
uses: voidzero-dev/setup-vp@1b32467adbe183473499fd9d5d372c3ed9641754 # v1.18.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
node-version-file: package.json
|
||||
cache: true
|
||||
run-install: true
|
||||
|
||||
1
.github/labeler.yml
vendored
1
.github/labeler.yml
vendored
@@ -6,7 +6,6 @@ web:
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- '.nvmrc'
|
||||
|
||||
e2e:
|
||||
- changed-files:
|
||||
|
||||
2
.github/linters/.yaml-lint.yml
vendored
2
.github/linters/.yaml-lint.yml
vendored
@@ -4,6 +4,8 @@ extends: default
|
||||
rules:
|
||||
brackets:
|
||||
max-spaces-inside: 1
|
||||
comments:
|
||||
min-spaces-from-content: 1
|
||||
comments-indentation: disable
|
||||
document-start: disable
|
||||
line-length: disable
|
||||
|
||||
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -21,4 +21,4 @@
|
||||
- [ ] I understand that this PR may be closed in case there was no previous discussion or issues. (This doesn't apply to typos!)
|
||||
- [ ] 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 updated the documentation accordingly.
|
||||
- [ ] I ran `make lint && make type-check` (backend) and `cd web && pnpm exec vp staged` (frontend) to appease the lint gods
|
||||
- [ ] I ran `make lint && make type-check` (backend) and `vp staged` (frontend) to appease the lint gods
|
||||
|
||||
4
.github/workflows/accessibility-e2e.yml
vendored
4
.github/workflows/accessibility-e2e.yml
vendored
@@ -36,7 +36,7 @@ concurrency:
|
||||
jobs:
|
||||
accessibility:
|
||||
name: WCAG Level ${{ inputs.level == 'a' && 'A' || 'AA' }} · ${{ inputs.page }}
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
defaults:
|
||||
run:
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
|
||||
6
.github/workflows/api-tests.yml
vendored
6
.github/workflows/api-tests.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -145,7 +145,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
|
||||
46
.github/workflows/autofix.yml
vendored
46
.github/workflows/autofix.yml
vendored
@@ -5,8 +5,6 @@ on:
|
||||
merge_group:
|
||||
branches: ['main']
|
||||
types: [checks_requested]
|
||||
push:
|
||||
branches: ['main']
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -32,28 +30,6 @@ jobs:
|
||||
docker/.env.example
|
||||
docker/docker-compose-template.yaml
|
||||
docker/docker-compose.yaml
|
||||
- name: Check web inputs
|
||||
if: github.event_name != 'merge_group'
|
||||
id: web-changes
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: |
|
||||
web/**
|
||||
cli/**
|
||||
e2e/**
|
||||
packages/**
|
||||
sdks/nodejs-client/**
|
||||
package.json
|
||||
pnpm-lock.yaml
|
||||
pnpm-workspace.yaml
|
||||
.nvmrc
|
||||
vite.config.ts
|
||||
lint.config.ts
|
||||
eslint.config.mjs
|
||||
oxlint-suppressions.json
|
||||
eslint-suppressions.json
|
||||
.vscode/**
|
||||
.github/**
|
||||
- name: Check api inputs
|
||||
if: github.event_name != 'merge_group'
|
||||
id: api-changes
|
||||
@@ -82,13 +58,13 @@ jobs:
|
||||
dify-agent/**/*.py
|
||||
dify-agent/pyproject.toml
|
||||
dify-agent/uv.lock
|
||||
- if: github.event_name != 'merge_group'
|
||||
- if: github.event_name != 'merge_group' && (steps.docker-compose-changes.outputs.any_changed == 'true' || steps.api-changes.outputs.any_changed == 'true' || steps.frontend-contract-changes.outputs.any_changed == 'true' || steps.dify-agent-changes.outputs.any_changed == 'true')
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- if: github.event_name != 'merge_group'
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
- if: github.event_name != 'merge_group' && (steps.api-changes.outputs.any_changed == 'true' || steps.frontend-contract-changes.outputs.any_changed == 'true' || steps.dify-agent-changes.outputs.any_changed == 'true')
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
|
||||
- name: Generate Docker Compose
|
||||
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'
|
||||
@@ -118,12 +94,6 @@ jobs:
|
||||
# Format code
|
||||
uv run ruff format .
|
||||
|
||||
- name: count migration progress
|
||||
if: github.event_name != 'merge_group' && steps.api-changes.outputs.any_changed == 'true'
|
||||
run: |
|
||||
cd api
|
||||
./cnt_base.sh
|
||||
|
||||
- name: ast-grep
|
||||
if: github.event_name != 'merge_group' && steps.api-changes.outputs.any_changed == 'true'
|
||||
run: |
|
||||
@@ -155,7 +125,7 @@ jobs:
|
||||
find . -name "*.py.bak" -type f -delete
|
||||
|
||||
- name: Setup web environment
|
||||
if: github.event_name != 'merge_group'
|
||||
if: github.event_name != 'merge_group' && steps.frontend-contract-changes.outputs.any_changed == 'true'
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Generate API docs
|
||||
@@ -168,13 +138,5 @@ jobs:
|
||||
if: github.event_name != 'merge_group' && steps.frontend-contract-changes.outputs.any_changed == 'true'
|
||||
run: pnpm --dir packages/contracts gen-api-contract
|
||||
|
||||
- name: ESLint fallback autofix
|
||||
if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true'
|
||||
run: pnpm lint:eslint:fix --quiet || true
|
||||
|
||||
- name: Vite+ static autofix
|
||||
if: github.event_name != 'merge_group' && (steps.web-changes.outputs.any_changed == 'true' || steps.frontend-contract-changes.outputs.any_changed == 'true')
|
||||
run: vp check --fix || true
|
||||
|
||||
- if: github.event_name != 'merge_group'
|
||||
uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a # v1.3.4
|
||||
|
||||
33
.github/workflows/build-push.yml
vendored
33
.github/workflows/build-push.yml
vendored
@@ -103,7 +103,7 @@ jobs:
|
||||
password: ${{ env.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
|
||||
uses: depot/setup-action@91bc8495a33ebfc504ffc89e5674379ccf23c29c # v1.7.2
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
@@ -160,7 +160,7 @@ jobs:
|
||||
file: 'docker/Dockerfile'
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Validate Docker image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
@@ -229,3 +229,32 @@ jobs:
|
||||
IMAGE_VERSION: ${{ steps.meta.outputs.version }}
|
||||
run: |
|
||||
docker buildx imagetools inspect "$IMAGE_NAME:$IMAGE_VERSION"
|
||||
|
||||
sync-e2b-template:
|
||||
needs: create-manifest
|
||||
runs-on: ubuntu-24.04
|
||||
if: github.repository == 'langgenius/dify' && startsWith(github.ref, 'refs/tags/')
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- project: dev
|
||||
api_key_secret: E2B_API_KEY_DEV
|
||||
- project: prod
|
||||
api_key_secret: E2B_API_KEY_PROD
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ env.DOCKERHUB_USER }}
|
||||
password: ${{ env.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Sync E2B Template (${{ matrix.project }})
|
||||
env:
|
||||
E2B_API_KEY: ${{ secrets[matrix.api_key_secret] }}
|
||||
run: ./dify-agent-runtime/docker/sync-e2b-template.sh
|
||||
|
||||
45
.github/workflows/cli-e2e.yml
vendored
45
.github/workflows/cli-e2e.yml
vendored
@@ -112,7 +112,7 @@ jobs:
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
suite-framework-output-error:
|
||||
name: 'Suite: framework + output + error-handling'
|
||||
if: ${{ inputs.suite_framework_output_error != 'false' }}
|
||||
if: ${{ inputs.suite_framework_output_error }}
|
||||
needs: provision
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
@@ -129,9 +129,12 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-web
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with: { bun-version: latest }
|
||||
with:
|
||||
bun-version: latest
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with: { package_json_field: packageManager, run_install: false }
|
||||
with:
|
||||
package_json_field: packageManager
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
|
||||
@@ -159,7 +162,7 @@ jobs:
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
suite-discovery:
|
||||
name: 'Suite: discovery'
|
||||
if: ${{ inputs.suite_discovery != 'false' }}
|
||||
if: ${{ inputs.suite_discovery }}
|
||||
needs: provision
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
@@ -176,9 +179,12 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-web
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with: { bun-version: latest }
|
||||
with:
|
||||
bun-version: latest
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with: { package_json_field: packageManager, run_install: false }
|
||||
with:
|
||||
package_json_field: packageManager
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
|
||||
@@ -207,7 +213,7 @@ jobs:
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
suite-run:
|
||||
name: 'Suite: run / ${{ matrix.name }}'
|
||||
if: ${{ inputs.suite_run != 'false' }}
|
||||
if: ${{ inputs.suite_run }}
|
||||
needs: provision
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
@@ -239,9 +245,12 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-web
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with: { bun-version: latest }
|
||||
with:
|
||||
bun-version: latest
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with: { package_json_field: packageManager, run_install: false }
|
||||
with:
|
||||
package_json_field: packageManager
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
|
||||
@@ -284,7 +293,7 @@ jobs:
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
suite-auth-safe:
|
||||
name: 'Suite: auth (login / status / whoami)'
|
||||
if: ${{ inputs.suite_auth != 'false' }}
|
||||
if: ${{ inputs.suite_auth }}
|
||||
needs: provision
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
@@ -301,9 +310,12 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-web
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with: { bun-version: latest }
|
||||
with:
|
||||
bun-version: latest
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with: { package_json_field: packageManager, run_install: false }
|
||||
with:
|
||||
package_json_field: packageManager
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
|
||||
@@ -333,7 +345,7 @@ jobs:
|
||||
suite-last:
|
||||
name: 'Suite: auth-use + devices + logout + agent (last, serial)'
|
||||
# Runs when auth is selected; also runs after all parallel jobs finish
|
||||
if: ${{ inputs.suite_auth != 'false' || inputs.suite_agent != 'false' }}
|
||||
if: ${{ inputs.suite_auth || inputs.suite_agent }}
|
||||
needs:
|
||||
- provision
|
||||
- suite-framework-output-error
|
||||
@@ -357,9 +369,12 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-web
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with: { bun-version: latest }
|
||||
with:
|
||||
bun-version: latest
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with: { package_json_field: packageManager, run_install: false }
|
||||
with:
|
||||
package_json_field: packageManager
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
|
||||
|
||||
42
.github/workflows/cli-release.yml
vendored
42
.github/workflows/cli-release.yml
vendored
@@ -7,12 +7,22 @@ on:
|
||||
description: Dify release tag to attach difyctl assets to (blank = latest stable)
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: Build and checksum only — skip asset upload and stale-asset prune
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
workflow_call:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Dify release tag to attach difyctl assets to (blank = latest stable)
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: Build and checksum only — skip asset upload and stale-asset prune
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
release:
|
||||
types: [released]
|
||||
|
||||
@@ -39,11 +49,8 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Export manifest to env
|
||||
run: node scripts/release-naming.mjs github-env >> "$GITHUB_ENV"
|
||||
|
||||
- name: Validate manifest
|
||||
run: scripts/release-validate-manifest.sh
|
||||
run: node scripts/release-naming.mjs validate
|
||||
|
||||
- name: Resolve target Dify release
|
||||
id: resolve
|
||||
@@ -75,15 +82,6 @@ jobs:
|
||||
DIFY_TAG: ${{ steps.resolve.outputs.dify_tag }}
|
||||
run: node scripts/release-naming.mjs compat-check "$DIFY_TAG"
|
||||
|
||||
- name: Reject duplicate difyctl version
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${difyctlTag}" >/dev/null 2>&1; then
|
||||
echo "::error::difyctl ${version} already released (tag ${difyctlTag} exists); bump cli/package.json version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
release:
|
||||
name: build + attach standalone binaries (all targets)
|
||||
needs: validate
|
||||
@@ -120,6 +118,7 @@ jobs:
|
||||
|
||||
- name: Compile standalone binaries (all targets)
|
||||
run: |
|
||||
CLI_VERSION="$version" \
|
||||
DIFYCTL_COMMIT="$(git rev-parse HEAD)" \
|
||||
DIFYCTL_BUILD_DATE="$(git log -1 --format=%cI HEAD)" \
|
||||
pnpm build:bin
|
||||
@@ -128,6 +127,7 @@ jobs:
|
||||
run: scripts/release-write-checksums.sh
|
||||
|
||||
- name: Attach difyctl assets to Dify release
|
||||
if: ${{ !inputs.dry_run }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
@@ -135,6 +135,7 @@ jobs:
|
||||
--repo "$GITHUB_REPOSITORY" --clobber
|
||||
|
||||
- name: Prune stale difyctl assets
|
||||
if: ${{ !inputs.dry_run }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
@@ -149,18 +150,3 @@ jobs:
|
||||
--repo "$GITHUB_REPOSITORY" --yes
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Create provenance tag
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
ref="refs/tags/${difyctlTag}"
|
||||
sha="$(git rev-parse HEAD)"
|
||||
status="$(gh api -X POST "repos/${GITHUB_REPOSITORY}/git/refs" \
|
||||
-f ref="$ref" -f sha="$sha" --silent --include 2>/dev/null \
|
||||
| awk 'NR==1 {print $2; exit}' || true)"
|
||||
case "$status" in
|
||||
201) echo "::notice::created ${ref}" ;;
|
||||
422) echo "::notice::tag ${ref} already exists; skipping (immutable)" ;;
|
||||
*) echo "::error::provenance tag ${ref} not created (HTTP ${status:-unknown})"; exit 1 ;;
|
||||
esac
|
||||
|
||||
2
.github/workflows/cli-tests.yml
vendored
2
.github/workflows/cli-tests.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
||||
|
||||
- name: Validate release manifest
|
||||
if: matrix.os == 'depot-ubuntu-24.04'
|
||||
run: scripts/release-validate-manifest.sh
|
||||
run: node scripts/release-naming.mjs validate
|
||||
|
||||
- name: CI pipeline (tree, coverage, build)
|
||||
run: pnpm run ci
|
||||
|
||||
8
.github/workflows/db-migration-test.yml
vendored
8
.github/workflows/db-migration-test.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
cp envs/middleware.env.example middleware.env
|
||||
|
||||
- name: Set up Middlewares
|
||||
uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0
|
||||
uses: hoverkraft-tech/compose-action@ee6af68587292d72db67743171809c19787df4c9 # v3.1.0
|
||||
with:
|
||||
compose-file: |
|
||||
docker/docker-compose.middleware.yaml
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
sed -i 's/DB_USERNAME=postgres/DB_USERNAME=mysql/' middleware.env
|
||||
|
||||
- name: Set up Middlewares
|
||||
uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0
|
||||
uses: hoverkraft-tech/compose-action@ee6af68587292d72db67743171809c19787df4c9 # v3.1.0
|
||||
with:
|
||||
compose-file: |
|
||||
docker/docker-compose.middleware.yaml
|
||||
|
||||
25
.github/workflows/deploy-hitl-im-dev.yml
vendored
Normal file
25
.github/workflows/deploy-hitl-im-dev.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: Deploy HITL IM Dev
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['Build and Push API & Web']
|
||||
branches:
|
||||
- 'build/feat/hitl-im-dev'
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: depot-ubuntu-24.04
|
||||
if: |
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_branch == 'build/feat/hitl-im-dev'
|
||||
steps:
|
||||
- name: Deploy to server
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
with:
|
||||
host: ${{ secrets.HITL_SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
script: |
|
||||
${{ vars.SSH_SCRIPT || secrets.SSH_SCRIPT }}
|
||||
47
.github/workflows/dify-agent-tests.yml
vendored
Normal file
47
.github/workflows/dify-agent-tests.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
name: Dify Agent Tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: dify-agent-tests-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
dify-agent-unit:
|
||||
name: Dify Agent Unit Tests
|
||||
runs-on: depot-ubuntu-24.04
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: dify-agent
|
||||
strategy:
|
||||
matrix:
|
||||
python-version:
|
||||
- '3.12'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache-dependency-glob: dify-agent/uv.lock
|
||||
|
||||
- name: Check UV lockfile
|
||||
run: uv lock --check
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra server --dev
|
||||
|
||||
- name: Run unit tests
|
||||
run: uv run pytest
|
||||
4
.github/workflows/docker-build.yml
vendored
4
.github/workflows/docker-build.yml
vendored
@@ -65,7 +65,7 @@ jobs:
|
||||
file: 'docker/Dockerfile'
|
||||
steps:
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
|
||||
uses: depot/setup-action@91bc8495a33ebfc504ffc89e5674379ccf23c29c # v1.7.2
|
||||
|
||||
- name: Build Docker Image
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
file: 'docker/Dockerfile'
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
|
||||
75
.github/workflows/main-ci.yml
vendored
75
.github/workflows/main-ci.yml
vendored
@@ -15,8 +15,10 @@ permissions:
|
||||
statuses: write
|
||||
|
||||
concurrency:
|
||||
group: main-ci-${{ github.event.pull_request.number || github.event.merge_group.head_sha || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
# Pull request deduplication is owned by skip-duplicate-actions so pre_job can
|
||||
# publish should_skip before required status finalizers run.
|
||||
group: main-ci-${{ github.event.merge_group.head_sha || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'merge_group' }}
|
||||
|
||||
jobs:
|
||||
pre_job:
|
||||
@@ -46,6 +48,7 @@ jobs:
|
||||
vdb-changed: ${{ steps.changes.outputs.vdb }}
|
||||
migration-changed: ${{ steps.changes.outputs.migration }}
|
||||
sandbox-runtime-changed: ${{ steps.changes.outputs.sandbox-runtime }}
|
||||
dify-agent-changed: ${{ steps.changes.outputs.dify-agent }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
|
||||
@@ -79,7 +82,6 @@ jobs:
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'lint.config.ts'
|
||||
- '.npmrc'
|
||||
- '.nvmrc'
|
||||
- '.github/workflows/cli-tests.yml'
|
||||
- '.github/actions/setup-web/**'
|
||||
web:
|
||||
@@ -88,7 +90,6 @@ jobs:
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- '.nvmrc'
|
||||
- '.github/workflows/main-ci.yml'
|
||||
- '.github/workflows/web-tests.yml'
|
||||
- '.github/actions/setup-web/**'
|
||||
@@ -102,7 +103,6 @@ jobs:
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- '.nvmrc'
|
||||
- 'docker/docker-compose.middleware.yaml'
|
||||
- 'docker/envs/middleware.env.example'
|
||||
- '.github/workflows/web-e2e.yml'
|
||||
@@ -136,6 +136,9 @@ jobs:
|
||||
sandbox-runtime:
|
||||
- 'dify-agent-runtime/**'
|
||||
- '.github/workflows/sandbox-runtime-tests.yml'
|
||||
dify-agent:
|
||||
- 'dify-agent/**'
|
||||
- '.github/workflows/dify-agent-tests.yml'
|
||||
migration:
|
||||
- 'api/migrations/**'
|
||||
- 'api/.env.example'
|
||||
@@ -394,10 +397,10 @@ jobs:
|
||||
style-check:
|
||||
name: Style Check
|
||||
needs: pre_job
|
||||
if: needs.pre_job.outputs.should_skip != 'true'
|
||||
uses: ./.github/workflows/style.yml
|
||||
with:
|
||||
base-rev: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}
|
||||
skip-checks: ${{ needs.pre_job.outputs.should_skip == 'true' }}
|
||||
|
||||
vdb-tests-run:
|
||||
name: Run VDB Tests
|
||||
@@ -576,3 +579,63 @@ jobs:
|
||||
|
||||
echo "Sandbox runtime tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
dify-agent-tests-run:
|
||||
name: Run Dify Agent Tests
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.dify-agent-changed == 'true'
|
||||
uses: ./.github/workflows/dify-agent-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
dify-agent-tests-skip:
|
||||
name: Skip Dify Agent Tests
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.dify-agent-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped dify agent tests
|
||||
run: echo "No dify-agent-related changes detected; skipping dify agent tests."
|
||||
|
||||
dify-agent-tests:
|
||||
name: Dify Agent Tests
|
||||
if: ${{ always() }}
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
- dify-agent-tests-run
|
||||
- dify-agent-tests-skip
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize Dify Agent Tests status
|
||||
env:
|
||||
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||
TESTS_CHANGED: ${{ needs.check-changes.outputs.dify-agent-changed }}
|
||||
RUN_RESULT: ${{ needs.dify-agent-tests-run.result }}
|
||||
SKIP_RESULT: ${{ needs.dify-agent-tests-skip.result }}
|
||||
run: |
|
||||
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||
echo "Dify agent 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 "Dify agent tests ran successfully."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Dify agent tests were required but finished with result: $RUN_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||
echo "Dify agent tests were skipped because no dify-agent-related files changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Dify agent tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
1
.github/workflows/post-merge.yml
vendored
1
.github/workflows/post-merge.yml
vendored
@@ -39,7 +39,6 @@ jobs:
|
||||
- 'e2e/tsx-register.js'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- '.nvmrc'
|
||||
- '.github/workflows/post-merge.yml'
|
||||
- '.github/workflows/web-e2e.yml'
|
||||
- '.github/actions/setup-web/**'
|
||||
|
||||
2
.github/workflows/pyrefly-diff.yml
vendored
2
.github/workflows/pyrefly-diff.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
2
.github/workflows/pyrefly-type-coverage.yml
vendored
2
.github/workflows/pyrefly-type-coverage.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
19
.github/workflows/style.yml
vendored
19
.github/workflows/style.yml
vendored
@@ -6,6 +6,11 @@ on:
|
||||
base-rev:
|
||||
required: true
|
||||
type: string
|
||||
skip-checks:
|
||||
description: Create the required check runs without repeating previously successful work.
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
checks: write
|
||||
@@ -15,6 +20,7 @@ permissions:
|
||||
jobs:
|
||||
python-style:
|
||||
name: Python Style
|
||||
if: ${{ !inputs.skip-checks }}
|
||||
runs-on: depot-ubuntu-24.04
|
||||
|
||||
steps:
|
||||
@@ -41,7 +47,7 @@ jobs:
|
||||
|
||||
- name: Setup UV and Python
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: false
|
||||
python-version: '3.12'
|
||||
@@ -79,6 +85,7 @@ jobs:
|
||||
|
||||
web-style:
|
||||
name: Web Style
|
||||
if: ${{ !inputs.skip-checks }}
|
||||
runs-on: depot-ubuntu-24.04
|
||||
defaults:
|
||||
run:
|
||||
@@ -107,7 +114,6 @@ jobs:
|
||||
pnpm-workspace.yaml
|
||||
knip.config.ts
|
||||
scripts/check-web-production-unused-after-knip-fix.mjs
|
||||
.nvmrc
|
||||
.github/workflows/style.yml
|
||||
.github/actions/setup-web/**
|
||||
|
||||
@@ -138,6 +144,7 @@ jobs:
|
||||
|
||||
ts-common-style:
|
||||
name: TS Common
|
||||
if: ${{ !inputs.skip-checks }}
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
permissions:
|
||||
checks: write
|
||||
@@ -162,10 +169,11 @@ jobs:
|
||||
package.json
|
||||
pnpm-lock.yaml
|
||||
pnpm-workspace.yaml
|
||||
.nvmrc
|
||||
vite.config.ts
|
||||
lint.config.ts
|
||||
eslint.config.mjs
|
||||
knip.config.ts
|
||||
scripts/check-web-production-unused-after-knip-fix.mjs
|
||||
oxlint-suppressions.json
|
||||
eslint-suppressions.json
|
||||
.vscode/**
|
||||
@@ -181,6 +189,7 @@ jobs:
|
||||
|
||||
superlinter:
|
||||
name: SuperLinter
|
||||
if: ${{ !inputs.skip-checks }}
|
||||
runs-on: depot-ubuntu-24.04
|
||||
|
||||
steps:
|
||||
@@ -201,6 +210,7 @@ jobs:
|
||||
**Dockerfile
|
||||
dev/**
|
||||
.editorconfig
|
||||
.vite-hooks/**
|
||||
|
||||
- name: Super-linter
|
||||
uses: super-linter/super-linter/slim@4ce20838b8ab83717e78138c5b3a1407148e0918 # v8.7.0
|
||||
@@ -209,10 +219,11 @@ jobs:
|
||||
BASH_SEVERITY: warning
|
||||
DEFAULT_BRANCH: origin/main
|
||||
EDITORCONFIG_FILE_NAME: editorconfig-checker.json
|
||||
FILTER_REGEX_INCLUDE: pnpm-lock.yaml
|
||||
ENABLE_GITHUB_PULL_REQUEST_SUMMARY_COMMENT: false
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
IGNORE_GENERATED_FILES: true
|
||||
IGNORE_GITIGNORED_FILES: true
|
||||
VALIDATE_ALL_CODEBASE: false
|
||||
VALIDATE_BASH: true
|
||||
VALIDATE_BASH_EXEC: true
|
||||
# FIXME: temporarily disabled until api-docker.yaml's run script is fixed for shellcheck
|
||||
|
||||
2
.github/workflows/tool-test-sdks.yaml
vendored
2
.github/workflows/tool-test-sdks.yaml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
node-version-file: package.json
|
||||
cache: ''
|
||||
cache-dependency-path: 'pnpm-lock.yaml'
|
||||
|
||||
|
||||
12
.github/workflows/translate-i18n-claude.yml
vendored
12
.github/workflows/translate-i18n-claude.yml
vendored
@@ -8,15 +8,19 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
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.'
|
||||
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
|
||||
type: string
|
||||
languages:
|
||||
description: 'Specific languages to translate (space-separated, e.g., "zh-Hans ja-JP"). Leave empty for all supported target languages except en-US.'
|
||||
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
|
||||
type: string
|
||||
mode:
|
||||
description: 'Sync mode: incremental (compare with previous en-US revision) or full (sync all keys in scope)'
|
||||
description: 'Sync mode: incremental (compare with previous en-US revision) or
|
||||
full (sync all keys in scope)'
|
||||
required: false
|
||||
default: incremental
|
||||
type: choice
|
||||
@@ -158,7 +162,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code for Translation Sync
|
||||
if: steps.context.outputs.CHANGED_FILES != ''
|
||||
uses: anthropics/claude-code-action@c038e4dcdedfbbca18dfb17df35a17e40ded4ddc # v1.0.186
|
||||
uses: anthropics/claude-code-action@833fb0f8c9f6686b33d963a8bae0a94f4936ab2a # v1.0.211
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
4
.github/workflows/vdb-tests-full.yml
vendored
4
.github/workflows/vdb-tests-full.yml
vendored
@@ -29,14 +29,14 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Free Disk Space
|
||||
uses: endersonmenezes/free-disk-space@7901478139cff6e9d44df5972fd8ab8fcade4db1 # v3.2.2
|
||||
uses: endersonmenezes/free-disk-space@2a22f8c59cae9cddae7194b33e6b92d023b2b4b8 # v4.0.0
|
||||
with:
|
||||
remove_dotnet: true
|
||||
remove_haskell: true
|
||||
remove_tool_cache: true
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
4
.github/workflows/vdb-tests.yml
vendored
4
.github/workflows/vdb-tests.yml
vendored
@@ -26,14 +26,14 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Free Disk Space
|
||||
uses: endersonmenezes/free-disk-space@7901478139cff6e9d44df5972fd8ab8fcade4db1 # v3.2.2
|
||||
uses: endersonmenezes/free-disk-space@2a22f8c59cae9cddae7194b33e6b92d023b2b4b8 # v4.0.0
|
||||
with:
|
||||
remove_dotnet: true
|
||||
remove_haskell: true
|
||||
remove_tool_cache: true
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
287
.github/workflows/web-e2e.yml
vendored
287
.github/workflows/web-e2e.yml
vendored
@@ -12,13 +12,11 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Web Full-Stack E2E
|
||||
core-build:
|
||||
name: Prepare Core E2E Web Build
|
||||
if: ${{ !inputs.run-external-runtime }}
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
timeout-minutes: 120
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -26,16 +24,196 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Switch Ubuntu mirror
|
||||
run: |
|
||||
sudo sed -i 's|http://us-east-1.ec2.archive.ubuntu.com/ubuntu|http://azure.archive.ubuntu.com/ubuntu|g' /etc/apt/sources.list.d/ubuntu.sources
|
||||
sudo apt-get update
|
||||
- name: Setup web dependencies
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Run E2E support unit tests
|
||||
working-directory: ./e2e
|
||||
run: vp run test:unit
|
||||
|
||||
- name: Build production Web app
|
||||
working-directory: ./e2e
|
||||
env:
|
||||
E2E_FORCE_WEB_BUILD: '1'
|
||||
run: vp run e2e:web:build
|
||||
|
||||
- name: Package Web build
|
||||
run: >-
|
||||
tar -cf e2e-web-build.tar -C web
|
||||
.next/BUILD_ID
|
||||
.next/e2e-web-build.sha256
|
||||
.next/standalone
|
||||
.next/static
|
||||
|
||||
- name: Upload Web build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: core-e2e-web-build
|
||||
path: e2e-web-build.tar
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
retention-days: 7
|
||||
|
||||
chromium-full:
|
||||
name: Chromium Full (${{ matrix.shard }}/3)
|
||||
if: ${{ !inputs.run-external-runtime }}
|
||||
needs: core-build
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3]
|
||||
env:
|
||||
E2E_ADMIN_EMAIL: e2e-admin@example.com
|
||||
E2E_ADMIN_NAME: E2E Admin
|
||||
E2E_ADMIN_PASSWORD: E2eAdmin12345
|
||||
E2E_INIT_PASSWORD: E2eInit12345
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup web dependencies
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
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: Download Web build
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: core-e2e-web-build
|
||||
|
||||
- name: Extract Web build
|
||||
run: tar -xf e2e-web-build.tar -C web
|
||||
|
||||
- name: Install Chromium
|
||||
timeout-minutes: 15
|
||||
working-directory: ./e2e
|
||||
run: vp run e2e:install:ci:chromium
|
||||
|
||||
- name: Run Chromium full shard
|
||||
working-directory: ./e2e
|
||||
run: vp run e2e:full -- --shard ${{ matrix.shard }}/3
|
||||
|
||||
- name: Upload Cucumber report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: cucumber-report-chromium-${{ matrix.shard }}
|
||||
path: e2e/cucumber-report
|
||||
if-no-files-found: ignore
|
||||
overwrite: true
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload E2E logs
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-logs-chromium-${{ matrix.shard }}
|
||||
path: e2e/.logs/*.log
|
||||
if-no-files-found: ignore
|
||||
include-hidden-files: true
|
||||
overwrite: true
|
||||
retention-days: 7
|
||||
|
||||
webkit-smoke:
|
||||
name: WebKit Browser Smoke
|
||||
if: ${{ !inputs.run-external-runtime }}
|
||||
needs: core-build
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
E2E_ADMIN_EMAIL: e2e-admin@example.com
|
||||
E2E_ADMIN_NAME: E2E Admin
|
||||
E2E_ADMIN_PASSWORD: E2eAdmin12345
|
||||
E2E_BROWSER: webkit
|
||||
E2E_INIT_PASSWORD: E2eInit12345
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup web dependencies
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
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: Download Web build
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: core-e2e-web-build
|
||||
|
||||
- name: Extract Web build
|
||||
run: tar -xf e2e-web-build.tar -C web
|
||||
|
||||
- name: Install WebKit
|
||||
timeout-minutes: 15
|
||||
working-directory: ./e2e
|
||||
run: vp run e2e:install:ci:webkit
|
||||
|
||||
- name: Run WebKit browser smoke
|
||||
working-directory: ./e2e
|
||||
run: vp run e2e:full -- --tags '@browser-smoke'
|
||||
|
||||
- name: Upload Cucumber report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: cucumber-report-webkit
|
||||
path: e2e/cucumber-report
|
||||
if-no-files-found: ignore
|
||||
overwrite: true
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload E2E logs
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-logs-webkit
|
||||
path: e2e/.logs/*.log
|
||||
if-no-files-found: ignore
|
||||
include-hidden-files: true
|
||||
overwrite: true
|
||||
retention-days: 7
|
||||
|
||||
external-runtime:
|
||||
name: Web Full-Stack E2E
|
||||
if: ${{ inputs.run-external-runtime }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup web dependencies
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
@@ -46,82 +224,12 @@ jobs:
|
||||
- name: Install API dependencies
|
||||
run: uv sync --project api --dev
|
||||
|
||||
- name: Run E2E support unit tests
|
||||
if: ${{ !inputs.run-external-runtime }}
|
||||
working-directory: ./e2e
|
||||
run: vp run test:unit
|
||||
|
||||
- name: Install Playwright browsers for core E2E
|
||||
if: ${{ !inputs.run-external-runtime }}
|
||||
timeout-minutes: 15
|
||||
working-directory: ./e2e
|
||||
run: vp run e2e:install:ci
|
||||
|
||||
- name: Install Chromium for external runtime E2E
|
||||
if: ${{ inputs.run-external-runtime }}
|
||||
- name: Install Chromium
|
||||
timeout-minutes: 15
|
||||
working-directory: ./e2e
|
||||
run: vp run e2e:install:ci:chromium
|
||||
|
||||
- name: Run isolated source-api and built-web Cucumber E2E tests
|
||||
if: ${{ !inputs.run-external-runtime }}
|
||||
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: Preserve Chromium E2E report and logs
|
||||
if: ${{ !cancelled() && !inputs.run-external-runtime }}
|
||||
run: |
|
||||
if [[ -d e2e/cucumber-report ]]; then
|
||||
mv e2e/cucumber-report e2e/cucumber-report-non-external
|
||||
fi
|
||||
if [[ -d e2e/.logs ]]; then
|
||||
mv e2e/.logs e2e/.logs-non-external
|
||||
fi
|
||||
|
||||
- name: Run WebKit keyboard and browser smoke tests
|
||||
if: ${{ !inputs.run-external-runtime }}
|
||||
working-directory: ./e2e
|
||||
env:
|
||||
E2E_ADMIN_EMAIL: e2e-admin@example.com
|
||||
E2E_ADMIN_NAME: E2E Admin
|
||||
E2E_ADMIN_PASSWORD: E2eAdmin12345
|
||||
E2E_BROWSER: webkit
|
||||
E2E_INIT_PASSWORD: E2eInit12345
|
||||
run: |
|
||||
teardown_webkit_smoke() {
|
||||
local run_status=$?
|
||||
trap - EXIT
|
||||
if ! vp run e2e:middleware:down; then
|
||||
echo "::error title=E2E teardown failed::WebKit smoke middleware did not shut down cleanly."
|
||||
if [[ "$run_status" -eq 0 ]]; then
|
||||
run_status=1
|
||||
fi
|
||||
fi
|
||||
exit "$run_status"
|
||||
}
|
||||
|
||||
trap teardown_webkit_smoke EXIT
|
||||
vp run e2e:middleware:up
|
||||
vp run e2e -- --tags '@browser-smoke'
|
||||
|
||||
- name: Preserve WebKit E2E report and logs
|
||||
if: ${{ !cancelled() && !inputs.run-external-runtime }}
|
||||
run: |
|
||||
if [[ -d e2e/cucumber-report ]]; then
|
||||
mv e2e/cucumber-report e2e/cucumber-report-webkit
|
||||
fi
|
||||
if [[ -d e2e/.logs ]]; then
|
||||
mv e2e/.logs e2e/.logs-webkit
|
||||
fi
|
||||
|
||||
- name: Run prepared and external runtime E2E tests
|
||||
if: ${{ inputs.run-external-runtime }}
|
||||
working-directory: ./e2e
|
||||
env:
|
||||
E2E_ADMIN_EMAIL: e2e-admin@example.com
|
||||
@@ -154,10 +262,8 @@ jobs:
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: cucumber-report
|
||||
path: |
|
||||
e2e/cucumber-report
|
||||
e2e/cucumber-report-non-external
|
||||
e2e/cucumber-report-webkit
|
||||
path: e2e/cucumber-report
|
||||
overwrite: true
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload E2E logs
|
||||
@@ -165,18 +271,17 @@ jobs:
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-logs
|
||||
path: |
|
||||
e2e/.logs/*.log
|
||||
e2e/.logs-non-external/*.log
|
||||
e2e/.logs-webkit/*.log
|
||||
path: e2e/.logs/*.log
|
||||
include-hidden-files: true
|
||||
overwrite: true
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload E2E seed report
|
||||
if: ${{ !cancelled() && inputs.run-external-runtime }}
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-seed-report
|
||||
path: e2e/seed-report
|
||||
if-no-files-found: ignore
|
||||
overwrite: true
|
||||
retention-days: 7
|
||||
|
||||
71
.github/workflows/web-tests.yml
vendored
71
.github/workflows/web-tests.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Run tests
|
||||
run: vp test run --reporter=blob --reporter=minimal --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage
|
||||
run: vp test run --project unit --reporter=blob --reporter=minimal --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage
|
||||
|
||||
- name: Upload blob report
|
||||
if: ${{ !cancelled() }}
|
||||
@@ -44,6 +44,41 @@ jobs:
|
||||
include-hidden-files: true
|
||||
retention-days: 1
|
||||
|
||||
browser-test:
|
||||
name: Web Browser Tests
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: ./web
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup web environment
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Install Chromium for Browser Mode
|
||||
timeout-minutes: 15
|
||||
run: vp exec playwright install --with-deps --only-shell chromium
|
||||
|
||||
- name: Run browser tests
|
||||
run: vp test run --project browser --silent=passed-only
|
||||
|
||||
- name: Upload browser failure artifacts
|
||||
if: ${{ failure() && !cancelled() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: browser-test-failures
|
||||
path: web/.vitest-browser/
|
||||
if-no-files-found: ignore
|
||||
include-hidden-files: true
|
||||
retention-days: 7
|
||||
|
||||
merge-reports:
|
||||
name: Merge Test Reports
|
||||
if: ${{ !cancelled() }}
|
||||
@@ -86,7 +121,7 @@ jobs:
|
||||
|
||||
dify-ui-test:
|
||||
name: dify-ui Tests
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
@@ -101,11 +136,6 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Switch Ubuntu mirror
|
||||
run: |
|
||||
sudo sed -i 's|http://us-east-1.ec2.archive.ubuntu.com/ubuntu|http://azure.archive.ubuntu.com/ubuntu|g' /etc/apt/sources.list.d/ubuntu.sources
|
||||
sudo apt-get update
|
||||
|
||||
- name: Setup web environment
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
@@ -116,6 +146,16 @@ jobs:
|
||||
- name: Run dify-ui tests
|
||||
run: vp test run --project unit --coverage --silent=passed-only
|
||||
|
||||
- name: Upload dify-ui test failure artifacts
|
||||
if: ${{ failure() && !cancelled() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: dify-ui-test-failures
|
||||
path: packages/dify-ui/.vitest-browser/
|
||||
if-no-files-found: ignore
|
||||
include-hidden-files: true
|
||||
retention-days: 7
|
||||
|
||||
- name: Report coverage
|
||||
if: ${{ env.CODECOV_TOKEN != '' }}
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
@@ -127,7 +167,7 @@ jobs:
|
||||
|
||||
dify-ui-storybook-test:
|
||||
name: dify-ui Storybook Tests
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
@@ -140,11 +180,6 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Switch Ubuntu mirror
|
||||
run: |
|
||||
sudo sed -i 's|http://us-east-1.ec2.archive.ubuntu.com/ubuntu|http://azure.archive.ubuntu.com/ubuntu|g' /etc/apt/sources.list.d/ubuntu.sources
|
||||
sudo apt-get update
|
||||
|
||||
- name: Setup web environment
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
@@ -154,3 +189,13 @@ jobs:
|
||||
|
||||
- name: Run dify-ui Storybook tests
|
||||
run: vp run test:storybook
|
||||
|
||||
- name: Upload dify-ui Storybook test failure artifacts
|
||||
if: ${{ failure() && !cancelled() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: dify-ui-storybook-test-failures
|
||||
path: packages/dify-ui/.vitest-browser/
|
||||
if-no-files-found: ignore
|
||||
include-hidden-files: true
|
||||
retention-days: 7
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -263,6 +263,7 @@ scripts/stress-test/reports/
|
||||
.qoder/*
|
||||
.context/
|
||||
# Vitest local reports
|
||||
web/.vitest-browser/
|
||||
web/.vitest-reports/
|
||||
|
||||
# dify-agent-runtime
|
||||
|
||||
@@ -5,7 +5,6 @@ files=$(git diff --cached --name-only)
|
||||
# check if api or web directory is modified
|
||||
|
||||
api_modified=false
|
||||
web_modified=false
|
||||
skip_web_checks=false
|
||||
|
||||
git_path() {
|
||||
@@ -29,10 +28,6 @@ do
|
||||
# set api_modified flag to true
|
||||
api_modified=true
|
||||
;;
|
||||
web/*)
|
||||
# set web_modified flag to true
|
||||
web_modified=true
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
|
||||
5
.vscode/settings.example.json
vendored
5
.vscode/settings.example.json
vendored
@@ -4,7 +4,6 @@
|
||||
|
||||
"tailwindCSS.experimental.configFile": "web/app/styles/globals.css",
|
||||
|
||||
// Format
|
||||
"[javascript]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
|
||||
"[javascriptreact]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
|
||||
"[typescript]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
|
||||
@@ -25,9 +24,9 @@
|
||||
"[handlebars]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnSaveMode": "file",
|
||||
"oxc.fmt.configPath": "./vite.config.ts",
|
||||
"oxc.disableNestedConfig": true,
|
||||
"oxc.fmt.disableNestedConfig": true,
|
||||
|
||||
// Lint fix
|
||||
"eslint.useFlatConfig": true,
|
||||
"eslint.validate": ["json", "jsonc", "markdown", "yaml", "toml"],
|
||||
"editor.codeActionsOnSave": {
|
||||
|
||||
@@ -7,3 +7,9 @@ Dify is an open-source platform for building LLM applications, agentic workflows
|
||||
- Run backend commands through `uv run --project api <command>`.
|
||||
- Backend integration tests are CI-only and are not expected to run locally.
|
||||
- Keep `docker/.env.example` limited to variables required for a default Docker Compose deployment to start. Put optional and provider-specific settings in the matching `docker/envs/*.env.example` file; `docker/.env` overrides those service-specific env files.
|
||||
|
||||
## Frontend Workflow
|
||||
|
||||
- For truncated text disclosure and native `title` decisions, follow [Truncated Text Disclosure].
|
||||
|
||||
[Truncated Text Disclosure]: web/docs/truncated-text-disclosure.md
|
||||
|
||||
84
README.md
84
README.md
@@ -129,83 +129,35 @@ All of Dify's offerings come with corresponding APIs, so you could effortlessly
|
||||
|
||||
Star Dify on GitHub and be instantly notified of new releases.
|
||||
|
||||

|
||||
<img width="1344" height="720" alt="star" src="https://github.com/user-attachments/assets/dcd086d1-af0f-471b-ae52-1ad2fa040595" />
|
||||
|
||||
## Advanced Setup
|
||||
|
||||
### Custom configurations
|
||||
|
||||
If you need to customize the configuration, edit `docker/.env`. The essential startup defaults live in [`docker/.env.example`](docker/.env.example), and optional advanced variables are split under `docker/envs/` by theme. After making any changes, re-run `docker compose up -d` from the `docker` directory. You can find the full list of available environment variables [here](https://docs.dify.ai/getting-started/install-self-hosted/environments).
|
||||
|
||||
### Metrics Monitoring with Grafana
|
||||
|
||||
Import the dashboard to Grafana, using Dify's PostgreSQL database as data source, to monitor metrics in granularity of apps, tenants, messages, and more.
|
||||
|
||||
- [Grafana Dashboard by @bowenliang123](https://github.com/bowenliang123/dify-grafana-dashboard)
|
||||
|
||||
### Deployment with Kubernetes
|
||||
|
||||
If you'd like to configure a highly available setup, there are community-contributed [Helm Charts](https://helm.sh/) and YAML files which allow Dify to be deployed on Kubernetes.
|
||||
|
||||
- [Helm Chart by @LeoQuote](https://github.com/douban/charts/tree/master/charts/dify)
|
||||
- [Helm Chart by @BorisPolonsky](https://github.com/BorisPolonsky/dify-helm)
|
||||
- [Helm Chart by @magicsong](https://github.com/magicsong/ai-charts)
|
||||
- [YAML file by @Winson-030](https://github.com/Winson-030/dify-kubernetes)
|
||||
- [YAML file by @wyy-holding](https://github.com/wyy-holding/dify-k8s)
|
||||
- [🚀 NEW! YAML files (Supports Dify v1.6.0) by @Zhoneym](https://github.com/Zhoneym/DifyAI-Kubernetes)
|
||||
|
||||
#### Using Terraform for Deployment
|
||||
|
||||
Deploy Dify to Cloud Platform with a single click using [terraform](https://www.terraform.io/)
|
||||
|
||||
##### Azure Global
|
||||
|
||||
- [Azure Terraform by @nikawang](https://github.com/nikawang/dify-azure-terraform)
|
||||
|
||||
##### Google Cloud
|
||||
|
||||
- [Google Cloud Terraform by @sotazum](https://github.com/DeNA/dify-google-cloud-terraform)
|
||||
|
||||
#### Using AWS CDK for Deployment
|
||||
|
||||
Deploy Dify to AWS with [CDK](https://aws.amazon.com/cdk/)
|
||||
|
||||
##### AWS
|
||||
|
||||
- [AWS CDK by @KevinZhao (EKS based)](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [AWS CDK by @tmokmss (ECS based)](https://github.com/aws-samples/dify-self-hosted-on-aws)
|
||||
|
||||
#### Using Alibaba Cloud Computing Nest
|
||||
|
||||
Quickly deploy Dify to Alibaba cloud with [Alibaba Cloud Computing Nest](https://computenest.console.aliyun.com/service/instance/create/default?type=user&ServiceName=Dify%E7%A4%BE%E5%8C%BA%E7%89%88)
|
||||
|
||||
#### Using Alibaba Cloud Data Management
|
||||
|
||||
One-Click deploy Dify to Alibaba Cloud with [Alibaba Cloud Data Management](https://www.alibabacloud.com/help/en/dms/dify-in-invitational-preview/)
|
||||
|
||||
#### Deploy to AKS with Azure Devops Pipeline
|
||||
|
||||
One-Click deploy Dify to AKS with [Azure Devops Pipeline Helm Chart by @LeoZhang](https://github.com/Ruiruiz30/Dify-helm-chart-AKS)
|
||||
For custom configuration, observability, and deployment options, see [Advanced Setup](docs/ADVANCED_SETUP.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
For those who'd like to contribute code, see our [Contribution Guide](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md).
|
||||
At the same time, please consider supporting Dify by sharing it on social media and at events and conferences.
|
||||
Dify welcomes contributions of all kinds:
|
||||
|
||||
> We are looking for contributors to help translate Dify into languages other than Mandarin or English. If you are interested in helping, please see the [i18n README](https://github.com/langgenius/dify/blob/main/web/i18n-config/README.md) for more information, and leave us a comment in the `global-users` channel of our [Discord Community Server](https://discord.gg/8Tpq4AcN9c).
|
||||
- **Code**: Read the [Contribution Guide](CONTRIBUTING.md), then browse [good first issues](https://github.com/langgenius/dify/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22).
|
||||
- **Ideas and feedback**: Start or join a [GitHub Discussion](https://github.com/langgenius/dify/discussions).
|
||||
- **Translations**: Follow the [internationalization guide](web/i18n-config/README.md) to add or update a locale.
|
||||
- **Community**: Share the apps you build, help other users, and spread the word about Dify.
|
||||
|
||||
### Contributors
|
||||
|
||||
<a href="https://github.com/langgenius/dify/graphs/contributors">
|
||||
<img alt="Dify contributors" src="https://contrib.rocks/image?repo=langgenius/dify" />
|
||||
</a>
|
||||
|
||||
## Community & contact
|
||||
|
||||
- [GitHub Discussion](https://github.com/langgenius/dify/discussions). Best for: sharing feedback and asking questions.
|
||||
- [GitHub Issues](https://github.com/langgenius/dify/issues). Best for: bugs you encounter using Dify.AI, and feature proposals. See our [Contribution Guide](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md).
|
||||
- [Discord](https://discord.gg/FngNHpbcY7). Best for: sharing your applications and hanging out with the community.
|
||||
- [X(Twitter)](https://twitter.com/dify_ai). Best for: sharing your applications and hanging out with the community.
|
||||
Choose the channel that best fits your question:
|
||||
|
||||
**Contributors**
|
||||
|
||||
<a href="https://github.com/langgenius/dify/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=langgenius/dify" />
|
||||
</a>
|
||||
- [GitHub Discussions](https://github.com/langgenius/dify/discussions): Get help, share feedback, and propose ideas.
|
||||
- [GitHub Issues](https://github.com/langgenius/dify/issues): Report reproducible bugs and track engineering work. Read the [Contribution Guide](CONTRIBUTING.md) before opening one.
|
||||
- [Discord](https://discord.gg/FngNHpbcY7): Chat in real time, share your apps, and connect with other Dify users.
|
||||
- [X](https://x.com/dify_ai): Follow Dify for release news and project updates.
|
||||
|
||||
## Star History
|
||||
|
||||
|
||||
@@ -98,9 +98,11 @@ REDIS_KEEPALIVE=true
|
||||
CELERY_BROKER_URL=redis://:difyai123456@localhost:${REDIS_PORT}/1
|
||||
CELERY_BACKEND=redis
|
||||
|
||||
# Ops trace retry configuration
|
||||
OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES=60
|
||||
# Ops trace configuration
|
||||
OPS_TRACE_UNIFIED_ENABLED=false
|
||||
OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES=780
|
||||
OPS_TRACE_RETRYABLE_DISPATCH_DELAY_SECONDS=5
|
||||
OPS_TRACE_PARENT_CONTEXT_TTL_SECONDS=3900
|
||||
|
||||
# Database configuration
|
||||
DB_TYPE=postgresql
|
||||
@@ -220,6 +222,7 @@ SUPABASE_URL=your-server-url
|
||||
# CORS configuration
|
||||
WEB_API_CORS_ALLOW_ORIGINS=http://localhost:3000,*
|
||||
CONSOLE_CORS_ALLOW_ORIGINS=http://localhost:3000,*
|
||||
WEBSOCKET_MAX_HTTP_BUFFER_SIZE=10485760
|
||||
# When the frontend and backend run on different subdomains, set COOKIE_DOMAIN to the site’s top-level domain (e.g., `example.com`). Leading dots are optional.
|
||||
COOKIE_DOMAIN=
|
||||
|
||||
@@ -488,6 +491,13 @@ SENDGRID_API_KEY=
|
||||
# Sentry configuration
|
||||
SENTRY_DSN=
|
||||
|
||||
# Cloudflare Turnstile server-side verification for Dify Cloud sign-in
|
||||
TURNSTILE_SECRET_KEY=
|
||||
# Comma-separated parent or exact hostnames, for example: dify.ai,staging.dify.dev
|
||||
TURNSTILE_ALLOWED_HOSTNAMES=
|
||||
# Enable only after the compatible web client has been fully deployed.
|
||||
TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED=false
|
||||
|
||||
# DEBUG
|
||||
DEBUG=false
|
||||
ENABLE_REQUEST_LOGGING=False
|
||||
@@ -578,7 +588,7 @@ INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=4000
|
||||
|
||||
# Workflow runtime configuration
|
||||
WORKFLOW_MAX_EXECUTION_STEPS=500
|
||||
WORKFLOW_MAX_EXECUTION_TIME=1200
|
||||
WORKFLOW_MAX_EXECUTION_TIME=3600
|
||||
WORKFLOW_CALL_MAX_DEPTH=5
|
||||
MAX_VARIABLE_SIZE=204800
|
||||
# Maximum concurrent node-builder LLM calls per workflow generation request
|
||||
@@ -623,7 +633,7 @@ WORKFLOW_LOG_CLEANUP_BATCH_SIZE=100
|
||||
WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS=
|
||||
|
||||
# App configuration
|
||||
APP_MAX_EXECUTION_TIME=1200
|
||||
APP_MAX_EXECUTION_TIME=3600
|
||||
APP_DEFAULT_ACTIVE_REQUESTS=0
|
||||
APP_MAX_ACTIVE_REQUESTS=0
|
||||
|
||||
@@ -698,11 +708,12 @@ INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y
|
||||
|
||||
# Dify Agent backend
|
||||
AGENT_BACKEND_BASE_URL=http://localhost:5050
|
||||
# Bearer token sent to the Agent backend /runs API. Must match DIFY_AGENT_API_TOKEN on the server side.
|
||||
# Bearer token sent to the Agent backend control-plane API. Must match DIFY_AGENT_API_TOKEN on the server side.
|
||||
AGENT_BACKEND_API_TOKEN=dify-agent-run-token-for-dev-only
|
||||
AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS=30
|
||||
AGENT_BACKEND_STREAM_MAX_RECONNECTS=3
|
||||
AGENT_BACKEND_RUN_TIMEOUT_SECONDS=1200
|
||||
# Client deadline for converting a Binding file to a ToolFile through the Agent backend.
|
||||
AGENT_BACKEND_BINDING_FILE_DOWNLOAD_TIMEOUT_SECONDS=240
|
||||
|
||||
# KnowledgeFS (Dataset 2.0)
|
||||
KNOWLEDGE_FS_ENABLED=false
|
||||
@@ -729,6 +740,8 @@ RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5
|
||||
EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES=5
|
||||
CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5
|
||||
OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5
|
||||
EMAIL_CODE_LOGIN_TOKEN_EXPIRY_MINUTES=5
|
||||
EMAIL_CODE_LOGIN_MAX_ATTEMPTS=5
|
||||
|
||||
CREATE_TIDB_SERVICE_JOB_ENABLED=false
|
||||
|
||||
@@ -825,6 +838,12 @@ ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true
|
||||
# Human input timeout check interval in minutes
|
||||
HUMAN_INPUT_TIMEOUT_TASK_INTERVAL=1
|
||||
|
||||
# Whether to recover soft-deleted conversation cleanup jobs periodically
|
||||
ENABLE_CONVERSATION_CLEANUP_TASK=true
|
||||
# Recovery interval in minutes and maximum conversations dispatched per sweep
|
||||
CONVERSATION_CLEANUP_TASK_INTERVAL=5
|
||||
CONVERSATION_CLEANUP_BATCH_SIZE=100
|
||||
|
||||
# Nacos remote settings source HTTP timeouts (seconds).
|
||||
# Bound how long requests to the Nacos endpoint wait before failing, so a slow or
|
||||
# unresponsive Nacos server cannot stall API startup or token refresh.
|
||||
@@ -832,3 +851,6 @@ HUMAN_INPUT_TIMEOUT_TASK_INTERVAL=1
|
||||
DIFY_ENV_NACOS_REQUEST_TIMEOUT=10.0
|
||||
# Connect timeout for Nacos requests (default: 3.0)
|
||||
DIFY_ENV_NACOS_CONNECT_TIMEOUT=3.0
|
||||
|
||||
# skill entry
|
||||
ENABLE_SKILL=true
|
||||
|
||||
@@ -15,6 +15,112 @@ root_packages =
|
||||
services
|
||||
include_external_packages = True
|
||||
|
||||
[importlinter:contract:backend-layers]
|
||||
name = Backend modules follow the transport, application, domain, and shared-library layers
|
||||
type = layers
|
||||
layers =
|
||||
controllers
|
||||
services
|
||||
core
|
||||
libs
|
||||
# Migration baseline: keep every exception exact. The default unmatched-import
|
||||
# error makes this list shrink whenever a legacy dependency is removed.
|
||||
unmatched_ignore_imports_alerting = error
|
||||
ignore_imports =
|
||||
core.app.app_config.easy_ui_based_app.dataset.manager -> services.dataset_service
|
||||
core.app.apps.advanced_chat.app_generator -> controllers.console.app.workflow
|
||||
core.app.apps.advanced_chat.app_generator -> services.conversation_service
|
||||
core.app.apps.advanced_chat.app_generator -> services.errors.conversation
|
||||
core.app.apps.advanced_chat.app_generator -> services.workflow_draft_variable_service
|
||||
core.app.apps.advanced_chat.app_runner -> services.conversation_variable_updater
|
||||
core.app.apps.agent_app.app_generator -> services.agent.composer_service
|
||||
core.app.apps.agent_app.app_generator -> services.agent.workspace_service
|
||||
core.app.apps.agent_app.app_generator -> services.conversation_service
|
||||
core.app.apps.agent_app.runtime_request_builder -> services.agent.prompt_mentions
|
||||
core.app.apps.agent_app.session_store -> services.agent.workspace_service
|
||||
core.app.apps.agent_chat.app_generator -> services.conversation_service
|
||||
core.app.apps.base_app_generator -> services.workflow_draft_variable_service
|
||||
core.app.apps.chat.app_generator -> services.conversation_service
|
||||
core.app.apps.common.workflow_response_converter -> services.variable_truncator
|
||||
core.app.apps.completion.app_generator -> services.errors.app
|
||||
core.app.apps.completion.app_generator -> services.errors.message
|
||||
core.app.apps.message_based_app_generator -> services.errors.app_model_config
|
||||
core.app.apps.message_based_app_generator -> services.errors.conversation
|
||||
core.app.apps.message_based_app_generator -> services.errors.message
|
||||
core.app.apps.pipeline.pipeline_generator -> services
|
||||
core.app.apps.pipeline.pipeline_generator -> services.dataset_service
|
||||
core.app.apps.pipeline.pipeline_generator -> services.datasource_provider_service
|
||||
core.app.apps.pipeline.pipeline_generator -> services.feature_service
|
||||
core.app.apps.pipeline.pipeline_generator -> services.workflow_draft_variable_service
|
||||
core.app.apps.pipeline.pipeline_runner -> services.dataset_ref_service
|
||||
core.app.apps.workflow.app_generator -> controllers.console.app.workflow
|
||||
core.app.apps.workflow.app_generator -> services.snippet_generate_service
|
||||
core.app.apps.workflow.app_generator -> services.workflow_draft_variable_service
|
||||
core.app.apps.workflow_app_runner -> tasks.mail_human_input_delivery_task
|
||||
core.app.features.annotation_reply.annotation_reply -> services.annotation_service
|
||||
core.app.features.annotation_reply.annotation_reply -> services.dataset_service
|
||||
core.app.llm.quota -> services.credit_pool_service
|
||||
core.app.task_pipeline.message_cycle_manager -> services.account_service
|
||||
core.app.task_pipeline.message_cycle_manager -> services.annotation_service
|
||||
core.app.workflow.layers.persistence -> services.workflow.inspector_events
|
||||
core.datasource.datasource_manager -> services.datasource_provider_service
|
||||
core.helper.credential_utils -> services.enterprise.plugin_manager_service
|
||||
core.helper.credential_utils -> services.system_feature_service
|
||||
core.indexing_runner -> services.vector_space_admission_service
|
||||
core.mcp.auth_client -> services
|
||||
core.provider_manager -> services.credential_permission_service
|
||||
core.provider_manager -> services.credit_pool_service
|
||||
core.provider_manager -> services.feature_service
|
||||
core.rag.datasource.retrieval_service -> services.external_knowledge_service
|
||||
core.rag.index_processor.index_processor -> services.vector_space_admission_service
|
||||
core.rag.index_processor.index_processor_base -> services.file_service
|
||||
core.rag.index_processor.processor.paragraph_index_processor -> services.account_service
|
||||
core.rag.index_processor.processor.paragraph_index_processor -> services.summary_index_service
|
||||
core.rag.index_processor.processor.parent_child_index_processor -> services.account_service
|
||||
core.rag.index_processor.processor.parent_child_index_processor -> services.summary_index_service
|
||||
core.rag.index_processor.processor.qa_index_processor -> services.summary_index_service
|
||||
core.rag.retrieval.dataset_retrieval -> services.external_knowledge_service
|
||||
core.rag.retrieval.dataset_retrieval -> services.feature_service
|
||||
core.rag.summary_index.summary_index -> services.summary_index_service
|
||||
core.rag.summary_index.summary_index -> tasks.generate_summary_index_task
|
||||
core.repositories.sqlalchemy_workflow_node_execution_repository -> services.file_service
|
||||
core.repositories.sqlalchemy_workflow_node_execution_repository -> services.variable_truncator
|
||||
core.tools.tool_manager -> services
|
||||
core.workflow.node_runtime -> services
|
||||
core.workflow.nodes.agent.message_transformer -> services
|
||||
core.workflow.nodes.agent_v2.agent_node -> services.agent.prompt_mentions
|
||||
core.workflow.nodes.agent_v2.agent_node -> services.agent.workspace_service
|
||||
core.workflow.nodes.agent_v2.dify_tools_builder -> services
|
||||
core.workflow.nodes.agent_v2.runtime_feature_manifest -> services.agent.knowledge_datasets
|
||||
core.workflow.nodes.agent_v2.runtime_request_builder -> services.agent.prompt_mentions
|
||||
core.workflow.nodes.agent_v2.runtime_request_builder -> services.skill_management_service
|
||||
core.workflow.nodes.agent_v2.session_store -> services.agent.workspace_service
|
||||
core.workflow.nodes.agent_v2.validators -> services.agent.knowledge_datasets
|
||||
core.workflow.nodes.agent_v2.workspace_retirement_layer -> tasks.collect_agent_resources_task
|
||||
libs.device_flow_security -> controllers.openapi._models
|
||||
libs.device_flow_security -> services.entities.feature_entities
|
||||
libs.device_flow_security -> services.system_feature_service
|
||||
libs.email_i18n -> services.entities.feature_entities
|
||||
libs.email_i18n -> services.system_feature_service
|
||||
libs.external_api -> core
|
||||
libs.external_api -> core.errors.error
|
||||
libs.external_api -> extensions.ext_logging
|
||||
libs.flask_utils -> models
|
||||
libs.helper -> core.app.features.rate_limiting.rate_limit
|
||||
libs.helper -> models
|
||||
libs.helper -> models.model
|
||||
libs.json_in_md_parser -> core.llm_generator.output_parser.errors
|
||||
libs.login -> extensions.ext_login
|
||||
libs.login -> models
|
||||
libs.login -> models.model
|
||||
libs.oauth -> core.helper.http_client_pooling
|
||||
libs.oauth_bearer -> models
|
||||
libs.rsa -> extensions.ext_storage
|
||||
libs.workspace_permission -> services.enterprise.enterprise_service
|
||||
services.account_service -> controllers
|
||||
services.account_service -> controllers.console.error
|
||||
services.app_generate_service -> controllers.console.app.workflow
|
||||
|
||||
[importlinter:contract:no-direct-rsa-imports]
|
||||
# Note: `libs` itself is deliberately excluded from source_modules -- import-linter's
|
||||
# `forbidden` contract cannot check a package against its own descendant (libs.rsa lives
|
||||
@@ -65,6 +171,169 @@ forbidden_modules =
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:dataset-application-boundary]
|
||||
name = Dataset application services and contracts are framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.data_source.binding_application_service
|
||||
services.data_source.notion_import_application_service
|
||||
services.entities.data_source.credential
|
||||
services.entities.data_source.notion_import
|
||||
services.entities.knowledge_entities.indexing_estimate
|
||||
services.knowledge.application
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
tasks
|
||||
werkzeug
|
||||
services.dataset_service
|
||||
services.datasource_provider_service
|
||||
core.datasource.datasource_manager
|
||||
core.indexing_runner
|
||||
core.rag.extractor.notion_extractor
|
||||
|
||||
[importlinter:contract:dataset-reference-boundary]
|
||||
name = Dataset owner references do not depend on web application layers
|
||||
type = forbidden
|
||||
source_modules =
|
||||
core.rag.entities.dataset_reference
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
services
|
||||
sqlalchemy
|
||||
tasks
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:init-validation-service-boundary]
|
||||
name = Initialization validation application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.init_validation_service
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:explore-banner-query-service-boundary]
|
||||
name = Explore banner query application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.explore_banner_query_service
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:account-application-boundary]
|
||||
name = Account application services and contracts are framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.account_avatar_service
|
||||
services.account_change_email_ports
|
||||
services.account_change_email_service
|
||||
services.account_email_registration_service
|
||||
services.account_deletion_service
|
||||
services.account_deletion_feedback_service
|
||||
services.account_education_service
|
||||
services.account_errors
|
||||
services.account_forgot_password_service
|
||||
services.account_initialization_service
|
||||
services.account_integration_service
|
||||
services.account_password_service
|
||||
services.account_ports
|
||||
services.account_profile_service
|
||||
services.entities.account_entities
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:app-definition-query-service-boundary]
|
||||
name = App definition query application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.app_definition_query_service
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:app-site-service-boundary]
|
||||
name = App site application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.app_site_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:webapp-access-query-service-boundary]
|
||||
name = Web app access query application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.webapp_access_query_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
services.enterprise
|
||||
services.feature_service
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:web-app-runtime-query-service-boundary]
|
||||
name = Web app runtime query application service does not directly depend on configuration, feature implementation, transport, or ORM modules
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.web_app_runtime_query_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
services.feature_service
|
||||
services.feature_service_gateway
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:feature-query-service-boundary]
|
||||
name = Feature query application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
@@ -79,6 +348,40 @@ forbidden_modules =
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:data-source-oauth-service-boundary]
|
||||
name = Data source OAuth application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.data_source.oauth_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
httpx
|
||||
libs
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:oauth-server-application-service-boundary]
|
||||
name = OAuth server application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.oauth_server_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
httpx
|
||||
libs
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:workspace-member-query-service-boundary]
|
||||
name = Workspace member query application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
@@ -125,3 +428,125 @@ forbidden_modules =
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:inner-mail-service-boundary]
|
||||
name = Inner mail application service is framework and implementation neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.inner_mail_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
tasks
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:web-passport-service-boundary]
|
||||
name = Web passport application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.web_passport_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:account-activation-service-boundary]
|
||||
name = Account activation application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.account_activation_service
|
||||
services.entities.account_activation_entities
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:tag-application-service-boundary]
|
||||
name = Tag application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.tag_application_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:recommended-app-query-service-boundary]
|
||||
name = Recommended app query application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.recommended_app_query_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
services.feature_service
|
||||
services.recommended_app_catalog_gateway
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:recommended-app-catalog-gateway-boundary]
|
||||
name = Recommended app catalog gateway does not depend on Flask
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.recommended_app_catalog_gateway
|
||||
forbidden_modules =
|
||||
flask
|
||||
|
||||
[importlinter:contract:billing-application-boundary]
|
||||
name = Billing application services and ports are framework and infrastructure neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.billing_portal_service
|
||||
services.compliance_download_service
|
||||
services.partner_tenant_binding_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
services.billing_service
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:workflow-run-archive-application-boundary]
|
||||
name = Workflow run archive application service and task domain are framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.retention.workflow_run.archive_download_task
|
||||
services.retention.workflow_run.archive_log_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
tasks
|
||||
werkzeug
|
||||
|
||||
@@ -33,7 +33,6 @@ RUN uv sync --frozen --no-dev --no-editable
|
||||
FROM base AS production
|
||||
|
||||
ENV FLASK_APP=app.py
|
||||
ENV EDITION=SELF_HOSTED
|
||||
ENV DEPLOY_ENV=PRODUCTION
|
||||
ENV CONSOLE_API_URL=http://127.0.0.1:5001
|
||||
ENV CONSOLE_WEB_URL=http://127.0.0.1:3000
|
||||
@@ -54,15 +53,14 @@ WORKDIR /app/api
|
||||
|
||||
# Create non-root user
|
||||
ARG dify_uid=1001
|
||||
ARG NODE_MAJOR=22
|
||||
ARG NODE_PACKAGE_VERSION=22.21.0-1nodesource1
|
||||
ARG NODE_PACKAGE_VERSION=24.20.0-1nodesource1
|
||||
ARG NODESOURCE_KEY_FPR=6F71F525282841EEDAF851B42F59B5F99B1BE0B4
|
||||
RUN groupadd -r -g ${dify_uid} dify && \
|
||||
useradd -r -u ${dify_uid} -g ${dify_uid} -s /bin/bash dify && \
|
||||
chown -R dify:dify /app
|
||||
|
||||
RUN \
|
||||
apt-get update \
|
||||
RUN NODE_MAJOR="${NODE_PACKAGE_VERSION%%.*}" \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
|
||||
@@ -14,6 +14,7 @@ from contexts.wrapper import RecyclableContextVar
|
||||
from controllers.console.error import UnauthorizedAndForceLogout
|
||||
from core.logging.context import init_request_context
|
||||
from dify_app import DifyApp
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_socketio import sio
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.entities.feature_entities import LicenseStatus
|
||||
@@ -112,7 +113,7 @@ def create_flask_app_with_configs() -> DifyApp:
|
||||
init_request_context()
|
||||
RecyclableContextVar.increment_thread_recycles()
|
||||
|
||||
if dify_config.ENTERPRISE_ENABLED:
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE:
|
||||
surface = _match_license_gated_surface(request.path)
|
||||
if surface is not None:
|
||||
try:
|
||||
|
||||
@@ -23,6 +23,7 @@ from dify_agent.protocol import (
|
||||
CancelRunResponse,
|
||||
CreateRunRequest,
|
||||
CreateRunResponse,
|
||||
RunCancelledEvent,
|
||||
RunEvent,
|
||||
RunStatusResponse,
|
||||
)
|
||||
@@ -45,6 +46,15 @@ class AgentBackendRunClient(Protocol):
|
||||
def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
|
||||
"""Request explicit cancellation for one Agent backend run."""
|
||||
|
||||
def cancel_run_and_wait(
|
||||
self,
|
||||
run_id: str,
|
||||
request: CancelRunRequest | None = None,
|
||||
*,
|
||||
after: str | None = None,
|
||||
) -> RunCancelledEvent:
|
||||
"""Request cancellation and wait for runner cleanup to finish."""
|
||||
|
||||
def stream_events(
|
||||
self,
|
||||
run_id: str,
|
||||
@@ -67,6 +77,15 @@ class _DifyAgentSyncClient(Protocol):
|
||||
def cancel_run_sync(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
|
||||
"""Cancel one run synchronously."""
|
||||
|
||||
def cancel_run_and_wait_sync(
|
||||
self,
|
||||
run_id: str,
|
||||
request: CancelRunRequest | None = None,
|
||||
*,
|
||||
after: str | None = None,
|
||||
) -> RunCancelledEvent:
|
||||
"""Cancel one run and wait for its terminal event synchronously."""
|
||||
|
||||
def stream_events_sync(
|
||||
self,
|
||||
run_id: str,
|
||||
@@ -92,11 +111,9 @@ class DifyAgentBackendRunClient:
|
||||
client: _DifyAgentSyncClient,
|
||||
*,
|
||||
stream_max_reconnects: int = 3,
|
||||
stream_timeout_seconds: float = 1200,
|
||||
) -> None:
|
||||
self.client = client
|
||||
self._stream_max_reconnects = stream_max_reconnects
|
||||
self._stream_timeout_seconds = stream_timeout_seconds
|
||||
|
||||
def create_run(self, request: CreateRunRequest) -> CreateRunResponse:
|
||||
"""Create one run through ``POST /runs`` and normalize client exceptions."""
|
||||
@@ -112,6 +129,19 @@ class DifyAgentBackendRunClient:
|
||||
except Exception as exc:
|
||||
raise _normalize_dify_agent_error(exc) from exc
|
||||
|
||||
def cancel_run_and_wait(
|
||||
self,
|
||||
run_id: str,
|
||||
request: CancelRunRequest | None = None,
|
||||
*,
|
||||
after: str | None = None,
|
||||
) -> RunCancelledEvent:
|
||||
"""Cancel one run, then wait for the cleanup-complete terminal event."""
|
||||
try:
|
||||
return self.client.cancel_run_and_wait_sync(run_id, request=request, after=after)
|
||||
except Exception as exc:
|
||||
raise _normalize_dify_agent_error(exc) from exc
|
||||
|
||||
def stream_events(
|
||||
self,
|
||||
run_id: str,
|
||||
@@ -125,7 +155,6 @@ class DifyAgentBackendRunClient:
|
||||
run_id,
|
||||
after=after,
|
||||
max_reconnects=self._stream_max_reconnects,
|
||||
timeout_seconds=self._stream_timeout_seconds,
|
||||
should_stop=should_stop,
|
||||
)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -8,11 +8,40 @@ classes.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
|
||||
from dify_agent.client import DifyAgentHTTPError
|
||||
from dify_agent.protocol import RunFailureType
|
||||
|
||||
|
||||
def backend_reported_failure(exc: DifyAgentHTTPError) -> bool:
|
||||
"""Whether the backend itself reported the failure.
|
||||
|
||||
``DifyAgentValidationError`` is a ``DifyAgentHTTPError`` that the client also
|
||||
raises when a *successful* response fails DTO validation. Those carry a 2xx
|
||||
status and a Pydantic error list, not a backend-authored error envelope.
|
||||
"""
|
||||
return exc.status_code >= HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
def backend_error_detail(
|
||||
exc: DifyAgentHTTPError,
|
||||
*,
|
||||
default_code: str = "agent_backend_error",
|
||||
) -> tuple[str, str]:
|
||||
"""Split an Agent backend HTTP error into the code and message the backend itself sent."""
|
||||
detail = exc.detail
|
||||
if isinstance(detail, dict):
|
||||
code = detail.get("code")
|
||||
message = detail.get("message")
|
||||
return (
|
||||
code if isinstance(code, str) and code else default_code,
|
||||
message if isinstance(message, str) and message else str(exc),
|
||||
)
|
||||
return default_code, str(detail)
|
||||
|
||||
|
||||
class AgentBackendError(Exception):
|
||||
"""Base error for API-side Agent backend integration failures."""
|
||||
|
||||
|
||||
@@ -99,6 +99,8 @@ class AgentBackendRunFailedInternalEvent(AgentBackendInternalEventBase):
|
||||
error: str
|
||||
error_type: RunFailureType | None = None
|
||||
reason: str | None = None
|
||||
session_snapshot: CompositorSessionSnapshot | None = None
|
||||
usage: dict[str, JsonValue] | None = None
|
||||
|
||||
|
||||
class AgentBackendRunCancelledInternalEvent(AgentBackendInternalEventBase):
|
||||
@@ -107,6 +109,8 @@ class AgentBackendRunCancelledInternalEvent(AgentBackendInternalEventBase):
|
||||
type: Literal[AgentBackendInternalEventType.RUN_CANCELLED] = AgentBackendInternalEventType.RUN_CANCELLED
|
||||
reason: str | None = None
|
||||
message: str | None = None
|
||||
session_snapshot: CompositorSessionSnapshot | None = None
|
||||
usage: dict[str, JsonValue] | None = None
|
||||
|
||||
|
||||
type AgentBackendInternalEvent = Annotated[
|
||||
@@ -184,6 +188,8 @@ class AgentBackendRunEventAdapter:
|
||||
error=event.data.error,
|
||||
error_type=event.data.error_type,
|
||||
reason=event.data.reason,
|
||||
session_snapshot=event.data.session_snapshot,
|
||||
usage=_agent_run_usage(event.data.usage),
|
||||
)
|
||||
]
|
||||
case RunCancelledEvent():
|
||||
@@ -193,6 +199,8 @@ class AgentBackendRunEventAdapter:
|
||||
source_event_id=event.id,
|
||||
reason=event.data.reason,
|
||||
message=event.data.message,
|
||||
session_snapshot=event.data.session_snapshot,
|
||||
usage=_agent_run_usage(event.data.usage),
|
||||
)
|
||||
]
|
||||
raise TypeError(f"unsupported agent backend run event: {type(event).__name__}")
|
||||
|
||||
@@ -8,6 +8,25 @@ from clients.agent_backend.client import AgentBackendRunClient, DifyAgentBackend
|
||||
from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAgentBackendScenario
|
||||
|
||||
|
||||
def create_agent_backend_client(
|
||||
*,
|
||||
base_url: str,
|
||||
api_token: str | None = None,
|
||||
stream_timeout: float = 30,
|
||||
timeout: float = 30.0,
|
||||
binding_file_download_timeout: float = 240,
|
||||
) -> Client:
|
||||
api_token = api_token.strip() if api_token else None
|
||||
headers = {"Authorization": f"Bearer {api_token}"} if api_token else None
|
||||
return Client(
|
||||
base_url=base_url,
|
||||
stream_timeout=stream_timeout,
|
||||
timeout=timeout,
|
||||
binding_file_download_timeout=binding_file_download_timeout,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
def create_agent_backend_run_client(
|
||||
*,
|
||||
base_url: str | None = None,
|
||||
@@ -16,18 +35,17 @@ def create_agent_backend_run_client(
|
||||
fake_scenario: str | FakeAgentBackendScenario = FakeAgentBackendScenario.SUCCESS,
|
||||
stream_read_timeout_seconds: float = 30,
|
||||
stream_max_reconnects: int = 3,
|
||||
stream_run_timeout_seconds: float = 1200,
|
||||
) -> AgentBackendRunClient:
|
||||
"""Create the API-side run client without hiding the ``dify-agent`` protocol."""
|
||||
if use_fake:
|
||||
return FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario(fake_scenario))
|
||||
if base_url is None:
|
||||
raise ValueError("base_url is required when creating a real Agent backend client")
|
||||
headers: dict[str, str] = {}
|
||||
if api_token:
|
||||
headers["Authorization"] = f"Bearer {api_token}"
|
||||
return DifyAgentBackendRunClient(
|
||||
Client(base_url=base_url, stream_timeout=stream_read_timeout_seconds, headers=headers),
|
||||
create_agent_backend_client(
|
||||
base_url=base_url,
|
||||
api_token=api_token,
|
||||
stream_timeout=stream_read_timeout_seconds,
|
||||
),
|
||||
stream_max_reconnects=stream_max_reconnects,
|
||||
stream_timeout_seconds=stream_run_timeout_seconds,
|
||||
)
|
||||
|
||||
@@ -18,6 +18,8 @@ from dify_agent.protocol import (
|
||||
CreateRunRequest,
|
||||
CreateRunResponse,
|
||||
DeferredToolCallPayload,
|
||||
RunCancelledEvent,
|
||||
RunCancelledEventData,
|
||||
RunEvent,
|
||||
RunFailedEvent,
|
||||
RunFailedEventData,
|
||||
@@ -69,6 +71,28 @@ class FakeAgentBackendRunClient:
|
||||
del request
|
||||
return CancelRunResponse(run_id=run_id, status="cancelled")
|
||||
|
||||
def cancel_run_and_wait(
|
||||
self,
|
||||
run_id: str,
|
||||
request: CancelRunRequest | None = None,
|
||||
*,
|
||||
after: str | None = None,
|
||||
) -> RunCancelledEvent:
|
||||
"""Return a deterministic cleanup-complete cancellation event."""
|
||||
del after
|
||||
request = request or CancelRunRequest()
|
||||
_ = self.cancel_run(run_id, request)
|
||||
return RunCancelledEvent(
|
||||
id="cancel-0",
|
||||
run_id=run_id,
|
||||
created_at=_FIXED_TIME,
|
||||
data=RunCancelledEventData(
|
||||
reason=request.reason,
|
||||
message=request.message,
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||
),
|
||||
)
|
||||
|
||||
def stream_events(
|
||||
self,
|
||||
run_id: str,
|
||||
@@ -133,7 +157,11 @@ class FakeAgentBackendRunClient:
|
||||
id="2-0",
|
||||
run_id=run_id,
|
||||
created_at=_FIXED_TIME,
|
||||
data=RunFailedEventData(error="fake failure", reason="unit_test"),
|
||||
data=RunFailedEventData(
|
||||
error="fake failure",
|
||||
reason="unit_test",
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||
),
|
||||
),
|
||||
)
|
||||
case FakeAgentBackendScenario.PAUSED:
|
||||
|
||||
@@ -25,11 +25,9 @@ from dify_agent.layers.dify_core_tools import DIFY_CORE_TOOLS_LAYER_TYPE_ID, Dif
|
||||
from dify_agent.layers.dify_plugin import (
|
||||
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
||||
DifyPluginCredentialValue,
|
||||
DifyPluginLLMLayerConfig,
|
||||
DifyPluginToolsLayerConfig,
|
||||
)
|
||||
from dify_agent.layers.drive import DIFY_DRIVE_LAYER_TYPE_ID, DifyDriveLayerConfig
|
||||
from dify_agent.layers.execution_context import (
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
DifyExecutionContextLayerConfig,
|
||||
@@ -57,7 +55,6 @@ AGENT_APP_USER_PROMPT_LAYER_ID = "agent_app_user_prompt"
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context"
|
||||
DIFY_RUNTIME_LAYER_ID = "runtime"
|
||||
DIFY_CONFIG_LAYER_ID = "config"
|
||||
DIFY_DRIVE_LAYER_ID = "drive"
|
||||
DIFY_PLUGIN_TOOLS_LAYER_ID = "tools"
|
||||
DIFY_CORE_TOOLS_LAYER_ID = "core_tools"
|
||||
DIFY_KNOWLEDGE_BASE_LAYER_ID = "knowledge"
|
||||
@@ -73,24 +70,10 @@ def _shell_layer_deps() -> dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
def _drive_layer_deps() -> dict[str, str]:
|
||||
return {"shell": DIFY_SHELL_LAYER_ID}
|
||||
|
||||
|
||||
def _config_layer_deps() -> dict[str, str]:
|
||||
return {"shell": DIFY_SHELL_LAYER_ID}
|
||||
|
||||
|
||||
def _shell_config_with_drive_ref(
|
||||
shell_config: DifyShellLayerConfig | None,
|
||||
drive_config: DifyDriveLayerConfig | None,
|
||||
) -> DifyShellLayerConfig:
|
||||
config = shell_config or DifyShellLayerConfig()
|
||||
if drive_config is None:
|
||||
return config
|
||||
return config.model_copy(update={"agent_stub_drive_ref": drive_config.drive_ref})
|
||||
|
||||
|
||||
def _markdown_backtick_fence(text: str) -> str:
|
||||
"""Choose a fence that will not terminate inside the prompt body."""
|
||||
longest_backtick_run = max((len(match.group(0)) for match in re.finditer(r"`+", text)), default=0)
|
||||
@@ -150,8 +133,8 @@ class AgentBackendModelConfig(BaseModel):
|
||||
plugin_id: str
|
||||
model_provider: str
|
||||
model: str
|
||||
credentials: dict[str, DifyPluginCredentialValue] = Field(default_factory=dict)
|
||||
model_settings: dict[str, JsonValue] = Field(default_factory=dict)
|
||||
context_window_tokens: int | None = Field(default=None, gt=0)
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -159,8 +142,12 @@ class AgentBackendModelConfig(BaseModel):
|
||||
# ``DifyPluginLLMLayerConfig.model_settings`` is pydantic_ai's ``ModelSettings``
|
||||
# TypedDict (closed: unknown keys are rejected, explicit ``None`` values fail the
|
||||
# per-field type checks). Agent Soul model settings carry a wider, nullable shape
|
||||
# (``stop`` / ``response_format`` plus null-padded fields), so the layer config
|
||||
# only receives the keys the runtime contract accepts.
|
||||
# (``stop`` / ``response_format`` plus null-padded fields, plus arbitrary
|
||||
# plugin-declared parameters such as Qwen's ``enable_thinking``), so the layer
|
||||
# config only receives the keys the runtime contract accepts directly; anything
|
||||
# else is forwarded through ``extra_body``, the TypedDict's own escape hatch for
|
||||
# provider-specific parameters (see
|
||||
# ``dify_agent.adapters.llm.model._map_model_settings_to_parameters``).
|
||||
_AGENT_MODEL_SETTINGS_PASSTHROUGH_KEYS = (
|
||||
"temperature",
|
||||
"top_p",
|
||||
@@ -168,6 +155,7 @@ _AGENT_MODEL_SETTINGS_PASSTHROUGH_KEYS = (
|
||||
"frequency_penalty",
|
||||
"max_tokens",
|
||||
)
|
||||
_AGENT_MODEL_SETTINGS_KNOWN_KEYS = frozenset({*_AGENT_MODEL_SETTINGS_PASSTHROUGH_KEYS, "stop", "response_format"})
|
||||
|
||||
|
||||
def _agent_model_settings(settings: Mapping[str, JsonValue]) -> dict[str, JsonValue] | None:
|
||||
@@ -177,6 +165,15 @@ def _agent_model_settings(settings: Mapping[str, JsonValue]) -> dict[str, JsonVa
|
||||
stop = settings.get("stop")
|
||||
if isinstance(stop, list) and stop:
|
||||
sanitized["stop_sequences"] = stop
|
||||
|
||||
extra_body: dict[str, JsonValue] = {
|
||||
key: value
|
||||
for key, value in settings.items()
|
||||
if key not in _AGENT_MODEL_SETTINGS_KNOWN_KEYS and value is not None
|
||||
}
|
||||
if extra_body:
|
||||
sanitized["extra_body"] = extra_body
|
||||
|
||||
return sanitized or None
|
||||
|
||||
|
||||
@@ -211,9 +208,6 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
|
||||
core_tools: DifyCoreToolsLayerConfig | None = None
|
||||
knowledge: DifyKnowledgeBaseLayerConfig | None = None
|
||||
config_layer_config: DifyConfigLayerConfig | None = None
|
||||
# Drive Skills & Files declaration (dify.drive) — an index the agent pulls
|
||||
# through the back proxy, never inline content.
|
||||
drive_config: DifyDriveLayerConfig | None = None
|
||||
# Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when
|
||||
# the Agent Soul configures human involvement; a deferred call ends the run and
|
||||
# the workflow pauses via the existing HITL form mechanism (ENG-635).
|
||||
@@ -260,9 +254,6 @@ class AgentBackendAgentAppRunInput(BaseModel):
|
||||
core_tools: DifyCoreToolsLayerConfig | None = None
|
||||
knowledge: DifyKnowledgeBaseLayerConfig | None = None
|
||||
config_layer_config: DifyConfigLayerConfig | None = None
|
||||
# Drive Skills & Files declaration (dify.drive) — an index the agent pulls
|
||||
# through the back proxy, never inline content.
|
||||
drive_config: DifyDriveLayerConfig | None = None
|
||||
# Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when
|
||||
# the Agent Soul configures human involvement (ENG-635).
|
||||
ask_human_config: DifyAskHumanLayerConfig | None = None
|
||||
@@ -294,7 +285,7 @@ class AgentBackendRunRequestBuilder:
|
||||
"""Build an Agent App conversation-turn run request.
|
||||
|
||||
Layer graph: optional Agent Soul system prompt → user prompt →
|
||||
execution context → optional shell / config / drive / history
|
||||
execution context → optional shell / config / history
|
||||
(multi-turn) → LLM → optional plugin-direct tools / core-routed tools /
|
||||
knowledge search / ask_human / structured output. Mirrors the
|
||||
workflow-node layer ordering minus the workflow-job / previous-node
|
||||
@@ -332,9 +323,7 @@ class AgentBackendRunRequestBuilder:
|
||||
]
|
||||
)
|
||||
|
||||
include_shell = (
|
||||
run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None
|
||||
)
|
||||
include_shell = run_input.include_shell or run_input.config_layer_config is not None
|
||||
if include_shell:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
@@ -344,16 +333,15 @@ class AgentBackendRunRequestBuilder:
|
||||
config=DifyRuntimeLayerConfig(backend_binding_ref=run_input.backend_binding_ref),
|
||||
)
|
||||
)
|
||||
# Sandboxed bash workspace (dify.shell). It enters before config/drive
|
||||
# so eager pulls materialize content in the same filesystem used by
|
||||
# model commands.
|
||||
# Sandboxed bash workspace (dify.shell). It enters before config so
|
||||
# eager pulls materialize content in the same filesystem used by model commands.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_SHELL_LAYER_ID,
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
deps=_shell_layer_deps(),
|
||||
metadata=run_input.metadata,
|
||||
config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config),
|
||||
config=run_input.shell_config or DifyShellLayerConfig(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -368,19 +356,6 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.drive_config is not None:
|
||||
# Drive Skills & Files declaration (dify.drive): the catalog plus
|
||||
# prompt-mentioned entries eagerly pulled through the shell layer.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_DRIVE_LAYER_ID,
|
||||
type=DIFY_DRIVE_LAYER_TYPE_ID,
|
||||
deps=_drive_layer_deps(),
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.drive_config,
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.include_history:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
@@ -400,8 +375,8 @@ class AgentBackendRunRequestBuilder:
|
||||
plugin_id=run_input.model.plugin_id,
|
||||
model_provider=run_input.model.model_provider,
|
||||
model=run_input.model.model,
|
||||
credentials=run_input.model.credentials,
|
||||
model_settings=_agent_model_settings(run_input.model.model_settings),
|
||||
context_window_tokens=run_input.model.context_window_tokens,
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -482,7 +457,7 @@ class AgentBackendRunRequestBuilder:
|
||||
"""Build a workflow Agent Node run request without defining another wire schema.
|
||||
|
||||
Layer graph mirrors the workflow surface: prompts → execution context →
|
||||
optional shell / config / drive / history → LLM → optional
|
||||
optional shell / config / history → LLM → optional
|
||||
plugin-direct tools / core-routed tools / knowledge search /
|
||||
ask_human / structured output.
|
||||
"""
|
||||
@@ -524,9 +499,7 @@ class AgentBackendRunRequestBuilder:
|
||||
]
|
||||
)
|
||||
|
||||
include_shell = (
|
||||
run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None
|
||||
)
|
||||
include_shell = run_input.include_shell or run_input.config_layer_config is not None
|
||||
if include_shell:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
@@ -536,16 +509,15 @@ class AgentBackendRunRequestBuilder:
|
||||
config=DifyRuntimeLayerConfig(backend_binding_ref=run_input.backend_binding_ref),
|
||||
)
|
||||
)
|
||||
# Sandboxed bash workspace (dify.shell). It enters before drive so
|
||||
# drive can materialize mentioned targets with `dify-agent drive pull`
|
||||
# in the same shell-visible filesystem used by model commands.
|
||||
# Sandboxed bash workspace (dify.shell). It enters before config so
|
||||
# eager pulls materialize content in the same filesystem used by model commands.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_SHELL_LAYER_ID,
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
deps=_shell_layer_deps(),
|
||||
metadata=run_input.metadata,
|
||||
config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config),
|
||||
config=run_input.shell_config or DifyShellLayerConfig(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -560,19 +532,6 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.drive_config is not None:
|
||||
# Drive Skills & Files declaration (dify.drive): the catalog plus
|
||||
# prompt-mentioned entries eagerly pulled through the shell layer.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_DRIVE_LAYER_ID,
|
||||
type=DIFY_DRIVE_LAYER_TYPE_ID,
|
||||
deps=_drive_layer_deps(),
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.drive_config,
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.include_history:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
@@ -593,8 +552,8 @@ class AgentBackendRunRequestBuilder:
|
||||
plugin_id=run_input.model.plugin_id,
|
||||
model_provider=run_input.model.model_provider,
|
||||
model=run_input.model.model,
|
||||
credentials=run_input.model.credentials,
|
||||
model_settings=_agent_model_settings(run_input.model.model_settings),
|
||||
context_window_tokens=run_input.model.context_window_tokens,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -22,7 +22,12 @@ from .plugin import (
|
||||
setup_system_trigger_oauth_client,
|
||||
transform_datasource_credentials,
|
||||
)
|
||||
from .rbac import migrate_dataset_permissions_to_rbac, migrate_member_roles_to_rbac
|
||||
from .rbac import (
|
||||
migrate_dataset_permissions_to_rbac,
|
||||
migrate_member_roles_to_rbac,
|
||||
migrate_only_me_resource_whitelist_scopes_to_automatic_include,
|
||||
migrate_resource_whitelist_scopes_to_automatic_include,
|
||||
)
|
||||
from .retention import (
|
||||
archive_workflow_runs,
|
||||
archive_workflow_runs_plan,
|
||||
@@ -81,7 +86,9 @@ __all__ = [
|
||||
"migrate_dataset_permissions_to_rbac",
|
||||
"migrate_knowledge_vector_database",
|
||||
"migrate_member_roles_to_rbac",
|
||||
"migrate_only_me_resource_whitelist_scopes_to_automatic_include",
|
||||
"migrate_oss",
|
||||
"migrate_resource_whitelist_scopes_to_automatic_include",
|
||||
"migration_data_wizard",
|
||||
"old_metadata_migration",
|
||||
"remove_orphaned_files_on_storage",
|
||||
|
||||
@@ -8,6 +8,7 @@ from constants.languages import languages
|
||||
from extensions.ext_database import db
|
||||
from libs.helper import email as email_validate
|
||||
from libs.password import hash_password, password_pattern, valid_password
|
||||
from services.account_email import normalize_email
|
||||
from services.account_service import AccountService, RegisterService, TenantService
|
||||
|
||||
|
||||
@@ -20,7 +21,7 @@ def reset_password(email, new_password, password_confirm):
|
||||
Reset password of owner account
|
||||
Only available in SELF_HOSTED mode
|
||||
"""
|
||||
if str(new_password).strip() != str(password_confirm).strip():
|
||||
if new_password.strip() != password_confirm.strip():
|
||||
click.echo(click.style("Passwords do not match.", fg="red"))
|
||||
return
|
||||
normalized_email = email.strip().lower()
|
||||
@@ -33,7 +34,7 @@ def reset_password(email, new_password, password_confirm):
|
||||
|
||||
try:
|
||||
valid_password(new_password)
|
||||
except:
|
||||
except ValueError:
|
||||
click.echo(click.style(f"Invalid password. Must match {password_pattern}", fg="red"))
|
||||
return
|
||||
|
||||
@@ -62,7 +63,7 @@ def reset_email(email, new_email, email_confirm):
|
||||
Replace account email
|
||||
:return:
|
||||
"""
|
||||
if str(new_email).strip() != str(email_confirm).strip():
|
||||
if new_email.strip() != email_confirm.strip():
|
||||
click.echo(click.style("New emails do not match.", fg="red"))
|
||||
return
|
||||
normalized_new_email = new_email.strip().lower()
|
||||
@@ -75,13 +76,14 @@ def reset_email(email, new_email, email_confirm):
|
||||
|
||||
try:
|
||||
email_validate(normalized_new_email)
|
||||
except:
|
||||
except ValueError:
|
||||
click.echo(click.style(f"Invalid email: {new_email}", fg="red"))
|
||||
return
|
||||
|
||||
with Session(db.engine) as session:
|
||||
account = session.merge(account)
|
||||
account.email = normalized_new_email
|
||||
account.normalized_email = normalize_email(normalized_new_email)
|
||||
session.commit()
|
||||
click.echo(click.style("Email updated successfully.", fg="green"))
|
||||
|
||||
|
||||
@@ -7,7 +7,11 @@ from typing import cast
|
||||
|
||||
import click
|
||||
|
||||
from commands.rbac import migrate_dataset_permissions_to_rbac
|
||||
from commands.rbac import (
|
||||
migrate_dataset_permissions_to_rbac,
|
||||
migrate_only_me_resource_whitelist_scopes_to_automatic_include,
|
||||
migrate_resource_whitelist_scopes_to_automatic_include, # noqa: F401
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from services.legacy_model_type_migration import (
|
||||
@@ -179,3 +183,4 @@ def legacy_model_types(
|
||||
|
||||
data_migrate.add_command(legacy_model_types)
|
||||
data_migrate.add_command(migrate_dataset_permissions_to_rbac)
|
||||
data_migrate.add_command(migrate_only_me_resource_whitelist_scopes_to_automatic_include)
|
||||
|
||||
@@ -11,10 +11,11 @@ from sqlalchemy.orm import Session
|
||||
from configs import dify_config
|
||||
from core.db.session_factory import session_factory
|
||||
from core.rbac import RBACResourceWhitelistScope
|
||||
from models import Dataset, DatasetPermission, DatasetPermissionEnum, TenantAccountJoin, TenantAccountRole
|
||||
from models import App, Dataset, DatasetPermission, DatasetPermissionEnum, TenantAccountJoin, TenantAccountRole
|
||||
from services.enterprise.rbac_service import ListOption, RBACService, ReplaceMemberBindings, ReplaceUserAccessPolicies
|
||||
|
||||
_RBAC_DEFAULT_ACCESS_POLICY_ID = "default"
|
||||
_RBAC_RESOURCE_ACCESS_POLICY_BATCH_SIZE = 500
|
||||
|
||||
_LEGACY_ROLE_TO_BUILTIN_TAG = {
|
||||
TenantAccountRole.OWNER.value: "owner",
|
||||
@@ -310,6 +311,499 @@ def _emit_dataset_permission_migration_event(payload: dict[str, object]) -> None
|
||||
click.echo(json.dumps(payload, sort_keys=True))
|
||||
|
||||
|
||||
def _emit_resource_whitelist_scope_migration_event(payload: dict[str, object]) -> None:
|
||||
click.echo(json.dumps(payload, sort_keys=True))
|
||||
|
||||
|
||||
def _normalize_rbac_whitelist_scope(scope: object) -> RBACResourceWhitelistScope | None:
|
||||
if scope is None:
|
||||
return None
|
||||
try:
|
||||
return RBACResourceWhitelistScope(str(scope))
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Unsupported RBAC whitelist scope: {scope}") from exc
|
||||
|
||||
|
||||
def _owner_account_id(tenant_id: str, *, session: Session) -> str:
|
||||
account_id = session.scalar(
|
||||
select(TenantAccountJoin.account_id)
|
||||
.where(TenantAccountJoin.tenant_id == tenant_id, TenantAccountJoin.role == TenantAccountRole.OWNER)
|
||||
.order_by(TenantAccountJoin.id.asc())
|
||||
.limit(1)
|
||||
)
|
||||
if not account_id:
|
||||
raise ValueError(f"Workspace owner not found for tenant={tenant_id}")
|
||||
return str(account_id)
|
||||
|
||||
|
||||
def _workspace_member_account_id_batches(tenant_id: str, batch_size: int) -> Iterator[list[str]]:
|
||||
last_join_id: str | None = None
|
||||
while True:
|
||||
with session_factory.create_session() as session:
|
||||
stmt = (
|
||||
select(TenantAccountJoin.id, TenantAccountJoin.account_id)
|
||||
.where(TenantAccountJoin.tenant_id == tenant_id)
|
||||
.order_by(TenantAccountJoin.id.asc())
|
||||
.limit(batch_size)
|
||||
)
|
||||
if last_join_id:
|
||||
stmt = stmt.where(TenantAccountJoin.id > last_join_id)
|
||||
|
||||
rows = list(session.execute(stmt).all())
|
||||
if not rows:
|
||||
return
|
||||
|
||||
yield [str(row.account_id) for row in rows]
|
||||
last_join_id = str(rows[-1].id)
|
||||
|
||||
|
||||
def _replace_resource_whitelist(
|
||||
resource_type: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
operator_account_id: str,
|
||||
resource_id: str,
|
||||
automatic_include_workspace_members: bool,
|
||||
) -> None:
|
||||
payload = ReplaceMemberBindings(automatic_include_workspace_members=automatic_include_workspace_members)
|
||||
if resource_type == "app":
|
||||
RBACService.AppAccess.replace_whitelist(
|
||||
tenant_id=tenant_id,
|
||||
account_id=operator_account_id,
|
||||
app_id=resource_id,
|
||||
payload=payload,
|
||||
)
|
||||
return
|
||||
RBACService.DatasetAccess.replace_whitelist(
|
||||
tenant_id=tenant_id,
|
||||
account_id=operator_account_id,
|
||||
dataset_id=resource_id,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
def _replace_resource_default_access_policies(
|
||||
resource_type: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
operator_account_id: str,
|
||||
resource_id: str,
|
||||
account_ids: list[str],
|
||||
) -> None:
|
||||
if not account_ids:
|
||||
return
|
||||
payload = ReplaceUserAccessPolicies(
|
||||
access_policy_ids=[_RBAC_DEFAULT_ACCESS_POLICY_ID],
|
||||
account_ids=account_ids,
|
||||
)
|
||||
if resource_type == "app":
|
||||
RBACService.AppAccess.replace_user_access_policies(
|
||||
tenant_id=tenant_id,
|
||||
account_id=operator_account_id,
|
||||
app_id=resource_id,
|
||||
target_account_id=None,
|
||||
payload=payload,
|
||||
)
|
||||
return
|
||||
RBACService.DatasetAccess.replace_user_access_policies(
|
||||
tenant_id=tenant_id,
|
||||
account_id=operator_account_id,
|
||||
dataset_id=resource_id,
|
||||
target_account_id=None,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
def _resource_legacy_whitelist_config(
|
||||
resource_type: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
operator_account_id: str,
|
||||
resource_id: str,
|
||||
):
|
||||
if resource_type == "app":
|
||||
return RBACService.AppAccess.legacy_whitelist_config(
|
||||
tenant_id=tenant_id,
|
||||
account_id=operator_account_id,
|
||||
app_id=resource_id,
|
||||
)
|
||||
return RBACService.DatasetAccess.legacy_whitelist_config(
|
||||
tenant_id=tenant_id,
|
||||
account_id=operator_account_id,
|
||||
dataset_id=resource_id,
|
||||
)
|
||||
|
||||
|
||||
def _iter_rbac_resource_rows(
|
||||
resource_type: str,
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
resource_id: str | None,
|
||||
batch_size: int,
|
||||
) -> Iterator[tuple[str, str, str, str | None]]:
|
||||
model = App if resource_type == "app" else Dataset
|
||||
last_resource_id: str | None = None
|
||||
while True:
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(model.id, model.tenant_id, model.maintainer).order_by(model.id.asc()).limit(batch_size)
|
||||
if tenant_id:
|
||||
stmt = stmt.where(model.tenant_id == tenant_id)
|
||||
if resource_id:
|
||||
stmt = stmt.where(model.id == resource_id)
|
||||
if last_resource_id:
|
||||
stmt = stmt.where(model.id > last_resource_id)
|
||||
|
||||
rows = list(session.execute(stmt).all())
|
||||
if not rows:
|
||||
return
|
||||
|
||||
for row in rows:
|
||||
yield resource_type, str(row.tenant_id), str(row.id), str(row.maintainer) if row.maintainer else None
|
||||
|
||||
last_resource_id = str(rows[-1].id)
|
||||
if resource_id:
|
||||
return
|
||||
|
||||
|
||||
def _iter_selected_rbac_resource_rows(
|
||||
resource_type: str,
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
resource_id: str | None,
|
||||
batch_size: int,
|
||||
) -> Iterator[tuple[str, str, str, str | None]]:
|
||||
if resource_type in {"app", "all"}:
|
||||
yield from _iter_rbac_resource_rows(
|
||||
"app",
|
||||
tenant_id=tenant_id,
|
||||
resource_id=resource_id if resource_type == "app" else None,
|
||||
batch_size=batch_size,
|
||||
)
|
||||
if resource_type in {"dataset", "all"}:
|
||||
yield from _iter_rbac_resource_rows(
|
||||
"dataset",
|
||||
tenant_id=tenant_id,
|
||||
resource_id=resource_id if resource_type == "dataset" else None,
|
||||
batch_size=batch_size,
|
||||
)
|
||||
|
||||
|
||||
@click.command(
|
||||
"rbac-migrate-resource-whitelist-scopes",
|
||||
help=(
|
||||
"Migrate RBAC app/dataset whitelist scope values to automatic_include_workspace_members. "
|
||||
"Old scope all becomes true; specific and only_me become false."
|
||||
),
|
||||
)
|
||||
@click.option("--tenant-id", help="Only migrate resources in a single workspace.")
|
||||
@click.option(
|
||||
"--resource-type",
|
||||
type=click.Choice(["app", "dataset", "all"]),
|
||||
default="all",
|
||||
show_default=True,
|
||||
help="Resource type to migrate.",
|
||||
)
|
||||
@click.option("--resource-id", help="Only migrate a single resource. Requires --resource-type app or dataset.")
|
||||
@click.option("--batch-size", default=500, show_default=True, type=click.IntRange(min=1))
|
||||
@click.option(
|
||||
"--member-batch-size",
|
||||
default=_RBAC_RESOURCE_ACCESS_POLICY_BATCH_SIZE,
|
||||
show_default=True,
|
||||
type=click.IntRange(min=1),
|
||||
help="Workspace members written per default-policy call when migrating old scope all.",
|
||||
)
|
||||
@click.option(
|
||||
"--dry-run/--apply",
|
||||
default=True,
|
||||
show_default=True,
|
||||
help="Preview the migration without writing RBAC bindings. Use --apply to write changes.",
|
||||
)
|
||||
def migrate_resource_whitelist_scopes_to_automatic_include(
|
||||
tenant_id: str | None,
|
||||
resource_type: str,
|
||||
resource_id: str | None,
|
||||
batch_size: int,
|
||||
member_batch_size: int,
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
"""Backfill RBAC app/dataset automatic workspace-member inclusion from old RBAC scope."""
|
||||
if resource_id and resource_type == "all":
|
||||
raise click.BadParameter("--resource-id requires --resource-type app or dataset", param_hint="--resource-id")
|
||||
|
||||
click.echo(click.style("Starting RBAC resource whitelist scope migration.", fg="green"))
|
||||
|
||||
scanned_count = 0
|
||||
migrated_count = 0
|
||||
member_policy_batch_count = 0
|
||||
owner_account_ids_by_tenant_id: dict[str, str] = {}
|
||||
|
||||
for (
|
||||
current_resource_type,
|
||||
workspace_id,
|
||||
current_resource_id,
|
||||
maintainer_account_id,
|
||||
) in _iter_selected_rbac_resource_rows(
|
||||
resource_type,
|
||||
tenant_id=tenant_id,
|
||||
resource_id=resource_id,
|
||||
batch_size=batch_size,
|
||||
):
|
||||
scanned_count += 1
|
||||
with session_factory.create_session() as session:
|
||||
operator_account_id = maintainer_account_id or owner_account_ids_by_tenant_id.get(workspace_id)
|
||||
if not operator_account_id:
|
||||
operator_account_id = _owner_account_id(workspace_id, session=session)
|
||||
owner_account_ids_by_tenant_id[workspace_id] = operator_account_id
|
||||
|
||||
legacy_config = _resource_legacy_whitelist_config(
|
||||
current_resource_type,
|
||||
tenant_id=workspace_id,
|
||||
operator_account_id=operator_account_id,
|
||||
resource_id=current_resource_id,
|
||||
)
|
||||
scope = _normalize_rbac_whitelist_scope(legacy_config.rbac_whitelist_scope)
|
||||
if scope is None:
|
||||
_emit_resource_whitelist_scope_migration_event(
|
||||
{
|
||||
"event": "resource_whitelist_scope_migration_skipped",
|
||||
"reason": "missing_legacy_scope",
|
||||
"dry_run": dry_run,
|
||||
"tenant_id": workspace_id,
|
||||
"resource_type": current_resource_type,
|
||||
"resource_id": current_resource_id,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
automatic_include_workspace_members = scope is RBACResourceWhitelistScope.ALL
|
||||
if scope is RBACResourceWhitelistScope.SPECIFIC:
|
||||
member_account_ids = sorted(set(legacy_config.account_ids))
|
||||
member_source = "legacy_specific_members"
|
||||
elif scope is RBACResourceWhitelistScope.ONLY_ME:
|
||||
member_account_ids = [maintainer_account_id] if maintainer_account_id else []
|
||||
member_source = "resource_maintainer"
|
||||
else:
|
||||
member_account_ids = []
|
||||
member_source = "workspace_members"
|
||||
|
||||
_emit_resource_whitelist_scope_migration_event(
|
||||
{
|
||||
"event": "resource_whitelist_scope_migration_proposed_change",
|
||||
"dry_run": dry_run,
|
||||
"tenant_id": workspace_id,
|
||||
"operator_account_id": operator_account_id,
|
||||
"resource_type": current_resource_type,
|
||||
"resource_id": current_resource_id,
|
||||
"before": {
|
||||
"rbac_whitelist_scope": scope.value,
|
||||
"legacy_account_ids": sorted(set(legacy_config.account_ids)),
|
||||
},
|
||||
"after": {
|
||||
"automatic_include_workspace_members": automatic_include_workspace_members,
|
||||
"default_policy_member_source": member_source,
|
||||
"default_policy_account_ids": member_account_ids,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
_replace_resource_whitelist(
|
||||
current_resource_type,
|
||||
tenant_id=workspace_id,
|
||||
operator_account_id=operator_account_id,
|
||||
resource_id=current_resource_id,
|
||||
automatic_include_workspace_members=automatic_include_workspace_members,
|
||||
)
|
||||
migrated_count += 1
|
||||
|
||||
if scope is RBACResourceWhitelistScope.ALL:
|
||||
for batch in _workspace_member_account_id_batches(workspace_id, member_batch_size):
|
||||
_replace_resource_default_access_policies(
|
||||
current_resource_type,
|
||||
tenant_id=workspace_id,
|
||||
operator_account_id=operator_account_id,
|
||||
resource_id=current_resource_id,
|
||||
account_ids=batch,
|
||||
)
|
||||
member_policy_batch_count += 1
|
||||
else:
|
||||
_replace_resource_default_access_policies(
|
||||
current_resource_type,
|
||||
tenant_id=workspace_id,
|
||||
operator_account_id=operator_account_id,
|
||||
resource_id=current_resource_id,
|
||||
account_ids=member_account_ids,
|
||||
)
|
||||
if member_account_ids:
|
||||
member_policy_batch_count += 1
|
||||
|
||||
if scanned_count == 0:
|
||||
click.echo(click.style("No RBAC resources found for migration.", fg="yellow"))
|
||||
return
|
||||
|
||||
if dry_run:
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Dry run completed. Scanned {scanned_count} RBAC resources. No RBAC bindings were written.",
|
||||
fg="yellow",
|
||||
)
|
||||
)
|
||||
else:
|
||||
click.echo(
|
||||
click.style(
|
||||
"RBAC resource whitelist scope migration completed. "
|
||||
f"Scanned {scanned_count} resources, migrated {migrated_count}, "
|
||||
f"wrote {member_policy_batch_count} default-policy batches.",
|
||||
fg="green",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@click.command(
|
||||
"rbac-migrate-resource-whitelist-scopes",
|
||||
help=(
|
||||
"Migrate RBAC app/dataset whitelist configs whose old scope is only_me to "
|
||||
"automatic_include_workspace_members=true and sync workspace members into the whitelist."
|
||||
),
|
||||
)
|
||||
@click.option("--tenant-id", help="Only migrate resources in a single workspace.")
|
||||
@click.option(
|
||||
"--resource-type",
|
||||
type=click.Choice(["app", "dataset", "all"]),
|
||||
default="all",
|
||||
show_default=True,
|
||||
help="Resource type to migrate.",
|
||||
)
|
||||
@click.option("--resource-id", help="Only migrate a single resource. Requires --resource-type app or dataset.")
|
||||
@click.option("--batch-size", default=500, show_default=True, type=click.IntRange(min=1))
|
||||
@click.option(
|
||||
"--member-batch-size",
|
||||
default=_RBAC_RESOURCE_ACCESS_POLICY_BATCH_SIZE,
|
||||
show_default=True,
|
||||
type=click.IntRange(min=1),
|
||||
help="Workspace members written per default-policy call.",
|
||||
)
|
||||
@click.option(
|
||||
"--dry-run/--apply",
|
||||
default=True,
|
||||
show_default=True,
|
||||
help="Preview the migration without writing RBAC bindings. Use --apply to write changes.",
|
||||
)
|
||||
def migrate_only_me_resource_whitelist_scopes_to_automatic_include(
|
||||
tenant_id: str | None,
|
||||
resource_type: str,
|
||||
resource_id: str | None,
|
||||
batch_size: int,
|
||||
member_batch_size: int,
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
"""Backfill automatic workspace-member inclusion for old RBAC only_me resource scopes."""
|
||||
if resource_id and resource_type == "all":
|
||||
raise click.BadParameter("--resource-id requires --resource-type app or dataset", param_hint="--resource-id")
|
||||
|
||||
click.echo(click.style("Starting RBAC only_me resource whitelist scope migration.", fg="green"))
|
||||
|
||||
scanned_count = 0
|
||||
only_me_count = 0
|
||||
migrated_count = 0
|
||||
member_policy_batch_count = 0
|
||||
owner_account_ids_by_tenant_id: dict[str, str] = {}
|
||||
|
||||
for (
|
||||
current_resource_type,
|
||||
workspace_id,
|
||||
current_resource_id,
|
||||
maintainer_account_id,
|
||||
) in _iter_selected_rbac_resource_rows(
|
||||
resource_type,
|
||||
tenant_id=tenant_id,
|
||||
resource_id=resource_id,
|
||||
batch_size=batch_size,
|
||||
):
|
||||
scanned_count += 1
|
||||
with session_factory.create_session() as session:
|
||||
operator_account_id = maintainer_account_id or owner_account_ids_by_tenant_id.get(workspace_id)
|
||||
if not operator_account_id:
|
||||
operator_account_id = _owner_account_id(workspace_id, session=session)
|
||||
owner_account_ids_by_tenant_id[workspace_id] = operator_account_id
|
||||
|
||||
legacy_config = _resource_legacy_whitelist_config(
|
||||
current_resource_type,
|
||||
tenant_id=workspace_id,
|
||||
operator_account_id=operator_account_id,
|
||||
resource_id=current_resource_id,
|
||||
)
|
||||
scope = _normalize_rbac_whitelist_scope(legacy_config.rbac_whitelist_scope)
|
||||
if scope is not RBACResourceWhitelistScope.ONLY_ME:
|
||||
continue
|
||||
|
||||
only_me_count += 1
|
||||
_emit_resource_whitelist_scope_migration_event(
|
||||
{
|
||||
"event": "only_me_resource_whitelist_scope_migration_proposed_change",
|
||||
"dry_run": dry_run,
|
||||
"tenant_id": workspace_id,
|
||||
"operator_account_id": operator_account_id,
|
||||
"resource_type": current_resource_type,
|
||||
"resource_id": current_resource_id,
|
||||
"before": {
|
||||
"rbac_whitelist_scope": scope.value,
|
||||
"legacy_account_ids": sorted(set(legacy_config.account_ids)),
|
||||
},
|
||||
"after": {
|
||||
"automatic_include_workspace_members": True,
|
||||
"default_policy_member_source": "workspace_members",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
_replace_resource_whitelist(
|
||||
current_resource_type,
|
||||
tenant_id=workspace_id,
|
||||
operator_account_id=operator_account_id,
|
||||
resource_id=current_resource_id,
|
||||
automatic_include_workspace_members=True,
|
||||
)
|
||||
migrated_count += 1
|
||||
|
||||
for batch in _workspace_member_account_id_batches(workspace_id, member_batch_size):
|
||||
_replace_resource_default_access_policies(
|
||||
current_resource_type,
|
||||
tenant_id=workspace_id,
|
||||
operator_account_id=operator_account_id,
|
||||
resource_id=current_resource_id,
|
||||
account_ids=batch,
|
||||
)
|
||||
member_policy_batch_count += 1
|
||||
|
||||
if scanned_count == 0:
|
||||
click.echo(click.style("No RBAC resources found for migration.", fg="yellow"))
|
||||
return
|
||||
|
||||
if dry_run:
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Dry run completed. Scanned {scanned_count} RBAC resources, found {only_me_count} only_me resources. "
|
||||
"No RBAC bindings were written.",
|
||||
fg="yellow",
|
||||
)
|
||||
)
|
||||
else:
|
||||
click.echo(
|
||||
click.style(
|
||||
"RBAC only_me resource whitelist scope migration completed. "
|
||||
f"Scanned {scanned_count} resources, migrated {migrated_count}, "
|
||||
f"wrote {member_policy_batch_count} default-policy batches.",
|
||||
fg="green",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@click.command(
|
||||
"rbac-migrate-dataset-permissions",
|
||||
help=(
|
||||
@@ -399,7 +893,9 @@ def migrate_dataset_permissions_to_rbac(
|
||||
)
|
||||
|
||||
scanned_count += 1
|
||||
replace_whitelist_payload = ReplaceMemberBindings(scope=scope)
|
||||
replace_whitelist_payload = ReplaceMemberBindings(
|
||||
automatic_include_workspace_members=scope is RBACResourceWhitelistScope.ALL
|
||||
)
|
||||
if dry_run:
|
||||
_emit_dataset_permission_migration_event(
|
||||
{
|
||||
|
||||
@@ -10,6 +10,8 @@ import click
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from configs import dify_config
|
||||
from enums import CloudPlan, DeploymentEdition
|
||||
from extensions.ext_database import db
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from services.clear_free_plan_tenant_expired_logs import ClearFreePlanTenantExpiredLogs
|
||||
@@ -126,14 +128,12 @@ def _get_archive_candidate_tenant_ids_by_prefix(
|
||||
|
||||
|
||||
def _filter_paid_workflow_archive_tenant_ids(tenant_ids: list[str]) -> tuple[list[str], list[str]]:
|
||||
from configs import dify_config
|
||||
from enums.cloud_plan import CloudPlan
|
||||
from services.billing_service import BillingService
|
||||
|
||||
tenant_ids = sorted(set(tenant_ids))
|
||||
if not tenant_ids:
|
||||
return [], []
|
||||
if not dify_config.BILLING_ENABLED:
|
||||
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
|
||||
return tenant_ids, []
|
||||
|
||||
plans = BillingService.get_plan_bulk_with_cache(tenant_ids)
|
||||
@@ -1462,7 +1462,7 @@ def cleanup_orphaned_draft_variables(
|
||||
"--graceful-period",
|
||||
default=21,
|
||||
show_default=True,
|
||||
help="Graceful period in days after subscription expiration, will be ignored when billing is disabled.",
|
||||
help="Graceful period in days after subscription expiration; ignored outside the Cloud edition.",
|
||||
)
|
||||
@click.option("--dry-run", is_flag=True, default=False, help="Show messages logs would be cleaned without deleting")
|
||||
def clean_expired_messages(
|
||||
@@ -1514,8 +1514,8 @@ def clean_expired_messages(
|
||||
if from_days_ago <= before_days:
|
||||
raise click.UsageError("--from-days-ago must be greater than --before-days.")
|
||||
|
||||
# Create policy based on billing configuration
|
||||
# NOTE: graceful_period will be ignored when billing is disabled.
|
||||
# Create the policy for the configured deployment edition.
|
||||
# NOTE: graceful_period is ignored outside the Cloud edition.
|
||||
policy = create_message_clean_policy(graceful_period_days=graceful_period)
|
||||
|
||||
if from_days_ago is not None and before_days is not None:
|
||||
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from configs import dify_config
|
||||
from enums.deployment_edition import DeploymentEdition
|
||||
from enums import DeploymentEdition
|
||||
from events.app_event import app_was_created
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
|
||||
@@ -5,7 +5,6 @@ from typing import Any, override
|
||||
from pydantic.fields import FieldInfo
|
||||
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, TomlConfigSettingsSource
|
||||
|
||||
from enums.deployment_edition import DeploymentEdition
|
||||
from libs.file_utils import search_file_upwards
|
||||
|
||||
from .deploy import DeploymentConfig
|
||||
@@ -117,11 +116,3 @@ class DifyConfig(
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@property
|
||||
def DEPLOYMENT_EDITION(self) -> DeploymentEdition:
|
||||
if self.EDITION == "CLOUD":
|
||||
return DeploymentEdition.CLOUD
|
||||
if self.ENTERPRISE_ENABLED:
|
||||
return DeploymentEdition.ENTERPRISE
|
||||
return DeploymentEdition.COMMUNITY
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
from enums import DeploymentEdition
|
||||
|
||||
|
||||
class DeploymentConfig(BaseSettings):
|
||||
"""
|
||||
@@ -25,9 +25,14 @@ class DeploymentConfig(BaseSettings):
|
||||
default=False,
|
||||
)
|
||||
|
||||
EDITION: Literal["SELF_HOSTED", "CLOUD"] = Field(
|
||||
description="Deployment edition of the application (e.g., 'SELF_HOSTED', 'CLOUD')",
|
||||
default="SELF_HOSTED",
|
||||
DEPLOYMENT_EDITION: DeploymentEdition = Field(
|
||||
description="Product edition of the application.",
|
||||
default=DeploymentEdition.COMMUNITY,
|
||||
)
|
||||
|
||||
INIT_PASSWORD: str = Field(
|
||||
description="Password required before initializing a self-hosted deployment",
|
||||
default="",
|
||||
)
|
||||
|
||||
DEPLOY_ENV: str = Field(
|
||||
|
||||
@@ -8,12 +8,6 @@ class EnterpriseFeatureConfig(BaseSettings):
|
||||
**Before using, please contact business@dify.ai by email to inquire about licensing matters.**
|
||||
"""
|
||||
|
||||
ENTERPRISE_ENABLED: bool = Field(
|
||||
description="Enable or disable enterprise-level features."
|
||||
"Before using, please contact business@dify.ai by email to inquire about licensing matters.",
|
||||
default=False,
|
||||
)
|
||||
|
||||
WEBAPP_PUBLIC_ACCESS_ENABLED: bool = Field(
|
||||
description="Whether admins are allowed to set a webapp's access mode to public (anyone with the link, "
|
||||
"no auth). Disable in security-sensitive on-prem deployments.",
|
||||
@@ -59,7 +53,7 @@ class EnterpriseTelemetryConfig(BaseSettings):
|
||||
"""
|
||||
|
||||
ENTERPRISE_TELEMETRY_ENABLED: bool = Field(
|
||||
description="Enable enterprise telemetry collection (also requires ENTERPRISE_ENABLED=true).",
|
||||
description="Enable enterprise telemetry collection for enterprise deployments.",
|
||||
default=False,
|
||||
)
|
||||
|
||||
@@ -89,11 +83,6 @@ class EnterpriseTelemetryConfig(BaseSettings):
|
||||
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,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from configs.extra.agent_backend_config import AgentBackendConfig
|
||||
from configs.extra.archive_config import ArchiveStorageConfig
|
||||
from configs.extra.knowledge_fs_config import KnowledgeFSConfig
|
||||
from configs.extra.logstore_config import LogStoreConfig
|
||||
from configs.extra.notion_config import NotionConfig
|
||||
from configs.extra.sentry_config import SentryConfig
|
||||
from configs.extra.turnstile_config import TurnstileConfig
|
||||
|
||||
|
||||
class ExtraServiceConfig(
|
||||
@@ -10,7 +12,9 @@ class ExtraServiceConfig(
|
||||
AgentBackendConfig,
|
||||
ArchiveStorageConfig,
|
||||
KnowledgeFSConfig,
|
||||
LogStoreConfig,
|
||||
NotionConfig,
|
||||
SentryConfig,
|
||||
TurnstileConfig,
|
||||
):
|
||||
pass
|
||||
|
||||
@@ -13,7 +13,7 @@ class AgentBackendConfig(BaseSettings):
|
||||
)
|
||||
|
||||
AGENT_BACKEND_API_TOKEN: str | None = Field(
|
||||
description="Bearer token for authenticating with the Agent backend /runs API.",
|
||||
description="Bearer token for authenticating with the Agent backend control-plane API.",
|
||||
default=None,
|
||||
)
|
||||
|
||||
@@ -37,9 +37,17 @@ class AgentBackendConfig(BaseSettings):
|
||||
default=3,
|
||||
)
|
||||
|
||||
AGENT_BACKEND_RUN_TIMEOUT_SECONDS: PositiveFloat = Field(
|
||||
description="Total deadline for one Agent backend run event stream.",
|
||||
default=1200,
|
||||
AGENT_BACKEND_HOME_SNAPSHOT_TIMEOUT_SECONDS: PositiveFloat = Field(
|
||||
description=(
|
||||
"Client timeout for Agent backend calls that may carry a Home Snapshot transfer: "
|
||||
"snapshot capture and delete, and Execution Binding creation that restores one. "
|
||||
),
|
||||
default=45.0,
|
||||
)
|
||||
|
||||
AGENT_BACKEND_BINDING_FILE_DOWNLOAD_TIMEOUT_SECONDS: PositiveFloat = Field(
|
||||
description="Client timeout for converting a Binding file to a ToolFile through the Agent backend.",
|
||||
default=240,
|
||||
)
|
||||
|
||||
AGENT_SHELL_ENABLED: bool = Field(
|
||||
|
||||
11
api/configs/extra/logstore_config.py
Normal file
11
api/configs/extra/logstore_config.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class LogStoreConfig(BaseSettings):
|
||||
"""Migration controls for repositories backed by Aliyun LogStore."""
|
||||
|
||||
LOGSTORE_DUAL_WRITE_ENABLED: bool = False
|
||||
|
||||
# Keep workflow graphs in LogStore by default. Deployments may disable this
|
||||
# while migrating large graph payloads to another persistence owner.
|
||||
LOGSTORE_ENABLE_PUT_GRAPH_FIELD: bool = True
|
||||
41
api/configs/extra/turnstile_config.py
Normal file
41
api/configs/extra/turnstile_config.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from pydantic import Field, SecretStr, field_validator
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class TurnstileConfig(BaseSettings):
|
||||
"""Server-side Cloudflare Turnstile settings for Cloud sign-in."""
|
||||
|
||||
TURNSTILE_SECRET_KEY: SecretStr | None = Field(
|
||||
default=None,
|
||||
description="Secret key used to validate Cloudflare Turnstile tokens.",
|
||||
)
|
||||
TURNSTILE_ALLOWED_HOSTNAMES: str = Field(
|
||||
default="",
|
||||
description="Comma-separated parent or exact hostnames accepted from Turnstile.",
|
||||
)
|
||||
TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Require a separate Turnstile challenge when verifying email login codes on Dify Cloud. "
|
||||
"Enable after the compatible web client has been deployed."
|
||||
),
|
||||
)
|
||||
|
||||
@field_validator("TURNSTILE_SECRET_KEY", mode="before")
|
||||
@classmethod
|
||||
def normalize_secret_key(cls, value: object) -> object:
|
||||
if isinstance(value, SecretStr):
|
||||
normalized = value.get_secret_value().strip()
|
||||
return SecretStr(normalized) if normalized else None
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip()
|
||||
return normalized or None
|
||||
return value
|
||||
|
||||
@property
|
||||
def TURNSTILE_ALLOWED_HOSTNAME_SET(self) -> frozenset[str]:
|
||||
return frozenset(
|
||||
hostname.strip().lower().strip(".")
|
||||
for hostname in self.TURNSTILE_ALLOWED_HOSTNAMES.split(",")
|
||||
if hostname.strip().strip(".")
|
||||
)
|
||||
@@ -12,6 +12,7 @@ from pydantic import (
|
||||
PositiveInt,
|
||||
computed_field,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
@@ -83,7 +84,7 @@ class AppExecutionConfig(BaseSettings):
|
||||
|
||||
APP_MAX_EXECUTION_TIME: PositiveInt = Field(
|
||||
description="Maximum allowed execution time for the application in seconds",
|
||||
default=1200,
|
||||
default=3600,
|
||||
)
|
||||
APP_DEFAULT_ACTIVE_REQUESTS: NonNegativeInt = Field(
|
||||
description="Default number of concurrent active requests per app (0 for unlimited)",
|
||||
@@ -592,6 +593,16 @@ class HttpConfig(BaseSettings):
|
||||
def CONSOLE_CORS_ALLOW_ORIGINS(self) -> list[str]:
|
||||
return self.inner_CONSOLE_CORS_ALLOW_ORIGINS.split(",")
|
||||
|
||||
WEBSOCKET_MAX_HTTP_BUFFER_SIZE: PositiveInt = Field(
|
||||
description=(
|
||||
"Maximum Socket.IO / Engine.IO HTTP buffer size in bytes. "
|
||||
"Large workflow collaboration payloads (sync_request graph snapshots) "
|
||||
"exceed the Engine.IO default of 1 MiB and get rejected, which "
|
||||
"disconnects the editor WebSocket. Default is 10 MiB."
|
||||
),
|
||||
default=10 * 1024 * 1024,
|
||||
)
|
||||
|
||||
inner_WEB_API_CORS_ALLOW_ORIGINS: str = Field(
|
||||
description="",
|
||||
validation_alias=AliasChoices("WEB_API_CORS_ALLOW_ORIGINS"),
|
||||
@@ -810,17 +821,6 @@ class ModelLoadBalanceConfig(BaseSettings):
|
||||
)
|
||||
|
||||
|
||||
class BillingConfig(BaseSettings):
|
||||
"""
|
||||
Configuration for platform billing features
|
||||
"""
|
||||
|
||||
BILLING_ENABLED: bool = Field(
|
||||
description="Enable or disable billing functionality",
|
||||
default=False,
|
||||
)
|
||||
|
||||
|
||||
class UpdateConfig(BaseSettings):
|
||||
"""
|
||||
Configuration for application update checks
|
||||
@@ -900,7 +900,7 @@ class WorkflowConfig(BaseSettings):
|
||||
|
||||
WORKFLOW_MAX_EXECUTION_TIME: PositiveInt = Field(
|
||||
description="Maximum execution time in seconds for a single workflow",
|
||||
default=1200,
|
||||
default=3600,
|
||||
)
|
||||
|
||||
WORKFLOW_CALL_MAX_DEPTH: PositiveInt = Field(
|
||||
@@ -1301,6 +1301,13 @@ class DataSetConfig(BaseSettings):
|
||||
)
|
||||
|
||||
|
||||
class SkillConfig(BaseSettings):
|
||||
ENABLE_SKILL: bool = Field(
|
||||
description="Enable or disable Skill feature entry points",
|
||||
default=True,
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceConfig(BaseSettings):
|
||||
"""
|
||||
Configuration for workspace management
|
||||
@@ -1335,10 +1342,28 @@ class MultiModalTransferConfig(BaseSettings):
|
||||
)
|
||||
|
||||
|
||||
class NewAgentBetaConfig(BaseSettings):
|
||||
NEW_AGENT_BETA_ACTIVITY_START_AT: datetime | None = Field(
|
||||
description="New Agent Beta Publish window start in RFC3339 UTC (inclusive)",
|
||||
default=None,
|
||||
)
|
||||
NEW_AGENT_BETA_ACTIVITY_END_AT: datetime | None = Field(
|
||||
description="New Agent Beta Publish window end in RFC3339 UTC (exclusive)",
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
class OpsTraceConfig(BaseSettings):
|
||||
OPS_TRACE_UNIFIED_ENABLED: bool = Field(
|
||||
description="Enable unified ops tracing for providers registered in the unified registry.",
|
||||
default=False,
|
||||
)
|
||||
|
||||
# Include scheduling and export grace after the parent workflow's maximum execution time.
|
||||
# Recommended: max_retries >= ceil((WORKFLOW_MAX_EXECUTION_TIME + grace_seconds) / delay_seconds).
|
||||
OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES: PositiveInt = Field(
|
||||
description="Maximum retry attempts for transient ops trace provider dispatch failures.",
|
||||
default=60,
|
||||
default=780,
|
||||
)
|
||||
|
||||
OPS_TRACE_RETRYABLE_DISPATCH_DELAY_SECONDS: PositiveInt = Field(
|
||||
@@ -1346,6 +1371,20 @@ class OpsTraceConfig(BaseSettings):
|
||||
default=5,
|
||||
)
|
||||
|
||||
OPS_TRACE_PARENT_CONTEXT_TTL_SECONDS: PositiveInt = Field(
|
||||
description="Retention in seconds for unified tracing parent contexts.",
|
||||
default=3900,
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_parent_context_retention(self) -> "OpsTraceConfig":
|
||||
if not self.OPS_TRACE_UNIFIED_ENABLED:
|
||||
return self
|
||||
retry_window = self.OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES * self.OPS_TRACE_RETRYABLE_DISPATCH_DELAY_SECONDS
|
||||
if retry_window > self.OPS_TRACE_PARENT_CONTEXT_TTL_SECONDS:
|
||||
raise ValueError("OPS_TRACE_PARENT_CONTEXT_TTL_SECONDS must cover the retry window")
|
||||
return self
|
||||
|
||||
|
||||
class CeleryBeatConfig(BaseSettings):
|
||||
CELERY_BEAT_SCHEDULER_TIME: int = Field(
|
||||
@@ -1355,6 +1394,18 @@ class CeleryBeatConfig(BaseSettings):
|
||||
|
||||
|
||||
class CeleryScheduleTasksConfig(BaseSettings):
|
||||
ENABLE_CONVERSATION_CLEANUP_TASK: bool = Field(
|
||||
description="Enable periodic recovery of soft-deleted conversation cleanup",
|
||||
default=True,
|
||||
)
|
||||
CONVERSATION_CLEANUP_TASK_INTERVAL: PositiveInt = Field(
|
||||
description="Soft-deleted conversation cleanup recovery interval in minutes",
|
||||
default=5,
|
||||
)
|
||||
CONVERSATION_CLEANUP_BATCH_SIZE: PositiveInt = Field(
|
||||
description="Maximum soft-deleted conversations dispatched per cleanup sweep",
|
||||
default=100,
|
||||
)
|
||||
ENABLE_CLEAN_EMBEDDING_CACHE_TASK: bool = Field(
|
||||
description="Enable clean embedding cache task",
|
||||
default=False,
|
||||
@@ -1537,6 +1588,10 @@ class LoginConfig(BaseSettings):
|
||||
description="expiry time in minutes for email code login token",
|
||||
default=5,
|
||||
)
|
||||
EMAIL_CODE_LOGIN_MAX_ATTEMPTS: PositiveInt = Field(
|
||||
description="maximum number of verification attempts for an email code login challenge",
|
||||
default=5,
|
||||
)
|
||||
ALLOW_REGISTER: bool = Field(
|
||||
description="whether to enable register",
|
||||
default=False,
|
||||
@@ -1621,7 +1676,6 @@ class FeatureConfig(
|
||||
# place the configs in alphabet order
|
||||
AppExecutionConfig,
|
||||
AuthConfig, # Changed from OAuthConfig to AuthConfig
|
||||
BillingConfig,
|
||||
CodeExecutionSandboxConfig,
|
||||
CreatorsPlatformConfig,
|
||||
TriggerConfig,
|
||||
@@ -1641,12 +1695,14 @@ class FeatureConfig(
|
||||
ModelLoadBalanceConfig,
|
||||
ModerationConfig,
|
||||
MultiModalTransferConfig,
|
||||
NewAgentBetaConfig,
|
||||
OpsTraceConfig,
|
||||
PositionConfig,
|
||||
RagEtlConfig,
|
||||
RepositoryConfig,
|
||||
SandboxExpiredRecordsCleanConfig,
|
||||
SecurityConfig,
|
||||
SkillConfig,
|
||||
TenantIsolatedTaskQueueConfig,
|
||||
ToolConfig,
|
||||
UpdateConfig,
|
||||
|
||||
@@ -451,6 +451,12 @@ class HostedFetchAppTemplateConfig(BaseSettings):
|
||||
default="https://tmpl.dify.ai",
|
||||
)
|
||||
|
||||
HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL: int = Field(
|
||||
description="TTL in seconds for caching remote app template HTTP responses. 0 disables caching.",
|
||||
default=600,
|
||||
ge=0,
|
||||
)
|
||||
|
||||
|
||||
class HostedFetchPipelineTemplateConfig(BaseSettings):
|
||||
"""
|
||||
|
||||
@@ -335,22 +335,6 @@ class CeleryConfig(DatabaseConfig):
|
||||
return self.CELERY_BROKER_URL.startswith("rediss://") if self.CELERY_BROKER_URL else False
|
||||
|
||||
|
||||
class InternalTestConfig(BaseSettings):
|
||||
"""
|
||||
Configuration settings for Internal Test
|
||||
"""
|
||||
|
||||
AWS_SECRET_ACCESS_KEY: str | None = Field(
|
||||
description="Internal test AWS secret access key",
|
||||
default=None,
|
||||
)
|
||||
|
||||
AWS_ACCESS_KEY_ID: str | None = Field(
|
||||
description="Internal test AWS access key ID",
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
class DatasetQueueMonitorConfig(BaseSettings):
|
||||
"""
|
||||
Configuration settings for Dataset Queue Monitor
|
||||
@@ -416,7 +400,6 @@ class MiddlewareConfig(
|
||||
WeaviateConfig,
|
||||
ElasticsearchConfig,
|
||||
CouchbaseConfig,
|
||||
InternalTestConfig,
|
||||
VikingDBConfig,
|
||||
UpstashConfig,
|
||||
TidbOnQdrantConfig,
|
||||
|
||||
@@ -167,8 +167,8 @@ class ApolloClient:
|
||||
old_value = old_kv.get(key)
|
||||
if old_value is None:
|
||||
self._change_listener("add", namespace, key, new_value)
|
||||
except BaseException as e:
|
||||
logger.warning(str(e))
|
||||
except BaseException:
|
||||
logger.warning("Apollo config change listener failed", exc_info=True)
|
||||
|
||||
def _path_checker(self) -> None:
|
||||
if not os.path.isdir(self._cache_file_path):
|
||||
@@ -235,8 +235,8 @@ class ApolloClient:
|
||||
return
|
||||
else:
|
||||
logger.warning("Sleep...")
|
||||
except Exception as e:
|
||||
logger.warning(str(e))
|
||||
except Exception:
|
||||
logger.warning("Apollo long polling failed", exc_info=True)
|
||||
|
||||
def _get_net_and_set_local(self, namespace: str, n_id: int, call_change: bool = False) -> None:
|
||||
namespace_data = self.get_json_from_net(namespace)
|
||||
|
||||
@@ -36,6 +36,7 @@ _UNSTRUCTURED_DOCUMENT_EXTENSION_BASE: frozenset[str] = frozenset(
|
||||
"pptx",
|
||||
"xml",
|
||||
"epub",
|
||||
"odt",
|
||||
)
|
||||
)
|
||||
_DEFAULT_DOCUMENT_EXTENSION_BASE: frozenset[str] = frozenset(
|
||||
@@ -53,6 +54,7 @@ _DEFAULT_DOCUMENT_EXTENSION_BASE: frozenset[str] = frozenset(
|
||||
"csv",
|
||||
"vtt",
|
||||
"properties",
|
||||
"odt",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -166,6 +166,7 @@
|
||||
"categories": ["Workflow"],
|
||||
"copyright": null,
|
||||
"description": "Based on users' choice, retrieve external knowledge to more accurately summarize articles.",
|
||||
"is_learn_dify": true,
|
||||
"is_listed": true,
|
||||
"position": 5,
|
||||
"privacy_policy": null
|
||||
@@ -294,6 +295,7 @@
|
||||
"categories": ["Workflow"],
|
||||
"copyright": null,
|
||||
"description": "Basic Workflow Template, a chatbot capable of identifying intents alongside with a knowledge base.",
|
||||
"is_learn_dify": true,
|
||||
"is_listed": true,
|
||||
"position": 4,
|
||||
"privacy_policy": null
|
||||
@@ -326,6 +328,7 @@
|
||||
"categories": ["Workflow"],
|
||||
"copyright": null,
|
||||
"description": "Basic Workflow Template, A chatbot with a knowledge base. ",
|
||||
"is_learn_dify": true,
|
||||
"is_listed": true,
|
||||
"position": 4,
|
||||
"privacy_policy": null
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
TTS_AUTO_PLAY_TIMEOUT = 5
|
||||
|
||||
# sleep 20 ms ( 40ms => 1280 byte audio file,20ms => 640 byte audio file)
|
||||
TTS_AUTO_PLAY_YIELD_CPU_TIME = 0.02
|
||||
@@ -1,60 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot
|
||||
from core.app.apps.agent_app.app_feature_projection import merge_agent_app_features
|
||||
from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form
|
||||
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
|
||||
from models.agent import Agent, AgentConfigSnapshot, AgentStatus
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.model import App, load_annotation_reply_config
|
||||
|
||||
|
||||
def get_published_agent_app_feature_dict_and_user_input_form(
|
||||
app_model: App,
|
||||
*,
|
||||
session: Session,
|
||||
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
"""Return public Agent App parameters backed by the published Agent Soul."""
|
||||
app_model_config = app_model.app_model_config_with_session(session=session)
|
||||
|
||||
agent_id = app_model.bound_agent_id
|
||||
if not agent_id:
|
||||
raise AgentAppGeneratorError("Agent App has no bound Agent")
|
||||
|
||||
agent = session.scalar(
|
||||
select(Agent)
|
||||
.where(
|
||||
Agent.tenant_id == app_model.tenant_id,
|
||||
Agent.id == agent_id,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if agent is None:
|
||||
raise AgentAppGeneratorError("Agent App has no bound Agent")
|
||||
if not agent_has_workflow_callable_active_snapshot(session=session, agent=agent):
|
||||
raise AgentAppNotPublishedError("Agent has not been published")
|
||||
|
||||
snapshot = session.scalar(
|
||||
select(AgentConfigSnapshot)
|
||||
.where(
|
||||
AgentConfigSnapshot.tenant_id == app_model.tenant_id,
|
||||
AgentConfigSnapshot.agent_id == agent.id,
|
||||
AgentConfigSnapshot.id == agent.active_config_snapshot_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if snapshot is None:
|
||||
raise AgentAppGeneratorError("Agent published version not found")
|
||||
|
||||
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
|
||||
annotation_reply = load_annotation_reply_config(session, app_model.id) if app_model_config else None
|
||||
features_dict = merge_agent_app_features(
|
||||
agent_soul=agent_soul,
|
||||
app_model_config=app_model_config,
|
||||
annotation_reply=annotation_reply,
|
||||
)
|
||||
return features_dict, agent_app_variables_to_user_input_form(agent_soul.app_variables)
|
||||
@@ -17,8 +17,8 @@ if TYPE_CHECKING:
|
||||
# the console and OpenAPI app-list endpoints.
|
||||
APP_LIST_PERMISSION_KEYS: frozenset[str] = frozenset({"app.preview", "app.acl.preview", "app.full_access"})
|
||||
|
||||
# Workspace permission key that lets a caller see apps they maintain even when
|
||||
# those apps are not in their preview whitelist.
|
||||
# Workspace permission key that lets a caller see apps they maintain when the
|
||||
# app whitelist is unrestricted.
|
||||
_MANAGE_OWN_APPS_PERMISSION_KEY = "app.create_and_management"
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ class AppAccessFilter:
|
||||
|
||||
``accessible_app_ids`` of ``None`` means the caller can see every app in the
|
||||
workspace (unrestricted). Otherwise it is the exact set of app ids the
|
||||
caller may preview; combined with ``can_manage_own_apps`` it also covers
|
||||
caller may preview; combined with ``can_manage_own_apps`` it can also cover
|
||||
apps the caller maintains.
|
||||
"""
|
||||
|
||||
@@ -100,11 +100,10 @@ def resolve_app_access_filter(
|
||||
if getattr(whitelist_scope, "unrestricted", False):
|
||||
accessible_app_ids = permission_app_ids
|
||||
else:
|
||||
# A restricted app whitelist is the highest-priority visibility gate:
|
||||
# default preview, per-app permission overrides, and own-app management
|
||||
# must not expose apps outside this set.
|
||||
accessible_app_ids = set(whitelist_scope.resource_ids)
|
||||
if permission_app_ids is not None:
|
||||
accessible_app_ids |= permission_app_ids
|
||||
elif has_default_preview:
|
||||
# Default preview overrides the whitelist restriction.
|
||||
accessible_app_ids = None
|
||||
can_manage_own_apps = False
|
||||
|
||||
return AppAccessFilter(accessible_app_ids=accessible_app_ids, can_manage_own_apps=can_manage_own_apps)
|
||||
|
||||
@@ -108,33 +108,55 @@ class SavedMessageCreatePayload(BaseModel):
|
||||
|
||||
# --- Workflow schemas ---
|
||||
|
||||
|
||||
def _workflow_input_file_variant(transfer_method: str, source: str) -> dict[str, object]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"description": "File type.",
|
||||
"enum": ["document", "image", "audio", "video", "custom"],
|
||||
"type": "string",
|
||||
},
|
||||
"transfer_method": {
|
||||
"description": (
|
||||
"Transfer method: `remote_url` for a file URL or persisted uploaded-file reference, "
|
||||
"`local_file` for an uploaded file."
|
||||
),
|
||||
"enum": [transfer_method],
|
||||
"type": "string",
|
||||
},
|
||||
"url": {
|
||||
"description": "File URL when `transfer_method` is `remote_url`.",
|
||||
"format": "url",
|
||||
"type": "string",
|
||||
},
|
||||
"remote_url": {
|
||||
"description": "Legacy alias of `url` when `transfer_method` is `remote_url`.",
|
||||
"format": "url",
|
||||
"type": "string",
|
||||
},
|
||||
"upload_file_id": {
|
||||
"description": (
|
||||
"Uploaded file ID obtained from the [Upload File](/api-reference/files/upload-file) API. "
|
||||
"Required for `local_file`; also accepted with `remote_url` for compatibility with persisted "
|
||||
"file references."
|
||||
),
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"required": ["type", "transfer_method", source],
|
||||
}
|
||||
|
||||
|
||||
WORKFLOW_INPUT_FILE_ITEM_SCHEMA: dict[str, object] = {
|
||||
"type": "object",
|
||||
"required": ["type", "transfer_method"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"description": "File type.",
|
||||
"enum": ["document", "image", "audio", "video", "custom"],
|
||||
"type": "string",
|
||||
},
|
||||
"transfer_method": {
|
||||
"description": "Transfer method: `remote_url` for file URL, `local_file` for uploaded file.",
|
||||
"enum": ["remote_url", "local_file"],
|
||||
"type": "string",
|
||||
},
|
||||
"url": {
|
||||
"description": "File URL when `transfer_method` is `remote_url`.",
|
||||
"format": "url",
|
||||
"type": "string",
|
||||
},
|
||||
"upload_file_id": {
|
||||
"description": (
|
||||
"Uploaded file ID obtained from the [Upload File](/api-reference/files/upload-file) API when "
|
||||
"`transfer_method` is `local_file`."
|
||||
),
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"anyOf": [
|
||||
_workflow_input_file_variant("remote_url", "url"),
|
||||
_workflow_input_file_variant("remote_url", "remote_url"),
|
||||
_workflow_input_file_variant("remote_url", "upload_file_id"),
|
||||
_workflow_input_file_variant("local_file", "upload_file_id"),
|
||||
],
|
||||
}
|
||||
WORKFLOW_INPUT_FILE_LIST_SCHEMA: dict[str, object] = {
|
||||
"anyOf": [{"items": WORKFLOW_INPUT_FILE_ITEM_SCHEMA, "type": "array"}, {"type": "null"}]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, RootModel, computed_field
|
||||
from pydantic import BaseModel, ConfigDict, Field, RootModel, WithJsonSchema, computed_field
|
||||
|
||||
from fields.base import ResponseModel
|
||||
from graphon.file import helpers as file_helpers
|
||||
@@ -10,6 +10,125 @@ from models.model import IconType
|
||||
|
||||
type JSONValue = str | int | float | bool | None | dict[str, Any] | list[Any]
|
||||
type JSONObject = dict[str, Any]
|
||||
URLString = Annotated[str, WithJsonSchema({"format": "url", "type": "string"})]
|
||||
UUIDString = Annotated[str, WithJsonSchema({"format": "uuid", "type": "string"})]
|
||||
Int64 = Annotated[int, WithJsonSchema({"format": "int64", "type": "integer"})]
|
||||
FloatNumber = Annotated[float, WithJsonSchema({"format": "float", "type": "number"})]
|
||||
DoubleNumber = Annotated[float, WithJsonSchema({"format": "double", "type": "number"})]
|
||||
DecimalString = Annotated[str, WithJsonSchema({"format": "decimal", "type": "string"})]
|
||||
|
||||
FeatureToggleObject = Annotated[
|
||||
JSONObject,
|
||||
WithJsonSchema({"type": "object", "properties": {"enabled": {"type": "boolean"}}}),
|
||||
]
|
||||
TextToSpeechObject = Annotated[
|
||||
JSONObject,
|
||||
WithJsonSchema(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"voice": {"type": "string"},
|
||||
"language": {"type": "string"},
|
||||
"autoPlay": {"type": "string"},
|
||||
},
|
||||
}
|
||||
),
|
||||
]
|
||||
FileUploadObject = Annotated[
|
||||
JSONObject,
|
||||
WithJsonSchema(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"number_limits": {"type": "integer"},
|
||||
"allowed_file_types": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["document", "image", "audio", "video", "custom"],
|
||||
},
|
||||
},
|
||||
"allowed_file_extensions": {"type": "array", "items": {"type": "string"}},
|
||||
"allowed_file_upload_methods": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["remote_url", "local_file"],
|
||||
},
|
||||
},
|
||||
"image": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"number_limits": {"type": "integer"},
|
||||
"detail": {"type": "string"},
|
||||
"transfer_methods": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
]
|
||||
USER_INPUT_FORM_TYPES = (
|
||||
"text-input",
|
||||
"select",
|
||||
"paragraph",
|
||||
"number",
|
||||
"external_data_tool",
|
||||
"file",
|
||||
"file-list",
|
||||
"checkbox",
|
||||
"json_object",
|
||||
)
|
||||
USER_INPUT_FORM_ITEM_CONFIG_SCHEMA: dict[str, object] = {
|
||||
"type": "object",
|
||||
"required": ["label", "variable"],
|
||||
"properties": {
|
||||
"variable": {"type": "string"},
|
||||
"label": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"required": {"type": "boolean"},
|
||||
"hide": {"type": "boolean"},
|
||||
# Defaults are typed by the form kind: strings, numbers, booleans,
|
||||
# JSON objects, and file references are all valid public values.
|
||||
"default": {},
|
||||
"type": {"enum": list(USER_INPUT_FORM_TYPES), "type": "string"},
|
||||
"max_length": {"anyOf": [{"type": "integer"}, {"type": "null"}]},
|
||||
"options": {"type": "array", "items": {"type": "string"}},
|
||||
"allowed_file_types": {"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}]},
|
||||
"allowed_file_extensions": {"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}]},
|
||||
"allowed_file_upload_methods": {"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}]},
|
||||
"json_schema": {
|
||||
"anyOf": [
|
||||
{"type": "object", "additionalProperties": True},
|
||||
{"type": "null"},
|
||||
]
|
||||
},
|
||||
"config": {"type": "object", "additionalProperties": True},
|
||||
},
|
||||
}
|
||||
UserInputFormList = Annotated[
|
||||
list[JSONObject],
|
||||
WithJsonSchema(
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {form_type: USER_INPUT_FORM_ITEM_CONFIG_SCHEMA},
|
||||
"required": [form_type],
|
||||
}
|
||||
for form_type in USER_INPUT_FORM_TYPES
|
||||
],
|
||||
},
|
||||
}
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class SystemParameters(BaseModel):
|
||||
@@ -20,8 +139,200 @@ class SystemParameters(BaseModel):
|
||||
workflow_file_upload_limit: int
|
||||
|
||||
|
||||
class BlockingUsageResponse(ResponseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
prompt_tokens: int | None = None
|
||||
prompt_unit_price: DecimalString | None = None
|
||||
prompt_price_unit: DecimalString | None = None
|
||||
prompt_price: DecimalString | None = None
|
||||
completion_tokens: int | None = None
|
||||
completion_unit_price: DecimalString | None = None
|
||||
completion_price_unit: DecimalString | None = None
|
||||
completion_price: DecimalString | None = None
|
||||
total_tokens: int | None = None
|
||||
total_price: DecimalString | None = None
|
||||
currency: str | None = None
|
||||
latency: DoubleNumber | None = None
|
||||
|
||||
|
||||
class BlockingRetrieverResourceResponse(ResponseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
id: UUIDString | None = None
|
||||
message_id: UUIDString | None = None
|
||||
position: int
|
||||
dataset_id: UUIDString | None = None
|
||||
dataset_name: str | None = None
|
||||
document_id: UUIDString | None = None
|
||||
document_name: str | None = None
|
||||
data_source_type: str | None = None
|
||||
segment_id: UUIDString | None = None
|
||||
score: FloatNumber | None = None
|
||||
hit_count: int | None = None
|
||||
word_count: int | None = None
|
||||
segment_position: int | None = None
|
||||
index_node_hash: str | None = None
|
||||
content: str | None = None
|
||||
summary: str | None = None
|
||||
created_at: Int64 | None = None
|
||||
|
||||
|
||||
class BlockingMetadataResponse(ResponseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
usage: BlockingUsageResponse | None = None
|
||||
retriever_resources: list[BlockingRetrieverResourceResponse] | None = None
|
||||
|
||||
|
||||
class ChatMessageBlockingResponse(ResponseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
event: Literal["message"]
|
||||
task_id: UUIDString
|
||||
id: UUIDString
|
||||
message_id: UUIDString
|
||||
conversation_id: UUIDString
|
||||
mode: str
|
||||
answer: str
|
||||
metadata: BlockingMetadataResponse = Field(description="Metadata including usage and retriever resources.")
|
||||
created_at: Int64
|
||||
|
||||
|
||||
class PauseReasonResponseBase(ResponseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
form_id: UUIDString | None = None
|
||||
node_id: str | None = None
|
||||
node_title: str | None = None
|
||||
form_content: str | None = None
|
||||
inputs: list[JSONObject] = Field(default_factory=list)
|
||||
actions: list[JSONObject] = Field(default_factory=list)
|
||||
display_in_ui: bool | None = None
|
||||
resolved_default_values: dict[str, Any] = Field(default_factory=dict)
|
||||
form_token: str | None = None
|
||||
approval_channels: list[str] = Field(default_factory=list)
|
||||
expiration_time: Int64 | None = None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class ChatPauseReasonResponse(PauseReasonResponseBase):
|
||||
"""Public pause reason emitted by a blocking Chatflow execution."""
|
||||
|
||||
TYPE: str
|
||||
|
||||
|
||||
class WorkflowPauseReasonResponse(PauseReasonResponseBase):
|
||||
"""Public pause reason emitted by a blocking Workflow execution."""
|
||||
|
||||
TYPE: str
|
||||
|
||||
|
||||
class ChatPausedBlockingDataResponse(ResponseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
id: UUIDString
|
||||
mode: str
|
||||
conversation_id: UUIDString
|
||||
message_id: UUIDString
|
||||
workflow_run_id: UUIDString
|
||||
answer: str
|
||||
metadata: BlockingMetadataResponse = Field(description="Metadata including usage and retriever resources.")
|
||||
created_at: Int64
|
||||
paused_nodes: list[str]
|
||||
reasons: list[ChatPauseReasonResponse]
|
||||
status: Literal["paused"]
|
||||
elapsed_time: FloatNumber
|
||||
total_tokens: int
|
||||
total_steps: int
|
||||
|
||||
|
||||
class ChatPausedBlockingResponse(ResponseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
event: Literal["workflow_paused"]
|
||||
task_id: UUIDString
|
||||
id: UUIDString
|
||||
message_id: UUIDString
|
||||
conversation_id: UUIDString
|
||||
mode: str
|
||||
answer: str
|
||||
metadata: BlockingMetadataResponse = Field(description="Metadata including usage and retriever resources.")
|
||||
created_at: Int64
|
||||
workflow_run_id: UUIDString
|
||||
data: ChatPausedBlockingDataResponse
|
||||
|
||||
|
||||
class ChatBlockingResponse(
|
||||
RootModel[
|
||||
Annotated[
|
||||
ChatMessageBlockingResponse | ChatPausedBlockingResponse,
|
||||
Field(discriminator="event"),
|
||||
]
|
||||
]
|
||||
):
|
||||
"""Blocking chat response for a completed message or paused Chatflow."""
|
||||
|
||||
|
||||
class CompletionBlockingResponse(ResponseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
event: str
|
||||
task_id: UUIDString
|
||||
id: UUIDString
|
||||
message_id: UUIDString
|
||||
mode: str
|
||||
answer: str
|
||||
metadata: BlockingMetadataResponse = Field(description="Metadata including usage and retriever resources.")
|
||||
created_at: Int64
|
||||
|
||||
|
||||
class WorkflowBlockingDataBase(ResponseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
id: UUIDString
|
||||
workflow_id: UUIDString
|
||||
outputs: dict[str, Any] | None
|
||||
error: str | None
|
||||
elapsed_time: FloatNumber
|
||||
total_tokens: int
|
||||
total_steps: int
|
||||
created_at: Int64
|
||||
finished_at: Int64 | None
|
||||
|
||||
|
||||
class WorkflowFinishedBlockingDataResponse(WorkflowBlockingDataBase):
|
||||
status: Literal["succeeded", "failed", "stopped", "partial-succeeded"]
|
||||
|
||||
|
||||
class WorkflowPausedBlockingDataResponse(WorkflowBlockingDataBase):
|
||||
status: Literal["paused"]
|
||||
paused_nodes: list[str]
|
||||
reasons: list[WorkflowPauseReasonResponse]
|
||||
|
||||
|
||||
class WorkflowFinishedBlockingResponse(ResponseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
task_id: UUIDString
|
||||
workflow_run_id: UUIDString
|
||||
data: WorkflowFinishedBlockingDataResponse
|
||||
|
||||
|
||||
class WorkflowPausedBlockingResponse(ResponseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
task_id: UUIDString
|
||||
workflow_run_id: UUIDString
|
||||
data: WorkflowPausedBlockingDataResponse
|
||||
|
||||
|
||||
class WorkflowBlockingResponse(RootModel[WorkflowFinishedBlockingResponse | WorkflowPausedBlockingResponse]):
|
||||
"""Blocking workflow response for a finished or paused execution."""
|
||||
|
||||
|
||||
class SimpleResultResponse(ResponseModel):
|
||||
result: str
|
||||
result: str = Field(description="Operation result.")
|
||||
|
||||
|
||||
class GeneratedAppResponse(RootModel[JSONValue]):
|
||||
@@ -126,12 +437,6 @@ class UsageCountResponse(ResponseModel):
|
||||
count: int
|
||||
|
||||
|
||||
class IndexInfoResponse(ResponseModel):
|
||||
welcome: str
|
||||
api_version: str
|
||||
server_version: str
|
||||
|
||||
|
||||
class AvatarUrlResponse(ResponseModel):
|
||||
avatar_url: str
|
||||
|
||||
@@ -164,16 +469,16 @@ class NewAppResponse(ResponseModel):
|
||||
class Parameters(BaseModel):
|
||||
opening_statement: str | None = None
|
||||
suggested_questions: list[str]
|
||||
suggested_questions_after_answer: JSONObject
|
||||
speech_to_text: JSONObject
|
||||
text_to_speech: JSONObject
|
||||
retriever_resource: JSONObject
|
||||
annotation_reply: JSONObject
|
||||
more_like_this: JSONObject
|
||||
user_input_form: list[JSONObject]
|
||||
sensitive_word_avoidance: JSONObject
|
||||
file_upload: JSONObject
|
||||
system_parameters: SystemParameters
|
||||
suggested_questions_after_answer: FeatureToggleObject
|
||||
speech_to_text: FeatureToggleObject
|
||||
text_to_speech: TextToSpeechObject
|
||||
retriever_resource: FeatureToggleObject
|
||||
annotation_reply: FeatureToggleObject
|
||||
more_like_this: FeatureToggleObject
|
||||
user_input_form: UserInputFormList
|
||||
sensitive_word_avoidance: FeatureToggleObject
|
||||
file_upload: FileUploadObject
|
||||
system_parameters: SystemParameters = Field(description="System-level parameter limits.")
|
||||
|
||||
|
||||
class Site(BaseModel):
|
||||
@@ -194,7 +499,7 @@ class Site(BaseModel):
|
||||
show_workflow_steps: bool
|
||||
use_icon_as_answer_icon: bool
|
||||
|
||||
@computed_field(return_type=str | None) # type: ignore
|
||||
@computed_field(return_type=URLString | None) # type: ignore
|
||||
@property
|
||||
def icon_url(self) -> str | None:
|
||||
if self.icon and self.icon_type == IconType.IMAGE:
|
||||
|
||||
@@ -152,7 +152,7 @@ def _extract_resource_id(
|
||||
if resource_type == RBACResourceScope.APP:
|
||||
app_id = matched_args.get("app_id")
|
||||
if app_id:
|
||||
return str(app_id)
|
||||
return str(app_id) # pyrefly: ignore[unnecessary-type-conversion]
|
||||
|
||||
agent_id = matched_args.get("agent_id")
|
||||
if agent_id:
|
||||
@@ -161,7 +161,7 @@ def _extract_resource_id(
|
||||
|
||||
resource_id = matched_args.get("resource_id")
|
||||
if resource_id:
|
||||
return str(resource_id)
|
||||
return str(resource_id) # pyrefly: ignore[unnecessary-type-conversion]
|
||||
raise ValueError("Missing app_id in request path")
|
||||
|
||||
if resource_type == RBACResourceScope.DATASET:
|
||||
@@ -171,9 +171,11 @@ def _extract_resource_id(
|
||||
|
||||
pipeline_id = matched_args.get("pipeline_id")
|
||||
if pipeline_id:
|
||||
dataset = db.session.scalar(select(Dataset).where(Dataset.pipeline_id == str(pipeline_id)))
|
||||
dataset = db.session.scalar(
|
||||
select(Dataset).where(Dataset.pipeline_id == str(pipeline_id), Dataset.tenant_id == tenant_id)
|
||||
)
|
||||
if not dataset:
|
||||
raise NotFound("Dataset not found for pipeline")
|
||||
return str(dataset.id)
|
||||
return str(dataset.id) # pyrefly: ignore[unnecessary-type-conversion]
|
||||
raise ValueError("Missing dataset_id or pipeline_id in request path")
|
||||
raise ValueError(f"Unknown resource_type: {resource_type}")
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from http import HTTPStatus
|
||||
from importlib import import_module
|
||||
|
||||
from flask import Blueprint
|
||||
from flask import Blueprint, current_app, got_request_exception
|
||||
from flask_restx import Namespace
|
||||
|
||||
from libs.external_api import ExternalApi
|
||||
from machinery.errors import ActiveWorkspaceRequiredError
|
||||
|
||||
bp = Blueprint("console", __name__, url_prefix="/console/api")
|
||||
|
||||
@@ -14,6 +16,19 @@ api = ExternalApi(
|
||||
description="Console management APIs for app configuration, monitoring, and administration",
|
||||
)
|
||||
|
||||
|
||||
@api.errorhandler(ActiveWorkspaceRequiredError)
|
||||
def _handle_active_workspace_required_error(error: ActiveWorkspaceRequiredError):
|
||||
"""Map a broken Console admission invariant without exposing internal details."""
|
||||
got_request_exception.send(current_app, exception=error)
|
||||
status = HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
return {
|
||||
"code": error.error_code,
|
||||
"message": status.phrase,
|
||||
"status": status.value,
|
||||
}, status.value
|
||||
|
||||
|
||||
console_ns = Namespace("console", description="Console management API operations", path="/")
|
||||
|
||||
RESOURCE_MODULES = (
|
||||
@@ -41,10 +56,9 @@ from . import (
|
||||
knowledge_fs_proxy,
|
||||
notification,
|
||||
onboarding,
|
||||
ping,
|
||||
setup,
|
||||
spec,
|
||||
version,
|
||||
system,
|
||||
workflow_run_archive,
|
||||
)
|
||||
from .agent import composer as agent_composer
|
||||
@@ -58,7 +72,6 @@ from .app import (
|
||||
agent_app_feature,
|
||||
agent_app_sandbox,
|
||||
agent_config_inspector,
|
||||
agent_drive_inspector,
|
||||
annotation,
|
||||
app,
|
||||
audio,
|
||||
@@ -144,6 +157,7 @@ from .workspace import (
|
||||
models,
|
||||
plugin,
|
||||
rbac,
|
||||
skills,
|
||||
snippets,
|
||||
tool_providers,
|
||||
trigger_providers,
|
||||
@@ -162,7 +176,6 @@ __all__ = [
|
||||
"agent_app_sandbox",
|
||||
"agent_composer",
|
||||
"agent_config_inspector",
|
||||
"agent_drive_inspector",
|
||||
"agent_providers",
|
||||
"agent_roster",
|
||||
"annotation",
|
||||
@@ -213,7 +226,6 @@ __all__ = [
|
||||
"onboarding",
|
||||
"ops_trace",
|
||||
"parameter",
|
||||
"ping",
|
||||
"plugin",
|
||||
"rag_pipeline",
|
||||
"rag_pipeline_datasets",
|
||||
@@ -225,17 +237,18 @@ __all__ = [
|
||||
"saved_message",
|
||||
"setup",
|
||||
"site",
|
||||
"skills",
|
||||
"snippet_workflow",
|
||||
"snippet_workflow_draft_variable",
|
||||
"snippets",
|
||||
"socketio_workflow",
|
||||
"spec",
|
||||
"statistic",
|
||||
"system",
|
||||
"tags",
|
||||
"tool_providers",
|
||||
"trial",
|
||||
"trigger_providers",
|
||||
"version",
|
||||
"website",
|
||||
"workflow",
|
||||
"workflow_app_log",
|
||||
|
||||
@@ -7,7 +7,7 @@ from werkzeug.exceptions import NotFound
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.app.wraps import agent_manage_required_for_agent_app, get_app_model
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
@@ -182,14 +182,7 @@ class WorkflowAgentComposerValidateApi(Resource):
|
||||
AgentComposerService.validate_knowledge_datasets(
|
||||
session=session, tenant_id=tenant_id, agent_soul=req_data.agent_soul
|
||||
)
|
||||
findings = AgentComposerService.collect_validation_findings(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
payload=req_data,
|
||||
agent_id=AgentComposerService.resolve_workflow_node_agent_id(
|
||||
session=session, tenant_id=tenant_id, app_id=app_model.id, node_id=node_id
|
||||
),
|
||||
)
|
||||
findings = AgentComposerService.collect_validation_findings(payload=req_data)
|
||||
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
|
||||
|
||||
|
||||
@@ -284,7 +277,7 @@ class WorkflowAgentComposerSaveToRosterApi(Resource):
|
||||
|
||||
def _require_snippet_app_id(*, session: Session, tenant_id: str, snippet_id: UUID) -> str:
|
||||
snippet = SnippetService(session=session).get_snippet_by_id(
|
||||
snippet_id=str(snippet_id),
|
||||
snippet_id=str(snippet_id), # pyrefly: ignore[unnecessary-type-conversion]
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
if snippet is None:
|
||||
@@ -413,22 +406,12 @@ class SnippetAgentComposerValidateApi(Resource):
|
||||
@with_session(write=False)
|
||||
@model_validate(ComposerSavePayload)
|
||||
def post(self, req_data: ComposerSavePayload, session: Session, tenant_id: str, snippet_id: UUID, node_id: str):
|
||||
app_id = _require_snippet_app_id(session=session, tenant_id=tenant_id, snippet_id=snippet_id)
|
||||
_require_snippet_app_id(session=session, tenant_id=tenant_id, snippet_id=snippet_id)
|
||||
ComposerConfigValidator.validate_publish_payload(req_data)
|
||||
AgentComposerService.validate_knowledge_datasets(
|
||||
session=session, tenant_id=tenant_id, agent_soul=req_data.agent_soul
|
||||
)
|
||||
findings = AgentComposerService.collect_validation_findings(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
payload=req_data,
|
||||
agent_id=AgentComposerService.resolve_workflow_node_agent_id(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
node_id=node_id,
|
||||
),
|
||||
)
|
||||
findings = AgentComposerService.collect_validation_findings(payload=req_data)
|
||||
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
|
||||
|
||||
|
||||
@@ -529,6 +512,7 @@ class AgentComposerApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
@@ -543,8 +527,7 @@ class AgentComposerApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_EDIT)
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@@ -580,12 +563,7 @@ class AgentComposerValidateApi(Resource):
|
||||
AgentComposerService.validate_knowledge_datasets(
|
||||
session=session, tenant_id=tenant_id, agent_soul=req_data.agent_soul
|
||||
)
|
||||
findings = AgentComposerService.collect_validation_findings(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
payload=req_data,
|
||||
agent_id=str(agent_id),
|
||||
)
|
||||
findings = AgentComposerService.collect_validation_findings(payload=req_data)
|
||||
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from flask import abort, request
|
||||
from flask_restx import Resource
|
||||
from pydantic import AliasChoices, BaseModel, Field, field_validator
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.schema import (
|
||||
@@ -31,6 +32,7 @@ from controllers.console.app.app import (
|
||||
from controllers.console.app.app import (
|
||||
UpdateAppPayload as GenericUpdateAppPayload,
|
||||
)
|
||||
from controllers.console.app.wraps import agent_manage_required_for_agent_app
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
@@ -76,16 +78,25 @@ from services.agent.observability_service import (
|
||||
AgentStatisticsQueryParams,
|
||||
)
|
||||
from services.agent.roster_service import AgentRosterService
|
||||
from services.app_service import AppListParams, AppService, CreateAppParams
|
||||
from services.app_service import AgentAppPublicationCounts, AppListParams, AppService, CreateAppParams
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.entities.agent_entities import ComposerSavePayload, RosterListQuery
|
||||
from services.feature_service import FeatureService
|
||||
from services.system_feature_service import SystemFeatureService
|
||||
|
||||
AgentPublicationStatus = Literal["published", "drafts"]
|
||||
|
||||
|
||||
class AgentInviteOptionsQuery(RosterListQuery):
|
||||
app_id: str | None = Field(default=None, description="Workflow app id for in-current-workflow markers")
|
||||
|
||||
|
||||
class AgentAppListQuery(AppListQuery):
|
||||
publication_status: AgentPublicationStatus | None = Field(
|
||||
default=None,
|
||||
description="Filter by published or draft Agent configuration status",
|
||||
)
|
||||
|
||||
|
||||
class AgentIdPath(BaseModel):
|
||||
agent_id: str
|
||||
|
||||
@@ -299,7 +310,19 @@ class AgentSimpleResultResponse(BaseModel):
|
||||
result: str
|
||||
|
||||
|
||||
class AgentPublicationCountsResponse(ResponseModel):
|
||||
published: int = Field(
|
||||
ge=0,
|
||||
description="Published Agent Apps in the current list scope, excluding the publication status filter",
|
||||
)
|
||||
drafts: int = Field(
|
||||
ge=0,
|
||||
description="Draft Agent Apps in the current list scope, excluding the publication status filter",
|
||||
)
|
||||
|
||||
|
||||
class AgentAppPagination(GenericAppPagination):
|
||||
publication_counts: AgentPublicationCountsResponse
|
||||
data: list[AgentAppPartial] = Field( # type: ignore[assignment] # pyrefly: ignore[bad-override-mutable-attribute]
|
||||
validation_alias=AliasChoices("items", "data")
|
||||
)
|
||||
@@ -314,6 +337,7 @@ register_schema_models(
|
||||
AgentBuildDraftCheckoutPayload,
|
||||
ComposerSavePayload,
|
||||
AgentApiStatusPayload,
|
||||
AgentAppListQuery,
|
||||
AgentInviteOptionsQuery,
|
||||
AgentLogsQuery,
|
||||
AgentStatisticsQuery,
|
||||
@@ -323,6 +347,7 @@ register_schema_models(
|
||||
)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
AgentPublicationCountsResponse,
|
||||
AgentAppPagination,
|
||||
AgentApiAccessResponse,
|
||||
AgentAppPublishedReferenceResponse,
|
||||
@@ -363,7 +388,7 @@ def _serialize_agent_app_detail(
|
||||
"""
|
||||
|
||||
app_model = AppService().get_app(app_model, session=session)
|
||||
if FeatureService.get_system_features().webapp_auth.enabled:
|
||||
if SystemFeatureService.is_webapp_auth_enabled():
|
||||
app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id))
|
||||
app_model.access_mode = app_setting.access_mode # type: ignore[attr-defined]
|
||||
|
||||
@@ -408,7 +433,14 @@ def _serialize_agent_app_detail(
|
||||
return payload
|
||||
|
||||
|
||||
def _serialize_agent_app_pagination(session: Session, app_pagination, *, tenant_id: str, current_user: Account) -> dict:
|
||||
def _serialize_agent_app_pagination(
|
||||
session: Session,
|
||||
app_pagination,
|
||||
*,
|
||||
tenant_id: str,
|
||||
current_user: Account,
|
||||
publication_counts: AgentAppPublicationCounts,
|
||||
) -> dict:
|
||||
"""Serialize Agent App lists with roster-shaped items.
|
||||
|
||||
Each item starts from the shared App list shape, then drops
|
||||
@@ -441,8 +473,17 @@ def _serialize_agent_app_pagination(session: Session, app_pagination, *, tenant_
|
||||
account_id=current_user.id,
|
||||
)
|
||||
payload = AgentAppPagination.model_validate(
|
||||
app_pagination,
|
||||
from_attributes=True,
|
||||
{
|
||||
"page": app_pagination.page,
|
||||
"limit": app_pagination.per_page,
|
||||
"total": app_pagination.total,
|
||||
"has_more": app_pagination.has_next,
|
||||
"data": app_pagination.items,
|
||||
"publication_counts": {
|
||||
"published": publication_counts.published,
|
||||
"drafts": publication_counts.drafts,
|
||||
},
|
||||
},
|
||||
context={"session": session},
|
||||
).model_dump(mode="json")
|
||||
for item in payload["data"]:
|
||||
@@ -484,12 +525,13 @@ def _resolve_agent_runtime_app_model(session: Session, *, tenant_id: str, agent_
|
||||
return _agent_roster_service(session).get_agent_runtime_app_model(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||
|
||||
|
||||
def _agent_api_key_count(session: Session, app_id: str) -> int:
|
||||
def _agent_api_key_count(session: Session, app_model: App) -> int:
|
||||
return (
|
||||
session.scalar(
|
||||
select(func.count(ApiToken.id)).where(
|
||||
or_(ApiToken.tenant_id == app_model.tenant_id, ApiToken.tenant_id.is_(None)),
|
||||
ApiToken.type == ApiTokenType.APP,
|
||||
ApiToken.app_id == app_id,
|
||||
ApiToken.app_id == app_model.id,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
@@ -521,7 +563,7 @@ def _serialize_agent_api_access(session: Session, app_model: App) -> dict:
|
||||
meta_endpoint=f"{base_url}/meta",
|
||||
api_rpm=app_model.api_rpm or 0,
|
||||
api_rph=app_model.api_rph or 0,
|
||||
api_key_count=_agent_api_key_count(session, str(app_model.id)),
|
||||
api_key_count=_agent_api_key_count(session, app_model),
|
||||
)
|
||||
return response.model_dump(mode="json")
|
||||
|
||||
@@ -561,7 +603,7 @@ def _query_values(name: str, alias_name: str | None = None) -> list[str]:
|
||||
|
||||
@console_ns.route("/agent")
|
||||
class AgentAppListApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(AppListQuery))
|
||||
@console_ns.doc(params=query_params_from_model(AgentAppListQuery))
|
||||
@console_ns.response(200, "Agent app list", console_ns.models[AgentAppPagination.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@@ -571,7 +613,9 @@ class AgentAppListApi(Resource):
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def get(self, session: Session, current_tenant_id: str, current_user: Account):
|
||||
args = query_params_from_request(AppListQuery, list_fields=APP_LIST_QUERY_ARRAY_FIELDS)
|
||||
args = query_params_from_request(AgentAppListQuery, list_fields=APP_LIST_QUERY_ARRAY_FIELDS)
|
||||
agent_is_published = None if args.publication_status is None else args.publication_status == "published"
|
||||
|
||||
params = AppListParams(
|
||||
page=args.page,
|
||||
limit=args.limit,
|
||||
@@ -582,11 +626,29 @@ class AgentAppListApi(Resource):
|
||||
creator_ids=args.creator_ids,
|
||||
is_created_by_me=args.is_created_by_me,
|
||||
status="normal",
|
||||
agent_is_published=agent_is_published,
|
||||
)
|
||||
|
||||
app_pagination = AppService().get_paginate_apps(current_user.id, current_tenant_id, params, session)
|
||||
app_service = AppService()
|
||||
publication_counts = app_service.get_agent_publication_counts(
|
||||
current_user.id,
|
||||
current_tenant_id,
|
||||
params,
|
||||
session,
|
||||
)
|
||||
app_pagination = app_service.get_paginate_apps(current_user.id, current_tenant_id, params, session)
|
||||
if app_pagination is None:
|
||||
empty = AgentAppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
|
||||
empty = AgentAppPagination(
|
||||
page=args.page,
|
||||
limit=args.limit,
|
||||
total=0,
|
||||
has_more=False,
|
||||
publication_counts=AgentPublicationCountsResponse(
|
||||
published=publication_counts.published,
|
||||
drafts=publication_counts.drafts,
|
||||
),
|
||||
data=[],
|
||||
)
|
||||
return empty.model_dump(mode="json")
|
||||
|
||||
return _serialize_agent_app_pagination(
|
||||
@@ -594,10 +656,12 @@ class AgentAppListApi(Resource):
|
||||
app_pagination,
|
||||
tenant_id=current_tenant_id,
|
||||
current_user=current_user,
|
||||
publication_counts=publication_counts,
|
||||
)
|
||||
|
||||
@console_ns.expect(console_ns.models[AgentAppCreatePayload.__name__])
|
||||
@console_ns.response(201, "Agent app created successfully", console_ns.models[AgentAppDetailWithSite.__name__])
|
||||
@console_ns.response(409, "Agent name already exists")
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@console_ns.response(400, "Invalid request parameters")
|
||||
@setup_required
|
||||
@@ -630,6 +694,7 @@ class AgentAppApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@enterprise_license_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@@ -919,8 +984,7 @@ class AgentApiStatusApi(Resource):
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@model_validate(AgentApiStatusPayload)
|
||||
@@ -938,8 +1002,9 @@ class AgentApiKeyListApi(BaseApiKeyListResource):
|
||||
token_prefix = "app-"
|
||||
|
||||
@console_ns.response(200, "Agent service API keys", console_ns.models[ApiKeyList.__name__])
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@with_current_tenant_id
|
||||
@edit_permission_required
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID) -> dict[str, object]:
|
||||
app_model = _resolve_agent_app_model(session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
@@ -949,8 +1014,7 @@ class AgentApiKeyListApi(BaseApiKeyListResource):
|
||||
@console_ns.response(400, "Maximum keys exceeded")
|
||||
@with_current_tenant_id
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@with_session
|
||||
def post(self, session: Session, tenant_id: str, agent_id: UUID) -> tuple[dict[str, object], int]:
|
||||
app_model = _resolve_agent_app_model(session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
@@ -969,8 +1033,7 @@ class AgentApiKeyApi(BaseApiKeyResource):
|
||||
@console_ns.response(204, "Agent service API key deleted")
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@with_session
|
||||
def delete(
|
||||
self,
|
||||
@@ -1069,7 +1132,7 @@ class AgentLogMessagesApi(Resource):
|
||||
payload = _agent_observability_service(session).list_log_messages(
|
||||
app=app_model,
|
||||
agent_id=str(agent_id),
|
||||
conversation_id=str(conversation_id),
|
||||
conversation_id=str(conversation_id), # pyrefly: ignore[unnecessary-type-conversion]
|
||||
params=AgentLogQueryParams(
|
||||
page=query.page,
|
||||
limit=query.limit,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from collections.abc import Iterable, Mapping
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
@@ -5,7 +6,7 @@ import flask_restx
|
||||
from flask_restx import Resource
|
||||
from flask_restx._http import HTTPStatus
|
||||
from pydantic import field_validator
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
@@ -40,6 +41,9 @@ class ApiKeyItem(ResponseModel):
|
||||
id: str
|
||||
type: str
|
||||
token: str
|
||||
# Dataset keys only: the knowledge bases this key is bound to. Empty = the key can
|
||||
# access every dataset in the tenant (default). App keys are always empty.
|
||||
dataset_ids: list[str] = []
|
||||
last_used_at: int | None = None
|
||||
created_at: int | None = None
|
||||
|
||||
@@ -56,6 +60,38 @@ class ApiKeyList(ResponseModel):
|
||||
register_response_schema_models(console_ns, ApiKeyItem, ApiKeyList)
|
||||
|
||||
|
||||
def mask_api_token(token: str) -> str:
|
||||
"""Mask a secret token for list responses.
|
||||
|
||||
Reveal-once: the full secret is only returned by the create endpoint. List
|
||||
endpoints expose just enough (prefix + last 4) to identify a key, never the
|
||||
full value, so an existing key's secret cannot be retrieved after creation.
|
||||
"""
|
||||
if len(token) <= 8:
|
||||
return "***"
|
||||
return f"{token[:5]}...{token[-4:]}"
|
||||
|
||||
|
||||
def build_masked_api_key_list(
|
||||
api_tokens: Iterable[ApiToken],
|
||||
bindings_by_token: Mapping[str, list[str]] | None = None,
|
||||
) -> ApiKeyList:
|
||||
"""Build an ApiKeyList from ORM tokens with their secrets masked.
|
||||
|
||||
``bindings_by_token`` maps an api_token id to the dataset ids it is bound to
|
||||
(from DatasetApiTokenBinding); tokens absent from the map are unbound (empty =
|
||||
access all). App-key lists omit it entirely.
|
||||
"""
|
||||
bindings_by_token = bindings_by_token or {}
|
||||
items: list[ApiKeyItem] = []
|
||||
for api_token in api_tokens:
|
||||
item = ApiKeyItem.model_validate(api_token, from_attributes=True)
|
||||
item.token = mask_api_token(item.token)
|
||||
item.dataset_ids = bindings_by_token.get(str(api_token.id), [])
|
||||
items.append(item)
|
||||
return ApiKeyList(data=items)
|
||||
|
||||
|
||||
def _get_resource(resource_id, tenant_id, resource_model, *, session: Session):
|
||||
resource = session.execute(
|
||||
select(resource_model).filter_by(id=resource_id, tenant_id=tenant_id)
|
||||
@@ -89,10 +125,14 @@ class BaseApiKeyListResource(Resource):
|
||||
_get_resource(resource_id, current_tenant_id, self.resource_model, session=session)
|
||||
keys = session.scalars(
|
||||
select(ApiToken).where(
|
||||
ApiToken.type == self.resource_type, getattr(ApiToken, self.resource_id_field) == resource_id
|
||||
or_(ApiToken.tenant_id == current_tenant_id, ApiToken.tenant_id.is_(None)),
|
||||
ApiToken.type == self.resource_type,
|
||||
getattr(ApiToken, self.resource_id_field) == resource_id,
|
||||
)
|
||||
).all()
|
||||
return ApiKeyList.model_validate({"data": keys}, from_attributes=True)
|
||||
# App and agent keys keep their existing (unmasked) list behavior; reveal-once
|
||||
# masking is scoped to dataset keys, which build their list in datasets.py.
|
||||
return ApiKeyList(data=[ApiKeyItem.model_validate(key, from_attributes=True) for key in keys])
|
||||
|
||||
@edit_permission_required
|
||||
@with_session
|
||||
@@ -110,7 +150,9 @@ class BaseApiKeyListResource(Resource):
|
||||
current_key_count: int = (
|
||||
session.scalar(
|
||||
select(func.count(ApiToken.id)).where(
|
||||
ApiToken.type == self.resource_type, getattr(ApiToken, self.resource_id_field) == resource_id
|
||||
or_(ApiToken.tenant_id == current_tenant_id, ApiToken.tenant_id.is_(None)),
|
||||
ApiToken.type == self.resource_type,
|
||||
getattr(ApiToken, self.resource_id_field) == resource_id,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
@@ -172,6 +214,7 @@ class BaseApiKeyResource(Resource):
|
||||
key = session.scalar(
|
||||
select(ApiToken)
|
||||
.where(
|
||||
or_(ApiToken.tenant_id == current_tenant_id, ApiToken.tenant_id.is_(None)),
|
||||
getattr(ApiToken, self.resource_id_field) == resource_id,
|
||||
ApiToken.type == self.resource_type,
|
||||
ApiToken.id == api_key_id,
|
||||
@@ -187,7 +230,7 @@ class BaseApiKeyResource(Resource):
|
||||
assert key is not None # nosec - for type checker only
|
||||
ApiTokenCache.delete(key.token, key.type)
|
||||
|
||||
session.execute(delete(ApiToken).where(ApiToken.id == api_key_id))
|
||||
session.delete(key)
|
||||
session.commit()
|
||||
|
||||
|
||||
@@ -198,13 +241,15 @@ class AppApiKeyListResource(BaseApiKeyListResource):
|
||||
@console_ns.doc(params={"resource_id": "App ID"})
|
||||
@console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__])
|
||||
@with_current_tenant_id
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@agent_manage_required_for_agent_app
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, current_tenant_id: str, resource_id: UUID) -> dict[str, object]:
|
||||
"""Get all API keys for an app"""
|
||||
return dump_response(
|
||||
ApiKeyList,
|
||||
self._get_api_key_list(str(resource_id), current_tenant_id, session=session),
|
||||
self._get_api_key_list(str(resource_id), current_tenant_id, session=session), # pyrefly: ignore[unnecessary-type-conversion]
|
||||
)
|
||||
|
||||
@console_ns.doc("create_app_api_key")
|
||||
@@ -221,7 +266,7 @@ class AppApiKeyListResource(BaseApiKeyListResource):
|
||||
"""Create a new API key for an app"""
|
||||
return dump_response(
|
||||
ApiKeyItem,
|
||||
self._create_api_key(str(resource_id), current_tenant_id, session=session),
|
||||
self._create_api_key(str(resource_id), current_tenant_id, session=session), # pyrefly: ignore[unnecessary-type-conversion]
|
||||
), 201
|
||||
|
||||
resource_type = ApiTokenType.APP
|
||||
@@ -251,7 +296,7 @@ class AppApiKeyResource(BaseApiKeyResource):
|
||||
) -> tuple[str, int]:
|
||||
"""Delete an API key for an app"""
|
||||
self._delete_api_key(
|
||||
str(resource_id),
|
||||
str(resource_id), # pyrefly: ignore[unnecessary-type-conversion]
|
||||
str(api_key_id),
|
||||
current_tenant_id,
|
||||
current_user,
|
||||
@@ -264,6 +309,10 @@ class AppApiKeyResource(BaseApiKeyResource):
|
||||
resource_id_field = "app_id"
|
||||
|
||||
|
||||
# Dataset service-API keys are also managed at the workspace level (create with a set of
|
||||
# knowledge bases, list, delete) by DatasetApiKeyApi in
|
||||
# controllers/console/datasets/datasets.py, using DatasetApiTokenBinding for scoping.
|
||||
# The per-dataset routes below remain for callers that key an API token to a single dataset.
|
||||
@console_ns.route("/datasets/<uuid:resource_id>/api-keys")
|
||||
class DatasetApiKeyListResource(BaseApiKeyListResource):
|
||||
@console_ns.doc("get_dataset_api_keys")
|
||||
@@ -271,12 +320,14 @@ class DatasetApiKeyListResource(BaseApiKeyListResource):
|
||||
@console_ns.doc(params={"resource_id": "Dataset ID"})
|
||||
@console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__])
|
||||
@with_current_tenant_id
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_API_KEY_MANAGE)
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, current_tenant_id: str, resource_id: UUID) -> dict[str, object]:
|
||||
"""Get all API keys for a dataset"""
|
||||
return dump_response(
|
||||
ApiKeyList,
|
||||
self._get_api_key_list(str(resource_id), current_tenant_id, session=session),
|
||||
self._get_api_key_list(str(resource_id), current_tenant_id, session=session), # pyrefly: ignore[unnecessary-type-conversion]
|
||||
)
|
||||
|
||||
@console_ns.doc("create_dataset_api_key")
|
||||
@@ -292,7 +343,7 @@ class DatasetApiKeyListResource(BaseApiKeyListResource):
|
||||
"""Create a new API key for a dataset"""
|
||||
return dump_response(
|
||||
ApiKeyItem,
|
||||
self._create_api_key(str(resource_id), current_tenant_id, session=session),
|
||||
self._create_api_key(str(resource_id), current_tenant_id, session=session), # pyrefly: ignore[unnecessary-type-conversion]
|
||||
), 201
|
||||
|
||||
resource_type = ApiTokenType.DATASET
|
||||
@@ -321,7 +372,7 @@ class DatasetApiKeyResource(BaseApiKeyResource):
|
||||
) -> tuple[str, int]:
|
||||
"""Delete an API key for a dataset"""
|
||||
self._delete_api_key(
|
||||
str(resource_id),
|
||||
str(resource_id), # pyrefly: ignore[unnecessary-type-conversion]
|
||||
str(api_key_id),
|
||||
current_tenant_id,
|
||||
current_user,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from typing import Any
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -10,7 +9,7 @@ from controllers.common.schema import (
|
||||
register_response_schema_models,
|
||||
)
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import account_initialization_required, setup_required
|
||||
from controllers.console.wraps import account_initialization_required, model_validate, setup_required
|
||||
from fields.base import ResponseModel
|
||||
from libs.login import login_required
|
||||
from services.advanced_prompt_template_service import AdvancedPromptTemplateArgs, AdvancedPromptTemplateService
|
||||
@@ -49,12 +48,12 @@ class AdvancedPromptTemplateList(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self):
|
||||
args = AdvancedPromptTemplateQuery.model_validate(request.args.to_dict(flat=True))
|
||||
@model_validate(AdvancedPromptTemplateQuery)
|
||||
def get(self, req_data: AdvancedPromptTemplateQuery):
|
||||
prompt_args: AdvancedPromptTemplateArgs = {
|
||||
"app_mode": args.app_mode,
|
||||
"model_mode": args.model_mode,
|
||||
"model_name": args.model_name,
|
||||
"has_context": args.has_context,
|
||||
"app_mode": req_data.app_mode,
|
||||
"model_mode": req_data.model_mode,
|
||||
"model_name": req_data.model_name,
|
||||
"has_context": req_data.has_context,
|
||||
}
|
||||
return AdvancedPromptTemplateService.get_prompt(prompt_args)
|
||||
|
||||
@@ -1,63 +1,27 @@
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.schema import (
|
||||
query_params_from_model,
|
||||
query_params_from_request,
|
||||
register_response_schema_models,
|
||||
register_schema_models,
|
||||
)
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import uuid_value
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.model import App, AppMode, UploadFile
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
from services.agent.skill_package_service import SkillManifest, SkillPackageError
|
||||
from services.agent.skill_standardize_service import SkillStandardizeService
|
||||
from services.agent.skill_tool_inference_service import (
|
||||
SkillToolInferenceError,
|
||||
SkillToolInferenceResult,
|
||||
SkillToolInferenceService,
|
||||
)
|
||||
from services.agent_drive_service import (
|
||||
AgentDriveError,
|
||||
AgentDriveService,
|
||||
DriveCommitItem,
|
||||
DriveFileRef,
|
||||
normalize_drive_key,
|
||||
)
|
||||
from models.model import App, AppMode
|
||||
from services.agent_service import AgentService
|
||||
|
||||
_WORKFLOW_AGENT_DRIVE_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
|
||||
_AGENT_SKILL_UPLOAD_PARAMS = {
|
||||
"file": {
|
||||
"in": "formData",
|
||||
"type": "file",
|
||||
"required": True,
|
||||
"description": "Skill package (.zip or .skill).",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AgentLogQuery(BaseModel):
|
||||
message_id: str = Field(..., description="Message UUID")
|
||||
@@ -69,27 +33,6 @@ class AgentLogQuery(BaseModel):
|
||||
return uuid_value(value)
|
||||
|
||||
|
||||
class AgentDriveFilePayload(BaseModel):
|
||||
upload_file_id: str = Field(..., description="UploadFile UUID from POST /console/api/files/upload")
|
||||
|
||||
@field_validator("upload_file_id")
|
||||
@classmethod
|
||||
def validate_upload_file_id(cls, value: str) -> str:
|
||||
return uuid_value(value)
|
||||
|
||||
|
||||
class AgentDriveMutationQuery(BaseModel):
|
||||
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
|
||||
|
||||
|
||||
class AgentDriveDeleteFileQuery(AgentDriveMutationQuery):
|
||||
key: str = Field(min_length=1, description="Drive key, e.g. files/sample.pdf")
|
||||
|
||||
|
||||
class AgentDriveDeleteFileByAgentQuery(BaseModel):
|
||||
key: str = Field(min_length=1, description="Drive key, e.g. files/sample.pdf")
|
||||
|
||||
|
||||
class AgentLogMetaResponse(ResponseModel):
|
||||
status: str
|
||||
executor: str
|
||||
@@ -127,204 +70,7 @@ class AgentLogResponse(ResponseModel):
|
||||
files: list[Any] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentUploadedSkillResponse(ResponseModel):
|
||||
name: str
|
||||
description: str
|
||||
path: str
|
||||
skill_md_key: str
|
||||
archive_key: str | None = None
|
||||
|
||||
|
||||
class AgentSkillUploadResponse(ResponseModel):
|
||||
skill: AgentUploadedSkillResponse
|
||||
manifest: SkillManifest
|
||||
|
||||
|
||||
class AgentDriveFileResponse(ResponseModel):
|
||||
name: str
|
||||
drive_key: str
|
||||
file_id: str
|
||||
size: int | None = None
|
||||
mime_type: str | None = None
|
||||
|
||||
|
||||
class AgentDriveFileCommitResponse(ResponseModel):
|
||||
file: AgentDriveFileResponse
|
||||
|
||||
|
||||
class AgentDriveDeleteResponse(ResponseModel):
|
||||
result: str
|
||||
removed_keys: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
register_schema_models(console_ns, AgentLogQuery, AgentDriveFilePayload, AgentDriveDeleteFileByAgentQuery)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
AgentDriveDeleteResponse,
|
||||
AgentDriveFileCommitResponse,
|
||||
AgentDriveFileResponse,
|
||||
AgentLogResponse,
|
||||
AgentUploadedSkillResponse,
|
||||
AgentSkillUploadResponse,
|
||||
SkillToolInferenceResult,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_agent_id(session: Session, app_model: App, node_id: str | None) -> str | None:
|
||||
if node_id and app_model.mode != AppMode.AGENT:
|
||||
return AgentComposerService.resolve_workflow_node_agent_id(
|
||||
session=session, tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id
|
||||
)
|
||||
return app_model.bound_agent_id_with_session(session=session)
|
||||
|
||||
|
||||
def _agent_not_bound() -> tuple[dict[str, str], int]:
|
||||
return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400
|
||||
|
||||
|
||||
def _upload_skill_for_app(*, session: Session, current_user: Account, app_model: App):
|
||||
"""Upload one skill package and commit its normalized files into the agent drive."""
|
||||
|
||||
query = query_params_from_request(AgentDriveMutationQuery)
|
||||
agent_id = _resolve_agent_id(session, app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
if "file" not in request.files:
|
||||
return {"code": "no_file", "message": "no skill file uploaded"}, 400
|
||||
if len(request.files) > 1:
|
||||
return {"code": "too_many_files", "message": "only one skill file is allowed"}, 400
|
||||
|
||||
upload = request.files["file"]
|
||||
content = upload.stream.read()
|
||||
try:
|
||||
result = SkillStandardizeService().standardize(
|
||||
content=content,
|
||||
filename=upload.filename or "",
|
||||
tenant_id=app_model.tenant_id,
|
||||
user_id=current_user.id,
|
||||
agent_id=agent_id,
|
||||
session=session,
|
||||
)
|
||||
except (SkillPackageError, AgentDriveError) as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
return result, 201
|
||||
|
||||
|
||||
def _commit_drive_file_for_app(*, session: Session, current_user: Account, app_model: App, allow_node_id: bool = True):
|
||||
query = query_params_from_request(AgentDriveMutationQuery)
|
||||
node_id = query.node_id if allow_node_id else None
|
||||
agent_id = _resolve_agent_id(session, app_model, node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
payload = AgentDriveFilePayload.model_validate(console_ns.payload or {})
|
||||
|
||||
upload_file = session.scalar(
|
||||
select(UploadFile).where(
|
||||
UploadFile.id == payload.upload_file_id,
|
||||
UploadFile.tenant_id == app_model.tenant_id,
|
||||
)
|
||||
)
|
||||
if upload_file is None:
|
||||
return {"code": "upload_file_not_found", "message": "upload file not found in this workspace"}, 404
|
||||
|
||||
try:
|
||||
key = normalize_drive_key(f"files/{upload_file.name}")
|
||||
committed = AgentDriveService().commit(
|
||||
tenant_id=app_model.tenant_id,
|
||||
user_id=current_user.id,
|
||||
agent_id=agent_id,
|
||||
items=[
|
||||
DriveCommitItem(
|
||||
key=key,
|
||||
file_ref=DriveFileRef(kind="upload_file", id=upload_file.id),
|
||||
# ADD FILE uploads exist solely to live in the drive, so the
|
||||
# drive owns (and physically cleans) the value on delete.
|
||||
value_owned_by_drive=True,
|
||||
)
|
||||
],
|
||||
session=session,
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
row = committed[0]
|
||||
return {
|
||||
"file": {
|
||||
"name": upload_file.name,
|
||||
"drive_key": row["key"],
|
||||
"file_id": upload_file.id,
|
||||
"size": row.get("size"),
|
||||
"mime_type": row.get("mime_type"),
|
||||
},
|
||||
}, 201
|
||||
|
||||
|
||||
def _delete_drive_file_for_app(*, session: Session, current_user: Account, app_model: App, allow_node_id: bool = True):
|
||||
query = query_params_from_request(AgentDriveDeleteFileQuery)
|
||||
node_id = query.node_id if allow_node_id else None
|
||||
agent_id = _resolve_agent_id(session, app_model, node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
key = normalize_drive_key(query.key)
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
try:
|
||||
result = AgentDriveService().commit(
|
||||
tenant_id=app_model.tenant_id,
|
||||
user_id=current_user.id,
|
||||
agent_id=agent_id,
|
||||
items=[DriveCommitItem(key=key, file_ref=None)],
|
||||
session=session,
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
removed_keys = [item["key"] for item in result if item.get("removed")]
|
||||
return {"result": "success", "removed_keys": removed_keys}
|
||||
|
||||
|
||||
def _delete_skill_for_app(
|
||||
*, session: Session, current_user: Account, app_model: App, slug: str, allow_node_id: bool = True
|
||||
):
|
||||
query = query_params_from_request(AgentDriveMutationQuery)
|
||||
node_id = query.node_id if allow_node_id else None
|
||||
agent_id = _resolve_agent_id(session, app_model, node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
if "/" in slug or not slug.strip():
|
||||
return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
|
||||
|
||||
try:
|
||||
result = AgentDriveService().commit(
|
||||
tenant_id=app_model.tenant_id,
|
||||
user_id=current_user.id,
|
||||
agent_id=agent_id,
|
||||
items=[
|
||||
DriveCommitItem(key=f"{slug}/SKILL.md", file_ref=None),
|
||||
DriveCommitItem(key=f"{slug}/.DIFY-SKILL-FULL.zip", file_ref=None),
|
||||
],
|
||||
session=session,
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
removed_keys = [item["key"] for item in result if item.get("removed")]
|
||||
return {"result": "success", "removed_keys": removed_keys}
|
||||
|
||||
|
||||
def _infer_skill_tools_for_app(*, session: Session, app_model: App, slug: str):
|
||||
query = query_params_from_request(AgentDriveMutationQuery)
|
||||
agent_id = _resolve_agent_id(session, app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
if "/" in slug or not slug.strip():
|
||||
return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
|
||||
try:
|
||||
return SkillToolInferenceService().infer(
|
||||
tenant_id=app_model.tenant_id, agent_id=agent_id, slug=slug, session=session
|
||||
)
|
||||
except SkillToolInferenceError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
register_response_schema_models(console_ns, AgentLogResponse)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/logs")
|
||||
@@ -341,211 +87,8 @@ class AgentLogApi(Resource):
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=[AppMode.AGENT_CHAT])
|
||||
def get(self, session: Session, app_model: App):
|
||||
"""Get agent logs"""
|
||||
args = AgentLogQuery.model_validate(request.args.to_dict(flat=True))
|
||||
@model_validate(AgentLogQuery)
|
||||
def get(self, req_data: AgentLogQuery, session: Session, app_model: App):
|
||||
"""Get agent logs."""
|
||||
|
||||
return AgentService.get_agent_logs(app_model, args.conversation_id, args.message_id, session)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/skills/upload")
|
||||
class AgentSkillUploadByAgentApi(Resource):
|
||||
@console_ns.doc("upload_agent_skill_by_agent")
|
||||
@console_ns.doc(description="Upload + standardize a Skill into an Agent App drive")
|
||||
@console_ns.doc(consumes=["multipart/form-data"], params={"agent_id": "Agent ID", **_AGENT_SKILL_UPLOAD_PARAMS})
|
||||
@console_ns.response(201, "Skill uploaded into drive", console_ns.models[AgentSkillUploadResponse.__name__])
|
||||
@console_ns.response(400, "Invalid skill package or no bound agent")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _upload_skill_for_app(session=session, current_user=current_user, app_model=app_model)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/skills/upload")
|
||||
class AgentSkillUploadApi(Resource):
|
||||
@console_ns.doc("upload_agent_skill")
|
||||
@console_ns.doc(description="Upload + standardize a Skill into the agent drive")
|
||||
@console_ns.doc(
|
||||
consumes=["multipart/form-data"],
|
||||
params={
|
||||
"app_id": "Application ID",
|
||||
**query_params_from_model(AgentDriveMutationQuery),
|
||||
**_AGENT_SKILL_UPLOAD_PARAMS,
|
||||
},
|
||||
)
|
||||
@console_ns.response(201, "Skill uploaded into drive", console_ns.models[AgentSkillUploadResponse.__name__])
|
||||
@console_ns.response(400, "Invalid skill package or no bound agent")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_session
|
||||
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
|
||||
def post(self, session: Session, current_user: Account, app_model: App):
|
||||
"""Upload a Skill, validate it, and commit drive-backed skill files."""
|
||||
return _upload_skill_for_app(session=session, current_user=current_user, app_model=app_model)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/files")
|
||||
class AgentDriveFilesByAgentApi(Resource):
|
||||
@console_ns.doc("commit_agent_drive_file_by_agent")
|
||||
@console_ns.doc(description="Commit an uploaded file into the Agent App drive under files/<name>")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID"})
|
||||
@console_ns.expect(console_ns.models[AgentDriveFilePayload.__name__])
|
||||
@console_ns.response(
|
||||
201, "File committed into the agent drive", console_ns.models[AgentDriveFileCommitResponse.__name__]
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _commit_drive_file_for_app(
|
||||
session=session, current_user=current_user, app_model=app_model, allow_node_id=False
|
||||
)
|
||||
|
||||
@console_ns.doc("delete_agent_drive_file_by_agent")
|
||||
@console_ns.doc(description="Delete one Agent App drive file by key")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveDeleteFileByAgentQuery)})
|
||||
@console_ns.response(200, "File removed", console_ns.models[AgentDriveDeleteResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def delete(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _delete_drive_file_for_app(
|
||||
session=session, current_user=current_user, app_model=app_model, allow_node_id=False
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/files")
|
||||
class AgentDriveFilesApi(Resource):
|
||||
@console_ns.doc("commit_agent_drive_file")
|
||||
@console_ns.doc(description="Commit an uploaded file into the agent drive under files/<name> (ENG-625 D3)")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveMutationQuery)})
|
||||
@console_ns.expect(console_ns.models[AgentDriveFilePayload.__name__])
|
||||
@console_ns.response(
|
||||
201, "File committed into the agent drive", console_ns.models[AgentDriveFileCommitResponse.__name__]
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_session
|
||||
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
|
||||
def post(self, session: Session, current_user: Account, app_model: App):
|
||||
"""ADD FILE: commit one uploaded file into the bound agent's drive."""
|
||||
return _commit_drive_file_for_app(session=session, current_user=current_user, app_model=app_model)
|
||||
|
||||
@console_ns.doc("delete_agent_drive_file")
|
||||
@console_ns.doc(description="Delete one drive file by key via drive commit-null semantics")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveDeleteFileQuery)})
|
||||
@console_ns.response(200, "File removed", console_ns.models[AgentDriveDeleteResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_session
|
||||
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
|
||||
def delete(self, session: Session, current_user: Account, app_model: App):
|
||||
return _delete_drive_file_for_app(session=session, current_user=current_user, app_model=app_model)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/skills/<string:slug>")
|
||||
class AgentSkillByAgentApi(Resource):
|
||||
@console_ns.doc("delete_agent_skill_by_agent")
|
||||
@console_ns.doc(description="Delete a standardized skill from an Agent App drive")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", "slug": "Skill slug (single path segment)"})
|
||||
@console_ns.response(200, "Skill removed", console_ns.models[AgentDriveDeleteResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def delete(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID, slug: str):
|
||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _delete_skill_for_app(
|
||||
session=session, current_user=current_user, app_model=app_model, slug=slug, allow_node_id=False
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/skills/<string:slug>")
|
||||
class AgentSkillApi(Resource):
|
||||
@console_ns.doc("delete_agent_skill")
|
||||
@console_ns.doc(description="Delete a standardized skill by removing its known drive keys via commit-null")
|
||||
@console_ns.doc(
|
||||
params={
|
||||
"app_id": "Application ID",
|
||||
"slug": "Skill slug (single path segment)",
|
||||
**query_params_from_model(AgentDriveMutationQuery),
|
||||
}
|
||||
)
|
||||
@console_ns.response(200, "Skill removed", console_ns.models[AgentDriveDeleteResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_session
|
||||
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
|
||||
def delete(self, session: Session, current_user: Account, app_model: App, slug: str):
|
||||
return _delete_skill_for_app(session=session, current_user=current_user, app_model=app_model, slug=slug)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/skills/<string:slug>/infer-tools")
|
||||
class AgentSkillInferToolsByAgentApi(Resource):
|
||||
@console_ns.doc("infer_agent_skill_tools_by_agent")
|
||||
@console_ns.doc(description="Infer CLI tool + ENV suggestions from a standardized Agent App skill")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", "slug": "Skill slug (single path segment)"})
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Inference result (draft suggestions, nothing persisted)",
|
||||
console_ns.models[SkillToolInferenceResult.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def post(self, session: Session, tenant_id: str, agent_id: UUID, slug: str):
|
||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _infer_skill_tools_for_app(session=session, app_model=app_model, slug=slug)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/skills/<string:slug>/infer-tools")
|
||||
class AgentSkillInferToolsApi(Resource):
|
||||
@console_ns.doc("infer_agent_skill_tools")
|
||||
@console_ns.doc(
|
||||
description="Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371)"
|
||||
)
|
||||
@console_ns.doc(
|
||||
params={
|
||||
"app_id": "Application ID",
|
||||
"slug": "Skill slug (single path segment)",
|
||||
**query_params_from_model(AgentDriveMutationQuery),
|
||||
}
|
||||
)
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Inference result (draft suggestions, nothing persisted)",
|
||||
console_ns.models[SkillToolInferenceResult.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
|
||||
def post(self, session: Session, app_model: App, slug: str):
|
||||
"""Suggest CLI tools/env for a skill. Saving still goes through composer validation."""
|
||||
return _infer_skill_tools_for_app(session=session, app_model=app_model, slug=slug)
|
||||
return AgentService.get_agent_logs(app_model, req_data.conversation_id, req_data.message_id, session)
|
||||
|
||||
@@ -25,6 +25,7 @@ from controllers.console.wraps import (
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
@@ -87,14 +88,21 @@ class AgentAppFeatureConfigResource(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
@model_validate(AgentAppFeaturesPayload)
|
||||
def post(
|
||||
self,
|
||||
req_data: AgentAppFeaturesPayload,
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
current_user: Account,
|
||||
agent_id: UUID,
|
||||
):
|
||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
args = AgentAppFeaturesPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
new_app_model_config = AgentAppFeatureConfigService.update_features(
|
||||
app_model=app_model,
|
||||
account=current_user,
|
||||
config=args.model_dump(exclude_none=True),
|
||||
config=req_data.model_dump(exclude_none=True),
|
||||
session=session,
|
||||
)
|
||||
|
||||
|
||||
@@ -10,10 +10,11 @@ from __future__ import annotations
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from dify_agent.client import DifyAgentClientError, DifyAgentHTTPError, DifyAgentTimeoutError
|
||||
from dify_agent.client import DifyAgentClientError, DifyAgentHTTPError
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from clients.agent_backend.errors import backend_error_detail, backend_reported_failure
|
||||
from controllers.common.schema import (
|
||||
query_params_from_model,
|
||||
query_params_from_request,
|
||||
@@ -22,10 +23,13 @@ from controllers.common.schema import (
|
||||
)
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.error import AppNotFoundError
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.app.wraps import agent_manage_required_for_agent_app, get_app_model
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
@@ -132,15 +136,10 @@ register_response_schema_models(
|
||||
def _handle(exc: Exception) -> tuple[dict[str, object], int]:
|
||||
if isinstance(exc, AgentSandboxInspectorError):
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
if isinstance(exc, DifyAgentHTTPError):
|
||||
detail = exc.detail
|
||||
if isinstance(detail, dict):
|
||||
return {
|
||||
"code": detail.get("code", "agent_backend_error"),
|
||||
"message": detail.get("message", str(exc)),
|
||||
}, exc.status_code
|
||||
return {"code": "agent_backend_error", "message": str(detail)}, exc.status_code
|
||||
if isinstance(exc, DifyAgentTimeoutError | DifyAgentClientError):
|
||||
if isinstance(exc, DifyAgentHTTPError) and backend_reported_failure(exc):
|
||||
code, message = backend_error_detail(exc)
|
||||
return {"code": code, "message": message}, exc.status_code
|
||||
if isinstance(exc, DifyAgentClientError):
|
||||
return {"code": "agent_backend_unreachable", "message": str(exc)}, 502
|
||||
raise exc
|
||||
|
||||
@@ -154,6 +153,7 @@ class AgentAppSandboxInfoResource(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_VIEW_LAYOUT)
|
||||
@with_current_tenant_id
|
||||
@with_current_user
|
||||
def get(self, current_user: Account, tenant_id: str, agent_id: UUID):
|
||||
@@ -183,6 +183,7 @@ class AgentAppSandboxListResource(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_VIEW_LAYOUT)
|
||||
@with_current_tenant_id
|
||||
@with_current_user
|
||||
def get(self, current_user: Account, tenant_id: str, agent_id: UUID):
|
||||
@@ -213,6 +214,7 @@ class AgentAppSandboxReadResource(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_VIEW_LAYOUT)
|
||||
@with_current_tenant_id
|
||||
@with_current_user
|
||||
def get(self, current_user: Account, tenant_id: str, agent_id: UUID):
|
||||
@@ -243,6 +245,7 @@ class AgentAppSandboxDownloadResource(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_VIEW_LAYOUT)
|
||||
@with_current_tenant_id
|
||||
@with_current_user
|
||||
@model_validate(AgentSandboxDownloadPayload)
|
||||
@@ -286,6 +289,7 @@ class WorkflowAgentSandboxListResource(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str):
|
||||
@@ -323,6 +327,7 @@ class WorkflowAgentSandboxReadResource(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str):
|
||||
@@ -353,6 +358,7 @@ class WorkflowAgentSandboxDownloadResource(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@model_validate(WorkflowAgentSandboxDownloadPayload)
|
||||
|
||||
@@ -1,434 +0,0 @@
|
||||
"""Console read-only inspector for the agent drive (ENG-624).
|
||||
|
||||
``agent-drive`` looks at the *static* drive assets (standardized skills and
|
||||
committed files); the sibling ``agent-sandbox`` routes look at a *runtime*
|
||||
sandbox workspace. Unlike the sandbox routes this never proxies to the agent
|
||||
backend — drive data lives in the API's own DB/storage, served straight from
|
||||
``AgentDriveService``. Download hands the browser an **external** signed URL
|
||||
(the inner manifest hands agents internal ones — the two must never mix).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from flask import Response
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.schema import (
|
||||
query_params_from_model,
|
||||
query_params_from_request,
|
||||
register_response_schema_models,
|
||||
)
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
|
||||
from fields.base import ResponseModel
|
||||
from libs.login import login_required
|
||||
from models.model import App, AppMode
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
from services.agent_drive_service import AgentDriveError, AgentDriveService
|
||||
|
||||
|
||||
class AgentDriveListQuery(BaseModel):
|
||||
prefix: str = Field(default="", description="Key prefix filter: '<slug>/' for one skill, 'files/' for files")
|
||||
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
|
||||
|
||||
|
||||
class AgentDriveListByAgentQuery(BaseModel):
|
||||
prefix: str = Field(default="", description="Key prefix filter: '<slug>/' for one skill, 'files/' for files")
|
||||
|
||||
|
||||
class AgentDriveFileQuery(BaseModel):
|
||||
key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md")
|
||||
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
|
||||
|
||||
|
||||
class AgentDriveFileByAgentQuery(BaseModel):
|
||||
key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md")
|
||||
|
||||
|
||||
class AgentDriveSkillInspectQuery(BaseModel):
|
||||
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
|
||||
|
||||
|
||||
class AgentDriveItemResponse(ResponseModel):
|
||||
key: str
|
||||
size: int | None = None
|
||||
mime_type: str | None = None
|
||||
hash: str | None = None
|
||||
file_kind: str
|
||||
created_at: int | None = None
|
||||
is_skill: bool | None = None
|
||||
skill_metadata: str | None = None
|
||||
|
||||
|
||||
class AgentDriveListResponse(ResponseModel):
|
||||
items: list[AgentDriveItemResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentDriveSkillItemResponse(ResponseModel):
|
||||
path: str
|
||||
skill_md_key: str
|
||||
archive_key: str | None = None
|
||||
name: str
|
||||
description: str
|
||||
size: int | None = None
|
||||
mime_type: str | None = None
|
||||
hash: str | None = None
|
||||
created_at: int | None = None
|
||||
|
||||
|
||||
class AgentDriveSkillListResponse(ResponseModel):
|
||||
items: list[AgentDriveSkillItemResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentDriveSkillFileResponse(ResponseModel):
|
||||
path: str
|
||||
name: str
|
||||
type: str
|
||||
drive_key: str | None = None
|
||||
available_in_drive: bool
|
||||
|
||||
|
||||
class AgentDriveSkillMarkdownResponse(ResponseModel):
|
||||
key: str
|
||||
size: int | None = None
|
||||
truncated: bool
|
||||
binary: bool
|
||||
text: str | None = None
|
||||
|
||||
|
||||
class AgentDriveSkillInspectResponse(ResponseModel):
|
||||
path: str
|
||||
skill_md_key: str
|
||||
archive_key: str | None = None
|
||||
name: str
|
||||
description: str
|
||||
size: int | None = None
|
||||
mime_type: str | None = None
|
||||
hash: str | None = None
|
||||
created_at: int | None = None
|
||||
source: str
|
||||
files: list[AgentDriveSkillFileResponse] = Field(default_factory=list)
|
||||
file_tree: list[dict[str, Any]] = Field(default_factory=list)
|
||||
skill_md: AgentDriveSkillMarkdownResponse
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentDrivePreviewResponse(ResponseModel):
|
||||
key: str
|
||||
size: int | None = None
|
||||
truncated: bool
|
||||
binary: bool
|
||||
text: str | None = None
|
||||
|
||||
|
||||
class AgentDriveDownloadResponse(ResponseModel):
|
||||
url: str
|
||||
|
||||
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
AgentDriveDownloadResponse,
|
||||
AgentDriveListResponse,
|
||||
AgentDrivePreviewResponse,
|
||||
AgentDriveSkillInspectResponse,
|
||||
AgentDriveSkillListResponse,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_agent_id(session: Session, app_model: App, node_id: str | None) -> str | None:
|
||||
"""Agent identity for the drive: app-bound agent, or the workflow node binding."""
|
||||
if node_id:
|
||||
return AgentComposerService.resolve_workflow_node_agent_id(
|
||||
session=session, tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id
|
||||
)
|
||||
return app_model.bound_agent_id_with_session(session=session)
|
||||
|
||||
|
||||
def _agent_not_bound() -> tuple[dict[str, object], int]:
|
||||
return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400
|
||||
|
||||
|
||||
def _handle(exc: AgentDriveError) -> tuple[dict[str, object], int]:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
|
||||
def _json_response(data: Mapping[str, Any]):
|
||||
return Response(
|
||||
response=json.dumps(data, ensure_ascii=False, separators=(",", ":")),
|
||||
content_type="application/json; charset=utf-8",
|
||||
)
|
||||
|
||||
|
||||
_WORKFLOW_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/drive/files")
|
||||
class AgentDriveListByAgentApi(Resource):
|
||||
@console_ns.doc("list_agent_drive_files_by_agent")
|
||||
@console_ns.doc(description="List agent drive entries for an Agent App")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveListByAgentQuery)})
|
||||
@console_ns.response(200, "Drive entries", console_ns.models[AgentDriveListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
query = query_params_from_request(AgentDriveListByAgentQuery)
|
||||
resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
items = AgentDriveService().manifest(
|
||||
tenant_id=tenant_id, agent_id=str(agent_id), prefix=query.prefix, session=session
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]}
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/drive/skills")
|
||||
class AgentDriveSkillListByAgentApi(Resource):
|
||||
@console_ns.doc("list_agent_drive_skills_by_agent")
|
||||
@console_ns.doc(description="List drive-backed skills for an Agent App")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID"})
|
||||
@console_ns.response(200, "Drive skills", console_ns.models[AgentDriveSkillListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=str(agent_id), session=session)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/drive/skills/<path:skill_path>/inspect")
|
||||
class AgentDriveSkillInspectByAgentApi(Resource):
|
||||
@console_ns.doc("inspect_agent_drive_skill_by_agent")
|
||||
@console_ns.doc(description="Inspect one drive-backed skill for slash-menu hover/detail UI")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", "skill_path": "Skill path/slug, e.g. tender-analyzer"})
|
||||
@console_ns.response(200, "Drive skill inspect view", console_ns.models[AgentDriveSkillInspectResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID, skill_path: str):
|
||||
resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
return _json_response(
|
||||
AgentDriveService().inspect_skill(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
skill_path=skill_path,
|
||||
session=session,
|
||||
)
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/drive/files/preview")
|
||||
class AgentDrivePreviewByAgentApi(Resource):
|
||||
@console_ns.doc("preview_agent_drive_file_by_agent")
|
||||
@console_ns.doc(description="Truncated text preview of one Agent App drive value")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveFileByAgentQuery)})
|
||||
@console_ns.response(200, "Preview", console_ns.models[AgentDrivePreviewResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
query = query_params_from_request(AgentDriveFileByAgentQuery)
|
||||
resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
return AgentDriveService().preview(
|
||||
tenant_id=tenant_id, agent_id=str(agent_id), key=query.key, session=session
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/drive/files/download")
|
||||
class AgentDriveDownloadByAgentApi(Resource):
|
||||
@console_ns.doc("download_agent_drive_file_by_agent")
|
||||
@console_ns.doc(description="Time-limited external signed URL for one Agent App drive value")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveFileByAgentQuery)})
|
||||
@console_ns.response(200, "Signed URL", console_ns.models[AgentDriveDownloadResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
query = query_params_from_request(AgentDriveFileByAgentQuery)
|
||||
resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
url = AgentDriveService().download_url(
|
||||
tenant_id=tenant_id, agent_id=str(agent_id), key=query.key, session=session
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"url": url}
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files")
|
||||
class AgentDriveListApi(Resource):
|
||||
@console_ns.doc("list_agent_drive_files")
|
||||
@console_ns.doc(description="List agent drive entries (read-only inspector; one endpoint for both tabs)")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveListQuery)})
|
||||
@console_ns.response(200, "Drive entries", console_ns.models[AgentDriveListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=_WORKFLOW_APP_MODES)
|
||||
def get(self, session: Session, app_model: App):
|
||||
query = query_params_from_request(AgentDriveListQuery)
|
||||
agent_id = _resolve_agent_id(session, app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
items = AgentDriveService().manifest(
|
||||
tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=query.prefix, session=session
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
# the inner manifest exposes file_id for agent-side pulls; the console
|
||||
# inspector is a pure read surface and does not need value pointers
|
||||
return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]}
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/drive/skills")
|
||||
class AgentDriveSkillListApi(Resource):
|
||||
@console_ns.doc("list_agent_drive_skills")
|
||||
@console_ns.doc(description="List drive-backed skills for the bound agent")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveListQuery)})
|
||||
@console_ns.response(200, "Drive skills", console_ns.models[AgentDriveSkillListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=_WORKFLOW_APP_MODES)
|
||||
def get(self, session: Session, app_model: App):
|
||||
query = query_params_from_request(AgentDriveListQuery)
|
||||
agent_id = _resolve_agent_id(session, app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
items = AgentDriveService().list_skills(tenant_id=app_model.tenant_id, agent_id=agent_id, session=session)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/drive/skills/<path:skill_path>/inspect")
|
||||
class AgentDriveSkillInspectApi(Resource):
|
||||
@console_ns.doc("inspect_agent_drive_skill")
|
||||
@console_ns.doc(description="Inspect one drive-backed skill for slash-menu hover/detail UI")
|
||||
@console_ns.doc(
|
||||
params={
|
||||
"app_id": "Application ID",
|
||||
"skill_path": "Skill path/slug, e.g. tender-analyzer",
|
||||
**query_params_from_model(AgentDriveSkillInspectQuery),
|
||||
}
|
||||
)
|
||||
@console_ns.response(200, "Drive skill inspect view", console_ns.models[AgentDriveSkillInspectResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=_WORKFLOW_APP_MODES)
|
||||
def get(self, session: Session, app_model: App, skill_path: str):
|
||||
query = query_params_from_request(AgentDriveSkillInspectQuery)
|
||||
agent_id = _resolve_agent_id(session, app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
return _json_response(
|
||||
AgentDriveService().inspect_skill(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_id=agent_id,
|
||||
skill_path=skill_path,
|
||||
session=session,
|
||||
)
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files/preview")
|
||||
class AgentDrivePreviewApi(Resource):
|
||||
@console_ns.doc("preview_agent_drive_file")
|
||||
@console_ns.doc(description="Truncated text preview of one drive value (binary-safe; SKILL.md is the main case)")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveFileQuery)})
|
||||
@console_ns.response(200, "Preview", console_ns.models[AgentDrivePreviewResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=_WORKFLOW_APP_MODES)
|
||||
def get(self, session: Session, app_model: App):
|
||||
query = query_params_from_request(AgentDriveFileQuery)
|
||||
agent_id = _resolve_agent_id(session, app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
return AgentDriveService().preview(
|
||||
tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key, session=session
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files/download")
|
||||
class AgentDriveDownloadApi(Resource):
|
||||
@console_ns.doc("download_agent_drive_file")
|
||||
@console_ns.doc(description="Time-limited external signed URL for one drive value (no streaming proxy)")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveFileQuery)})
|
||||
@console_ns.response(200, "Signed URL", console_ns.models[AgentDriveDownloadResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=_WORKFLOW_APP_MODES)
|
||||
def get(self, session: Session, app_model: App):
|
||||
query = query_params_from_request(AgentDriveFileQuery)
|
||||
agent_id = _resolve_agent_id(session, app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
url = AgentDriveService().download_url(
|
||||
tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key, session=session
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"url": url}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentDriveDownloadApi",
|
||||
"AgentDriveDownloadByAgentApi",
|
||||
"AgentDriveListApi",
|
||||
"AgentDriveListByAgentApi",
|
||||
"AgentDrivePreviewApi",
|
||||
"AgentDrivePreviewByAgentApi",
|
||||
"AgentDriveSkillInspectApi",
|
||||
"AgentDriveSkillInspectByAgentApi",
|
||||
"AgentDriveSkillListApi",
|
||||
"AgentDriveSkillListByAgentApi",
|
||||
]
|
||||
@@ -4,7 +4,6 @@ from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import AliasChoices, BaseModel, Field, ValidationInfo, computed_field, field_validator, model_validator
|
||||
from sqlalchemy import select
|
||||
@@ -33,6 +32,7 @@ from controllers.console.wraps import (
|
||||
edit_permission_required,
|
||||
enterprise_license_required,
|
||||
is_admin_or_owner_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
@@ -42,8 +42,8 @@ from controllers.console.wraps import (
|
||||
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.rbac import RBACResourceWhitelistScope
|
||||
from core.trigger.constants import TRIGGER_NODE_TYPES
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from graphon.enums import WorkflowExecutionStatus
|
||||
@@ -77,7 +77,7 @@ from services.entities.knowledge_entities.knowledge_entities import (
|
||||
WeightVectorSetting,
|
||||
)
|
||||
from services.errors.account import NoPermissionError
|
||||
from services.feature_service import FeatureService
|
||||
from services.system_feature_service import SystemFeatureService
|
||||
from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task
|
||||
|
||||
ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "completion"]
|
||||
@@ -516,7 +516,7 @@ class AppImportResponse(ResponseModel):
|
||||
|
||||
|
||||
def _enrich_app_list_items(session: Session, *, apps: Sequence[App], tenant_id: str) -> None:
|
||||
if FeatureService.get_system_features().webapp_auth.enabled:
|
||||
if SystemFeatureService.is_webapp_auth_enabled():
|
||||
app_ids = [str(app.id) for app in apps]
|
||||
res = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids=app_ids)
|
||||
if len(res) != len(app_ids):
|
||||
@@ -642,13 +642,13 @@ class AppListApi(Resource):
|
||||
)
|
||||
|
||||
permissions = enterprise_rbac_service.RBACService.MyPermissions.get(
|
||||
str(current_tenant_id),
|
||||
current_tenant_id,
|
||||
current_user_id,
|
||||
session=session,
|
||||
)
|
||||
if dify_config.RBAC_ENABLED:
|
||||
access_filter = resolve_app_access_filter(
|
||||
str(current_tenant_id),
|
||||
current_tenant_id,
|
||||
current_user_id,
|
||||
session=session,
|
||||
permissions=permissions,
|
||||
@@ -675,7 +675,7 @@ class AppListApi(Resource):
|
||||
pagination_model = pagination_model.model_copy(
|
||||
update={
|
||||
"data": [
|
||||
item.model_copy(update={"permission_keys": permission_keys_map.get(str(item.id), [])})
|
||||
item.model_copy(update={"permission_keys": permission_keys_map.get(item.id, [])})
|
||||
for item in pagination_model.data
|
||||
]
|
||||
}
|
||||
@@ -697,30 +697,22 @@ class AppListApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def post(self, session: Session, current_tenant_id: str, current_user: Account):
|
||||
@model_validate(CreateAppPayload)
|
||||
def post(self, req_data: CreateAppPayload, session: Session, current_tenant_id: str, current_user: Account):
|
||||
"""Create app"""
|
||||
args = CreateAppPayload.model_validate(console_ns.payload)
|
||||
params = CreateAppParams(
|
||||
name=args.name,
|
||||
description=args.description,
|
||||
mode=args.mode,
|
||||
icon_type=args.icon_type,
|
||||
icon=args.icon,
|
||||
icon_background=args.icon_background,
|
||||
name=req_data.name,
|
||||
description=req_data.description,
|
||||
mode=req_data.mode,
|
||||
icon_type=req_data.icon_type,
|
||||
icon=req_data.icon,
|
||||
icon_background=req_data.icon_background,
|
||||
)
|
||||
|
||||
app_service = AppService()
|
||||
app = app_service.create_app(current_tenant_id, params, current_user, session=session)
|
||||
if dify_config.RBAC_ENABLED:
|
||||
enterprise_rbac_service.RBACService.AppAccess.replace_whitelist(
|
||||
tenant_id=str(current_tenant_id),
|
||||
account_id=current_user.id,
|
||||
app_id=str(app.id),
|
||||
payload=enterprise_rbac_service.ReplaceMemberBindings(scope=RBACResourceWhitelistScope.ALL),
|
||||
)
|
||||
initialize_created_app_rbac_access_task.delay(current_tenant_id, current_user.id, app_id=app.id)
|
||||
permission_keys_map = enterprise_rbac_service.RBACService.AppPermissions.batch_get(
|
||||
str(current_tenant_id),
|
||||
current_tenant_id,
|
||||
current_user.id,
|
||||
[str(app.id)],
|
||||
session=session,
|
||||
@@ -730,6 +722,17 @@ class AppListApi(Resource):
|
||||
from_attributes=True,
|
||||
context={"session": session},
|
||||
).model_copy(update={"permission_keys": permission_keys_map.get(str(app.id), [])})
|
||||
|
||||
if dify_config.RBAC_ENABLED:
|
||||
enterprise_rbac_service.RBACService.AppAccess.replace_whitelist(
|
||||
current_tenant_id,
|
||||
current_user.id,
|
||||
str(app.id),
|
||||
enterprise_rbac_service.ReplaceMemberBindings(automatic_include_workspace_members=True),
|
||||
)
|
||||
|
||||
initialize_created_app_rbac_access_task.delay(current_tenant_id, current_user.id, app_id=app.id)
|
||||
|
||||
return app_detail.model_dump(mode="json"), 201
|
||||
|
||||
|
||||
@@ -874,12 +877,12 @@ class AppApi(Resource):
|
||||
|
||||
app_model = app_service.get_app(app_model, session=session)
|
||||
|
||||
if FeatureService.get_system_features().webapp_auth.enabled:
|
||||
if SystemFeatureService.is_webapp_auth_enabled():
|
||||
app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id))
|
||||
app_model.access_mode = app_setting.access_mode
|
||||
|
||||
permissions = enterprise_rbac_service.RBACService.MyPermissions.get(
|
||||
str(current_tenant_id),
|
||||
current_tenant_id,
|
||||
current_user.id,
|
||||
app_id=str(app_model.id),
|
||||
session=session,
|
||||
@@ -908,20 +911,20 @@ class AppApi(Resource):
|
||||
@agent_manage_required_for_agent_app
|
||||
@with_session
|
||||
@get_app_model(mode=None)
|
||||
def put(self, session: Session, app_model: App):
|
||||
@model_validate(UpdateAppPayload)
|
||||
def put(self, req_data: UpdateAppPayload, session: Session, app_model: App):
|
||||
"""Update app"""
|
||||
args = UpdateAppPayload.model_validate(console_ns.payload)
|
||||
|
||||
app_service = AppService()
|
||||
|
||||
args_dict: AppService.ArgsDict = {
|
||||
"name": args.name,
|
||||
"description": args.description or "",
|
||||
"icon_type": args.icon_type,
|
||||
"icon": args.icon or "",
|
||||
"icon_background": args.icon_background or "",
|
||||
"use_icon_as_answer_icon": args.use_icon_as_answer_icon or False,
|
||||
"max_active_requests": args.max_active_requests or 0,
|
||||
"name": req_data.name,
|
||||
"description": req_data.description or "",
|
||||
"icon_type": req_data.icon_type,
|
||||
"icon": req_data.icon or "",
|
||||
"icon_background": req_data.icon_background or "",
|
||||
"use_icon_as_answer_icon": req_data.use_icon_as_answer_icon or False,
|
||||
"max_active_requests": req_data.max_active_requests or 0,
|
||||
}
|
||||
app_model = app_service.update_app(app_model, args_dict, session=session)
|
||||
return AppDetailWithSite.model_validate(
|
||||
@@ -969,10 +972,10 @@ class AppCopyApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@get_app_model(mode=None)
|
||||
def post(self, current_tenant_id: str, current_user: Account, app_model: App):
|
||||
@model_validate(CopyAppPayload)
|
||||
def post(self, req_data: CopyAppPayload, current_tenant_id: str, current_user: Account, app_model: App):
|
||||
"""Copy app"""
|
||||
# The role of the current user in the ta table must be admin, owner, or editor
|
||||
args = CopyAppPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
import_service = AppDslService(session)
|
||||
@@ -982,11 +985,11 @@ class AppCopyApi(Resource):
|
||||
account=current_user,
|
||||
import_mode=ImportMode.YAML_CONTENT,
|
||||
yaml_content=yaml_content,
|
||||
name=args.name,
|
||||
description=args.description,
|
||||
icon_type=args.icon_type,
|
||||
icon=args.icon,
|
||||
icon_background=args.icon_background,
|
||||
name=req_data.name,
|
||||
description=req_data.description,
|
||||
icon_type=req_data.icon_type,
|
||||
icon=req_data.icon,
|
||||
icon_background=req_data.icon_background,
|
||||
)
|
||||
except NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
@@ -999,7 +1002,7 @@ class AppCopyApi(Resource):
|
||||
session.commit()
|
||||
|
||||
# Inherit web app permission from original app
|
||||
if result.app_id and FeatureService.get_system_features().webapp_auth.enabled:
|
||||
if result.app_id and SystemFeatureService.is_webapp_auth_enabled():
|
||||
try:
|
||||
# Get the original app's access mode
|
||||
original_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_model.id)
|
||||
@@ -1017,7 +1020,7 @@ class AppCopyApi(Resource):
|
||||
raise NotFound("App not found")
|
||||
|
||||
permission_keys_map = enterprise_rbac_service.RBACService.AppPermissions.batch_get(
|
||||
str(current_tenant_id),
|
||||
current_tenant_id,
|
||||
current_user.id,
|
||||
[str(app.id)],
|
||||
session=session,
|
||||
@@ -1045,16 +1048,16 @@ class AppExportApi(Resource):
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_IMPORT_EXPORT_DSL)
|
||||
@agent_manage_required_for_agent_app
|
||||
@get_app_model
|
||||
def get(self, app_model: App):
|
||||
@model_validate(AppExportQuery)
|
||||
def get(self, req_data: AppExportQuery, app_model: App):
|
||||
"""Export app"""
|
||||
args = AppExportQuery.model_validate(request.args.to_dict(flat=True))
|
||||
|
||||
response = AppExportResponse(
|
||||
data=AppDslService.export_dsl(
|
||||
app_model=app_model,
|
||||
session=db.session(),
|
||||
include_secret=args.include_secret,
|
||||
workflow_id=args.workflow_id,
|
||||
include_secret=req_data.include_secret,
|
||||
workflow_id=req_data.workflow_id,
|
||||
)
|
||||
)
|
||||
return response.model_dump(mode="json")
|
||||
@@ -1082,7 +1085,17 @@ class AppPublishToCreatorsPlatformApi(Resource):
|
||||
dsl_bytes = dsl_content.encode("utf-8")
|
||||
|
||||
claim_code = upload_dsl(dsl_bytes)
|
||||
redirect_url = get_redirect_url(current_user_id, claim_code)
|
||||
# TODO: Move this configuration and OAuth orchestration into the Creators Platform application service
|
||||
# when that domain is refactored. This controller-level integration is a temporary compatibility bridge.
|
||||
oauth_code = None
|
||||
client_id = dify_config.CREATORS_PLATFORM_OAUTH_CLIENT_ID or ""
|
||||
if client_id:
|
||||
authorization = application_services().oauth_server.issue_authorization_code(
|
||||
client_id=client_id,
|
||||
account_id=current_user_id,
|
||||
)
|
||||
oauth_code = authorization.code
|
||||
redirect_url = get_redirect_url(claim_code, oauth_code=oauth_code)
|
||||
|
||||
return RedirectUrlResponse(redirect_url=redirect_url).model_dump(mode="json")
|
||||
|
||||
@@ -1102,11 +1115,11 @@ class AppNameApi(Resource):
|
||||
@agent_manage_required_for_agent_app
|
||||
@with_session
|
||||
@get_app_model(mode=None)
|
||||
def post(self, session: Session, app_model: App):
|
||||
args = AppNamePayload.model_validate(console_ns.payload)
|
||||
@model_validate(AppNamePayload)
|
||||
def post(self, req_data: AppNamePayload, session: Session, app_model: App):
|
||||
|
||||
app_service = AppService()
|
||||
app_model = app_service.update_app_name(app_model, args.name, session=session)
|
||||
app_model = app_service.update_app_name(app_model, req_data.name, session=session)
|
||||
return AppDetail.model_validate(
|
||||
app_model,
|
||||
from_attributes=True,
|
||||
@@ -1130,15 +1143,15 @@ class AppIconApi(Resource):
|
||||
@agent_manage_required_for_agent_app
|
||||
@with_session
|
||||
@get_app_model(mode=None)
|
||||
def post(self, session: Session, app_model: App):
|
||||
args = AppIconPayload.model_validate(console_ns.payload or {})
|
||||
@model_validate(AppIconPayload)
|
||||
def post(self, req_data: AppIconPayload, session: Session, app_model: App):
|
||||
|
||||
app_service = AppService()
|
||||
app_model = app_service.update_app_icon(
|
||||
app_model,
|
||||
args.icon or "",
|
||||
args.icon_background or "",
|
||||
args.icon_type,
|
||||
req_data.icon or "",
|
||||
req_data.icon_background or "",
|
||||
req_data.icon_type,
|
||||
session=session,
|
||||
)
|
||||
return AppDetail.model_validate(
|
||||
@@ -1160,15 +1173,14 @@ class AppSiteStatus(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@agent_manage_required_for_agent_app
|
||||
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@with_session
|
||||
@get_app_model(mode=None)
|
||||
def post(self, session: Session, app_model: App):
|
||||
args = AppSiteStatusPayload.model_validate(console_ns.payload)
|
||||
@model_validate(AppSiteStatusPayload)
|
||||
def post(self, req_data: AppSiteStatusPayload, session: Session, app_model: App):
|
||||
|
||||
app_service = AppService()
|
||||
app_model = app_service.update_app_site_status(app_model, args.enable_site, session=session)
|
||||
app_model = app_service.update_app_site_status(app_model, req_data.enable_site, session=session)
|
||||
return AppDetail.model_validate(
|
||||
app_model,
|
||||
from_attributes=True,
|
||||
@@ -1192,11 +1204,11 @@ class AppApiStatus(Resource):
|
||||
@agent_manage_required_for_agent_app
|
||||
@with_session
|
||||
@get_app_model(mode=None)
|
||||
def post(self, session: Session, app_model: App):
|
||||
args = AppApiStatusPayload.model_validate(console_ns.payload)
|
||||
@model_validate(AppApiStatusPayload)
|
||||
def post(self, req_data: AppApiStatusPayload, session: Session, app_model: App):
|
||||
|
||||
app_service = AppService()
|
||||
app_model = app_service.update_app_api_status(app_model, args.enable_api, session=session)
|
||||
app_model = app_service.update_app_api_status(app_model, req_data.enable_api, session=session)
|
||||
return AppDetail.model_validate(
|
||||
app_model,
|
||||
from_attributes=True,
|
||||
@@ -1242,14 +1254,14 @@ class AppTraceApi(Resource):
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TRACING_CONFIG)
|
||||
@get_app_model
|
||||
def post(self, app_model: App):
|
||||
@model_validate(AppTracePayload)
|
||||
def post(self, req_data: AppTracePayload, app_model: App):
|
||||
# add app trace
|
||||
args = AppTracePayload.model_validate(console_ns.payload)
|
||||
|
||||
OpsTraceManager.update_app_tracing_config(
|
||||
app_id=app_model.id,
|
||||
enabled=args.enabled,
|
||||
tracing_provider=args.tracing_provider,
|
||||
enabled=req_data.enabled,
|
||||
tracing_provider=req_data.tracing_provider,
|
||||
)
|
||||
|
||||
return SimpleResultResponse(result="success").model_dump(mode="json")
|
||||
|
||||
@@ -12,6 +12,7 @@ from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
cloud_edition_billing_resource_check,
|
||||
edit_permission_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_user,
|
||||
@@ -30,7 +31,7 @@ from services.app_dsl_service import (
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.entities.dsl_entities import CheckDependenciesResult, ImportStatus
|
||||
from services.errors.account import NoPermissionError
|
||||
from services.feature_service import FeatureService
|
||||
from services.system_feature_service import SystemFeatureService
|
||||
|
||||
from .. import console_ns
|
||||
from .permission_keys import get_app_permission_keys
|
||||
@@ -83,8 +84,8 @@ class AppImportApi(Resource):
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_IMPORT_EXPORT_DSL, resource_required=False)
|
||||
@with_current_user
|
||||
def post(self, current_user: Account | None = None):
|
||||
args = AppImportPayload.model_validate(console_ns.payload)
|
||||
@model_validate(AppImportPayload)
|
||||
def post(self, req_data: AppImportPayload, current_user: Account | None = None):
|
||||
current_user = current_user if current_user is not None else _current_user_and_tenant_id(None)[0]
|
||||
|
||||
# AppDslService performs internal commits for some creation paths, so use a plain
|
||||
@@ -96,15 +97,15 @@ class AppImportApi(Resource):
|
||||
try:
|
||||
result = import_service.import_app(
|
||||
account=account,
|
||||
import_mode=args.mode,
|
||||
yaml_content=args.yaml_content,
|
||||
yaml_url=args.yaml_url,
|
||||
name=args.name,
|
||||
description=args.description,
|
||||
icon_type=args.icon_type,
|
||||
icon=args.icon,
|
||||
icon_background=args.icon_background,
|
||||
app_id=args.app_id,
|
||||
import_mode=req_data.mode,
|
||||
yaml_content=req_data.yaml_content,
|
||||
yaml_url=req_data.yaml_url,
|
||||
name=req_data.name,
|
||||
description=req_data.description,
|
||||
icon_type=req_data.icon_type,
|
||||
icon=req_data.icon,
|
||||
icon_background=req_data.icon_background,
|
||||
app_id=req_data.app_id,
|
||||
)
|
||||
except NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
@@ -113,7 +114,7 @@ class AppImportApi(Resource):
|
||||
else:
|
||||
session.commit()
|
||||
|
||||
is_created_app = args.app_id is None and result.status in {
|
||||
is_created_app = req_data.app_id is None and result.status in {
|
||||
ImportStatus.COMPLETED,
|
||||
ImportStatus.COMPLETED_WITH_WARNINGS,
|
||||
}
|
||||
@@ -126,7 +127,7 @@ class AppImportApi(Resource):
|
||||
result.app_id,
|
||||
)
|
||||
|
||||
if result.app_id and FeatureService.get_system_features().webapp_auth.enabled:
|
||||
if result.app_id and SystemFeatureService.is_webapp_auth_enabled():
|
||||
# update web app setting as private
|
||||
EnterpriseService.WebAppAuth.update_app_access_mode(result.app_id, "private")
|
||||
# Return appropriate status code based on result
|
||||
|
||||
@@ -31,6 +31,7 @@ from controllers.console.wraps import (
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
@@ -276,15 +277,15 @@ class ChatMessageTextApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model
|
||||
def post(self, app_model: App):
|
||||
@model_validate(TextToSpeechPayload)
|
||||
def post(self, req_data: TextToSpeechPayload, app_model: App):
|
||||
try:
|
||||
payload = TextToSpeechPayload.model_validate(console_ns.payload)
|
||||
message_ref = None
|
||||
if payload.message_id:
|
||||
if req_data.message_id:
|
||||
app_ref = AppRefService.create_app_ref(app_model)
|
||||
message_ref = AppRefService.create_message_ref(
|
||||
app_ref,
|
||||
payload.message_id,
|
||||
req_data.message_id,
|
||||
account_id=current_user.id,
|
||||
)
|
||||
|
||||
@@ -292,8 +293,8 @@ class ChatMessageTextApi(Resource):
|
||||
return AudioService.transcript_tts(
|
||||
app_model=app_model,
|
||||
session=db.session(),
|
||||
text=payload.text,
|
||||
voice=payload.voice,
|
||||
text=req_data.text,
|
||||
voice=req_data.voice,
|
||||
message_ref=message_ref,
|
||||
is_draft=True,
|
||||
)
|
||||
@@ -339,13 +340,12 @@ class TextModesApi(Resource):
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
|
||||
@get_app_model
|
||||
def get(self, app_model: App):
|
||||
@model_validate(TextToSpeechVoiceQuery)
|
||||
def get(self, req_data: TextToSpeechVoiceQuery, app_model: App):
|
||||
try:
|
||||
args = TextToSpeechVoiceQuery.model_validate(request.args.to_dict(flat=True))
|
||||
|
||||
response = AudioService.transcript_tts_voices(
|
||||
tenant_id=app_model.tenant_id,
|
||||
language=args.language,
|
||||
language=req_data.language,
|
||||
)
|
||||
|
||||
return dump_response(TextToSpeechVoiceListResponse, response)
|
||||
|
||||
@@ -16,6 +16,7 @@ from controllers.common.schema import register_response_schema_models, register_
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.app.error import (
|
||||
AgentSessionConfigurationChangedError,
|
||||
AppUnavailableError,
|
||||
CompletionRequestError,
|
||||
ConversationCompletedError,
|
||||
@@ -23,12 +24,13 @@ from controllers.console.app.error import (
|
||||
ProviderNotInitializeError,
|
||||
ProviderQuotaExceededError,
|
||||
)
|
||||
from controllers.console.app.wraps import get_app_model, with_session
|
||||
from controllers.console.app.wraps import agent_manage_required_for_agent_app, get_app_model, with_session
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
@@ -160,11 +162,11 @@ class CompletionMessageApi(Resource):
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
|
||||
@with_session
|
||||
@get_app_model(mode=AppMode.COMPLETION)
|
||||
def post(self, session: Session, current_user: Account, app_model: App):
|
||||
args_model = CompletionMessagePayload.model_validate(console_ns.payload)
|
||||
args = args_model.model_dump(exclude_none=True, by_alias=True)
|
||||
@model_validate(CompletionMessagePayload)
|
||||
def post(self, req_data: CompletionMessagePayload, session: Session, current_user: Account, app_model: App):
|
||||
args = req_data.model_dump(exclude_none=True, by_alias=True)
|
||||
|
||||
streaming = args_model.response_mode != "blocking"
|
||||
streaming = req_data.response_mode != "blocking"
|
||||
args["auto_generate_name"] = False
|
||||
|
||||
try:
|
||||
@@ -261,7 +263,7 @@ class AgentChatMessageApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
|
||||
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_TEST_AND_RUN)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@@ -290,7 +292,7 @@ class AgentBuildChatFinalizeApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
|
||||
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_TEST_AND_RUN)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@@ -619,6 +621,10 @@ def _raise_agent_stream_error_before_response(response):
|
||||
if isinstance(response, _ClosableStream):
|
||||
response.close()
|
||||
message = error_payload.get("message")
|
||||
if error_payload.get("code") == AgentSessionConfigurationChangedError.error_code:
|
||||
raise AgentSessionConfigurationChangedError(
|
||||
str(message or AgentSessionConfigurationChangedError.description)
|
||||
)
|
||||
raise CompletionRequestError(str(message or "Agent App chat failed."))
|
||||
|
||||
return _prepend_stream_chunks(buffered, chunk, iterator)
|
||||
|
||||
@@ -2,7 +2,7 @@ from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import abort, request
|
||||
from flask import abort
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import func, or_
|
||||
@@ -18,6 +18,7 @@ from controllers.console.wraps import (
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_user,
|
||||
@@ -108,17 +109,17 @@ class CompletionConversationApi(Resource):
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=AppMode.COMPLETION)
|
||||
def get(self, session: Session, current_user: Account, app_model: App):
|
||||
args = CompletionConversationQuery.model_validate(request.args.to_dict(flat=True))
|
||||
@model_validate(CompletionConversationQuery)
|
||||
def get(self, req_data: CompletionConversationQuery, session: Session, current_user: Account, app_model: App):
|
||||
|
||||
query = sa.select(Conversation).where(
|
||||
Conversation.app_id == app_model.id, Conversation.mode == "completion", Conversation.is_deleted.is_(False)
|
||||
)
|
||||
|
||||
if args.keyword:
|
||||
if req_data.keyword:
|
||||
from libs.helper import escape_like_pattern
|
||||
|
||||
escaped_keyword = escape_like_pattern(args.keyword)
|
||||
escaped_keyword = escape_like_pattern(req_data.keyword)
|
||||
query = query.join(Message, Message.conversation_id == Conversation.id).where(
|
||||
or_(
|
||||
Message.query.ilike(f"%{escaped_keyword}%", escape="\\"),
|
||||
@@ -130,7 +131,7 @@ class CompletionConversationApi(Resource):
|
||||
assert account.timezone is not None
|
||||
|
||||
try:
|
||||
start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone)
|
||||
start_datetime_utc, end_datetime_utc = parse_time_range(req_data.start, req_data.end, account.timezone)
|
||||
except ValueError as e:
|
||||
abort(400, description=str(e))
|
||||
|
||||
@@ -142,7 +143,7 @@ class CompletionConversationApi(Resource):
|
||||
query = query.where(Conversation.created_at < end_datetime_utc)
|
||||
|
||||
# FIXME, the type ignore in this file
|
||||
if args.annotation_status == "annotated":
|
||||
if req_data.annotation_status == "annotated":
|
||||
query = (
|
||||
query.options(selectinload(Conversation.message_annotations)) # type: ignore[arg-type]
|
||||
.join( # type: ignore
|
||||
@@ -150,7 +151,7 @@ class CompletionConversationApi(Resource):
|
||||
)
|
||||
.group_by(Conversation.id)
|
||||
)
|
||||
elif args.annotation_status == "not_annotated":
|
||||
elif req_data.annotation_status == "not_annotated":
|
||||
query = (
|
||||
query.outerjoin(MessageAnnotation, MessageAnnotation.conversation_id == Conversation.id)
|
||||
.group_by(Conversation.id)
|
||||
@@ -159,7 +160,7 @@ class CompletionConversationApi(Resource):
|
||||
|
||||
query = query.order_by(Conversation.created_at.desc())
|
||||
|
||||
conversations = paginate_query(query, session=session, page=args.page, per_page=args.limit)
|
||||
conversations = paginate_query(query, session=session, page=req_data.page, per_page=req_data.limit)
|
||||
|
||||
return dump_response(
|
||||
ConversationPaginationResponse,
|
||||
@@ -238,8 +239,8 @@ class ChatConversationApi(Resource):
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
|
||||
def get(self, session: Session, current_user: Account, app_model: App):
|
||||
args = ChatConversationQuery.model_validate(request.args.to_dict(flat=True))
|
||||
@model_validate(ChatConversationQuery)
|
||||
def get(self, req_data: ChatConversationQuery, session: Session, current_user: Account, app_model: App):
|
||||
|
||||
subquery = (
|
||||
sa.select(Conversation.id.label("conversation_id"), EndUser.session_id.label("from_end_user_session_id"))
|
||||
@@ -249,10 +250,10 @@ class ChatConversationApi(Resource):
|
||||
|
||||
query = sa.select(Conversation).where(Conversation.app_id == app_model.id, Conversation.is_deleted.is_(False))
|
||||
|
||||
if args.keyword:
|
||||
if req_data.keyword:
|
||||
from libs.helper import escape_like_pattern
|
||||
|
||||
escaped_keyword = escape_like_pattern(args.keyword)
|
||||
escaped_keyword = escape_like_pattern(req_data.keyword)
|
||||
keyword_filter = f"%{escaped_keyword}%"
|
||||
query = (
|
||||
query.join(
|
||||
@@ -276,12 +277,12 @@ class ChatConversationApi(Resource):
|
||||
assert account.timezone is not None
|
||||
|
||||
try:
|
||||
start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone)
|
||||
start_datetime_utc, end_datetime_utc = parse_time_range(req_data.start, req_data.end, account.timezone)
|
||||
except ValueError as e:
|
||||
abort(400, description=str(e))
|
||||
|
||||
if start_datetime_utc:
|
||||
match args.sort_by:
|
||||
match req_data.sort_by:
|
||||
case "updated_at" | "-updated_at":
|
||||
query = query.where(Conversation.updated_at >= start_datetime_utc)
|
||||
case "created_at" | "-created_at" | _:
|
||||
@@ -289,13 +290,13 @@ class ChatConversationApi(Resource):
|
||||
|
||||
if end_datetime_utc:
|
||||
end_datetime_utc = end_datetime_utc.replace(second=59)
|
||||
match args.sort_by:
|
||||
match req_data.sort_by:
|
||||
case "updated_at" | "-updated_at":
|
||||
query = query.where(Conversation.updated_at <= end_datetime_utc)
|
||||
case "created_at" | "-created_at" | _:
|
||||
query = query.where(Conversation.created_at <= end_datetime_utc)
|
||||
|
||||
match args.annotation_status:
|
||||
match req_data.annotation_status:
|
||||
case "annotated":
|
||||
query = (
|
||||
query.options(selectinload(Conversation.message_annotations)) # type: ignore[arg-type]
|
||||
@@ -316,7 +317,7 @@ class ChatConversationApi(Resource):
|
||||
if app_model.mode == AppMode.ADVANCED_CHAT:
|
||||
query = query.where(Conversation.invoke_from != InvokeFrom.DEBUGGER)
|
||||
|
||||
match args.sort_by:
|
||||
match req_data.sort_by:
|
||||
case "created_at":
|
||||
query = query.order_by(Conversation.created_at.asc())
|
||||
case "-created_at":
|
||||
@@ -328,7 +329,7 @@ class ChatConversationApi(Resource):
|
||||
case _:
|
||||
query = query.order_by(Conversation.created_at.desc())
|
||||
|
||||
conversations = paginate_query(query, session=session, page=args.page, per_page=args.limit)
|
||||
conversations = paginate_query(query, session=session, page=req_data.page, per_page=req_data.limit)
|
||||
|
||||
return dump_response(
|
||||
ConversationWithSummaryPaginationResponse,
|
||||
|
||||
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
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
|
||||
@@ -16,6 +15,7 @@ from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
)
|
||||
@@ -101,15 +101,15 @@ class ConversationVariablesApi(Resource):
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT)
|
||||
@get_app_model(mode=AppMode.ADVANCED_CHAT)
|
||||
def get(self, app_model: App):
|
||||
args = ConversationVariablesQuery.model_validate(request.args.to_dict(flat=True))
|
||||
@model_validate(ConversationVariablesQuery)
|
||||
def get(self, req_data: ConversationVariablesQuery, app_model: App):
|
||||
|
||||
stmt = (
|
||||
select(ConversationVariable)
|
||||
.where(ConversationVariable.app_id == app_model.id)
|
||||
.order_by(ConversationVariable.created_at)
|
||||
)
|
||||
stmt = stmt.where(ConversationVariable.conversation_id == args.conversation_id)
|
||||
stmt = stmt.where(ConversationVariable.conversation_id == req_data.conversation_id)
|
||||
|
||||
# NOTE: This is a temporary solution to avoid performance issues.
|
||||
page = 1
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user