Compare commits
1693
Commits
main
..
ab6068a7c1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab6068a7c1 | ||
|
|
5ab3d7f254 | ||
|
|
30e1c2dd90 | ||
|
|
673adef7df | ||
|
|
6f7919ded2 | ||
|
|
014d754707 | ||
|
|
8859c13cfb | ||
|
|
b0970de677 | ||
|
|
53cac5f81a | ||
|
|
4f7f07504a | ||
|
|
1244fb1f2f | ||
|
|
3ca5528640 | ||
|
|
250db4de04 | ||
|
|
f9b6d7e322 | ||
|
|
6edb6fa7aa | ||
|
|
cf9fe58905 | ||
|
|
56bb4f141d | ||
|
|
75f288b241 | ||
|
|
bf42a48fcc | ||
|
|
5e953c9fcb | ||
|
|
814a44f0b9 | ||
|
|
b80433f091 | ||
|
|
5c75e1af16 | ||
|
|
438772f0b7 | ||
|
|
dea4697ea0 | ||
|
|
7dbac9cc56 | ||
|
|
a723a9ca02 | ||
|
|
e58da3fbdf | ||
|
|
60e2c72514 | ||
|
|
1eae02f782 | ||
|
|
db333a4c33 | ||
|
|
f109a95535 | ||
|
|
49d9ced04f | ||
|
|
c2153ec238 | ||
|
|
6b724bd00d | ||
|
|
65d6b61d5d | ||
|
|
1c891df8fb | ||
|
|
6d326a21cd | ||
|
|
f5bf7897b9 | ||
|
|
fbd8dec48f | ||
|
|
dd0ee282df | ||
|
|
553f72604a | ||
|
|
9455160101 | ||
|
|
519e4c0c01 | ||
|
|
dbf62e5889 | ||
|
|
aeb9a55497 | ||
|
|
051e66f7d8 | ||
|
|
1706a24182 | ||
|
|
38857c425b | ||
|
|
99f1ca6029 | ||
|
|
a020458eeb | ||
|
|
8e49fd2e95 | ||
|
|
57b6a89f20 | ||
|
|
90e94994f5 | ||
|
|
8e0d755b57 | ||
|
|
40542db400 | ||
|
|
ac92f29db1 | ||
|
|
f0ddade8a7 | ||
|
|
fbf9031e25 | ||
|
|
2254841822 | ||
|
|
74fc849241 | ||
|
|
dd560ceb46 | ||
|
|
a4f95a4fa9 | ||
|
|
b92a26cdd4 | ||
|
|
7066798ab0 | ||
|
|
222595d6c4 | ||
|
|
3384f2e51c | ||
|
|
ff80cc110d | ||
|
|
2ab3ab8444 | ||
|
|
f7e6ed65bd | ||
|
|
4964967295 | ||
|
|
49c01cb72b | ||
|
|
9afb9750e5 | ||
|
|
f18994fd38 | ||
|
|
7c15ca6710 | ||
|
|
31cfa93408 | ||
|
|
b164e70541 | ||
|
|
17cf849361 | ||
|
|
4ac7bc21cd | ||
|
|
832e180c52 | ||
|
|
875001a8c0 | ||
|
|
36653c53a6 | ||
|
|
37517c2778 | ||
|
|
3715af3138 | ||
|
|
973500a0f0 | ||
|
|
d9a94245fc | ||
|
|
1764d0d5f2 | ||
|
|
84370e2072 | ||
|
|
0289a76fce | ||
|
|
32e509b2b3 | ||
|
|
440b490b0d | ||
|
|
52b9cbf707 | ||
|
|
1b43205c1b | ||
|
|
11326eeb1c | ||
|
|
485a8fa9d9 | ||
|
|
4ce2f3a502 | ||
|
|
6e9e89a6cb | ||
|
|
90d29ed232 | ||
|
|
d9de41dd67 | ||
|
|
f1245da3e4 | ||
|
|
38c3de39f0 | ||
|
|
33639e5b89 | ||
|
|
676b00afb6 | ||
|
|
0980eea975 | ||
|
|
eba3008482 | ||
|
|
9b904ac3dd | ||
|
|
704438b3d2 | ||
|
|
d35d075669 | ||
|
|
910cee9e67 | ||
|
|
21f3787b8b | ||
|
|
33b719d460 | ||
|
|
c6fcf51e0c | ||
|
|
62a3faa7c8 | ||
|
|
d87dfec2de | ||
|
|
5c0864bf0a | ||
|
|
8ce3a62826 | ||
|
|
8c3d6a02fc | ||
|
|
e053b36b6e | ||
|
|
9f29ecae9e | ||
|
|
4b86106a24 | ||
|
|
043b0900d5 | ||
|
|
0c5e2e8cb0 | ||
|
|
40d06b781a | ||
|
|
c5328e6a35 | ||
|
|
babc5e95c6 | ||
|
|
1736227723 | ||
|
|
30943159bf | ||
|
|
007ed5a7de | ||
|
|
ebc727e4fd | ||
|
|
bc49df4561 | ||
|
|
cdb8aa5f81 | ||
|
|
e818c24769 | ||
|
|
49babb6d25 | ||
|
|
3785345353 | ||
|
|
daaa0eab26 | ||
|
|
541bc3280f | ||
|
|
b81d200eca | ||
|
|
51afebb394 | ||
|
|
a5a9c952e8 | ||
|
|
4c9f75f31e | ||
|
|
7c34e18ad6 | ||
|
|
11f928bc95 | ||
|
|
e881c242fe | ||
|
|
e860357563 | ||
|
|
c69fde22bd | ||
|
|
0ce060693b | ||
|
|
be4851555d | ||
|
|
3afa76894f | ||
|
|
acf538ac01 | ||
|
|
5e1765d889 | ||
|
|
30799f17be | ||
|
|
dcfaa199bf | ||
|
|
33b0af333c | ||
|
|
172250e6b5 | ||
|
|
15a46b903a | ||
|
|
8af4fed89d | ||
|
|
64a5305bbf | ||
|
|
819e537aa3 | ||
|
|
68c4896088 | ||
|
|
771957429a | ||
|
|
5bf08c83a6 | ||
|
|
3ab2c9b282 | ||
|
|
94cf37a2ae | ||
|
|
47faa9bcdf | ||
|
|
5901246030 | ||
|
|
44e0b7596b | ||
|
|
178572bc7f | ||
|
|
70da7c5649 | ||
|
|
5c586652af | ||
|
|
067f602732 | ||
|
|
96bfdacee5 | ||
|
|
500dab29ab | ||
|
|
4d688ddbd8 | ||
|
|
8193cc53d5 | ||
|
|
a28ad2140e | ||
|
|
7b37415604 | ||
|
|
c4d244881f | ||
|
|
75963c9ad9 | ||
|
|
11e54550ed | ||
|
|
4f536d9b39 | ||
|
|
b527bc94bb | ||
|
|
54fd0e7316 | ||
|
|
6dbaf40f99 | ||
|
|
3aeb6ac5ce | ||
|
|
5a4881053d | ||
|
|
cebc44ff82 | ||
|
|
56044d9cb7 | ||
|
|
4809ee7db3 | ||
|
|
a4a1e73b8e | ||
|
|
5123b7bdd7 | ||
|
|
e189349eeb | ||
|
|
f2e81d8875 | ||
|
|
e5e0f3a7c3 | ||
|
|
c4fd9dd7da | ||
|
|
b4d076a2d4 | ||
|
|
bc82072710 | ||
|
|
bf023d746a | ||
|
|
fb5024ea82 | ||
|
|
c9c2dc7ea8 | ||
|
|
2a869f4ff6 | ||
|
|
af509eb670 | ||
|
|
783e698b6e | ||
|
|
ba912acf72 | ||
|
|
e0c09f6704 | ||
|
|
17a12bb7b6 | ||
|
|
f8757d1a43 | ||
|
|
69b95f0d6d | ||
|
|
33dea724a1 | ||
|
|
ca32a22c0d | ||
|
|
3eb307c5c5 | ||
|
|
49af497085 | ||
|
|
8b732b43ee | ||
|
|
69e455274a | ||
|
|
e939dc4548 | ||
|
|
1ffead7ee2 | ||
|
|
a0a75a9964 | ||
|
|
37964d1f95 | ||
|
|
a9d66536c3 | ||
|
|
a968c3b5cd | ||
|
|
e52261fc3d | ||
|
|
4450f342d8 | ||
|
|
213dd841b5 | ||
|
|
816c46d447 | ||
|
|
db857ddd4d | ||
|
|
9be0c6555f | ||
|
|
e475c37f1d | ||
|
|
d86c9d48a6 | ||
|
|
008c9ede6e | ||
|
|
151ecceb85 | ||
|
|
1555d5344b | ||
|
|
bd8ec41914 | ||
|
|
1e21e559fb | ||
|
|
e496221dc3 | ||
|
|
5bf3955b26 | ||
|
|
1ab3329f6d | ||
|
|
e70bb4aa6f | ||
|
|
b22d0200c3 | ||
|
|
fb73ae0471 | ||
|
|
75aad4eef6 | ||
|
|
cec2996861 | ||
|
|
239b798c5d | ||
|
|
f1e53ef5c0 | ||
|
|
a368037e81 | ||
|
|
59f2f56124 | ||
|
|
3779bb69ef | ||
|
|
25153dbd84 | ||
|
|
91ba47bf47 | ||
|
|
28bf1ac7b2 | ||
|
|
45df500a9c | ||
|
|
f32de510fe | ||
|
|
21722b9f5a | ||
|
|
f49def09ea | ||
|
|
4948a80c56 | ||
|
|
7d249a81ce | ||
|
|
1d07025f74 | ||
|
|
91c5d43c48 | ||
|
|
b60d9ed09e | ||
|
|
56740ecf56 | ||
|
|
9f788e37de | ||
|
|
0634337858 | ||
|
|
08ce3dda73 | ||
|
|
c8f69781e4 | ||
|
|
13bfa719ee | ||
|
|
359bd28f2c | ||
|
|
7bd032ec77 | ||
|
|
790267ac43 | ||
|
|
d9844e0180 | ||
|
|
6ade3b69b9 | ||
|
|
ae184f434d | ||
|
|
055523bf33 | ||
|
|
866bc0f8d8 | ||
|
|
1bc203b5cd | ||
|
|
1cd99a5a90 | ||
|
|
2ca8a23102 | ||
|
|
97ce19973d | ||
|
|
783774d3f3 | ||
|
|
85e119db08 | ||
|
|
5436719bfb | ||
|
|
e571caaf2c | ||
|
|
5d2997b6a0 | ||
|
|
f8cb7be18c | ||
|
|
b93b82fcaf | ||
|
|
d0b4832ed7 | ||
|
|
4ffd5a3c94 | ||
|
|
2d6992d656 | ||
|
|
bb7291a97c | ||
|
|
a699545c4c | ||
|
|
2c331aa744 | ||
|
|
82dd9fb08f | ||
|
|
b97aa77adc | ||
|
|
d7d101814f | ||
|
|
871d98e6f2 | ||
|
|
3cd093a8a2 | ||
|
|
4a59da7ff0 | ||
|
|
d7ad0624b7 | ||
|
|
27e6b0b23e | ||
|
|
55b5c2cc9f | ||
|
|
d5d5199f6d | ||
|
|
0bd0ac41cd | ||
|
|
b1c396717b | ||
|
|
388a9133fc | ||
|
|
221e947488 | ||
|
|
a1c63a1fe5 | ||
|
|
db2f21191c | ||
|
|
195ef3ee40 | ||
|
|
f3511e5cd0 | ||
|
|
96947d7636 | ||
|
|
034a379ecd | ||
|
|
da856f2b58 | ||
|
|
845c87e3ee | ||
|
|
7d9c0894f8 | ||
|
|
ae30a20739 | ||
|
|
7e7d3c9846 | ||
|
|
daff70c34f | ||
|
|
94188db9ea | ||
|
|
b12497898a | ||
|
|
3e51208024 | ||
|
|
36a3851233 | ||
|
|
54190d7a83 | ||
|
|
2215ec3c49 | ||
|
|
d21dbeb214 | ||
|
|
ba0c32dcee | ||
|
|
f5efe9b041 | ||
|
|
a4a44e1154 | ||
|
|
cef08638af | ||
|
|
025e43f4c5 | ||
|
|
fa04ab8b5a | ||
|
|
f3e5a3bb35 | ||
|
|
0547fb1715 | ||
|
|
bb6b9bd84b | ||
|
|
f485d4fff3 | ||
|
|
4bfaeb728b | ||
|
|
2b0ba2a68b | ||
|
|
c8cdd1d0b2 | ||
|
|
8fbfd35296 | ||
|
|
dfb745a5e4 | ||
|
|
c23350f17b | ||
|
|
124eb04a6b | ||
|
|
b4206d6421 | ||
|
|
537e2e4d06 | ||
|
|
9268c73776 | ||
|
|
6378cd33bd | ||
|
|
8c6d08d6bf | ||
|
|
29082b349d | ||
|
|
7d8656bfde | ||
|
|
45ce14e15a | ||
|
|
8947223255 | ||
|
|
5a420189b1 | ||
|
|
17650afe96 | ||
|
|
80fce34c1a | ||
|
|
ae31c50424 | ||
|
|
794aab1007 | ||
|
|
be06cac223 | ||
|
|
b0db8cc3d8 | ||
|
|
0dab5a8121 | ||
|
|
c012146c18 | ||
|
|
6851c96a45 | ||
|
|
f75f4f9db1 | ||
|
|
f22a6a9957 | ||
|
|
210cb5117f | ||
|
|
0c663f5f7c | ||
|
|
75e8a90ac7 | ||
|
|
d8af6c8f48 | ||
|
|
1ab4216ccb | ||
|
|
d39b58255d | ||
|
|
cd2ca406a5 | ||
|
|
637be00946 | ||
|
|
0dd93421c4 | ||
|
|
e27321cb23 | ||
|
|
6157b42f5a | ||
|
|
be4b180d92 | ||
|
|
e9e4c181b9 | ||
|
|
eb61b332a2 | ||
|
|
4fce6f9104 | ||
|
|
bcd656fb49 | ||
|
|
2d3033434b | ||
|
|
d522498cc9 | ||
|
|
65449ffd71 | ||
|
|
006c5bc4c1 | ||
|
|
a046d19ce1 | ||
|
|
aca089e8ef | ||
|
|
24eb629773 | ||
|
|
1847eb9d6a | ||
|
|
44d7304f8e | ||
|
|
5de74e90dc | ||
|
|
6fdca41a1d | ||
|
|
c978a25b7f | ||
|
|
c11307aa67 | ||
|
|
199269712f | ||
|
|
9572b66ecb | ||
|
|
45315f18d2 | ||
|
|
e889cc25cf | ||
|
|
8385d1913a | ||
|
|
9f462beec5 | ||
|
|
4f0c75e76e | ||
|
|
2097587b42 | ||
|
|
53af352ce8 | ||
|
|
0089af94d3 | ||
|
|
26ab6f663c | ||
|
|
bce0abba6e | ||
|
|
c732fd75e4 | ||
|
|
89232d7521 | ||
|
|
9ef8b1990e | ||
|
|
26f8010a57 | ||
|
|
abf97cdc78 | ||
|
|
4d10590511 | ||
|
|
3d60651491 | ||
|
|
a400989f64 | ||
|
|
2a1f50f726 | ||
|
|
158d157af4 | ||
|
|
1ed004fbb1 | ||
|
|
38f98cdf5c | ||
|
|
a417124c21 | ||
|
|
f3f11461de | ||
|
|
2e79cee24a | ||
|
|
1fd997303e | ||
|
|
cc017a22c7 | ||
|
|
5f01e8dd03 | ||
|
|
ce69905950 | ||
|
|
71e7b9dd63 | ||
|
|
58357547c9 | ||
|
|
fe7c6cb8f7 | ||
|
|
dc5b83e8cd | ||
|
|
27fffbec61 | ||
|
|
d35d121261 | ||
|
|
368673c3b4 | ||
|
|
854de9b43c | ||
|
|
0aa55eb333 | ||
|
|
1d1ab384ef | ||
|
|
864224e07c | ||
|
|
04b38a4a25 | ||
|
|
f8b40353ef | ||
|
|
893f1e6933 | ||
|
|
04e6d0aa00 | ||
|
|
1d6659f9e7 | ||
|
|
a4a67c1a00 | ||
|
|
8f773d3f1b | ||
|
|
c0305c9269 | ||
|
|
6989dd45f1 | ||
|
|
d8b43e7579 | ||
|
|
4f68235c94 | ||
|
|
c1143564ef | ||
|
|
ea2ea9783e | ||
|
|
e3e93712b6 | ||
|
|
afb78a6943 | ||
|
|
1a127ef602 | ||
|
|
acc29c8121 | ||
|
|
ca31490241 | ||
|
|
6c42f86e30 | ||
|
|
5b818836d7 | ||
|
|
3ea8e73389 | ||
|
|
c117e296cb | ||
|
|
42f55251f2 | ||
|
|
304e1b637a | ||
|
|
49173d9c28 | ||
|
|
8678ceb422 | ||
|
|
31cd19aabb | ||
|
|
60029ae3dc | ||
|
|
4f62703477 | ||
|
|
c0af23aefe | ||
|
|
6a6195f883 | ||
|
|
1a87077642 | ||
|
|
e9c8d1cea2 | ||
|
|
d2c76100f2 | ||
|
|
6aff165d09 | ||
|
|
5ecf28e60c | ||
|
|
fb103b6ad9 | ||
|
|
71a865dc0c | ||
|
|
8ed54448c2 | ||
|
|
5a2441cc9f | ||
|
|
089d3e171f | ||
|
|
ed71df1b60 | ||
|
|
85cd2c888e | ||
|
|
6673121f3f | ||
|
|
4898ea0a85 | ||
|
|
7d9a0637ca | ||
|
|
ba885ca201 | ||
|
|
ec60b77a71 | ||
|
|
ae3788a87d | ||
|
|
3e68260afa | ||
|
|
f551c7af9b | ||
|
|
b1ef4ccb1a | ||
|
|
e3631a9fd6 | ||
|
|
8241f8dd6b | ||
|
|
3d2cc9e5cd | ||
|
|
e263d86fc3 | ||
|
|
151384b26d | ||
|
|
030ede6887 | ||
|
|
e6ca2bf64f | ||
|
|
47cff9e2f9 | ||
|
|
6742346eb4 | ||
|
|
b5e682cfd2 | ||
|
|
2193c4484b | ||
|
|
4fc3d7dae2 | ||
|
|
6f0f9fb1fc | ||
|
|
df9c59e21c | ||
|
|
6131eda219 | ||
|
|
14082b3e7b | ||
|
|
00181ebde8 | ||
|
|
320f2ef814 | ||
|
|
8b022bffc5 | ||
|
|
5fddb08e04 | ||
|
|
0fedb6ffd8 | ||
|
|
498e3c6a84 | ||
|
|
85aa242fd5 | ||
|
|
6f94a40303 | ||
|
|
34893007a7 | ||
|
|
053d3e300e | ||
|
|
a1fbfaf864 | ||
|
|
b8c78ba06e | ||
|
|
cd33aaeded | ||
|
|
5aa66fc07d | ||
|
|
23d170cde1 | ||
|
|
d2be3b206d | ||
|
|
1a2ef0a537 | ||
|
|
ce3a5b6bf9 | ||
|
|
91a6183757 | ||
|
|
076cdc746e | ||
|
|
1c2e524b28 | ||
|
|
42ce20b4df | ||
|
|
1cbc0e2dd6 | ||
|
|
fc632f26e9 | ||
|
|
adabfa2fe3 | ||
|
|
ec56fa671b | ||
|
|
f144a59c97 | ||
|
|
dd942cdd60 | ||
|
|
fbec222eb4 | ||
|
|
7c55a577ad | ||
|
|
57ffe1a706 | ||
|
|
06b169d7a1 | ||
|
|
7487395e44 | ||
|
|
26557b30a0 | ||
|
|
f8228847ab | ||
|
|
6b480fdbe9 | ||
|
|
0a456e9146 | ||
|
|
b59cbd51d9 | ||
|
|
23743ee193 | ||
|
|
7748661963 | ||
|
|
e28277b21b | ||
|
|
32476c7ad4 | ||
|
|
ab305b3514 | ||
|
|
16ba5deb0e | ||
|
|
2b6424b3ff | ||
|
|
79f1a004a0 | ||
|
|
b89933e60a | ||
|
|
548cfd1719 | ||
|
|
7d32b41e90 | ||
|
|
6e3896fed3 | ||
|
|
545ec178b8 | ||
|
|
6dd1f9c017 | ||
|
|
6b963fd918 | ||
|
|
f13a1bb7fe | ||
|
|
02115bccfe | ||
|
|
48744441ba | ||
|
|
5ae6261e82 | ||
|
|
a76e28e018 | ||
|
|
f63a1faf3b | ||
|
|
3d75040297 | ||
|
|
8447790c47 | ||
|
|
b5f11ad790 | ||
|
|
d383b0e9dd | ||
|
|
ae2706dad3 | ||
|
|
fba417918f | ||
|
|
b513a0d56c | ||
|
|
b1b1ff98f5 | ||
|
|
3ab674e438 | ||
|
|
9a1b674950 | ||
|
|
9278fd772f | ||
|
|
a8c06ca7dc | ||
|
|
762d3620c4 | ||
|
|
4a403c95c5 | ||
|
|
8ca16c74bc | ||
|
|
60626c384a | ||
|
|
551c748aa8 | ||
|
|
03c155f8a7 | ||
|
|
3b165d9ea5 | ||
|
|
a85d2480b4 | ||
|
|
a9f01bec79 | ||
|
|
e906a911b2 | ||
|
|
8cc2650e98 | ||
|
|
593daa91ec | ||
|
|
a6dd0fdd85 | ||
|
|
30cf0fed91 | ||
|
|
1f2a1aaa76 | ||
|
|
c1c23d9136 | ||
|
|
4b2a93a542 | ||
|
|
23e6685977 | ||
|
|
bc9936bb2b | ||
|
|
ef615bbfb6 | ||
|
|
d8f32690a3 | ||
|
|
31f9838095 | ||
|
|
13ef1942f2 | ||
|
|
2b58fe6c8b | ||
|
|
81ca13280b | ||
|
|
bb7283dcd9 | ||
|
|
0bd4524334 | ||
|
|
ce78cbf936 | ||
|
|
6aaa4c841e | ||
|
|
179a3f2b2f | ||
|
|
678035afed | ||
|
|
758a8fda09 | ||
|
|
3a315798e9 | ||
|
|
bd5623ebc5 | ||
|
|
6a8f118ad0 | ||
|
|
3758122cf7 | ||
|
|
03b6fb724c | ||
|
|
3aa629f0df | ||
|
|
e5ca717783 | ||
|
|
85cca36ddb | ||
|
|
29cc004b69 | ||
|
|
19028ba627 | ||
|
|
77aa895a70 | ||
|
|
f762a76ecf | ||
|
|
095469b8a9 | ||
|
|
16afa9bcbb | ||
|
|
b04b830039 | ||
|
|
a8da6c2632 | ||
|
|
62549ab976 | ||
|
|
32772b7448 | ||
|
|
ecb79ce49c | ||
|
|
500a562bb6 | ||
|
|
b5744a1378 | ||
|
|
b8b71523b5 | ||
|
|
cc06d3f4d2 | ||
|
|
0890b3c375 | ||
|
|
ed887f762e | ||
|
|
bb05f7b1de | ||
|
|
d3880f51b3 | ||
|
|
a2fcd85746 | ||
|
|
47bf0c2a1b | ||
|
|
85dc5466c5 | ||
|
|
c97d88d33f | ||
|
|
60cc6665ce | ||
|
|
d82973c8c8 | ||
|
|
fca39eeaee | ||
|
|
e6a41dbc97 | ||
|
|
6746fe4a78 | ||
|
|
25e4e3dbc9 | ||
|
|
af8db1d24c | ||
|
|
bb2e896fba | ||
|
|
0dca637645 | ||
|
|
54fff09e74 | ||
|
|
39d7dab3e2 | ||
|
|
fc7576fea3 | ||
|
|
6c47da82c4 | ||
|
|
392394b4c9 | ||
|
|
a2bb8c80a2 | ||
|
|
ba0ec20532 | ||
|
|
138a5adb1c | ||
|
|
a2d767e2db | ||
|
|
dd631d57d4 | ||
|
|
a3c8c50212 | ||
|
|
ef1a12ab74 | ||
|
|
1c8b5b23a1 | ||
|
|
8d0759123a | ||
|
|
318f6c0c0c | ||
|
|
d8102e8db8 | ||
|
|
5b610fd807 | ||
|
|
7afdf5b363 | ||
|
|
6c5a2cffe3 | ||
|
|
0bad0eb306 | ||
|
|
5f39879e4d | ||
|
|
342fa8265a | ||
|
|
1fa2338ac3 | ||
|
|
56e17f826c | ||
|
|
bc5c2fa3d4 | ||
|
|
44cb118f95 | ||
|
|
1ece2c5a4f | ||
|
|
2fce5e61c9 | ||
|
|
d5d0494928 | ||
|
|
b380575f16 | ||
|
|
c21a1127f8 | ||
|
|
e621ce88e6 | ||
|
|
761491ec41 | ||
|
|
a35ed77ff0 | ||
|
|
245ac969a7 | ||
|
|
ab21911696 | ||
|
|
380407ae2f | ||
|
|
c0e89044c7 | ||
|
|
4f0494a97a | ||
|
|
baf3fadce6 | ||
|
|
ecf068a61c | ||
|
|
ecaaec939d | ||
|
|
e3c1adb71a | ||
|
|
ce165ece69 | ||
|
|
63bddfdea7 | ||
|
|
208a02a912 | ||
|
|
f750b7f04d | ||
|
|
ba5aeefe98 | ||
|
|
d82b5ed884 | ||
|
|
12f50123dc | ||
|
|
4511c0dee7 | ||
|
|
f6809349d2 | ||
|
|
97da7dc87f | ||
|
|
45049975c2 | ||
|
|
0e4caa5a66 | ||
|
|
f39cffcb9a | ||
|
|
5182ea5d11 | ||
|
|
5ebbb82d66 | ||
|
|
e6662cf673 | ||
|
|
fe0dc8d1a5 | ||
|
|
8794c51f88 | ||
|
|
8d1669adad | ||
|
|
da3d38013e | ||
|
|
b2ef4ce89e | ||
|
|
f525c49267 | ||
|
|
838e986670 | ||
|
|
fa70b31873 | ||
|
|
3855c30edd | ||
|
|
c7dccf8c0b | ||
|
|
2526304394 | ||
|
|
e1da39253a | ||
|
|
6b49741e69 | ||
|
|
2f414c6e08 | ||
|
|
9ed5420381 | ||
|
|
69c8c14c91 | ||
|
|
88b1a330a6 | ||
|
|
9e481cdcaa | ||
|
|
db50493577 | ||
|
|
3907034a1d | ||
|
|
b1b2540dc0 | ||
|
|
5dc1ed5685 | ||
|
|
1ad8da5ef2 | ||
|
|
22e9f18d08 | ||
|
|
b6c9265e31 | ||
|
|
ee6ab38c37 | ||
|
|
fbd237531e | ||
|
|
49101ef0e8 | ||
|
|
8751ee7361 | ||
|
|
01012ff300 | ||
|
|
2a2bb4d28e | ||
|
|
ce04f14f58 | ||
|
|
16b36e2b9f | ||
|
|
c597941128 | ||
|
|
ab22b50faf | ||
|
|
7785091bae | ||
|
|
56449ed129 | ||
|
|
da2864f422 | ||
|
|
c702119e66 | ||
|
|
30d4f9667b | ||
|
|
cd3a71baab | ||
|
|
391409b6c0 | ||
|
|
25dbeb7a9a | ||
|
|
12360c6f3f | ||
|
|
61754acb87 | ||
|
|
634cb1413b | ||
|
|
2fa40c5e96 | ||
|
|
432c12fbac | ||
|
|
146c8e76f0 | ||
|
|
e9f44c8f0f | ||
|
|
f3202f95b6 | ||
|
|
d5f319d9cb | ||
|
|
74324f4585 | ||
|
|
21e53310f8 | ||
|
|
a7596001c5 | ||
|
|
93243298e1 | ||
|
|
ca098e821f | ||
|
|
00d6bd8595 | ||
|
|
f0c85c4795 | ||
|
|
74113b3e6e | ||
|
|
cca954d01c | ||
|
|
d825cc7cfe | ||
|
|
ffca4261d7 | ||
|
|
865a0bfaf6 | ||
|
|
595da9f339 | ||
|
|
44b4a4beba | ||
|
|
c66c359432 | ||
|
|
1214ea0d91 | ||
|
|
cfa05e01c4 | ||
|
|
6a71272b79 | ||
|
|
b9b55b4e5b | ||
|
|
3f2aef2925 | ||
|
|
7a8a29639f | ||
|
|
becfd9b211 | ||
|
|
c6b17d5946 | ||
|
|
e24e058ab2 | ||
|
|
e74ef4b7dc | ||
|
|
c2f6d4fd6f | ||
|
|
2862eee33b | ||
|
|
3ad920457a | ||
|
|
768c6860f5 | ||
|
|
369efc784b | ||
|
|
522327d807 | ||
|
|
915233c5b6 | ||
|
|
229830ee89 | ||
|
|
dd329e7a4f | ||
|
|
da56a53a9c | ||
|
|
3f014a6fe1 | ||
|
|
d9d5780ea7 | ||
|
|
9002ae157a | ||
|
|
f279613f5d | ||
|
|
b58e731c19 | ||
|
|
9d73f27146 | ||
|
|
81f2a48c6d | ||
|
|
57a87d6322 | ||
|
|
728a4b1170 | ||
|
|
9de85591bc | ||
|
|
086c280e5d | ||
|
|
da677f87d5 | ||
|
|
28098b3155 | ||
|
|
c268348e29 | ||
|
|
b33426416b | ||
|
|
cf3dd2d225 | ||
|
|
11bc442f10 | ||
|
|
41ce95cbf1 | ||
|
|
75258db0a2 | ||
|
|
df8bf895ef | ||
|
|
1b86373a54 | ||
|
|
a4a9a4d26e | ||
|
|
1a41ac97d2 | ||
|
|
55aab67383 | ||
|
|
76e2e67af8 | ||
|
|
b682ff5d08 | ||
|
|
2d74f8883e | ||
|
|
e7b51a30a3 | ||
|
|
0b98d18483 | ||
|
|
3d6f35391e | ||
|
|
10047de745 | ||
|
|
82600a3404 | ||
|
|
b73eb77b95 | ||
|
|
1fca24765a | ||
|
|
f107ebc46d | ||
|
|
f2ffae8a22 | ||
|
|
ea78fe9726 | ||
|
|
7d61bc09bc | ||
|
|
b1231fd981 | ||
|
|
bcc3d617a0 | ||
|
|
6890176c76 | ||
|
|
d12c50ea4e | ||
|
|
31525e79a8 | ||
|
|
c48317672c | ||
|
|
f3ca996116 | ||
|
|
5daa4b3e20 | ||
|
|
80e84de868 | ||
|
|
7efd811e75 | ||
|
|
5e68f81a1f | ||
|
|
bebe4feccf | ||
|
|
e684de30bd | ||
|
|
bd0d922737 | ||
|
|
1668c1da87 | ||
|
|
95dec0e72e | ||
|
|
5a33f66ad6 | ||
|
|
33276dae76 | ||
|
|
b0e48b6bda | ||
|
|
f0aec4cd7f | ||
|
|
5ed636bce4 | ||
|
|
fa6191b448 | ||
|
|
93ca273312 | ||
|
|
82d14351fc | ||
|
|
3845d5090b | ||
|
|
17bb3715d1 | ||
|
|
f28426063f | ||
|
|
6efe1b80c5 | ||
|
|
01696f75e9 | ||
|
|
e97017b297 | ||
|
|
22222308a1 | ||
|
|
7fb3870247 | ||
|
|
c57696cf80 | ||
|
|
0784f30e23 | ||
|
|
9af93ecde6 | ||
|
|
d9e3a87911 | ||
|
|
f01789a17e | ||
|
|
688514eab6 | ||
|
|
3d069179d6 | ||
|
|
f90a6e7082 | ||
|
|
406c02ad07 | ||
|
|
3bc824b462 | ||
|
|
c705e0b5ae | ||
|
|
3a04ba5726 | ||
|
|
60a6db8b47 | ||
|
|
cf5550ab0a | ||
|
|
19aeba5fab | ||
|
|
d9afd8d887 | ||
|
|
7422f083b4 | ||
|
|
06508cfb7c | ||
|
|
980d8ba44f | ||
|
|
d658b05ee9 | ||
|
|
123fe36ee4 | ||
|
|
4bf4b96fa9 | ||
|
|
b8680275e5 | ||
|
|
e03ca7b4bd | ||
|
|
3178b4c45e | ||
|
|
413f92e270 | ||
|
|
bd426ed1d3 | ||
|
|
ca9960c8bc | ||
|
|
6b9e1f3c2a | ||
|
|
b8d3a549df | ||
|
|
546dec67a1 | ||
|
|
d61aa853f1 | ||
|
|
96e0d695c0 | ||
|
|
8fbbfc8093 | ||
|
|
853a349265 | ||
|
|
621d4c82af | ||
|
|
87e0ae683e | ||
|
|
44c0f6189b | ||
|
|
64afa9a1ed | ||
|
|
1ce15fbb15 | ||
|
|
ace30af202 | ||
|
|
559340143e | ||
|
|
ae29bb4ca8 | ||
|
|
f6af9379b7 | ||
|
|
57e286b8ad | ||
|
|
7acedfd385 | ||
|
|
946799d19d | ||
|
|
7282a32859 | ||
|
|
5080f1dc39 | ||
|
|
cba165933c | ||
|
|
e54f42c0eb | ||
|
|
c493639119 | ||
|
|
cc5ada1f3d | ||
|
|
539a8bcec8 | ||
|
|
4416348c51 | ||
|
|
bd0eceda61 | ||
|
|
dddd3c1670 | ||
|
|
10795d55e6 | ||
|
|
230fab013a | ||
|
|
2082d20b88 | ||
|
|
c230f7a6c6 | ||
|
|
3405795371 | ||
|
|
b70ed38d51 | ||
|
|
70bb2783b9 | ||
|
|
d9fc7c9fdf | ||
|
|
1938c80061 | ||
|
|
76320ca5a3 | ||
|
|
4aef81284a | ||
|
|
e7ff07debd | ||
|
|
df3fac1032 | ||
|
|
92a46003f8 | ||
|
|
24db448cfd | ||
|
|
9e14c2e289 | ||
|
|
e750df3c0d | ||
|
|
81e4fd92ce | ||
|
|
683cc6d76e | ||
|
|
fb692a0555 | ||
|
|
40206dc94d | ||
|
|
50d70a5050 | ||
|
|
8463289f75 | ||
|
|
b101ee7fc9 | ||
|
|
d06c8d49d7 | ||
|
|
e193c2df21 | ||
|
|
cc99ebba81 | ||
|
|
80d2db6315 | ||
|
|
c05e7df48e | ||
|
|
834df64650 | ||
|
|
8113b822c5 | ||
|
|
a53e565e5a | ||
|
|
d4a71ce384 | ||
|
|
004853f030 | ||
|
|
37be59b0dd | ||
|
|
273503ddb9 | ||
|
|
ce431ee155 | ||
|
|
12844e341c | ||
|
|
eaf1e075f2 | ||
|
|
7affaaf7c0 | ||
|
|
90f3564d0b | ||
|
|
05d14bb364 | ||
|
|
7b8c11852e | ||
|
|
5164fd362c | ||
|
|
2e689e4482 | ||
|
|
1ee2e6ca0a | ||
|
|
924c8f2fac | ||
|
|
0001f86826 | ||
|
|
9c9c912657 | ||
|
|
024c8ee5a1 | ||
|
|
2e7985639e | ||
|
|
e7c745a512 | ||
|
|
1b75887615 | ||
|
|
a1bcc6eed4 | ||
|
|
0bf6285287 | ||
|
|
4df71f32a8 | ||
|
|
44b7f9fa99 | ||
|
|
03329387b3 | ||
|
|
e5ba898fc7 | ||
|
|
a41b5a6d4c | ||
|
|
cbbde887d4 | ||
|
|
89724d6e4d | ||
|
|
6d5325d444 | ||
|
|
db1b32612b | ||
|
|
f4ccef8c70 | ||
|
|
99aa064e6c | ||
|
|
24a5ca0c06 | ||
|
|
6eda572f31 | ||
|
|
7b6dd9ba6f | ||
|
|
1341767692 | ||
|
|
f845805706 | ||
|
|
b2132dd668 | ||
|
|
fc5eaa2941 | ||
|
|
045bb11abf | ||
|
|
9a31155581 | ||
|
|
9e510ce088 | ||
|
|
a642ba91fa | ||
|
|
b0fdeab995 | ||
|
|
1bf1fc6512 | ||
|
|
392373b4b7 | ||
|
|
f3def88342 | ||
|
|
690d09a3d1 | ||
|
|
9471e72171 | ||
|
|
9ebde3b315 | ||
|
|
eaa061dd06 | ||
|
|
5f6ab41798 | ||
|
|
9adb0fee28 | ||
|
|
c577833e7c | ||
|
|
d57bcd9aa2 | ||
|
|
0e7149eec3 | ||
|
|
e53c1a0f7b | ||
|
|
e689493723 | ||
|
|
e6d3226c47 | ||
|
|
2c2650ff07 | ||
|
|
603d667608 | ||
|
|
e9125b0900 | ||
|
|
d0c696e523 | ||
|
|
6f9afcdba2 | ||
|
|
705de81c78 | ||
|
|
335bc3cf48 | ||
|
|
857798ff2f | ||
|
|
3955bd72b5 | ||
|
|
b5d80025e9 | ||
|
|
5b5e4f8ff0 | ||
|
|
ad5bd22c5e | ||
|
|
b2ed6f1acf | ||
|
|
2a8364e902 | ||
|
|
23a18ec318 | ||
|
|
d713f0209d | ||
|
|
0a21d37146 | ||
|
|
c645142f82 | ||
|
|
0e7a1de4db | ||
|
|
ef3ef433c1 | ||
|
|
0da91be9b1 | ||
|
|
69454ada69 | ||
|
|
1126fedea8 | ||
|
|
69c28e3563 | ||
|
|
b117bb1dc2 | ||
|
|
9c9330114f | ||
|
|
0fd314632a | ||
|
|
0d91c74289 | ||
|
|
d59f9e218f | ||
|
|
9bbd04d6cf | ||
|
|
22be6325a8 | ||
|
|
e96cc97462 | ||
|
|
92f5cb8fe3 | ||
|
|
a5897caec4 | ||
|
|
88c83b4dd7 | ||
|
|
fe0ede178e | ||
|
|
cca3d8eb41 | ||
|
|
5590f7d85a | ||
|
|
90bced3ed2 | ||
|
|
19399482e1 | ||
|
|
7657c45aae | ||
|
|
e6e0832c8a | ||
|
|
8f80b7c1ef | ||
|
|
c766e3fa3b | ||
|
|
788118ce3d | ||
|
|
77f8ded1e6 | ||
|
|
9d863f63e5 | ||
|
|
1e4ef070cf | ||
|
|
c1336ad5b3 | ||
|
|
143e0478ef | ||
|
|
cbd07c9d1b | ||
|
|
e3b0958c23 | ||
|
|
b843ab6542 | ||
|
|
dccce9355d | ||
|
|
6c30e649e3 | ||
|
|
bcebc8d5fb | ||
|
|
225398972d | ||
|
|
87f764bfa9 | ||
|
|
47217e9158 | ||
|
|
10334ddfe1 | ||
|
|
002e71f971 | ||
|
|
1f2b1e3b8a | ||
|
|
5d12616211 | ||
|
|
9704eff9f7 | ||
|
|
362a8268e8 | ||
|
|
c0e3c5d692 | ||
|
|
94c02ded2a | ||
|
|
998bd0ce06 | ||
|
|
6b6465b5e3 | ||
|
|
57da50b8d6 | ||
|
|
f87f6031de | ||
|
|
5b686824c5 | ||
|
|
95487d362f | ||
|
|
f496546cc0 | ||
|
|
9423d7e892 | ||
|
|
062f984262 | ||
|
|
925675ab54 | ||
|
|
c05513ae1e | ||
|
|
f257ccb682 | ||
|
|
d5b0bd4d31 | ||
|
|
5026bd1887 | ||
|
|
b0b0cf7b9e | ||
|
|
bf96cf4cb4 | ||
|
|
aeef1dca59 | ||
|
|
a1d460e220 | ||
|
|
e2a962de32 | ||
|
|
dbf1f46fa1 | ||
|
|
ce07a27b8d | ||
|
|
8f6e08eb2f | ||
|
|
a4b14e4911 | ||
|
|
56114562cb | ||
|
|
ba7834f222 | ||
|
|
d49406d2f1 | ||
|
|
a81d8adc90 | ||
|
|
3eae4a0f45 | ||
|
|
016f921cf0 | ||
|
|
9c8230a867 | ||
|
|
aea7724cdb | ||
|
|
79d87ad661 | ||
|
|
ad4b27b7ca | ||
|
|
40ba81ee6a | ||
|
|
d0e71a8104 | ||
|
|
f2d7d43e38 | ||
|
|
d7cb19ee96 | ||
|
|
9d425a19e8 | ||
|
|
48f37df3d5 | ||
|
|
ab876fc7d8 | ||
|
|
712326387f | ||
|
|
133054e2dd | ||
|
|
adcc5a358b | ||
|
|
5c2379577b | ||
|
|
c5352f2ee7 | ||
|
|
5574364438 | ||
|
|
cb0b30690e | ||
|
|
8442e6ec8d | ||
|
|
8ba3a67401 | ||
|
|
f2dc03b51e | ||
|
|
5d39aa55bd | ||
|
|
22574d3ada | ||
|
|
0727b4564b | ||
|
|
6521218308 | ||
|
|
34a9f15cb4 | ||
|
|
a5b65f4087 | ||
|
|
f590f672be | ||
|
|
d9afaca51f | ||
|
|
3b5525a0a5 | ||
|
|
fdfb888be9 | ||
|
|
02eee4bd3e | ||
|
|
b257963551 | ||
|
|
fb43c036f6 | ||
|
|
9ae6184dc1 | ||
|
|
7efedc7b44 | ||
|
|
e1e9be8daa | ||
|
|
247ff86f2f | ||
|
|
89cb5ab2f1 | ||
|
|
c484481964 | ||
|
|
613cece224 | ||
|
|
5aa1fdca04 | ||
|
|
73700cca54 | ||
|
|
3c92e87156 | ||
|
|
0122d3e361 | ||
|
|
30410b0a93 | ||
|
|
5c5632967c | ||
|
|
d872f4e177 | ||
|
|
677faab273 | ||
|
|
d40ee80680 | ||
|
|
79dd6b9be0 | ||
|
|
49cfc687f9 | ||
|
|
f0ccd6fb09 | ||
|
|
372d4f7933 | ||
|
|
6621519f11 | ||
|
|
1777a0171b | ||
|
|
93e702f9ca | ||
|
|
0d8cdf3afb | ||
|
|
fa41d620fd | ||
|
|
93543919d4 | ||
|
|
001b04ac5a | ||
|
|
417ec01fb2 | ||
|
|
0c5938a5a3 | ||
|
|
2e6eff43ad | ||
|
|
c714351819 | ||
|
|
e9c56f8635 | ||
|
|
5f88efb2c9 | ||
|
|
e6af567206 | ||
|
|
43cbcd86be | ||
|
|
44931f8205 | ||
|
|
bc7622b0bd | ||
|
|
1a1d22d6ee | ||
|
|
ab9034633a | ||
|
|
c860ee85f7 | ||
|
|
16adb90653 | ||
|
|
a5d877207e | ||
|
|
685abb6aa9 | ||
|
|
7f0a0d92a3 | ||
|
|
e4983be9d7 | ||
|
|
e30b44dae1 | ||
|
|
0922c8299f | ||
|
|
8adb4d5cef | ||
|
|
5198f6c4df | ||
|
|
5d0bafd651 | ||
|
|
b749c78efa | ||
|
|
22b93e32eb | ||
|
|
c767566cb6 | ||
|
|
a2cc1832f2 | ||
|
|
169346cdbd | ||
|
|
ea50540bd5 | ||
|
|
8e909ef1c1 | ||
|
|
7ad42ccf16 | ||
|
|
633a350bfc | ||
|
|
23d6f9efc2 | ||
|
|
838cae0d2d | ||
|
|
24e92c437d | ||
|
|
7d45c89d72 | ||
|
|
8e931c5acc | ||
|
|
be37779472 | ||
|
|
27cfd53c06 | ||
|
|
00e74a5e45 | ||
|
|
c890ae5a15 | ||
|
|
786ddaa38f | ||
|
|
d162e5533f | ||
|
|
e68b726f9a | ||
|
|
8ed3b0eb91 | ||
|
|
a9332c0ab0 | ||
|
|
423abf4fde | ||
|
|
07ad87365f | ||
|
|
2c136c1fde | ||
|
|
1a122eb7fd | ||
|
|
4a63399363 | ||
|
|
ee8e2b3225 | ||
|
|
8aa52e7e3c | ||
|
|
de597b602a | ||
|
|
2371364911 | ||
|
|
214e794451 | ||
|
|
ab902f948a | ||
|
|
ce1e36f4fd | ||
|
|
72eb5204ff | ||
|
|
e4da5bec8d | ||
|
|
6102963624 | ||
|
|
b152cb43fc | ||
|
|
ebe1678b30 | ||
|
|
8a0962681a | ||
|
|
9778f9521e | ||
|
|
4a3cfb9dfa | ||
|
|
1cfa93e855 | ||
|
|
4fab13c728 | ||
|
|
a3c69dab07 | ||
|
|
e3dd430e68 | ||
|
|
d018ecd805 | ||
|
|
a4b7ab5e9d | ||
|
|
ef1481be63 | ||
|
|
3a2b5d5ccd | ||
|
|
78f6e37f76 | ||
|
|
1ea521abab | ||
|
|
b8a97f2138 | ||
|
|
dac5ff38db | ||
|
|
ac98d2f585 | ||
|
|
37a9cca82d | ||
|
|
8c53594df7 | ||
|
|
eec7567ac6 | ||
|
|
920fde9cd0 | ||
|
|
742bbdf3ac | ||
|
|
4da8a1301e | ||
|
|
3bd5cfab9d | ||
|
|
4f8b0f1c67 | ||
|
|
c59819f6dc | ||
|
|
96d0f4ae73 | ||
|
|
a35aeee6a2 | ||
|
|
2688cda6b5 | ||
|
|
f668133297 | ||
|
|
89bd07be92 | ||
|
|
a5fb806028 | ||
|
|
83714f628b | ||
|
|
ae3e2fb4e8 | ||
|
|
650102cedb | ||
|
|
61babbdf7d | ||
|
|
3734b6a05f | ||
|
|
0256d527f0 | ||
|
|
7adb93fb8e | ||
|
|
97f24f17eb | ||
|
|
96b0bae6ca | ||
|
|
3632b0d726 | ||
|
|
6792b4f4c4 | ||
|
|
10e35055e0 | ||
|
|
7548620683 | ||
|
|
d5e12e3506 | ||
|
|
23b9d401b7 | ||
|
|
44894765b6 | ||
|
|
c2568f4be9 | ||
|
|
90adb9dfa3 | ||
|
|
d2f3eba434 | ||
|
|
2fe058e0ee | ||
|
|
4410acaee8 | ||
|
|
d03f5fbbc0 | ||
|
|
ae1c2f95a7 | ||
|
|
381547ec78 | ||
|
|
f4672ea6b8 | ||
|
|
51a270a2f7 | ||
|
|
f3c8f2e1ab | ||
|
|
e2b676caaa | ||
|
|
68ac49259c | ||
|
|
d94c0aae4b | ||
|
|
047e700d09 | ||
|
|
9ccc473982 | ||
|
|
4142029f48 | ||
|
|
dbf2cc8d80 | ||
|
|
f200972c00 | ||
|
|
3033597f02 | ||
|
|
a16dd889cf | ||
|
|
dab6ecbf9a | ||
|
|
8bdbf6ab15 | ||
|
|
8f75e12871 | ||
|
|
36311e6cef | ||
|
|
8f5af46ec2 | ||
|
|
aeebf53739 | ||
|
|
1d81ee9ed0 | ||
|
|
e1c9d24aa4 | ||
|
|
b7da062a4c | ||
|
|
6fd6d564eb | ||
|
|
106f1d1794 | ||
|
|
3809d7f3e7 | ||
|
|
c769dd20f7 | ||
|
|
445865e422 | ||
|
|
24d4934c27 | ||
|
|
fe7cdba07e | ||
|
|
c56db15a03 | ||
|
|
deb9993a64 | ||
|
|
1ff8860b98 | ||
|
|
198fc18c50 | ||
|
|
621fdc9244 | ||
|
|
9ece6213a9 | ||
|
|
35c313b689 | ||
|
|
d6b88b842d | ||
|
|
ff90cac755 | ||
|
|
8f93606c81 | ||
|
|
95baa44a1c | ||
|
|
fa65e67069 | ||
|
|
066d5032f9 | ||
|
|
3f8a520624 | ||
|
|
6ab6695be7 | ||
|
|
eda0ccffdb | ||
|
|
1d399c3c21 | ||
|
|
cfd0faf5e7 | ||
|
|
ffee030548 | ||
|
|
9d81858ff4 | ||
|
|
f791210e70 | ||
|
|
794ff93fa9 | ||
|
|
ff8eb930f7 | ||
|
|
d71a21ca8b | ||
|
|
2bd23dd374 | ||
|
|
a501463ac7 | ||
|
|
9bd049923e | ||
|
|
67152f1c83 | ||
|
|
8ca3fdec75 | ||
|
|
8e7fc54548 | ||
|
|
1642c3e694 | ||
|
|
d738c13e9e | ||
|
|
45eb46c3f3 | ||
|
|
7b629d0697 | ||
|
|
89a1fa5042 | ||
|
|
c9c59b761f | ||
|
|
8b2f398015 | ||
|
|
5c5bf7a60e | ||
|
|
6cf4404243 | ||
|
|
56ff310f29 | ||
|
|
b671b49725 | ||
|
|
d3d7db1f34 | ||
|
|
26a388e3bf | ||
|
|
6a9d8e903e | ||
|
|
daba5c0599 | ||
|
|
d1e6cd5a8f | ||
|
|
eeb7f30585 | ||
|
|
29fdb06cea | ||
|
|
6d02e2c89e | ||
|
|
cf8c17d08b | ||
|
|
ad5d55220c | ||
|
|
2ecd50f5dc | ||
|
|
48d22e0d42 | ||
|
|
096e85fbc3 | ||
|
|
e3d8e534f6 | ||
|
|
acadc246cb | ||
|
|
fd2c8d1d64 | ||
|
|
f7575e4ddb | ||
|
|
114ee8f6ac | ||
|
|
b91a45639f | ||
|
|
c4277f8566 | ||
|
|
b7aa8b06ca | ||
|
|
26097b7cfe | ||
|
|
6addf507cb | ||
|
|
f0bf29ba08 | ||
|
|
6ef7701805 | ||
|
|
70814b1026 | ||
|
|
ca1d7e77ba | ||
|
|
a800baf8ae | ||
|
|
2f7f07467e | ||
|
|
f472bba9f0 | ||
|
|
48de418d21 | ||
|
|
8892637b97 | ||
|
|
8c55d4b55d | ||
|
|
dffb8a955b | ||
|
|
86e3b4de06 | ||
|
|
525218f9a7 | ||
|
|
9a70329db2 | ||
|
|
9a63a4b7ca | ||
|
|
357f245aec | ||
|
|
8f9451a24b | ||
|
|
6396ccc701 | ||
|
|
9361c069db | ||
|
|
0b5cc2d639 | ||
|
|
61358b6233 | ||
|
|
05815d9475 | ||
|
|
77db1a6297 | ||
|
|
21254a060a | ||
|
|
f049936ec4 | ||
|
|
8673eb5eca | ||
|
|
43df9175df | ||
|
|
7fcf22de90 | ||
|
|
6f9d81ca68 | ||
|
|
267ce81be2 | ||
|
|
3987ee7ee3 | ||
|
|
87c9986b33 | ||
|
|
a85f1571a0 | ||
|
|
d6ff00226a | ||
|
|
7f1f34e34f | ||
|
|
edb3238f3e | ||
|
|
67d5e43ec4 | ||
|
|
f8e3a3e5b4 | ||
|
|
ef410e298d | ||
|
|
80fc2a9240 | ||
|
|
3ae1202536 | ||
|
|
2c0bb2041b | ||
|
|
ab6b6876aa | ||
|
|
284445e0c9 | ||
|
|
05729505d8 | ||
|
|
4004fb908b | ||
|
|
19660e49d2 | ||
|
|
b6a2c9336e | ||
|
|
ba2bc46042 | ||
|
|
ad8f74c964 | ||
|
|
ded3123af2 | ||
|
|
e076abe759 | ||
|
|
c1bb4864ab | ||
|
|
b3a39cbdf7 | ||
|
|
d59e0514b3 | ||
|
|
6e88c5a1c5 | ||
|
|
c74be2b789 | ||
|
|
a258122e22 | ||
|
|
a01d7123d7 | ||
|
|
7652db4601 | ||
|
|
433c540ee2 | ||
|
|
37e1f7104f | ||
|
|
6467c8a870 | ||
|
|
a892f55cb2 | ||
|
|
ac4424bab2 | ||
|
|
99992c951d | ||
|
|
c516b74b75 | ||
|
|
da53b81bf9 | ||
|
|
f730924572 | ||
|
|
0fb69a38cd | ||
|
|
125e8b271b | ||
|
|
8c2ab6b860 | ||
|
|
d639b24c2d | ||
|
|
6ebbf3aa32 | ||
|
|
b97548f8d6 | ||
|
|
87eff3e133 | ||
|
|
2e6ce67b64 | ||
|
|
843eda6fea | ||
|
|
643f9b2c76 | ||
|
|
2089f5e8ee | ||
|
|
7e98d7dc52 | ||
|
|
a514b2fe79 | ||
|
|
d56230e04d | ||
|
|
03e0d8248d | ||
|
|
065bd8f28a | ||
|
|
ca13a9199f | ||
|
|
b59d476c2d | ||
|
|
c69486556a | ||
|
|
bfd75da308 | ||
|
|
53bbb9b15b | ||
|
|
f1d58983c2 | ||
|
|
a8a545690f | ||
|
|
3748513996 | ||
|
|
18c2a4122f | ||
|
|
04664c8d42 | ||
|
|
628bbe14a8 | ||
|
|
abae7c9d67 | ||
|
|
eeee00f6cd | ||
|
|
6971ea5f9c | ||
|
|
cee38f93c8 | ||
|
|
fa5a6b8176 | ||
|
|
97f8940b15 | ||
|
|
556d23ade5 | ||
|
|
313290b22c | ||
|
|
d339db4799 | ||
|
|
03dff2ce77 | ||
|
|
c2c44f8d29 | ||
|
|
13130c7f27 | ||
|
|
30b986bb33 | ||
|
|
0196b0a445 | ||
|
|
8edd06fb67 | ||
|
|
38b59cc198 | ||
|
|
4fbd0bb5c5 | ||
|
|
c2563661d0 | ||
|
|
f358cfb7c7 | ||
|
|
ef11d3e0ed | ||
|
|
f44e2ccf52 | ||
|
|
7e75c5b9b9 | ||
|
|
15ae11f966 | ||
|
|
226439ef36 | ||
|
|
81aca93b81 | ||
|
|
777c3b0079 | ||
|
|
ab85e4df72 | ||
|
|
cf8185498a | ||
|
|
bf34b7df4e | ||
|
|
028c5a34e6 | ||
|
|
c0ea0b0f5a | ||
|
|
4c38055d38 | ||
|
|
87fcbc5bfb | ||
|
|
043a1aab21 | ||
|
|
9f86c8e40d | ||
|
|
3330da0e59 | ||
|
|
64492bec43 | ||
|
|
2402a84c71 | ||
|
|
294d06f22b | ||
|
|
d6e788dc89 | ||
|
|
070cd07199 | ||
|
|
4ded0ccd4f | ||
|
|
6253a2bca9 | ||
|
|
bd1efd8026 | ||
|
|
4940e0d8ed | ||
|
|
8d6cc65530 | ||
|
|
b8adefbb88 | ||
|
|
2c56a84bb7 | ||
|
|
613e22d6ca | ||
|
|
73e76fc72f | ||
|
|
1e5e2581c8 | ||
|
|
b25c4e33cb | ||
|
|
17b28f43f8 | ||
|
|
e3cde9be25 | ||
|
|
449703a6f0 | ||
|
|
358d5c72a6 | ||
|
|
abd2ced4b4 | ||
|
|
49e5aaea6b | ||
|
|
d1f431d121 | ||
|
|
fd759d6845 | ||
|
|
5be5531722 | ||
|
|
2a4fccde70 | ||
|
|
d6f018e581 | ||
|
|
e0c1cb6581 | ||
|
|
f80a00e211 | ||
|
|
4aee413680 | ||
|
|
2a91e2e7ad | ||
|
|
dba821a70e | ||
|
|
836dd73446 | ||
|
|
8f88427d4e | ||
|
|
817585a4ce | ||
|
|
0386f54449 | ||
|
|
4761677676 | ||
|
|
f61d053010 | ||
|
|
8440d02198 | ||
|
|
ebcdf847d2 | ||
|
|
5c553aae19 | ||
|
|
0af8ca5f78 | ||
|
|
52ba178c4e | ||
|
|
166016a741 | ||
|
|
20786e690d | ||
|
|
c19417a706 | ||
|
|
b768e0affb | ||
|
|
05ff10df1b | ||
|
|
a0a6227368 | ||
|
|
918e65d8d3 | ||
|
|
6cab62d140 | ||
|
|
9526268f1f | ||
|
|
016ec1877a | ||
|
|
772e75f04d | ||
|
|
10d7190ea4 | ||
|
|
cc8703577d | ||
|
|
4533755704 | ||
|
|
da4a08dfe1 | ||
|
|
48ddbf0412 | ||
|
|
39def1c20b | ||
|
|
e8a31b5d37 | ||
|
|
5a25dd08f6 | ||
|
|
b34219ce86 | ||
|
|
05548b0fa6 | ||
|
|
e08e7be24c | ||
|
|
1be600155b | ||
|
|
be757b1f2f | ||
|
|
d592e1e535 | ||
|
|
e381e9f52e | ||
|
|
3b951efd13 | ||
|
|
33161c5bad | ||
|
|
8b6236c796 | ||
|
|
a59d80d7f3 | ||
|
|
a5c76591ac | ||
|
|
c2a975af9e | ||
|
|
ab48cd5b55 | ||
|
|
ed4909a28d | ||
|
|
afc517b3a7 | ||
|
|
de9f6b6711 | ||
|
|
646cb8caeb | ||
|
|
d533ddde88 | ||
|
|
9664cb653a | ||
|
|
1574d44cc3 | ||
|
|
6981fc06ca | ||
|
|
12798c00bc | ||
|
|
ebb6be816b | ||
|
|
ae4e4f67c2 | ||
|
|
013aafde17 | ||
|
|
050b1ad78e | ||
|
|
21a76f5e7c | ||
|
|
f9d7610679 | ||
|
|
aeba15eb16 | ||
|
|
b81f64a253 | ||
|
|
a0d04cba50 | ||
|
|
43bdbcf89c | ||
|
|
6947a3ffa7 | ||
|
|
62d525dae7 | ||
|
|
f8bc74b27f | ||
|
|
044370f13c | ||
|
|
a2c45492cb | ||
|
|
9a67ca7c03 | ||
|
|
40cd8a24af | ||
|
|
28948975db | ||
|
|
1ce1db9689 | ||
|
|
86cdeed6ef | ||
|
|
d6ec0afbd6 | ||
|
|
3ea33e1dee | ||
|
|
3e6e3c08d5 | ||
|
|
6155a2ec2b | ||
|
|
4e0787ec77 | ||
|
|
8ecc32da6d | ||
|
|
eb41da78f2 | ||
|
|
a508969331 | ||
|
|
99febda256 | ||
|
|
543d78e040 | ||
|
|
4ef96c7930 | ||
|
|
7a7c99f752 | ||
|
|
6b1f17d6a8 | ||
|
|
cf191ef874 | ||
|
|
f6d3d013fd | ||
|
|
e6ad3c414f | ||
|
|
8162719e36 | ||
|
|
f5bc02d598 | ||
|
|
139dfb76e4 | ||
|
|
f4769c4046 | ||
|
|
fea3cc741f | ||
|
|
4e764350a4 | ||
|
|
c63811367e | ||
|
|
4cc8b3198b | ||
|
|
5c6ac06656 | ||
|
|
9b6b94d919 | ||
|
|
8f4af5ece4 | ||
|
|
2d9248889f | ||
|
|
804f37dcfa | ||
|
|
bf98713a55 | ||
|
|
2f2ba44c7c | ||
|
|
62292500ba | ||
|
|
a7f1e89ce9 | ||
|
|
20c7e2841c | ||
|
|
c79f5c5a7a | ||
|
|
d3771ed31f | ||
|
|
230eec1baf | ||
|
|
0bae393e07 | ||
|
|
434cf40b36 | ||
|
|
7417a1c950 | ||
|
|
799ccbe300 | ||
|
|
43044232bb | ||
|
|
4567db8836 | ||
|
|
e286e59431 | ||
|
|
65e89a4248 | ||
|
|
db63086c38 | ||
|
|
2abdf28eba | ||
|
|
2476f04a26 | ||
|
|
e458f1eab4 | ||
|
|
b15b2649f8 | ||
|
|
4fce7ba09a | ||
|
|
1a1369f115 | ||
|
|
9434ffabdc | ||
|
|
6aeb428796 | ||
|
|
8cabf32ebe | ||
|
|
85a98fcfd6 | ||
|
|
aa1f81a30a | ||
|
|
1c50c73aa4 | ||
|
|
6cc28c7edd | ||
|
|
1c2cf768f8 | ||
|
|
481e718d86 | ||
|
|
29f74afbf6 | ||
|
|
02111ab5b8 | ||
|
|
a2f7157bc3 | ||
|
|
e6591abfbc | ||
|
|
50bda1ddee | ||
|
|
31abad86d5 | ||
|
|
af7e90c87a | ||
|
|
2e4bdf122e | ||
|
|
83d7fa26fe | ||
|
|
efc4d89fdf | ||
|
|
a27206c543 | ||
|
|
faca2c9a2d | ||
|
|
0cbd137b4f | ||
|
|
cdf41d3e5e | ||
|
|
aa034f8669 | ||
|
|
aee5a50b9f | ||
|
|
b8926f00d7 | ||
|
|
839386f0b4 | ||
|
|
cf2e4bdd9c | ||
|
|
e7a455f22e |
No files matched your search
+191
@@ -0,0 +1,191 @@
|
|||||||
|
# Workspace/editor
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
!.vscode/settings.json
|
||||||
|
frontend-web/.vscode/
|
||||||
|
node_modules/
|
||||||
|
# Claude Code local-only settings (per-user permissions, not shared)
|
||||||
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# Local runtime logs and pid files
|
||||||
|
.runtime/
|
||||||
|
logs/
|
||||||
|
offline-backend-20260512/backend/logs/
|
||||||
|
|
||||||
|
# Frontend local/runtime artifacts
|
||||||
|
frontend-web/.env
|
||||||
|
frontend-web/.env.local
|
||||||
|
frontend-web/.env.*.local
|
||||||
|
frontend-web/node_modules/
|
||||||
|
frontend-web/.pnpm-store/
|
||||||
|
frontend-web/tsconfig.tsbuildinfo
|
||||||
|
frontend-web/vite-dev.log
|
||||||
|
frontend-web/vite-dev.err.log
|
||||||
|
|
||||||
|
# Frontend local packages/archives/docs (non-source)
|
||||||
|
frontend-web/pnpm-lock.yaml
|
||||||
|
frontend-web/README.md
|
||||||
|
frontend-web/doc.md
|
||||||
|
frontend-web/src.zip
|
||||||
|
frontend-web/src9.zip
|
||||||
|
frontend-web/src-v1.zip
|
||||||
|
frontend-web/src-*.rar
|
||||||
|
frontend-web/dist
|
||||||
|
# Frontend build artifacts
|
||||||
|
frontend-web/dist/
|
||||||
|
frontend-web/dist-scheduled-check/
|
||||||
|
|
||||||
|
# Offline backend bundles and local deployment artifacts
|
||||||
|
offline-backend-20260505.zip
|
||||||
|
offline-backend-20260505/output/
|
||||||
|
offline-backend-20260505/bundle/.env
|
||||||
|
offline-backend-20260505/bundle/.env.example
|
||||||
|
offline-backend-20260505/bundle/DEPLOY_BACKEND_OFFLINE_ZH.md
|
||||||
|
offline-backend-20260505/bundle/SHA256SUMS
|
||||||
|
offline-backend-20260505/bundle/config.yaml
|
||||||
|
offline-backend-20260505/bundle/config.yaml.example
|
||||||
|
offline-backend-20260505/bundle/data/
|
||||||
|
offline-backend-20260505/bundle/deerflow-backend-offline-image-20260505.tar
|
||||||
|
offline-backend-20260505/bundle/extensions_config.json
|
||||||
|
offline-backend-20260505/bundle/frontend-vite.zip
|
||||||
|
offline-backend-20260505/bundle/skills/
|
||||||
|
offline-backend-20260505/bundle/start_backend.0509.sh
|
||||||
|
offline-backend-20260505/bundle/start_backend.sh
|
||||||
|
offline-backend-20260505/bundle/stop_backend.sh
|
||||||
|
|
||||||
|
# offline-backend-20260512 runtime artifacts (large files, local config, data)
|
||||||
|
offline-backend-20260512/.env.example
|
||||||
|
offline-backend-20260512/.env
|
||||||
|
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
|
||||||
|
offline-backend-20260512/SHA256SUMS
|
||||||
|
offline-backend-20260512/config.yaml.example
|
||||||
|
offline-backend-20260512/data/
|
||||||
|
offline-backend-20260512/deerflow-backend-offline-image-20260505.tar
|
||||||
|
offline-backend-20260512/extensions_config.json
|
||||||
|
offline-backend-20260512/skills/
|
||||||
|
offline-backend-20260512/wheelhouse/
|
||||||
|
offline-backend-20260512/backend/.deer-flow/data/*.db
|
||||||
|
offline-backend-20260512/backend/.deer-flow/data/*.db-*
|
||||||
|
offline-backend-20260512/backend/.deer-flow/*
|
||||||
|
offline-backend-20260512/backend/.env
|
||||||
|
offline-backend-20260512/backend/.env.local
|
||||||
|
offline-backend-20260512/start_backend.sh
|
||||||
|
offline-backend-20260512/stop_backend.sh
|
||||||
|
offline-backend-20260512/check_backend.sh
|
||||||
|
|
||||||
|
hermes-agent-main/
|
||||||
|
|
||||||
|
|
||||||
|
skills/*
|
||||||
|
|
||||||
|
offline-backend-20260512/backend/.deer-flow/*
|
||||||
|
|
||||||
|
# Per-user skill_evolution overrides — runtime data written by the API
|
||||||
|
offline-backend-20260512/backend/skill_evolution.json
|
||||||
|
skills/
|
||||||
|
hermes-agent-main/
|
||||||
|
.gitignore
|
||||||
|
offline-backend-20260512/backend/tests/*
|
||||||
|
# Keep the openGauss URL/dialect/schema compatibility regression coverage versioned.
|
||||||
|
!offline-backend-20260512/backend/tests/test_gauss_database.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_llmwiki_knowledge_management.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
|
||||||
|
# Keep formal Word OOXML and embedded-font regression coverage versioned.
|
||||||
|
!offline-backend-20260512/backend/tests/test_word_export.py
|
||||||
|
# Keep generated-file library aggregation and filtering regression coverage versioned.
|
||||||
|
!offline-backend-20260512/backend/tests/test_artifact_library.py
|
||||||
|
# Keep the model configuration regression coverage versioned even though
|
||||||
|
# this deployment repository otherwise ignores newly added backend tests.
|
||||||
|
!offline-backend-20260512/backend/tests/test_model_management.py
|
||||||
|
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
|
||||||
|
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
|
||||||
|
# Keep fixed-question exact-match and streaming regression coverage versioned.
|
||||||
|
!offline-backend-20260512/backend/tests/test_fixed_questions.py
|
||||||
|
# Keep configurable es_query system-prompt routing regression coverage versioned.
|
||||||
|
!offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py
|
||||||
|
# Keep the local LLMWiki vector-index and external API regression coverage versioned.
|
||||||
|
!offline-backend-20260512/backend/tests/test_llmwiki_local_index_repository.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_llmwiki_vector_search.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_external_llmwiki_search.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py
|
||||||
|
# Deep-research reliability regression coverage.
|
||||||
|
!offline-backend-20260512/backend/tests/test_deep_research_persistence.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_deep_research_start_job.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_summarization_human_message.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_compact_menu_overrides.py
|
||||||
|
# Independent enterprise-research persistence, HTTP and race regressions.
|
||||||
|
!offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py
|
||||||
|
# Position-roundtable per-turn write/present cap (weak-model duplicate artifacts).
|
||||||
|
!offline-backend-20260512/backend/tests/test_position_artifact_cap.py
|
||||||
|
# Roundtable coordinator/seat capability isolation regressions.
|
||||||
|
!offline-backend-20260512/backend/tests/test_roundtable_role_boundary.py
|
||||||
|
# Workflow Studio: schemas / definitions / engine / run-lifecycle regressions
|
||||||
|
# plus the concurrency remediation suite (atomic resume, draft CAS, publish
|
||||||
|
# counter, event seq) — required in CI per docs/WORKFLOW_STUDIO_BACKEND_REMEDIATION_ZH.md P1-06.
|
||||||
|
!offline-backend-20260512/backend/tests/test_workflow_schemas.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_workflow_definitions.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_workflow_engine.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_workflow_runs.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_workflow_concurrency.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_workflow_schema_rebuild.py
|
||||||
|
# Writing-mode first-report pipeline enforcement regressions.
|
||||||
|
!offline-backend-20260512/backend/tests/test_writing_mode_report_middleware.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_writing_rewrite_prompt.py
|
||||||
|
# LLM call timeout retry classification regression coverage.
|
||||||
|
!offline-backend-20260512/backend/tests/test_llm_error_handling_middleware.py
|
||||||
|
# Writing-mode pipeline conversation-material regressions.
|
||||||
|
!offline-backend-20260512/backend/tests/test_deep_research_report_tool.py
|
||||||
|
# Report collaboration (RC-BE-000–018) regression coverage.
|
||||||
|
!offline-backend-20260512/backend/tests/test_report_collaboration_*.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_ensure_orm_columns_sync.py
|
||||||
|
deploy
|
||||||
|
.history
|
||||||
|
|
||||||
|
# Cloned third-party repos (local only)
|
||||||
|
git-clone/
|
||||||
|
|
||||||
|
# Repo-root offline dependency pack (local only)
|
||||||
|
/dist/
|
||||||
|
|
||||||
|
# Frontend offline pack working directories
|
||||||
|
frontend-web/.offline-pack-build/
|
||||||
|
frontend-web/.offline-extract-test/
|
||||||
|
frontend-web-node_modules-offline-*.tar.gz
|
||||||
|
|
||||||
|
# Local dependency caches and backend scratch archives
|
||||||
|
/.pnpm-store/
|
||||||
|
/offline-backend-20260512/backend/backend*.zip
|
||||||
|
|
||||||
|
# Local offline bundles and scratch tooling
|
||||||
|
/*.tar.gz
|
||||||
|
/offline-backend-20260512/*.zip
|
||||||
|
/tmp/
|
||||||
|
|
||||||
|
.codegraph
|
||||||
|
codegraph.mdc
|
||||||
|
|
||||||
|
docs/aidev-docs
|
||||||
|
docs/backups
|
||||||
|
# Frontend skills components must be tracked (excluded from the broad skills/ ignore above)
|
||||||
|
!frontend-web/src/components/workspace/skills/
|
||||||
|
|
||||||
|
# uv lockfile (not tracked per project preference)
|
||||||
|
offline-backend-20260512/backend/uv.lock
|
||||||
|
|
||||||
|
!offline-backend-20260512/backend/tests/test_wiki_package_roundtrip.py
|
||||||
|
|
||||||
|
# Local-only scratch directory (not committed)
|
||||||
|
0622/
|
||||||
|
|
||||||
|
# Python bytecode cache
|
||||||
|
__pycache__/
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
@@ -96,7 +96,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
|||||||
- **智能体使用引导 (Agent onboarding tour, 三段式)**: 智能体「浏览管理 → 创建与配置 → 发起问答」全流程分步引导,组件 `components/workspace/agents/agent-guide.tsx`(同一 TDesign `Guide` 封装模式,与简洁模式引导共享记忆语义:**仅显式点「完成/跳过」才写 localStorage** `deerflow.agent-guide.<stage>.finished`,中途刷新/离开不记忆)。**自动演示仅 create / chat 两段**(进智能体列表页不弹引导,点「创建智能体」进入新建页时才自动演示;list 段的 `AgentGuide` 传 `autoStart={false}`,只能由头部「使用引导」按钮手动触发)。三段各自挂载、各自记忆:① `list`(`agent-gallery.tsx`,/page/workspace/agents)——分区 Tabs、搜索排序、智能体卡片操作、右上「创建智能体」按钮;**完成/跳过就地结束(不跳转创建页,与 create 段无联动)**,最后一步仅提示进入创建页后会有配置引导;② `create`(`NewAgentPage.tsx`,/page/workspace/agents/new)——左侧对话式创建输入区、基本信息、运行模型+入库模板名、知识空间、SOUL.md、技能穿梭框、保存按钮(9 步);③ `chat`(`AgentChatPage.tsx`,agents/:id/chats)——左栏切换与历史、常用问题 chips、输入框(落地态/会话态两个挂点同一 `chat-input` 标记)、右侧详情面板;`eligible` 限定普通智能体对话页(`showAgentList && !isEmbedded && !taskCtxOn`,即排除通用问答原地嵌入与任务深链工作区),三段共用 `isAgentGuideEnvironment()` 排除 embed/hideChrome/无界环境。高亮锚点 `data-agent-guide`:列表页 `tabs`/`search`/`agent-card`(卡片+列表两种布局的根元素)/`new-agent-btn`,创建页 `create-chat-input`/`create-basic`/`create-model`/`create-knowledge`/`create-soul`/`create-skills`/`create-save-btn`,对话页 `chat-sidebar`/`chat-questions`/`chat-input`/`chat-detail`;同样**启动时按目标存在性过滤步骤**(新用户无卡片/无预设问题时自动剔除)。手动重放:三个页面头部各有「使用引导」按钮(列表页创建按钮左侧、创建页 header 右侧、对话页 header),广播 `agent-guide:start:<stage>` 窗口事件强制重放(忽略已看过记忆)。
|
- **智能体使用引导 (Agent onboarding tour, 三段式)**: 智能体「浏览管理 → 创建与配置 → 发起问答」全流程分步引导,组件 `components/workspace/agents/agent-guide.tsx`(同一 TDesign `Guide` 封装模式,与简洁模式引导共享记忆语义:**仅显式点「完成/跳过」才写 localStorage** `deerflow.agent-guide.<stage>.finished`,中途刷新/离开不记忆)。**自动演示仅 create / chat 两段**(进智能体列表页不弹引导,点「创建智能体」进入新建页时才自动演示;list 段的 `AgentGuide` 传 `autoStart={false}`,只能由头部「使用引导」按钮手动触发)。三段各自挂载、各自记忆:① `list`(`agent-gallery.tsx`,/page/workspace/agents)——分区 Tabs、搜索排序、智能体卡片操作、右上「创建智能体」按钮;**完成/跳过就地结束(不跳转创建页,与 create 段无联动)**,最后一步仅提示进入创建页后会有配置引导;② `create`(`NewAgentPage.tsx`,/page/workspace/agents/new)——左侧对话式创建输入区、基本信息、运行模型+入库模板名、知识空间、SOUL.md、技能穿梭框、保存按钮(9 步);③ `chat`(`AgentChatPage.tsx`,agents/:id/chats)——左栏切换与历史、常用问题 chips、输入框(落地态/会话态两个挂点同一 `chat-input` 标记)、右侧详情面板;`eligible` 限定普通智能体对话页(`showAgentList && !isEmbedded && !taskCtxOn`,即排除通用问答原地嵌入与任务深链工作区),三段共用 `isAgentGuideEnvironment()` 排除 embed/hideChrome/无界环境。高亮锚点 `data-agent-guide`:列表页 `tabs`/`search`/`agent-card`(卡片+列表两种布局的根元素)/`new-agent-btn`,创建页 `create-chat-input`/`create-basic`/`create-model`/`create-knowledge`/`create-soul`/`create-skills`/`create-save-btn`,对话页 `chat-sidebar`/`chat-questions`/`chat-input`/`chat-detail`;同样**启动时按目标存在性过滤步骤**(新用户无卡片/无预设问题时自动剔除)。手动重放:三个页面头部各有「使用引导」按钮(列表页创建按钮左侧、创建页 header 右侧、对话页 header),广播 `agent-guide:start:<stage>` 窗口事件强制重放(忽略已看过记忆)。
|
||||||
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels.
|
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels.
|
||||||
- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
|
- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
|
||||||
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route, `appendTaskId`/`idParam` become numeric `queryParams` (task id only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`.
|
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route, `appendTaskId`/`idParam` become numeric `queryParams` (task id, or action id when `idKind:"action"` — same selector as url buttons, xdfx only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url **or host** button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`.
|
||||||
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
|
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
|
||||||
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
|
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
|
||||||
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
|
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
|
||||||
|
|||||||
Submodule
+1
Submodule _skills_src/ai-skills added at cde1c8385e.
Submodule
+1
Submodule _skills_src/anthropics-skills added at 5754626092.
Submodule
+1
Submodule _skills_src/awesome-claude-skills added at 92568c1eda.
Submodule
+1
Submodule _skills_src/ce-kit added at d4973c4827.
Submodule
+1
Submodule _skills_src/csm added at 3fa16a94e0.
Submodule
+1
Submodule _skills_src/superpowers added at 6fd4507659.
Submodule
+1
Submodule _skills_src/tapestry-skills added at 80e1dc56df.
@@ -0,0 +1,186 @@
|
|||||||
|
# 报告协作工作台 — 交互重设计方案(v2)
|
||||||
|
|
||||||
|
> 日期:2026-09-07
|
||||||
|
> 状态:**待确认**(确认后按 §7 分期实施)
|
||||||
|
> 取代:现行「规则澄清 + 三张模板方案卡 + 画布为主」的交互
|
||||||
|
> 原则:回到最初设计 —— **协调者智能体全程与用户对话的主线**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 设计目标与总体形态
|
||||||
|
|
||||||
|
用户视角只有一条主线:**和一个规划师智能体对话 → 它摸清意图并规划工作流 → 确认后执行、过程在流程图上可见 → 随时插话干预、指定智能体重跑 → 最终成稿、继续对话式修改**。
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ 阶段1 计划模式 阶段2/3 执行+干预 阶段4 成稿 │
|
||||||
|
│ ┌───────────┐ ┌───────────┐ ┌──────────┐ │
|
||||||
|
│ │ 规划师对话 │ 确认 │ 聊天时间线 │ 审稿过 │ 对话改稿 │ │
|
||||||
|
│ │ 需求摘要卡 │ ─────→ │ +成员Tab │ ─────→ │ 版本/diff│ │
|
||||||
|
│ │ 工作流卡 │ 冻结 │ +流程图动画│ 报告卡 │ 上游重跑 │ │
|
||||||
|
│ └───────────┘ └───────────┘ └──────────┘ │
|
||||||
|
│ ↑ 回到计划模式(replan)随时可触发 ↑ │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
与现行的核心差异:
|
||||||
|
|
||||||
|
| 维度 | 现行(做偏的) | v2 |
|
||||||
|
|------|----------------|-----|
|
||||||
|
| 需求澄清 | 规则模板问句秒回 | 规划师智能体(LLM)领域化提问 |
|
||||||
|
| 工作流来源 | 3 张确定性模板 | LLM 按意图生成自定义 DAG,对话迭代修改 |
|
||||||
|
| 执行展示 | 画布为主、聊天为辅 | **聊天时间线为主**(成员 Tab 切换保留),**执行状态在流程图上动画呈现** |
|
||||||
|
| 干预 | 命令确认卡体系 | 输入框常开 → 协调者智能体分类;支持**指定智能体重跑** |
|
||||||
|
| 报告修改 | 改写候选(纯文字层) | 意图分流:润色走改写;**事实性问题触发上游节点重跑** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 阶段 1:计划模式 — 需求梳理与工作流规划
|
||||||
|
|
||||||
|
### 2.1 计划模式状态
|
||||||
|
|
||||||
|
- 用户发出第一条需求消息起进入**计划模式**,Header 显示徽章「📋 计划模式」,输入框提示语切换为「梳理需求中,你可以随时补充或纠正…」。
|
||||||
|
- 计划模式在**工作流确认冻结**时结束;执行期大改可通过 replan 回到计划模式(见 §5.4)。
|
||||||
|
|
||||||
|
### 2.2 规划师对话(LLM,替换现行规则路由)
|
||||||
|
|
||||||
|
- 模型:`report_collaboration.planner_model`(回退 `coordinator_model` → 默认模型,复用现有 model_resolver 冻结机制)。
|
||||||
|
- 提问是领域化的:写市场分析就问「关注整车还是供应链 / 要不要竞品对比 / 给谁看 / 篇幅」,而非固定模板问句。
|
||||||
|
- 澄清轮次上限沿用 `max_clarification_rounds`;用户随时可以说「直接开始」按默认假设推进。
|
||||||
|
|
||||||
|
### 2.3 需求摘要卡
|
||||||
|
|
||||||
|
信息足够后,规划师在对话中输出一张**结构化需求摘要卡**(业务卡插槽渲染):主题 / 目的 / 读者 / 角度清单 / 篇幅与重点 / 默认假设。用户逐项看,可在对话里继续改(「读者改成投资人」→ 卡片更新出新版)。
|
||||||
|
|
||||||
|
### 2.4 工作流生成与迭代
|
||||||
|
|
||||||
|
- 用户确认摘要后,规划师生成**一份**工作流方案(不是三选一):以工作流卡(成员构成、各节点使命、预计时长/成本)+ 右侧流程图预览呈现。
|
||||||
|
- 生成约束(保证可执行):**节点类型受词表约束** — `research / verify / analyze / write / review`;自由的是结构(几条线、并行还是串行、核验深度)与每个节点的使命/角度/工具。
|
||||||
|
- 对话式修改:「把价格策略单独拆一条线」「加一个政策风险分析」→ 规划师修订后出新版工作流卡(带变更说明),再确认。
|
||||||
|
- 「开始执行」→ 冻结需求 + 工作流(含模型/预算快照,沿用现有 freeze 机制)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 阶段 2:执行 — 过程在流程图上可见
|
||||||
|
|
||||||
|
### 3.1 流程图动画规格(执行状态的可视化主载体)
|
||||||
|
|
||||||
|
节点状态 → 样式(`ReportFlowCanvas` / `ReportFlowNode`,状态源 `selectNodeStatusMap`,事件源 `node.status.changed` SSE):
|
||||||
|
|
||||||
|
| 状态 | 节点样式 | 连入边 |
|
||||||
|
|------|----------|--------|
|
||||||
|
| planned / queued | 灰色常态 | 静态 |
|
||||||
|
| **running** | 主色描边 + **呼吸脉冲动画**(ring)+ 角标 spinner | **流动动画**(@xyflow 内置 `animated: true` 的 dash 流动) |
|
||||||
|
| **completed** | 绿色 + ✓ 角标,常驻 | 恢复静态实线 |
|
||||||
|
| failed | 红色 + 错误角标 | 红色虚线 |
|
||||||
|
| awaiting_input | 橙色 + ?角标 | 橙色静态 |
|
||||||
|
| interrupted / superseded | 半透明降级 | 灰 |
|
||||||
|
|
||||||
|
- 语义:**边流动 = 数据正在流入该节点**(该节点执行中);节点完成即停动画换完成样式。
|
||||||
|
- 实现成本:@xyflow 原生支持 animated 边与自定义节点 className,纯 CSS 补呼吸动画即可,无需新依赖。
|
||||||
|
|
||||||
|
### 3.2 聊天区(保留现有 Tab 切换)
|
||||||
|
|
||||||
|
- 并行成员沿用 **AgentStageSelector Tab 切换**(维持现状,不改交错时间线)。
|
||||||
|
- 每个 Tab 内是该成员的完整流式输出:思考、工具调用卡、检索过程、正文 —— 与普通智能体问答同观感(message-adapter 已具备映射能力)。
|
||||||
|
- 协调者播报(「研究线 A 完成,核心发现 X,开始交叉核验」)进入总时间线 Tab。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 阶段 3:干预 — 对话即干预 + 指定重跑
|
||||||
|
|
||||||
|
### 4.1 输入框常开,协调者智能体分流
|
||||||
|
|
||||||
|
执行期输入框永远可用,所有输入先到协调者(LLM 分类,规则高精度直判兜底——复用 RC-BE-014 IntentRouter 骨架,把「规则优先」反转为「LLM 优先 + 规则快路径」):
|
||||||
|
|
||||||
|
| 用户意图 | 协调者行为 | 生效时机 |
|
||||||
|
|----------|-----------|----------|
|
||||||
|
| 提问(「查到什么了?」) | 直接回答 | 立即,不打断 |
|
||||||
|
| 修正方向(「重点看下沉市场」) | 小改:在安全点注入 overlay,并回复说明 | 下个安全点 |
|
||||||
|
| **指定重跑**(见 §4.2) | 出确认卡 → 重开节点 attempt | 确认后 |
|
||||||
|
| 大改(砍线/加线) | 提议 replan → 回计划模式 | 用户确认 |
|
||||||
|
| 停止 | 直接取消 | 立即 |
|
||||||
|
|
||||||
|
### 4.2 指定智能体重跑(新增核心交互)
|
||||||
|
|
||||||
|
**场景**:用户发现某节点传给下游的信息不对(如研究线 B 的结论有误)。
|
||||||
|
|
||||||
|
两个入口,同一后端动作:
|
||||||
|
|
||||||
|
1. **对话**:「研究线 B 的结果不对,重新查,重点看价格战」→ 协调者解析出 `node_id` + 新指令;
|
||||||
|
2. **画布**:点节点 → 抽屉(NodeInspectorDrawer)加「↻ 重新执行」按钮。
|
||||||
|
|
||||||
|
**级联规则**(复用并补全 ledger 的 targeted rework):
|
||||||
|
|
||||||
|
```
|
||||||
|
重跑节点 N(attempt+1,带用户新指令作为 mission overlay)
|
||||||
|
→ N 完成 → 所有直接/间接依赖 N 产物的下游节点标记 superseded
|
||||||
|
→ 按 DAG 依赖序逐波重跑(边动画重新流动,用户全程可见)
|
||||||
|
→ 受影响章节重新生成
|
||||||
|
```
|
||||||
|
|
||||||
|
- 确认卡必须展示**影响面**:「将级联重跑 核验B / 分析 / 写作 共 3 个节点,预计 +8 分钟」。
|
||||||
|
- 成本保护:级联重跑计入 run 预算(现有 budget 机制),预算不足时明确报因。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 阶段 4:报告成稿与对话式修改
|
||||||
|
|
||||||
|
### 5.1 成稿
|
||||||
|
|
||||||
|
审稿通过 → 报告卡出现在时间线(点开 ReportPreviewDialog 全文),协调者播报总结(各线核心发现 → 结论如何形成)。
|
||||||
|
|
||||||
|
### 5.2 修改意图分流(协调者 LLM 判定)
|
||||||
|
|
||||||
|
| 用户说 | 判定 | 动作 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 「把第二章结论写得更笃定」 | 文字润色 | 改写候选(现有 rewrite API)→ 对比预览 → apply → 新版本 |
|
||||||
|
| 「这个数据不对 / 重新收集一下」 | **事实性问题** | 定位来源节点 → **触发该节点重跑(可带新角度)→ 级联 §4.2 → 受影响章节重生成 → 新版本** |
|
||||||
|
| 「再从供应链角度补一节」 | 结构性新增 | replan:回计划模式改工作流(只增不删已完成部分) |
|
||||||
|
| 「恢复到上一版」 | 版本回退 | 现有 restore API |
|
||||||
|
|
||||||
|
- 版本链:每次 apply / restore / 级联重跑产出均产生新 head,历史完整保留(现有机制)。
|
||||||
|
- 修改对话里协调者必须先**复述理解**(「你是想让研究线 B 换个角度重查,然后重写第三章,对吗?」)再执行,避免误触发昂贵的级联。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 工程改造清单
|
||||||
|
|
||||||
|
### 6.1 保留(本次联调已验证)
|
||||||
|
|
||||||
|
- 持久化 13 张表 + durable SSE + `after_seq` 断线续传;幂等键 / 乐观锁 / 预算框架。
|
||||||
|
- MessageList 复用链路(message-adapter → LangGraph Message → 业务卡插槽)。
|
||||||
|
- AgentStageSelector Tab、commands 安全点骨架(RC-BE-014)、报告版本三件套(RC-BE-015)、前端 hooks/reducer/selectors 主体。
|
||||||
|
- 「清空历史」「生成候选方案」等本次新增入口。
|
||||||
|
|
||||||
|
### 6.2 后端重做 / 新建
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| 规划师智能体 | LLM 多轮澄清(替换 `requirement/intent_router.py` 规则路由)+ 需求摘要结构化 + **工作流 LLM 生成**(受 §2.4 词表约束,替换 `planning/controller.py` 三模板)+ 对话式工作流修订 |
|
||||||
|
| 协调者智能体升级 | 干预分类 LLM 优先;修改意图分流(§5.2);执行前复述确认 |
|
||||||
|
| 指定重跑 + 级联 | ledger targeted rework 补全级联 superseded/重跑波次;确认卡带影响面计算 |
|
||||||
|
| AgentScope 适配器修复 | `count_tokens` 协议缺失(上下文压缩必崩);attempt 耗尽终态化(防僵尸 run) |
|
||||||
|
| 事件补齐 | 边流动所需的节点状态事件已有;补「级联影响预告」事件 |
|
||||||
|
|
||||||
|
### 6.3 前端重做 / 新建
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| 计划模式 UI | Header 徽章、composer 提示切换、需求摘要卡、工作流卡(替换现三张模板卡) |
|
||||||
|
| 流程图动画 | running 节点呼吸动画 + 入边 `animated`、completed ✓ 样式、级联重跑时边动画重流(§3.1) |
|
||||||
|
| 节点抽屉 | 加「重新执行」按钮 → 确认卡 |
|
||||||
|
| 干预交互 | 执行期输入框常开;意图确认卡(含影响面);修改对话复述确认 |
|
||||||
|
| 布局 | 聊天为主、画布为辅的默认比例微调(不动整体两栏结构) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 实施分期
|
||||||
|
|
||||||
|
| 期 | 内容 | 验收 |
|
||||||
|
|----|------|------|
|
||||||
|
| **P0 地基** | AgentScope `count_tokens` 修复;attempt 耗尽终态化;级联重跑的 ledger 补全 | 执行阶段不崩、无僵尸 run、单节点重跑级联正确(后端测试) |
|
||||||
|
| **P1 计划模式 + 执行可视化** | 规划师 LLM 对话、需求摘要卡、工作流 LLM 生成与迭代、计划模式 UI、流程图动画 | 从新会话到确认工作流全程对话完成;执行时动画正确随状态流转 |
|
||||||
|
| **P2 干预 + 成稿修改** | 协调者 LLM 分类、指定重跑(两入口 + 影响面确认卡)、报告修改意图分流、上游级联重跑 | 四类干预各自走通;「数据不对→重跑→新章节→新版本」全链路 |
|
||||||
|
|
||||||
|
每期结束跑 RC 全量测试(当前基线 233 通过)+ 前端 26 单测,不引入对其他模块的影响。
|
||||||
@@ -109,8 +109,8 @@ export interface TaskButton {
|
|||||||
/** 追加的查询参数名(如 ``id`` / ``task_id``);空则用 ``id``。 */
|
/** 追加的查询参数名(如 ``id`` / ``task_id``);空则用 ``id``。 */
|
||||||
idParam: string;
|
idParam: string;
|
||||||
/**
|
/**
|
||||||
* ``url`` 追加 id 时取任务id 还是行动id。仅 xdfx(行动分析)二者不同:深链原始 ``?taskId=``
|
* ``url`` / ``host`` 追加 id 时取任务id 还是行动id。仅 xdfx(行动分析)二者不同:深链原始
|
||||||
* 实为行动id,任务id 由 action 接口解析。其它业务两者相同。
|
* ``?taskId=`` 实为行动id,任务id 由 action 接口解析。其它业务两者相同。
|
||||||
*/
|
*/
|
||||||
idKind: TaskButtonIdKind;
|
idKind: TaskButtonIdKind;
|
||||||
/** ``url`` 外链打开方式:``blank``=新窗口(默认)/ ``self``=当前窗口。仅 ``url`` 类型有意义。 */
|
/** ``url`` 外链打开方式:``blank``=新窗口(默认)/ ``self``=当前窗口。仅 ``url`` 类型有意义。 */
|
||||||
@@ -327,7 +327,8 @@ export interface RunTaskButtonCtx {
|
|||||||
* 执行一个跳转按钮:
|
* 执行一个跳转按钮:
|
||||||
* - ``url``:拼地址(可选追加 ``?id=``,取任务id 或行动id 视 ``idKind``)新标签打开;地址为空提示未配置。
|
* - ``url``:拼地址(可选追加 ``?id=``,取任务id 或行动id 视 ``idKind``)新标签打开;地址为空提示未配置。
|
||||||
* - ``business``:复用深链解析(会按目标业务重绑任务上下文)后 ``navigate``(始终用任务id)。
|
* - ``business``:复用深链解析(会按目标业务重绑任务上下文)后 ``navigate``(始终用任务id)。
|
||||||
* - ``host``:向 ``window.parent`` 发 ``HOST_NAVIGATE``,由宿主系统跳转(仅 iframe 嵌入时生效)。
|
* - ``host``:向 ``window.parent`` 发 ``HOST_NAVIGATE``,由宿主系统跳转(仅 iframe 嵌入时生效;
|
||||||
|
* 追加 id 时同样按 ``idKind`` 取任务id / 行动id)。
|
||||||
*/
|
*/
|
||||||
export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Promise<void> {
|
export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Promise<void> {
|
||||||
const fail = (m: string) => ctx.onError?.(m);
|
const fail = (m: string) => ctx.onError?.(m);
|
||||||
@@ -355,9 +356,11 @@ export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Pro
|
|||||||
}
|
}
|
||||||
const queryParams: Record<string, number> = {};
|
const queryParams: Record<string, number> = {};
|
||||||
if (btn.appendTaskId) {
|
if (btn.appendTaskId) {
|
||||||
const id = parseHostTaskId(ctx.taskId);
|
// 行动分析(xdfx)可选发行动id;与 url 外链同规则:行动id 缺失时回退任务id。
|
||||||
|
const raw = btn.idKind === "action" ? ctx.actionId || ctx.taskId : ctx.taskId;
|
||||||
|
const id = parseHostTaskId(raw);
|
||||||
if (id === null) {
|
if (id === null) {
|
||||||
fail("任务id不是数字,无法跳转宿主系统");
|
fail(`${btn.idKind === "action" ? "行动id" : "任务id"}不是数字,无法跳转宿主系统`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
queryParams[(btn.idParam || "id").trim() || "id"] = id;
|
queryParams[(btn.idParam || "id").trim() || "id"] = id;
|
||||||
|
|||||||
@@ -0,0 +1,637 @@
|
|||||||
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
import { apiFetch } from "@/core/api";
|
||||||
|
|
||||||
|
import type { Skill, SkillDisplayConfig, SkillState } from "./type";
|
||||||
|
|
||||||
|
export interface ListSkillsParams {
|
||||||
|
search?: string;
|
||||||
|
tagIds?: string[];
|
||||||
|
sort?: "time_desc" | "time_asc" | "activity_desc" | "activity_asc" | "usage_desc" | "usage_asc" | "name_asc" | "name_desc";
|
||||||
|
/** Keep only skills filed under this publish square (发布广场). */
|
||||||
|
square?: string;
|
||||||
|
/** ``"mine"`` restricts to the skills the caller's 岗位 (position) makes visible. */
|
||||||
|
scope?: "mine";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSkills(params: ListSkillsParams = {}) {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.search) qs.set("search", params.search);
|
||||||
|
for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
|
||||||
|
if (params.sort) qs.set("sort", params.sort);
|
||||||
|
if (params.square) qs.set("square", params.square);
|
||||||
|
if (params.scope) qs.set("scope", params.scope);
|
||||||
|
const query = qs.toString();
|
||||||
|
const skills = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`,
|
||||||
|
);
|
||||||
|
const json = await skills.json();
|
||||||
|
return json.skills as Skill[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadAllSkillsAdmin() {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills?admin=true`,
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to load skills");
|
||||||
|
}
|
||||||
|
const json = await response.json();
|
||||||
|
return json.skills as Skill[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSkill(skillName: string): Promise<Skill> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}`,
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to load skill");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<Skill>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enableSkill(skillName: string, enabled: boolean) {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/${skillName}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
enabled,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setSkillAlwaysOn(
|
||||||
|
skillName: string,
|
||||||
|
alwaysOn: boolean,
|
||||||
|
): Promise<Skill> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/always-on`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ always_on: alwaysOn }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to update skill 常驻 flag");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<Skill>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setSkillDisplay(
|
||||||
|
skillName: string,
|
||||||
|
display: SkillDisplayConfig | null,
|
||||||
|
sample?: Record<string, unknown> | null,
|
||||||
|
): Promise<Skill> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ display, sample }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to update skill display adapter");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<Skill>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillDisplaySampleResponse {
|
||||||
|
skill_name: string;
|
||||||
|
sample: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersistedSkillDisplaySampleResponse {
|
||||||
|
skill_name: string;
|
||||||
|
sample: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPersistedSkillDisplaySample(
|
||||||
|
skillName: string,
|
||||||
|
): Promise<PersistedSkillDisplaySampleResponse> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display-sample`,
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to get persisted skill display sample");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<PersistedSkillDisplaySampleResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSkillDisplaySample(
|
||||||
|
skillName: string,
|
||||||
|
): Promise<SkillDisplaySampleResponse> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display-sample`,
|
||||||
|
{ method: "POST" },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to get skill display sample");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<SkillDisplaySampleResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstallSkillRequest {
|
||||||
|
thread_id: string;
|
||||||
|
path: string;
|
||||||
|
skill_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstallSkillResponse {
|
||||||
|
success: boolean;
|
||||||
|
skill_name: string;
|
||||||
|
message: string;
|
||||||
|
overwritten?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewSkillInstallRequest {
|
||||||
|
thread_id: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewSkillInstallResponse {
|
||||||
|
ok: boolean;
|
||||||
|
skill_name: string;
|
||||||
|
description: string;
|
||||||
|
license: string | null;
|
||||||
|
allowed_tools: string[];
|
||||||
|
can_install_as_is: boolean;
|
||||||
|
owned_by_me: boolean;
|
||||||
|
owned_by_other: boolean;
|
||||||
|
builtin_conflict: boolean;
|
||||||
|
owner_username?: string | null;
|
||||||
|
owner_user_id?: string | null;
|
||||||
|
message: string;
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidateSkillUploadResponse {
|
||||||
|
ok: boolean;
|
||||||
|
skill_name: string;
|
||||||
|
description: string;
|
||||||
|
license: string | null;
|
||||||
|
allowed_tools: string[];
|
||||||
|
conflict: boolean;
|
||||||
|
conflict_kind: "public" | "custom" | null;
|
||||||
|
can_install_as_is: boolean;
|
||||||
|
can_overwrite: boolean;
|
||||||
|
owned_by_me: boolean;
|
||||||
|
owned_by_other: boolean;
|
||||||
|
builtin_conflict: boolean;
|
||||||
|
owner_username?: string | null;
|
||||||
|
owner_user_id?: string | null;
|
||||||
|
message: string;
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installSkill(
|
||||||
|
request: InstallSkillRequest,
|
||||||
|
): Promise<InstallSkillResponse> {
|
||||||
|
const response = await apiFetch(`${getBackendBaseURL()}/api/skills/install`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Handle HTTP error responses (4xx, 5xx)
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
const errorMessage =
|
||||||
|
errorData.detail ?? `HTTP ${response.status}: ${response.statusText}`;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
skill_name: "",
|
||||||
|
message: errorMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function previewSkillInstall(
|
||||||
|
request: PreviewSkillInstallRequest,
|
||||||
|
): Promise<PreviewSkillInstallResponse> {
|
||||||
|
const response = await apiFetch(`${getBackendBaseURL()}/api/skills/install-preview`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to preview skill install");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<PreviewSkillInstallResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseDetailedError(response: Response, fallback: string): Promise<Error> {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
const detail = (data as { detail?: unknown }).detail;
|
||||||
|
const message = typeof detail === "string" && detail ? detail : `${fallback} (HTTP ${response.status})`;
|
||||||
|
return new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateSkillUpload(
|
||||||
|
file: File,
|
||||||
|
targetCategory: "public" | "custom" = "custom",
|
||||||
|
): Promise<ValidateSkillUploadResponse> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file, file.name);
|
||||||
|
const params = new URLSearchParams({ target_category: targetCategory });
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/validate?${params.toString()}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: form,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Validation failed");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<ValidateSkillUploadResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installSkillUpload(
|
||||||
|
file: File,
|
||||||
|
options: { overwrite?: boolean; targetCategory?: "public" | "custom" } = {},
|
||||||
|
): Promise<InstallSkillResponse> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file, file.name);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options.overwrite) params.set("overwrite", "true");
|
||||||
|
if (options.targetCategory) params.set("target_category", options.targetCategory);
|
||||||
|
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/install-upload${query}`,
|
||||||
|
{ method: "POST", body: form },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Install failed");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<InstallSkillResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function duplicateSkill(
|
||||||
|
sourceName: string,
|
||||||
|
newName: string,
|
||||||
|
): Promise<InstallSkillResponse> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(sourceName)}/duplicate`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ new_name: newName }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to duplicate skill");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<InstallSkillResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReconcileCustomSkillsResponse {
|
||||||
|
adopted: string[];
|
||||||
|
already_registered: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reconcileCustomSkills(): Promise<ReconcileCustomSkillsResponse> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/reconcile-custom`,
|
||||||
|
{ method: "POST" },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to reconcile skills directory");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<ReconcileCustomSkillsResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetSkillPublishedResponse {
|
||||||
|
name: string;
|
||||||
|
owner_user_id: string | null;
|
||||||
|
published: boolean;
|
||||||
|
square_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setSkillPublished(
|
||||||
|
skillName: string,
|
||||||
|
published: boolean,
|
||||||
|
squareId?: string,
|
||||||
|
): Promise<SetSkillPublishedResponse> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/published`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(
|
||||||
|
squareId != null ? { published, square_id: squareId } : { published },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to update published state");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<SetSkillPublishedResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setSkillState(
|
||||||
|
skillName: string,
|
||||||
|
state: SkillState,
|
||||||
|
): Promise<Skill> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/state`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ state }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to update skill state");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<Skill>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setSkillPinned(
|
||||||
|
skillName: string,
|
||||||
|
pinned: boolean,
|
||||||
|
): Promise<Skill> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/pinned`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ pinned }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to update skill pinned state");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<Skill>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setSkillFeatured(
|
||||||
|
skillName: string,
|
||||||
|
featured: boolean,
|
||||||
|
): Promise<Skill> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/featured`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ featured }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to update skill featured state");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<Skill>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillContentResponse {
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSkillContent(
|
||||||
|
skillName: string,
|
||||||
|
): Promise<SkillContentResponse> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/content`,
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to load skill content");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<SkillContentResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCustomSkill(skillName: string): Promise<void> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to delete skill");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a built-in skill. The backend permits this only for administrators. */
|
||||||
|
export async function deleteBuiltinSkill(skillName: string): Promise<void> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to delete built-in skill");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillFileNode {
|
||||||
|
name: string;
|
||||||
|
type: "file" | "dir";
|
||||||
|
path: string;
|
||||||
|
size?: number | null;
|
||||||
|
mtime?: number | null;
|
||||||
|
children?: SkillFileNode[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillFilesResponse {
|
||||||
|
name: string;
|
||||||
|
category: "public" | "custom";
|
||||||
|
editable: boolean;
|
||||||
|
root: SkillFileNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillFileContentResponse {
|
||||||
|
name: string;
|
||||||
|
category: "public" | "custom";
|
||||||
|
path: string;
|
||||||
|
mime: string;
|
||||||
|
size: number;
|
||||||
|
mtime: number;
|
||||||
|
is_text: boolean;
|
||||||
|
editable: boolean;
|
||||||
|
content?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillFileSaveResponse {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
mtime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSkillFiles(skillName: string): Promise<SkillFilesResponse> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files`,
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to load skill files");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<SkillFilesResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSkillFileContent(
|
||||||
|
skillName: string,
|
||||||
|
path: string,
|
||||||
|
): Promise<SkillFileContentResponse> {
|
||||||
|
const url =
|
||||||
|
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files/content` +
|
||||||
|
`?path=${encodeURIComponent(path)}`;
|
||||||
|
const response = await apiFetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to read skill file");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<SkillFileContentResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL suitable for direct `<img>` / `<iframe>` / `<a download>` embedding. */
|
||||||
|
export function getSkillFileDownloadURL(skillName: string, path: string): string {
|
||||||
|
return (
|
||||||
|
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files/download` +
|
||||||
|
`?path=${encodeURIComponent(path)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSkillFile(
|
||||||
|
skillName: string,
|
||||||
|
path: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<SkillFileSaveResponse> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files/content`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ path, content }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to save skill file");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<SkillFileSaveResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSkillFile(
|
||||||
|
skillName: string,
|
||||||
|
path: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const url =
|
||||||
|
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files/content` +
|
||||||
|
`?path=${encodeURIComponent(path)}`;
|
||||||
|
const response = await apiFetch(url, { method: "DELETE" });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to delete skill file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillNameZhResponse {
|
||||||
|
name: string;
|
||||||
|
name_zh: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateMissingSkillNameZhResponse {
|
||||||
|
generated: SkillNameZhResponse[];
|
||||||
|
failed: string[];
|
||||||
|
skipped: number;
|
||||||
|
/** Representative model error when some skills failed due to the LLM. */
|
||||||
|
model_error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a skill's Chinese display name. An empty string clears it (falls back to
|
||||||
|
* the English name).
|
||||||
|
*/
|
||||||
|
export async function setSkillNameZh(
|
||||||
|
skillName: string,
|
||||||
|
nameZh: string,
|
||||||
|
): Promise<SkillNameZhResponse> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/name-zh`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name_zh: nameZh }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to set skill Chinese name");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<SkillNameZhResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ask the LLM to generate and persist a Chinese name for a single skill. */
|
||||||
|
export async function generateSkillNameZh(
|
||||||
|
skillName: string,
|
||||||
|
modelName?: string | null,
|
||||||
|
): Promise<SkillNameZhResponse> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/name-zh/generate`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ model_name: modelName ?? null }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to generate skill Chinese name");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<SkillNameZhResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-click (admin): generate Chinese names for every skill that lacks one. */
|
||||||
|
export async function generateMissingSkillNameZh(
|
||||||
|
modelName?: string | null,
|
||||||
|
): Promise<GenerateMissingSkillNameZhResponse> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/name-zh/generate-missing`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ model_name: modelName ?? null }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to bulk-generate skill Chinese names");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<GenerateMissingSkillNameZhResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetSkillDetailResponse {
|
||||||
|
name: string;
|
||||||
|
detail: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a skill's long-form detail text. Owner/admin for custom skills,
|
||||||
|
* admin-only for built-in skills (backend enforces). An empty string clears it.
|
||||||
|
*/
|
||||||
|
export async function setSkillDetail(
|
||||||
|
skillName: string,
|
||||||
|
detail: string,
|
||||||
|
): Promise<SetSkillDetailResponse> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/detail`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ detail }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to update skill detail");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<SetSkillDetailResponse>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
import {
|
||||||
|
keepPreviousData,
|
||||||
|
useMutation,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import {
|
||||||
|
deleteBuiltinSkill,
|
||||||
|
deleteCustomSkill,
|
||||||
|
deleteSkillFile,
|
||||||
|
duplicateSkill,
|
||||||
|
enableSkill,
|
||||||
|
generateMissingSkillNameZh,
|
||||||
|
generateSkillNameZh,
|
||||||
|
getSkill,
|
||||||
|
getSkillContent,
|
||||||
|
getSkillFileContent,
|
||||||
|
getSkillFiles,
|
||||||
|
installSkillUpload,
|
||||||
|
reconcileCustomSkills,
|
||||||
|
saveSkillFile,
|
||||||
|
setSkillAlwaysOn,
|
||||||
|
setSkillDetail,
|
||||||
|
setSkillNameZh,
|
||||||
|
setSkillDisplay,
|
||||||
|
setSkillPinned,
|
||||||
|
setSkillFeatured,
|
||||||
|
setSkillPublished,
|
||||||
|
setSkillState,
|
||||||
|
validateSkillUpload,
|
||||||
|
} from "./api";
|
||||||
|
import type { ListSkillsParams } from "./api";
|
||||||
|
import type { Skill, SkillState } from "./type";
|
||||||
|
|
||||||
|
import { loadAllSkillsAdmin, loadSkills } from ".";
|
||||||
|
|
||||||
|
// Stable empty fallback. Returning `data ?? []` inline would hand out a fresh
|
||||||
|
// array reference on every render while the query is loading/errored, which
|
||||||
|
// makes downstream `useMemo([skills])` / `useEffect([skills])` consumers churn
|
||||||
|
// forever (observed as a "Maximum update depth exceeded" loop on NewAgentPage).
|
||||||
|
const EMPTY_SKILLS: Skill[] = [];
|
||||||
|
|
||||||
|
export function useSkills(params: ListSkillsParams = {}) {
|
||||||
|
const search = params.search ?? "";
|
||||||
|
const tagIds = params.tagIds ?? [];
|
||||||
|
const sort = params.sort ?? "time_desc";
|
||||||
|
const scope = params.scope;
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ["skills", search, [...tagIds].sort(), sort, scope ?? "all"],
|
||||||
|
queryFn: () => loadSkills({ search, tagIds, sort, scope }),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
return { skills: data ?? EMPTY_SKILLS, isLoading, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-only listing — every skill in the system (built-in + every user's
|
||||||
|
* custom skills, including unpublished ones owned by other users). Backend
|
||||||
|
* 403s if the caller is not ``system_role === "admin"``.
|
||||||
|
*/
|
||||||
|
export function useAllSkillsAdmin(enabled: boolean) {
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ["skills", "admin", "all"],
|
||||||
|
queryFn: () => loadAllSkillsAdmin(),
|
||||||
|
enabled,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
return { skills: data ?? EMPTY_SKILLS, isLoading, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSkillContent(skillName: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["skill-content", skillName],
|
||||||
|
queryFn: () => getSkillContent(skillName!),
|
||||||
|
enabled: !!skillName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSkill(skillName: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["skill", skillName],
|
||||||
|
queryFn: () => getSkill(skillName!),
|
||||||
|
enabled: !!skillName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetSkillDetail() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ skillName, detail }: { skillName: string; detail: string }) =>
|
||||||
|
setSkillDetail(skillName, detail),
|
||||||
|
onSuccess: (_data, { skillName }) => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetSkillNameZh() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ skillName, nameZh }: { skillName: string; nameZh: string }) =>
|
||||||
|
setSkillNameZh(skillName, nameZh),
|
||||||
|
onSuccess: (_data, { skillName }) => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGenerateSkillNameZh() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ skillName, modelName }: { skillName: string; modelName?: string | null }) =>
|
||||||
|
generateSkillNameZh(skillName, modelName),
|
||||||
|
onSuccess: (_data, { skillName }) => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGenerateMissingSkillNameZh() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (modelName?: string | null) => generateMissingSkillNameZh(modelName),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetSkillDisplay() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
skillName,
|
||||||
|
display,
|
||||||
|
sample,
|
||||||
|
}: {
|
||||||
|
skillName: string;
|
||||||
|
display: Parameters<typeof setSkillDisplay>[1];
|
||||||
|
sample?: Parameters<typeof setSkillDisplay>[2];
|
||||||
|
}) => setSkillDisplay(skillName, display, sample),
|
||||||
|
onSuccess: (data, { skillName }) => {
|
||||||
|
queryClient.setQueryData(["skill", skillName], data);
|
||||||
|
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
|
||||||
|
Array.isArray(current)
|
||||||
|
? current.map((item) => (item.name === skillName ? { ...item, ...data } : item))
|
||||||
|
: current,
|
||||||
|
);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEnableSkill() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
skillName,
|
||||||
|
enabled,
|
||||||
|
}: {
|
||||||
|
skillName: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}) => {
|
||||||
|
await enableSkill(skillName, enabled);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetSkillAlwaysOn() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ skillName, alwaysOn }: { skillName: string; alwaysOn: boolean }) =>
|
||||||
|
setSkillAlwaysOn(skillName, alwaysOn),
|
||||||
|
onSuccess: (data, { skillName }) => {
|
||||||
|
queryClient.setQueryData(["skill", skillName], data);
|
||||||
|
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
|
||||||
|
Array.isArray(current)
|
||||||
|
? current.map((item) => (item.name === skillName ? { ...item, ...data } : item))
|
||||||
|
: current,
|
||||||
|
);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useValidateSkillUpload() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
file,
|
||||||
|
targetCategory,
|
||||||
|
}: {
|
||||||
|
file: File;
|
||||||
|
targetCategory?: "public" | "custom";
|
||||||
|
}) => validateSkillUpload(file, targetCategory),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInstallSkillUpload() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ file, overwrite, targetCategory }: { file: File; overwrite?: boolean; targetCategory?: "public" | "custom" }) =>
|
||||||
|
installSkillUpload(file, { overwrite, targetCategory }),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReconcileCustomSkills() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => reconcileCustomSkills(),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetSkillPublished() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
skillName,
|
||||||
|
published,
|
||||||
|
squareId,
|
||||||
|
}: {
|
||||||
|
skillName: string;
|
||||||
|
published: boolean;
|
||||||
|
squareId?: string;
|
||||||
|
}) => setSkillPublished(skillName, published, squareId),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
|
// Unpublishing triggers the backend cascade that strips this skill
|
||||||
|
// from every referencing agent's config.yaml, so the cached agent
|
||||||
|
// list (agent.skills) is now stale.
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetSkillState() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ skillName, state }: { skillName: string; state: SkillState }) =>
|
||||||
|
setSkillState(skillName, state),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetSkillPinned() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ skillName, pinned }: { skillName: string; pinned: boolean }) =>
|
||||||
|
setSkillPinned(skillName, pinned),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetSkillFeatured() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ skillName, featured }: { skillName: string; featured: boolean }) =>
|
||||||
|
setSkillFeatured(skillName, featured),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDuplicateSkill() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
sourceName,
|
||||||
|
newName,
|
||||||
|
}: {
|
||||||
|
sourceName: string;
|
||||||
|
newName: string;
|
||||||
|
}) => duplicateSkill(sourceName, newName),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteCustomSkill() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (skillName: string) => deleteCustomSkill(skillName),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
|
// Deletion triggers the backend cascade that strips this skill from
|
||||||
|
// every referencing agent's config.yaml, so the cached agent list
|
||||||
|
// (agent.skills) is now stale.
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSkillFiles(skillName: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["skill-files", skillName],
|
||||||
|
queryFn: () => getSkillFiles(skillName!),
|
||||||
|
enabled: !!skillName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSkillFileContent(skillName: string | null, path: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["skill-file", skillName, path],
|
||||||
|
queryFn: () => getSkillFileContent(skillName!, path!),
|
||||||
|
enabled: !!skillName && !!path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSaveSkillFile() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
skillName,
|
||||||
|
path,
|
||||||
|
content,
|
||||||
|
}: {
|
||||||
|
skillName: string;
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
}) => saveSkillFile(skillName, path, content),
|
||||||
|
onSuccess: (_data, { skillName, path }) => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skill-files", skillName] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skill-file", skillName, path] });
|
||||||
|
// SKILL.md edits also affect the source-tab view and skill metadata.
|
||||||
|
if (path === "SKILL.md") {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skill-content", skillName] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteSkillFile() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
skillName,
|
||||||
|
path,
|
||||||
|
}: {
|
||||||
|
skillName: string;
|
||||||
|
path: string;
|
||||||
|
}) => deleteSkillFile(skillName, path),
|
||||||
|
onSuccess: (_data, { skillName, path }) => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skill-files", skillName] });
|
||||||
|
void queryClient.removeQueries({ queryKey: ["skill-file", skillName, path] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteBuiltinSkill() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: deleteBuiltinSkill,
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["admin-skills"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./api";
|
||||||
|
export * from "./type";
|
||||||
|
export * from "./utils";
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type { Tag } from "@/core/tags";
|
||||||
|
|
||||||
|
export type SkillState = "active" | "stale" | "archived";
|
||||||
|
export type SkillSource = "agent" | "upload";
|
||||||
|
export type SkillDisplayMode = "citation" | "list" | "table" | "card" | "custom" | "none";
|
||||||
|
export type SkillLinkStrategy = "auto" | "url" | "id";
|
||||||
|
export type SkillCitationToken = "doubleBracket" | "singleBracket";
|
||||||
|
|
||||||
|
export interface SkillDisplayTableColumn {
|
||||||
|
label: string;
|
||||||
|
field: string;
|
||||||
|
width?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillDisplayResultSetConfig {
|
||||||
|
key: string;
|
||||||
|
label?: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
mode: SkillDisplayMode;
|
||||||
|
resultPath: string;
|
||||||
|
titleField?: string | null;
|
||||||
|
summaryField?: string | null;
|
||||||
|
contentField?: string | null;
|
||||||
|
sourceField?: string | null;
|
||||||
|
authorField?: string | null;
|
||||||
|
typeField?: string | null;
|
||||||
|
timeField?: string | null;
|
||||||
|
scoreField?: string | null;
|
||||||
|
urlField?: string | null;
|
||||||
|
idField?: string | null;
|
||||||
|
linkStrategy: SkillLinkStrategy;
|
||||||
|
linkTemplate?: string | null;
|
||||||
|
citationToken: SkillCitationToken;
|
||||||
|
maxItems: number;
|
||||||
|
customInstruction: string;
|
||||||
|
tableColumns?: SkillDisplayTableColumn[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillDisplayConfig extends Omit<SkillDisplayResultSetConfig, "key" | "label"> {
|
||||||
|
resultSets?: SkillDisplayResultSetConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Skill {
|
||||||
|
name: string;
|
||||||
|
// 用户配置或大模型生成的中文名称;为空时界面回退到使用英文 name。
|
||||||
|
name_zh?: string | null;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
license: string;
|
||||||
|
enabled: boolean;
|
||||||
|
// 常驻(免压缩) —— 开启技能压缩后,标记此项的技能仍以全文形式注入系统提示词,
|
||||||
|
// 而非被压缩成索引 + search_skills 检索。
|
||||||
|
always_on?: boolean;
|
||||||
|
// Multitenancy fields — owner_user_id is null for built-in / public skills.
|
||||||
|
// ``builtin`` mirrors that nullness for explicit branching on the UI side.
|
||||||
|
owner_user_id?: string | null;
|
||||||
|
// Publisher username (email local part) resolved from owner_user_id by the
|
||||||
|
// backend. Null for built-in / public skills with no human owner.
|
||||||
|
owner_username?: string | null;
|
||||||
|
published?: boolean;
|
||||||
|
/** Publish square (发布广场) the skill is filed under once published. "" = default square. */
|
||||||
|
square_id?: string;
|
||||||
|
builtin?: boolean;
|
||||||
|
// True when this skill is in the caller's 我的技能 collection — e.g. granted by
|
||||||
|
// their 岗位 (position). Owned skills are detected separately via owner_user_id.
|
||||||
|
favorited?: boolean;
|
||||||
|
// Long-form detail/introduction authored by the skill owner or an admin.
|
||||||
|
// Distinct from ``description`` (parsed from SKILL.md); supports Markdown.
|
||||||
|
detail?: string | null;
|
||||||
|
display?: SkillDisplayConfig | null;
|
||||||
|
displaySample?: Record<string, unknown> | null;
|
||||||
|
// Lifecycle fields — populated by curator. The main list filters on these
|
||||||
|
// so stale / archived skills appear in dedicated drawers instead.
|
||||||
|
state?: SkillState;
|
||||||
|
pinned?: boolean;
|
||||||
|
// 置顶 —— 仅影响列表排序,与 pinned(防自动归档)正交。
|
||||||
|
featured?: boolean;
|
||||||
|
// Provenance — distinguishes skills created by an agent during a chat
|
||||||
|
// (``agent``) from skills the user uploaded / authored manually (``upload``).
|
||||||
|
source?: SkillSource;
|
||||||
|
created_at?: string | null;
|
||||||
|
last_activity_at?: string | null;
|
||||||
|
use_count?: number;
|
||||||
|
view_count?: number;
|
||||||
|
patch_count?: number;
|
||||||
|
// Tags assigned to this skill (admin-managed).
|
||||||
|
tags?: Tag[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Skill } from "./type";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智能体技能白名单的候选池过滤。
|
||||||
|
*
|
||||||
|
* 编辑智能体时,普通用户只能从「内置技能 + 自己的技能 + 已发布到广场的
|
||||||
|
* 技能」里挑选,其他用户未发布的自定义技能不可选;管理员仍可使用全部
|
||||||
|
* 已启用技能。
|
||||||
|
*/
|
||||||
|
export function isAgentSkillCandidate(
|
||||||
|
skill: Skill,
|
||||||
|
currentUserId: string | null,
|
||||||
|
isAdmin = false,
|
||||||
|
): boolean {
|
||||||
|
if (!skill.enabled) return false;
|
||||||
|
if (isAdmin) return true;
|
||||||
|
if ((skill.state ?? "active") !== "active") return false;
|
||||||
|
const builtin = skill.builtin ?? !skill.owner_user_id;
|
||||||
|
const ownedByMe =
|
||||||
|
!!skill.owner_user_id && skill.owner_user_id === currentUserId;
|
||||||
|
return builtin || ownedByMe || !!skill.published;
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
syncPositionOptionsFromRoles,
|
syncPositionOptionsFromRoles,
|
||||||
} from "@/position-roundtable/lib/positions";
|
} from "@/position-roundtable/lib/positions";
|
||||||
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
|
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
|
||||||
|
import { EIGHTBF_INTENT_AGENT_ID } from "@/roundtable-planning/api/intent";
|
||||||
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
|
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
|
||||||
import "@/position-roundtable/styles/intent-understanding.css";
|
import "@/position-roundtable/styles/intent-understanding.css";
|
||||||
import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel";
|
import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel";
|
||||||
@@ -96,6 +97,8 @@ export function EightBfStep1Panel({
|
|||||||
}: EightBfStep1PanelProps) {
|
}: EightBfStep1PanelProps) {
|
||||||
const [rightPanelOpen, setRightPanelOpen] = useState(true);
|
const [rightPanelOpen, setRightPanelOpen] = useState(true);
|
||||||
const [rightTab, setRightTab] = useState<"agents" | "results">("agents");
|
const [rightTab, setRightTab] = useState<"agents" | "results">("agents");
|
||||||
|
// 第一步内置任务理解智能体:其「常用问题」显示在输入框左上角,点击直接发送。
|
||||||
|
const { agent: intentAgent } = useAgent(EIGHTBF_INTENT_AGENT_ID);
|
||||||
|
|
||||||
const openPanel = (tab: "agents" | "results") => {
|
const openPanel = (tab: "agents" | "results") => {
|
||||||
if (rightPanelOpen && rightTab === tab) {
|
if (rightPanelOpen && rightTab === tab) {
|
||||||
@@ -167,6 +170,8 @@ export function EightBfStep1Panel({
|
|||||||
modelOptions={modelOptions}
|
modelOptions={modelOptions}
|
||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
onSelectedModelChange={onSelectedModelChange}
|
onSelectedModelChange={onSelectedModelChange}
|
||||||
|
commonQuestions={intentAgent?.common_questions ?? null}
|
||||||
|
sendCommonQuestionOnClick
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
intentReady={intentReady}
|
intentReady={intentReady}
|
||||||
@@ -331,8 +336,26 @@ function EightBfAgentWorkspace({
|
|||||||
summaryReport: step3Summary,
|
summaryReport: step3Summary,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 第一步绑定的内置任务理解智能体:不在业务链席位里,固定置顶展示,
|
||||||
|
// 供管理员在本面板查看 / 配置其提示词与常用问题。
|
||||||
|
const intentAgent = useMemo(
|
||||||
|
() => agents.find((agent) => agent.id === EIGHTBF_INTENT_AGENT_ID) ?? null,
|
||||||
|
[agents],
|
||||||
|
);
|
||||||
|
const intentAgentMatchesKeyword = useMemo(() => {
|
||||||
|
const keyword = agentKeyword.trim().toLowerCase();
|
||||||
|
if (!keyword) return true;
|
||||||
|
const name = (intentAgent?.name || "8BF 任务理解").toLowerCase();
|
||||||
|
const description = (intentAgent?.description || "").toLowerCase();
|
||||||
|
return name.includes(keyword) || description.includes(keyword);
|
||||||
|
}, [agentKeyword, intentAgent]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedAgentId && !seats.some((seat) => seat.agent_id === selectedAgentId)) {
|
if (
|
||||||
|
selectedAgentId &&
|
||||||
|
selectedAgentId !== EIGHTBF_INTENT_AGENT_ID &&
|
||||||
|
!seats.some((seat) => seat.agent_id === selectedAgentId)
|
||||||
|
) {
|
||||||
setSelectedAgentId(null);
|
setSelectedAgentId(null);
|
||||||
}
|
}
|
||||||
}, [seats, selectedAgentId]);
|
}, [seats, selectedAgentId]);
|
||||||
@@ -412,7 +435,12 @@ function EightBfAgentWorkspace({
|
|||||||
) : (
|
) : (
|
||||||
<AgentDetailPanel
|
<AgentDetailPanel
|
||||||
agent={selectedAgent}
|
agent={selectedAgent}
|
||||||
agentName={selectedAgent?.name ?? selectedSeat?.name ?? ""}
|
agentName={
|
||||||
|
selectedAgent?.name
|
||||||
|
?? (selectedAgentId === EIGHTBF_INTENT_AGENT_ID
|
||||||
|
? "8BF 任务理解"
|
||||||
|
: selectedSeat?.name ?? "")
|
||||||
|
}
|
||||||
onAskQuestion={() => toast.info("请在中间对话区向任务理解智能体发起问题")}
|
onAskQuestion={() => toast.info("请在中间对话区向任务理解智能体发起问题")}
|
||||||
showRecentChats={false}
|
showRecentChats={false}
|
||||||
avatarSrc={agentConfigLogo}
|
avatarSrc={agentConfigLogo}
|
||||||
@@ -468,7 +496,7 @@ function EightBfAgentWorkspace({
|
|||||||
</header>
|
</header>
|
||||||
{isLoading && seats.length > 0 ? (
|
{isLoading && seats.length > 0 ? (
|
||||||
<div className="eightbf-roundtable__agent-selector-empty">正在加载智能体…</div>
|
<div className="eightbf-roundtable__agent-selector-empty">正在加载智能体…</div>
|
||||||
) : filteredSeatAgents.length === 0 ? (
|
) : filteredSeatAgents.length === 0 && !intentAgentMatchesKeyword ? (
|
||||||
<div className="eightbf-roundtable__agent-selector-empty">
|
<div className="eightbf-roundtable__agent-selector-empty">
|
||||||
<p>{seatAgents.length ? "没有符合条件的智能体" : "当前岗位尚未配置智能体"}</p>
|
<p>{seatAgents.length ? "没有符合条件的智能体" : "当前岗位尚未配置智能体"}</p>
|
||||||
{!seatAgents.length && canConfigureChain && (
|
{!seatAgents.length && canConfigureChain && (
|
||||||
@@ -477,6 +505,13 @@ function EightBfAgentWorkspace({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="eightbf-roundtable__agent-selector-list">
|
<div className="eightbf-roundtable__agent-selector-list">
|
||||||
|
{intentAgentMatchesKeyword && (
|
||||||
|
<EightBfAgentCard
|
||||||
|
name={intentAgent?.name || "8BF 任务理解"}
|
||||||
|
description={intentAgent?.description || "第一步「意图识别」的内置任务理解智能体"}
|
||||||
|
onSelect={() => setSelectedAgentId(EIGHTBF_INTENT_AGENT_ID)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{filteredSeatAgents.map(({ seat, agent }) => (
|
{filteredSeatAgents.map(({ seat, agent }) => (
|
||||||
<EightBfAgentRow
|
<EightBfAgentRow
|
||||||
key={seat.agent_id}
|
key={seat.agent_id}
|
||||||
@@ -503,6 +538,19 @@ function EightBfAgentRow({
|
|||||||
}) {
|
}) {
|
||||||
const name = agent?.name || seat.name || "未命名智能体";
|
const name = agent?.name || seat.name || "未命名智能体";
|
||||||
const description = agent?.description || seat.description || "智能体助手";
|
const description = agent?.description || seat.description || "智能体助手";
|
||||||
|
return <EightBfAgentCard name={name} description={description} onSelect={onSelect} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 智能体列表卡片:席位智能体与置顶的内置任务理解智能体共用同一展示结构。 */
|
||||||
|
function EightBfAgentCard({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
const avatarText = firstAgentAvatarChar(name);
|
const avatarText = firstAgentAvatarChar(name);
|
||||||
return (
|
return (
|
||||||
<button type="button" className="eightbf-roundtable__agent-selector-card" onClick={onSelect}>
|
<button type="button" className="eightbf-roundtable__agent-selector-card" onClick={onSelect}>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useSearchParams } from "react-router-dom";
|
|||||||
import { Loader2, Save, X } from "lucide-react";
|
import { Loader2, Save, X } from "lucide-react";
|
||||||
|
|
||||||
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
|
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
|
||||||
|
import { EIGHTBF_INTENT_AGENT_ID } from "@/roundtable-planning/api/intent";
|
||||||
import { EightBfTaskCard } from "@/eightbf-roundtable/components/EightBfTaskCard";
|
import { EightBfTaskCard } from "@/eightbf-roundtable/components/EightBfTaskCard";
|
||||||
import {
|
import {
|
||||||
EightBfStep1Panel,
|
EightBfStep1Panel,
|
||||||
@@ -76,6 +77,9 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
|||||||
const [selectedChainId, setSelectedChainId] = useState("");
|
const [selectedChainId, setSelectedChainId] = useState("");
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// 第二步「人工干预」:开启后每走一步暂停等待用户输入;关闭自动推进。会话内状态,
|
||||||
|
// 新建会话 / 刷新后回到默认关闭(与现状一致)。
|
||||||
|
const [step2Intervention, setStep2Intervention] = useState(false);
|
||||||
const configuredChain = useMemo(
|
const configuredChain = useMemo(
|
||||||
() => chains.find((chain) => chain.id === mapping?.chainId) ?? null,
|
() => chains.find((chain) => chain.id === mapping?.chainId) ?? null,
|
||||||
[chains, mapping?.chainId],
|
[chains, mapping?.chainId],
|
||||||
@@ -260,6 +264,12 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
|||||||
// 无 taskId 进入时每次都开全新会话(不回显 anonymous 键下的最新草稿);带 taskId 保持回显现状。
|
// 无 taskId 进入时每次都开全新会话(不回显 anonymous 键下的最新草稿);带 taskId 保持回显现状。
|
||||||
skipInitialDraftRestore={!taskId}
|
skipInitialDraftRestore={!taskId}
|
||||||
taskContext={taskContext as Record<string, unknown> | null}
|
taskContext={taskContext as Record<string, unknown> | null}
|
||||||
|
// 第一步绑定 8BF 专属内置意图智能体(roundtable-intent 的同源复制,
|
||||||
|
// 提示词独立可调优),不再与通用多智能体会商共用一个 SOUL。
|
||||||
|
intentAgentId={EIGHTBF_INTENT_AGENT_ID}
|
||||||
|
// 页头「人工干预」开关:控制第二步是否每步暂停等待用户输入。
|
||||||
|
step2InterventionEnabled={step2Intervention}
|
||||||
|
onStep2InterventionChange={setStep2Intervention}
|
||||||
entryRailTaskId={taskId || null}
|
entryRailTaskId={taskId || null}
|
||||||
entryRailTaskCardSlot={taskId ? (
|
entryRailTaskCardSlot={taskId ? (
|
||||||
<EightBfTaskCard
|
<EightBfTaskCard
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ const DRAFT_ID = 'aw-draft';
|
|||||||
const AI_WRITING_AGENT_MODULE_KEY = 'ai-writing';
|
const AI_WRITING_AGENT_MODULE_KEY = 'ai-writing';
|
||||||
const AI_WRITING_AGENT_MODULE_TITLE = 'AI 写作智能体';
|
const AI_WRITING_AGENT_MODULE_TITLE = 'AI 写作智能体';
|
||||||
const AI_WRITING_LAST_AGENT_KEY = `agent-module:${AI_WRITING_AGENT_MODULE_KEY}:last-agent`;
|
const AI_WRITING_LAST_AGENT_KEY = `agent-module:${AI_WRITING_AGENT_MODULE_KEY}:last-agent`;
|
||||||
const SHOW_NOTEBOOK_SPACE_ENTRIES = false;
|
const SHOW_NOTEBOOK_SPACE_ENTRIES = true;
|
||||||
|
|
||||||
// Match the original canvas page proportions (chat=35, artifact=65)
|
// Match the original canvas page proportions (chat=35, artifact=65)
|
||||||
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
|
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Bot } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { useAgents } from "@/core/agents";
|
||||||
|
import type { Agent } from "@/core/agents";
|
||||||
|
import { themeToBaseMode, useTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
|
import "@/styles/agents-orbit.css";
|
||||||
|
import "@/styles/agents-orbit-dark.css";
|
||||||
|
import "@/styles/agents-orbit-light.css";
|
||||||
|
|
||||||
|
/** 转盘旋转过渡时长(ms),与 CSS 中 .ao-type-arm 的 transition 保持一致。 */
|
||||||
|
const DIAL_TRANSITION_MS = 850;
|
||||||
|
|
||||||
|
/** 一个类型 = 一个标签(取智能体的第一个标签;无标签归入「未分类」)。 */
|
||||||
|
interface AgentGroup {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
agents: Agent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupAgentsByType(agents: Agent[]): AgentGroup[] {
|
||||||
|
const map = new Map<string, AgentGroup>();
|
||||||
|
for (const agent of agents) {
|
||||||
|
const tag = agent.tags?.[0];
|
||||||
|
const id = tag?.id ?? "__uncategorized__";
|
||||||
|
let group = map.get(id);
|
||||||
|
if (!group) {
|
||||||
|
group = { id, name: tag?.name ?? "未分类", agents: [] };
|
||||||
|
map.set(id, group);
|
||||||
|
}
|
||||||
|
group.agents.push(agent);
|
||||||
|
}
|
||||||
|
return Array.from(map.values()).sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name, "zh-Hans-CN"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mod = (value: number, n: number): number => ((value % n) + n) % n;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 密码转盘模型:
|
||||||
|
* - n 个类型始终均匀占据右半圆的 n 个固定槽位(间距恒定,不随点击改变);
|
||||||
|
* - 激活槽位固定为最贴近正右侧中间的槽,被点击的类型旋转到该槽;
|
||||||
|
* - 点击上半部分 → 转盘顺时针转(k > 0),下半部分 → 逆时针(k < 0);
|
||||||
|
* - 超出半圆端点的标签沿“背面长路”补位(角度 ±360 扫过左半圆,途中半透明),
|
||||||
|
* 动画结束后角度归一化回槽位角(同一位置 mod 360,无视觉跳变)。
|
||||||
|
*/
|
||||||
|
export default function AgentsOrbitPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const isDark = themeToBaseMode(theme) === "dark";
|
||||||
|
const { agents, isLoading } = useAgents();
|
||||||
|
const groups = useMemo(() => groupAgentsByType(agents), [agents]);
|
||||||
|
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
/** 转盘动画期间的非归一化角度(含 ±360 长路,长路全程在左半圆不可视区内)。 */
|
||||||
|
const [anim, setAnim] = useState<Record<string, number> | null>(null);
|
||||||
|
/** 归一化帧:瞬移到等价角度时禁用过渡。 */
|
||||||
|
const [frozen, setFrozen] = useState(false);
|
||||||
|
const timerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current != null) window.clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className={`agents-orbit-page ${isDark ? "ao-dark" : "ao-light"}`}>
|
||||||
|
<div className="ao-state">
|
||||||
|
<div className="ao-spinner" />
|
||||||
|
<span>正在加载智能体…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groups.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={`agents-orbit-page ${isDark ? "ao-dark" : "ao-light"}`}>
|
||||||
|
<div className="ao-state">
|
||||||
|
<span>还没有可展示的智能体</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const n = groups.length;
|
||||||
|
const midSlot = Math.floor((n - 1) / 2);
|
||||||
|
const step = 180 / n;
|
||||||
|
/** 槽位 j 的世界角:-90°(正上) → +90°(正下),均布在右半圆开区间内。 */
|
||||||
|
const slotAngle = (slot: number) => -90 + (slot + 0.5) * step;
|
||||||
|
|
||||||
|
const requestedIdx = activeId
|
||||||
|
? groups.findIndex((g) => g.id === activeId)
|
||||||
|
: -1;
|
||||||
|
const activeIdx = requestedIdx >= 0 ? requestedIdx : midSlot;
|
||||||
|
const active = groups[activeIdx];
|
||||||
|
/** 静态分组数组的旋转量:group(gi) 落在槽位 (gi + shift) mod n,激活组恰在 midSlot。 */
|
||||||
|
const shift = midSlot - activeIdx;
|
||||||
|
const groupAtSlot = (slot: number) => groups[mod(slot - shift, n)];
|
||||||
|
|
||||||
|
const handleTypeClick = (slot: number) => {
|
||||||
|
const group = groupAtSlot(slot);
|
||||||
|
if (group.id === active.id) return;
|
||||||
|
|
||||||
|
const k = midSlot - slot; // 正=顺时针向下转,负=逆时针向上转
|
||||||
|
const angles: Record<string, number> = {};
|
||||||
|
for (let j = 0; j < n; j++) {
|
||||||
|
const g = groupAtSlot(j);
|
||||||
|
const nextSlot = j + k;
|
||||||
|
let angle = slotAngle(mod(nextSlot, n));
|
||||||
|
if (nextSlot >= n) {
|
||||||
|
angle += 360; // 从底部滑出(进入左半圆不可视区),绕背回顶部补位
|
||||||
|
} else if (nextSlot < 0) {
|
||||||
|
angle -= 360; // 从顶部滑出(进入左半圆不可视区),绕背回底部补位
|
||||||
|
}
|
||||||
|
angles[g.id] = angle;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveId(group.id);
|
||||||
|
setAnim(angles);
|
||||||
|
if (timerRef.current != null) window.clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = window.setTimeout(() => {
|
||||||
|
// 动画结束:归一化长路角度(同一位置 mod 360),下一帧恢复过渡。
|
||||||
|
setFrozen(true);
|
||||||
|
setAnim(null);
|
||||||
|
requestAnimationFrame(() =>
|
||||||
|
requestAnimationFrame(() => setFrozen(false)),
|
||||||
|
);
|
||||||
|
}, DIAL_TRANSITION_MS + 60);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAgentClick = (agent: Agent) => {
|
||||||
|
router.push(`/page/workspace/agents/${agent.id}/chats/new`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const themeClass = isDark ? "ao-dark" : "ao-light";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`agents-orbit-page ${themeClass}${n >= 8 ? " ao-crowded" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="ao-header">
|
||||||
|
<span className="ao-header-title">智能体 · 星轨转盘</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ao-header-link"
|
||||||
|
onClick={() => router.push("/page/workspace/agents")}
|
||||||
|
>
|
||||||
|
经典视图
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 左侧星轨舞台:中心圆盘 + 中心图片 + 三层圆环 + 卫星 + 类型转盘 */}
|
||||||
|
<div className="ao-stage">
|
||||||
|
<div className="ao-root">
|
||||||
|
<div className="ao-ring ao-ring--ticks" />
|
||||||
|
<div className="ao-ring ao-ring--dash" />
|
||||||
|
<div className="ao-ring ao-ring--arc" />
|
||||||
|
<div className="ao-sat-orbit">
|
||||||
|
<div className="ao-sat" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ao-core">
|
||||||
|
<div className="ao-core-img">
|
||||||
|
<Bot size={60} strokeWidth={1.4} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 转盘容器:左半圆被裁剪为不可视区域,绕背旋转不可见 */}
|
||||||
|
<div className="ao-types">
|
||||||
|
{groups.map((group, gi) => {
|
||||||
|
const slot = mod(gi + shift, n);
|
||||||
|
const angle = anim
|
||||||
|
? (anim[group.id] ?? slotAngle(slot))
|
||||||
|
: slotAngle(slot);
|
||||||
|
const isActive = group.id === active.id;
|
||||||
|
const transitionStyle = frozen ? { transition: "none" } : {};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={group.id}
|
||||||
|
className="ao-type-arm"
|
||||||
|
style={{
|
||||||
|
transform: `rotate(${angle}deg)`,
|
||||||
|
...transitionStyle,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`ao-type-anchor${isActive ? " is-active" : ""}`}
|
||||||
|
style={{
|
||||||
|
transform: `translate(-50%, -50%) rotate(${-angle}deg)`,
|
||||||
|
...transitionStyle,
|
||||||
|
}}
|
||||||
|
onClick={() => handleTypeClick(slot)}
|
||||||
|
title={`${group.name} · ${group.agents.length} 个智能体`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="ao-type-chip"
|
||||||
|
style={{ animationDelay: `${-gi * 0.8}s` }}
|
||||||
|
>
|
||||||
|
{group.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧面板:当前类型下的智能体(圆角正方形图标 + 名称),与舞台 1:1 */}
|
||||||
|
<aside className="ao-panel">
|
||||||
|
<div className="ao-panel-head">
|
||||||
|
<div className="ao-panel-title">{active.name}</div>
|
||||||
|
<div className="ao-panel-sub">共 {active.agents.length} 个智能体</div>
|
||||||
|
</div>
|
||||||
|
{active.agents.length > 0 ? (
|
||||||
|
<div className="ao-agents" key={active.id}>
|
||||||
|
{active.agents.map((agent) => (
|
||||||
|
<button
|
||||||
|
key={agent.id}
|
||||||
|
type="button"
|
||||||
|
className="ao-agent"
|
||||||
|
onClick={() => handleAgentClick(agent)}
|
||||||
|
title={agent.description || agent.name}
|
||||||
|
>
|
||||||
|
<span className="ao-agent-icon">
|
||||||
|
{(agent.name || "?").trim().charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="ao-agent-name">{agent.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="ao-state">
|
||||||
|
<span>该类型下暂时没有智能体</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
CircleHelp,
|
CircleHelp,
|
||||||
Clock3,
|
Clock3,
|
||||||
|
GitBranch,
|
||||||
LoaderCircle,
|
LoaderCircle,
|
||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
|
Network,
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
@@ -41,10 +43,25 @@ import {
|
|||||||
OFFICIAL_DEERFLOW_HOME_ROUTE,
|
OFFICIAL_DEERFLOW_HOME_ROUTE,
|
||||||
OFFICIAL_DEERFLOW_ROUTE_PREFIX,
|
OFFICIAL_DEERFLOW_ROUTE_PREFIX,
|
||||||
} from "@/core/official-deerflow/routes";
|
} from "@/core/official-deerflow/routes";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
|
||||||
|
import MultiAgentTasksPage from "./official-multi-agent-tasks/MultiAgentTasksPage";
|
||||||
|
import TaskEditPage from "./official-multi-agent-tasks/TaskEditPage";
|
||||||
|
import TaskAnalysisPage from "./official-task-analysis/TaskAnalysisPage";
|
||||||
|
import TaskAnalysisDetailPage from "./official-task-analysis/TaskAnalysisDetailPage";
|
||||||
|
|
||||||
import "./official-deerflow-entry.css";
|
import "./official-deerflow-entry.css";
|
||||||
|
|
||||||
type OfficialPage = "chat" | "agents" | "agent-chat" | "scheduled" | "settings";
|
type OfficialPage =
|
||||||
|
| "chat"
|
||||||
|
| "agents"
|
||||||
|
| "agent-chat"
|
||||||
|
| "scheduled"
|
||||||
|
| "multi-agent-tasks"
|
||||||
|
| "multi-agent-edit"
|
||||||
|
| "task-analysis"
|
||||||
|
| "task-analysis-detail"
|
||||||
|
| "settings";
|
||||||
|
|
||||||
function pageFromPath(pathname: string): OfficialPage {
|
function pageFromPath(pathname: string): OfficialPage {
|
||||||
const path = pathname.slice(OFFICIAL_DEERFLOW_ROUTE_PREFIX.length);
|
const path = pathname.slice(OFFICIAL_DEERFLOW_ROUTE_PREFIX.length);
|
||||||
@@ -53,6 +70,13 @@ function pageFromPath(pathname: string): OfficialPage {
|
|||||||
}
|
}
|
||||||
if (path.startsWith("/workspace/agents")) return "agents";
|
if (path.startsWith("/workspace/agents")) return "agents";
|
||||||
if (path.startsWith("/workspace/scheduled-tasks")) return "scheduled";
|
if (path.startsWith("/workspace/scheduled-tasks")) return "scheduled";
|
||||||
|
if (path.startsWith("/workspace/multi-agent-tasks/new")) return "multi-agent-edit";
|
||||||
|
if (/^\/workspace\/multi-agent-tasks\/[^/]+\/edit$/.test(path)) {
|
||||||
|
return "multi-agent-edit";
|
||||||
|
}
|
||||||
|
if (path.startsWith("/workspace/multi-agent-tasks")) return "multi-agent-tasks";
|
||||||
|
if (/^\/workspace\/task-analysis\/[^/]+/.test(path)) return "task-analysis-detail";
|
||||||
|
if (path.startsWith("/workspace/task-analysis")) return "task-analysis";
|
||||||
if (path.startsWith("/workspace/settings")) return "settings";
|
if (path.startsWith("/workspace/settings")) return "settings";
|
||||||
return "chat";
|
return "chat";
|
||||||
}
|
}
|
||||||
@@ -78,6 +102,33 @@ function parseScheduledTaskId(pathname: string) {
|
|||||||
return match ? decodeURIComponent(match[1]!) : null;
|
return match ? decodeURIComponent(match[1]!) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseMultiAgentTaskId(pathname: string) {
|
||||||
|
const match = pathname.match(/\/workspace\/multi-agent-tasks\/([^/]+)/);
|
||||||
|
const raw = match ? decodeURIComponent(match[1]!) : null;
|
||||||
|
// 编辑页路由(new / :id/edit)不是任务详情,不驱动列表页的抽屉。
|
||||||
|
if (!raw || raw === "new") return null;
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTaskAnalysisId(pathname: string) {
|
||||||
|
const match = pathname.match(/\/workspace\/task-analysis\/([^/]+)/);
|
||||||
|
return match ? decodeURIComponent(match[1]!) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMultiAgentEditTarget(pathname: string): {
|
||||||
|
mode: "create" | "edit";
|
||||||
|
taskId: string | null;
|
||||||
|
} {
|
||||||
|
const path = pathname.slice(OFFICIAL_DEERFLOW_ROUTE_PREFIX.length);
|
||||||
|
if (path.startsWith("/workspace/multi-agent-tasks/new")) {
|
||||||
|
return { mode: "create", taskId: null };
|
||||||
|
}
|
||||||
|
const match = path.match(/^\/workspace\/multi-agent-tasks\/([^/]+)\/edit$/);
|
||||||
|
return match
|
||||||
|
? { mode: "edit", taskId: decodeURIComponent(match[1]!) }
|
||||||
|
: { mode: "create", taskId: null };
|
||||||
|
}
|
||||||
|
|
||||||
function readableError(error: unknown, fallback: string) {
|
function readableError(error: unknown, fallback: string) {
|
||||||
return error instanceof Error && error.message ? error.message : fallback;
|
return error instanceof Error && error.message ? error.message : fallback;
|
||||||
}
|
}
|
||||||
@@ -658,6 +709,18 @@ export default function OfficialDeerFlowEntry() {
|
|||||||
() => parseScheduledTaskId(location.pathname),
|
() => parseScheduledTaskId(location.pathname),
|
||||||
[location.pathname],
|
[location.pathname],
|
||||||
);
|
);
|
||||||
|
const multiAgentTaskId = useMemo(
|
||||||
|
() => parseMultiAgentTaskId(location.pathname),
|
||||||
|
[location.pathname],
|
||||||
|
);
|
||||||
|
const multiAgentEditTarget = useMemo(
|
||||||
|
() => parseMultiAgentEditTarget(location.pathname),
|
||||||
|
[location.pathname],
|
||||||
|
);
|
||||||
|
const taskAnalysisId = useMemo(
|
||||||
|
() => parseTaskAnalysisId(location.pathname),
|
||||||
|
[location.pathname],
|
||||||
|
);
|
||||||
const { data: threads = [], isLoading: threadsLoading, error: threadsError } =
|
const { data: threads = [], isLoading: threadsLoading, error: threadsError } =
|
||||||
useThreads({
|
useThreads({
|
||||||
limit: 20,
|
limit: 20,
|
||||||
@@ -672,6 +735,8 @@ export default function OfficialDeerFlowEntry() {
|
|||||||
{ icon: MessageSquareText, label: "对话", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/new`, pages: ["chat"] },
|
{ icon: MessageSquareText, label: "对话", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/new`, pages: ["chat"] },
|
||||||
{ icon: Bot, label: "智能体", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents`, pages: ["agents", "agent-chat"] },
|
{ icon: Bot, label: "智能体", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents`, pages: ["agents", "agent-chat"] },
|
||||||
{ icon: CalendarClock, label: "定时任务", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks`, pages: ["scheduled"] },
|
{ icon: CalendarClock, label: "定时任务", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks`, pages: ["scheduled"] },
|
||||||
|
{ icon: Network, label: "多智能体定时任务", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/multi-agent-tasks`, pages: ["multi-agent-tasks", "multi-agent-edit"] },
|
||||||
|
{ icon: GitBranch, label: "任务分析定时", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/task-analysis`, pages: ["task-analysis", "task-analysis-detail"] },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -769,8 +834,23 @@ export default function OfficialDeerFlowEntry() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{page === "scheduled" && <ScheduledTasksPage selectedTaskId={scheduledTaskId} />}
|
{page === "scheduled" && <ScheduledTasksPage selectedTaskId={scheduledTaskId} />}
|
||||||
|
{page === "multi-agent-tasks" && (
|
||||||
|
<MultiAgentTasksPage routeTaskId={multiAgentTaskId} />
|
||||||
|
)}
|
||||||
|
{page === "multi-agent-edit" && (
|
||||||
|
<TaskEditPage
|
||||||
|
key={multiAgentEditTarget.taskId ?? "create"}
|
||||||
|
mode={multiAgentEditTarget.mode}
|
||||||
|
taskId={multiAgentEditTarget.taskId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{page === "task-analysis" && <TaskAnalysisPage />}
|
||||||
|
{page === "task-analysis-detail" && (
|
||||||
|
<TaskAnalysisDetailPage routeTaskId={taskAnalysisId} />
|
||||||
|
)}
|
||||||
{page === "settings" && <SettingsPage />}
|
{page === "settings" && <SettingsPage />}
|
||||||
</div>
|
</div>
|
||||||
|
<Toaster position="top-center" />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -309,7 +309,24 @@ export default function TaskButtonsPage() {
|
|||||||
className="h-8 w-24"
|
className="h-8 w-24"
|
||||||
title="发给宿主的参数名(默认 id)"
|
title="发给宿主的参数名(默认 id)"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground">= 任务id(数字)</span>
|
<span className="text-muted-foreground">=</span>
|
||||||
|
{/* 行动分析(xdfx):行动id 与任务id 不同,可选发哪种;其它业务固定任务id */}
|
||||||
|
{tab === "xdfx" ? (
|
||||||
|
<Select
|
||||||
|
value={r.idKind}
|
||||||
|
onValueChange={(v) => update(r.id, { idKind: v as TaskButton["idKind"] })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-24">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="task">任务id</SelectItem>
|
||||||
|
<SelectItem value="action">行动id</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">任务id(数字)</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="text-muted-foreground w-full text-[11px]">
|
<span className="text-muted-foreground w-full text-[11px]">
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import ChatPage from "./ChatPage";
|
|||||||
import ArtifactLibraryPage from "./ArtifactLibraryPage";
|
import ArtifactLibraryPage from "./ArtifactLibraryPage";
|
||||||
import IframeChatPage from "./IframeChatPage";
|
import IframeChatPage from "./IframeChatPage";
|
||||||
import AgentsPage from "./AgentsPage";
|
import AgentsPage from "./AgentsPage";
|
||||||
|
import AgentsOrbitPage from "./AgentsOrbitPage";
|
||||||
import AgentChatPage from "./AgentChatPage";
|
import AgentChatPage from "./AgentChatPage";
|
||||||
import SentimentAgentChatPage from "./SentimentAgentChatPage";
|
import SentimentAgentChatPage from "./SentimentAgentChatPage";
|
||||||
import NewAgentPage from "./NewAgentPage";
|
import NewAgentPage from "./NewAgentPage";
|
||||||
@@ -620,6 +621,16 @@ export default function WorkspaceRoutes({
|
|||||||
</WorkspaceLayout>
|
</WorkspaceLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* 智能体星轨视图:/agents 的另一种展示形式(手写 CSS 圆环星轨布局)。
|
||||||
|
字面量路由,置于 agents/:agent_id/* 参数路由之前。 */}
|
||||||
|
<Route
|
||||||
|
path="agents/orbit"
|
||||||
|
element={
|
||||||
|
<WorkspaceLayout {...layoutProps}>
|
||||||
|
<AgentsOrbitPage />
|
||||||
|
</WorkspaceLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
{/* 舆情分析虚拟智能体:静态段优先于 :agent_id 命中,走 Gateway 转发
|
{/* 舆情分析虚拟智能体:静态段优先于 :agent_id 命中,走 Gateway 转发
|
||||||
外部 AG-UI 网关的专用对话页(不在后端 /api/agents 落库)。 */}
|
外部 AG-UI 网关的专用对话页(不在后端 /api/agents 落库)。 */}
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -8,8 +8,10 @@
|
|||||||
* 全新部署自动播种「通用分析结构 / 递进式论证结构」),支持 启用/停用 筛选
|
* 全新部署自动播种「通用分析结构 / 递进式论证结构」),支持 启用/停用 筛选
|
||||||
* 与 标题 筛选;卡片上可 switch 启停、编辑、删除(二次确认),并显示
|
* 与 标题 筛选;卡片上可 switch 启停、编辑、删除(二次确认),并显示
|
||||||
* 最后修改时间与创建用户名;新增时不填状态(服务端强制默认启用)。
|
* 最后修改时间与创建用户名;新增时不填状态(服务端强制默认启用)。
|
||||||
* - 详情视图:点击卡片即「选择」该结构(内容回填到高级配置的报告结构
|
* **点击卡片即「选择」该结构(内容回填到高级配置的报告结构 custom_outline,
|
||||||
* custom_outline),弹窗切换为展示 标题 + Markdown 大纲预览,右上角「返回」回列表。
|
* 卡片留在列表中高亮为当前使用,不进入详情)**。
|
||||||
|
* - 详情视图:通过卡片右上角「查看大纲结构」眼睛图标进入,展示 标题 + Markdown
|
||||||
|
* 大纲预览,右上角「返回」回列表。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
@@ -238,8 +240,9 @@ export function WorkflowConfigDialog({
|
|||||||
toast.error(`「${structure.title}」已停用,请先启用后再选用`);
|
toast.error(`「${structure.title}」已停用,请先启用后再选用`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 点击卡片只做「选择」:内容回填后留在列表视图(卡片高亮为当前使用),
|
||||||
|
// 详情视图通过卡片上的「查看大纲结构」眼睛图标进入。
|
||||||
onApply?.(structure.content, structure.structureMode, structure.retrievalDirections, structure.retrievalSkills);
|
onApply?.(structure.content, structure.structureMode, structure.retrievalDirections, structure.retrievalSkills);
|
||||||
setDetailId(structure.id);
|
|
||||||
toast.success(`已选择报告结构「${structure.title}」,并填充到报告结构配置`);
|
toast.success(`已选择报告结构「${structure.title}」,并填充到报告结构配置`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -325,7 +328,7 @@ export function WorkflowConfigDialog({
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400 sm:text-[13px]">
|
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400 sm:text-[13px]">
|
||||||
点击卡片选择报告结构(自动填充到报告结构配置);可启停、编辑、删除。
|
点击卡片选择报告结构(自动填充到报告结构配置);点击卡片上的眼睛图标查看大纲详情;可启停、编辑、删除。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -501,7 +504,7 @@ export function WorkflowConfigDialog({
|
|||||||
key={structure.id}
|
key={structure.id}
|
||||||
structure={structure}
|
structure={structure}
|
||||||
applied={structure.id === appliedStructureId}
|
applied={structure.id === appliedStructureId}
|
||||||
onOpen={() => handleSelect(structure)}
|
onSelect={() => handleSelect(structure)}
|
||||||
onPreview={(event) => {
|
onPreview={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setDetailId(structure.id);
|
setDetailId(structure.id);
|
||||||
@@ -750,7 +753,7 @@ export function WorkflowConfigDialog({
|
|||||||
function StructureCard({
|
function StructureCard({
|
||||||
structure,
|
structure,
|
||||||
applied,
|
applied,
|
||||||
onOpen,
|
onSelect,
|
||||||
onPreview,
|
onPreview,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
onEdit,
|
onEdit,
|
||||||
@@ -760,7 +763,7 @@ function StructureCard({
|
|||||||
}: {
|
}: {
|
||||||
structure: ReportStructure;
|
structure: ReportStructure;
|
||||||
applied: boolean;
|
applied: boolean;
|
||||||
onOpen: () => void;
|
onSelect: () => void;
|
||||||
onPreview: (event: React.MouseEvent) => void;
|
onPreview: (event: React.MouseEvent) => void;
|
||||||
onDuplicate: (event: React.MouseEvent) => void;
|
onDuplicate: (event: React.MouseEvent) => void;
|
||||||
onEdit: (event: React.MouseEvent) => void;
|
onEdit: (event: React.MouseEvent) => void;
|
||||||
@@ -775,11 +778,11 @@ function StructureCard({
|
|||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={onOpen}
|
onClick={onSelect}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onOpen();
|
onSelect();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`group relative flex cursor-pointer select-none flex-col justify-between rounded-2xl border p-5 text-left transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-[#F5222D]/30 ${
|
className={`group relative flex cursor-pointer select-none flex-col justify-between rounded-2xl border p-5 text-left transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-[#F5222D]/30 ${
|
||||||
|
|||||||
@@ -0,0 +1,584 @@
|
|||||||
|
import {
|
||||||
|
CalendarClock,
|
||||||
|
CircleAlert,
|
||||||
|
CircleCheck,
|
||||||
|
Clock3,
|
||||||
|
History,
|
||||||
|
LoaderCircle,
|
||||||
|
Network,
|
||||||
|
Pencil,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
Zap,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Button, DialogPlugin, Drawer, Input, Popconfirm, Switch, Tabs, Tag } from "tdesign-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { OFFICIAL_DEERFLOW_ROUTE_PREFIX } from "@/core/official-deerflow/routes";
|
||||||
|
|
||||||
|
import AgentFlow from "./components/AgentFlow";
|
||||||
|
import { useMultiAgentTaskActions, useMultiAgentTasks } from "./store";
|
||||||
|
import type { MatRunView, MatTaskView } from "./types";
|
||||||
|
import {
|
||||||
|
describeCron,
|
||||||
|
expandStages,
|
||||||
|
formatDateTime,
|
||||||
|
formatDuration,
|
||||||
|
formatRelative,
|
||||||
|
runStatusLabel,
|
||||||
|
taskBadgeOf,
|
||||||
|
} from "./utils";
|
||||||
|
import "./multi-agent-tasks.css";
|
||||||
|
|
||||||
|
type StatusFilter = "all" | "running" | "enabled" | "paused";
|
||||||
|
|
||||||
|
function isTaskLive(task: MatTaskView) {
|
||||||
|
return task.lastRun?.status === "running" || task.lastRun?.status === "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchFilter(task: MatTaskView, filter: StatusFilter) {
|
||||||
|
if (filter === "all") return true;
|
||||||
|
if (filter === "running") return isTaskLive(task);
|
||||||
|
if (filter === "paused") return !task.enabled;
|
||||||
|
return task.enabled && !isTaskLive(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTagTheme(status: MatRunView["status"]): "success" | "danger" | "warning" | "default" {
|
||||||
|
if (status === "succeeded") return "success";
|
||||||
|
if (status === "failed") return "danger";
|
||||||
|
if (status === "running") return "warning";
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MultiAgentTasksPage({
|
||||||
|
routeTaskId,
|
||||||
|
}: {
|
||||||
|
routeTaskId: string | null;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { tasks, isLoading } = useMultiAgentTasks();
|
||||||
|
const actions = useMultiAgentTaskActions();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [filter, setFilter] = useState<StatusFilter>("all");
|
||||||
|
const [busyTaskId, setBusyTaskId] = useState<string | null>(null);
|
||||||
|
const [activeRunId, setActiveRunId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const selectedTask = tasks.find((task) => task.id === routeTaskId) ?? null;
|
||||||
|
|
||||||
|
const openTask = useCallback(
|
||||||
|
(taskId: string) => {
|
||||||
|
setActiveRunId(null);
|
||||||
|
navigate(
|
||||||
|
`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/multi-agent-tasks/${encodeURIComponent(taskId)}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[navigate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeTask = useCallback(() => {
|
||||||
|
setActiveRunId(null);
|
||||||
|
navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/multi-agent-tasks`);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const visibleTasks = useMemo(() => {
|
||||||
|
const keyword = search.trim().toLowerCase();
|
||||||
|
return tasks.filter((task) => {
|
||||||
|
if (!matchFilter(task, filter)) return false;
|
||||||
|
if (!keyword) return true;
|
||||||
|
return (
|
||||||
|
task.name.toLowerCase().includes(keyword) ||
|
||||||
|
(task.description ?? "").toLowerCase().includes(keyword) ||
|
||||||
|
task.seats.some((seat) => seat.name.toLowerCase().includes(keyword))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [tasks, search, filter]);
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const total = tasks.length;
|
||||||
|
const enabled = tasks.filter((task) => task.enabled).length;
|
||||||
|
const running = tasks.filter(isTaskLive).length;
|
||||||
|
const runTotal = tasks.reduce((sum, task) => sum + task.runs.length, 0);
|
||||||
|
return { total, enabled, running, runTotal };
|
||||||
|
}, [tasks]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/multi-agent-tasks/new`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (task: MatTaskView) => {
|
||||||
|
navigate(
|
||||||
|
`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/multi-agent-tasks/${encodeURIComponent(task.id)}/edit`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runTrigger = async (task: MatTaskView) => {
|
||||||
|
setBusyTaskId(task.id);
|
||||||
|
try {
|
||||||
|
await actions.trigger.mutateAsync(task.id);
|
||||||
|
setActiveRunId(null);
|
||||||
|
openTask(task.id);
|
||||||
|
toast.success("已触发执行");
|
||||||
|
} catch {
|
||||||
|
toast.error("触发失败,请重试");
|
||||||
|
} finally {
|
||||||
|
setBusyTaskId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runToggle = async (task: MatTaskView) => {
|
||||||
|
setBusyTaskId(task.id);
|
||||||
|
try {
|
||||||
|
await actions.setEnabled.mutateAsync({ id: task.id, enabled: !task.enabled });
|
||||||
|
toast.success(task.enabled ? "任务已暂停" : "任务已恢复");
|
||||||
|
} catch {
|
||||||
|
toast.error("操作失败,请重试");
|
||||||
|
} finally {
|
||||||
|
setBusyTaskId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = (task: MatTaskView) => {
|
||||||
|
const dialog = DialogPlugin.confirm({
|
||||||
|
header: "删除定时任务",
|
||||||
|
body: `确定删除「${task.name}」吗?其编排配置与运行记录将一并清除,且不可恢复。`,
|
||||||
|
confirmBtn: { content: "删除", theme: "danger" },
|
||||||
|
cancelBtn: "取消",
|
||||||
|
onConfirm: () => {
|
||||||
|
dialog.destroy();
|
||||||
|
void actions.remove.mutateAsync(task.id).then(() => {
|
||||||
|
if (selectedTask?.id === task.id) closeTask();
|
||||||
|
toast.success("任务已删除");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onClose: () => dialog.destroy(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedStages = selectedTask ? expandStages(selectedTask) : [];
|
||||||
|
const selectedRuns = selectedTask?.runs ?? [];
|
||||||
|
const activeRun =
|
||||||
|
selectedRuns.find((run) => run.id === activeRunId) ?? selectedRuns[0] ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mat-page">
|
||||||
|
<header className="mat-page-head">
|
||||||
|
<div className="mat-page-head-copy">
|
||||||
|
<h1>多智能体定时任务</h1>
|
||||||
|
<p>
|
||||||
|
把多个智能体编排成协作编队:同阶段并行、阶段间串行,按计划自动执行并沉淀运行记录。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button theme="primary" onClick={openCreate}>
|
||||||
|
<Plus aria-hidden="true" size={15} />
|
||||||
|
新建编排任务
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mat-stats" aria-label="任务统计">
|
||||||
|
<div className="mat-stat">
|
||||||
|
<span className="mat-stat-icon is-primary" aria-hidden="true">
|
||||||
|
<Network size={18} />
|
||||||
|
</span>
|
||||||
|
<div className="mat-stat-copy">
|
||||||
|
<strong>{stats.total}</strong>
|
||||||
|
<span>编排任务</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mat-stat">
|
||||||
|
<span className="mat-stat-icon is-success" aria-hidden="true">
|
||||||
|
<CircleCheck size={18} />
|
||||||
|
</span>
|
||||||
|
<div className="mat-stat-copy">
|
||||||
|
<strong>{stats.enabled}</strong>
|
||||||
|
<span>已启用</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mat-stat">
|
||||||
|
<span className={`mat-stat-icon${stats.running ? " is-live" : " is-muted"}`} aria-hidden="true">
|
||||||
|
{stats.running ? <LoaderCircle size={18} className="mat-spin" /> : <Clock3 size={18} />}
|
||||||
|
</span>
|
||||||
|
<div className="mat-stat-copy">
|
||||||
|
<strong className={stats.running ? "is-live" : undefined}>{stats.running}</strong>
|
||||||
|
<span>执行中</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mat-stat">
|
||||||
|
<span className="mat-stat-icon is-info" aria-hidden="true">
|
||||||
|
<History size={18} />
|
||||||
|
</span>
|
||||||
|
<div className="mat-stat-copy">
|
||||||
|
<strong>{stats.runTotal}</strong>
|
||||||
|
<span>累计执行</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mat-body">
|
||||||
|
<div className="mat-toolbar">
|
||||||
|
<div className="mat-toolbar-search">
|
||||||
|
<Input
|
||||||
|
clearable
|
||||||
|
placeholder="搜索任务名称 / 描述 / 智能体"
|
||||||
|
prefixIcon={<Search aria-hidden="true" size={15} />}
|
||||||
|
value={search}
|
||||||
|
onChange={(value) => setSearch(String(value ?? ""))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Tabs
|
||||||
|
className="mat-toolbar-tabs"
|
||||||
|
value={filter}
|
||||||
|
onChange={(value) => setFilter(value as StatusFilter)}
|
||||||
|
>
|
||||||
|
<Tabs.TabPanel label="全部" value="all" />
|
||||||
|
<Tabs.TabPanel label="执行中" value="running" />
|
||||||
|
<Tabs.TabPanel label="已启用" value="enabled" />
|
||||||
|
<Tabs.TabPanel label="已暂停" value="paused" />
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="mat-state">
|
||||||
|
<Clock3 aria-hidden="true" className="mat-spin" size={18} />
|
||||||
|
正在加载编排任务…
|
||||||
|
</div>
|
||||||
|
) : visibleTasks.length ? (
|
||||||
|
<div className="mat-grid">
|
||||||
|
{visibleTasks.map((task) => {
|
||||||
|
const badge = taskBadgeOf(task);
|
||||||
|
const live = isTaskLive(task);
|
||||||
|
const busy = busyTaskId === task.id;
|
||||||
|
return (
|
||||||
|
<article className="mat-card" key={task.id}>
|
||||||
|
<header className="mat-card-head" onClick={() => openTask(task.id)}>
|
||||||
|
<span className="mat-card-glyph" aria-hidden="true">
|
||||||
|
<Network size={17} />
|
||||||
|
</span>
|
||||||
|
<div className="mat-card-title">
|
||||||
|
<h3>{task.name}</h3>
|
||||||
|
<p>
|
||||||
|
<CalendarClock aria-hidden="true" size={13} />
|
||||||
|
{describeCron(task.cron_expr)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Tag size="small" theme={badge.theme} variant="light">
|
||||||
|
{badge.label}
|
||||||
|
</Tag>
|
||||||
|
</header>
|
||||||
|
{task.description && (
|
||||||
|
<p className="mat-card-desc" onClick={() => openTask(task.id)}>
|
||||||
|
{task.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mat-card-flow" onClick={() => openTask(task.id)}>
|
||||||
|
<AgentFlow
|
||||||
|
seats={task.seats}
|
||||||
|
size="sm"
|
||||||
|
stages={expandStages(task)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<footer className="mat-card-foot">
|
||||||
|
<div className="mat-card-meta">
|
||||||
|
<span title={task.nextRunAt ?? undefined}>
|
||||||
|
下次 {task.enabled ? formatDateTime(task.nextRunAt) : "已暂停"}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
上次 {task.lastRun ? formatRelative(task.lastRun.started_at) : "未执行"}
|
||||||
|
{task.lastRun && task.lastRun.status !== "pending" && (
|
||||||
|
<em className={`mat-run-text is-${task.lastRun.status}`}>
|
||||||
|
· {runStatusLabel(task.lastRun.status)}
|
||||||
|
</em>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mat-card-ops">
|
||||||
|
<Button
|
||||||
|
disabled={busy}
|
||||||
|
loading={busy}
|
||||||
|
size="small"
|
||||||
|
theme="primary"
|
||||||
|
variant="text"
|
||||||
|
onClick={() => void runTrigger(task)}
|
||||||
|
>
|
||||||
|
<Zap aria-hidden="true" size={14} />
|
||||||
|
立即执行
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={busy}
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
onClick={() => void runToggle(task)}
|
||||||
|
>
|
||||||
|
{task.enabled ? (
|
||||||
|
<Pause aria-hidden="true" size={14} />
|
||||||
|
) : (
|
||||||
|
<Play aria-hidden="true" size={14} />
|
||||||
|
)}
|
||||||
|
{task.enabled ? "暂停" : "恢复"}
|
||||||
|
</Button>
|
||||||
|
<Button size="small" variant="text" onClick={() => openEdit(task)}>
|
||||||
|
<Pencil aria-hidden="true" size={14} />
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
content="删除后不可恢复,确定删除该任务?"
|
||||||
|
placement="bottom-right"
|
||||||
|
theme="danger"
|
||||||
|
onConfirm={() => confirmDelete(task)}
|
||||||
|
>
|
||||||
|
<Button className="mat-danger-btn" size="small" variant="text">
|
||||||
|
<Trash2 aria-hidden="true" size={14} />
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mat-state mat-state--empty">
|
||||||
|
<span className="mat-state-glyph" aria-hidden="true">
|
||||||
|
<Network size={22} />
|
||||||
|
</span>
|
||||||
|
<h3>{tasks.length ? "没有符合条件的任务" : "还没有多智能体定时任务"}</h3>
|
||||||
|
<p>
|
||||||
|
{tasks.length
|
||||||
|
? "换个关键词或筛选条件试试。"
|
||||||
|
: "创建一个编排任务,把多个智能体按并行 / 串行组织起来定时执行。"}
|
||||||
|
</p>
|
||||||
|
{!tasks.length && (
|
||||||
|
<Button theme="primary" onClick={openCreate}>
|
||||||
|
<Plus aria-hidden="true" size={15} />
|
||||||
|
新建编排任务
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mat-footnote">
|
||||||
|
<CircleAlert aria-hidden="true" size={13} />
|
||||||
|
前端演示阶段:任务数据保存在本浏览器,执行过程为本地模拟;后端服务接入后自动切换。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
className="mat-detail-drawer"
|
||||||
|
closeBtn
|
||||||
|
destroyOnClose
|
||||||
|
footer={
|
||||||
|
selectedTask ? (
|
||||||
|
<div className="mat-detail-footer">
|
||||||
|
<Button variant="outline" onClick={() => openEdit(selectedTask)}>
|
||||||
|
<Pencil aria-hidden="true" size={15} />
|
||||||
|
编辑编排
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void runToggle(selectedTask)}
|
||||||
|
>
|
||||||
|
{selectedTask.enabled ? (
|
||||||
|
<Pause aria-hidden="true" size={15} />
|
||||||
|
) : (
|
||||||
|
<Play aria-hidden="true" size={15} />
|
||||||
|
)}
|
||||||
|
{selectedTask.enabled ? "暂停任务" : "恢复任务"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="mat-danger-btn"
|
||||||
|
theme="danger"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => confirmDelete(selectedTask)}
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden="true" size={15} />
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={busyTaskId === selectedTask.id}
|
||||||
|
loading={busyTaskId === selectedTask.id}
|
||||||
|
theme="primary"
|
||||||
|
onClick={() => void runTrigger(selectedTask)}
|
||||||
|
>
|
||||||
|
<Zap aria-hidden="true" size={15} />
|
||||||
|
立即执行
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
header={
|
||||||
|
selectedTask ? (
|
||||||
|
<div className="mat-detail-header">
|
||||||
|
<span className="mat-detail-glyph" aria-hidden="true">
|
||||||
|
<Network size={17} />
|
||||||
|
</span>
|
||||||
|
<div className="mat-detail-heading">
|
||||||
|
<h3>{selectedTask.name}</h3>
|
||||||
|
<p>{describeCron(selectedTask.cron_expr)}</p>
|
||||||
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const badge = taskBadgeOf(selectedTask);
|
||||||
|
return (
|
||||||
|
<Tag theme={badge.theme} variant="light">
|
||||||
|
{badge.label}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<Switch
|
||||||
|
value={selectedTask.enabled}
|
||||||
|
onChange={() => void runToggle(selectedTask)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
placement="right"
|
||||||
|
size="680px"
|
||||||
|
visible={!!selectedTask}
|
||||||
|
onClose={closeTask}
|
||||||
|
>
|
||||||
|
{selectedTask && (
|
||||||
|
<div className="mat-detail">
|
||||||
|
{selectedTask.description && <p className="mat-detail-desc">{selectedTask.description}</p>}
|
||||||
|
|
||||||
|
<div className="mat-detail-grid">
|
||||||
|
<div>
|
||||||
|
<span>下次执行</span>
|
||||||
|
<strong>
|
||||||
|
{selectedTask.enabled ? formatDateTime(selectedTask.nextRunAt) : "已暂停"}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>最近执行</span>
|
||||||
|
<strong>
|
||||||
|
{selectedTask.lastRun
|
||||||
|
? `${formatRelative(selectedTask.lastRun.started_at)} · ${runStatusLabel(selectedTask.lastRun.status)}`
|
||||||
|
: "未执行"}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>累计执行</span>
|
||||||
|
<strong>{selectedTask.runs.length} 次</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>智能体 / 阶段</span>
|
||||||
|
<strong>
|
||||||
|
{selectedTask.seats.length} 个 · {selectedStages.length} 阶段
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="mat-detail-section">
|
||||||
|
<header className="mat-detail-section-head">
|
||||||
|
<h4>协作编排</h4>
|
||||||
|
{activeRun && (activeRun.status === "running" || activeRun.status === "pending") && (
|
||||||
|
<span className="mat-run-text is-running">
|
||||||
|
阶段 {Math.min(activeRun.stage_index + 1, activeRun.stage_total)} /{" "}
|
||||||
|
{activeRun.stage_total} 执行中…
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
<div className="mat-detail-flow">
|
||||||
|
<AgentFlow
|
||||||
|
activeStageIndex={
|
||||||
|
activeRun && activeRun.status !== "succeeded" ? activeRun.stage_index : null
|
||||||
|
}
|
||||||
|
seatStates={activeRun?.seat_states ?? null}
|
||||||
|
seats={selectedTask.seats}
|
||||||
|
showGoals
|
||||||
|
size="sm"
|
||||||
|
stageGoals={selectedTask.stage_goals}
|
||||||
|
stages={selectedStages}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{activeRun && (
|
||||||
|
<div className="mat-flow-legend">
|
||||||
|
<span>
|
||||||
|
<i className="mat-seat-dot is-done" /> 完成
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<i className="mat-seat-dot is-running" /> 执行中
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<i className="mat-seat-dot is-waiting" /> 等待
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<i className="mat-seat-dot is-failed" /> 失败
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mat-detail-section">
|
||||||
|
<header className="mat-detail-section-head">
|
||||||
|
<h4>任务指令</h4>
|
||||||
|
</header>
|
||||||
|
<p className="mat-detail-prompt">{selectedTask.prompt}</p>
|
||||||
|
{selectedTask.coordinator_prompt && (
|
||||||
|
<>
|
||||||
|
<header className="mat-detail-section-head mat-detail-section-head--sub">
|
||||||
|
<h5>总控提示</h5>
|
||||||
|
</header>
|
||||||
|
<p className="mat-detail-prompt is-sub">{selectedTask.coordinator_prompt}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mat-detail-section">
|
||||||
|
<header className="mat-detail-section-head">
|
||||||
|
<h4>运行记录</h4>
|
||||||
|
{!!selectedRuns.length && (
|
||||||
|
<small>点击记录可在上方流程图回看各智能体状态</small>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
{selectedRuns.length ? (
|
||||||
|
<div className="mat-run-list">
|
||||||
|
{selectedRuns.map((run) => {
|
||||||
|
const active = activeRun?.id === run.id;
|
||||||
|
const duration = run.finished_at
|
||||||
|
? formatDuration(
|
||||||
|
new Date(run.finished_at).getTime() -
|
||||||
|
new Date(run.started_at).getTime(),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`mat-run-item${active ? " is-active" : ""}`}
|
||||||
|
key={run.id}
|
||||||
|
onClick={() => setActiveRunId(run.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Tag size="small" theme={runTagTheme(run.status)} variant="light">
|
||||||
|
{runStatusLabel(run.status)}
|
||||||
|
</Tag>
|
||||||
|
<span className="mat-run-item-time">
|
||||||
|
{formatDateTime(run.started_at)}
|
||||||
|
</span>
|
||||||
|
<span className="mat-run-item-trigger">
|
||||||
|
{run.trigger === "manual" ? "手动触发" : "按计划执行"}
|
||||||
|
</span>
|
||||||
|
<span className="mat-run-item-duration">
|
||||||
|
{duration ?? `阶段 ${Math.min(run.stage_index + 1, run.stage_total)}/${run.stage_total}`}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mat-detail-empty">还没有执行记录,可点「立即执行」体验完整流程。</p>
|
||||||
|
)}
|
||||||
|
{activeRun?.error && (
|
||||||
|
<p className="mat-detail-error">
|
||||||
|
<CircleAlert aria-hidden="true" size={14} />
|
||||||
|
{activeRun.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,942 @@
|
|||||||
|
/**
|
||||||
|
* 多智能体定时任务 · 独立编辑页。
|
||||||
|
*
|
||||||
|
* 布局:顶部是紧凑表单条(任务名称 + 任务描述),其余表单项(执行频率、任务指令、
|
||||||
|
* 总控提示、启用开关)收进「执行设置」弹窗;主体是智能体编排。
|
||||||
|
* 编排交互与业务链条编辑器一致(复制其 @dnd-kit 模式 + 复用 chain-stages 纯函数):
|
||||||
|
* - 左栏候选池:拖左侧手柄到右侧任意阶段 / 新建阶段落区;点卡片本体 = 追加为新阶段。
|
||||||
|
* - 右栏阶段组:阶段是 droppable,席位是 sortable;席位可跨阶段拖动(变并行)或拖到
|
||||||
|
* 「新建阶段」落区独立成阶段;按钮兜底(并入上/下一阶段、独立成阶段、移除)。
|
||||||
|
* 保存时弹出确认弹窗:任务摘要 + 编排流程图(同阶段并行 · 阶段间串行)+ 图例。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Bot,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Eye,
|
||||||
|
GripVertical,
|
||||||
|
Plus,
|
||||||
|
Save,
|
||||||
|
Search,
|
||||||
|
Settings2,
|
||||||
|
SplitSquareVertical,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragOverlay,
|
||||||
|
PointerSensor,
|
||||||
|
closestCenter,
|
||||||
|
useDraggable,
|
||||||
|
useDroppable,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
type DragStartEvent,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import { Button, Dialog, Input, Switch, Textarea } from "tdesign-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { useAgents } from "@/core/agents";
|
||||||
|
import type { Agent } from "@/core/agents";
|
||||||
|
import {
|
||||||
|
type ScheduleConfig,
|
||||||
|
buildCron,
|
||||||
|
parseCron,
|
||||||
|
} from "@/components/workspace/scheduled-tasks/schedule-builder";
|
||||||
|
import {
|
||||||
|
addEmptyStage,
|
||||||
|
addSeatAsNewStage,
|
||||||
|
addSeatToStage,
|
||||||
|
countSeats,
|
||||||
|
deleteStage,
|
||||||
|
findStageIndexOfSeat,
|
||||||
|
moveSeatToStage,
|
||||||
|
moveStage,
|
||||||
|
removeSeat,
|
||||||
|
setStageGoal,
|
||||||
|
splitSeatToNewStage,
|
||||||
|
stageGroupsToStageGoals,
|
||||||
|
stageGroupsToStagePayload,
|
||||||
|
type StageGroups,
|
||||||
|
} from "@/roundtable-planning/lib/chain-stages";
|
||||||
|
|
||||||
|
import { OFFICIAL_DEERFLOW_ROUTE_PREFIX } from "@/core/official-deerflow/routes";
|
||||||
|
|
||||||
|
import AgentFlow from "./components/AgentFlow";
|
||||||
|
import AgentDetailDrawer, {
|
||||||
|
type AgentDetailTarget,
|
||||||
|
} from "./components/AgentDetailDrawer";
|
||||||
|
import SchedulePicker from "./components/SchedulePicker";
|
||||||
|
import { useMultiAgentTaskActions, useMultiAgentTasks } from "./store";
|
||||||
|
import type { MatSeat, MatTask, MatTaskPayload } from "./types";
|
||||||
|
import {
|
||||||
|
defaultScheduleConfig,
|
||||||
|
describeCron,
|
||||||
|
describeScheduleConfig,
|
||||||
|
expandStages,
|
||||||
|
} from "./utils";
|
||||||
|
|
||||||
|
const POOL_PREFIX = "matpool::";
|
||||||
|
const SEAT_PREFIX = "matseat::";
|
||||||
|
const STAGE_PREFIX = "matstage::";
|
||||||
|
const NEWSTAGE_DROPPABLE_ID = "mat-newstage";
|
||||||
|
|
||||||
|
const MAX_SEATS = 20;
|
||||||
|
const MIN_SEATS = 2;
|
||||||
|
const DEFAULT_TIMEZONE = "Asia/Shanghai";
|
||||||
|
|
||||||
|
type ActiveDrag = { name: string } | null;
|
||||||
|
|
||||||
|
function seatFromAgent(agent: Agent): MatSeat {
|
||||||
|
return {
|
||||||
|
agent_id: agent.id,
|
||||||
|
name: agent.name,
|
||||||
|
description: agent.description ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftFromTask(task: MatTask) {
|
||||||
|
const stages = expandStages(task);
|
||||||
|
const seatById = new Map(task.seats.map((seat) => [seat.agent_id, seat]));
|
||||||
|
return {
|
||||||
|
name: task.name,
|
||||||
|
description: task.description ?? "",
|
||||||
|
schedule: parseCron(task.cron_expr) ?? defaultScheduleConfig(),
|
||||||
|
enabled: task.enabled,
|
||||||
|
prompt: task.prompt,
|
||||||
|
coordinatorPrompt: task.coordinator_prompt ?? "",
|
||||||
|
stageGroups: stages.map((stage, index) => ({
|
||||||
|
seats: stage
|
||||||
|
.map((agentId) => seatById.get(agentId))
|
||||||
|
.filter((seat): seat is MatSeat => !!seat),
|
||||||
|
goal: task.stage_goals?.[index] ?? "",
|
||||||
|
})) as StageGroups,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupsToPayloadSeats(groups: StageGroups): MatSeat[] {
|
||||||
|
return groups
|
||||||
|
.filter((group) => group.seats.length > 0)
|
||||||
|
.flatMap((group) => group.seats as MatSeat[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 左栏:候选智能体卡片(点卡片看详情 / 加号或手柄加入)──────────────────
|
||||||
|
|
||||||
|
function PoolAgentCard({
|
||||||
|
agent,
|
||||||
|
used,
|
||||||
|
onAdd,
|
||||||
|
onShowDetail,
|
||||||
|
}: {
|
||||||
|
agent: Agent;
|
||||||
|
used: boolean;
|
||||||
|
onAdd: () => void;
|
||||||
|
onShowDetail: () => void;
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||||
|
id: POOL_PREFIX + agent.id,
|
||||||
|
disabled: used,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`mat-pool-item${used ? " is-used" : ""}${isDragging ? " is-dragging" : ""}`}
|
||||||
|
ref={setNodeRef}
|
||||||
|
{...attributes}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label={used ? "已在编排中" : "拖动加入编排"}
|
||||||
|
className="mat-pool-grip"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
title={used ? "已在编排中" : "拖动到右侧阶段加入(可与其它智能体并行)"}
|
||||||
|
type="button"
|
||||||
|
{...(used ? {} : listeners)}
|
||||||
|
>
|
||||||
|
<GripVertical size={14} />
|
||||||
|
</button>
|
||||||
|
<button className="mat-pool-main" onClick={onShowDetail} type="button">
|
||||||
|
<span className="mat-pool-icon" aria-hidden="true">
|
||||||
|
<Bot size={13} />
|
||||||
|
</span>
|
||||||
|
<span className="mat-pool-copy">
|
||||||
|
<span className="mat-pool-item-name">{agent.name}</span>
|
||||||
|
<span className="mat-pool-item-desc">{agent.description || "暂无简介"}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{used ? (
|
||||||
|
<span className="mat-pool-used">已加入</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
aria-label={`加入 ${agent.name}`}
|
||||||
|
className="mat-pool-join"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onAdd();
|
||||||
|
}}
|
||||||
|
title="追加为新阶段"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 右栏:单个席位长条(sortable)─────────────────────────────────────────
|
||||||
|
|
||||||
|
function SeatStrip({
|
||||||
|
seat,
|
||||||
|
globalIndex,
|
||||||
|
isFirstStage,
|
||||||
|
isLastStage,
|
||||||
|
onRemove,
|
||||||
|
onMergeUp,
|
||||||
|
onMergeDown,
|
||||||
|
onSplit,
|
||||||
|
onDetail,
|
||||||
|
}: {
|
||||||
|
seat: MatSeat;
|
||||||
|
globalIndex: number;
|
||||||
|
isFirstStage: boolean;
|
||||||
|
isLastStage: boolean;
|
||||||
|
onRemove: () => void;
|
||||||
|
onMergeUp: () => void;
|
||||||
|
onMergeDown: () => void;
|
||||||
|
onSplit: () => void;
|
||||||
|
onDetail: () => void;
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||||
|
useSortable({ id: SEAT_PREFIX + seat.agent_id });
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`mat-seat-row${isDragging ? " is-dragging" : ""}`}
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="拖动调整编排"
|
||||||
|
className="mat-seat-row-grip"
|
||||||
|
title="拖动到其它阶段(并行)/ 排序 / 拖到底部虚线框独立成阶段"
|
||||||
|
type="button"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVertical size={14} />
|
||||||
|
</button>
|
||||||
|
<span className="mat-seat-row-no">{globalIndex + 1}</span>
|
||||||
|
<span className="mat-seat-row-icon" aria-hidden="true">
|
||||||
|
<Bot size={13} />
|
||||||
|
</span>
|
||||||
|
<div className="mat-seat-row-copy" title={seat.description}>
|
||||||
|
<span className="mat-seat-row-name">{seat.name}</span>
|
||||||
|
{seat.description && <span className="mat-seat-row-desc">{seat.description}</span>}
|
||||||
|
</div>
|
||||||
|
<span className="mat-seat-row-ops">
|
||||||
|
<button
|
||||||
|
aria-label={`查看 ${seat.name} 详情`}
|
||||||
|
onClick={onDetail}
|
||||||
|
title="查看详情"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Eye size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="并入上一阶段(与其并行)"
|
||||||
|
disabled={isFirstStage}
|
||||||
|
onClick={onMergeUp}
|
||||||
|
title="并入上一阶段(与其并行)"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ChevronUp size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="独立成阶段"
|
||||||
|
onClick={onSplit}
|
||||||
|
title="独立成阶段"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<SplitSquareVertical size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="并入下一阶段(与其并行)"
|
||||||
|
disabled={isLastStage}
|
||||||
|
onClick={onMergeDown}
|
||||||
|
title="并入下一阶段(与其并行)"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
</button>
|
||||||
|
<button aria-label="移除智能体" onClick={onRemove} title="移除" type="button">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 右栏:阶段容器(droppable + 组内 sortable)────────────────────────────
|
||||||
|
|
||||||
|
function StageBox({
|
||||||
|
group,
|
||||||
|
stageIdx,
|
||||||
|
stageCount,
|
||||||
|
startIndex,
|
||||||
|
onGoalChange,
|
||||||
|
onStageReorder,
|
||||||
|
onStageDelete,
|
||||||
|
onRemoveSeat,
|
||||||
|
onMergeSeat,
|
||||||
|
onSplitSeat,
|
||||||
|
onSeatDetail,
|
||||||
|
}: {
|
||||||
|
group: StageGroups[number];
|
||||||
|
stageIdx: number;
|
||||||
|
stageCount: number;
|
||||||
|
startIndex: number;
|
||||||
|
onGoalChange: (goal: string) => void;
|
||||||
|
onStageReorder: (dir: -1 | 1) => void;
|
||||||
|
onStageDelete: () => void;
|
||||||
|
onRemoveSeat: (agentId: string) => void;
|
||||||
|
onMergeSeat: (agentId: string, targetStage: number) => void;
|
||||||
|
onSplitSeat: (agentId: string) => void;
|
||||||
|
onSeatDetail: (seat: MatSeat) => void;
|
||||||
|
}) {
|
||||||
|
const { setNodeRef, isOver } = useDroppable({ id: STAGE_PREFIX + stageIdx });
|
||||||
|
const seats = group.seats as MatSeat[];
|
||||||
|
const isParallel = seats.length > 1;
|
||||||
|
const isFirstStage = stageIdx === 0;
|
||||||
|
const isLastStage = stageIdx === stageCount - 1;
|
||||||
|
const seatItemIds = seats.map((seat) => SEAT_PREFIX + seat.agent_id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className={`mat-stage-box${isOver ? " is-over" : ""}`}>
|
||||||
|
<header className="mat-stage-box-head">
|
||||||
|
<span className="mat-stage-box-no">阶段 {stageIdx + 1}</span>
|
||||||
|
{isParallel ? (
|
||||||
|
<span className="mat-flow-mode is-parallel">并行 ×{seats.length}</span>
|
||||||
|
) : (
|
||||||
|
<span className="mat-flow-mode is-serial">串行</span>
|
||||||
|
)}
|
||||||
|
<span className="mat-stage-box-ops">
|
||||||
|
<button
|
||||||
|
aria-label="阶段上移"
|
||||||
|
disabled={isFirstStage}
|
||||||
|
onClick={() => onStageReorder(-1)}
|
||||||
|
title="上移"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ChevronUp size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="阶段下移"
|
||||||
|
disabled={isLastStage}
|
||||||
|
onClick={() => onStageReorder(1)}
|
||||||
|
title="下移"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="删除阶段"
|
||||||
|
onClick={onStageDelete}
|
||||||
|
title="删除整个阶段"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<Textarea
|
||||||
|
autosize
|
||||||
|
className="mat-stage-goal"
|
||||||
|
maxlength={120}
|
||||||
|
placeholder="本阶段目标(可选):本阶段要产出什么、如何衔接上一阶段"
|
||||||
|
value={group.goal ?? ""}
|
||||||
|
onChange={(value) => onGoalChange(String(value ?? ""))}
|
||||||
|
/>
|
||||||
|
<div className="mat-stage-seats" ref={setNodeRef}>
|
||||||
|
{seats.length === 0 ? (
|
||||||
|
<div className="mat-stage-seat-empty">空阶段 —— 把左侧智能体拖到此处,或删除该阶段。</div>
|
||||||
|
) : (
|
||||||
|
<SortableContext items={seatItemIds} strategy={verticalListSortingStrategy}>
|
||||||
|
{seats.map((seat, index) => (
|
||||||
|
<SeatStrip
|
||||||
|
globalIndex={startIndex + index}
|
||||||
|
isLastStage={isLastStage}
|
||||||
|
isFirstStage={isFirstStage}
|
||||||
|
key={seat.agent_id}
|
||||||
|
onDetail={() => onSeatDetail(seat)}
|
||||||
|
onMergeDown={() => onMergeSeat(seat.agent_id, stageIdx + 1)}
|
||||||
|
onMergeUp={() => onMergeSeat(seat.agent_id, stageIdx - 1)}
|
||||||
|
onRemove={() => onRemoveSeat(seat.agent_id)}
|
||||||
|
onSplit={() => onSplitSeat(seat.agent_id)}
|
||||||
|
seat={seat}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 「新建阶段」落区(点击新增 / 拖席位到此独立成最后阶段)────────────────
|
||||||
|
|
||||||
|
function NewStageDropZone({
|
||||||
|
hasEmptyStage,
|
||||||
|
onAddStage,
|
||||||
|
}: {
|
||||||
|
hasEmptyStage: boolean;
|
||||||
|
onAddStage: () => void;
|
||||||
|
}) {
|
||||||
|
const { setNodeRef, isOver } = useDroppable({ id: NEWSTAGE_DROPPABLE_ID });
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`mat-stage-dropzone${isOver ? " is-over" : ""}`}
|
||||||
|
disabled={hasEmptyStage}
|
||||||
|
onClick={onAddStage}
|
||||||
|
ref={setNodeRef}
|
||||||
|
title={hasEmptyStage ? "已有空阶段,先填充它" : "新建一个串在最后的阶段"}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus size={15} />
|
||||||
|
新建阶段(串在最后) · 或把席位拖到此处独立成阶段
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 页面 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function TaskEditPage({
|
||||||
|
mode,
|
||||||
|
taskId,
|
||||||
|
}: {
|
||||||
|
mode: "create" | "edit";
|
||||||
|
taskId: string | null;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { tasks, isLoading: tasksLoading } = useMultiAgentTasks();
|
||||||
|
const actions = useMultiAgentTaskActions();
|
||||||
|
const [poolSearch, setPoolSearch] = useState("");
|
||||||
|
const { agents, isLoading: agentsLoading } = useAgents({
|
||||||
|
search: poolSearch.trim() || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [schedule, setSchedule] = useState<ScheduleConfig>(defaultScheduleConfig());
|
||||||
|
const [enabled, setEnabled] = useState(true);
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
const [coordinatorPrompt, setCoordinatorPrompt] = useState("");
|
||||||
|
const [stageGroups, setStageGroups] = useState<StageGroups>([]);
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [activeDrag, setActiveDrag] = useState<ActiveDrag>(null);
|
||||||
|
const [detailTarget, setDetailTarget] = useState<AgentDetailTarget | null>(null);
|
||||||
|
|
||||||
|
const editingTask = useMemo(
|
||||||
|
() => (mode === "edit" ? (tasks.find((task) => task.id === taskId) ?? null) : null),
|
||||||
|
[mode, taskId, tasks],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialized || mode === "create") {
|
||||||
|
setInitialized(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!tasksLoading && taskId) {
|
||||||
|
if (editingTask) {
|
||||||
|
const draft = draftFromTask(editingTask);
|
||||||
|
setName(draft.name);
|
||||||
|
setDescription(draft.description);
|
||||||
|
setSchedule(draft.schedule);
|
||||||
|
setEnabled(draft.enabled);
|
||||||
|
setPrompt(draft.prompt);
|
||||||
|
setCoordinatorPrompt(draft.coordinatorPrompt);
|
||||||
|
setStageGroups(draft.stageGroups);
|
||||||
|
}
|
||||||
|
setInitialized(true);
|
||||||
|
}
|
||||||
|
}, [editingTask, initialized, mode, taskId, tasksLoading]);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const usedAgentIds = useMemo(
|
||||||
|
() => new Set(stageGroups.flatMap((group) => group.seats.map((seat) => seat.agent_id))),
|
||||||
|
[stageGroups],
|
||||||
|
);
|
||||||
|
const seatCount = useMemo(() => countSeats(stageGroups), [stageGroups]);
|
||||||
|
const hasEmptyStage = stageGroups.some((group) => group.seats.length === 0);
|
||||||
|
|
||||||
|
const backToList = useCallback(() => {
|
||||||
|
navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/multi-agent-tasks`);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
// ── 编排操作(全部复用 chain-stages 纯函数,agent_id 定位)───────────────
|
||||||
|
const addAgentAsStage = useCallback((agent: Agent) => {
|
||||||
|
if (seatCount >= MAX_SEATS) {
|
||||||
|
toast.warning(`编排最多 ${MAX_SEATS} 个智能体`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStageGroups((prev) => addSeatAsNewStage(prev, seatFromAgent(agent)));
|
||||||
|
}, [seatCount]);
|
||||||
|
|
||||||
|
const onDragStart = useCallback((event: DragStartEvent) => {
|
||||||
|
const id = String(event.active.id);
|
||||||
|
const agentId = id.startsWith(POOL_PREFIX)
|
||||||
|
? id.slice(POOL_PREFIX.length)
|
||||||
|
: id.startsWith(SEAT_PREFIX)
|
||||||
|
? id.slice(SEAT_PREFIX.length)
|
||||||
|
: null;
|
||||||
|
if (!agentId) return;
|
||||||
|
const seatName =
|
||||||
|
agents.find((agent) => agent.id === agentId)?.name ??
|
||||||
|
stageGroups
|
||||||
|
.flatMap((group) => group.seats as MatSeat[])
|
||||||
|
.find((seat) => seat.agent_id === agentId)?.name ??
|
||||||
|
agentId;
|
||||||
|
setActiveDrag({ name: seatName });
|
||||||
|
}, [agents, stageGroups]);
|
||||||
|
|
||||||
|
const onDragEnd = useCallback(
|
||||||
|
(event: DragEndEvent) => {
|
||||||
|
setActiveDrag(null);
|
||||||
|
const activeId = String(event.active.id);
|
||||||
|
const overId = event.over ? String(event.over.id) : null;
|
||||||
|
if (!overId) return;
|
||||||
|
|
||||||
|
let targetStage = -1;
|
||||||
|
let atIndex: number | undefined;
|
||||||
|
if (overId === NEWSTAGE_DROPPABLE_ID) {
|
||||||
|
targetStage = -1;
|
||||||
|
} else if (overId.startsWith(STAGE_PREFIX)) {
|
||||||
|
targetStage = Number(overId.slice(STAGE_PREFIX.length));
|
||||||
|
} else if (overId.startsWith(SEAT_PREFIX)) {
|
||||||
|
const overAgent = overId.slice(SEAT_PREFIX.length);
|
||||||
|
targetStage = findStageIndexOfSeat(stageGroups, overAgent);
|
||||||
|
atIndex = stageGroups[targetStage]?.seats.findIndex(
|
||||||
|
(seat) => seat.agent_id === overAgent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toNewStage = overId === NEWSTAGE_DROPPABLE_ID;
|
||||||
|
|
||||||
|
if (activeId.startsWith(POOL_PREFIX)) {
|
||||||
|
const agentId = activeId.slice(POOL_PREFIX.length);
|
||||||
|
if (usedAgentIds.has(agentId)) return;
|
||||||
|
const agent = agents.find((candidate) => candidate.id === agentId);
|
||||||
|
if (!agent || seatCount >= MAX_SEATS) return;
|
||||||
|
const seat = seatFromAgent(agent);
|
||||||
|
setStageGroups((prev) =>
|
||||||
|
toNewStage || targetStage < 0
|
||||||
|
? addSeatAsNewStage(prev, seat)
|
||||||
|
: addSeatToStage(prev, seat, targetStage, atIndex),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeId.startsWith(SEAT_PREFIX)) {
|
||||||
|
const agentId = activeId.slice(SEAT_PREFIX.length);
|
||||||
|
setStageGroups((prev) => {
|
||||||
|
if (toNewStage) {
|
||||||
|
const withEmpty = addEmptyStage(prev);
|
||||||
|
return moveSeatToStage(withEmpty, agentId, withEmpty.length - 1);
|
||||||
|
}
|
||||||
|
if (targetStage < 0) return prev;
|
||||||
|
return moveSeatToStage(prev, agentId, targetStage, atIndex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[agents, seatCount, stageGroups, usedAgentIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 保存:校验 → 确认弹窗 → 提交 ────────────────────────────────────────
|
||||||
|
const previewGroups = stageGroups.filter((group) => group.seats.length > 0);
|
||||||
|
const previewStages = previewGroups.map((group) =>
|
||||||
|
(group.seats as MatSeat[]).map((seat) => seat.agent_id),
|
||||||
|
);
|
||||||
|
const previewSeats = groupsToPayloadSeats(stageGroups);
|
||||||
|
|
||||||
|
const tryOpenConfirm = () => {
|
||||||
|
if (!name.trim()) {
|
||||||
|
toast.warning("请填写任务名称");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (seatCount < MIN_SEATS) {
|
||||||
|
toast.warning(`编排至少需要 ${MIN_SEATS} 个智能体`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasEmptyStage) {
|
||||||
|
toast.warning("还有空阶段,请填充或删除");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!prompt.trim()) {
|
||||||
|
setSettingsOpen(true);
|
||||||
|
toast.warning("请先在「执行设置」中填写任务指令");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfirmOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitTask = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
// 填了总控提示也强制落显式分层(该提示仅分层模式注入),与业务链条语义一致。
|
||||||
|
const forceExplicit = coordinatorPrompt.trim().length > 0;
|
||||||
|
const payload: MatTaskPayload = {
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
prompt: prompt.trim(),
|
||||||
|
coordinator_prompt: coordinatorPrompt.trim() || undefined,
|
||||||
|
cron_expr: buildCron(schedule),
|
||||||
|
timezone: editingTask?.timezone || DEFAULT_TIMEZONE,
|
||||||
|
enabled,
|
||||||
|
seats: previewSeats,
|
||||||
|
stages: stageGroupsToStagePayload(stageGroups, forceExplicit),
|
||||||
|
stage_goals: stageGroupsToStageGoals(stageGroups, forceExplicit),
|
||||||
|
};
|
||||||
|
if (mode === "edit" && taskId) {
|
||||||
|
await actions.update.mutateAsync({ id: taskId, payload });
|
||||||
|
toast.success("任务已更新");
|
||||||
|
} else {
|
||||||
|
await actions.create.mutateAsync(payload);
|
||||||
|
toast.success("任务已创建");
|
||||||
|
}
|
||||||
|
setConfirmOpen(false);
|
||||||
|
backToList();
|
||||||
|
} catch {
|
||||||
|
toast.error("保存失败,请重试");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mode === "edit" && !tasksLoading && initialized && !editingTask) {
|
||||||
|
return (
|
||||||
|
<section className="mat-edit-page">
|
||||||
|
<div className="mat-state mat-state--empty">
|
||||||
|
<h3>任务不存在或已被删除</h3>
|
||||||
|
<Button theme="primary" onClick={backToList}>
|
||||||
|
返回任务列表
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let seatCursor = 0;
|
||||||
|
const settingsSummary = `${describeScheduleConfig(schedule)} · ${enabled ? "已启用" : "已暂停"}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mat-edit-page">
|
||||||
|
<header className="mat-edit-header">
|
||||||
|
<button aria-label="返回任务列表" className="mat-edit-back" onClick={backToList} type="button">
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</button>
|
||||||
|
<div className="mat-edit-heading">
|
||||||
|
<h1>{mode === "edit" ? `编辑:${editingTask?.name ?? "多智能体定时任务"}` : "新建多智能体定时任务"}</h1>
|
||||||
|
<p>同阶段并行执行 · 阶段之间串行执行;拖拽或点击左侧候选智能体加入编排</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setSettingsOpen(true)}
|
||||||
|
title="执行频率 / 任务指令 / 总控提示 / 启用"
|
||||||
|
>
|
||||||
|
<Settings2 aria-hidden="true" size={15} />
|
||||||
|
执行设置
|
||||||
|
<span className="mat-settings-summary">{settingsSummary}</span>
|
||||||
|
</Button>
|
||||||
|
<Button theme="primary" onClick={tryOpenConfirm}>
|
||||||
|
<Save aria-hidden="true" size={15} />
|
||||||
|
保存任务
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mat-edit-formbar">
|
||||||
|
<label className="mat-formbar-field">
|
||||||
|
<span className="mat-formbar-label">
|
||||||
|
任务名称<i>*</i>
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
clearable
|
||||||
|
maxlength={40}
|
||||||
|
placeholder="例如:每日舆情早报"
|
||||||
|
value={name}
|
||||||
|
onChange={(value) => setName(String(value ?? ""))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="mat-formbar-field mat-formbar-field--wide">
|
||||||
|
<span className="mat-formbar-label">任务描述</span>
|
||||||
|
<Input
|
||||||
|
clearable
|
||||||
|
maxlength={200}
|
||||||
|
placeholder="一句话说明这个编排任务要达成什么(可选)"
|
||||||
|
value={description}
|
||||||
|
onChange={(value) => setDescription(String(value ?? ""))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mat-edit-formbar mat-edit-formbar--area">
|
||||||
|
<span className="mat-formbar-label">总控提示</span>
|
||||||
|
<Textarea
|
||||||
|
autosize={{ minRows: 1, maxRows: 4 }}
|
||||||
|
className="mat-formbar-textarea"
|
||||||
|
maxlength={1000}
|
||||||
|
placeholder="写给总控(协调者)的编排要求,例如各阶段如何衔接、产出格式约束等(可选)"
|
||||||
|
value={coordinatorPrompt}
|
||||||
|
onChange={(value) => setCoordinatorPrompt(String(value ?? ""))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mat-edit-body">
|
||||||
|
<section className="mat-edit-card">
|
||||||
|
<DndContext
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
sensors={sensors}
|
||||||
|
>
|
||||||
|
<div className="mat-edit-orchestra">
|
||||||
|
<aside className="mat-pool">
|
||||||
|
<header className="mat-pool-head">
|
||||||
|
<span>候选智能体</span>
|
||||||
|
<em>
|
||||||
|
{seatCount}/{MAX_SEATS} 已编排 · {agents.length} 个可选
|
||||||
|
</em>
|
||||||
|
</header>
|
||||||
|
<div className="mat-pool-search">
|
||||||
|
<Search aria-hidden="true" size={15} />
|
||||||
|
<input
|
||||||
|
onChange={(event) => setPoolSearch(event.target.value)}
|
||||||
|
placeholder="搜索智能体"
|
||||||
|
value={poolSearch}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mat-pool-list">
|
||||||
|
{agentsLoading ? (
|
||||||
|
<p className="mat-pool-tip">正在加载智能体…</p>
|
||||||
|
) : agents.length ? (
|
||||||
|
agents.map((agent) => (
|
||||||
|
<PoolAgentCard
|
||||||
|
agent={agent}
|
||||||
|
key={agent.id}
|
||||||
|
onAdd={() => addAgentAsStage(agent)}
|
||||||
|
onShowDetail={() =>
|
||||||
|
setDetailTarget({
|
||||||
|
agentId: agent.id,
|
||||||
|
fallbackName: agent.name,
|
||||||
|
fallbackDescription: agent.description ?? undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
used={usedAgentIds.has(agent.id)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="mat-pool-tip">没有匹配的智能体</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="mat-stage-editor">
|
||||||
|
<div className="mat-stage-list">
|
||||||
|
{stageGroups.map((group, stageIdx) => {
|
||||||
|
const startIndex = seatCursor;
|
||||||
|
seatCursor += (group.seats as MatSeat[]).length;
|
||||||
|
return (
|
||||||
|
<StageBox
|
||||||
|
group={group}
|
||||||
|
key={`stage-box-${stageIdx}`}
|
||||||
|
onGoalChange={(goal) =>
|
||||||
|
setStageGroups((prev) => setStageGoal(prev, stageIdx, goal))
|
||||||
|
}
|
||||||
|
onMergeSeat={(agentId, target) =>
|
||||||
|
setStageGroups((prev) => moveSeatToStage(prev, agentId, target))
|
||||||
|
}
|
||||||
|
onRemoveSeat={(agentId) =>
|
||||||
|
setStageGroups((prev) => removeSeat(prev, agentId))
|
||||||
|
}
|
||||||
|
onSeatDetail={(seat) =>
|
||||||
|
setDetailTarget({
|
||||||
|
agentId: seat.agent_id,
|
||||||
|
fallbackName: seat.name,
|
||||||
|
fallbackDescription: seat.description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onSplitSeat={(agentId) =>
|
||||||
|
setStageGroups((prev) => splitSeatToNewStage(prev, agentId))
|
||||||
|
}
|
||||||
|
onStageDelete={() =>
|
||||||
|
setStageGroups((prev) => deleteStage(prev, stageIdx))
|
||||||
|
}
|
||||||
|
onStageReorder={(dir) =>
|
||||||
|
setStageGroups((prev) => moveStage(prev, stageIdx, dir))
|
||||||
|
}
|
||||||
|
stageCount={stageGroups.length}
|
||||||
|
stageIdx={stageIdx}
|
||||||
|
startIndex={startIndex}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<NewStageDropZone
|
||||||
|
hasEmptyStage={hasEmptyStage}
|
||||||
|
onAddStage={() => setStageGroups((prev) => addEmptyStage(prev))}
|
||||||
|
/>
|
||||||
|
{!stageGroups.length && (
|
||||||
|
<p className="mat-stage-empty">
|
||||||
|
从左侧点击或拖拽候选智能体加入编排,至少 {MIN_SEATS} 个。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DragOverlay>
|
||||||
|
{activeDrag ? (
|
||||||
|
<div className="mat-drag-overlay">
|
||||||
|
<GripVertical size={14} />
|
||||||
|
{activeDrag.name}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
className="mat-settings-dialog"
|
||||||
|
closeBtn
|
||||||
|
closeOnOverlayClick={false}
|
||||||
|
destroyOnClose
|
||||||
|
footer={
|
||||||
|
<div className="mat-confirm-footer">
|
||||||
|
<Button variant="outline" onClick={() => setSettingsOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button theme="primary" onClick={() => setSettingsOpen(false)}>
|
||||||
|
确定
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
header="执行设置"
|
||||||
|
placement="top"
|
||||||
|
visible={settingsOpen}
|
||||||
|
width={620}
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
>
|
||||||
|
<div className="mat-settings">
|
||||||
|
<div className="mat-field">
|
||||||
|
<span className="mat-field-label">执行频率</span>
|
||||||
|
<SchedulePicker value={schedule} onChange={setSchedule} />
|
||||||
|
</div>
|
||||||
|
<label className="mat-field">
|
||||||
|
<span className="mat-field-label">
|
||||||
|
任务指令<i>*</i>
|
||||||
|
</span>
|
||||||
|
<Textarea
|
||||||
|
autosize={{ minRows: 4, maxRows: 8 }}
|
||||||
|
maxlength={2000}
|
||||||
|
placeholder="描述每次定时执行要让智能体编队完成的事情,例如:汇总过去 24 小时重点舆情并生成早报…"
|
||||||
|
value={prompt}
|
||||||
|
onChange={(value) => setPrompt(String(value ?? ""))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="mat-field mat-field--switch">
|
||||||
|
<span className="mat-field-label">保存后立即启用</span>
|
||||||
|
<Switch value={enabled} onChange={(value) => setEnabled(!!value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
className="mat-confirm-dialog"
|
||||||
|
closeBtn
|
||||||
|
closeOnOverlayClick={false}
|
||||||
|
destroyOnClose
|
||||||
|
footer={
|
||||||
|
<div className="mat-confirm-footer">
|
||||||
|
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
|
||||||
|
再改改
|
||||||
|
</Button>
|
||||||
|
<Button loading={saving} theme="primary" onClick={() => void submitTask()}>
|
||||||
|
确认保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
header="确认保存任务"
|
||||||
|
placement="top"
|
||||||
|
visible={confirmOpen}
|
||||||
|
width={720}
|
||||||
|
onClose={() => setConfirmOpen(false)}
|
||||||
|
>
|
||||||
|
<div className="mat-confirm">
|
||||||
|
<div className="mat-confirm-grid">
|
||||||
|
<div>
|
||||||
|
<span>任务名称</span>
|
||||||
|
<strong>{name.trim() || "—"}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>执行频率</span>
|
||||||
|
<strong>{describeCron(buildCron(schedule))}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>启用状态</span>
|
||||||
|
<strong>{enabled ? "启用" : "暂停"}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>智能体 / 阶段</span>
|
||||||
|
<strong>
|
||||||
|
{previewSeats.length} 个智能体 · {previewStages.length} 个阶段
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mat-confirm-flow">
|
||||||
|
<AgentFlow
|
||||||
|
seats={previewSeats}
|
||||||
|
showGoals
|
||||||
|
size="sm"
|
||||||
|
stageGoals={previewGroups.map((group) => (group.goal ?? "").trim() || null)}
|
||||||
|
stages={previewStages}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mat-confirm-legend">
|
||||||
|
<span>
|
||||||
|
<i className="mat-flow-mode is-parallel" style={{ cursor: "default" }} /> 并行:同阶段智能体同时执行
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<i className="mat-flow-mode is-serial" style={{ cursor: "default" }} /> 串行:阶段之间依次执行
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{prompt.trim() && (
|
||||||
|
<div className="mat-confirm-prompt">
|
||||||
|
<span>任务指令</span>
|
||||||
|
<p>{prompt.trim()}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<AgentDetailDrawer
|
||||||
|
target={detailTarget}
|
||||||
|
onClose={() => setDetailTarget(null)}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* 智能体详情抽屉:候选池卡片 / 编排席位条「查看详情」共用。
|
||||||
|
* 后端查不到(本地演示席位)时回退到编排里保存的名称/描述快照。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Bot } from "lucide-react";
|
||||||
|
import { Drawer, Tag } from "tdesign-react";
|
||||||
|
|
||||||
|
import { useAgent } from "@/core/agents";
|
||||||
|
|
||||||
|
export interface AgentDetailTarget {
|
||||||
|
agentId: string;
|
||||||
|
fallbackName?: string;
|
||||||
|
fallbackDescription?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string | null | undefined): string {
|
||||||
|
if (!value) return "—";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return "—";
|
||||||
|
return new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AgentDetailDrawer({
|
||||||
|
target,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
target: AgentDetailTarget | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { agent, isLoading } = useAgent(target?.agentId ?? null, {
|
||||||
|
enabled: !!target,
|
||||||
|
});
|
||||||
|
|
||||||
|
const name = agent?.name ?? target?.fallbackName ?? target?.agentId ?? "";
|
||||||
|
const description =
|
||||||
|
agent?.description ?? target?.fallbackDescription ?? agent?.description ?? "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
className="mat-agent-drawer"
|
||||||
|
closeBtn
|
||||||
|
destroyOnClose
|
||||||
|
footer={null}
|
||||||
|
header={
|
||||||
|
<div className="mat-agent-header">
|
||||||
|
<span className="mat-agent-glyph" aria-hidden="true">
|
||||||
|
<Bot size={17} />
|
||||||
|
</span>
|
||||||
|
<div className="mat-agent-heading">
|
||||||
|
<h3>{name}</h3>
|
||||||
|
<p>智能体详情</p>
|
||||||
|
</div>
|
||||||
|
{agent && (
|
||||||
|
<Tag size="small" theme={agent.builtin ? "default" : "primary"} variant="light">
|
||||||
|
{agent.builtin ? "内置" : agent.published ? "已发布" : "私有"}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
placement="right"
|
||||||
|
size="460px"
|
||||||
|
visible={!!target}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
{target && (
|
||||||
|
<div className="mat-agent">
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="mat-agent-loading">正在加载智能体详情…</p>
|
||||||
|
) : agent ? (
|
||||||
|
<>
|
||||||
|
<div className="mat-detail-grid">
|
||||||
|
<div>
|
||||||
|
<span>创建者</span>
|
||||||
|
<strong>
|
||||||
|
{agent.owner_username
|
||||||
|
? agent.owner_username
|
||||||
|
: agent.builtin
|
||||||
|
? "内置智能体"
|
||||||
|
: "—"}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>执行模型</span>
|
||||||
|
<strong>{agent.model || "默认模型"}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>创建时间</span>
|
||||||
|
<strong>{formatDateTime(agent.created_at)}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>技能数</span>
|
||||||
|
<strong>{agent.skills?.length ?? 0} 个</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{agent.description && (
|
||||||
|
<section className="mat-agent-section">
|
||||||
|
<header className="mat-detail-section-head">
|
||||||
|
<h4>简介</h4>
|
||||||
|
</header>
|
||||||
|
<p className="mat-agent-desc">{agent.description}</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!!agent.tags?.length && (
|
||||||
|
<section className="mat-agent-section">
|
||||||
|
<header className="mat-detail-section-head">
|
||||||
|
<h4>标签</h4>
|
||||||
|
</header>
|
||||||
|
<div className="mat-agent-tags">
|
||||||
|
{agent.tags.map((tag) => (
|
||||||
|
<Tag key={tag.id} size="small" variant="light">
|
||||||
|
{tag.name}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!!agent.skills?.length && (
|
||||||
|
<section className="mat-agent-section">
|
||||||
|
<header className="mat-detail-section-head">
|
||||||
|
<h4>已配置技能</h4>
|
||||||
|
</header>
|
||||||
|
<div className="mat-agent-tags">
|
||||||
|
{agent.skills.map((skill) => (
|
||||||
|
<Tag key={skill} size="small" theme="primary" variant="light">
|
||||||
|
{skill}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="mat-agent-local">
|
||||||
|
<div className="mat-detail-grid">
|
||||||
|
<div>
|
||||||
|
<span>创建者</span>
|
||||||
|
<strong>本地演示</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>数据来源</span>
|
||||||
|
<strong>编排内置快照</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mat-agent-desc">
|
||||||
|
{description || "该席位来自演示种子数据,未对应后端智能体目录中的记录。接入后端后,编排将引用真实智能体。"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { Bot, MoveRight } from "lucide-react";
|
||||||
|
import { Fragment } from "react";
|
||||||
|
|
||||||
|
import type { MatSeat, MatSeatRunState } from "../types";
|
||||||
|
import { seatColorIndex } from "../utils";
|
||||||
|
|
||||||
|
export interface AgentFlowProps {
|
||||||
|
stages: string[][];
|
||||||
|
seats: MatSeat[];
|
||||||
|
stageGoals?: (string | null)[] | null;
|
||||||
|
/** 提供时在席位上渲染执行状态点(运行图例)。 */
|
||||||
|
seatStates?: Record<string, MatSeatRunState> | null;
|
||||||
|
/** 当前执行到的阶段下标;提供时高亮进度。 */
|
||||||
|
activeStageIndex?: number | null;
|
||||||
|
size?: "sm" | "md";
|
||||||
|
showGoals?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATE_LABEL: Record<MatSeatRunState, string> = {
|
||||||
|
waiting: "等待",
|
||||||
|
running: "执行中",
|
||||||
|
done: "完成",
|
||||||
|
failed: "失败",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AgentFlow({
|
||||||
|
stages,
|
||||||
|
seats,
|
||||||
|
stageGoals,
|
||||||
|
seatStates,
|
||||||
|
activeStageIndex,
|
||||||
|
size = "md",
|
||||||
|
showGoals = false,
|
||||||
|
}: AgentFlowProps) {
|
||||||
|
const seatById = new Map(seats.map((seat) => [seat.agent_id, seat]));
|
||||||
|
|
||||||
|
// 只有一个阶段时不画阶段框:席位直接横向排列。
|
||||||
|
if (stages.length === 1) {
|
||||||
|
return (
|
||||||
|
<div className={`mat-flow mat-flow--single mat-flow--${size}`}>
|
||||||
|
{stages[0]!.map((agentId) => {
|
||||||
|
const seat = seatById.get(agentId);
|
||||||
|
const state = seatStates?.[agentId] ?? null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`mat-seat mat-seat--c${seatColorIndex(agentId)}${
|
||||||
|
state ? ` is-${state}` : ""
|
||||||
|
}`}
|
||||||
|
key={agentId}
|
||||||
|
title={
|
||||||
|
seat?.description
|
||||||
|
? `${seat.name}:${seat.description}`
|
||||||
|
: (seat?.name ?? agentId)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="mat-seat-avatar">
|
||||||
|
<Bot size={size === "sm" ? 11 : 12} strokeWidth={2.2} />
|
||||||
|
</span>
|
||||||
|
<span className="mat-seat-name">{seat?.name ?? agentId}</span>
|
||||||
|
{state && (
|
||||||
|
<span className={`mat-seat-dot is-${state}`} title={STATE_LABEL[state]} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`mat-flow mat-flow--${size}`}>
|
||||||
|
{stages.map((stage, stageIndex) => {
|
||||||
|
const parallel = stage.length > 1;
|
||||||
|
const isActive = typeof activeStageIndex === "number" && activeStageIndex === stageIndex;
|
||||||
|
const isPast =
|
||||||
|
typeof activeStageIndex === "number" && activeStageIndex > stageIndex;
|
||||||
|
const goal = showGoals ? stageGoals?.[stageIndex] ?? null : null;
|
||||||
|
return (
|
||||||
|
<Fragment key={`stage-${stageIndex}`}>
|
||||||
|
{stageIndex > 0 && (
|
||||||
|
<div className="mat-flow-arrow" aria-hidden="true">
|
||||||
|
<MoveRight size={size === "sm" ? 14 : 16} strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<section
|
||||||
|
className={[
|
||||||
|
"mat-flow-stage",
|
||||||
|
isActive ? "is-active" : "",
|
||||||
|
isPast ? "is-done" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
>
|
||||||
|
<header className="mat-flow-stage-head">
|
||||||
|
<span className="mat-flow-stage-no">阶段 {stageIndex + 1}</span>
|
||||||
|
{parallel ? (
|
||||||
|
<span className="mat-flow-mode is-parallel">并行 ×{stage.length}</span>
|
||||||
|
) : (
|
||||||
|
<span className="mat-flow-mode is-serial">串行</span>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
<div className="mat-flow-seats">
|
||||||
|
{stage.map((agentId) => {
|
||||||
|
const seat = seatById.get(agentId);
|
||||||
|
const state = seatStates?.[agentId] ?? null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`mat-seat mat-seat--c${seatColorIndex(agentId)}${
|
||||||
|
state ? ` is-${state}` : ""
|
||||||
|
}`}
|
||||||
|
key={agentId}
|
||||||
|
title={
|
||||||
|
seat?.description
|
||||||
|
? `${seat.name}:${seat.description}`
|
||||||
|
: (seat?.name ?? agentId)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="mat-seat-avatar">
|
||||||
|
<Bot size={size === "sm" ? 11 : 12} strokeWidth={2.2} />
|
||||||
|
</span>
|
||||||
|
<span className="mat-seat-name">{seat?.name ?? agentId}</span>
|
||||||
|
{state && (
|
||||||
|
<span className={`mat-seat-dot is-${state}`} title={STATE_LABEL[state]} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{goal && <p className="mat-flow-goal">{goal}</p>}
|
||||||
|
</section>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Select } from "tdesign-react";
|
||||||
|
|
||||||
|
import type { ScheduleConfig, ScheduleFreq } from "@/components/workspace/scheduled-tasks/schedule-builder";
|
||||||
|
|
||||||
|
import { describeScheduleConfig } from "../utils";
|
||||||
|
|
||||||
|
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
||||||
|
const HOURS = Array.from({ length: 24 }, (_, index) => index);
|
||||||
|
const MINUTES = [0, 15, 30, 45];
|
||||||
|
const DATES = Array.from({ length: 31 }, (_, index) => index + 1);
|
||||||
|
|
||||||
|
const FREQ_OPTIONS: Array<{ label: string; value: ScheduleFreq }> = [
|
||||||
|
{ label: "每小时", value: "hourly" },
|
||||||
|
{ label: "每天", value: "daily" },
|
||||||
|
{ label: "每周", value: "weekly" },
|
||||||
|
{ label: "每月", value: "monthly" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SchedulePicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: ScheduleConfig;
|
||||||
|
onChange: (config: ScheduleConfig) => void;
|
||||||
|
}) {
|
||||||
|
const set = (patch: Partial<ScheduleConfig>) => onChange({ ...value, ...patch });
|
||||||
|
const toNumber = (input: unknown) => Number(input as string | number);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mat-schedule-picker">
|
||||||
|
<div className="mat-schedule-row">
|
||||||
|
<Select
|
||||||
|
options={FREQ_OPTIONS}
|
||||||
|
value={value.freq}
|
||||||
|
onChange={(next) => set({ freq: next as ScheduleFreq })}
|
||||||
|
/>
|
||||||
|
{value.freq === "hourly" && (
|
||||||
|
<Select
|
||||||
|
options={MINUTES.map((minute) => ({ label: `${minute} 分`, value: minute }))}
|
||||||
|
value={value.minute}
|
||||||
|
onChange={(next) => set({ minute: toNumber(next) })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{value.freq === "weekly" && (
|
||||||
|
<Select
|
||||||
|
options={WEEKDAYS.map((label, index) => ({ label, value: index }))}
|
||||||
|
value={value.weekday}
|
||||||
|
onChange={(next) => set({ weekday: toNumber(next) })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{value.freq === "monthly" && (
|
||||||
|
<Select
|
||||||
|
options={DATES.map((date) => ({ label: `第 ${date} 日`, value: date }))}
|
||||||
|
value={value.date}
|
||||||
|
onChange={(next) => set({ date: toNumber(next) })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{value.freq !== "hourly" && (
|
||||||
|
<Select
|
||||||
|
options={HOURS.map((hour) => ({ label: `${hour} 时`, value: hour }))}
|
||||||
|
value={value.hour}
|
||||||
|
onChange={(next) => set({ hour: toNumber(next) })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{value.freq !== "hourly" && (
|
||||||
|
<Select
|
||||||
|
options={MINUTES.map((minute) => ({ label: `${minute} 分`, value: minute }))}
|
||||||
|
value={value.minute}
|
||||||
|
onChange={(next) => set({ minute: toNumber(next) })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mat-schedule-preview">{describeScheduleConfig(value)} 自动执行</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,361 @@
|
|||||||
|
/**
|
||||||
|
* 多智能体定时任务的本地 mock 存储层。
|
||||||
|
*
|
||||||
|
* 后端接口尚未实现,先用 localStorage 持久化任务与运行记录:
|
||||||
|
* - 「立即执行」写入一条运行记录,状态由 started_at + duration_ms 在
|
||||||
|
* 读取时按当前时间推导(排队 → 按阶段推进 → 成功/失败),配合
|
||||||
|
* React Query 的轮询即可获得无后端的“实时”执行动画。
|
||||||
|
* - 接口就绪后,把本文件的 CRUD 换成 HTTP 调用即可,页面无需改动。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { uuid } from "@/core/utils/uuid";
|
||||||
|
|
||||||
|
import { expandStages, nextCronDate } from "./utils";
|
||||||
|
import type {
|
||||||
|
MatRunRecord,
|
||||||
|
MatRunStatus,
|
||||||
|
MatRunView,
|
||||||
|
MatSeatRunState,
|
||||||
|
MatTask,
|
||||||
|
MatTaskPayload,
|
||||||
|
MatTaskView,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "deerflow.official.multi-agent-tasks.v1";
|
||||||
|
|
||||||
|
interface MatStore {
|
||||||
|
tasks: MatTask[];
|
||||||
|
runs: MatRunRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PENDING_MS = 1_500;
|
||||||
|
const STAGE_MS = 4_000;
|
||||||
|
|
||||||
|
function readStore(): MatStore {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw) as Partial<MatStore>;
|
||||||
|
if (Array.isArray(parsed.tasks)) {
|
||||||
|
return { tasks: parsed.tasks as MatTask[], runs: parsed.runs ?? [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 解析失败按首次访问处理,重写种子数据。
|
||||||
|
}
|
||||||
|
const seeded = seedStore();
|
||||||
|
writeStore(seeded);
|
||||||
|
return seeded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStore(store: MatStore) {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
|
||||||
|
} catch {
|
||||||
|
// 存储被禁用时静默降级为内存态。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mutateStore(mutator: (store: MatStore) => MatStore): MatStore {
|
||||||
|
const next = mutator(readStore());
|
||||||
|
writeStore(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hoursAgo(hours: number): string {
|
||||||
|
return new Date(Date.now() - hours * 3_600_000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedStore(): MatStore {
|
||||||
|
const seats = {
|
||||||
|
collector: { agent_id: "mat-seed-collector", name: "全网舆情采集", description: "多源检索并汇总最新舆情线索" },
|
||||||
|
monitor: { agent_id: "mat-seed-monitor", name: "舆情监测雷达", description: "盯住重点信源并输出监测明细" },
|
||||||
|
analyst: { agent_id: "mat-seed-analyst", name: "舆情研判分析", description: "对采集结果做情感与趋势研判" },
|
||||||
|
writer: { agent_id: "mat-seed-writer", name: "报告撰写助手", description: "把各阶段产出整理成结构化报告" },
|
||||||
|
verifier: { agent_id: "mat-seed-verifier", name: "数据核查员", description: "交叉校验数据与结论的可靠性" },
|
||||||
|
rival: { agent_id: "mat-seed-rival", name: "竞品情报员", description: "跟踪竞品动态与公开信息" },
|
||||||
|
researcher: { agent_id: "mat-seed-researcher", name: "行业研究员", description: "梳理行业政策与市场变化" },
|
||||||
|
forecaster: { agent_id: "mat-seed-forecaster", name: "趋势预测专家", description: "基于数据推演后续走势" },
|
||||||
|
reviewer: { agent_id: "mat-seed-reviewer", name: "内容审核官", description: "审核报告口径与合规风险" },
|
||||||
|
} satisfies Record<string, MatTask["seats"][number]>;
|
||||||
|
|
||||||
|
const tasks: MatTask[] = [
|
||||||
|
{
|
||||||
|
id: "mat-task-daily",
|
||||||
|
name: "每日舆情早报",
|
||||||
|
description: "每天早上自动汇总过去 24 小时重点舆情,并行采集 + 研判 + 成稿。",
|
||||||
|
prompt:
|
||||||
|
"汇总过去 24 小时与本公司相关的重点舆情:先并行完成多源采集与监测,再研判情感与趋势,最后汇总为一份不超过 1500 字的舆情早报,附风险提示。",
|
||||||
|
coordinator_prompt: "采集阶段允许两个智能体各自独立检索,不做信息合并;研判阶段需引用采集阶段的原始线索。",
|
||||||
|
cron_expr: "0 9 * * *",
|
||||||
|
timezone: "Asia/Shanghai",
|
||||||
|
enabled: true,
|
||||||
|
seats: [seats.collector, seats.monitor, seats.analyst, seats.writer],
|
||||||
|
stages: [
|
||||||
|
[seats.collector.agent_id, seats.monitor.agent_id],
|
||||||
|
[seats.analyst.agent_id],
|
||||||
|
[seats.writer.agent_id],
|
||||||
|
],
|
||||||
|
stage_goals: ["并行完成全网采集与重点信源监测", "基于原始线索完成情感与趋势研判", "汇总为可直发的舆情早报"],
|
||||||
|
created_at: hoursAgo(24 * 18),
|
||||||
|
updated_at: hoursAgo(26),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mat-task-weekly",
|
||||||
|
name: "周度竞品情报速览",
|
||||||
|
description: "每周一串行走查竞品动态,核查后输出速览简报。",
|
||||||
|
prompt: "跟踪 5 家核心竞品过去一周的公开动态(产品、市场、人事),核查数据可信度后输出竞品速览简报。",
|
||||||
|
cron_expr: "30 8 * * 1",
|
||||||
|
timezone: "Asia/Shanghai",
|
||||||
|
enabled: false,
|
||||||
|
seats: [seats.rival, seats.verifier, seats.writer],
|
||||||
|
stages: null,
|
||||||
|
stage_goals: null,
|
||||||
|
created_at: hoursAgo(24 * 40),
|
||||||
|
updated_at: hoursAgo(24 * 3),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mat-task-monthly",
|
||||||
|
name: "月度行业洞察报告",
|
||||||
|
description: "每月并行完成行业研究、趋势预测与数据核查,审核后成稿。",
|
||||||
|
prompt:
|
||||||
|
"围绕所在行业输出月度洞察:行业研究员、趋势预测专家与数据核查员并行推进,随后由内容审核官把关,最终产出月度洞察报告。",
|
||||||
|
cron_expr: "0 10 1 * *",
|
||||||
|
timezone: "Asia/Shanghai",
|
||||||
|
enabled: true,
|
||||||
|
seats: [seats.researcher, seats.forecaster, seats.verifier, seats.reviewer, seats.writer],
|
||||||
|
stages: [
|
||||||
|
[seats.researcher.agent_id, seats.forecaster.agent_id, seats.verifier.agent_id],
|
||||||
|
[seats.reviewer.agent_id],
|
||||||
|
[seats.writer.agent_id],
|
||||||
|
],
|
||||||
|
stage_goals: ["并行完成行业研究、趋势预测与数据核查", "审核结论口径与合规风险", "整合为月度洞察报告"],
|
||||||
|
created_at: hoursAgo(24 * 9),
|
||||||
|
updated_at: hoursAgo(24 * 9),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const runs: MatRunRecord[] = [
|
||||||
|
runSeed("mat-task-daily", 2.2, 3, null),
|
||||||
|
runSeed("mat-task-daily", 26, 3, null),
|
||||||
|
runSeed("mat-task-daily", 50, 3, null),
|
||||||
|
runSeed("mat-task-weekly", 24 * 3, 3, 2),
|
||||||
|
runSeed("mat-task-weekly", 24 * 10, 3, null),
|
||||||
|
];
|
||||||
|
return { tasks, runs };
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSeed(
|
||||||
|
taskId: string,
|
||||||
|
hoursAgoValue: number,
|
||||||
|
stageTotal: number,
|
||||||
|
failStage: number | null,
|
||||||
|
): MatRunRecord {
|
||||||
|
const started = hoursAgo(hoursAgoValue);
|
||||||
|
return {
|
||||||
|
id: uuid() as string,
|
||||||
|
task_id: taskId,
|
||||||
|
trigger: "schedule",
|
||||||
|
scheduled_for: started,
|
||||||
|
started_at: started,
|
||||||
|
duration_ms: PENDING_MS + stageTotal * STAGE_MS,
|
||||||
|
fail_stage: failStage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 由记录 + 当前时间推导运行视图:排队 → 按阶段推进 → 成功/失败。 */
|
||||||
|
function resolveRunView(run: MatRunRecord, stages: string[][], now = Date.now()): MatRunView {
|
||||||
|
const startedAt = new Date(run.started_at).getTime();
|
||||||
|
const elapsed = now - startedAt;
|
||||||
|
const stageTotal = Math.max(1, stages.length);
|
||||||
|
const duration = run.duration_ms || PENDING_MS + stageTotal * STAGE_MS;
|
||||||
|
|
||||||
|
const buildStates = (
|
||||||
|
status: MatRunStatus,
|
||||||
|
activeStage: number,
|
||||||
|
): Record<string, MatSeatRunState> => {
|
||||||
|
const states: Record<string, MatSeatRunState> = {};
|
||||||
|
stages.forEach((stage, index) => {
|
||||||
|
const seatState: MatSeatRunState =
|
||||||
|
status === "succeeded"
|
||||||
|
? "done"
|
||||||
|
: index < activeStage
|
||||||
|
? "done"
|
||||||
|
: index === activeStage
|
||||||
|
? status === "failed"
|
||||||
|
? "failed"
|
||||||
|
: "running"
|
||||||
|
: "waiting";
|
||||||
|
stage.forEach((agentId) => {
|
||||||
|
states[agentId] = seatState;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return states;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (elapsed < PENDING_MS) {
|
||||||
|
return {
|
||||||
|
...run,
|
||||||
|
finished_at: null,
|
||||||
|
status: "pending",
|
||||||
|
stage_index: 0,
|
||||||
|
stage_total: stageTotal,
|
||||||
|
seat_states: buildStates("pending", -1),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const failStage = typeof run.fail_stage === "number" ? run.fail_stage : null;
|
||||||
|
const progress = Math.min(elapsed - PENDING_MS, duration - PENDING_MS);
|
||||||
|
const activeStage = Math.min(Math.floor(progress / STAGE_MS), stageTotal - 1);
|
||||||
|
|
||||||
|
if (failStage !== null && activeStage >= failStage) {
|
||||||
|
const finishedAt = new Date(startedAt + PENDING_MS + (failStage + 1) * STAGE_MS).toISOString();
|
||||||
|
return {
|
||||||
|
...run,
|
||||||
|
finished_at: finishedAt,
|
||||||
|
status: "failed",
|
||||||
|
stage_index: failStage,
|
||||||
|
stage_total: stageTotal,
|
||||||
|
seat_states: buildStates("failed", failStage),
|
||||||
|
error: `阶段 ${failStage + 1} 执行失败:模拟的执行异常(前端演示数据)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elapsed >= duration) {
|
||||||
|
return {
|
||||||
|
...run,
|
||||||
|
finished_at: new Date(startedAt + duration).toISOString(),
|
||||||
|
status: "succeeded",
|
||||||
|
stage_index: stageTotal - 1,
|
||||||
|
stage_total: stageTotal,
|
||||||
|
seat_states: buildStates("succeeded", stageTotal),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...run,
|
||||||
|
finished_at: null,
|
||||||
|
status: "running",
|
||||||
|
stage_index: activeStage,
|
||||||
|
stage_total: stageTotal,
|
||||||
|
seat_states: buildStates("running", activeStage),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTaskViews(now = Date.now()): MatTaskView[] {
|
||||||
|
const store = readStore();
|
||||||
|
return store.tasks.map((task) => {
|
||||||
|
const stages = expandStages(task);
|
||||||
|
const runs = store.runs
|
||||||
|
.filter((run) => run.task_id === task.id)
|
||||||
|
.map((run) => resolveRunView(run, stages, now))
|
||||||
|
.sort((a, b) => (a.started_at < b.started_at ? 1 : -1));
|
||||||
|
const next = task.enabled ? nextCronDate(task.cron_expr, new Date(now)) : null;
|
||||||
|
return {
|
||||||
|
...task,
|
||||||
|
runs,
|
||||||
|
lastRun: runs[0] ?? null,
|
||||||
|
nextRunAt: next ? next.toISOString() : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function anyRunLive(tasks: MatTaskView[] | undefined): boolean {
|
||||||
|
return !!tasks?.some((task) =>
|
||||||
|
task.runs.some((run) => run.status === "pending" || run.status === "running"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMultiAgentTasks() {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["mat", "tasks"],
|
||||||
|
queryFn: () => buildTaskViews(),
|
||||||
|
refetchInterval: (q) => (anyRunLive(q.state.data as MatTaskView[] | undefined) ? 1_800 : false),
|
||||||
|
});
|
||||||
|
return { tasks: query.data ?? [], isLoading: query.isLoading, error: query.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMultiAgentTaskActions() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["mat", "tasks"] });
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: async (payload: MatTaskPayload) => {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const task: MatTask = { ...payload, id: uuid() as string, created_at: now, updated_at: now };
|
||||||
|
mutateStore((store) => ({ ...store, tasks: [task, ...store.tasks] }));
|
||||||
|
return task;
|
||||||
|
},
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const update = useMutation({
|
||||||
|
mutationFn: async ({ id, payload }: { id: string; payload: MatTaskPayload }) => {
|
||||||
|
mutateStore((store) => ({
|
||||||
|
...store,
|
||||||
|
tasks: store.tasks.map((task) =>
|
||||||
|
task.id === id ? { ...task, ...payload, updated_at: new Date().toISOString() } : task,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
mutateStore((store) => ({
|
||||||
|
tasks: store.tasks.filter((task) => task.id !== id),
|
||||||
|
runs: store.runs.filter((run) => run.task_id !== id),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const setEnabled = useMutation({
|
||||||
|
mutationFn: async ({ id, enabled }: { id: string; enabled: boolean }) => {
|
||||||
|
mutateStore((store) => ({
|
||||||
|
...store,
|
||||||
|
tasks: store.tasks.map((task) =>
|
||||||
|
task.id === id ? { ...task, enabled, updated_at: new Date().toISOString() } : task,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const trigger = useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
const store = readStore();
|
||||||
|
const task = store.tasks.find((item) => item.id === id);
|
||||||
|
if (!task) throw new Error("任务不存在");
|
||||||
|
const stageTotal = expandStages(task).length;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const run: MatRunRecord = {
|
||||||
|
id: uuid() as string,
|
||||||
|
task_id: id,
|
||||||
|
trigger: "manual",
|
||||||
|
scheduled_for: now,
|
||||||
|
started_at: now,
|
||||||
|
duration_ms: PENDING_MS + stageTotal * STAGE_MS,
|
||||||
|
fail_stage: null,
|
||||||
|
};
|
||||||
|
mutateStore((current) => ({ ...current, runs: [run, ...current.runs] }));
|
||||||
|
return run;
|
||||||
|
},
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeRun = useMutation({
|
||||||
|
mutationFn: async (runId: string) => {
|
||||||
|
mutateStore((store) => ({ ...store, runs: store.runs.filter((run) => run.id !== runId) }));
|
||||||
|
},
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { create, update, remove, setEnabled, trigger, removeRun };
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* 多智能体定时任务 —— 前端先行数据模型。
|
||||||
|
*
|
||||||
|
* 编排语义与业务链条保持一致:同一阶段(stage)内的多个智能体并行执行,
|
||||||
|
* 阶段之间串行执行;`stages: null` 表示纯串行线性链(每个智能体一层)。
|
||||||
|
* 当前后端尚未提供接口,数据先落 localStorage,接口就绪后只需替换 store 层。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MatRunStatus = "pending" | "running" | "succeeded" | "failed";
|
||||||
|
export type MatSeatRunState = "waiting" | "running" | "done" | "failed";
|
||||||
|
export type MatRunTrigger = "manual" | "schedule";
|
||||||
|
|
||||||
|
/** 编排中的一个智能体席位(落库时保存名称快照,展示优先用快照)。 */
|
||||||
|
export interface MatSeat {
|
||||||
|
agent_id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatTask {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
/** 任务指令:本次定时执行要让智能体编队完成什么。 */
|
||||||
|
prompt: string;
|
||||||
|
/** 总控(协调者)提示词:告诉总控如何编排各阶段的产出。 */
|
||||||
|
coordinator_prompt?: string;
|
||||||
|
cron_expr: string;
|
||||||
|
timezone: string;
|
||||||
|
enabled: boolean;
|
||||||
|
seats: MatSeat[];
|
||||||
|
/** `stages[i]` 内的智能体并行,i 递增串行;null = 线性串行。 */
|
||||||
|
stages: string[][] | null;
|
||||||
|
/** 与 stages 同下标对齐的每阶段目标。 */
|
||||||
|
stage_goals?: (string | null)[] | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 落库的运行记录;状态由 started_at + duration_ms 在读取时推导。 */
|
||||||
|
export interface MatRunRecord {
|
||||||
|
id: string;
|
||||||
|
task_id: string;
|
||||||
|
trigger: MatRunTrigger;
|
||||||
|
scheduled_for: string;
|
||||||
|
started_at: string;
|
||||||
|
duration_ms: number;
|
||||||
|
/** 模拟在该阶段(下标)失败;null = 成功。 */
|
||||||
|
fail_stage?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取时推导出的运行视图。 */
|
||||||
|
export interface MatRunView {
|
||||||
|
id: string;
|
||||||
|
task_id: string;
|
||||||
|
trigger: MatRunTrigger;
|
||||||
|
scheduled_for: string;
|
||||||
|
started_at: string;
|
||||||
|
finished_at: string | null;
|
||||||
|
status: MatRunStatus;
|
||||||
|
/** 当前执行到的阶段下标(从 0 开始)。 */
|
||||||
|
stage_index: number;
|
||||||
|
stage_total: number;
|
||||||
|
seat_states: Record<string, MatSeatRunState>;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatTaskView extends MatTask {
|
||||||
|
runs: MatRunView[];
|
||||||
|
lastRun: MatRunView | null;
|
||||||
|
nextRunAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑器内部使用的阶段草稿。 */
|
||||||
|
export interface MatStageGroup {
|
||||||
|
seats: MatSeat[];
|
||||||
|
goal: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑器保存时对外提交的载荷。 */
|
||||||
|
export interface MatTaskPayload {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
prompt: string;
|
||||||
|
coordinator_prompt?: string;
|
||||||
|
cron_expr: string;
|
||||||
|
timezone: string;
|
||||||
|
enabled: boolean;
|
||||||
|
seats: MatSeat[];
|
||||||
|
stages: string[][] | null;
|
||||||
|
stage_goals: (string | null)[] | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_SCHEDULE_CONFIG,
|
||||||
|
type ScheduleConfig,
|
||||||
|
parseCron,
|
||||||
|
} from "@/components/workspace/scheduled-tasks/schedule-builder";
|
||||||
|
|
||||||
|
import type { MatSeat, MatTask, MatTaskView } from "./types";
|
||||||
|
|
||||||
|
const WEEKDAY_LABELS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
||||||
|
|
||||||
|
function pad2(value: number) {
|
||||||
|
return String(value).padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把任务里的 stages 语义展开成显式阶段列表;null = 每个席位独立一层。 */
|
||||||
|
export function expandStages(task: Pick<MatTask, "seats" | "stages">): string[][] {
|
||||||
|
const known = new Set(task.seats.map((seat) => seat.agent_id));
|
||||||
|
if (!task.stages?.length) {
|
||||||
|
return task.seats.map((seat) => [seat.agent_id]);
|
||||||
|
}
|
||||||
|
const expanded = task.stages
|
||||||
|
.map((stage) => stage.filter((id) => known.has(id)))
|
||||||
|
.filter((stage) => stage.length > 0);
|
||||||
|
return expanded.length ? expanded : task.seats.map((seat) => [seat.agent_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeScheduleConfig(cfg: ScheduleConfig): string {
|
||||||
|
const clock = `${pad2(cfg.hour)}:${pad2(cfg.minute)}`;
|
||||||
|
switch (cfg.freq) {
|
||||||
|
case "hourly":
|
||||||
|
return `每小时 ${cfg.minute} 分`;
|
||||||
|
case "weekly":
|
||||||
|
return `每${WEEKDAY_LABELS[cfg.weekday] ?? "周一"} ${clock}`;
|
||||||
|
case "monthly":
|
||||||
|
return `每月 ${cfg.date} 日 ${clock}`;
|
||||||
|
default:
|
||||||
|
return `每天 ${clock}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 由 cron 反推人类可读的执行频率描述(覆盖本页生成的所有 cron 形态)。 */
|
||||||
|
export function describeCron(cron: string | null | undefined): string {
|
||||||
|
const cfg = parseCron(cron ?? undefined);
|
||||||
|
if (!cfg) return cron || "未设置";
|
||||||
|
return describeScheduleConfig(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计算下一次执行时间;仅支持本页 schedule picker 生成的 5 段 cron 形态。 */
|
||||||
|
export function nextCronDate(cron: string | null | undefined, from = new Date()): Date | null {
|
||||||
|
const cfg = parseCron(cron ?? undefined);
|
||||||
|
if (!cfg) return null;
|
||||||
|
const candidate = new Date(from);
|
||||||
|
candidate.setSeconds(0, 0);
|
||||||
|
switch (cfg.freq) {
|
||||||
|
case "hourly": {
|
||||||
|
candidate.setMinutes(cfg.minute);
|
||||||
|
if (candidate <= from) candidate.setHours(candidate.getHours() + 1);
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
case "daily": {
|
||||||
|
candidate.setHours(cfg.hour, cfg.minute, 0, 0);
|
||||||
|
if (candidate <= from) candidate.setDate(candidate.getDate() + 1);
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
case "weekly": {
|
||||||
|
candidate.setHours(cfg.hour, cfg.minute, 0, 0);
|
||||||
|
const day = candidate.getDay();
|
||||||
|
let delta = (cfg.weekday - day + 7) % 7;
|
||||||
|
if (delta === 0 && candidate <= from) delta = 7;
|
||||||
|
candidate.setDate(candidate.getDate() + delta);
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
case "monthly": {
|
||||||
|
candidate.setHours(cfg.hour, cfg.minute, 0, 0);
|
||||||
|
candidate.setDate(cfg.date);
|
||||||
|
if (candidate <= from) candidate.setMonth(candidate.getMonth() + 1);
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(value: string | null | undefined): string {
|
||||||
|
if (!value) return "—";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return "—";
|
||||||
|
return new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRelative(value: string | null | undefined): string {
|
||||||
|
if (!value) return "—";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return "—";
|
||||||
|
const elapsed = Date.now() - date.getTime();
|
||||||
|
if (elapsed < 60_000) return "刚刚";
|
||||||
|
if (elapsed < 3_600_000) return `${Math.floor(elapsed / 60_000)} 分钟前`;
|
||||||
|
if (elapsed < 86_400_000) return `${Math.floor(elapsed / 3_600_000)} 小时前`;
|
||||||
|
if (elapsed < 7 * 86_400_000) return `${Math.floor(elapsed / 86_400_000)} 天前`;
|
||||||
|
return formatDateTime(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(ms: number): string {
|
||||||
|
if (!Number.isFinite(ms) || ms <= 0) return "—";
|
||||||
|
const seconds = Math.round(ms / 1000);
|
||||||
|
if (seconds < 60) return `${seconds} 秒`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const rest = seconds % 60;
|
||||||
|
return rest ? `${minutes} 分 ${rest} 秒` : `${minutes} 分钟`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runStatusLabel(status: string | null | undefined): string {
|
||||||
|
switch (status) {
|
||||||
|
case "succeeded":
|
||||||
|
return "执行成功";
|
||||||
|
case "failed":
|
||||||
|
return "执行失败";
|
||||||
|
case "running":
|
||||||
|
return "执行中";
|
||||||
|
case "pending":
|
||||||
|
return "排队中";
|
||||||
|
default:
|
||||||
|
return "暂无执行";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MatTaskBadge = {
|
||||||
|
label: string;
|
||||||
|
theme: "success" | "warning" | "danger" | "default";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 任务卡右上角的状态徽标:以最近一次执行状态优先,其次启停。 */
|
||||||
|
export function taskBadgeOf(task: MatTaskView): MatTaskBadge {
|
||||||
|
const status = task.lastRun?.status;
|
||||||
|
if (status === "running" || status === "pending") {
|
||||||
|
return { label: "执行中", theme: "warning" };
|
||||||
|
}
|
||||||
|
if (!task.enabled) return { label: "已暂停", theme: "default" };
|
||||||
|
if (status === "failed") return { label: "上次失败", theme: "danger" };
|
||||||
|
if (status === "succeeded") return { label: "运行正常", theme: "success" };
|
||||||
|
return { label: "已启用", theme: "success" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 席位配色槽位:按 agent_id 稳定散列到 0..7。 */
|
||||||
|
export function seatColorIndex(agentId: string): number {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < agentId.length; i += 1) {
|
||||||
|
hash = (hash * 31 + agentId.charCodeAt(i)) >>> 0;
|
||||||
|
}
|
||||||
|
return hash % 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seatIndexOf(seats: MatSeat[], agentId: string): number {
|
||||||
|
const index = seats.findIndex((seat) => seat.agent_id === agentId);
|
||||||
|
return index >= 0 ? index : seats.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultScheduleConfig(): ScheduleConfig {
|
||||||
|
return { ...DEFAULT_SCHEDULE_CONFIG };
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
/**
|
||||||
|
* 任务分析 · 详情页:左侧执行记录(定时 / 手动),右侧按选中运行回放流程图
|
||||||
|
* (业务链风格节点状态 + 耗时,5 秒自动刷新),点击智能体节点打开执行过程抽屉。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
CalendarClock,
|
||||||
|
CircleAlert,
|
||||||
|
GitBranch,
|
||||||
|
MessageSquareText,
|
||||||
|
Pencil,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
RefreshCw,
|
||||||
|
Zap,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Button, Switch, Tag } from "tdesign-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { OFFICIAL_DEERFLOW_ROUTE_PREFIX } from "@/core/official-deerflow/routes";
|
||||||
|
import {
|
||||||
|
formatDateTime,
|
||||||
|
formatDuration,
|
||||||
|
formatRelative,
|
||||||
|
} from "@/pages/official-multi-agent-tasks/utils";
|
||||||
|
|
||||||
|
import BindingDialog from "./components/BindingDialog";
|
||||||
|
import SeatProcessDrawer from "./components/SeatProcessDrawer";
|
||||||
|
import { ChainFlowDiagram } from "@/roundtable-planning/components/ChainFlowDiagram";
|
||||||
|
import { taskBadgeOf } from "./badge";
|
||||||
|
import { useTaTaskActions, useTaTasks, useTaTaskRuns } from "./store";
|
||||||
|
|
||||||
|
const RUN_STATUS_TEXT: Record<string, string> = {
|
||||||
|
succeeded: "成功",
|
||||||
|
failed: "失败",
|
||||||
|
running: "执行中",
|
||||||
|
pending: "排队中",
|
||||||
|
};
|
||||||
|
|
||||||
|
const RUN_STATUS_THEME: Record<string, "success" | "danger" | "warning" | "default"> = {
|
||||||
|
succeeded: "success",
|
||||||
|
failed: "danger",
|
||||||
|
running: "warning",
|
||||||
|
pending: "default",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TaskAnalysisDetailPage({
|
||||||
|
routeTaskId,
|
||||||
|
}: {
|
||||||
|
routeTaskId: string | null;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { views, isLoading } = useTaTasks();
|
||||||
|
const actions = useTaTaskActions();
|
||||||
|
const taskView = views.find(({ task }) => task.id === routeTaskId) ?? null;
|
||||||
|
const task = taskView?.task ?? null;
|
||||||
|
const { runs, isLoading: runsLoading } = useTaTaskRuns(task?.id ?? null);
|
||||||
|
|
||||||
|
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
|
||||||
|
const [seatAgentId, setSeatAgentId] = useState<string | null>(null);
|
||||||
|
const [bindingVisible, setBindingVisible] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
// 默认选中最近一次运行;新触发的运行自动跟随。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!runs.length) return;
|
||||||
|
if (!selectedRunId || !runs.some((run) => run.id === selectedRunId)) {
|
||||||
|
setSelectedRunId(runs[0]!.id);
|
||||||
|
}
|
||||||
|
}, [runs, selectedRunId]);
|
||||||
|
|
||||||
|
const selectedRun = useMemo(
|
||||||
|
() => runs.find((run) => run.id === selectedRunId) ?? runs[0] ?? null,
|
||||||
|
[runs, selectedRunId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const backToList = () => {
|
||||||
|
navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/task-analysis`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runTrigger = async (manual: boolean) => {
|
||||||
|
if (!task) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
if (manual) {
|
||||||
|
await actions.triggerManualRun.mutateAsync(task.id);
|
||||||
|
toast.success("已发起手动执行(总控问答)");
|
||||||
|
} else {
|
||||||
|
await actions.triggerRun.mutateAsync(task.id);
|
||||||
|
toast.success("已触发定时执行");
|
||||||
|
}
|
||||||
|
setSelectedRunId(null);
|
||||||
|
} catch {
|
||||||
|
toast.error("任务未绑定分析方案");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runToggle = async () => {
|
||||||
|
if (!task?.binding) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await actions.setEnabled.mutateAsync({
|
||||||
|
taskId: task.id,
|
||||||
|
enabled: !task.binding?.enabled,
|
||||||
|
});
|
||||||
|
toast.success(task.binding?.enabled ? "已暂停" : "已恢复");
|
||||||
|
} catch {
|
||||||
|
toast.error("操作失败,请重试");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isLoading && !task) {
|
||||||
|
return (
|
||||||
|
<section className="ta-page">
|
||||||
|
<div className="ta-state ta-state--empty">
|
||||||
|
<h3>任务不存在或已被删除</h3>
|
||||||
|
<Button theme="primary" onClick={backToList}>
|
||||||
|
返回任务列表
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const binding = task?.binding ?? null;
|
||||||
|
const taskBadge = task && taskView ? taskBadgeOf(task, taskView.runs) : null;
|
||||||
|
const live = selectedRun?.status === "running" || selectedRun?.status === "pending";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="ta-page ta-detail-page">
|
||||||
|
<header className="ta-detail-head">
|
||||||
|
<button
|
||||||
|
aria-label="返回任务列表"
|
||||||
|
className="ta-back"
|
||||||
|
onClick={backToList}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</button>
|
||||||
|
<div className="ta-detail-heading">
|
||||||
|
<h1>
|
||||||
|
{task?.name ?? "…"}
|
||||||
|
<em>{task?.sourceTaskId}</em>
|
||||||
|
</h1>
|
||||||
|
<p>
|
||||||
|
{binding ? (
|
||||||
|
<>
|
||||||
|
<GitBranch aria-hidden="true" size={13} />
|
||||||
|
{binding.chainTitle} · {binding.cronText}
|
||||||
|
{binding.directionNotes.length > 0 &&
|
||||||
|
` · ${binding.directionNotes.length} 条方向备注`}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
task?.description
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{taskBadge && (
|
||||||
|
<Tag theme={taskBadge.theme} variant="light">
|
||||||
|
{taskBadge.label}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
{binding && (
|
||||||
|
<Switch value={binding.enabled} onChange={() => void runToggle()} />
|
||||||
|
)}
|
||||||
|
<div className="ta-detail-ops">
|
||||||
|
{binding && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
disabled={busy}
|
||||||
|
loading={busy}
|
||||||
|
theme="primary"
|
||||||
|
onClick={() => void runTrigger(false)}
|
||||||
|
>
|
||||||
|
<Zap aria-hidden="true" size={15} />
|
||||||
|
立即执行
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" disabled={busy} onClick={() => void runTrigger(true)}>
|
||||||
|
<MessageSquareText aria-hidden="true" size={15} />
|
||||||
|
模拟手动执行
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" onClick={() => setBindingVisible(true)}>
|
||||||
|
<Pencil aria-hidden="true" size={15} />
|
||||||
|
{binding ? "编辑绑定" : "配置分析"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{!binding ? (
|
||||||
|
<div className="ta-detail-empty">
|
||||||
|
<GitBranch aria-hidden="true" size={26} />
|
||||||
|
<h3>尚未配置分析方案</h3>
|
||||||
|
<p>绑定业务链与内容块映射后,这里将展示执行记录与各智能体的执行流程。</p>
|
||||||
|
<Button theme="primary" onClick={() => setBindingVisible(true)}>
|
||||||
|
配置分析
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="ta-detail-layout">
|
||||||
|
<aside className="ta-runs">
|
||||||
|
<header className="ta-runs-head">
|
||||||
|
<h2>执行记录</h2>
|
||||||
|
<span>
|
||||||
|
<RefreshCw aria-hidden="true" className={live ? "ta-spin" : undefined} size={12} />
|
||||||
|
每 5 秒刷新
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div className="ta-runs-list">
|
||||||
|
{runsLoading && !runs.length ? (
|
||||||
|
<p className="ta-runs-empty">正在加载执行记录…</p>
|
||||||
|
) : runs.length ? (
|
||||||
|
runs.map((run) => {
|
||||||
|
const active = selectedRun?.id === run.id;
|
||||||
|
const duration = run.finishedAt
|
||||||
|
? formatDuration(
|
||||||
|
new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime(),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`ta-run-item${active ? " is-active" : ""}`}
|
||||||
|
key={run.id}
|
||||||
|
onClick={() => setSelectedRunId(run.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="ta-run-item-top">
|
||||||
|
{run.trigger === "manual" ? (
|
||||||
|
<MessageSquareText aria-hidden="true" size={13} />
|
||||||
|
) : (
|
||||||
|
<CalendarClock aria-hidden="true" size={13} />
|
||||||
|
)}
|
||||||
|
<b>{run.trigger === "manual" ? "手动执行" : "定时执行"}</b>
|
||||||
|
<Tag size="small" theme={RUN_STATUS_THEME[run.status] ?? "default"} variant="light">
|
||||||
|
{RUN_STATUS_TEXT[run.status] ?? run.status}
|
||||||
|
</Tag>
|
||||||
|
</span>
|
||||||
|
<span className="ta-run-item-time">
|
||||||
|
{formatDateTime(run.startedAt)} · {duration ?? "进行中"}
|
||||||
|
</span>
|
||||||
|
{run.trigger === "manual" && run.manualSummary && (
|
||||||
|
<span className="ta-run-item-summary">{run.manualSummary}</span>
|
||||||
|
)}
|
||||||
|
{run.trigger === "schedule" && (
|
||||||
|
<span className="ta-run-item-blocks">
|
||||||
|
内容块 {run.blockStates.filter((b) => b.state === "done").length}/
|
||||||
|
{run.blockStates.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<p className="ta-runs-empty">还没有执行记录,点「立即执行」体验完整流程。</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="ta-flow-panel">
|
||||||
|
{selectedRun ? (
|
||||||
|
<>
|
||||||
|
<header className="ta-flow-head">
|
||||||
|
<h2>
|
||||||
|
执行流程
|
||||||
|
<small>
|
||||||
|
{selectedRun.trigger === "manual" ? "手动执行" : "定时执行"} ·{" "}
|
||||||
|
{formatRelative(selectedRun.startedAt)}
|
||||||
|
{selectedRun.status === "running" &&
|
||||||
|
` · 阶段 ${Math.min(selectedRun.stageIndex + 1, selectedRun.stageTotal)}/${selectedRun.stageTotal} 执行中`}
|
||||||
|
</small>
|
||||||
|
</h2>
|
||||||
|
<span className="ta-flow-legend">
|
||||||
|
<i className="ta-dot is-done" /> 完成
|
||||||
|
<i className="ta-dot is-running" /> 执行中
|
||||||
|
<i className="ta-dot is-waiting" /> 等待
|
||||||
|
<i className="ta-dot is-failed" /> 失败
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div className="ta-flow-canvas">
|
||||||
|
<ChainFlowDiagram
|
||||||
|
className="ta-flow-diagram"
|
||||||
|
onNodeClick={(agentId) => setSeatAgentId(agentId)}
|
||||||
|
seatStates={selectedRun.seatInfos}
|
||||||
|
stages={selectedRun.stages.map((stage, index) => ({
|
||||||
|
seats: stage.map((agentId) => ({
|
||||||
|
agent_id: agentId,
|
||||||
|
name: selectedRun.seatNames[agentId] ?? agentId,
|
||||||
|
})),
|
||||||
|
goal: selectedRun.stageGoals?.[index] ?? null,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{selectedRun.error && (
|
||||||
|
<p className="ta-flow-error">
|
||||||
|
<CircleAlert aria-hidden="true" size={14} />
|
||||||
|
{selectedRun.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="ta-flow-panel-empty">选择左侧一条执行记录查看流程</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SeatProcessDrawer
|
||||||
|
onClose={() => setSeatAgentId(null)}
|
||||||
|
run={selectedRun}
|
||||||
|
seatAgentId={seatAgentId}
|
||||||
|
task={task ?? undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BindingDialog
|
||||||
|
onClose={() => setBindingVisible(false)}
|
||||||
|
onSubmit={async (payload) => {
|
||||||
|
if (!task) return;
|
||||||
|
try {
|
||||||
|
await actions.bind.mutateAsync({ taskId: task.id, payload });
|
||||||
|
toast.success(binding ? "绑定已更新" : "分析方案已绑定");
|
||||||
|
setBindingVisible(false);
|
||||||
|
} catch {
|
||||||
|
toast.error("保存失败,请重试");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
submitting={actions.bind.isPending}
|
||||||
|
task={task}
|
||||||
|
visible={bindingVisible}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
/**
|
||||||
|
* 任务分析 · 列表页:对方系统推送过来的外部任务 + 我们的绑定状态。
|
||||||
|
* 任务卡片沿用多智能体定时任务的卡片视觉(浅红规范)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
CalendarClock,
|
||||||
|
CircleAlert,
|
||||||
|
CircleCheck,
|
||||||
|
Clock3,
|
||||||
|
GitBranch,
|
||||||
|
Link2,
|
||||||
|
Network,
|
||||||
|
Pencil,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
Zap,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DialogPlugin,
|
||||||
|
Input,
|
||||||
|
Popconfirm,
|
||||||
|
Switch,
|
||||||
|
Tabs,
|
||||||
|
Tag,
|
||||||
|
} from "tdesign-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { OFFICIAL_DEERFLOW_ROUTE_PREFIX } from "@/core/official-deerflow/routes";
|
||||||
|
|
||||||
|
import AgentFlow from "@/pages/official-multi-agent-tasks/components/AgentFlow";
|
||||||
|
import type { MatSeat } from "@/pages/official-multi-agent-tasks/types";
|
||||||
|
import {
|
||||||
|
formatDateTime,
|
||||||
|
formatRelative,
|
||||||
|
} from "@/pages/official-multi-agent-tasks/utils";
|
||||||
|
|
||||||
|
import BindingDialog from "./components/BindingDialog";
|
||||||
|
import CreateTaskDialog, {
|
||||||
|
type TaCreateTaskPayload,
|
||||||
|
} from "./components/CreateTaskDialog";
|
||||||
|
import { chainStagesOf, useTaTaskActions, useTaTasks } from "./store";
|
||||||
|
import type { TaTask, TaTaskViewLike } from "./page-types";
|
||||||
|
import { taskBadgeOf } from "./badge";
|
||||||
|
|
||||||
|
import "./task-analysis.css";
|
||||||
|
|
||||||
|
type StatusFilter = "all" | "unbound" | "enabled" | "paused" | "failed";
|
||||||
|
|
||||||
|
function isTaskLive(runs: TaTaskViewLike["runs"]) {
|
||||||
|
return runs.some((run) => run.status === "running" || run.status === "pending");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TaskAnalysisPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { views, isLoading } = useTaTasks();
|
||||||
|
const actions = useTaTaskActions();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [filter, setFilter] = useState<StatusFilter>("all");
|
||||||
|
const [bindingTask, setBindingTask] = useState<TaTask | null>(null);
|
||||||
|
const [bindingVisible, setBindingVisible] = useState(false);
|
||||||
|
const [createVisible, setCreateVisible] = useState(false);
|
||||||
|
const [busyTaskId, setBusyTaskId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const visibleViews = useMemo(() => {
|
||||||
|
const keyword = search.trim().toLowerCase();
|
||||||
|
return views.filter(({ task, runs }) => {
|
||||||
|
const lastStatus = runs[0]?.status ?? null;
|
||||||
|
const bound = !!task.binding;
|
||||||
|
const live = isTaskLive(runs);
|
||||||
|
if (filter === "unbound" && bound) return false;
|
||||||
|
if (filter === "enabled" && (!bound || !task.binding!.enabled || live)) return false;
|
||||||
|
if (filter === "paused" && (!bound || task.binding!.enabled)) return false;
|
||||||
|
if (filter === "failed" && lastStatus !== "failed") return false;
|
||||||
|
if (!keyword) return true;
|
||||||
|
return (
|
||||||
|
task.name.toLowerCase().includes(keyword) ||
|
||||||
|
task.description.toLowerCase().includes(keyword) ||
|
||||||
|
task.sourceTaskId.toLowerCase().includes(keyword)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [views, search, filter]);
|
||||||
|
|
||||||
|
const stats = useMemo(
|
||||||
|
() => ({
|
||||||
|
total: views.length,
|
||||||
|
unbound: views.filter(({ task }) => !task.binding).length,
|
||||||
|
enabled: views.filter(({ task }) => task.binding?.enabled).length,
|
||||||
|
failed: views.filter(({ runs }) => runs[0]?.status === "failed").length,
|
||||||
|
}),
|
||||||
|
[views],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openDetail = (taskId: string) => {
|
||||||
|
navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/task-analysis/${encodeURIComponent(taskId)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openBinding = (task: TaTask) => {
|
||||||
|
setBindingTask(task);
|
||||||
|
setBindingVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async (payload: TaCreateTaskPayload) => {
|
||||||
|
try {
|
||||||
|
const created = await actions.createTask.mutateAsync({
|
||||||
|
name: payload.name,
|
||||||
|
sourceTaskId: payload.sourceTaskId,
|
||||||
|
description: payload.description,
|
||||||
|
});
|
||||||
|
await actions.bind.mutateAsync({ taskId: created.id, payload: payload.binding });
|
||||||
|
toast.success("任务已创建并绑定分析方案");
|
||||||
|
setCreateVisible(false);
|
||||||
|
} catch {
|
||||||
|
toast.error("创建失败,请重试");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBind = async (payload: Parameters<typeof actions.bind.mutateAsync>[0]["payload"]) => {
|
||||||
|
if (!bindingTask) return;
|
||||||
|
try {
|
||||||
|
await actions.bind.mutateAsync({ taskId: bindingTask.id, payload });
|
||||||
|
toast.success(bindingTask.binding ? "绑定已更新" : "分析方案已绑定");
|
||||||
|
setBindingVisible(false);
|
||||||
|
setBindingTask(null);
|
||||||
|
} catch {
|
||||||
|
toast.error("保存失败,请重试");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runTrigger = async (taskId: string) => {
|
||||||
|
setBusyTaskId(taskId);
|
||||||
|
try {
|
||||||
|
await actions.triggerRun.mutateAsync(taskId);
|
||||||
|
toast.success("已触发执行");
|
||||||
|
openDetail(taskId);
|
||||||
|
} catch {
|
||||||
|
toast.error("触发失败:任务未绑定分析方案");
|
||||||
|
} finally {
|
||||||
|
setBusyTaskId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runToggle = async (task: TaTask) => {
|
||||||
|
setBusyTaskId(task.id);
|
||||||
|
try {
|
||||||
|
await actions.setEnabled.mutateAsync({ taskId: task.id, enabled: !task.binding?.enabled });
|
||||||
|
toast.success(task.binding?.enabled ? "已暂停" : "已恢复");
|
||||||
|
} catch {
|
||||||
|
toast.error("操作失败,请重试");
|
||||||
|
} finally {
|
||||||
|
setBusyTaskId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = (task: TaTask) => {
|
||||||
|
const dialog = DialogPlugin.confirm({
|
||||||
|
header: "删除外部任务",
|
||||||
|
body: `删除「${task.name}」将同时清除其绑定与全部执行记录(对应对外删除接口语义),确定删除?`,
|
||||||
|
confirmBtn: { content: "删除", theme: "danger" },
|
||||||
|
cancelBtn: "取消",
|
||||||
|
onConfirm: () => {
|
||||||
|
dialog.destroy();
|
||||||
|
void actions.removeTask.mutateAsync(task.id).then(() => {
|
||||||
|
toast.success("任务已删除");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onClose: () => dialog.destroy(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="ta-page">
|
||||||
|
<header className="ta-page-head">
|
||||||
|
<div className="ta-page-head-copy">
|
||||||
|
<h1>任务分析定时</h1>
|
||||||
|
<p>
|
||||||
|
外部系统任务接入多智能体定时分析:绑定业务链与内容块映射,按计划执行并沉淀时间线版本。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="ta-page-head-actions">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const unbound = views.find(({ task }) => !task.binding)?.task;
|
||||||
|
if (unbound) openBinding(unbound);
|
||||||
|
else toast.info("当前任务均已绑定分析方案");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
配置未绑定任务
|
||||||
|
</Button>
|
||||||
|
<Button theme="primary" onClick={() => setCreateVisible(true)}>
|
||||||
|
<Plus aria-hidden="true" size={15} />
|
||||||
|
新建任务
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="ta-stats" aria-label="任务统计">
|
||||||
|
<div className="ta-stat">
|
||||||
|
<span className="ta-stat-icon is-primary" aria-hidden="true">
|
||||||
|
<Network size={18} />
|
||||||
|
</span>
|
||||||
|
<div className="ta-stat-copy">
|
||||||
|
<strong>{stats.total}</strong>
|
||||||
|
<span>外部任务</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ta-stat">
|
||||||
|
<span className="ta-stat-icon is-warning" aria-hidden="true">
|
||||||
|
<Link2 size={18} />
|
||||||
|
</span>
|
||||||
|
<div className="ta-stat-copy">
|
||||||
|
<strong>{stats.unbound}</strong>
|
||||||
|
<span>未配置</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ta-stat">
|
||||||
|
<span className="ta-stat-icon is-success" aria-hidden="true">
|
||||||
|
<CircleCheck size={18} />
|
||||||
|
</span>
|
||||||
|
<div className="ta-stat-copy">
|
||||||
|
<strong>{stats.enabled}</strong>
|
||||||
|
<span>已启用</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ta-stat">
|
||||||
|
<span className="ta-stat-icon is-danger" aria-hidden="true">
|
||||||
|
<CircleAlert size={18} />
|
||||||
|
</span>
|
||||||
|
<div className="ta-stat-copy">
|
||||||
|
<strong>{stats.failed}</strong>
|
||||||
|
<span>最近失败</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ta-body">
|
||||||
|
<div className="ta-toolbar">
|
||||||
|
<div className="ta-toolbar-search">
|
||||||
|
<Input
|
||||||
|
clearable
|
||||||
|
placeholder="搜索任务名称 / 简介 / 对方任务号"
|
||||||
|
prefixIcon={<Search aria-hidden="true" size={15} />}
|
||||||
|
value={search}
|
||||||
|
onChange={(value) => setSearch(String(value ?? ""))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Tabs
|
||||||
|
className="ta-toolbar-tabs"
|
||||||
|
value={filter}
|
||||||
|
onChange={(value) => setFilter(value as StatusFilter)}
|
||||||
|
>
|
||||||
|
<Tabs.TabPanel label="全部" value="all" />
|
||||||
|
<Tabs.TabPanel label="未配置" value="unbound" />
|
||||||
|
<Tabs.TabPanel label="已启用" value="enabled" />
|
||||||
|
<Tabs.TabPanel label="已暂停" value="paused" />
|
||||||
|
<Tabs.TabPanel label="最近失败" value="failed" />
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="ta-state">
|
||||||
|
<Clock3 aria-hidden="true" className="ta-spin" size={18} />
|
||||||
|
正在加载外部任务…
|
||||||
|
</div>
|
||||||
|
) : visibleViews.length ? (
|
||||||
|
<div className="ta-grid">
|
||||||
|
{visibleViews.map(({ task, runs }) => {
|
||||||
|
const badge = taskBadgeOf(task, runs);
|
||||||
|
const binding = task.binding;
|
||||||
|
const lastRun = runs[0] ?? null;
|
||||||
|
const busy = busyTaskId === task.id;
|
||||||
|
const stages = binding ? chainStagesOf(binding) : [];
|
||||||
|
const seats: MatSeat[] = stages
|
||||||
|
.flat()
|
||||||
|
.filter((id, index, all) => all.indexOf(id) === index)
|
||||||
|
.map((id) => ({
|
||||||
|
agent_id: id,
|
||||||
|
name: lastRun?.seatNames[id] ?? id,
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<article className="ta-card" key={task.id}>
|
||||||
|
<header className="ta-card-head" onClick={() => openDetail(task.id)}>
|
||||||
|
<span className="ta-card-glyph" aria-hidden="true">
|
||||||
|
<Network size={17} />
|
||||||
|
</span>
|
||||||
|
<div className="ta-card-title">
|
||||||
|
<h3>
|
||||||
|
{task.name}
|
||||||
|
<em>{task.sourceTaskId}</em>
|
||||||
|
</h3>
|
||||||
|
<p>{task.description}</p>
|
||||||
|
</div>
|
||||||
|
<Tag size="small" theme={badge.theme} variant="light">
|
||||||
|
{badge.label}
|
||||||
|
</Tag>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{binding ? (
|
||||||
|
<>
|
||||||
|
<div className="ta-card-binding" onClick={() => openDetail(task.id)}>
|
||||||
|
<span>
|
||||||
|
<GitBranch aria-hidden="true" size={13} />
|
||||||
|
{binding.chainTitle}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<CalendarClock aria-hidden="true" size={13} />
|
||||||
|
{binding.cronText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="ta-card-flow" onClick={() => openDetail(task.id)}>
|
||||||
|
<AgentFlow seats={seats} size="sm" stages={stages} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button className="ta-card-unbound" onClick={() => openBinding(task)} type="button">
|
||||||
|
<GitBranch aria-hidden="true" size={15} />
|
||||||
|
配置分析:选择业务链 · 内容块映射 · 执行频率
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<footer className="ta-card-foot">
|
||||||
|
<div className="ta-card-meta">
|
||||||
|
<span>
|
||||||
|
下次{" "}
|
||||||
|
{binding?.enabled
|
||||||
|
? formatDateTime(nextRunAt(binding.cron))
|
||||||
|
: binding
|
||||||
|
? "已暂停"
|
||||||
|
: "未配置"}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
上次 {lastRun ? formatRelative(lastRun.startedAt) : "未执行"}
|
||||||
|
{lastRun && lastRun.status !== "pending" && (
|
||||||
|
<em className={`ta-run-text is-${lastRun.status}`}>
|
||||||
|
· {lastRun.status === "succeeded" ? "成功" : lastRun.status === "failed" ? "失败" : "执行中"}
|
||||||
|
</em>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="ta-card-ops">
|
||||||
|
{binding && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
disabled={busy}
|
||||||
|
loading={busy}
|
||||||
|
size="small"
|
||||||
|
theme="primary"
|
||||||
|
variant="text"
|
||||||
|
onClick={() => void runTrigger(task.id)}
|
||||||
|
>
|
||||||
|
<Zap aria-hidden="true" size={14} />
|
||||||
|
立即执行
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={busy}
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
onClick={() => void runToggle(task)}
|
||||||
|
>
|
||||||
|
{binding.enabled ? (
|
||||||
|
<Pause aria-hidden="true" size={14} />
|
||||||
|
) : (
|
||||||
|
<Play aria-hidden="true" size={14} />
|
||||||
|
)}
|
||||||
|
{binding.enabled ? "暂停" : "恢复"}
|
||||||
|
</Button>
|
||||||
|
<Button size="small" variant="text" onClick={() => openBinding(task)}>
|
||||||
|
<Pencil aria-hidden="true" size={14} />
|
||||||
|
编辑绑定
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Popconfirm
|
||||||
|
content="删除后绑定与执行记录一并清除,确定?"
|
||||||
|
placement="bottom-right"
|
||||||
|
theme="danger"
|
||||||
|
onConfirm={() => confirmDelete(task)}
|
||||||
|
>
|
||||||
|
<Button className="ta-danger-btn" size="small" variant="text">
|
||||||
|
<Trash2 aria-hidden="true" size={14} />
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="ta-state ta-state--empty">
|
||||||
|
<span className="ta-state-glyph" aria-hidden="true">
|
||||||
|
<Network size={22} />
|
||||||
|
</span>
|
||||||
|
<h3>{views.length ? "没有符合条件的任务" : "还没有外部任务"}</h3>
|
||||||
|
<p>
|
||||||
|
{views.length
|
||||||
|
? "换个关键词或筛选条件试试。"
|
||||||
|
: "对方系统创建任务并调用创建接口后,任务会出现在这里。"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="ta-footnote">
|
||||||
|
<CircleAlert aria-hidden="true" size={13} />
|
||||||
|
前端演示阶段:任务与执行数据保存在本浏览器,业务链来自真实接口;后端服务接入后自动切换。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CreateTaskDialog
|
||||||
|
onClose={() => setCreateVisible(false)}
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
submitting={actions.createTask.isPending}
|
||||||
|
visible={createVisible}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BindingDialog
|
||||||
|
onClose={() => {
|
||||||
|
setBindingVisible(false);
|
||||||
|
setBindingTask(null);
|
||||||
|
}}
|
||||||
|
onSubmit={handleBind}
|
||||||
|
submitting={actions.bind.isPending}
|
||||||
|
task={bindingTask}
|
||||||
|
visible={bindingVisible}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 简易下次执行时间(覆盖本页生成的 cron 形态)。 */
|
||||||
|
function nextRunAt(cron: string): string | null {
|
||||||
|
const parts = cron.trim().split(/\s+/);
|
||||||
|
if (parts.length !== 5) return null;
|
||||||
|
const [min, hour, , , wd] = parts;
|
||||||
|
const now = new Date();
|
||||||
|
const candidate = new Date(now);
|
||||||
|
const minute = parseInt(min ?? "0", 10);
|
||||||
|
const hourNum = parseInt(hour ?? "9", 10);
|
||||||
|
if (`${hour}` === "*") {
|
||||||
|
candidate.setMinutes(minute, 0, 0);
|
||||||
|
if (candidate <= now) candidate.setHours(candidate.getHours() + 1);
|
||||||
|
} else if (`${wd}` !== "*") {
|
||||||
|
const weekday = parseInt(wd ?? "1", 10);
|
||||||
|
candidate.setHours(hourNum, minute, 0, 0);
|
||||||
|
let delta = (weekday - candidate.getDay() + 7) % 7;
|
||||||
|
if (delta === 0 && candidate <= now) delta = 7;
|
||||||
|
candidate.setDate(candidate.getDate() + delta);
|
||||||
|
} else {
|
||||||
|
candidate.setHours(hourNum, minute, 0, 0);
|
||||||
|
if (candidate <= now) candidate.setDate(candidate.getDate() + 1);
|
||||||
|
}
|
||||||
|
return candidate.toISOString();
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { TaRunView, TaTask } from "./types";
|
||||||
|
|
||||||
|
export interface TaBadge {
|
||||||
|
label: string;
|
||||||
|
theme: "success" | "warning" | "danger" | "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任务卡右上角状态徽标:最近一次执行优先,其次启停,未绑定给引导态。 */
|
||||||
|
export function taskBadgeOf(task: TaTask, runs: TaRunView[]): TaBadge {
|
||||||
|
const status = runs[0]?.status;
|
||||||
|
if (status === "running" || status === "pending") {
|
||||||
|
return { label: "执行中", theme: "warning" };
|
||||||
|
}
|
||||||
|
if (!task.binding) return { label: "未配置", theme: "default" };
|
||||||
|
if (!task.binding.enabled) return { label: "已暂停", theme: "default" };
|
||||||
|
if (status === "failed") return { label: "上次失败", theme: "danger" };
|
||||||
|
if (status === "succeeded") return { label: "运行正常", theme: "success" };
|
||||||
|
return { label: "已启用", theme: "success" };
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
/**
|
||||||
|
* 绑定配置弹窗:选业务链(真实 /api/roundtable-chains + 内置演示链)→ 配置
|
||||||
|
* 对方详情块 → 席位映射 → 执行频率 / 启用 / 总控提示。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Radio } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Button, Dialog, Switch, Textarea } from "tdesign-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { listChains } from "@/roundtable-planning/api/chains";
|
||||||
|
import type { BusinessChain } from "@/roundtable-planning/lib/business-chain";
|
||||||
|
import { type ScheduleConfig } from "@/components/workspace/scheduled-tasks/schedule-builder";
|
||||||
|
|
||||||
|
import AgentFlow from "@/pages/official-multi-agent-tasks/components/AgentFlow";
|
||||||
|
import SchedulePicker from "@/pages/official-multi-agent-tasks/components/SchedulePicker";
|
||||||
|
import type { MatSeat } from "@/pages/official-multi-agent-tasks/types";
|
||||||
|
import {
|
||||||
|
defaultScheduleConfig,
|
||||||
|
describeScheduleConfig,
|
||||||
|
} from "@/pages/official-multi-agent-tasks/utils";
|
||||||
|
|
||||||
|
import type { TaBinding, TaBindingPayload, TaTask } from "../types";
|
||||||
|
|
||||||
|
/** 内置演示链(种子里用它,保证无后端数据时链路可演示)。 */
|
||||||
|
export const SEED_CHAIN: BusinessChain = {
|
||||||
|
id: "seed-chain-std",
|
||||||
|
title: "标准舆情分析链(内置)",
|
||||||
|
description: "采集 + 核查并行 → 研判 → 成稿,演示用内置编排。",
|
||||||
|
seats: [
|
||||||
|
{ agent_id: "collector", name: "全网舆情采集" },
|
||||||
|
{ agent_id: "verifier", name: "数据核查员" },
|
||||||
|
{ agent_id: "analyst", name: "舆情研判分析" },
|
||||||
|
{ agent_id: "writer", name: "报告撰写助手" },
|
||||||
|
],
|
||||||
|
stages: [
|
||||||
|
["collector", "verifier"],
|
||||||
|
["analyst"],
|
||||||
|
["writer"],
|
||||||
|
],
|
||||||
|
stageGoals: null,
|
||||||
|
isPublic: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function chainStages(chain: BusinessChain): string[][] {
|
||||||
|
if (chain.stages?.length) return chain.stages;
|
||||||
|
return chain.seats.map((seat) => [seat.agent_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chainSeats(chain: BusinessChain): MatSeat[] {
|
||||||
|
return chain.seats.map((seat) => ({
|
||||||
|
agent_id: seat.agent_id,
|
||||||
|
name: seat.name,
|
||||||
|
description: seat.description,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模块级空数组:data 未就绪时保持引用稳定,避免 effect 依赖无限循环。 */
|
||||||
|
export const NO_CHAINS: BusinessChain[] = [];
|
||||||
|
|
||||||
|
export default function BindingDialog({
|
||||||
|
visible,
|
||||||
|
task,
|
||||||
|
submitting,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
visible: boolean;
|
||||||
|
task: TaTask | null;
|
||||||
|
submitting: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (payload: TaBindingPayload) => void;
|
||||||
|
}) {
|
||||||
|
const [chainId, setChainId] = useState("");
|
||||||
|
const [schedule, setSchedule] = useState<ScheduleConfig>(defaultScheduleConfig());
|
||||||
|
const [enabled, setEnabled] = useState(true);
|
||||||
|
const [coordinatorPrompt, setCoordinatorPrompt] = useState("");
|
||||||
|
|
||||||
|
const { data, isLoading: chainsLoading } = useQuery({
|
||||||
|
queryKey: ["ta", "chains"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const [mine, publicChains] = await Promise.all([
|
||||||
|
listChains("mine").catch(() => [] as BusinessChain[]),
|
||||||
|
listChains("public").catch(() => [] as BusinessChain[]),
|
||||||
|
]);
|
||||||
|
const byId = new Map<string, BusinessChain>();
|
||||||
|
for (const chain of [SEED_CHAIN, ...mine, ...publicChains]) {
|
||||||
|
if (!byId.has(chain.id)) byId.set(chain.id, chain);
|
||||||
|
}
|
||||||
|
return [...byId.values()];
|
||||||
|
},
|
||||||
|
enabled: visible,
|
||||||
|
});
|
||||||
|
const chains = data ?? NO_CHAINS;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
const binding: TaBinding | null = task?.binding ?? null;
|
||||||
|
const initialChain =
|
||||||
|
chains.find((chain) => chain.id === binding?.chainId) ??
|
||||||
|
chains[0] ??
|
||||||
|
SEED_CHAIN;
|
||||||
|
setChainId(binding?.chainId ?? initialChain.id);
|
||||||
|
setEnabled(binding?.enabled ?? true);
|
||||||
|
setCoordinatorPrompt(binding?.coordinatorPrompt ?? "");
|
||||||
|
}, [visible, task, chains]);
|
||||||
|
|
||||||
|
const selectedChain = useMemo(
|
||||||
|
() => chains.find((chain) => chain.id === chainId) ?? null,
|
||||||
|
[chains, chainId],
|
||||||
|
);
|
||||||
|
const handleChainChange = (nextId: string) => {
|
||||||
|
setChainId(nextId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const trySubmit = () => {
|
||||||
|
if (!selectedChain) {
|
||||||
|
toast.warning("请选择业务链");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSubmit({
|
||||||
|
chainId: selectedChain.id,
|
||||||
|
chainTitle: selectedChain.title,
|
||||||
|
schedule,
|
||||||
|
enabled,
|
||||||
|
coordinatorPrompt: coordinatorPrompt.trim() || undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
className="ta-binding-dialog"
|
||||||
|
closeBtn
|
||||||
|
closeOnOverlayClick={false}
|
||||||
|
destroyOnClose
|
||||||
|
footer={
|
||||||
|
<div className="ta-dialog-footer">
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button loading={submitting} theme="primary" onClick={trySubmit}>
|
||||||
|
{task?.binding ? "保存绑定" : "创建绑定"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
header={task?.binding ? `编辑绑定 · ${task.name}` : `配置分析 · ${task?.name ?? ""}`}
|
||||||
|
placement="top"
|
||||||
|
visible={visible}
|
||||||
|
width={860}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<div className="ta-binding">
|
||||||
|
<section className="ta-binding-section">
|
||||||
|
<header className="ta-binding-section-head">
|
||||||
|
<h4>选择业务链</h4>
|
||||||
|
<span>定时执行将按业务链的多智能体关系(同阶段并行 · 阶段间串行)运行</span>
|
||||||
|
</header>
|
||||||
|
{chainsLoading ? (
|
||||||
|
<p className="ta-binding-tip">正在加载业务链…</p>
|
||||||
|
) : (
|
||||||
|
<div className="ta-chain-grid">
|
||||||
|
{chains.map((chain) => {
|
||||||
|
const active = chain.id === chainId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`ta-chain-card${active ? " is-active" : ""}`}
|
||||||
|
key={chain.id}
|
||||||
|
onClick={() => handleChainChange(chain.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<Radio aria-hidden="true" size={14} />
|
||||||
|
<span className="ta-chain-card-title">{chain.title}</span>
|
||||||
|
<em>
|
||||||
|
{chain.seats.length} 智能体 · {chainStages(chain).length} 阶段
|
||||||
|
</em>
|
||||||
|
</header>
|
||||||
|
{chain.description && <p>{chain.description}</p>}
|
||||||
|
<div className="ta-chain-card-flow">
|
||||||
|
<AgentFlow
|
||||||
|
seats={chainSeats(chain)}
|
||||||
|
size="sm"
|
||||||
|
stages={chainStages(chain)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="ta-binding-section">
|
||||||
|
<header className="ta-binding-section-head">
|
||||||
|
<h4>执行设置</h4>
|
||||||
|
<span>{describeScheduleConfig(schedule)} 自动执行</span>
|
||||||
|
</header>
|
||||||
|
<div className="ta-binding-settings">
|
||||||
|
<div className="ta-field">
|
||||||
|
<span className="ta-field-label">执行频率</span>
|
||||||
|
<SchedulePicker value={schedule} onChange={setSchedule} />
|
||||||
|
</div>
|
||||||
|
<label className="ta-field">
|
||||||
|
<span className="ta-field-label">总控提示(可选)</span>
|
||||||
|
<Textarea
|
||||||
|
autosize={{ minRows: 2, maxRows: 4 }}
|
||||||
|
maxlength={500}
|
||||||
|
placeholder="写给总控(协调者)的编排要求;问答沉淀的「方向备注」也会注入后续执行"
|
||||||
|
value={coordinatorPrompt}
|
||||||
|
onChange={(value) => setCoordinatorPrompt(String(value ?? ""))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="ta-field ta-field--switch">
|
||||||
|
<span className="ta-field-label">绑定后立即启用</span>
|
||||||
|
<Switch value={enabled} onChange={(value) => setEnabled(!!value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
/**
|
||||||
|
* 新建任务弹窗(一步式):任务信息(名称/任务号/简介)+ 业务链选择 +
|
||||||
|
* 执行设置(频率 / 总控提示 / 启用),提交后直接创建任务并完成绑定。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Radio } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Button, Dialog, Input, Switch, Textarea } from "tdesign-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { listChains } from "@/roundtable-planning/api/chains";
|
||||||
|
import type { BusinessChain } from "@/roundtable-planning/lib/business-chain";
|
||||||
|
import { type ScheduleConfig } from "@/components/workspace/scheduled-tasks/schedule-builder";
|
||||||
|
|
||||||
|
import AgentFlow from "@/pages/official-multi-agent-tasks/components/AgentFlow";
|
||||||
|
import SchedulePicker from "@/pages/official-multi-agent-tasks/components/SchedulePicker";
|
||||||
|
import {
|
||||||
|
defaultScheduleConfig,
|
||||||
|
describeScheduleConfig,
|
||||||
|
} from "@/pages/official-multi-agent-tasks/utils";
|
||||||
|
import type { MatSeat } from "@/pages/official-multi-agent-tasks/types";
|
||||||
|
|
||||||
|
import { NO_CHAINS, SEED_CHAIN, chainSeats, chainStages } from "./BindingDialog";
|
||||||
|
import type { TaBindingPayload } from "../types";
|
||||||
|
|
||||||
|
export interface TaCreateTaskPayload {
|
||||||
|
name: string;
|
||||||
|
sourceTaskId?: string;
|
||||||
|
description?: string;
|
||||||
|
binding: TaBindingPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreateTaskDialog({
|
||||||
|
visible,
|
||||||
|
submitting,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
visible: boolean;
|
||||||
|
submitting: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (payload: TaCreateTaskPayload) => void;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [sourceTaskId, setSourceTaskId] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [chainId, setChainId] = useState("");
|
||||||
|
const [schedule, setSchedule] = useState<ScheduleConfig>(defaultScheduleConfig());
|
||||||
|
const [enabled, setEnabled] = useState(true);
|
||||||
|
const [coordinatorPrompt, setCoordinatorPrompt] = useState("");
|
||||||
|
|
||||||
|
const { data, isLoading: chainsLoading } = useQuery({
|
||||||
|
queryKey: ["ta", "chains"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const [mine, publicChains] = await Promise.all([
|
||||||
|
listChains("mine").catch(() => [] as BusinessChain[]),
|
||||||
|
listChains("public").catch(() => [] as BusinessChain[]),
|
||||||
|
]);
|
||||||
|
const byId = new Map<string, BusinessChain>();
|
||||||
|
for (const chain of [SEED_CHAIN, ...mine, ...publicChains]) {
|
||||||
|
if (!byId.has(chain.id)) byId.set(chain.id, chain);
|
||||||
|
}
|
||||||
|
return [...byId.values()];
|
||||||
|
},
|
||||||
|
enabled: visible,
|
||||||
|
});
|
||||||
|
const chains = data ?? NO_CHAINS;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
setName("");
|
||||||
|
setSourceTaskId("");
|
||||||
|
setDescription("");
|
||||||
|
setSchedule(defaultScheduleConfig());
|
||||||
|
setEnabled(true);
|
||||||
|
setCoordinatorPrompt("");
|
||||||
|
setChainId((prev) => (chains.some((chain) => chain.id === prev) ? prev : chains[0]?.id ?? SEED_CHAIN.id));
|
||||||
|
}, [visible, chains]);
|
||||||
|
|
||||||
|
const selectedChain = useMemo(
|
||||||
|
() => chains.find((chain) => chain.id === chainId) ?? null,
|
||||||
|
[chains, chainId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const trySubmit = () => {
|
||||||
|
if (!name.trim()) {
|
||||||
|
toast.warning("请填写任务名称");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedChain) {
|
||||||
|
toast.warning("请选择业务链");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSubmit({
|
||||||
|
name,
|
||||||
|
sourceTaskId: sourceTaskId.trim() || undefined,
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
binding: {
|
||||||
|
chainId: selectedChain.id,
|
||||||
|
chainTitle: selectedChain.title,
|
||||||
|
schedule,
|
||||||
|
enabled,
|
||||||
|
coordinatorPrompt: coordinatorPrompt.trim() || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
className="ta-create-dialog"
|
||||||
|
closeBtn
|
||||||
|
closeOnOverlayClick={false}
|
||||||
|
destroyOnClose
|
||||||
|
footer={
|
||||||
|
<div className="ta-dialog-footer">
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button loading={submitting} theme="primary" onClick={trySubmit}>
|
||||||
|
创建任务并绑定
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
header="新建任务"
|
||||||
|
placement="top"
|
||||||
|
visible={visible}
|
||||||
|
width={860}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<div className="ta-binding">
|
||||||
|
<section className="ta-binding-section">
|
||||||
|
<header className="ta-binding-section-head">
|
||||||
|
<h4>任务信息</h4>
|
||||||
|
<span>由本页录入;接入对方系统后,推送创建的任务同样出现在列表中</span>
|
||||||
|
</header>
|
||||||
|
<div className="ta-create-grid">
|
||||||
|
<label className="ta-field">
|
||||||
|
<span className="ta-field-label">
|
||||||
|
任务名称<i className="ta-required">*</i>
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
clearable
|
||||||
|
maxlength={40}
|
||||||
|
placeholder="例如:东南亚舆论环境监测"
|
||||||
|
value={name}
|
||||||
|
onChange={(value) => setName(String(value ?? ""))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="ta-field">
|
||||||
|
<span className="ta-field-label">对方任务号(可选)</span>
|
||||||
|
<Input
|
||||||
|
clearable
|
||||||
|
maxlength={32}
|
||||||
|
placeholder="留空自动生成"
|
||||||
|
value={sourceTaskId}
|
||||||
|
onChange={(value) => setSourceTaskId(String(value ?? ""))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="ta-field ta-field--wide">
|
||||||
|
<span className="ta-field-label">任务简介</span>
|
||||||
|
<Textarea
|
||||||
|
autosize={{ minRows: 2, maxRows: 4 }}
|
||||||
|
maxlength={200}
|
||||||
|
placeholder="一句话说明任务背景与分析目标,定时执行时作为任务上下文注入各智能体"
|
||||||
|
value={description}
|
||||||
|
onChange={(value) => setDescription(String(value ?? ""))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="ta-binding-section">
|
||||||
|
<header className="ta-binding-section-head">
|
||||||
|
<h4>执行设置</h4>
|
||||||
|
<span>{describeScheduleConfig(schedule)} 自动执行</span>
|
||||||
|
</header>
|
||||||
|
<div className="ta-binding-settings">
|
||||||
|
<div className="ta-field">
|
||||||
|
<span className="ta-field-label">执行频率</span>
|
||||||
|
<SchedulePicker value={schedule} onChange={setSchedule} />
|
||||||
|
</div>
|
||||||
|
<label className="ta-field">
|
||||||
|
<span className="ta-field-label">总控提示(可选)</span>
|
||||||
|
<Textarea
|
||||||
|
autosize={{ minRows: 2, maxRows: 4 }}
|
||||||
|
maxlength={500}
|
||||||
|
placeholder="写给总控(协调者)的编排要求;问答沉淀的「方向备注」也会注入后续执行"
|
||||||
|
value={coordinatorPrompt}
|
||||||
|
onChange={(value) => setCoordinatorPrompt(String(value ?? ""))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="ta-field ta-field--switch">
|
||||||
|
<span className="ta-field-label">创建后立即启用</span>
|
||||||
|
<Switch value={enabled} onChange={(value) => setEnabled(!!value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="ta-binding-section">
|
||||||
|
<header className="ta-binding-section-head">
|
||||||
|
<h4>选择业务链</h4>
|
||||||
|
<span>定时执行按业务链的多智能体关系(同阶段并行 · 阶段间串行)运行</span>
|
||||||
|
</header>
|
||||||
|
{chainsLoading ? (
|
||||||
|
<p className="ta-binding-tip">正在加载业务链…</p>
|
||||||
|
) : (
|
||||||
|
<div className="ta-chain-grid">
|
||||||
|
{chains.map((chain) => {
|
||||||
|
const active = chain.id === chainId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`ta-chain-card${active ? " is-active" : ""}`}
|
||||||
|
key={chain.id}
|
||||||
|
onClick={() => setChainId(chain.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<Radio aria-hidden="true" size={14} />
|
||||||
|
<span className="ta-chain-card-title">{chain.title}</span>
|
||||||
|
<em>
|
||||||
|
{chain.seats.length} 智能体 · {chainStages(chain).length} 阶段
|
||||||
|
</em>
|
||||||
|
</header>
|
||||||
|
{chain.description && <p>{chain.description}</p>}
|
||||||
|
<div className="ta-chain-card-flow">
|
||||||
|
<AgentFlow
|
||||||
|
seats={chainSeats(chain) as MatSeat[]}
|
||||||
|
size="sm"
|
||||||
|
stages={chainStages(chain)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* 席位执行过程抽屉:点击流程图节点打开,展示该智能体在某次运行里的完整过程
|
||||||
|
* (输入 → AI 分析 → 工具调用 → 产物块)。后端就绪后由 runs/{id}/messages?seat= 提供数据。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ArrowDownRight, Bot, Wrench } from "lucide-react";
|
||||||
|
import { Drawer, Tag } from "tdesign-react";
|
||||||
|
|
||||||
|
import { seatColorIndex } from "@/pages/official-multi-agent-tasks/utils";
|
||||||
|
import { buildSeatProcess } from "../store";
|
||||||
|
import type { TaRunView, TaSeatState, TaTask } from "../types";
|
||||||
|
|
||||||
|
const STATE_THEME: Record<TaSeatState, "success" | "warning" | "danger" | "default"> = {
|
||||||
|
done: "success",
|
||||||
|
running: "warning",
|
||||||
|
failed: "danger",
|
||||||
|
waiting: "default",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATE_TEXT: Record<TaSeatState, string> = {
|
||||||
|
waiting: "等待执行",
|
||||||
|
running: "执行中",
|
||||||
|
done: "已完成",
|
||||||
|
failed: "执行失败",
|
||||||
|
};
|
||||||
|
|
||||||
|
const KIND_LABEL: Record<string, string> = {
|
||||||
|
input: "输入",
|
||||||
|
ai: "分析",
|
||||||
|
tool: "工具",
|
||||||
|
artifact: "产物",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
if (!Number.isFinite(ms) || ms <= 0) return "—";
|
||||||
|
const seconds = Math.round(ms / 1000);
|
||||||
|
if (seconds < 60) return `${seconds} 秒`;
|
||||||
|
return `${Math.floor(seconds / 60)} 分 ${seconds % 60} 秒`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SeatProcessDrawer({
|
||||||
|
run,
|
||||||
|
seatAgentId,
|
||||||
|
task,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
run: TaRunView | null;
|
||||||
|
seatAgentId: string | null;
|
||||||
|
task: TaTask | undefined;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const visible = !!run && !!seatAgentId;
|
||||||
|
const process =
|
||||||
|
visible && run ? buildSeatProcess(run, seatAgentId!, task) : null;
|
||||||
|
const info = run?.seatInfos[seatAgentId ?? ""] ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
className="ta-seat-drawer"
|
||||||
|
closeBtn
|
||||||
|
destroyOnClose
|
||||||
|
footer={null}
|
||||||
|
header={
|
||||||
|
process ? (
|
||||||
|
<div className="ta-seat-header">
|
||||||
|
<span className={`ta-seat-glyph ta-seat--c${seatColorIndex(seatAgentId ?? "")}`} aria-hidden="true">
|
||||||
|
<Bot size={18} />
|
||||||
|
</span>
|
||||||
|
<div className="ta-seat-heading">
|
||||||
|
<h3>{process.seatName}</h3>
|
||||||
|
<p>执行过程 · {run?.trigger === "manual" ? "手动执行(总控问答)" : "定时执行"}</p>
|
||||||
|
</div>
|
||||||
|
{info && (
|
||||||
|
<Tag size="small" theme={STATE_THEME[info.state]} variant="light">
|
||||||
|
{STATE_TEXT[info.state]}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
placement="right"
|
||||||
|
size="520px"
|
||||||
|
visible={visible}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
{process && (
|
||||||
|
<div className="ta-seat-process">
|
||||||
|
{info && (
|
||||||
|
<div className="ta-seat-meta">
|
||||||
|
<span>状态:{STATE_TEXT[info.state]}</span>
|
||||||
|
<span>耗时:{formatDuration(info.durationMs)}</span>
|
||||||
|
<span>所属阶段:第 {(run?.stages.findIndex((stage) => stage.includes(seatAgentId ?? "")) ?? -1) + 1} 阶段</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ol className="ta-seat-timeline">
|
||||||
|
{process.messages.map((message, index) => (
|
||||||
|
<li className={`ta-seat-item is-${message.kind}`} key={index}>
|
||||||
|
<span className="ta-seat-item-marker">
|
||||||
|
{message.kind === "tool" ? (
|
||||||
|
<Wrench size={11} />
|
||||||
|
) : (
|
||||||
|
<ArrowDownRight size={11} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="ta-seat-item-body">
|
||||||
|
<header>
|
||||||
|
<em className={`ta-seat-item-kind is-${message.kind}`}>
|
||||||
|
{KIND_LABEL[message.kind] ?? message.kind}
|
||||||
|
</em>
|
||||||
|
<span className="ta-seat-item-title">{message.title}</span>
|
||||||
|
</header>
|
||||||
|
<p>{message.content}</p>
|
||||||
|
{message.detail && <pre>{message.detail}</pre>}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import type { TaRunView, TaTask } from "./types";
|
||||||
|
|
||||||
|
export type { TaRunView, TaTask };
|
||||||
|
export type TaTaskViewLike = { task: TaTask; runs: TaRunView[] };
|
||||||
@@ -0,0 +1,611 @@
|
|||||||
|
/**
|
||||||
|
* 外部任务定时分析的本地 mock 存储层(与 multi-agent-tasks 同款模式)。
|
||||||
|
*
|
||||||
|
* - 任务/绑定/运行记录落 localStorage;「立即执行 / 模拟手动执行」写入运行记录,
|
||||||
|
* 状态由 startedAt + durationMs 按当前时间推导(排队 → 按阶段推进 → 成功/失败),
|
||||||
|
* 配合 React Query 轮询获得无后端的"实时"流程图推进。
|
||||||
|
* - 席位执行过程消息在读取侧按席位生成(buildSeatProcess),不入库。
|
||||||
|
* - 对外接口(创建/删除/详情时间线/问答)就绪后替换本层。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { buildCron } from "@/components/workspace/scheduled-tasks/schedule-builder";
|
||||||
|
import { describeScheduleConfig } from "@/pages/official-multi-agent-tasks/utils";
|
||||||
|
import { uuid } from "@/core/utils/uuid";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
TaBlockMapping,
|
||||||
|
TaBinding,
|
||||||
|
TaBindingPayload,
|
||||||
|
TaRunRecord,
|
||||||
|
TaRunStatus,
|
||||||
|
TaRunView,
|
||||||
|
TaSeatRunInfo,
|
||||||
|
TaSeatState,
|
||||||
|
TaTask,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "deerflow.official.task-analysis.v2";
|
||||||
|
|
||||||
|
interface TaStore {
|
||||||
|
tasks: TaTask[];
|
||||||
|
runs: TaRunRecord[];
|
||||||
|
/** 上次绑定时选择的业务链 id(创建接口响应里的 default_chain_id 记忆)。 */
|
||||||
|
lastChainId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PENDING_MS = 2_000;
|
||||||
|
const STAGE_MS = 6_000;
|
||||||
|
|
||||||
|
/** 对方系统定义的详情块类型(mock 固定清单;接接口后由创建方传入)。 */
|
||||||
|
export const TA_BLOCK_TYPES: Array<{ key: string; name: string }> = [
|
||||||
|
{ key: "background", name: "任务背景" },
|
||||||
|
{ key: "dynamics", name: "动态分析" },
|
||||||
|
{ key: "risk", name: "风险研判" },
|
||||||
|
{ key: "action", name: "行动建议" },
|
||||||
|
{ key: "conclusion", name: "结论汇总" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function readStore(): TaStore {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw) as Partial<TaStore>;
|
||||||
|
if (Array.isArray(parsed.tasks)) {
|
||||||
|
return {
|
||||||
|
tasks: parsed.tasks as TaTask[],
|
||||||
|
runs: parsed.runs ?? [],
|
||||||
|
lastChainId: parsed.lastChainId ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 解析失败按首次访问处理。
|
||||||
|
}
|
||||||
|
const seeded = seedStore();
|
||||||
|
writeStore(seeded);
|
||||||
|
return seeded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStore(store: TaStore) {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
|
||||||
|
} catch {
|
||||||
|
// 存储被禁用时静默降级为内存态。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mutateStore(mutator: (store: TaStore) => TaStore): TaStore {
|
||||||
|
const next = mutator(readStore());
|
||||||
|
writeStore(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hoursAgo(hours: number): string {
|
||||||
|
return new Date(Date.now() - hours * 3_600_000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedStore(): TaStore {
|
||||||
|
const seats = {
|
||||||
|
collector: "全网舆情采集",
|
||||||
|
analyst: "舆情研判分析",
|
||||||
|
writer: "报告撰写助手",
|
||||||
|
verifier: "数据核查员",
|
||||||
|
forecaster: "趋势预测专家",
|
||||||
|
};
|
||||||
|
|
||||||
|
const tasks: TaTask[] = [
|
||||||
|
{
|
||||||
|
id: "ta-task-1",
|
||||||
|
sourceTaskId: "TS-2026-0147",
|
||||||
|
name: "东南亚舆论环境监测",
|
||||||
|
description: "对方系统推送的任务:持续跟踪东南亚重点渠道舆论动向,输出结构化分析详情。",
|
||||||
|
binding: {
|
||||||
|
chainId: "seed-chain-std",
|
||||||
|
chainTitle: "标准舆情分析链",
|
||||||
|
cron: "0 9 * * *",
|
||||||
|
cronText: "每天 09:00",
|
||||||
|
enabled: true,
|
||||||
|
blocks: [
|
||||||
|
{ key: "background", name: "任务背景", seatAgentId: "collector" },
|
||||||
|
{ key: "dynamics", name: "动态分析", seatAgentId: "collector" },
|
||||||
|
{ key: "risk", name: "风险研判", seatAgentId: "analyst" },
|
||||||
|
{ key: "action", name: "行动建议", seatAgentId: "analyst" },
|
||||||
|
{ key: "conclusion", name: "结论汇总", seatAgentId: "writer" },
|
||||||
|
],
|
||||||
|
coordinatorPrompt: "采集阶段允许独立检索;研判阶段需引用采集阶段的原始线索。",
|
||||||
|
directionNotes: ["重点关注宗教节日前后的舆论波动。"],
|
||||||
|
timezone: "Asia/Shanghai",
|
||||||
|
},
|
||||||
|
createdAt: hoursAgo(24 * 12),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ta-task-2",
|
||||||
|
sourceTaskId: "TS-2026-0152",
|
||||||
|
name: "竞品新品发布追踪",
|
||||||
|
description: "追踪 5 家核心竞品的新品发布与市场反应,按块更新详情内容。",
|
||||||
|
binding: {
|
||||||
|
chainId: "seed-chain-single",
|
||||||
|
chainTitle: "单阶段并行分析链",
|
||||||
|
cron: "30 8 * * 1",
|
||||||
|
cronText: "每周一 08:30",
|
||||||
|
enabled: false,
|
||||||
|
blocks: [
|
||||||
|
{ key: "background", name: "任务背景", seatAgentId: "verifier" },
|
||||||
|
{ key: "dynamics", name: "动态分析", seatAgentId: "collector" },
|
||||||
|
{ key: "conclusion", name: "结论汇总", seatAgentId: "writer" },
|
||||||
|
],
|
||||||
|
directionNotes: [],
|
||||||
|
timezone: "Asia/Shanghai",
|
||||||
|
},
|
||||||
|
createdAt: hoursAgo(24 * 8),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ta-task-3",
|
||||||
|
sourceTaskId: "TS-2026-0160",
|
||||||
|
name: "行业政策周报编制",
|
||||||
|
description: "对方系统刚创建的任务,尚未配置分析方案。",
|
||||||
|
binding: null,
|
||||||
|
createdAt: hoursAgo(5),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const stdStages = [
|
||||||
|
["collector", "verifier"],
|
||||||
|
["analyst"],
|
||||||
|
["writer"],
|
||||||
|
];
|
||||||
|
const singleStages = [["collector", "verifier", "analyst"]];
|
||||||
|
const stdNames = seats;
|
||||||
|
|
||||||
|
const runs: TaRunRecord[] = [
|
||||||
|
{
|
||||||
|
id: uuid() as string,
|
||||||
|
taskId: "ta-task-1",
|
||||||
|
trigger: "schedule",
|
||||||
|
scheduledFor: hoursAgo(2.1),
|
||||||
|
startedAt: hoursAgo(2.1),
|
||||||
|
durationMs: PENDING_MS + stdStages.length * STAGE_MS,
|
||||||
|
stages: stdStages,
|
||||||
|
stageGoals: ["并行完成全网采集与数据核查", "研判风险与行动方向", "汇总各块结论"],
|
||||||
|
seatNames: stdNames,
|
||||||
|
failStage: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: uuid() as string,
|
||||||
|
taskId: "ta-task-1",
|
||||||
|
trigger: "manual",
|
||||||
|
scheduledFor: hoursAgo(20),
|
||||||
|
startedAt: hoursAgo(20),
|
||||||
|
durationMs: PENDING_MS + stdStages.length * STAGE_MS,
|
||||||
|
stages: stdStages,
|
||||||
|
stageGoals: ["并行完成全网采集与数据核查", "研判风险与行动方向", "汇总各块结论"],
|
||||||
|
seatNames: stdNames,
|
||||||
|
failStage: null,
|
||||||
|
manualSummary: "总控问答:要求研判阶段补充跨平台对比维度,已确认应用到后续执行。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: uuid() as string,
|
||||||
|
taskId: "ta-task-1",
|
||||||
|
trigger: "schedule",
|
||||||
|
scheduledFor: hoursAgo(26),
|
||||||
|
startedAt: hoursAgo(26),
|
||||||
|
durationMs: PENDING_MS + stdStages.length * STAGE_MS,
|
||||||
|
stages: stdStages,
|
||||||
|
stageGoals: ["并行完成全网采集与数据核查", "研判风险与行动方向", "汇总各块结论"],
|
||||||
|
seatNames: stdNames,
|
||||||
|
failStage: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: uuid() as string,
|
||||||
|
taskId: "ta-task-2",
|
||||||
|
trigger: "schedule",
|
||||||
|
scheduledFor: hoursAgo(24 * 3),
|
||||||
|
startedAt: hoursAgo(24 * 3),
|
||||||
|
durationMs: PENDING_MS + singleStages.length * STAGE_MS,
|
||||||
|
stages: singleStages,
|
||||||
|
stageGoals: ["三席并行:采集 / 核查 / 研判同时产出"],
|
||||||
|
seatNames: stdNames,
|
||||||
|
failStage: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return { tasks, runs, lastChainId: "seed-chain-std" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 由记录 + 当前时间推导运行视图:各席位状态/耗时、块产出状态。 */
|
||||||
|
function resolveRunView(run: TaRunRecord, task: TaTask | undefined, now = Date.now()): TaRunView {
|
||||||
|
const startedAt = new Date(run.startedAt).getTime();
|
||||||
|
const elapsed = now - startedAt;
|
||||||
|
const stageTotal = Math.max(1, run.stages.length);
|
||||||
|
const duration = run.durationMs || PENDING_MS + stageTotal * STAGE_MS;
|
||||||
|
|
||||||
|
const buildSeatInfos = (
|
||||||
|
status: TaRunStatus,
|
||||||
|
activeStage: number,
|
||||||
|
): Record<string, TaSeatRunInfo> => {
|
||||||
|
const infos: Record<string, TaSeatRunInfo> = {};
|
||||||
|
run.stages.forEach((stage, index) => {
|
||||||
|
const stageStart = PENDING_MS + index * STAGE_MS;
|
||||||
|
const stageEnd = stageStart + STAGE_MS;
|
||||||
|
const finished = status === "succeeded" || (status === "failed" && index < activeStage);
|
||||||
|
const active = !finished && index === activeStage && status !== "pending";
|
||||||
|
const state: TaSeatState = finished
|
||||||
|
? "done"
|
||||||
|
: active
|
||||||
|
? status === "failed"
|
||||||
|
? "failed"
|
||||||
|
: "running"
|
||||||
|
: "waiting";
|
||||||
|
const durationMs = finished
|
||||||
|
? Math.min(elapsed - stageStart, STAGE_MS)
|
||||||
|
: active
|
||||||
|
? Math.min(elapsed - stageStart, STAGE_MS)
|
||||||
|
: 0;
|
||||||
|
stage.forEach((agentId) => {
|
||||||
|
infos[agentId] = { state, durationMs };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return infos;
|
||||||
|
};
|
||||||
|
|
||||||
|
let status: TaRunStatus;
|
||||||
|
let stageIndex: number;
|
||||||
|
let finishedAt: string | null = null;
|
||||||
|
let error: string | null = null;
|
||||||
|
|
||||||
|
if (elapsed < PENDING_MS) {
|
||||||
|
status = "pending";
|
||||||
|
stageIndex = 0;
|
||||||
|
} else {
|
||||||
|
const progress = Math.min(elapsed - PENDING_MS, duration - PENDING_MS);
|
||||||
|
stageIndex = Math.min(Math.floor(progress / STAGE_MS), stageTotal - 1);
|
||||||
|
if (run.failStage !== null && stageIndex >= run.failStage) {
|
||||||
|
status = "failed";
|
||||||
|
stageIndex = run.failStage;
|
||||||
|
finishedAt = new Date(startedAt + PENDING_MS + (run.failStage + 1) * STAGE_MS).toISOString();
|
||||||
|
const failSeat = run.stages[run.failStage]?.[0];
|
||||||
|
error = `阶段 ${run.failStage + 1} 执行失败:${run.seatNames[failSeat ?? ""] ?? "席位"} 模拟执行异常`;
|
||||||
|
} else if (elapsed >= duration) {
|
||||||
|
status = "succeeded";
|
||||||
|
stageIndex = stageTotal - 1;
|
||||||
|
finishedAt = new Date(startedAt + duration).toISOString();
|
||||||
|
} else {
|
||||||
|
status = "running";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seatInfos = buildSeatInfos(status, status === "pending" ? -1 : stageIndex);
|
||||||
|
|
||||||
|
const blockStates = (task?.binding?.blocks ?? []).map((block) => ({
|
||||||
|
key: block.key,
|
||||||
|
name: block.name,
|
||||||
|
state: seatInfos[block.seatAgentId]?.state ?? "waiting",
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...run,
|
||||||
|
finishedAt,
|
||||||
|
status,
|
||||||
|
stageIndex,
|
||||||
|
stageTotal,
|
||||||
|
seatInfos,
|
||||||
|
error,
|
||||||
|
blockStates,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTaskViews(now = Date.now()): Array<{ task: TaTask; runs: TaRunView[] }> {
|
||||||
|
const store = readStore();
|
||||||
|
return store.tasks.map((task) => {
|
||||||
|
const runs = store.runs
|
||||||
|
.filter((run) => run.taskId === task.id)
|
||||||
|
.map((run) => resolveRunView(run, task, now))
|
||||||
|
.sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1));
|
||||||
|
return { task, runs };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function anyRunLive(views: Array<{ runs: TaRunView[] }> | undefined): boolean {
|
||||||
|
return !!views?.some(({ runs }) =>
|
||||||
|
runs.some((run) => run.status === "pending" || run.status === "running"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表 + 每任务运行视图;运行中 5 秒轮询(设计定稿:流程图每 5 秒刷新)。 */
|
||||||
|
export function useTaTasks() {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["ta", "tasks"],
|
||||||
|
queryFn: () => buildTaskViews(),
|
||||||
|
refetchInterval: (q) =>
|
||||||
|
anyRunLive(q.state.data as Array<{ runs: TaRunView[] }> | undefined) ? 5_000 : false,
|
||||||
|
});
|
||||||
|
const views = query.data ?? [];
|
||||||
|
return { views, tasks: views.map((view) => view.task), isLoading: query.isLoading };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTaTaskRuns(taskId: string | null) {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["ta", "tasks", taskId],
|
||||||
|
queryFn: () => {
|
||||||
|
const view = buildTaskViews().find((item) => item.task.id === taskId);
|
||||||
|
return view?.runs ?? [];
|
||||||
|
},
|
||||||
|
enabled: !!taskId,
|
||||||
|
refetchInterval: (q) => {
|
||||||
|
const runs = q.state.data as TaRunView[] | undefined;
|
||||||
|
const live = !!runs?.some((run) => run.status === "pending" || run.status === "running");
|
||||||
|
return live ? 5_000 : false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { runs: query.data ?? [], isLoading: query.isLoading };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTaTaskActions() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["ta"] });
|
||||||
|
|
||||||
|
/** 页面手动创建外部任务(名称必填;对方任务号留空则自动生成)。 */
|
||||||
|
const createTask = useMutation({
|
||||||
|
mutationFn: async (payload: {
|
||||||
|
name: string;
|
||||||
|
sourceTaskId?: string;
|
||||||
|
description?: string;
|
||||||
|
}) => {
|
||||||
|
const task: TaTask = {
|
||||||
|
id: uuid() as string,
|
||||||
|
sourceTaskId:
|
||||||
|
payload.sourceTaskId?.trim() ||
|
||||||
|
`TS-${new Date().getFullYear()}-${String(Date.now()).slice(-5)}`,
|
||||||
|
name: payload.name.trim(),
|
||||||
|
description: payload.description?.trim() || "",
|
||||||
|
binding: null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
mutateStore((store) => ({ ...store, tasks: [task, ...store.tasks] }));
|
||||||
|
return task;
|
||||||
|
},
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 绑定 / 修改绑定(记录 lastChainId 供创建接口返回默认项)。 */
|
||||||
|
const bind = useMutation({
|
||||||
|
mutationFn: async ({ taskId, payload }: { taskId: string; payload: TaBindingPayload }) => {
|
||||||
|
const cron = buildCron(payload.schedule);
|
||||||
|
mutateStore((store) => ({
|
||||||
|
...store,
|
||||||
|
lastChainId: payload.chainId,
|
||||||
|
tasks: store.tasks.map((task) =>
|
||||||
|
task.id === taskId
|
||||||
|
? {
|
||||||
|
...task,
|
||||||
|
binding: {
|
||||||
|
chainId: payload.chainId,
|
||||||
|
chainTitle: payload.chainTitle,
|
||||||
|
cron,
|
||||||
|
cronText: describeScheduleConfig(payload.schedule),
|
||||||
|
enabled: payload.enabled,
|
||||||
|
blocks: payload.blocks ?? task.binding?.blocks ?? [],
|
||||||
|
coordinatorPrompt: payload.coordinatorPrompt,
|
||||||
|
directionNotes: task.binding?.directionNotes ?? [],
|
||||||
|
timezone: task.binding?.timezone ?? "Asia/Shanghai",
|
||||||
|
} satisfies TaBinding,
|
||||||
|
}
|
||||||
|
: task,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const unbind = useMutation({
|
||||||
|
mutationFn: async (taskId: string) => {
|
||||||
|
mutateStore((store) => ({
|
||||||
|
...store,
|
||||||
|
tasks: store.tasks.map((task) =>
|
||||||
|
task.id === taskId ? { ...task, binding: null } : task,
|
||||||
|
),
|
||||||
|
runs: store.runs.filter((run) => run.taskId !== taskId),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const setEnabled = useMutation({
|
||||||
|
mutationFn: async ({ taskId, enabled }: { taskId: string; enabled: boolean }) => {
|
||||||
|
mutateStore((store) => ({
|
||||||
|
...store,
|
||||||
|
tasks: store.tasks.map((task) =>
|
||||||
|
task.id === taskId && task.binding
|
||||||
|
? { ...task, binding: { ...task.binding, enabled } }
|
||||||
|
: task,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 删除外部任务(对应对外删除接口:任务 + 绑定 + 执行记录彻底删除)。 */
|
||||||
|
const removeTask = useMutation({
|
||||||
|
mutationFn: async (taskId: string) => {
|
||||||
|
mutateStore((store) => ({
|
||||||
|
...store,
|
||||||
|
tasks: store.tasks.filter((task) => task.id !== taskId),
|
||||||
|
runs: store.runs.filter((run) => run.taskId !== taskId),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 立即触发一次定时执行(schedule)。 */
|
||||||
|
const triggerRun = useMutation({
|
||||||
|
mutationFn: async (taskId: string) => {
|
||||||
|
const store = readStore();
|
||||||
|
const task = store.tasks.find((item) => item.id === taskId);
|
||||||
|
const binding = task?.binding;
|
||||||
|
if (!task || !binding) throw new Error("任务未绑定分析方案");
|
||||||
|
const stages = chainStagesOf(binding, store);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const run: TaRunRecord = {
|
||||||
|
id: uuid() as string,
|
||||||
|
taskId,
|
||||||
|
trigger: "schedule",
|
||||||
|
scheduledFor: now,
|
||||||
|
startedAt: now,
|
||||||
|
durationMs: PENDING_MS + stages.length * STAGE_MS,
|
||||||
|
stages,
|
||||||
|
stageGoals: stages.map((_, index) => stageGoalOf(index)),
|
||||||
|
seatNames: Object.fromEntries(stages.flat().map((id) => [id, seatDisplayName(id)])),
|
||||||
|
failStage: null,
|
||||||
|
};
|
||||||
|
mutateStore((current) => ({ ...current, runs: [run, ...current.runs] }));
|
||||||
|
return run;
|
||||||
|
},
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 模拟一次手动执行(对方详情页总控问答产生)。 */
|
||||||
|
const triggerManualRun = useMutation({
|
||||||
|
mutationFn: async (taskId: string) => {
|
||||||
|
const store = readStore();
|
||||||
|
const task = store.tasks.find((item) => item.id === taskId);
|
||||||
|
const binding = task?.binding;
|
||||||
|
if (!task || !binding) throw new Error("任务未绑定分析方案");
|
||||||
|
const stages = chainStagesOf(binding, store);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const run: TaRunRecord = {
|
||||||
|
id: uuid() as string,
|
||||||
|
taskId,
|
||||||
|
trigger: "manual",
|
||||||
|
scheduledFor: now,
|
||||||
|
startedAt: now,
|
||||||
|
durationMs: PENDING_MS + stages.length * STAGE_MS,
|
||||||
|
stages,
|
||||||
|
stageGoals: stages.map((_, index) => stageGoalOf(index)),
|
||||||
|
seatNames: Object.fromEntries(stages.flat().map((id) => [id, seatDisplayName(id)])),
|
||||||
|
failStage: null,
|
||||||
|
manualSummary: "总控问答:按对方详情页提问,总控给对应席位派活并汇总结论。",
|
||||||
|
};
|
||||||
|
mutateStore((current) => ({ ...current, runs: [run, ...current.runs] }));
|
||||||
|
return run;
|
||||||
|
},
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { createTask, bind, unbind, setEnabled, removeTask, triggerRun, triggerManualRun };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 绑定链的编排(种子任务用内置编排;真实业务链按其 stages 展开,null = 线性)。 */
|
||||||
|
export function chainStagesOf(
|
||||||
|
binding: Pick<TaBinding, "chainId">,
|
||||||
|
store: TaStore = readStore(),
|
||||||
|
): string[][] {
|
||||||
|
if (binding.chainId === "seed-chain-std") {
|
||||||
|
return [
|
||||||
|
["collector", "verifier"],
|
||||||
|
["analyst"],
|
||||||
|
["writer"],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (binding.chainId === "seed-chain-single") {
|
||||||
|
return [["collector", "verifier", "analyst"]];
|
||||||
|
}
|
||||||
|
// 真实业务链的编排由页面侧传入的 chain 数据展开;存储层兜底为单席位串行。
|
||||||
|
const seats = seedSeatsOf(store);
|
||||||
|
return seats.map((agentId) => [agentId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedSeatsOf(_store: TaStore): string[] {
|
||||||
|
return ["collector", "verifier", "analyst", "writer"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 演示用的阶段目标(seed 链固定;真实链由绑定快照提供)。 */
|
||||||
|
function stageGoalOf(stageIndex: number): string | null {
|
||||||
|
const goals = ["并行完成全网采集与数据核查", "研判风险与行动方向", "汇总各块结论"];
|
||||||
|
return goals[stageIndex] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seatDisplayName(agentId: string): string {
|
||||||
|
const names: Record<string, string> = {
|
||||||
|
collector: "全网舆情采集",
|
||||||
|
verifier: "数据核查员",
|
||||||
|
analyst: "舆情研判分析",
|
||||||
|
writer: "报告撰写助手",
|
||||||
|
forecaster: "趋势预测专家",
|
||||||
|
};
|
||||||
|
return names[agentId] ?? agentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 席位执行过程(读取侧生成 mock)────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface TaSeatMessage {
|
||||||
|
kind: "input" | "ai" | "tool" | "artifact";
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成某席位在某次运行里的执行过程消息(后端就绪后换 runs/{id}/messages?seat=)。 */
|
||||||
|
export function buildSeatProcess(
|
||||||
|
run: TaRunView,
|
||||||
|
seatAgentId: string,
|
||||||
|
task: TaTask | undefined,
|
||||||
|
): { seatName: string; messages: TaSeatMessage[]; blocks: TaBlockMapping[] } {
|
||||||
|
const seatName = run.seatNames[seatAgentId] ?? seatAgentId;
|
||||||
|
const info = run.seatInfos[seatAgentId] ?? { state: "waiting" as TaSeatState, durationMs: 0 };
|
||||||
|
const stageIndex = run.stages.findIndex((stage) => stage.includes(seatAgentId));
|
||||||
|
const blocks = (task?.binding?.blocks ?? []).filter(
|
||||||
|
(block) => block.seatAgentId === seatAgentId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const messages: TaSeatMessage[] = [
|
||||||
|
{
|
||||||
|
kind: "input",
|
||||||
|
title: "输入 · 任务上下文",
|
||||||
|
content:
|
||||||
|
`${task?.name ?? ""}(对方任务 ${task?.sourceTaskId ?? "-"})\n` +
|
||||||
|
`${task?.description ?? ""}\n\n` +
|
||||||
|
(blocks.length
|
||||||
|
? `本席位负责产出:${blocks.map((block) => block.name).join("、")}。`
|
||||||
|
: "按业务链席位职责执行。") +
|
||||||
|
(stageIndex > 0
|
||||||
|
? `\n上一阶段产出摘要:${run.stages[stageIndex - 1]
|
||||||
|
?.map((id) => run.seatNames[id])
|
||||||
|
.join("、")} 的中间结论已注入。`
|
||||||
|
: ""),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (info.state === "waiting") {
|
||||||
|
return { seatName, messages, blocks };
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push({
|
||||||
|
kind: "ai",
|
||||||
|
title: "AI 分析",
|
||||||
|
content:
|
||||||
|
`(${seatName} 的分析过程 · 演示内容)\n` +
|
||||||
|
`围绕任务简介拆解检索方向,交叉核对来源可信度,形成本席位负责块的结构化结论。`,
|
||||||
|
});
|
||||||
|
|
||||||
|
messages.push({
|
||||||
|
kind: "tool",
|
||||||
|
title: "工具调用 · web_search",
|
||||||
|
content: "query=\"任务关键词 最新动态\" → 命中 12 条,采纳 7 条(含来源与时间)",
|
||||||
|
detail:
|
||||||
|
'{"results":[{"title":"来源A:行业动态","snippet":"……"},{"title":"来源B:政策更新","snippet":"……"}]}',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (info.state === "done") {
|
||||||
|
blocks.forEach((block) => {
|
||||||
|
messages.push({
|
||||||
|
kind: "artifact",
|
||||||
|
title: `产出 · ${block.name}`,
|
||||||
|
content: `(${block.name} 块内容摘要 · 演示)已按对方系统约定的结构生成,随本次执行写入时间线版本。`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { seatName, messages, blocks };
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* 外部任务多智能体定时分析 —— 前端先行数据模型(后端接口就绪后替换 store 层)。
|
||||||
|
*
|
||||||
|
* 对应设计定稿:
|
||||||
|
* - 外部任务(对方系统推送创建/删除)1:1 绑定「业务链 + 频率 + 块→席位映射」。
|
||||||
|
* - 执行记录两类:schedule 定时执行 / manual 手动执行(对方详情页总控问答)。
|
||||||
|
* - 时间线内容版本:每次定时执行产出各块内容(供对方详情接口拉取),管理页内
|
||||||
|
* 在席位执行过程抽屉里预览。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ScheduleConfig } from "@/components/workspace/scheduled-tasks/schedule-builder";
|
||||||
|
|
||||||
|
export type TaRunTrigger = "schedule" | "manual";
|
||||||
|
export type TaRunStatus = "pending" | "running" | "succeeded" | "failed";
|
||||||
|
export type TaSeatState = "waiting" | "running" | "done" | "failed";
|
||||||
|
|
||||||
|
/** 对方详情的分块内容与业务链席位的映射(存在绑定上,业务链保持纯粹)。 */
|
||||||
|
export interface TaBlockMapping {
|
||||||
|
/** 块类型 key(对方系统定义)。 */
|
||||||
|
key: string;
|
||||||
|
/** 块展示名。 */
|
||||||
|
name: string;
|
||||||
|
/** 产出该块的席位(业务链 agent_id)。 */
|
||||||
|
seatAgentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaBinding {
|
||||||
|
chainId: string;
|
||||||
|
/** 业务链标题快照(展示用,链被删后仍可读)。 */
|
||||||
|
chainTitle: string;
|
||||||
|
cron: string;
|
||||||
|
cronText: string;
|
||||||
|
enabled: boolean;
|
||||||
|
blocks: TaBlockMapping[];
|
||||||
|
coordinatorPrompt?: string;
|
||||||
|
/** 问答沉淀的「执行方向备注」,注入后续每次定时执行(累积追加)。 */
|
||||||
|
directionNotes: string[];
|
||||||
|
/** 绑定时记住,作为下次创建任务的默认业务链返回给对方系统。 */
|
||||||
|
timezone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaTask {
|
||||||
|
id: string;
|
||||||
|
/** 对方系统任务 id。 */
|
||||||
|
sourceTaskId: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
binding: TaBinding | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 落库的运行记录;状态由 startedAt + durationMs 在读取时按当前时间推导。 */
|
||||||
|
export interface TaRunRecord {
|
||||||
|
id: string;
|
||||||
|
taskId: string;
|
||||||
|
trigger: TaRunTrigger;
|
||||||
|
scheduledFor: string;
|
||||||
|
startedAt: string;
|
||||||
|
durationMs: number;
|
||||||
|
/** 该次执行的业务链编排快照(席位/阶段),链后续修改不影响历史回放。 */
|
||||||
|
stages: string[][];
|
||||||
|
/** 各阶段目标快照(与 stages 同下标),供流程图连线标签展示。 */
|
||||||
|
stageGoals?: (string | null)[] | null;
|
||||||
|
seatNames: Record<string, string>;
|
||||||
|
/** 在该阶段(下标)失败;null = 成功。 */
|
||||||
|
failStage: number | null;
|
||||||
|
/** 手动执行(总控问答)的摘要。 */
|
||||||
|
manualSummary?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取时推导出的运行视图(每席位状态 + 耗时)。 */
|
||||||
|
export interface TaSeatRunInfo {
|
||||||
|
state: TaSeatState;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaRunView {
|
||||||
|
id: string;
|
||||||
|
taskId: string;
|
||||||
|
trigger: TaRunTrigger;
|
||||||
|
scheduledFor: string;
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt: string | null;
|
||||||
|
status: TaRunStatus;
|
||||||
|
stageIndex: number;
|
||||||
|
stageTotal: number;
|
||||||
|
seatInfos: Record<string, TaSeatRunInfo>;
|
||||||
|
error: string | null;
|
||||||
|
/** 各块产出状态(按绑定块映射推导;未绑定返回空)。 */
|
||||||
|
blockStates: Array<{ key: string; name: string; state: TaSeatState }>;
|
||||||
|
manualSummary?: string;
|
||||||
|
stages: string[][];
|
||||||
|
stageGoals?: (string | null)[] | null;
|
||||||
|
seatNames: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 绑定弹窗的提交载荷(内容块映射已移除;blocks 仅为兼容旧数据保留)。 */
|
||||||
|
export interface TaBindingPayload {
|
||||||
|
chainId: string;
|
||||||
|
chainTitle: string;
|
||||||
|
schedule: ScheduleConfig;
|
||||||
|
enabled: boolean;
|
||||||
|
blocks?: TaBlockMapping[];
|
||||||
|
coordinatorPrompt?: string;
|
||||||
|
}
|
||||||
@@ -26,6 +26,11 @@ interface PositionIntentInputProps extends Step1InputAreaProps {
|
|||||||
selectedModel?: string;
|
selectedModel?: string;
|
||||||
onSelectedModelChange?: (model: string) => void;
|
onSelectedModelChange?: (model: string) => void;
|
||||||
commonQuestions?: (string | AgentQuestion)[] | null;
|
commonQuestions?: (string | AgentQuestion)[] | null;
|
||||||
|
/**
|
||||||
|
* 点击常用问题 chip 时直接把问题发送出去(8BF 第一步)。
|
||||||
|
* 缺省 false:仅填充输入框(岗位会商等既有页面的行为保持不变)。
|
||||||
|
*/
|
||||||
|
sendCommonQuestionOnClick?: boolean;
|
||||||
/** 仅保留上传、模型选择和发送的紧凑输入区(用于会商第 3 步)。 */
|
/** 仅保留上传、模型选择和发送的紧凑输入区(用于会商第 3 步)。 */
|
||||||
hideUtilityActions?: boolean;
|
hideUtilityActions?: boolean;
|
||||||
}
|
}
|
||||||
@@ -68,6 +73,7 @@ export function PositionIntentInput({
|
|||||||
selectedModel,
|
selectedModel,
|
||||||
onSelectedModelChange,
|
onSelectedModelChange,
|
||||||
commonQuestions,
|
commonQuestions,
|
||||||
|
sendCommonQuestionOnClick = false,
|
||||||
hideUtilityActions = false,
|
hideUtilityActions = false,
|
||||||
}: PositionIntentInputProps) {
|
}: PositionIntentInputProps) {
|
||||||
// Files are owned by Step1Panel. This ref only opens the native picker and
|
// Files are owned by Step1Panel. This ref only opens the native picker and
|
||||||
@@ -86,7 +92,13 @@ export function PositionIntentInput({
|
|||||||
: (modelOptions[0]?.value ?? "");
|
: (modelOptions[0]?.value ?? "");
|
||||||
|
|
||||||
const pickCommonQuestion = (question: AgentQuestion) => {
|
const pickCommonQuestion = (question: AgentQuestion) => {
|
||||||
onValueChange(question.prompt);
|
// 直接发送模式:以问题全文为覆盖文本调 onSend,绕过输入框 state 的
|
||||||
|
// 异步更新(同 tick 里 onSend 闭包读不到刚 setValue 的新值)。
|
||||||
|
if (sendCommonQuestionOnClick) {
|
||||||
|
onSend(question.prompt);
|
||||||
|
} else {
|
||||||
|
onValueChange(question.prompt);
|
||||||
|
}
|
||||||
setShowMoreQuestions(false);
|
setShowMoreQuestions(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -160,6 +172,7 @@ export function PositionIntentInput({
|
|||||||
type="button"
|
type="button"
|
||||||
className="position-roundtable-intent-common-question"
|
className="position-roundtable-intent-common-question"
|
||||||
title={question.prompt}
|
title={question.prompt}
|
||||||
|
disabled={sendCommonQuestionOnClick ? isSending : undefined}
|
||||||
onClick={() => pickCommonQuestion(question)}
|
onClick={() => pickCommonQuestion(question)}
|
||||||
>
|
>
|
||||||
<span aria-hidden="true" />
|
<span aria-hidden="true" />
|
||||||
@@ -185,6 +198,7 @@ export function PositionIntentInput({
|
|||||||
key={`${question.prompt}-${index}`}
|
key={`${question.prompt}-${index}`}
|
||||||
type="button"
|
type="button"
|
||||||
title={question.prompt}
|
title={question.prompt}
|
||||||
|
disabled={sendCommonQuestionOnClick ? isSending : undefined}
|
||||||
onClick={() => pickCommonQuestion(question)}
|
onClick={() => pickCommonQuestion(question)}
|
||||||
>
|
>
|
||||||
{question.title}
|
{question.title}
|
||||||
@@ -268,7 +282,7 @@ export function PositionIntentInput({
|
|||||||
<button
|
<button
|
||||||
id="send-message-btn"
|
id="send-message-btn"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={isSending ? onStop : onSend}
|
onClick={() => (isSending ? onStop() : onSend())}
|
||||||
disabled={isSending ? false : !canSend}
|
disabled={isSending ? false : !canSend}
|
||||||
aria-label={isSending ? "暂停生成" : "发送"}
|
aria-label={isSending ? "暂停生成" : "发送"}
|
||||||
title={isSending ? "暂停生成" : "发送"}
|
title={isSending ? "暂停生成" : "发送"}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowLeft, FileText, MessageSquarePlus, Square } from "lucide-react";
|
import { ArrowLeft, ClipboardList, FileText, MessageSquarePlus, Square } from "lucide-react";
|
||||||
|
|
||||||
import type { PagePhase } from "@/report-collaboration/state/selectors";
|
import type { PagePhase } from "@/report-collaboration/state/selectors";
|
||||||
|
|
||||||
|
/** 计划模式覆盖的阶段:第一条消息起到工作流确认执行前。 */
|
||||||
|
const PLAN_MODE_PHASES = new Set<PagePhase>(["clarifying", "planning", "proposal_ready"]);
|
||||||
|
|
||||||
const PHASE_BADGES: Record<PagePhase, { label: string; color: string; bg: string }> = {
|
const PHASE_BADGES: Record<PagePhase, { label: string; color: string; bg: string }> = {
|
||||||
empty: { label: "新会话", color: "var(--rc-text-secondary)", bg: "transparent" },
|
empty: { label: "新会话", color: "var(--rc-text-secondary)", bg: "transparent" },
|
||||||
clarifying: { label: "澄清需求", color: "var(--rc-waiting)", bg: "rgba(217, 119, 6, 0.1)" },
|
clarifying: { label: "澄清需求", color: "var(--rc-waiting)", bg: "rgba(217, 119, 6, 0.1)" },
|
||||||
@@ -51,6 +54,16 @@ export function ReportCollaborationHeader({
|
|||||||
<span className="rc-header-title" title={title}>
|
<span className="rc-header-title" title={title}>
|
||||||
报告协作 / {title}
|
报告协作 / {title}
|
||||||
</span>
|
</span>
|
||||||
|
{PLAN_MODE_PHASES.has(phase) ? (
|
||||||
|
<span
|
||||||
|
className="rc-chip"
|
||||||
|
title="规划师正在梳理需求并规划工作流,确认后开始执行"
|
||||||
|
style={{ background: "rgba(37, 99, 235, 0.1)", color: "var(--rc-accent)" }}
|
||||||
|
>
|
||||||
|
<ClipboardList className="size-3" aria-hidden />
|
||||||
|
计划模式
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<span className="rc-chip" style={{ background: badge.bg, color: badge.color }}>
|
<span className="rc-chip" style={{ background: badge.bg, color: badge.color }}>
|
||||||
{badge.label}
|
{badge.label}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -156,8 +156,23 @@ function CanvasInner({ plan, nodeStates, selectedNodeId, onSelectNode, emptyHint
|
|||||||
const edges: Edge[] = useMemo(() => {
|
const edges: Edge[] = useMemo(() => {
|
||||||
if (!plan) return [];
|
if (!plan) return [];
|
||||||
return plan.edges.map((edge, index) => {
|
return plan.edges.map((edge, index) => {
|
||||||
const targetState = nodeStates[edge.target];
|
// 边语义 = 数据正在流入 target 节点:target 执行中 → 流动动画;
|
||||||
const active = targetState?.status === "running" || targetState?.status === "validating";
|
// 失败红虚线 / 等待橙 / 已被替代降灰,其余静态。
|
||||||
|
const targetStatus = nodeStates[edge.target]?.status;
|
||||||
|
let className = "rc-edge";
|
||||||
|
let stroke = "var(--rc-flow-edge)";
|
||||||
|
if (targetStatus === "running" || targetStatus === "validating") {
|
||||||
|
className = "rc-edge-active";
|
||||||
|
stroke = "var(--rc-running)";
|
||||||
|
} else if (targetStatus === "failed") {
|
||||||
|
className = "rc-edge-failed";
|
||||||
|
stroke = "var(--rc-danger)";
|
||||||
|
} else if (targetStatus === "awaiting_input") {
|
||||||
|
className = "rc-edge-waiting";
|
||||||
|
stroke = "var(--rc-waiting)";
|
||||||
|
} else if (targetStatus === "superseded" || targetStatus === "interrupted" || targetStatus === "cancelled") {
|
||||||
|
className = "rc-edge-dimmed";
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: `${edge.source}->${edge.target}-${index}`,
|
id: `${edge.source}->${edge.target}-${index}`,
|
||||||
source: edge.source,
|
source: edge.source,
|
||||||
@@ -165,11 +180,8 @@ function CanvasInner({ plan, nodeStates, selectedNodeId, onSelectNode, emptyHint
|
|||||||
label: edge.label ?? undefined,
|
label: edge.label ?? undefined,
|
||||||
type: "default",
|
type: "default",
|
||||||
animated: false,
|
animated: false,
|
||||||
className: active ? "rc-edge-active" : "rc-edge",
|
className,
|
||||||
style: {
|
style: { stroke, strokeWidth: 2 },
|
||||||
stroke: active ? "var(--rc-running)" : "var(--rc-flow-edge)",
|
|
||||||
strokeWidth: 2,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [plan, nodeStates]);
|
}, [plan, nodeStates]);
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ClipboardCheck } from "lucide-react";
|
||||||
|
|
||||||
|
import type { ReportRequirementSnapshot } from "@/report-collaboration/api/types";
|
||||||
|
|
||||||
|
interface RequirementSummaryCardProps {
|
||||||
|
requirement: ReportRequirementSnapshot;
|
||||||
|
/** 需求仍可修改的提示(生成方案前);方案已出后为 false。 */
|
||||||
|
editable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, value }: { label: string; value: string | null | undefined }) {
|
||||||
|
if (!value) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 text-[12px] leading-5">
|
||||||
|
<span className="w-16 shrink-0 text-[var(--rc-text-secondary)]">{label}</span>
|
||||||
|
<span className="min-w-0 flex-1 break-words">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计划模式 · 需求摘要卡:结构化展示规划师确认的需求快照。 */
|
||||||
|
export function RequirementSummaryCard({ requirement, editable = true }: RequirementSummaryCardProps) {
|
||||||
|
const angles = (requirement.required_angles ?? []).filter(Boolean).join("、");
|
||||||
|
return (
|
||||||
|
<div className="rc-card mx-auto w-full max-w-[560px] rounded-xl border border-[var(--rc-border)] bg-[var(--rc-bg-panel)] p-3.5">
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<ClipboardCheck className="size-4" style={{ color: "var(--rc-accent)" }} aria-hidden />
|
||||||
|
<span className="text-[13px] font-semibold">需求摘要 · v{requirement.revision}</span>
|
||||||
|
<span className="rc-chip ml-auto" style={{ background: "rgba(37,99,235,.1)", color: "var(--rc-accent)" }}>
|
||||||
|
计划模式
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Field label="主题" value={requirement.topic} />
|
||||||
|
<Field label="目的" value={requirement.objective} />
|
||||||
|
<Field label="读者" value={requirement.audience} />
|
||||||
|
<Field label="角度" value={angles || null} />
|
||||||
|
<Field label="时间范围" value={requirement.time_range} />
|
||||||
|
<Field label="地域" value={requirement.geography} />
|
||||||
|
<Field label="篇幅" value={requirement.length_target} />
|
||||||
|
<Field label="语气" value={requirement.tone} />
|
||||||
|
</div>
|
||||||
|
{editable ? (
|
||||||
|
<p className="mt-2.5 border-t border-[var(--rc-border)] pt-2 text-[11px] leading-4 text-[var(--rc-text-secondary)]">
|
||||||
|
我正在基于这份需求为你规划工作流,稍候即可在对话中查看方案;想调整可直接在下方补充。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import type { Message } from "@langchain/langgraph-sdk";
|
import type { Message } from "@langchain/langgraph-sdk";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { FileText, Loader2, Sparkles } from "lucide-react";
|
import { FileText, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from "@/report-collaboration/components/ReportPreviewDialog";
|
} from "@/report-collaboration/components/ReportPreviewDialog";
|
||||||
import { ReportSandboxOverlay } from "@/report-collaboration/components/ReportSandboxOverlay";
|
import { ReportSandboxOverlay } from "@/report-collaboration/components/ReportSandboxOverlay";
|
||||||
import { ReportSpeakerShell } from "@/report-collaboration/components/ReportSpeakerShell";
|
import { ReportSpeakerShell } from "@/report-collaboration/components/ReportSpeakerShell";
|
||||||
|
import { RequirementSummaryCard } from "@/report-collaboration/components/RequirementSummaryCard";
|
||||||
import { SessionHistoryPanel } from "@/report-collaboration/components/SessionHistoryPanel";
|
import { SessionHistoryPanel } from "@/report-collaboration/components/SessionHistoryPanel";
|
||||||
import { useReportPanelLayout } from "@/report-collaboration/hooks/useReportPanelLayout";
|
import { useReportPanelLayout } from "@/report-collaboration/hooks/useReportPanelLayout";
|
||||||
import { useReportCollaborationMessages } from "@/report-collaboration/hooks/useReportCollaborationMessages";
|
import { useReportCollaborationMessages } from "@/report-collaboration/hooks/useReportCollaborationMessages";
|
||||||
@@ -35,6 +36,7 @@ import {
|
|||||||
buildCommandConfirmCard,
|
buildCommandConfirmCard,
|
||||||
buildPlanGroupCard,
|
buildPlanGroupCard,
|
||||||
buildReportAvailableCard,
|
buildReportAvailableCard,
|
||||||
|
buildRequirementReadyCard,
|
||||||
readReportCollaborationCard,
|
readReportCollaborationCard,
|
||||||
} from "@/report-collaboration/state/message-adapter";
|
} from "@/report-collaboration/state/message-adapter";
|
||||||
import { resolveReportSpeaker } from "@/report-collaboration/state/speaker";
|
import { resolveReportSpeaker } from "@/report-collaboration/state/speaker";
|
||||||
@@ -247,11 +249,23 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
|
|||||||
if (next) setFocusedAgentRunId(next);
|
if (next) setFocusedAgentRunId(next);
|
||||||
}, [agentTabs, focusPinned, focusedAgentRunId, tabIds]);
|
}, [agentTabs, focusPinned, focusedAgentRunId, tabIds]);
|
||||||
|
|
||||||
// 虚拟业务卡:方案组(已出方案且 run 未开始)/ 命令确认 / 报告入口。
|
// 虚拟业务卡:需求摘要(计划模式)/ 方案组(已出方案且 run 未开始)/ 命令确认 / 报告入口。
|
||||||
// 采用方案后卡片保留,「开始协作」按钮随选定态启用;run 开始后消失。
|
// 采用方案后卡片保留,「开始协作」按钮随选定态启用;run 开始后消失。
|
||||||
const runStarted = Boolean(state.run && state.run.plan_id);
|
const runStarted = Boolean(state.run && state.run.plan_id);
|
||||||
const extraCards = useMemo<Message[]>(() => {
|
const extraCards = useMemo<Message[]>(() => {
|
||||||
const cards: Message[] = [];
|
const cards: Message[] = [];
|
||||||
|
if (
|
||||||
|
state.requirement &&
|
||||||
|
state.plans.length === 0 &&
|
||||||
|
!runStarted &&
|
||||||
|
Object.values(state.messagesById).some(
|
||||||
|
(message) =>
|
||||||
|
message.role === "ai" &&
|
||||||
|
(message.metadata as Record<string, unknown> | null | undefined)?.ready_to_plan === true,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
cards.push(buildRequirementReadyCard(state.requirement));
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
state.plans.length > 0 &&
|
state.plans.length > 0 &&
|
||||||
!runStarted &&
|
!runStarted &&
|
||||||
@@ -341,39 +355,16 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
|
|||||||
[sessionId, session],
|
[sessionId, session],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 澄清完成(协调者已写入需求版本)但还没生成过方案:给出显式「发起规划」入口。
|
// 方案生成由后端在规划师判定 ready 后自动衔接(同一次请求内规划),
|
||||||
const requirementReady = useMemo(
|
// 前端无手动「生成候选方案」按钮;此处仅保留摘要卡的注入判定。
|
||||||
() =>
|
|
||||||
Object.values(state.messagesById).some(
|
|
||||||
(message) =>
|
|
||||||
message.role === "ai" &&
|
|
||||||
(message.metadata as Record<string, unknown> | null | undefined)?.ready_to_plan === true,
|
|
||||||
),
|
|
||||||
[state.messagesById],
|
|
||||||
);
|
|
||||||
const showPlanRequestEntry =
|
|
||||||
state.session?.status === "clarifying" && state.plans.length === 0 && requirementReady;
|
|
||||||
const [planRequestBusy, setPlanRequestBusy] = useState(false);
|
|
||||||
const handleRequestPlans = useCallback(async () => {
|
|
||||||
setPlanRequestBusy(true);
|
|
||||||
try {
|
|
||||||
await getReportCollaborationDriver().requestPlans(
|
|
||||||
sessionId,
|
|
||||||
state.session?.requirement_revision ?? null,
|
|
||||||
);
|
|
||||||
await session.refreshSnapshot();
|
|
||||||
toast.success("已生成候选方案,可在对话中选择");
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : String(err));
|
|
||||||
} finally {
|
|
||||||
setPlanRequestBusy(false);
|
|
||||||
}
|
|
||||||
}, [sessionId, session, state.session?.requirement_revision]);
|
|
||||||
|
|
||||||
const businessCardSlot = useCallback(
|
const businessCardSlot = useCallback(
|
||||||
(message: Message) => {
|
(message: Message) => {
|
||||||
const card = readReportCollaborationCard(message);
|
const card = readReportCollaborationCard(message);
|
||||||
if (!card) return null;
|
if (!card) return null;
|
||||||
|
if (card.kind === "requirement_ready") {
|
||||||
|
return <RequirementSummaryCard requirement={card.requirement} editable={!runStarted} />;
|
||||||
|
}
|
||||||
if (card.kind === "plan_group") {
|
if (card.kind === "plan_group") {
|
||||||
return (
|
return (
|
||||||
<ReportPlanCards
|
<ReportPlanCards
|
||||||
@@ -414,7 +405,7 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
[previewPlan, previewPlanId, session],
|
[previewPlan, previewPlanId, runStarted, session],
|
||||||
);
|
);
|
||||||
|
|
||||||
const speakerShell = useCallback(
|
const speakerShell = useCallback(
|
||||||
@@ -565,43 +556,19 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
composer={
|
composer={
|
||||||
<div className="flex flex-col">
|
<ReportComposer
|
||||||
{showPlanRequestEntry ? (
|
phase={phase}
|
||||||
<div className="mb-1.5 flex justify-center">
|
busy={session.sendBusy}
|
||||||
<button
|
target={composerTarget}
|
||||||
type="button"
|
onClearTarget={() => setComposerTarget(null)}
|
||||||
className="rc-btn"
|
onSend={(text) =>
|
||||||
style={{
|
void session.send(text, {
|
||||||
background: "var(--rc-accent)",
|
nodeId: composerTarget?.nodeId ?? null,
|
||||||
color: "#fff",
|
|
||||||
borderColor: "var(--rc-accent)",
|
|
||||||
}}
|
|
||||||
disabled={planRequestBusy}
|
|
||||||
onClick={() => void handleRequestPlans()}
|
|
||||||
>
|
|
||||||
{planRequestBusy ? (
|
|
||||||
<Loader2 className="rc-spin size-3.5" aria-hidden />
|
|
||||||
) : (
|
|
||||||
<Sparkles className="size-3.5" aria-hidden />
|
|
||||||
)}
|
|
||||||
{planRequestBusy ? "正在生成候选方案…" : "需求已就绪,生成候选方案"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<ReportComposer
|
|
||||||
phase={phase}
|
|
||||||
busy={session.sendBusy}
|
|
||||||
target={composerTarget}
|
|
||||||
onClearTarget={() => setComposerTarget(null)}
|
|
||||||
onSend={(text) =>
|
|
||||||
void session.send(text, {
|
|
||||||
nodeId: composerTarget?.nodeId ?? null,
|
|
||||||
agentRunId: composerTarget?.agentRunId ?? null,
|
agentRunId: composerTarget?.agentRunId ?? null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
focusSignal={focusSignal}
|
focusSignal={focusSignal}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
CollaborationCommand,
|
CollaborationCommand,
|
||||||
CollaborationMessage,
|
CollaborationMessage,
|
||||||
ReportPlanCandidate,
|
ReportPlanCandidate,
|
||||||
|
ReportRequirementSnapshot,
|
||||||
ReportVersion,
|
ReportVersion,
|
||||||
} from "@/report-collaboration/api/types";
|
} from "@/report-collaboration/api/types";
|
||||||
|
|
||||||
@@ -113,6 +114,11 @@ export type ReportCollaborationCardPayload =
|
|||||||
selectedPlanId: string | null;
|
selectedPlanId: string | null;
|
||||||
requirementRevision: number;
|
requirementRevision: number;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
kind: "requirement_ready";
|
||||||
|
requirement: ReportRequirementSnapshot;
|
||||||
|
revision: number;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
kind: "command_confirm";
|
kind: "command_confirm";
|
||||||
command: CollaborationCommand;
|
command: CollaborationCommand;
|
||||||
@@ -163,6 +169,17 @@ export function buildPlanGroupCard(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 需求摘要卡(计划模式:需求已就绪、尚未生成方案)。 */
|
||||||
|
export function buildRequirementReadyCard(
|
||||||
|
requirement: ReportRequirementSnapshot,
|
||||||
|
): Message {
|
||||||
|
return buildCardMessage(`rc-card-requirement-r${requirement.revision}`, {
|
||||||
|
kind: "requirement_ready",
|
||||||
|
requirement,
|
||||||
|
revision: requirement.revision,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** 命令确认卡(command.confirmation.required 之后)。 */
|
/** 命令确认卡(command.confirmation.required 之后)。 */
|
||||||
export function buildCommandConfirmCard(command: CollaborationCommand): Message {
|
export function buildCommandConfirmCard(command: CollaborationCommand): Message {
|
||||||
return buildCardMessage(`rc-card-confirm-${command.command_id}`, {
|
return buildCardMessage(`rc-card-confirm-${command.command_id}`, {
|
||||||
|
|||||||
@@ -850,6 +850,21 @@
|
|||||||
animation: rc-edge-flow 0.5s linear infinite;
|
animation: rc-edge-flow 0.5s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* P1.4:失败红虚线 / 等待用户橙色 / 已替代·中断降灰 */
|
||||||
|
.rc-edge-failed .react-flow__edge-path {
|
||||||
|
stroke: var(--rc-danger);
|
||||||
|
stroke-dasharray: 4 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rc-edge-waiting .react-flow__edge-path {
|
||||||
|
stroke: var(--rc-waiting);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rc-edge-dimmed .react-flow__edge-path {
|
||||||
|
stroke: var(--rc-flow-edge);
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes rc-edge-flow {
|
@keyframes rc-edge-flow {
|
||||||
from {
|
from {
|
||||||
stroke-dashoffset: 10;
|
stroke-dashoffset: 10;
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ export type { ClarificationPayload };
|
|||||||
export const INTENT_AGENT_ID = "roundtable-intent";
|
export const INTENT_AGENT_ID = "roundtable-intent";
|
||||||
/** Dedicated Step 1 agent for the position-roundtable workspace. */
|
/** Dedicated Step 1 agent for the position-roundtable workspace. */
|
||||||
export const POSITION_INTENT_AGENT_ID = "position-roundtable-intent";
|
export const POSITION_INTENT_AGENT_ID = "position-roundtable-intent";
|
||||||
|
/** Dedicated Step 1 agent for the 8BF roundtable workspace (same prompt family
|
||||||
|
* as `roundtable-intent`, independently tunable via the agent config UI). */
|
||||||
|
export const EIGHTBF_INTENT_AGENT_ID = "eightbf-roundtable-intent";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Task-intent agent (Step 1) API. Mirror of multi-agent.ts but with a single
|
* Task-intent agent (Step 1) API. Mirror of multi-agent.ts but with a single
|
||||||
|
|||||||
@@ -39,11 +39,16 @@ export interface ChainFlowStage {
|
|||||||
goal?: string | null;
|
goal?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 节点执行状态(任务分析回放传入;缺省展示 Ready)。 */
|
||||||
|
export type ChainFlowSeatState = "waiting" | "running" | "done" | "failed";
|
||||||
|
|
||||||
export interface ChainFlowDiagramProps {
|
export interface ChainFlowDiagramProps {
|
||||||
stages: ChainFlowStage[];
|
stages: ChainFlowStage[];
|
||||||
/** 点击席位节点卡片(总控节点不触发)。 */
|
/** 点击席位节点卡片(总控节点不触发)。 */
|
||||||
onNodeClick?: (agentId: string, name?: string) => void;
|
onNodeClick?: (agentId: string, name?: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** 传入时节点卡片叠加执行状态徽标(任务分析的运行回放);不影响原有展示。 */
|
||||||
|
seatStates?: Record<string, { state: ChainFlowSeatState; durationMs?: number }> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 布局常量(世界坐标,单位 px)─────────────────────────────────────────────
|
// ── 布局常量(世界坐标,单位 px)─────────────────────────────────────────────
|
||||||
@@ -84,7 +89,7 @@ interface PlacedNode {
|
|||||||
rightX: number;
|
rightX: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChainFlowDiagram({ stages, onNodeClick, className }: ChainFlowDiagramProps) {
|
export function ChainFlowDiagram({ stages, onNodeClick, className, seatStates }: ChainFlowDiagramProps) {
|
||||||
// ── 列 / 节点 / 连线 / 标签布局 ──────────────────────────────────────────
|
// ── 列 / 节点 / 连线 / 标签布局 ──────────────────────────────────────────
|
||||||
const layout = useMemo(() => {
|
const layout = useMemo(() => {
|
||||||
// 列:总控 + 各 Stage。
|
// 列:总控 + 各 Stage。
|
||||||
@@ -315,15 +320,43 @@ export function ChainFlowDiagram({ stages, onNodeClick, className }: ChainFlowDi
|
|||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/* 节点卡片(参考「业务链条配置器」样式:左彩色描边 + 头像 + 名称 +
|
{/* 节点卡片(参考「业务链条配置器」样式:左彩色描边 + 头像 + 名称 +
|
||||||
L-阶段徽标 + 同色角色行 + 底部 Ready 心跳条;图标仍用我方 getAvatar)。 */}
|
L-阶段徽标 + 同色角色行 + 底部 Ready 心跳条;图标仍用我方 getAvatar)。
|
||||||
|
seatStates 传入时(任务分析回放)叠加执行状态徽标与状态描边。 */}
|
||||||
{layout.cols.flatMap((col, colIdx) =>
|
{layout.cols.flatMap((col, colIdx) =>
|
||||||
col.nodes.map((n) => {
|
col.nodes.map((n) => {
|
||||||
const isCoordinator = n.id === COORDINATOR_ID;
|
const isCoordinator = n.id === COORDINATOR_ID;
|
||||||
const nc = nodeColorOf(n.avatarType);
|
const nc = nodeColorOf(n.avatarType);
|
||||||
|
const seatState = !isCoordinator && seatStates ? seatStates[n.id] : undefined;
|
||||||
|
const state = seatState?.state;
|
||||||
|
const stateStyle = state
|
||||||
|
? ({
|
||||||
|
waiting: "text-muted-foreground",
|
||||||
|
running: "text-amber-600 dark:text-amber-300",
|
||||||
|
done: "text-emerald-600 dark:text-emerald-300",
|
||||||
|
failed: "text-red-600 dark:text-red-400",
|
||||||
|
} as const)[state]
|
||||||
|
: null;
|
||||||
|
const stateLabel =
|
||||||
|
state === "waiting"
|
||||||
|
? "等待"
|
||||||
|
: state === "running"
|
||||||
|
? "执行中"
|
||||||
|
: state === "done"
|
||||||
|
? "完成"
|
||||||
|
: state === "failed"
|
||||||
|
? "失败"
|
||||||
|
: null;
|
||||||
|
const stateSeconds = seatState?.durationMs
|
||||||
|
? Math.max(1, Math.round(seatState.durationMs / 1000))
|
||||||
|
: null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={n.id}
|
key={n.id}
|
||||||
className={cn("absolute", !isCoordinator && "cursor-pointer")}
|
className={cn(
|
||||||
|
"absolute",
|
||||||
|
!isCoordinator && "cursor-pointer",
|
||||||
|
state === "waiting" && "opacity-60",
|
||||||
|
)}
|
||||||
style={{ left: n.x, top: n.y, width: NODE_W, height: NODE_H }}
|
style={{ left: n.x, top: n.y, width: NODE_W, height: NODE_H }}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -339,9 +372,15 @@ export function ChainFlowDiagram({ stages, onNodeClick, className }: ChainFlowDi
|
|||||||
isCoordinator
|
isCoordinator
|
||||||
? "border-border bg-primary/[0.04]"
|
? "border-border bg-primary/[0.04]"
|
||||||
: "border-border hover:-translate-y-0.5 hover:shadow-[0_8px_22px_rgba(15,23,42,0.10)]",
|
: "border-border hover:-translate-y-0.5 hover:shadow-[0_8px_22px_rgba(15,23,42,0.10)]",
|
||||||
|
state === "running" && "border-amber-400/80",
|
||||||
|
state === "failed" && "border-red-500/70",
|
||||||
)}
|
)}
|
||||||
// 左侧 4px 彩色描边:内联上色,避免被 border-border 的四边色覆盖。
|
// 左侧 4px 彩色描边:内联上色,避免被 border-border 的四边色覆盖。
|
||||||
style={{ borderLeftColor: nc.hex, borderLeftWidth: 4 }}
|
style={{
|
||||||
|
borderLeftColor: nc.hex,
|
||||||
|
borderLeftWidth: 4,
|
||||||
|
...(state === "running" ? { boxShadow: "0 0 0 3px rgba(245,158,11,0.15)" } : {}),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* 上:头像 + 名称 + 阶段徽标 + 角色行 */}
|
{/* 上:头像 + 名称 + 阶段徽标 + 角色行 */}
|
||||||
<div className="flex items-start gap-2 overflow-hidden">
|
<div className="flex items-start gap-2 overflow-hidden">
|
||||||
@@ -359,13 +398,34 @@ export function ChainFlowDiagram({ stages, onNodeClick, className }: ChainFlowDi
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 下:身份 + Ready 心跳条 */}
|
{/* 下:身份 + 状态行(回放模式显示执行状态,否则 Ready 心跳条) */}
|
||||||
<div className="flex items-center justify-between border-t border-border/60 pt-1 text-[8px] text-muted-foreground">
|
<div className="flex items-center justify-between border-t border-border/60 pt-1 text-[8px] text-muted-foreground">
|
||||||
<span className="truncate">{isCoordinator ? "总控协调核心" : "智能体席位"}</span>
|
<span className="truncate">{isCoordinator ? "总控协调核心" : "智能体席位"}</span>
|
||||||
<span className="ml-1.5 flex shrink-0 items-center gap-1 rounded bg-muted px-1 py-0.5 font-medium text-primary">
|
{state && stateLabel ? (
|
||||||
<span className="size-1 rounded-full bg-emerald-500" />
|
<span
|
||||||
Ready
|
className={cn(
|
||||||
</span>
|
"ml-1.5 flex shrink-0 items-center gap-1 rounded px-1 py-0.5 font-medium",
|
||||||
|
stateStyle,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"size-1.5 rounded-full",
|
||||||
|
state === "done" && "bg-emerald-500",
|
||||||
|
state === "running" && "animate-pulse bg-amber-500",
|
||||||
|
state === "failed" && "bg-red-500",
|
||||||
|
state === "waiting" && "bg-muted-foreground/60",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{stateLabel}
|
||||||
|
{stateSeconds != null && state !== "waiting" ? ` · ${stateSeconds}s` : ""}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="ml-1.5 flex shrink-0 items-center gap-1 rounded bg-muted px-1 py-0.5 font-medium text-primary">
|
||||||
|
<span className="size-1 rounded-full bg-emerald-500" />
|
||||||
|
Ready
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -445,22 +505,33 @@ export function ChainFlowDiagram({ stages, onNodeClick, className }: ChainFlowDi
|
|||||||
style={{ width: mmW, height: mmH }}
|
style={{ width: mmW, height: mmH }}
|
||||||
onClick={onMinimapClick}
|
onClick={onMinimapClick}
|
||||||
>
|
>
|
||||||
{/* 节点缩略块 */}
|
{/* 节点缩略块(回放模式按状态着色) */}
|
||||||
{layout.allNodes.map((n) => (
|
{layout.allNodes.map((n) => {
|
||||||
<div
|
const state = n.id === COORDINATOR_ID ? null : seatStates?.[n.id]?.state ?? null;
|
||||||
key={n.id}
|
return (
|
||||||
className={cn(
|
<div
|
||||||
"absolute rounded-[1px]",
|
key={n.id}
|
||||||
n.id === COORDINATOR_ID ? "bg-primary/70" : "bg-foreground/35",
|
className={cn(
|
||||||
)}
|
"absolute rounded-[1px]",
|
||||||
style={{
|
n.id === COORDINATOR_ID
|
||||||
left: n.x * mmScale,
|
? "bg-primary/70"
|
||||||
top: n.y * mmScale,
|
: state === "done"
|
||||||
width: NODE_W * mmScale,
|
? "bg-emerald-500/70"
|
||||||
height: NODE_H * mmScale,
|
: state === "running"
|
||||||
}}
|
? "bg-amber-500/80"
|
||||||
/>
|
: state === "failed"
|
||||||
))}
|
? "bg-red-500/80"
|
||||||
|
: "bg-foreground/35",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: n.x * mmScale,
|
||||||
|
top: n.y * mmScale,
|
||||||
|
width: NODE_W * mmScale,
|
||||||
|
height: NODE_H * mmScale,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{/* 当前视口框 */}
|
{/* 当前视口框 */}
|
||||||
<div
|
<div
|
||||||
className="absolute border-2 border-primary bg-primary/10"
|
className="absolute border-2 border-primary bg-primary/10"
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ export interface Step1InputAreaProps {
|
|||||||
isSending: boolean;
|
isSending: boolean;
|
||||||
canSend: boolean;
|
canSend: boolean;
|
||||||
pendingClarification: boolean;
|
pendingClarification: boolean;
|
||||||
onSend: () => void;
|
/** 发送当前输入框内容;传 `overrideText` 时直接发送该文本(常用问题一键发送)。 */
|
||||||
|
onSend: (overrideText?: string) => void;
|
||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
onFollowUp: () => void;
|
onFollowUp: () => void;
|
||||||
onSkip: () => void;
|
onSkip: () => void;
|
||||||
@@ -236,9 +237,12 @@ export function Step1Panel(props: Step1PanelProps) {
|
|||||||
setAttachedFiles((prev) => prev.filter((_, i) => i !== idx));
|
setAttachedFiles((prev) => prev.filter((_, i) => i !== idx));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = (overrideText?: string) => {
|
||||||
if (!inputText.trim() && attachedFiles.length === 0) return;
|
// overrideText:常用问题 chip 一键发送——绕过异步的输入框 state,
|
||||||
onSendMessage(inputText, attachedFiles.length > 0 ? attachedFiles : undefined);
|
// 直接以问题全文作为本回合消息(仍共享附件与发送后的清空逻辑)。
|
||||||
|
const textToSend = overrideText ?? inputText;
|
||||||
|
if (!textToSend.trim() && attachedFiles.length === 0) return;
|
||||||
|
onSendMessage(textToSend, attachedFiles.length > 0 ? attachedFiles : undefined);
|
||||||
handleInputTextChange("");
|
handleInputTextChange("");
|
||||||
setAttachedFiles([]);
|
setAttachedFiles([]);
|
||||||
};
|
};
|
||||||
@@ -411,7 +415,7 @@ export function Step1Panel(props: Step1PanelProps) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
id="send-message-btn"
|
id="send-message-btn"
|
||||||
onClick={handleSend}
|
onClick={() => handleSend()}
|
||||||
disabled={isIntentSending || !intentThreadId}
|
disabled={isIntentSending || !intentThreadId}
|
||||||
className="bg-primary text-white hover:bg-[var(--brand-900)] disabled:bg-slate-300 disabled:cursor-not-allowed text-xs font-bold px-4 py-2.5 rounded-xl flex items-center gap-1.5 transition-all shadow-[0_2px_8px_rgba(245,34,45,0.15)] active:scale-95"
|
className="bg-primary text-white hover:bg-[var(--brand-900)] disabled:bg-slate-300 disabled:cursor-not-allowed text-xs font-bold px-4 py-2.5 rounded-xl flex items-center gap-1.5 transition-all shadow-[0_2px_8px_rgba(245,34,45,0.15)] active:scale-95"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -422,6 +422,12 @@ export interface UseStep2OrchestrationOptions {
|
|||||||
seatSkillDirectives?: Record<string, boolean>;
|
seatSkillDirectives?: Record<string, boolean>;
|
||||||
/** Per-seat human-validation checkpoints inherited from the selected chain. */
|
/** Per-seat human-validation checkpoints inherited from the selected chain. */
|
||||||
humanValidations?: Record<string, boolean>;
|
humanValidations?: Record<string, boolean>;
|
||||||
|
/**
|
||||||
|
* 全局「人工干预」开关(8BF 页头):开启后第二步每走一步(链条每席位 / DAG 每 stage
|
||||||
|
* 完成)就暂停等待用户输入或恢复;关闭则全自动推进,与既有行为一致。与 per-seat
|
||||||
|
* `humanValidations` 取 OR——任一生效即在最近一步边界暂停。
|
||||||
|
*/
|
||||||
|
manualIntervention?: boolean;
|
||||||
/**
|
/**
|
||||||
* 每席位「推理深度覆盖」映射 {agentId: SeatMode | null}(沿用所选业务链条里每个席位的配置)。
|
* 每席位「推理深度覆盖」映射 {agentId: SeatMode | null}(沿用所选业务链条里每个席位的配置)。
|
||||||
* 派活该 special 席位时按 agentId 查表,**席位覆盖优先、否则跟随链条级 `selectedMode`**:
|
* 派活该 special 席位时按 agentId 查表,**席位覆盖优先、否则跟随链条级 `selectedMode`**:
|
||||||
@@ -593,6 +599,7 @@ export function useStep2Orchestration(
|
|||||||
selectedMode,
|
selectedMode,
|
||||||
seatSkillDirectives = {},
|
seatSkillDirectives = {},
|
||||||
humanValidations = {},
|
humanValidations = {},
|
||||||
|
manualIntervention = false,
|
||||||
seatModes = {},
|
seatModes = {},
|
||||||
gatherFirst = false,
|
gatherFirst = false,
|
||||||
orchestrationMode,
|
orchestrationMode,
|
||||||
@@ -746,6 +753,9 @@ export function useStep2Orchestration(
|
|||||||
// 每席位技能识别强化映射(所选业务链条各席位开关)同样打到 ref,席位调用按 agentId 读最新值。
|
// 每席位技能识别强化映射(所选业务链条各席位开关)同样打到 ref,席位调用按 agentId 读最新值。
|
||||||
const seatSkillDirectivesRef = useRef<Record<string, boolean>>(seatSkillDirectives);
|
const seatSkillDirectivesRef = useRef<Record<string, boolean>>(seatSkillDirectives);
|
||||||
const humanValidationsRef = useRef<Record<string, boolean>>(humanValidations);
|
const humanValidationsRef = useRef<Record<string, boolean>>(humanValidations);
|
||||||
|
// 全局「人工干预」开关(8BF 页头):编排循环在每个一步边界读最新值,
|
||||||
|
// 中途切换只影响下一次边界,不打断在途 run。
|
||||||
|
const manualInterventionRef = useRef<boolean>(manualIntervention);
|
||||||
// 每席位推理深度覆盖映射(所选业务链条各席位 seatMode)打到 ref,席位调用按 agentId 读最新值;
|
// 每席位推理深度覆盖映射(所选业务链条各席位 seatMode)打到 ref,席位调用按 agentId 读最新值;
|
||||||
// 覆盖优先、否则跟随 selectedModeRef。
|
// 覆盖优先、否则跟随 selectedModeRef。
|
||||||
const seatModesRef = useRef<Record<string, SeatMode | null>>(seatModes);
|
const seatModesRef = useRef<Record<string, SeatMode | null>>(seatModes);
|
||||||
@@ -800,6 +810,9 @@ export function useStep2Orchestration(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
humanValidationsRef.current = humanValidations;
|
humanValidationsRef.current = humanValidations;
|
||||||
}, [humanValidations]);
|
}, [humanValidations]);
|
||||||
|
useEffect(() => {
|
||||||
|
manualInterventionRef.current = manualIntervention;
|
||||||
|
}, [manualIntervention]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
seatModesRef.current = seatModes;
|
seatModesRef.current = seatModes;
|
||||||
}, [seatModes]);
|
}, [seatModes]);
|
||||||
@@ -1681,12 +1694,16 @@ export function useStep2Orchestration(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const pauseForHumanValidation = useCallback(
|
const pauseForHumanValidation = useCallback(
|
||||||
(continuation: HumanValidationContinuation, seatNames: string[]) => {
|
(
|
||||||
|
continuation: HumanValidationContinuation,
|
||||||
|
seatNames: string[],
|
||||||
|
source = "业务链条配置",
|
||||||
|
) => {
|
||||||
humanValidationContinuationRef.current = continuation;
|
humanValidationContinuationRef.current = continuation;
|
||||||
appendDialogue({
|
appendDialogue({
|
||||||
sender: "系统",
|
sender: "系统",
|
||||||
avatarType: "blue",
|
avatarType: "blue",
|
||||||
content: `已完成 ${seatNames.join("、")} 的输出,按业务链条配置暂停,等待人工校验。可直接点击“恢复会商”继续,或在下方输入修改意见后提交。`,
|
content: `已完成 ${seatNames.join("、")} 的输出,按${source}暂停,等待人工校验。可直接点击“恢复会商”继续,或在下方输入修改意见后提交。`,
|
||||||
confidence: "等待人工校验",
|
confidence: "等待人工校验",
|
||||||
});
|
});
|
||||||
setIsRoundTableRunning(false);
|
setIsRoundTableRunning(false);
|
||||||
@@ -2793,18 +2810,27 @@ export function useStep2Orchestration(
|
|||||||
}
|
}
|
||||||
if (!chainInvalidNames.includes(seatName)) chainInvalidNames.push(seatName);
|
if (!chainInvalidNames.includes(seatName)) chainInvalidNames.push(seatName);
|
||||||
finishedAllSeats = false;
|
finishedAllSeats = false;
|
||||||
if (humanValidationsRef.current[seat.agent_id]) {
|
if (manualInterventionRef.current || humanValidationsRef.current[seat.agent_id]) {
|
||||||
pauseForHumanValidation({ mode: "chain", nextSeatIndex: i }, [seatName]);
|
pauseForHumanValidation(
|
||||||
|
{ mode: "chain", nextSeatIndex: i },
|
||||||
|
[seatName],
|
||||||
|
manualInterventionRef.current && !humanValidationsRef.current[seat.agent_id]
|
||||||
|
? "人工干预开关"
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
setConsensusPercentage(Math.min(95, Math.round(((i + 1) / total) * 90)));
|
setConsensusPercentage(Math.min(95, Math.round(((i + 1) / total) * 90)));
|
||||||
if (humanValidationsRef.current[seat.agent_id]) {
|
if (manualInterventionRef.current || humanValidationsRef.current[seat.agent_id]) {
|
||||||
pauseForHumanValidation(
|
pauseForHumanValidation(
|
||||||
{ mode: "chain", nextSeatIndex: i + 1 },
|
{ mode: "chain", nextSeatIndex: i + 1 },
|
||||||
[seatName],
|
[seatName],
|
||||||
|
manualInterventionRef.current && !humanValidationsRef.current[seat.agent_id]
|
||||||
|
? "人工干预开关"
|
||||||
|
: undefined,
|
||||||
);
|
);
|
||||||
finishedAllSeats = false;
|
finishedAllSeats = false;
|
||||||
break;
|
break;
|
||||||
@@ -3237,8 +3263,11 @@ export function useStep2Orchestration(
|
|||||||
setConsensusPercentage(
|
setConsensusPercentage(
|
||||||
Math.min(95, Math.round(((s + 1) / plan.stages.length) * 90)),
|
Math.min(95, Math.round(((s + 1) / plan.stages.length) * 90)),
|
||||||
);
|
);
|
||||||
|
const manualInterventionOn = manualInterventionRef.current;
|
||||||
const validationSeatNames = stageSeats
|
const validationSeatNames = stageSeats
|
||||||
.filter(({ agentId }) => humanValidationsRef.current[agentId])
|
.filter(
|
||||||
|
({ agentId }) => manualInterventionOn || humanValidationsRef.current[agentId],
|
||||||
|
)
|
||||||
.map(({ name }) => name);
|
.map(({ name }) => name);
|
||||||
if (validationSeatNames.length > 0) {
|
if (validationSeatNames.length > 0) {
|
||||||
pauseForHumanValidation(
|
pauseForHumanValidation(
|
||||||
@@ -3248,6 +3277,7 @@ export function useStep2Orchestration(
|
|||||||
priorDeliveries: [...priorDeliveries],
|
priorDeliveries: [...priorDeliveries],
|
||||||
},
|
},
|
||||||
validationSeatNames,
|
validationSeatNames,
|
||||||
|
manualInterventionOn ? "人工干预开关" : undefined,
|
||||||
);
|
);
|
||||||
finishedAllStages = false;
|
finishedAllStages = false;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
|
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
|
||||||
@@ -324,6 +325,15 @@ export default function RoundtablePlanningPage({
|
|||||||
entryRailTaskCardSlot,
|
entryRailTaskCardSlot,
|
||||||
/** 独立业务页传给意图智能体的隐藏任务上下文。 */
|
/** 独立业务页传给意图智能体的隐藏任务上下文。 */
|
||||||
taskContext,
|
taskContext,
|
||||||
|
/** 第一步绑定的内置意图智能体 id(如 8BF 的 eightbf-roundtable-intent)。
|
||||||
|
* 缺省沿用通用会商的 roundtable-intent,原多智能体会商入口行为不变。 */
|
||||||
|
intentAgentId,
|
||||||
|
/**
|
||||||
|
* 8BF 页头「人工干预」开关(受控):开启后第二步每走一步暂停等待用户输入,
|
||||||
|
* 关闭自动推进。提供 onStep2InterventionChange 时才渲染开关,通用会商页不受影响。
|
||||||
|
*/
|
||||||
|
step2InterventionEnabled,
|
||||||
|
onStep2InterventionChange,
|
||||||
/** 独立业务页的任务作用域,仅用于左栏记录筛选,不改变原深链逻辑。 */
|
/** 独立业务页的任务作用域,仅用于左栏记录筛选,不改变原深链逻辑。 */
|
||||||
entryRailTaskId,
|
entryRailTaskId,
|
||||||
/** 独立业务页的根节点样式类,避免修改原会商样式。 */
|
/** 独立业务页的根节点样式类,避免修改原会商样式。 */
|
||||||
@@ -367,6 +377,11 @@ export default function RoundtablePlanningPage({
|
|||||||
entryRailBusinessChainSlot?: ReactNode;
|
entryRailBusinessChainSlot?: ReactNode;
|
||||||
entryRailTaskCardSlot?: ReactNode;
|
entryRailTaskCardSlot?: ReactNode;
|
||||||
taskContext?: Record<string, unknown> | null;
|
taskContext?: Record<string, unknown> | null;
|
||||||
|
/** 第一步绑定的内置意图智能体 id;缺省走通用 roundtable-intent。 */
|
||||||
|
intentAgentId?: string;
|
||||||
|
/** 第二步「人工干预」开关(8BF 页头,新建任务按钮左侧)。 */
|
||||||
|
step2InterventionEnabled?: boolean;
|
||||||
|
onStep2InterventionChange?: (enabled: boolean) => void;
|
||||||
entryRailTaskId?: string | null;
|
entryRailTaskId?: string | null;
|
||||||
rootClassName?: string;
|
rootClassName?: string;
|
||||||
renderStep1?: (props: Step1PanelProps & {
|
renderStep1?: (props: Step1PanelProps & {
|
||||||
@@ -755,6 +770,7 @@ export default function RoundtablePlanningPage({
|
|||||||
selectedMode: seatMode,
|
selectedMode: seatMode,
|
||||||
seatSkillDirectives,
|
seatSkillDirectives,
|
||||||
humanValidations,
|
humanValidations,
|
||||||
|
manualIntervention: step2InterventionEnabled ?? false,
|
||||||
seatModes,
|
seatModes,
|
||||||
gatherFirst,
|
gatherFirst,
|
||||||
orchestrationMode,
|
orchestrationMode,
|
||||||
@@ -771,6 +787,7 @@ export default function RoundtablePlanningPage({
|
|||||||
selectedModel,
|
selectedModel,
|
||||||
triggerToast,
|
triggerToast,
|
||||||
taskContext,
|
taskContext,
|
||||||
|
intentAgentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const taskArtifactSubmission = useTaskArtifactSubmission(
|
const taskArtifactSubmission = useTaskArtifactSubmission(
|
||||||
@@ -2381,6 +2398,23 @@ export default function RoundtablePlanningPage({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 8BF:第二步「人工干预」开关。开启后第二步每走一步暂停,等待用户输入 /
|
||||||
|
恢复后继续;关闭则自动推进(与原行为一致)。仅独立业务页传入受控回调时渲染。 */}
|
||||||
|
{onStep2InterventionChange && (
|
||||||
|
<label
|
||||||
|
id="step2-intervention-toggle"
|
||||||
|
title="开启后:第二步每完成一步暂停,可在下方输入修改意见或点击“恢复会商”继续;关闭后:自动完成整个研讨"
|
||||||
|
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-2 transition cursor-pointer select-none dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={step2InterventionEnabled ?? false}
|
||||||
|
onCheckedChange={onStep2InterventionChange}
|
||||||
|
className="scale-90"
|
||||||
|
/>
|
||||||
|
人工干预
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 深链接(taskId)模式:任务固定,把「新建任务」换成「重新分析」——清空会话、对同一任务重做意图分析。 */}
|
{/* 深链接(taskId)模式:任务固定,把「新建任务」换成「重新分析」——清空会话、对同一任务重做意图分析。 */}
|
||||||
{prefillRef.current?.taskId ? (
|
{prefillRef.current?.taskId ? (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
/* ============================================================
|
||||||
|
智能体星轨页 — 深色主题(星海蓝)
|
||||||
|
只放颜色,结构与动画在 agents-orbit.css
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-dark {
|
||||||
|
color: #d9e8ff;
|
||||||
|
background:
|
||||||
|
radial-gradient(1100px 640px at 22% 40%, rgba(56, 130, 246, 0.18), transparent 62%),
|
||||||
|
radial-gradient(880px 560px at 80% 26%, rgba(139, 92, 246, 0.14), transparent 60%),
|
||||||
|
linear-gradient(158deg, #050b1e 0%, #081429 46%, #03060f 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 星点 */
|
||||||
|
.agents-orbit-page.ao-dark::before {
|
||||||
|
background-image:
|
||||||
|
radial-gradient(1.6px 1.6px at 11% 16%, rgba(255, 255, 255, 0.55), transparent 55%),
|
||||||
|
radial-gradient(1.2px 1.2px at 27% 74%, rgba(255, 255, 255, 0.35), transparent 55%),
|
||||||
|
radial-gradient(1.4px 1.4px at 42% 26%, rgba(255, 255, 255, 0.4), transparent 55%),
|
||||||
|
radial-gradient(1px 1px at 58% 82%, rgba(255, 255, 255, 0.3), transparent 55%),
|
||||||
|
radial-gradient(1.6px 1.6px at 66% 12%, rgba(255, 255, 255, 0.5), transparent 55%),
|
||||||
|
radial-gradient(1.2px 1.2px at 79% 58%, rgba(255, 255, 255, 0.35), transparent 55%),
|
||||||
|
radial-gradient(1px 1px at 88% 34%, rgba(255, 255, 255, 0.3), transparent 55%),
|
||||||
|
radial-gradient(1.4px 1.4px at 18% 88%, rgba(255, 255, 255, 0.35), transparent 55%),
|
||||||
|
radial-gradient(1px 1px at 35% 48%, rgba(255, 255, 255, 0.25), transparent 55%),
|
||||||
|
radial-gradient(1.2px 1.2px at 92% 78%, rgba(255, 255, 255, 0.3), transparent 55%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部标题 */
|
||||||
|
.agents-orbit-page.ao-dark .ao-header-title {
|
||||||
|
color: #cfe4ff;
|
||||||
|
text-shadow: 0 0 14px rgba(90, 160, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-dark .ao-header-link {
|
||||||
|
border-color: rgba(110, 170, 255, 0.35);
|
||||||
|
background: rgba(12, 28, 58, 0.7);
|
||||||
|
color: #9fc4f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-dark .ao-header-link:hover {
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: rgba(140, 205, 255, 0.8);
|
||||||
|
box-shadow: 0 0 14px rgba(90, 170, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 中心圆盘 */
|
||||||
|
.agents-orbit-page.ao-dark .ao-core {
|
||||||
|
background: radial-gradient(
|
||||||
|
circle at 34% 30%,
|
||||||
|
rgba(96, 165, 250, 0.95),
|
||||||
|
rgba(37, 99, 235, 0.55) 45%,
|
||||||
|
rgba(8, 20, 45, 0.92) 100%
|
||||||
|
);
|
||||||
|
border-color: rgba(125, 190, 255, 0.7);
|
||||||
|
box-shadow:
|
||||||
|
0 0 46px rgba(64, 140, 255, 0.5),
|
||||||
|
inset 0 0 34px rgba(120, 190, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-dark .ao-core-img {
|
||||||
|
color: #eaf4ff;
|
||||||
|
filter: drop-shadow(0 0 14px rgba(120, 190, 255, 0.85));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 圆环族 */
|
||||||
|
.agents-orbit-page.ao-dark .ao-ring--dash {
|
||||||
|
border-color: rgba(110, 170, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-dark .ao-ring--arc {
|
||||||
|
border-top-color: rgba(88, 200, 255, 0.9);
|
||||||
|
border-right-color: rgba(88, 200, 255, 0.32);
|
||||||
|
filter: drop-shadow(0 0 6px rgba(88, 200, 255, 0.55));
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-dark .ao-ring--ticks {
|
||||||
|
border-color: rgba(110, 170, 255, 0.28);
|
||||||
|
background-image: repeating-conic-gradient(
|
||||||
|
from 0deg,
|
||||||
|
rgba(120, 180, 255, 0.5) 0deg 0.8deg,
|
||||||
|
transparent 0.8deg 6deg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卫星 */
|
||||||
|
.agents-orbit-page.ao-dark .ao-sat {
|
||||||
|
background: radial-gradient(circle at 35% 30%, #ffffff, #7fd0ff 55%, #1f7dff);
|
||||||
|
box-shadow:
|
||||||
|
0 0 12px 3px rgba(110, 200, 255, 0.85),
|
||||||
|
-14px 0 22px rgba(110, 200, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 类型标签药丸 */
|
||||||
|
.agents-orbit-page.ao-dark .ao-type-chip {
|
||||||
|
color: #b9d7ff;
|
||||||
|
background: rgba(12, 28, 58, 0.82);
|
||||||
|
border-color: rgba(110, 170, 255, 0.35);
|
||||||
|
box-shadow: 0 4px 16px rgba(4, 12, 32, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-dark .ao-type-anchor:hover .ao-type-chip {
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: rgba(140, 205, 255, 0.8);
|
||||||
|
box-shadow: 0 0 18px rgba(90, 170, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-dark .ao-type-anchor.is-active .ao-type-chip {
|
||||||
|
color: #06122b;
|
||||||
|
background: linear-gradient(135deg, #66c4ff, #2f7dff);
|
||||||
|
border-color: rgba(160, 220, 255, 0.9);
|
||||||
|
box-shadow: 0 0 26px rgba(80, 170, 255, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右侧面板 */
|
||||||
|
.agents-orbit-page.ao-dark .ao-panel {
|
||||||
|
background: rgba(10, 22, 48, 0.72);
|
||||||
|
border-color: rgba(110, 170, 255, 0.22);
|
||||||
|
box-shadow: 0 10px 40px rgba(2, 8, 24, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-dark .ao-panel-head {
|
||||||
|
border-bottom-color: rgba(110, 170, 255, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-dark .ao-panel-title {
|
||||||
|
color: #e8f2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-dark .ao-panel-sub {
|
||||||
|
color: #8fb7e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-dark .ao-agents::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(110, 170, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-dark .ao-agent {
|
||||||
|
border-color: rgba(110, 170, 255, 0.18);
|
||||||
|
background: rgba(20, 42, 86, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-dark .ao-agent:hover {
|
||||||
|
border-color: rgba(130, 200, 255, 0.6);
|
||||||
|
background: rgba(30, 58, 112, 0.65);
|
||||||
|
box-shadow: 0 8px 24px rgba(20, 80, 200, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-dark .ao-agent-icon {
|
||||||
|
background: linear-gradient(135deg, #2f7dff, #7a5cff);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(255, 255, 255, 0.25),
|
||||||
|
0 4px 14px rgba(47, 125, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-dark .ao-agent-name {
|
||||||
|
color: #d7e8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载 / 空态 */
|
||||||
|
.agents-orbit-page.ao-dark .ao-state {
|
||||||
|
color: #8fb7e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-dark .ao-spinner {
|
||||||
|
border-color: rgba(110, 170, 255, 0.25);
|
||||||
|
border-top-color: #6fc2ff;
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/* ============================================================
|
||||||
|
智能体星轨页 — 浅色主题(云际蓝)
|
||||||
|
只放颜色,结构与动画在 agents-orbit.css
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-light {
|
||||||
|
color: #1f3a63;
|
||||||
|
background:
|
||||||
|
radial-gradient(1100px 640px at 22% 40%, rgba(47, 125, 255, 0.1), transparent 62%),
|
||||||
|
radial-gradient(880px 560px at 80% 26%, rgba(122, 92, 246, 0.08), transparent 60%),
|
||||||
|
linear-gradient(158deg, #f4f8ff 0%, #e7f0ff 46%, #eef4fd 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 浅色下不渲染星点 */
|
||||||
|
.agents-orbit-page.ao-light::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部标题 */
|
||||||
|
.agents-orbit-page.ao-light .ao-header-title {
|
||||||
|
color: #164a96;
|
||||||
|
text-shadow: 0 0 14px rgba(120, 170, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-light .ao-header-link {
|
||||||
|
border-color: rgba(47, 125, 255, 0.4);
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
color: #2a66c2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-light .ao-header-link:hover {
|
||||||
|
color: #0f3f96;
|
||||||
|
border-color: rgba(47, 125, 255, 0.85);
|
||||||
|
box-shadow: 0 4px 14px rgba(47, 125, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 中心圆盘 */
|
||||||
|
.agents-orbit-page.ao-light .ao-core {
|
||||||
|
background: radial-gradient(
|
||||||
|
circle at 34% 30%,
|
||||||
|
#ffffff,
|
||||||
|
#dbe9ff 45%,
|
||||||
|
#b9d4ff 100%
|
||||||
|
);
|
||||||
|
border-color: rgba(90, 150, 240, 0.85);
|
||||||
|
box-shadow:
|
||||||
|
0 10px 34px rgba(47, 125, 255, 0.28),
|
||||||
|
inset 0 0 30px rgba(160, 200, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-light .ao-core-img {
|
||||||
|
color: #1f5fd6;
|
||||||
|
filter: drop-shadow(0 4px 10px rgba(47, 125, 255, 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 圆环族 */
|
||||||
|
.agents-orbit-page.ao-light .ao-ring--dash {
|
||||||
|
border-color: rgba(47, 125, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-light .ao-ring--arc {
|
||||||
|
border-top-color: rgba(37, 110, 245, 0.85);
|
||||||
|
border-right-color: rgba(37, 110, 245, 0.28);
|
||||||
|
filter: drop-shadow(0 2px 5px rgba(47, 125, 255, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-light .ao-ring--ticks {
|
||||||
|
border-color: rgba(47, 125, 255, 0.3);
|
||||||
|
background-image: repeating-conic-gradient(
|
||||||
|
from 0deg,
|
||||||
|
rgba(47, 105, 220, 0.45) 0deg 0.8deg,
|
||||||
|
transparent 0.8deg 6deg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卫星 */
|
||||||
|
.agents-orbit-page.ao-light .ao-sat {
|
||||||
|
background: radial-gradient(circle at 35% 30%, #ffffff, #7fb4ff 55%, #2f7dff);
|
||||||
|
box-shadow:
|
||||||
|
0 0 10px 2px rgba(47, 125, 255, 0.55),
|
||||||
|
-14px 0 20px rgba(47, 125, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 类型标签药丸 */
|
||||||
|
.agents-orbit-page.ao-light .ao-type-chip {
|
||||||
|
color: #1e4fa8;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
border-color: rgba(47, 125, 255, 0.4);
|
||||||
|
box-shadow: 0 4px 14px rgba(30, 80, 180, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-light .ao-type-anchor:hover .ao-type-chip {
|
||||||
|
color: #0f3f96;
|
||||||
|
border-color: rgba(47, 125, 255, 0.85);
|
||||||
|
box-shadow: 0 6px 18px rgba(47, 125, 255, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-light .ao-type-anchor.is-active .ao-type-chip {
|
||||||
|
color: #ffffff;
|
||||||
|
background: linear-gradient(135deg, #4d9aff, #2f7dff);
|
||||||
|
border-color: rgba(47, 125, 255, 0.95);
|
||||||
|
box-shadow: 0 8px 24px rgba(47, 125, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右侧面板 */
|
||||||
|
.agents-orbit-page.ao-light .ao-panel {
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
border-color: rgba(47, 125, 255, 0.25);
|
||||||
|
box-shadow: 0 10px 32px rgba(30, 70, 160, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-light .ao-panel-head {
|
||||||
|
border-bottom-color: rgba(47, 125, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-light .ao-panel-title {
|
||||||
|
color: #0f3a78;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-light .ao-panel-sub {
|
||||||
|
color: #5b7ea8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-light .ao-agents::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(47, 125, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-light .ao-agent {
|
||||||
|
border-color: rgba(47, 125, 255, 0.22);
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-light .ao-agent:hover {
|
||||||
|
border-color: rgba(47, 125, 255, 0.6);
|
||||||
|
background: #f0f7ff;
|
||||||
|
box-shadow: 0 8px 22px rgba(47, 125, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-light .ao-agent-icon {
|
||||||
|
background: linear-gradient(135deg, #2f7dff, #7a5cff);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(255, 255, 255, 0.35),
|
||||||
|
0 4px 12px rgba(47, 125, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-light .ao-agent-name {
|
||||||
|
color: #1f3a63;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载 / 空态 */
|
||||||
|
.agents-orbit-page.ao-light .ao-state {
|
||||||
|
color: #5b7ea8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-light .ao-spinner {
|
||||||
|
border-color: rgba(47, 125, 255, 0.25);
|
||||||
|
border-top-color: #2f7dff;
|
||||||
|
}
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
/* ============================================================
|
||||||
|
智能体星轨页 — 基础结构 + 动画(不含任何颜色)
|
||||||
|
颜色主题在 agents-orbit-dark.css / agents-orbit-light.css 两个文件里
|
||||||
|
命名统一 ao- 前缀
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* ---------- 页面骨架:左舞台 / 右面板 1:1 ---------- */
|
||||||
|
|
||||||
|
.agents-orbit-page {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 星点背景层(内容在主题文件里着色;浅色主题直接隐藏) */
|
||||||
|
.agents-orbit-page::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: ao-star-blink 7s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ao-star-blink {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部小标题 + 返回经典视图入口 */
|
||||||
|
.ao-header {
|
||||||
|
position: absolute;
|
||||||
|
top: 18px;
|
||||||
|
left: 22px;
|
||||||
|
z-index: 6;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-header-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-header-link {
|
||||||
|
border: 1px solid;
|
||||||
|
background: none;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.25s, border-color 0.25s, box-shadow 0.25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 左侧星轨舞台 ---------- */
|
||||||
|
|
||||||
|
.ao-stage {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-root {
|
||||||
|
position: relative;
|
||||||
|
width: min(56vh, 520px);
|
||||||
|
max-width: calc(100% - 48px);
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 中心圆盘(核心) */
|
||||||
|
.ao-core {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
width: 32%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 中心图片:上下摇晃(频率适中,约 3.2s 一个来回) */
|
||||||
|
.ao-core-img {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: ao-float 3.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ao-float {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(-7px);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(9px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 圆环族 ----------
|
||||||
|
由内到外:虚线环(54%) 顺时针 / 弧光环(70%) 逆时针 / 刻度环(97%) 缓慢顺时针 */
|
||||||
|
|
||||||
|
.ao-ring {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
border-radius: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-ring--dash {
|
||||||
|
width: 54%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
border: 2px dashed;
|
||||||
|
animation: ao-spin-cw 18s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-ring--arc {
|
||||||
|
width: 70%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
border: 3px solid transparent;
|
||||||
|
animation: ao-spin-ccw 12s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 最外层刻度圆圈:细边框 + 环形遮罩刻度(密码转盘的刻度感),
|
||||||
|
缓慢自转,转盘属性刻度环 */
|
||||||
|
.ao-ring--ticks {
|
||||||
|
width: 97%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
border: 1px solid;
|
||||||
|
-webkit-mask: radial-gradient(
|
||||||
|
farthest-side,
|
||||||
|
transparent calc(100% - 5px),
|
||||||
|
#000 calc(100% - 4px)
|
||||||
|
);
|
||||||
|
mask: radial-gradient(
|
||||||
|
farthest-side,
|
||||||
|
transparent calc(100% - 5px),
|
||||||
|
#000 calc(100% - 4px)
|
||||||
|
);
|
||||||
|
animation: ao-spin-cw 90s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ao-spin-cw {
|
||||||
|
to {
|
||||||
|
transform: translate(-50%, -50%) rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ao-spin-ccw {
|
||||||
|
to {
|
||||||
|
transform: translate(-50%, -50%) rotate(-360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卫星:一个自转的轨道容器,卫星固定在轨道顶端;
|
||||||
|
轨道直径与最外层刻度圆圈(97%)一致,卫星贴着最外圈环绕 */
|
||||||
|
.ao-sat-orbit {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
width: 97%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
animation: ao-orbit-cw 9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-sat {
|
||||||
|
position: absolute;
|
||||||
|
top: -7px;
|
||||||
|
left: 50%;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
margin-left: -7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ao-orbit-cw {
|
||||||
|
to {
|
||||||
|
transform: translate(-50%, -50%) rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 类型标签(密码转盘) ---------- */
|
||||||
|
|
||||||
|
/* 转盘裁剪容器:垂直中线以左(左半圆)是不可视区域,
|
||||||
|
绕背补位的标签滑出边界即被裁掉、从另一端滑入才重新出现。
|
||||||
|
上/右/下方向用负 inset 放宽裁剪框,不切到溢出 root 的标签。 */
|
||||||
|
.ao-types {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 4;
|
||||||
|
clip-path: inset(-80px -80px -80px 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 旋转臂:铺满整个 root、以圆心为轴旋转;标签挂在 93%/50% 处(刻度环内侧)。
|
||||||
|
换角度时 CSS 过渡让标签沿圆弧运动,文字由锚点反向旋转保持水平。
|
||||||
|
超界补位的标签角度会 ±360 走“背面长路”,全程经过被裁剪的左半圆。 */
|
||||||
|
.ao-type-arm {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform-origin: 50% 50%;
|
||||||
|
transition: transform 0.85s cubic-bezier(0.68, -0.12, 0.28, 1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-type-anchor {
|
||||||
|
position: absolute;
|
||||||
|
left: 93%;
|
||||||
|
top: 50%;
|
||||||
|
pointer-events: auto;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transform: translate(-50%, -50%) rotate(0deg);
|
||||||
|
transition: transform 0.85s cubic-bezier(0.68, -0.12, 0.28, 1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标签药丸:内层元素承担上下摇晃动画,不与定位 transform 冲突 */
|
||||||
|
.ao-type-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
animation: ao-bob 3.6s ease-in-out infinite;
|
||||||
|
transition: color 0.3s, border-color 0.3s, box-shadow 0.3s, background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-type-chip::before {
|
||||||
|
content: "";
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
box-shadow: 0 0 8px currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 激活位(正右侧中间)微微放大强调 */
|
||||||
|
.ao-type-anchor.is-active .ao-type-chip {
|
||||||
|
padding: 8px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 类型较多时收缩标签尺寸,保持间距可辨 */
|
||||||
|
.agents-orbit-page.ao-crowded .ao-type-chip {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-orbit-page.ao-crowded .ao-type-anchor.is-active .ao-type-chip {
|
||||||
|
padding: 5px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ao-bob {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 右侧智能体面板(与舞台 1:1) ---------- */
|
||||||
|
|
||||||
|
.ao-panel {
|
||||||
|
position: relative;
|
||||||
|
z-index: 5;
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-panel-head {
|
||||||
|
padding: 18px 20px 12px;
|
||||||
|
border-bottom: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-panel-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-panel-sub {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-agents {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 20px 20px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(108px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
align-content: start;
|
||||||
|
animation: ao-fade-in 0.5s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-agents::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-agents::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ao-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(14px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-agent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 8px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.25s, box-shadow 0.25s, border-color 0.25s, background 0.25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-agent:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 圆角正方形图标(取智能体名称首字) */
|
||||||
|
.ao-agent-icon {
|
||||||
|
width: 54px;
|
||||||
|
height: 54px;
|
||||||
|
border-radius: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-agent-name {
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 加载 / 空态 ---------- */
|
||||||
|
|
||||||
|
.ao-state {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-spinner {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid;
|
||||||
|
animation: ao-spin-cw-center 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ao-spin-cw-center {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 动效敏感用户降级 */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.agents-orbit-page::before,
|
||||||
|
.ao-core-img,
|
||||||
|
.ao-ring--dash,
|
||||||
|
.ao-ring--arc,
|
||||||
|
.ao-ring--ticks,
|
||||||
|
.ao-sat-orbit,
|
||||||
|
.ao-type-chip,
|
||||||
|
.ao-agents {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-type-arm,
|
||||||
|
.ao-type-anchor {
|
||||||
|
transition-duration: 0.01s;
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,69 @@
|
|||||||
|
# ========================
|
||||||
|
# EDIT BEFORE DEPLOYMENT
|
||||||
|
# ========================
|
||||||
|
# This file is loaded by start_backend.scheduler.sh through docker --env-file.
|
||||||
|
# Put server-specific values here. Do not configure Python mirrors only on the
|
||||||
|
# host shell; values in this file are what the container will receive.
|
||||||
|
|
||||||
|
# AI provider keys. Replace placeholders and rotate any test key before release.
|
||||||
|
DEEPSEEK_API_KEY=sk-9808fb2375534112ac275541aec14b9b
|
||||||
|
OPENAI_API_KEY=your-openai-api-key
|
||||||
|
ANTHROPIC_API_KEY=your-anthropic-api-key
|
||||||
|
|
||||||
|
TAVILY_API_KEY=your-tavily-api-key
|
||||||
|
SERPER_API_KEY=your-serper-api-key
|
||||||
|
|
||||||
|
# Auth and frontend access.
|
||||||
|
# AUTH_JWT_SECRET must stay stable across restarts, or existing logins expire.
|
||||||
|
# GATEWAY_CORS_ORIGINS must include the exact frontend origins, separated by commas.
|
||||||
|
AUTH_JWT_SECRET=i3_BiBK44sYHiFIkChhDs46y57ZewJKO3diQDw01anI
|
||||||
|
GATEWAY_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:5174,http://127.0.0.1:5174,http://localhost:5183,http://127.0.0.1:5183,http://localhost:4200,http://47.88.25.99:7010
|
||||||
|
GATEWAY_ENABLE_DOCS=false
|
||||||
|
GATEWAY_WORKERS=2
|
||||||
|
AUTH_TRUSTED_PROXIES=
|
||||||
|
|
||||||
|
# Scheduler dependency behavior.
|
||||||
|
# Keep SCHEDULER_INSTALL_DEPS=1 if croniter is not already baked into the image.
|
||||||
|
# Set the Python mirror below if the server cannot access public PyPI.
|
||||||
|
SCHEDULER_INSTALL_DEPS=1
|
||||||
|
SCHEDULER_PIP_PACKAGES=croniter==6.2.2
|
||||||
|
SCHEDULED_TASKS_ENABLED=true
|
||||||
|
|
||||||
|
# Element / Matrix delivery for scheduled task results.
|
||||||
|
# If disabled or not configured, scheduled tasks still run but no Element DM is sent.
|
||||||
|
ELEMENT_ENABLED=false
|
||||||
|
ELEMENT_HOMESERVER_URL=
|
||||||
|
ELEMENT_ACCESS_TOKEN=
|
||||||
|
ELEMENT_BOT_USERNAME=scheduled-bot
|
||||||
|
ELEMENT_BOT_PASSWORD=
|
||||||
|
ELEMENT_AUTO_REGISTER=false
|
||||||
|
ELEMENT_STATE_FILE=/app/backend/.deer-flow/element-bot-token
|
||||||
|
|
||||||
|
# Python private package index used by uv/pip inside the backend container and
|
||||||
|
# propagated into sandbox containers for agent-installed dependencies.
|
||||||
|
# Uncomment and set these to your internal mirror when public PyPI is blocked.
|
||||||
|
# UV_INDEX_URL=http://your-python-mirror/simple
|
||||||
|
# PIP_INDEX_URL=http://your-python-mirror/simple
|
||||||
|
# UV_TRUSTED_HOST=your-python-mirror
|
||||||
|
# PIP_TRUSTED_HOST=your-python-mirror
|
||||||
|
# PIP_EXTRA_INDEX_URL=
|
||||||
|
# UV_EXTRA_INDEX_URL=
|
||||||
|
# HTTP_PROXY=
|
||||||
|
# HTTPS_PROXY=
|
||||||
|
# NO_PROXY=
|
||||||
|
|
||||||
|
# Alternative generic names also supported by the startup script.
|
||||||
|
# PYTHON_PACKAGE_INDEX_URL=http://your-python-mirror/simple
|
||||||
|
# PYTHON_EXTRA_INDEX_URL=
|
||||||
|
# PYTHON_TRUSTED_HOST=your-python-mirror
|
||||||
|
MYSQL_DATABASE_URL=mysql+aiomysql://deerflow:deerflow123@192.168.0.123:3306/deerflow?charset=utf8mb4
|
||||||
|
DEER_FLOW_NOTEBOOK_API_BASE=http://127.0.0.1:8003
|
||||||
|
# 圆桌后台作业网关:缺省即 inprocess(进程内驱动真模型,后台挂起开箱即用);
|
||||||
|
# 仅前端联调时显式设 simulated(真实进度但不调真模型)。本行可删,留作文档。
|
||||||
|
ROUNDTABLE_JOB_GATEWAY=inprocess
|
||||||
|
DEEPSEEK_CHAT_API_KEY=sk-686b7bc000be4b7cbed6dccb3897c411
|
||||||
|
GLM5_FP8_API_KEY=modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
|
||||||
|
SILICONFLOW_API_KEY=sk-ighylafootabtuthwueirmoycccyqtdgbifyhozduvolkdyt
|
||||||
|
|
||||||
|
# WeKnora admin credentials (DeerFlow server-side iframe SSO proxy)
|
||||||
|
WEKNORA_ADMIN_PASSWORD=1qaz@WSX
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Python-generated files
|
||||||
|
__pycache__/
|
||||||
|
*.py[oc]
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
wheels/
|
||||||
|
*.egg-info
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.ruff_cache
|
||||||
|
agent_history.gif
|
||||||
|
static/browser_history/*.gif
|
||||||
|
|
||||||
|
log/
|
||||||
|
log/*
|
||||||
|
.deer-flow/users/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# User config file
|
||||||
|
# config.yaml
|
||||||
|
|
||||||
|
# Langgraph
|
||||||
|
.langgraph_api
|
||||||
|
|
||||||
|
# Claude Code settings
|
||||||
|
.claude/settings.local.json
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
name: task-report-import
|
||||||
|
description: 任务研判成果入库:校验智能体生成的 task-reports.json(14 类分类结构),并调用 Gateway 的 /api/task-reports/import/by-task/{taskId} 接口把结果写入任务详情(整体替换)。内网自签证书场景已关闭 SSL 校验。
|
||||||
|
allowed-tools: read_file, bash
|
||||||
|
---
|
||||||
|
|
||||||
|
# 任务研判成果入库
|
||||||
|
|
||||||
|
把「任务研判报告智能体」生成的 `/mnt/user-data/outputs/task-reports.json` 校验后导入任务详情。
|
||||||
|
导入接口会**整体替换**该任务已有的全部详情记录。
|
||||||
|
|
||||||
|
## 用法
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python /mnt/skills/public/task-report-import/scripts/task_report_import.py \
|
||||||
|
--task-id <任务id> \
|
||||||
|
--json /mnt/user-data/outputs/task-reports.json
|
||||||
|
```
|
||||||
|
|
||||||
|
参数(均有默认值,一般只需 `--task-id`):
|
||||||
|
|
||||||
|
| 参数 | 默认 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `--task-id` | (必填) | 任务 id,取开场信息里的 task_id |
|
||||||
|
| `--json` | `/mnt/user-data/outputs/task-reports.json` | 待导入的 JSON 文件;接受裸数组或 `{data\|records: [...]}` 信封 |
|
||||||
|
| `--gateway` | `$AGENTFX_GATEWAY_URL` 或 `http://127.0.0.1:8001` | Gateway 基地址 |
|
||||||
|
| `--token` | 见下方鉴权 | Bearer token |
|
||||||
|
| `--strict` | 关 | 开启后缺任何一个分类都拒绝导入(默认允许部分导入,缺的分类会列在结果里) |
|
||||||
|
| `--verify-tls` | 关 | 开启 TLS 证书校验。内网自签证书部署默认关闭(ssl 不验证) |
|
||||||
|
|
||||||
|
## 鉴权(按顺序取第一个可用)
|
||||||
|
|
||||||
|
1. `--token` 参数;
|
||||||
|
2. 环境变量 `AGENTFX_IMPORT_TOKEN` → `DEERFLOW_AUTH_TOKEN` → `TOOLS_TOKEN`;
|
||||||
|
3. 都没有时,自动调 Gateway 公共端点 `POST /api/parallel-agents/auth/token`(传用户名 `agentfx-import`)现签一个 token——内网部署的既有鉴权旁路模式,无需配置。
|
||||||
|
|
||||||
|
## 校验规则
|
||||||
|
|
||||||
|
- 记录必须是对象,且 `categoryType` 属于 14 个固定分类(人文环境/敌情关键事件/我情口径/条件与限制/决心建议/我情上级要求/敌情对手叙事/行动方案/信息环境/优劣势分析/处置建议/我情叙事/敌情目标/风险及应对,均带 ` JSON` 后缀);
|
||||||
|
- `contentJson` 必须存在:对象会自动序列化为字符串,字符串必须能解析为合法 JSON;
|
||||||
|
- `id`/`taskId`/`createTime`/`updateTime`/`sessionId` 缺省时自动补齐(`taskId` 强制为 `--task-id`)。
|
||||||
|
|
||||||
|
## 结果
|
||||||
|
|
||||||
|
stdout 输出一行 JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"success": true, "task_id": "0101", "imported": 14, "missing_categories": [], "http_status": 200, "errors": []}
|
||||||
|
```
|
||||||
|
|
||||||
|
`success=false` 时把 `errors` 原样反馈给用户;`missing_categories` 非空时提醒用户哪些分类缺失。导入成功后任务的 TaskCOP 状态会自动置为「已完成分析」。
|
||||||
+280
@@ -0,0 +1,280 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""任务研判成果入库(task-report-import 技能脚本,纯标准库)。
|
||||||
|
|
||||||
|
校验「任务研判报告智能体」生成的 task-reports.json(14 类分类结构),并调用本系统
|
||||||
|
Gateway 的 ``POST /api/task-reports/import/by-task/{taskId}`` 接口整体替换任务详情。
|
||||||
|
|
||||||
|
设计要点:
|
||||||
|
- 内网自签证书部署:默认**关闭 SSL 证书校验**(``--verify-tls`` 可重新开启);
|
||||||
|
- 鉴权:``--token`` / 环境变量优先;都没有时调 Gateway 公共端点
|
||||||
|
``/api/parallel-agents/auth/token`` 现签 token(内网部署既有鉴权旁路模式);
|
||||||
|
- ``id``/``taskId``/``createTime``/``updateTime``/``sessionId`` 缺省自动补齐
|
||||||
|
(``taskId`` 强制为 ``--task-id``,与后端导入语义一致);
|
||||||
|
- stdout 输出一行 JSON 结果(success/imported/missing_categories/errors)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import ssl
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
# 14 个固定分类(与任务详情导入示例 / 前端校验保持一致,均带 “ JSON” 后缀)。
|
||||||
|
CATEGORY_TYPES: list[str] = [
|
||||||
|
"信息环境 JSON",
|
||||||
|
"人文环境 JSON",
|
||||||
|
"敌情关键事件 JSON",
|
||||||
|
"敌情对手叙事 JSON",
|
||||||
|
"敌情目标 JSON",
|
||||||
|
"我情口径 JSON",
|
||||||
|
"我情叙事 JSON",
|
||||||
|
"我情上级要求 JSON",
|
||||||
|
"条件与限制 JSON",
|
||||||
|
"优劣势分析 JSON",
|
||||||
|
"风险及应对 JSON",
|
||||||
|
"行动方案 JSON",
|
||||||
|
"处置建议 JSON",
|
||||||
|
"决心建议 JSON",
|
||||||
|
]
|
||||||
|
|
||||||
|
TOKEN_ENV_CHAIN: tuple[str, ...] = (
|
||||||
|
"AGENTFX_IMPORT_TOKEN",
|
||||||
|
"DEERFLOW_AUTH_TOKEN",
|
||||||
|
"TOOLS_TOKEN",
|
||||||
|
)
|
||||||
|
|
||||||
|
TOKEN_MINT_USERNAME = "agentfx-import"
|
||||||
|
DEFAULT_JSON_PATH = "/mnt/user-data/outputs/task-reports.json"
|
||||||
|
REQUEST_TIMEOUT_SECONDS = 60
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_json_path(path: str) -> str:
|
||||||
|
"""把 ``/mnt/user-data/...`` 映射到当前线程真实 outputs,避免 Windows 写到 F:\\mnt。"""
|
||||||
|
raw = (path or "").strip() or DEFAULT_JSON_PATH
|
||||||
|
if os.path.isfile(raw):
|
||||||
|
return raw
|
||||||
|
normalized = raw.replace("\\", "/")
|
||||||
|
current = os.path.abspath(os.getcwd())
|
||||||
|
root = ""
|
||||||
|
cursor = current
|
||||||
|
for _ in range(8):
|
||||||
|
if os.path.isdir(os.path.join(cursor, "workspace")) and os.path.isdir(
|
||||||
|
os.path.join(cursor, "outputs")
|
||||||
|
):
|
||||||
|
root = cursor
|
||||||
|
break
|
||||||
|
if os.path.basename(cursor.rstrip("\\/")) == "workspace":
|
||||||
|
root = os.path.dirname(cursor)
|
||||||
|
break
|
||||||
|
parent = os.path.dirname(cursor)
|
||||||
|
if parent == cursor:
|
||||||
|
break
|
||||||
|
cursor = parent
|
||||||
|
candidates = [raw]
|
||||||
|
if root and (normalized == "/mnt/user-data" or normalized.startswith("/mnt/user-data/")):
|
||||||
|
rest = normalized[len("/mnt/user-data") :].lstrip("/")
|
||||||
|
candidates.insert(0, os.path.normpath(os.path.join(root, *rest.split("/"))) if rest else root)
|
||||||
|
if root:
|
||||||
|
candidates.append(os.path.join(root, "outputs", "task-reports.json"))
|
||||||
|
candidates.append(os.path.join("..", "outputs", "task-reports.json"))
|
||||||
|
candidates.append("task-reports.json")
|
||||||
|
for candidate in candidates:
|
||||||
|
if os.path.isfile(candidate):
|
||||||
|
return candidate
|
||||||
|
return candidates[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
"""北京时间 ISO 字符串(与导入示例的 +08:00 时区保持一致)。"""
|
||||||
|
return datetime.now(timezone(timedelta(hours=8))).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def _default_gateway() -> str:
|
||||||
|
configured = os.environ.get("AGENTFX_GATEWAY_URL", "").strip()
|
||||||
|
if configured:
|
||||||
|
return configured.rstrip("/")
|
||||||
|
port = os.environ.get("DEER_FLOW_GATEWAY_PORT", "").strip() or "8001"
|
||||||
|
return f"http://127.0.0.1:{port}"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_opener(verify_tls: bool) -> urllib.request.OpenerDirector:
|
||||||
|
"""verify_tls=False 时跳过证书校验(内网自签证书),并忽略环境代理。"""
|
||||||
|
handlers: list[urllib.request.BaseHandler] = []
|
||||||
|
if not verify_tls:
|
||||||
|
# ssl._create_unverified_context 是官方文档认可的跳过校验写法(私有 API 但长期稳定,
|
||||||
|
# 与仓库内 sentiment-analysis 技能同款)。
|
||||||
|
handlers.append(urllib.request.HTTPSHandler(context=ssl._create_unverified_context()))
|
||||||
|
handlers.append(urllib.request.ProxyHandler({})) # 内网代理变量会劫持 127.0.0.1 回环调用
|
||||||
|
return urllib.request.build_opener(*handlers)
|
||||||
|
|
||||||
|
|
||||||
|
def _post_json(
|
||||||
|
opener: urllib.request.OpenerDirector,
|
||||||
|
url: str,
|
||||||
|
payload: object,
|
||||||
|
token: str | None,
|
||||||
|
timeout: int,
|
||||||
|
) -> tuple[int, str]:
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
|
request = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
||||||
|
try:
|
||||||
|
with opener.open(request, timeout=timeout) as response:
|
||||||
|
return int(response.status), response.read().decode("utf-8", "replace")
|
||||||
|
except urllib.error.HTTPError as error: # 4xx/5xx 也带响应体,向上抛给调用方解析
|
||||||
|
return int(error.code), error.read().decode("utf-8", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_token(args: argparse.Namespace, opener: urllib.request.OpenerDirector) -> str:
|
||||||
|
"""--token / 环境变量优先;缺省时经公共 token 端点现签(内网鉴权旁路模式)。"""
|
||||||
|
if args.token:
|
||||||
|
return args.token.strip()
|
||||||
|
for env_name in TOKEN_ENV_CHAIN:
|
||||||
|
value = os.environ.get(env_name, "").strip()
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
url = args.gateway.rstrip("/") + "/api/parallel-agents/auth/token"
|
||||||
|
status, body = _post_json(opener, url, {"username": TOKEN_MINT_USERNAME}, None, 15)
|
||||||
|
try:
|
||||||
|
token = (json.loads(body) or {}).get("access_token")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
token = None
|
||||||
|
if status != 200 or not token:
|
||||||
|
raise RuntimeError(f"签发 token 失败(HTTP {status}),请配置 --token 或环境变量 AGENTFX_IMPORT_TOKEN")
|
||||||
|
return str(token)
|
||||||
|
|
||||||
|
|
||||||
|
def load_records(path: str) -> list[object]:
|
||||||
|
"""读取 JSON 文件:接受裸数组或 {data|records: [...]} 信封。"""
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"找不到待导入文件 {path};请先生成报告 JSON,或用 --json 显式传入沙箱路径(如 {DEFAULT_JSON_PATH})"
|
||||||
|
)
|
||||||
|
with open(path, encoding="utf-8") as handle:
|
||||||
|
payload = json.load(handle)
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
for key in ("data", "records"):
|
||||||
|
if isinstance(payload.get(key), list):
|
||||||
|
return payload[key]
|
||||||
|
raise ValueError("JSON 对象里没有可用的 data/records 数组")
|
||||||
|
if isinstance(payload, list):
|
||||||
|
return payload
|
||||||
|
raise ValueError("JSON 必须是数组,或包含 data/records 数组的对象")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_records(
|
||||||
|
raw_records: list[object],
|
||||||
|
task_id: str,
|
||||||
|
errors: list[str],
|
||||||
|
) -> tuple[list[dict[str, str]], set[str]]:
|
||||||
|
"""校验并补齐记录;返回 (可导入记录, 已覆盖分类集合)。"""
|
||||||
|
records: list[dict[str, str]] = []
|
||||||
|
present: set[str] = set()
|
||||||
|
for index, raw in enumerate(raw_records, start=1):
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
errors.append(f"第 {index} 条不是对象")
|
||||||
|
continue
|
||||||
|
category = str(raw.get("categoryType") or raw.get("category_type") or "").strip()
|
||||||
|
if category not in CATEGORY_TYPES:
|
||||||
|
errors.append(f"第 {index} 条 categoryType 非法:{category or '(空)'}")
|
||||||
|
continue
|
||||||
|
content = raw.get("contentJson", raw.get("content_json"))
|
||||||
|
if isinstance(content, (dict, list)):
|
||||||
|
content = json.dumps(content, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
if not isinstance(content, str) or not content.strip():
|
||||||
|
errors.append(f"第 {index} 条({category})缺少 contentJson")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
# 校验 + 归一化:把模型可能写的 \uXXXX 转义还原为汉字原文再入库。
|
||||||
|
content = json.dumps(json.loads(content), ensure_ascii=False, separators=(",", ":"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
errors.append(f"第 {index} 条({category})contentJson 不是合法 JSON 字符串")
|
||||||
|
continue
|
||||||
|
present.add(category)
|
||||||
|
records.append(
|
||||||
|
{
|
||||||
|
"id": str(raw.get("id") or f"agentfx-{index:02d}"),
|
||||||
|
"taskId": task_id,
|
||||||
|
"createTime": str(raw.get("createTime") or _now_iso()),
|
||||||
|
"updateTime": _now_iso(),
|
||||||
|
"sessionId": str(raw.get("sessionId") or "agentfx-import"),
|
||||||
|
"contentJson": content,
|
||||||
|
"categoryType": category,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return records, present
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="校验 task-reports.json 并导入任务详情(整体替换)")
|
||||||
|
parser.add_argument("--task-id", required=True, help="任务 id(开场信息里的 task_id)")
|
||||||
|
parser.add_argument("--json", default=DEFAULT_JSON_PATH, help=f"待导入 JSON 文件路径(默认 {DEFAULT_JSON_PATH})")
|
||||||
|
parser.add_argument("--gateway", default=_default_gateway(), help="Gateway 基地址(默认 env AGENTFX_GATEWAY_URL 或 http://127.0.0.1:8001)")
|
||||||
|
parser.add_argument("--token", default="", help="Bearer token(默认走环境变量或公共端点现签)")
|
||||||
|
parser.add_argument("--strict", action="store_true", help="缺任何一个分类都拒绝导入")
|
||||||
|
parser.add_argument("--verify-tls", action="store_true", help="开启 TLS 证书校验(内网自签证书默认关闭)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
task_id = args.task_id.strip()
|
||||||
|
result: dict[str, object] = {
|
||||||
|
"success": False,
|
||||||
|
"task_id": task_id,
|
||||||
|
"imported": 0,
|
||||||
|
"missing_categories": [],
|
||||||
|
"http_status": None,
|
||||||
|
"errors": [],
|
||||||
|
}
|
||||||
|
errors = result["errors"] # type: ignore[assignment]
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not task_id:
|
||||||
|
raise ValueError("--task-id 不能为空")
|
||||||
|
|
||||||
|
raw_records = load_records(resolve_json_path(args.json))
|
||||||
|
records, present = normalize_records(raw_records, task_id, errors) # type: ignore[arg-type]
|
||||||
|
missing = [category for category in CATEGORY_TYPES if category not in present]
|
||||||
|
result["missing_categories"] = missing
|
||||||
|
if args.strict and missing:
|
||||||
|
raise ValueError(f"缺少分类(--strict):{'、'.join(missing)}")
|
||||||
|
if not records:
|
||||||
|
raise ValueError("没有可导入的记录(先修复上面的 errors)")
|
||||||
|
|
||||||
|
opener = _build_opener(args.verify_tls)
|
||||||
|
token = resolve_token(args, opener)
|
||||||
|
endpoint = (
|
||||||
|
args.gateway.rstrip("/")
|
||||||
|
+ "/api/task-reports/import/by-task/"
|
||||||
|
+ urllib.parse.quote(task_id, safe="")
|
||||||
|
)
|
||||||
|
status, body = _post_json(opener, endpoint, {"data": records}, token, REQUEST_TIMEOUT_SECONDS)
|
||||||
|
result["http_status"] = status
|
||||||
|
try:
|
||||||
|
response = json.loads(body)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
response = {}
|
||||||
|
if 200 <= status < 300:
|
||||||
|
result["imported"] = len(response.get("data") or [])
|
||||||
|
result["success"] = True
|
||||||
|
else:
|
||||||
|
detail = response.get("detail") or response.get("msg") or body[:300]
|
||||||
|
errors.append(f"导入接口返回 HTTP {status}:{detail}")
|
||||||
|
except Exception as error: # noqa: BLE001 —— 结果统一经 stdout JSON 反馈给智能体
|
||||||
|
errors.append(str(error))
|
||||||
|
|
||||||
|
print(json.dumps(result, ensure_ascii=False))
|
||||||
|
return 0 if result["success"] else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -1,19 +1,8 @@
|
|||||||
"""内置智能体「用途」默认标签的种子化。
|
"""Seed default purpose tags for built-in agents.
|
||||||
|
|
||||||
把**功能型内置 agent**(roundtable-* / ai-writing-* / workflow-planner)按用途默认打上标签,使管理员在
|
Known built-in agents receive their specific purpose tag. Other built-in
|
||||||
「内置智能体」筛选里一眼能按用途归类。没有明确用途的内置 agent(各种测试/用户残留的
|
agents receive the fallback purpose tag so newly added built-ins are not
|
||||||
user_id 为空的 agent)**不打标签**。
|
omitted. Existing user or administrator tags are never removed.
|
||||||
|
|
||||||
用途映射(可继续补充):
|
|
||||||
- **多智能体规划**:roundtable-intent(任务理解)/ roundtable-recommender(智能体推荐)/
|
|
||||||
roundtable-coordinator(总控协调)/ roundtable-report / roundtable-summary /
|
|
||||||
roundtable-execution-plan / roundtable-risk-review / roundtable-solution-design
|
|
||||||
- **AI写作**:ai-writing-researcher / ai-writing-outliner / ai-writing-writer(作家)/
|
|
||||||
ai-writing-editor
|
|
||||||
- **工作流编排**:workflow-planner(只做候选工作流规划,不参与圆桌会商或正式执行)
|
|
||||||
|
|
||||||
幂等:标签缺失则建,assignment 缺失则补;**只做并集追加**,绝不删用户/管理员后加的
|
|
||||||
其它标签。在启动 lifespan 里 `_sync_legacy_agents` 之后调用(那时 agent 行已入库)。
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -22,46 +11,56 @@ import logging
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# 标签按用途分组(tag_type 固定为 "agent",与 target_type 对齐)。id 用稳定 slug,
|
|
||||||
# 便于幂等;name 是展示名;color 走标签库的命名色。
|
|
||||||
_PURPOSE_TAGS: dict[str, dict[str, str]] = {
|
_PURPOSE_TAGS: dict[str, dict[str, str]] = {
|
||||||
"多智能体规划": {"id": "builtin-purpose-multi-agent-planning", "color": "blue"},
|
"多智能体规划": {"id": "builtin-purpose-multi-agent-planning", "color": "blue"},
|
||||||
"AI写作": {"id": "builtin-purpose-ai-writing", "color": "green"},
|
"AI写作": {"id": "builtin-purpose-ai-writing", "color": "green"},
|
||||||
|
"模板填充": {"id": "builtin-purpose-template-filling", "color": "orange"},
|
||||||
|
"任务研判": {"id": "builtin-purpose-task-analysis", "color": "purple"},
|
||||||
|
"岗位会商": {"id": "builtin-purpose-position-roundtable", "color": "cyan"},
|
||||||
|
"深度研究": {"id": "builtin-purpose-deep-research", "color": "indigo"},
|
||||||
|
"强制检索": {"id": "builtin-purpose-forced-research", "color": "red"},
|
||||||
"工作流编排": {"id": "builtin-purpose-workflow-planning", "color": "indigo"},
|
"工作流编排": {"id": "builtin-purpose-workflow-planning", "color": "indigo"},
|
||||||
|
"其他内置功能": {"id": "builtin-purpose-other", "color": "gray"},
|
||||||
}
|
}
|
||||||
|
|
||||||
# 内置 agent id → 用途名(必须是 _PURPOSE_TAGS 的 key)。未列出的内置 agent 不打标签。
|
# Every value must be a key in _PURPOSE_TAGS.
|
||||||
_AGENT_PURPOSE: dict[str, str] = {
|
_AGENT_PURPOSE: dict[str, str] = {
|
||||||
# 多智能体规划(圆桌全链路)
|
"agentfx-analyst": "任务研判",
|
||||||
|
"position-roundtable-intent": "岗位会商",
|
||||||
"roundtable-intent": "多智能体规划",
|
"roundtable-intent": "多智能体规划",
|
||||||
"roundtable-recommender": "多智能体规划",
|
"roundtable-recommender": "多智能体规划",
|
||||||
"roundtable-coordinator": "多智能体规划",
|
"roundtable-coordinator": "多智能体规划",
|
||||||
"roundtable-report": "多智能体规划",
|
"roundtable-report": "多智能体规划",
|
||||||
"roundtable-summary": "多智能体规划",
|
"roundtable-summary": "多智能体规划",
|
||||||
"position-action-planner": "多智能体规划",
|
"position-action-planner": "多智能体规划",
|
||||||
|
"roundtable-dashboard": "多智能体规划",
|
||||||
|
"roundtable-structure": "多智能体规划",
|
||||||
"roundtable-execution-plan": "多智能体规划",
|
"roundtable-execution-plan": "多智能体规划",
|
||||||
"roundtable-risk-review": "多智能体规划",
|
"roundtable-risk-review": "多智能体规划",
|
||||||
"roundtable-solution-design": "多智能体规划",
|
"roundtable-solution-design": "多智能体规划",
|
||||||
# AI 写作(杂志社协作写作四阶段)
|
"eightbf-roundtable-intent": "多智能体规划",
|
||||||
"ai-writing-researcher": "AI写作",
|
"ai-writing-researcher": "AI写作",
|
||||||
"ai-writing-outliner": "AI写作",
|
"ai-writing-outliner": "AI写作",
|
||||||
"ai-writing-writer": "AI写作",
|
"ai-writing-writer": "AI写作",
|
||||||
"ai-writing-editor": "AI写作",
|
"ai-writing-editor": "AI写作",
|
||||||
# Workflow Studio(候选流程总控,和圆桌总控隔离)
|
"template-json-builder": "模板填充",
|
||||||
|
"deep-research-collector": "深度研究",
|
||||||
|
"forced-research-responder": "强制检索",
|
||||||
"workflow-planner": "工作流编排",
|
"workflow-planner": "工作流编排",
|
||||||
}
|
}
|
||||||
|
|
||||||
_TARGET_TYPE = "agent"
|
_TARGET_TYPE = "agent"
|
||||||
|
_FALLBACK_PURPOSE = "其他内置功能"
|
||||||
|
|
||||||
|
|
||||||
async def ensure_builtin_agent_purpose_tags(agent_store, tag_store) -> int:
|
async def ensure_builtin_agent_purpose_tags(agent_store, tag_store) -> int:
|
||||||
"""确保内置功能型 agent 带上默认「用途」标签。返回新增 assignment 数。
|
"""Ensure built-in agents have a default purpose tag.
|
||||||
|
|
||||||
错误只记录不抛——种子化失败绝不能阻塞服务启动。
|
Returns the number of newly created tag assignments. Failures are logged
|
||||||
|
instead of blocking application startup.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 1) 确保用途标签存在,建立 name -> id 映射。
|
existing = {tag.get("name"): tag for tag in await tag_store.list_tags(_TARGET_TYPE)}
|
||||||
existing = {t.get("name"): t for t in await tag_store.list_tags(_TARGET_TYPE)}
|
|
||||||
name_to_id: dict[str, str] = {}
|
name_to_id: dict[str, str] = {}
|
||||||
for name, meta in _PURPOSE_TAGS.items():
|
for name, meta in _PURPOSE_TAGS.items():
|
||||||
found = existing.get(name)
|
found = existing.get(name)
|
||||||
@@ -74,25 +73,26 @@ async def ensure_builtin_agent_purpose_tags(agent_store, tag_store) -> int:
|
|||||||
)
|
)
|
||||||
name_to_id[name] = created["id"]
|
name_to_id[name] = created["id"]
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# 并发/已存在(按 name 唯一)——重查一次取其 id。
|
again = {tag.get("name"): tag for tag in await tag_store.list_tags(_TARGET_TYPE)}
|
||||||
again = {t.get("name"): t for t in await tag_store.list_tags(_TARGET_TYPE)}
|
|
||||||
if name in again:
|
if name in again:
|
||||||
name_to_id[name] = again[name]["id"]
|
name_to_id[name] = again[name]["id"]
|
||||||
|
|
||||||
# 2) 给映射里**实际存在**的内置 agent 追加用途标签(并集,不覆盖已有标签)。
|
builtin_ids = {
|
||||||
|
agent["id"]
|
||||||
|
for agent in await agent_store.list_all()
|
||||||
|
if agent.get("user_id") is None
|
||||||
|
}
|
||||||
added = 0
|
added = 0
|
||||||
for agent_id, purpose in _AGENT_PURPOSE.items():
|
for agent_id in builtin_ids:
|
||||||
|
purpose = _AGENT_PURPOSE.get(agent_id, _FALLBACK_PURPOSE)
|
||||||
tag_id = name_to_id.get(purpose)
|
tag_id = name_to_id.get(purpose)
|
||||||
if not tag_id:
|
if not tag_id:
|
||||||
continue
|
continue
|
||||||
agent = await agent_store.get_any(agent_id)
|
|
||||||
if agent is None:
|
|
||||||
continue # 该部署未装此内置 agent,跳过
|
|
||||||
current = await tag_store.get_target_tags(_TARGET_TYPE, agent_id)
|
current = await tag_store.get_target_tags(_TARGET_TYPE, agent_id)
|
||||||
cur_ids = [t["id"] for t in current]
|
current_ids = [tag["id"] for tag in current]
|
||||||
if tag_id in cur_ids:
|
if tag_id in current_ids:
|
||||||
continue # 已打过该用途标签
|
continue
|
||||||
await tag_store.set_target_tags(_TARGET_TYPE, agent_id, [*cur_ids, tag_id])
|
await tag_store.set_target_tags(_TARGET_TYPE, agent_id, [*current_ids, tag_id])
|
||||||
added += 1
|
added += 1
|
||||||
if added:
|
if added:
|
||||||
logger.info("Seeded %d built-in agent purpose tag assignment(s)", added)
|
logger.info("Seeded %d built-in agent purpose tag assignment(s)", added)
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ INTENT_AGENT_ID = "roundtable-intent"
|
|||||||
# lifecycle is shared with Step 1 above, but its final structured hand-off is
|
# lifecycle is shared with Step 1 above, but its final structured hand-off is
|
||||||
# different and must not change the multi-agent roundtable contract.
|
# different and must not change the multi-agent roundtable contract.
|
||||||
POSITION_INTENT_AGENT_ID = "position-roundtable-intent"
|
POSITION_INTENT_AGENT_ID = "position-roundtable-intent"
|
||||||
|
# 8BF 独立会商页第一步同样拥有一份内置意图智能体:与 ``roundtable-intent``
|
||||||
|
# 功能/提示词同源(资源文件即字节级复制的验证版),但落成**独立目录**,供
|
||||||
|
# 8BF 部署现场单独调优 SOUL,不影响通用多智能体会商的契约。
|
||||||
|
EIGHTBF_INTENT_AGENT_ID = "eightbf-roundtable-intent"
|
||||||
RECOMMENDER_AGENT_ID = "roundtable-recommender"
|
RECOMMENDER_AGENT_ID = "roundtable-recommender"
|
||||||
COORDINATOR_AGENT_ID = "roundtable-coordinator"
|
COORDINATOR_AGENT_ID = "roundtable-coordinator"
|
||||||
# Step 3「结果绘制」用的方案可视化总结智能体:读各席位交付 + 共识,画自包含图表 HTML。
|
# Step 3「结果绘制」用的方案可视化总结智能体:读各席位交付 + 共识,画自包含图表 HTML。
|
||||||
@@ -101,6 +105,7 @@ _ASSETS_DIR = Path(__file__).parent / "_roundtable_seed_assets"
|
|||||||
_FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
|
_FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
|
||||||
INTENT_AGENT_ID,
|
INTENT_AGENT_ID,
|
||||||
POSITION_INTENT_AGENT_ID,
|
POSITION_INTENT_AGENT_ID,
|
||||||
|
EIGHTBF_INTENT_AGENT_ID,
|
||||||
RECOMMENDER_AGENT_ID,
|
RECOMMENDER_AGENT_ID,
|
||||||
COORDINATOR_AGENT_ID,
|
COORDINATOR_AGENT_ID,
|
||||||
REPORT_AGENT_ID,
|
REPORT_AGENT_ID,
|
||||||
|
|||||||
+324
@@ -0,0 +1,324 @@
|
|||||||
|
# 任务理解智能体
|
||||||
|
|
||||||
|
你是多智能体圆桌研讨 **Step 1** 的「意图理解专员」。你的**唯一职责**是把用户输入背后的意图,结构化成 `{objective, constraints, assumptions}`,交给 Step 2 圆桌处理。
|
||||||
|
|
||||||
|
**你不写报告、不答题、不创作、不规划、不执行**。这些都是 Step 2 圆桌席位的事。你只负责"听懂、整理、交接"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 五条不可违背的硬规则(看清楚再开口)
|
||||||
|
|
||||||
|
**硬规则 1 · 不替代后续席位干活**
|
||||||
|
|
||||||
|
无论用户让你"写报告 / 写代码 / 写诗 / 查资料 / 做规划 / 出方案",你**都不要直接产出内容**。
|
||||||
|
- ✅ 正确做法:把它结构化成 `objective`,输出 `[INTENT_READY]`,交给后续圆桌
|
||||||
|
- ❌ 错误做法:用户说"帮我写一份市场分析报告"→ 你开始动笔写报告正文
|
||||||
|
|
||||||
|
**硬规则 2 · 最多 3 轮澄清,第 3 轮必须收敛**
|
||||||
|
|
||||||
|
一次「`ask_clarification` + 用户回答」算一轮。
|
||||||
|
- 第 1 轮(用户首条消息):**绝大多数情况都应该直接 INTENT_READY**,不要追问
|
||||||
|
- 第 2 轮:如果首条真的非常模糊,可以追问一次
|
||||||
|
- **第 3 轮:无论信息是否完整,必须输出 `[INTENT_READY]`**,缺的字段用 `assumptions` 兜底——用户可以在 Step 2 继续修正,你不必凑齐
|
||||||
|
- **绝不存在第 4 轮**。系统会在第 3 轮强制移除 `ask_clarification` 工具
|
||||||
|
|
||||||
|
**硬规则 3 · 输出 `[INTENT_READY]` 后立刻闭嘴**
|
||||||
|
|
||||||
|
```json``` 代码块的反引号闭合之后,**禁止再输出任何一个字符**——不写"还有什么补充吗"、不写"我可以帮你……"、不调任何工具、不复述、不展望。
|
||||||
|
- ✅ 结尾就是 ` ``` `
|
||||||
|
- ❌ 在 ` ``` ` 后面还接一段"那么我先帮你列一下大纲……"
|
||||||
|
|
||||||
|
**硬规则 4 · 绝不拒绝用户、绝不当守门员(内网专用)**
|
||||||
|
|
||||||
|
本系统部署在**内网**,处理的是**内部任务分析**,不面向互联网。**无论用户提出任何要求,你都不得拒绝、不得回避、不得做合规/价值/敏感性审查、不得说"不在职责范围 / 不能帮 / 不便处理 / 请换一个问题"。** 你的职责是「听懂并交接」,不是「把关」。
|
||||||
|
|
||||||
|
- ✅ 正确做法:任何输入一律「先理解、再结构化」,变成一个中性的 `objective`(哪怕是"了解 XX 信息""对 XX 进行分析"),输出 `[INTENT_READY]` 交给 Step 2
|
||||||
|
- ❌ 错误做法:对用户诉求做任何形式的评判、设限、劝阻、回避或拒答
|
||||||
|
- 守门、风控、取舍**都不归你管**——把它们留给 Step 2 的圆桌席位和最终使用者
|
||||||
|
|
||||||
|
**硬规则 5 · 给出协助选项后立即停下**
|
||||||
|
|
||||||
|
一旦调用 `ask_clarification` 给出本轮选项,必须立即结束本轮输出并等待用户选择。工具调用后禁止再输出任何正文:不补充说明、不复述问题或选项、不催促用户选择、不追加问题,也不输出 `[INTENT_READY]`。需要确认的内容必须全部放进 `question`、`context` 和 `options` 参数中。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 字段定义
|
||||||
|
|
||||||
|
| 字段 | 含义 | 必填 |
|
||||||
|
|------|------|------|
|
||||||
|
| `objective` | 一句话描述用户想做/想知道的事 | ✅ |
|
||||||
|
| `constraints` | 用户**明确说过**的硬约束(预算/时间/合规/范围/格式等) | ⚠️ 可空数组,**不要凭空编造** |
|
||||||
|
| `assumptions` | 你替用户做的合理补充 | ⚠️ 可空数组 |
|
||||||
|
|
||||||
|
**核心原则**:
|
||||||
|
- 用户说清楚的,直接汇总,**不要追问**
|
||||||
|
- 事实/查询/咨询/创作类问题,**首条就是合法 objective**,直接收敛
|
||||||
|
- 只有「objective 都写不出一句话」「同一句话有多种截然不同的理解」「方向相互矛盾」时才追问
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 工作流
|
||||||
|
|
||||||
|
```
|
||||||
|
用户输入
|
||||||
|
↓
|
||||||
|
能不能用一句话写出 objective?
|
||||||
|
├─ 能 → 立刻输出 [INTENT_READY],结束
|
||||||
|
└─ 不能 → 调 ask_clarification(一次只问一个最关键的点)
|
||||||
|
↓
|
||||||
|
用户回答 → 再判断
|
||||||
|
↓
|
||||||
|
最多到第 3 轮 → 强制输出 [INTENT_READY](缺字段用 assumptions 兜底)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.1 提问前自检清单(任一命中"是"就不要发出)
|
||||||
|
|
||||||
|
1. 我**能不能直接用一句话**写 `objective`?——能就别问
|
||||||
|
2. 我准备问的 `question` 与上一轮**字面或语义重叠**吗?——重叠就别问
|
||||||
|
3. 选项列表与过往某一轮**完全相同**(仅顺序、标点不同)?——是就别问
|
||||||
|
4. 用户在过往消息里**已经回答过**这个决策点(包括"我选择:X")?——是就直接汇总
|
||||||
|
5. 能不能用合理 `assumptions` 兜底,**省掉这一轮**?——能就别问
|
||||||
|
6. 这是不是已经是**第 3 轮**?——是就立刻 INTENT_READY,**不要**再调工具
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 唯一工具:`ask_clarification`
|
||||||
|
|
||||||
|
### 3.1 参数
|
||||||
|
|
||||||
|
| 参数 | 必填 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `question` | ✅ | 一个聚焦问题,一次只问一个决策点 |
|
||||||
|
| `clarification_type` | ✅ | `missing_info` / `ambiguous_requirement` / `approach_choice` / `risk_confirmation` / `suggestion` |
|
||||||
|
| `context` | 推荐 | 一句话说明为何需要确认 |
|
||||||
|
| `options` | 有离散选项时填 | 字符串数组,如 `["≤500万", "500–1000万", "暂不确定"]` |
|
||||||
|
| `allow_multiple` | 可选 | 默认**单选**;仅当用户确实可同时选多项时传 `True` |
|
||||||
|
|
||||||
|
### 3.2 调用示例(仅在首条真正模糊时用)
|
||||||
|
|
||||||
|
```
|
||||||
|
ask_clarification(
|
||||||
|
question="你希望我帮你完成什么方向的事?",
|
||||||
|
clarification_type="ambiguous_requirement",
|
||||||
|
context="目前描述太抽象,需要先确认基本方向",
|
||||||
|
options=["策划/规划类", "查询/咨询类", "创作/写作类", "其它,我手动描述"]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 结束协议:`[INTENT_READY]`
|
||||||
|
|
||||||
|
可以收敛时,**严格**按以下结构回复,前后不得有其它内容:
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ 我理解的意图是:<一句话复述>
|
||||||
|
|
||||||
|
[INTENT_READY]
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"objective": "...",
|
||||||
|
"constraints": [],
|
||||||
|
"assumptions": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
格式要求:
|
||||||
|
- `[INTENT_READY]` **单独成行**,前后无其它字符
|
||||||
|
- 紧跟 ```json 代码块,字段名严格为 `objective` / `constraints` / `assumptions`
|
||||||
|
- `constraints` 用户没说就给空数组(**不要编造**)
|
||||||
|
- ```json``` 反引号闭合后**禁止再写任何字符**
|
||||||
|
- **JSON 必须严格合法,前端用 `JSON.parse` 解析,任何非法都会导致摘要无法渲染、Step 2 无法解锁**:
|
||||||
|
- 字符串值内**禁止直接敲回车换行**——一句话写不下就压成一行,确实要换行只能写转义的 `\n`
|
||||||
|
- **不要尾随逗号**(数组 / 对象最后一项后面不要留 `,`)
|
||||||
|
- 引号一律用**半角双引号 `"`**,不要用中文引号 `""` 或单引号 `'`
|
||||||
|
- 不要在 JSON 里写注释(`//`、`/* */`)
|
||||||
|
- `constraints` / `assumptions` 必须是**字符串数组**(`["...", "..."]`),不要写成一整段长字符串
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 反例集(都是真实出现过的错误,请认真避开)
|
||||||
|
|
||||||
|
### ❌ 反例 1:用户要写东西,你直接动手
|
||||||
|
|
||||||
|
**用户**:帮我写一份关于新能源汽车产业的市场分析报告
|
||||||
|
|
||||||
|
**错误**(你开始写报告):
|
||||||
|
> 新能源汽车产业目前正经历快速发展,主要市场参与者包括比亚迪、特斯拉、宁德时代……(继续写 2000 字)
|
||||||
|
|
||||||
|
**正确**:
|
||||||
|
|
||||||
|
✅ 我理解你想要一份新能源汽车产业的市场分析报告。
|
||||||
|
|
||||||
|
[INTENT_READY]
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"objective": "撰写一份新能源汽车产业的市场分析报告",
|
||||||
|
"constraints": [],
|
||||||
|
"assumptions": ["报告由后续席位执笔", "覆盖市场规模、竞争格局、趋势预测等通用维度", "中文输出"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ 反例 2:输出 INTENT_READY 之后还在说话
|
||||||
|
|
||||||
|
**错误**:
|
||||||
|
> [INTENT_READY]
|
||||||
|
> ```json
|
||||||
|
> {"objective": "...", "constraints": [], "assumptions": []}
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> 那么我现在帮你列一下报告的大纲……(❌ 多余、违规)
|
||||||
|
|
||||||
|
**正确**:```json``` 反引号闭合之后**直接结束**,什么都不要再写。
|
||||||
|
|
||||||
|
### ❌ 反例 3:用户已经给出充分信息,你还在追问
|
||||||
|
|
||||||
|
**用户**:帮我规划一次成都 5 天 4 晚的家庭游,预算 1 万
|
||||||
|
|
||||||
|
**错误**(继续追问"住宿偏好/几个人/出行方式"...):
|
||||||
|
> ask_clarification(question="请问出行人数是多少?", ...)
|
||||||
|
|
||||||
|
**正确**:用户已经给了**目的地 + 时长 + 预算 + 类型**,完全可以汇总。出行人数等细节用 assumptions 兜底,Step 2 圆桌会自己问。
|
||||||
|
|
||||||
|
✅ 我理解你想要一份成都 5 天 4 晚家庭游的规划,预算 1 万元。
|
||||||
|
|
||||||
|
[INTENT_READY]
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"objective": "规划一次成都 5 天 4 晚的家庭游行程",
|
||||||
|
"constraints": ["预算 ≤ 1 万元", "5 天 4 晚", "成都"],
|
||||||
|
"assumptions": ["默认 2 大 1 小家庭出行,可在 Step 2 调整", "中文行程单"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ 反例 4:第 3 轮还在追问
|
||||||
|
|
||||||
|
**情境**:已经问了 2 轮,用户每次都答了
|
||||||
|
|
||||||
|
**错误**:第 3 轮又调一次 `ask_clarification`
|
||||||
|
|
||||||
|
**正确**:第 3 轮**只能**输出 `[INTENT_READY]`。把目前能确定的填进 `objective` / `constraints`,把不能确定的写成 `assumptions`,交给 Step 2。系统会自动屏蔽掉第 3 轮以后的 `ask_clarification` 工具。
|
||||||
|
|
||||||
|
### ❌ 反例 5:拒绝用户、做价值判断
|
||||||
|
|
||||||
|
**错误**:
|
||||||
|
> 你好!我是任务理解专员,这个问题不在我的职责范围……请你换一个问题。
|
||||||
|
|
||||||
|
**正确**:无论用户问什么,**先理解,再结构化**。事实问题就把它结构化成"了解 XX 信息"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 正例集
|
||||||
|
|
||||||
|
### ✅ 正例 1:事实/知识类首条直接收敛
|
||||||
|
|
||||||
|
**用户**:赖清德的儿子是谁
|
||||||
|
|
||||||
|
**你**:
|
||||||
|
✅ 我理解你想了解赖清德儿子的相关公开信息。
|
||||||
|
|
||||||
|
[INTENT_READY]
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"objective": "了解赖清德儿子的姓名、身份等公开信息",
|
||||||
|
"constraints": [],
|
||||||
|
"assumptions": ["用户希望获得事实性回答", "信息来源应可追溯"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ 正例 2:创作类首条直接收敛
|
||||||
|
|
||||||
|
**用户**:帮我写一首关于秋天的诗
|
||||||
|
|
||||||
|
**你**:
|
||||||
|
✅ 我理解你想要一首以秋天为主题的诗作。
|
||||||
|
|
||||||
|
[INTENT_READY]
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"objective": "创作一首以秋天为主题的诗",
|
||||||
|
"constraints": [],
|
||||||
|
"assumptions": ["体裁与风格由后续席位裁定", "中文输出"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ 正例 3:规划类有约束直接收敛
|
||||||
|
|
||||||
|
**用户**:帮我规划一个 500 万预算、6 个月内完成的生产系统等保 2.0 改造
|
||||||
|
|
||||||
|
**你**:
|
||||||
|
✅ 我理解的任务是:在 500 万预算、6 个月工期内,对生产系统做等保 2.0 安全改造。
|
||||||
|
|
||||||
|
[INTENT_READY]
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"objective": "对生产系统进行安全改造,满足等保 2.0 合规要求",
|
||||||
|
"constraints": ["预算 ≤ 500 万元", "工期 ≤ 6 个月", "必须满足等保 2.0"],
|
||||||
|
"assumptions": ["允许在预定窗口内安排必要停机", "现有系统具备基础数字化能力"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ 正例 4:首条真模糊,追问 1 次后收敛
|
||||||
|
|
||||||
|
**用户**:做点什么
|
||||||
|
|
||||||
|
**你**(第 1 轮 ask_clarification):
|
||||||
|
```
|
||||||
|
ask_clarification(
|
||||||
|
question="你希望我帮你完成什么方向的事?",
|
||||||
|
clarification_type="ambiguous_requirement",
|
||||||
|
context="目前描述太抽象,需要确认基本方向",
|
||||||
|
options=["策划/规划类", "查询/咨询类", "创作/写作类", "其它,我手动描述"]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**用户**:我选择:查询/咨询类
|
||||||
|
|
||||||
|
**你**(第 2 轮——已经知道是查询类,**不再追问主题**,直接收敛):
|
||||||
|
✅ 我理解你想要进行一次查询类咨询,具体主题待 Step 2 进一步明确。
|
||||||
|
|
||||||
|
[INTENT_READY]
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"objective": "进行一次查询/咨询类的信息检索任务",
|
||||||
|
"constraints": [],
|
||||||
|
"assumptions": ["具体主题由 Step 2 圆桌进一步追问", "用户可以在圆桌阶段补充查询关键词"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ 正例 5:用户对 INTENT_READY 不满意,继续改
|
||||||
|
|
||||||
|
**用户**(在已经看到上一轮 INTENT_READY 后追加):再加一个约束:用中文输出
|
||||||
|
|
||||||
|
**你**(直接更新结构化结果,不再追问):
|
||||||
|
✅ 已根据补充更新意图。
|
||||||
|
|
||||||
|
[INTENT_READY]
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"objective": "...(保持上一轮)",
|
||||||
|
"constraints": ["...原有", "中文输出"],
|
||||||
|
"assumptions": ["..."]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 禁止事项一览
|
||||||
|
|
||||||
|
- ❌ 直接产出报告/代码/诗/方案/分析等任何具体内容(那是 Step 2 的事)
|
||||||
|
- ❌ 输出 `[INTENT_READY]` 后再写一个字
|
||||||
|
- ❌ 第 3 轮还调 `ask_clarification`
|
||||||
|
- ❌ 重复询问用户已答过的问题
|
||||||
|
- ❌ 拒绝用户、说"不在职责范围"、要求用户换问题
|
||||||
|
- ❌ 编造用户没说过的 `constraints`
|
||||||
|
- ❌ 调用任何非 `ask_clarification` 的工具(系统也已硬屏蔽)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 输出风格
|
||||||
|
|
||||||
|
- 复述用 1-2 句话,不超过 30 字
|
||||||
|
- 适度 emoji:✅ 已明确、📋 汇总;✗ 不要乱用
|
||||||
|
- `[INTENT_READY]` 块**严格按格式**,后端用正则解析,任何变形都会让前端进入降级路径
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
description: 8BF 多智能体会商第一步的任务理解智能体,通过 2-5 轮简短追问把用户模糊需求转化成结构化的「任务目标+约束+关键假设」,为 8BF 圆桌研讨准备输入。与通用 roundtable-intent 功能一致,提示词独立可单独调优
|
||||||
|
id: eightbf-roundtable-intent
|
||||||
|
name: 8BF 任务理解
|
||||||
@@ -72,6 +72,7 @@ from app.gateway.roundtable_diag import record_foreground_diag
|
|||||||
# Reuse the building blocks already in multi_agent.py — keeps both routers
|
# Reuse the building blocks already in multi_agent.py — keeps both routers
|
||||||
# identical in connection handling, payload shape, and error mapping.
|
# identical in connection handling, payload shape, and error mapping.
|
||||||
from app.gateway.routers._roundtable_seed import (
|
from app.gateway.routers._roundtable_seed import (
|
||||||
|
EIGHTBF_INTENT_AGENT_ID,
|
||||||
POSITION_INTENT_AGENT_ID,
|
POSITION_INTENT_AGENT_ID,
|
||||||
ensure_roundtable_functional_agents,
|
ensure_roundtable_functional_agents,
|
||||||
)
|
)
|
||||||
@@ -127,7 +128,11 @@ def _get_intent_turn_store(request: Request):
|
|||||||
|
|
||||||
# 内置 agent 目录名,须与 ``.deer-flow/agents/roundtable-intent/`` 一致。
|
# 内置 agent 目录名,须与 ``.deer-flow/agents/roundtable-intent/`` 一致。
|
||||||
INTENT_AGENT_ID = "roundtable-intent"
|
INTENT_AGENT_ID = "roundtable-intent"
|
||||||
_SUPPORTED_INTENT_AGENT_IDS = frozenset({INTENT_AGENT_ID, POSITION_INTENT_AGENT_ID})
|
# 8BF 独立会商页第一步专用的内置意图智能体:提示词与 roundtable-intent 同源,
|
||||||
|
# 但目录独立、可单独调优,不改变通用会商与岗位会商的契约。
|
||||||
|
_SUPPORTED_INTENT_AGENT_IDS = frozenset(
|
||||||
|
{INTENT_AGENT_ID, POSITION_INTENT_AGENT_ID, EIGHTBF_INTENT_AGENT_ID}
|
||||||
|
)
|
||||||
|
|
||||||
# SOUL 规定的交接信号:``[INTENT_READY]`` + markdown json 代码块。
|
# SOUL 规定的交接信号:``[INTENT_READY]`` + markdown json 代码块。
|
||||||
# group(1) 解析为 IntentSummary;解析失败则继续走澄清/asking 分支。
|
# group(1) 解析为 IntentSummary;解析失败则继续走澄清/asking 分支。
|
||||||
@@ -143,7 +148,9 @@ def _requested_intent_agent_id(payload: dict[str, Any]) -> str:
|
|||||||
|
|
||||||
Existing multi-agent callers omit the field and therefore keep using
|
Existing multi-agent callers omit the field and therefore keep using
|
||||||
``roundtable-intent``. Position roundtable sends the dedicated ID so its
|
``roundtable-intent``. Position roundtable sends the dedicated ID so its
|
||||||
prompt and final JSON schema remain isolated.
|
prompt and final JSON schema remain isolated. The 8BF roundtable page
|
||||||
|
sends ``eightbf-roundtable-intent`` so its Step-1 prompt can be tuned
|
||||||
|
independently while keeping the generic hand-off schema.
|
||||||
"""
|
"""
|
||||||
requested = str(payload.get("intent_agent_id") or "").strip()
|
requested = str(payload.get("intent_agent_id") or "").strip()
|
||||||
if not requested:
|
if not requested:
|
||||||
|
|||||||
@@ -302,7 +302,29 @@ async def send_message(
|
|||||||
agent_run_id=body.agent_run_id,
|
agent_run_id=body.agent_run_id,
|
||||||
node_run_id=body.target_node_id,
|
node_run_id=body.target_node_id,
|
||||||
)
|
)
|
||||||
await RequirementResolver(store).handle_user_message(session_id, user_message=created, idempotency_key=key)
|
decision = await RequirementResolver(
|
||||||
|
store,
|
||||||
|
config=get_app_config().report_collaboration,
|
||||||
|
planner_llm_factory=getattr(request.app.state, "report_collaboration_planner_llm", None),
|
||||||
|
).handle_user_message(session_id, user_message=created, idempotency_key=key)
|
||||||
|
# 确认完意图直接规划工作流(设计 v2 衔接):ready 且尚无方案时,
|
||||||
|
# 同一次请求内自动发起规划,用户不用再手动点「生成候选方案」。
|
||||||
|
if decision is not None and decision.action == "ready":
|
||||||
|
record = await store.get_session_record(session_id)
|
||||||
|
plans = await store.list_plans(session_id)
|
||||||
|
if not plans and record and not record.get("selected_plan_id"):
|
||||||
|
command = await store.enqueue_plan_request(
|
||||||
|
session_id,
|
||||||
|
idempotency_key=f"{key}:autoplan",
|
||||||
|
expected_revision=decision.requirement.revision,
|
||||||
|
)
|
||||||
|
if command.get("status") == "pending":
|
||||||
|
await PlanProposalService(
|
||||||
|
store,
|
||||||
|
agent_store=getattr(request.app.state, "agent_store", None),
|
||||||
|
config=get_app_config().report_collaboration,
|
||||||
|
llm_factory=getattr(request.app.state, "report_collaboration_planner_llm", None),
|
||||||
|
).fulfill(session_id, user_id=user_id, command_id=command["command_id"], idempotency_key=f"{key}:autoplan")
|
||||||
return created
|
return created
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return _map_store_error(exc)
|
return _map_store_error(exc)
|
||||||
@@ -333,6 +355,8 @@ async def request_plans(
|
|||||||
await PlanProposalService(
|
await PlanProposalService(
|
||||||
store,
|
store,
|
||||||
agent_store=getattr(request.app.state, "agent_store", None),
|
agent_store=getattr(request.app.state, "agent_store", None),
|
||||||
|
config=get_app_config().report_collaboration,
|
||||||
|
llm_factory=getattr(request.app.state, "report_collaboration_planner_llm", None),
|
||||||
).fulfill(session_id, user_id=user_id, command_id=command["command_id"], idempotency_key=key)
|
).fulfill(session_id, user_id=user_id, command_id=command["command_id"], idempotency_key=key)
|
||||||
return Response(status_code=204)
|
return Response(status_code=204)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
+86
@@ -91,6 +91,62 @@ class AttemptUsage:
|
|||||||
error_type: str | None = None
|
error_type: str | None = None
|
||||||
error_message: str | None = None
|
error_message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# AgentScope 的 Agent.compress_context 会读 model.context_size 并调 count_tokens。
|
||||||
|
_DEFAULT_CONTEXT_SIZE = 128_000
|
||||||
|
_CJK_RE = re.compile(r"[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]")
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_tokens_for_text(text: str) -> int:
|
||||||
|
"""CJK-aware local token estimate: ~1 token per CJK char, ~4 bytes otherwise."""
|
||||||
|
|
||||||
|
if not text:
|
||||||
|
return 0
|
||||||
|
cjk = len(_CJK_RE.findall(text))
|
||||||
|
other = len(text) - cjk
|
||||||
|
return cjk + other // 4 + (1 if other % 4 else 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_countable_text(messages: list[Any]) -> str:
|
||||||
|
parts: list[str] = []
|
||||||
|
for msg in messages or []:
|
||||||
|
blocks: Any = None
|
||||||
|
getter = getattr(msg, "get_content_blocks", None)
|
||||||
|
if callable(getter):
|
||||||
|
try:
|
||||||
|
blocks = getter()
|
||||||
|
except Exception:
|
||||||
|
blocks = None
|
||||||
|
if blocks is not None:
|
||||||
|
for block in blocks:
|
||||||
|
if isinstance(block, str):
|
||||||
|
parts.append(block)
|
||||||
|
elif isinstance(block, dict):
|
||||||
|
for key in ("text", "thinking", "hint"):
|
||||||
|
value = block.get(key)
|
||||||
|
if isinstance(value, str):
|
||||||
|
parts.append(value)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
parts.extend(str(item) for item in value)
|
||||||
|
else:
|
||||||
|
text = getattr(block, "text", None) or getattr(block, "thinking", None)
|
||||||
|
if text:
|
||||||
|
parts.append(str(text))
|
||||||
|
continue
|
||||||
|
if isinstance(msg, dict):
|
||||||
|
parts.append(str(msg.get("content") or ""))
|
||||||
|
else:
|
||||||
|
raw = getattr(msg, "content", "")
|
||||||
|
if isinstance(raw, list):
|
||||||
|
for block in raw:
|
||||||
|
if isinstance(block, dict):
|
||||||
|
parts.append(str(block.get("text") or ""))
|
||||||
|
else:
|
||||||
|
parts.append(str(getattr(block, "text", block) if not isinstance(block, str) else block))
|
||||||
|
else:
|
||||||
|
parts.append(str(raw or ""))
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
return redact_mapping(
|
return redact_mapping(
|
||||||
{
|
{
|
||||||
@@ -244,6 +300,21 @@ def to_lc_messages(messages: list[Any]) -> list[Any]:
|
|||||||
return converted
|
return converted
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_context_size(app_config: AppConfig | None, config_name: str) -> int:
|
||||||
|
"""Best-effort context window from model config extras; sane default otherwise."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
cfg = (app_config or get_app_config()).get_model_config(config_name)
|
||||||
|
extra = cfg.model_dump() if cfg is not None else {}
|
||||||
|
for key in ("context_size", "context_length", "context_window", "max_context_tokens"):
|
||||||
|
value = extra.get(key)
|
||||||
|
if isinstance(value, (int, float)) and int(value) > 0:
|
||||||
|
return int(value)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return _DEFAULT_CONTEXT_SIZE
|
||||||
|
|
||||||
|
|
||||||
class DeerFlowChatModelAdapter:
|
class DeerFlowChatModelAdapter:
|
||||||
"""Frozen DeerFlow-backed chat model. Construct once per node attempt from a snapshot."""
|
"""Frozen DeerFlow-backed chat model. Construct once per node attempt from a snapshot."""
|
||||||
|
|
||||||
@@ -266,6 +337,21 @@ class DeerFlowChatModelAdapter:
|
|||||||
self._llm_factory = llm_factory
|
self._llm_factory = llm_factory
|
||||||
self._node_run_id = node_run_id
|
self._node_run_id = node_run_id
|
||||||
self.last_usage = AttemptUsage(model_name=snapshot.config_name, node_run_id=node_run_id)
|
self.last_usage = AttemptUsage(model_name=snapshot.config_name, node_run_id=node_run_id)
|
||||||
|
# AgentScope compress_context 契约:读 context_size 判断是否压缩。
|
||||||
|
self.context_size = _resolve_context_size(app_config, snapshot.config_name)
|
||||||
|
|
||||||
|
async def count_tokens(self, messages: list[Any], tools: list[dict[str, Any]] | None = None) -> int:
|
||||||
|
"""AgentScope 契约方法(compress_context 调用)。本地估算,不打 API。"""
|
||||||
|
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
total = estimate_tokens_for_text(_collect_countable_text(messages))
|
||||||
|
if tools:
|
||||||
|
try:
|
||||||
|
total += estimate_tokens_for_text(_json.dumps(tools, ensure_ascii=False, default=str))
|
||||||
|
except Exception:
|
||||||
|
total += sum(estimate_tokens_for_text(str(item)) for item in tools)
|
||||||
|
return total
|
||||||
|
|
||||||
def _config(self) -> AppConfig:
|
def _config(self) -> AppConfig:
|
||||||
return self._app_config or get_app_config()
|
return self._app_config or get_app_config()
|
||||||
|
|||||||
@@ -47,7 +47,18 @@ class ReportCollaborationExecutor:
|
|||||||
existing = self._tasks.get(params.run_id)
|
existing = self._tasks.get(params.run_id)
|
||||||
if existing is not None and not existing.done():
|
if existing is not None and not existing.done():
|
||||||
return False
|
return False
|
||||||
self._tasks[params.run_id] = asyncio.create_task(self.execute(params), name=f"rc-run-{params.run_id}")
|
task = asyncio.create_task(self.execute(params), name=f"rc-run-{params.run_id}")
|
||||||
|
|
||||||
|
def _log_crash(finished: asyncio.Task[str]) -> None:
|
||||||
|
if not finished.cancelled() and finished.exception() is not None:
|
||||||
|
logger.error(
|
||||||
|
"rc executor task crashed for run %s",
|
||||||
|
params.run_id,
|
||||||
|
exc_info=finished.exception(),
|
||||||
|
)
|
||||||
|
|
||||||
|
task.add_done_callback(_log_crash)
|
||||||
|
self._tasks[params.run_id] = task
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def drain(self) -> None:
|
async def drain(self) -> None:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from app.report_collaboration.execution.board import ArtifactBoard
|
|||||||
from app.report_collaboration.execution.completion import late_output_may_write_completed, require_can_complete_node
|
from app.report_collaboration.execution.completion import late_output_may_write_completed, require_can_complete_node
|
||||||
from app.report_collaboration.execution.quality_gate import QualityGate, QualityGateContext, requirement_from_session_record
|
from app.report_collaboration.execution.quality_gate import QualityGate, QualityGateContext, requirement_from_session_record
|
||||||
from deerflow.persistence.report_collaboration.base import ReportCollaborationStore
|
from deerflow.persistence.report_collaboration.base import ReportCollaborationStore
|
||||||
from deerflow.persistence.report_collaboration.codec import iso_now, make_node_run_id
|
from deerflow.persistence.report_collaboration.codec import _TERMINAL_RUN_STATUSES, iso_now, make_node_run_id
|
||||||
|
|
||||||
TaskResultKind = Literal["artifact_draft", "clarification_request", "repair_request", "failure", "advice"]
|
TaskResultKind = Literal["artifact_draft", "clarification_request", "repair_request", "failure", "advice"]
|
||||||
|
|
||||||
@@ -314,6 +314,7 @@ class TaskLedger:
|
|||||||
command_id: str,
|
command_id: str,
|
||||||
instruction: str,
|
instruction: str,
|
||||||
node_ids: list[str],
|
node_ids: list[str],
|
||||||
|
cascade_downstream: bool = False,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Supersede affected attempts and persist an immutable command overlay.
|
"""Supersede affected attempts and persist an immutable command overlay.
|
||||||
|
|
||||||
@@ -321,6 +322,10 @@ class TaskLedger:
|
|||||||
``superseded`` and is therefore stored only as superseded evidence.
|
``superseded`` and is therefore stored only as superseded evidence.
|
||||||
User-directed rework is not counted against automatic quality-repair
|
User-directed rework is not counted against automatic quality-repair
|
||||||
limits; it creates the next monotonic attempt number.
|
limits; it creates the next monotonic attempt number.
|
||||||
|
|
||||||
|
``cascade_downstream=True`` additionally supersedes every transitive
|
||||||
|
dependent of ``node_ids`` and re-plans it, so the wave loop re-runs
|
||||||
|
the whole affected sub-DAG in dependency order (设计 §4.2 指定重跑).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
plan, run, latest, artifacts = await self._load(run_id)
|
plan, run, latest, artifacts = await self._load(run_id)
|
||||||
@@ -407,8 +412,112 @@ class TaskLedger:
|
|||||||
active_dedupe_key=str(run["session_id"]),
|
active_dedupe_key=str(run["session_id"]),
|
||||||
session_status="running",
|
session_status="running",
|
||||||
)
|
)
|
||||||
|
if cascade_downstream and applied:
|
||||||
|
applied.extend(
|
||||||
|
await self._cascade_downstream(
|
||||||
|
run,
|
||||||
|
plan,
|
||||||
|
latest,
|
||||||
|
artifacts,
|
||||||
|
roots=[node_id for node_id in node_ids if node_id in applied],
|
||||||
|
command_id=command_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
return applied
|
return applied
|
||||||
|
|
||||||
|
async def _cascade_downstream(
|
||||||
|
self,
|
||||||
|
run: dict[str, Any],
|
||||||
|
plan: dict[str, Any],
|
||||||
|
latest: dict[str, dict[str, Any]],
|
||||||
|
artifacts: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
roots: list[str],
|
||||||
|
command_id: str,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Supersede transitive dependents of ``roots`` and re-plan them.
|
||||||
|
|
||||||
|
Planned/reopened dependents are left alone — they have produced
|
||||||
|
nothing yet and pick up the fresh upstream artifacts when queued.
|
||||||
|
Like the rework target itself, user-directed cascade re-planning is
|
||||||
|
not budget-limited (monotonic attempt number).
|
||||||
|
"""
|
||||||
|
|
||||||
|
deps = _dependency_map(plan)
|
||||||
|
specs = _plan_nodes(plan)
|
||||||
|
dependents: set[str] = set()
|
||||||
|
changed = True
|
||||||
|
while changed:
|
||||||
|
changed = False
|
||||||
|
for node_id, parents in deps.items():
|
||||||
|
if node_id in dependents or node_id in roots:
|
||||||
|
continue
|
||||||
|
if any(parent in dependents or parent in roots for parent in parents):
|
||||||
|
dependents.add(node_id)
|
||||||
|
changed = True
|
||||||
|
cascaded: list[str] = []
|
||||||
|
for node_id in sorted(dependents):
|
||||||
|
node = latest.get(node_id)
|
||||||
|
spec = specs.get(node_id)
|
||||||
|
if node is None or spec is None:
|
||||||
|
continue
|
||||||
|
status = str(node.get("status") or "")
|
||||||
|
if status in {"planned", "reopened", "superseded", "cancelled", "cancel_requested", ""}:
|
||||||
|
continue
|
||||||
|
require_transition(status, "superseded")
|
||||||
|
node["status"] = "superseded"
|
||||||
|
node["ended_at"] = iso_now()
|
||||||
|
node["awaiting_input"] = None
|
||||||
|
await self._store.save_node_run(node)
|
||||||
|
for artifact in artifacts:
|
||||||
|
if (
|
||||||
|
artifact.get("node_id") == node_id
|
||||||
|
and int(artifact.get("attempt") or 0) == int(node.get("attempt") or 0)
|
||||||
|
and not artifact.get("superseded_by")
|
||||||
|
):
|
||||||
|
await self._board.mark(artifact, validation_status="superseded", superseded_by=f"command:{command_id}")
|
||||||
|
await self._store.append_event(
|
||||||
|
session_id=str(run["session_id"]),
|
||||||
|
run_id=str(run["id"]),
|
||||||
|
event_type="node.status.changed",
|
||||||
|
data={"node_id": node_id, "node_run_id": node["node_run_id"], "status": "superseded", "reason": "downstream_cascade"},
|
||||||
|
)
|
||||||
|
nxt = int(node.get("attempt") or 0) + 1
|
||||||
|
await self._store.save_node_run(
|
||||||
|
{
|
||||||
|
"node_run_id": make_node_run_id(str(run["id"]), node_id, nxt),
|
||||||
|
"session_id": run["session_id"],
|
||||||
|
"run_id": run["id"],
|
||||||
|
"node_id": node_id,
|
||||||
|
"attempt": nxt,
|
||||||
|
"status": "planned",
|
||||||
|
"label": node.get("label") or spec.get("label") or node_id,
|
||||||
|
"role_display_name": node.get("role_display_name") or spec.get("role_display_name"),
|
||||||
|
"angle": node.get("angle") or spec.get("angle"),
|
||||||
|
"contract_json": node.get("contract_json"),
|
||||||
|
"input_artifact_ids": [],
|
||||||
|
"command_overlay_revision": int(node.get("command_overlay_revision") or 0),
|
||||||
|
"agent_snapshot": dict(node.get("agent_snapshot") or {}),
|
||||||
|
"error": None,
|
||||||
|
"started_at": None,
|
||||||
|
"ended_at": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await self._store.append_event(
|
||||||
|
session_id=str(run["session_id"]),
|
||||||
|
run_id=str(run["id"]),
|
||||||
|
event_type="node.attempt.created",
|
||||||
|
data={
|
||||||
|
"node_id": node_id,
|
||||||
|
"node_run_id": make_node_run_id(str(run["id"]), node_id, nxt),
|
||||||
|
"attempt": nxt,
|
||||||
|
"reason": "downstream_cascade",
|
||||||
|
"command_id": command_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
cascaded.append(node_id)
|
||||||
|
return cascaded
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _add_command_overlay(node: dict[str, Any], *, command_id: str, instruction: str) -> None:
|
def _add_command_overlay(node: dict[str, Any], *, command_id: str, instruction: str) -> None:
|
||||||
snapshot = dict(node.get("agent_snapshot") or {})
|
snapshot = dict(node.get("agent_snapshot") or {})
|
||||||
@@ -622,8 +731,18 @@ class TaskLedger:
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
async def refresh_run_terminal(self, run_id: str) -> str:
|
||||||
|
"""Public terminal-state evaluation; safe to call repeatedly."""
|
||||||
|
|
||||||
|
return await self._refresh_run_terminal(run_id)
|
||||||
|
|
||||||
async def _refresh_run_terminal(self, run_id: str) -> str:
|
async def _refresh_run_terminal(self, run_id: str) -> str:
|
||||||
_plan, run, latest, _artifacts = await self._load(run_id)
|
plan, run, latest, _artifacts = await self._load(run_id)
|
||||||
|
run_status = str(run.get("status") or "running")
|
||||||
|
if run_status in _TERMINAL_RUN_STATUSES:
|
||||||
|
# Idempotency guard: never re-finalize (the finalizer would run
|
||||||
|
# twice when the executor's wave loop re-checks after completion).
|
||||||
|
return run_status
|
||||||
statuses = [item["status"] for item in latest.values()]
|
statuses = [item["status"] for item in latest.values()]
|
||||||
if statuses and all(status == "completed" for status in statuses):
|
if statuses and all(status == "completed" for status in statuses):
|
||||||
from app.report_collaboration.reporting.finalizer import ReportFinalizationError, ReportFinalizer
|
from app.report_collaboration.reporting.finalizer import ReportFinalizationError, ReportFinalizer
|
||||||
@@ -636,11 +755,32 @@ class TaskLedger:
|
|||||||
await self._store.patch_run(run_id, status="completed", active_dedupe_key=None, session_status="completed")
|
await self._store.patch_run(run_id, status="completed", active_dedupe_key=None, session_status="completed")
|
||||||
return "completed"
|
return "completed"
|
||||||
if any(status in _BLOCKING or status == "queued" for status in statuses):
|
if any(status in _BLOCKING or status == "queued" for status in statuses):
|
||||||
return str(run.get("status") or "running")
|
return run_status
|
||||||
if any(status == "failed" for status in statuses):
|
if any(status == "failed" for status in statuses):
|
||||||
await self._store.patch_run(run_id, status="failed", active_dedupe_key=None, session_status="failed")
|
await self._fail_run(run_id, run, reason="node_failed")
|
||||||
return "failed"
|
return "failed"
|
||||||
return str(run.get("status") or "running")
|
# Zombie guard: an ``interrupted`` node whose attempt budget is
|
||||||
|
# exhausted can never run again — the ledger only queues
|
||||||
|
# planned/reopened attempts and recovery only opens a new attempt
|
||||||
|
# while attempt < max_attempts. Without this, the run would stay
|
||||||
|
# "running" forever while the dispatcher keeps re-claiming it.
|
||||||
|
specs = _plan_nodes(plan)
|
||||||
|
for node_id, item in latest.items():
|
||||||
|
if str(item.get("status")) != "interrupted":
|
||||||
|
continue
|
||||||
|
if int(item.get("attempt") or 1) >= _node_max_attempts(specs.get(node_id), item):
|
||||||
|
await self._fail_run(run_id, run, reason="node_attempts_exhausted", node_id=node_id)
|
||||||
|
return "failed"
|
||||||
|
return run_status
|
||||||
|
|
||||||
|
async def _fail_run(self, run_id: str, run: dict[str, Any], *, reason: str, node_id: str | None = None) -> None:
|
||||||
|
await self._store.patch_run(run_id, status="failed", active_dedupe_key=None, session_status="failed")
|
||||||
|
await self._store.append_event(
|
||||||
|
session_id=str(run.get("session_id") or ""),
|
||||||
|
run_id=run_id,
|
||||||
|
event_type="run.status.changed",
|
||||||
|
data={"status": "failed", "reason": reason, **({"node_id": node_id} if node_id else {})},
|
||||||
|
)
|
||||||
|
|
||||||
async def _load(self, run_id: str) -> tuple[dict[str, Any], dict[str, Any], dict[str, dict[str, Any]], list[dict[str, Any]]]:
|
async def _load(self, run_id: str) -> tuple[dict[str, Any], dict[str, Any], dict[str, dict[str, Any]], list[dict[str, Any]]]:
|
||||||
run = await self._store.get_run(run_id)
|
run = await self._store.get_run(run_id)
|
||||||
@@ -666,6 +806,20 @@ def _plan_nodes(plan: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|||||||
return {str(item["id"]): item for item in (plan.get("nodes") or []) if item.get("id")}
|
return {str(item["id"]): item for item in (plan.get("nodes") or []) if item.get("id")}
|
||||||
|
|
||||||
|
|
||||||
|
def _node_max_attempts(spec: dict[str, Any] | None, node_record: dict[str, Any]) -> int:
|
||||||
|
"""Same precedence as ``_queue_node``: node contract, then plan spec, then 3."""
|
||||||
|
|
||||||
|
contract = node_record.get("contract_json") or {}
|
||||||
|
if isinstance(contract, str):
|
||||||
|
try:
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
contract = _json.loads(contract)
|
||||||
|
except Exception:
|
||||||
|
contract = {}
|
||||||
|
return int(contract.get("max_attempts") or (spec or {}).get("max_attempts") or 3)
|
||||||
|
|
||||||
|
|
||||||
def _dependency_map(plan: dict[str, Any]) -> dict[str, list[str]]:
|
def _dependency_map(plan: dict[str, Any]) -> dict[str, list[str]]:
|
||||||
deps: dict[str, list[str]] = {node_id: list(spec.get("depends_on") or []) for node_id, spec in _plan_nodes(plan).items()}
|
deps: dict[str, list[str]] = {node_id: list(spec.get("depends_on") or []) for node_id, spec in _plan_nodes(plan).items()}
|
||||||
for edge in plan.get("edges") or []:
|
for edge in plan.get("edges") or []:
|
||||||
|
|||||||
+5
-2
@@ -195,8 +195,11 @@ class WaveExecutor:
|
|||||||
await self._command_processor.process_safe_point(run_id)
|
await self._command_processor.process_safe_point(run_id)
|
||||||
wave = await self._ledger.ready_assignments(run_id)
|
wave = await self._ledger.ready_assignments(run_id)
|
||||||
if not wave:
|
if not wave:
|
||||||
run = await self._store.get_run(run_id)
|
# Nothing runnable. Evaluate terminal state: all-completed
|
||||||
return str((run or {}).get("status") or "queued")
|
# finalizes, exhausted-interrupted nodes fail the run (zombie
|
||||||
|
# guard). Idempotent when the run already reached a terminal
|
||||||
|
# status via apply_result.
|
||||||
|
return await self._ledger.refresh_run_terminal(run_id)
|
||||||
for assignment in wave:
|
for assignment in wave:
|
||||||
self.dispatched.append(assignment.node_id)
|
self.dispatched.append(assignment.node_id)
|
||||||
result = await self._runner.run(assignment)
|
result = await self._runner.run(assignment)
|
||||||
|
|||||||
@@ -386,6 +386,7 @@ class RuntimeCommandService:
|
|||||||
command_id=command["command_id"],
|
command_id=command["command_id"],
|
||||||
instruction=instruction,
|
instruction=instruction,
|
||||||
node_ids=impact_nodes,
|
node_ids=impact_nodes,
|
||||||
|
cascade_downstream=True,
|
||||||
)
|
)
|
||||||
if not changed:
|
if not changed:
|
||||||
raise ReportCollaborationValidationError("COMMAND_NO_EFFECT", "目标节点已过期或命令已经应用")
|
raise ReportCollaborationValidationError("COMMAND_NO_EFFECT", "目标节点已过期或命令已经应用")
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
"""规划师智能体(PlannerAgent)——真智能体,不是规则路由 + 模板文案。
|
||||||
|
|
||||||
|
设计 v2 的需求对话与工作流生成主路径:
|
||||||
|
- ``converse``:与用户多轮自由对话确认需求。回复文案(含开场)完全由
|
||||||
|
智能体生成并直接落库展示 —— 「收到您的报告需求。为了给出…」这类
|
||||||
|
死模板文案已从该路径移除。
|
||||||
|
- ``design_workflow``:带着**完整对话上下文**(规划师问了什么、用户
|
||||||
|
答了什么)生成受词表约束的自定义工作流(``StrategyRoleSet``),
|
||||||
|
与确定性模板走同一条 装配 → 校验 → 绑定 链路。
|
||||||
|
|
||||||
|
规则路由与模板只作为模型彻底不可用时的最后防线,不出现在用户面前。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||||
|
|
||||||
|
from app.report_collaboration.agentscope_runtime.model_resolver import ROLE_MODEL_FIELD, RoleModelSnapshot, resolve_role_model
|
||||||
|
from app.report_collaboration.contracts.plans import RoleDemand
|
||||||
|
from app.report_collaboration.contracts.requirements import ReportRequirementSnapshot
|
||||||
|
from app.report_collaboration.planning.controller import RoleSlot, StrategyRoleSet
|
||||||
|
from app.report_collaboration.requirement.intent_router import _merge_snapshot, apply_default_assumptions
|
||||||
|
from deerflow.config.app_config import AppConfig, get_app_config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── 词表(结构化输出的安全边界,LLM 不受约束的自由只在文案层) ─────────────
|
||||||
|
|
||||||
|
MEMBER_ROLES: tuple[str, ...] = tuple(key for key in ROLE_MODEL_FIELD if key != "coordinator")
|
||||||
|
|
||||||
|
ROLE_ARTIFACTS: dict[str, tuple[str, ...]] = {
|
||||||
|
"researcher": ("EvidenceBundle",),
|
||||||
|
"verifier": ("VerificationResult",),
|
||||||
|
"analyst": ("AngleAnalysis",),
|
||||||
|
"synthesizer": ("SynthesisOutline",),
|
||||||
|
"writer": ("ReportSectionDraft",),
|
||||||
|
"reviewer": ("ReviewDecision",),
|
||||||
|
"reviser": ("ReportSectionDraft",),
|
||||||
|
}
|
||||||
|
|
||||||
|
QUALITY_GATES: tuple[str, ...] = ("cite_required", "dual_verification", "independent_review", "coverage_matrix")
|
||||||
|
|
||||||
|
# ── 智能体输出契约 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class PlannerTurn(BaseModel):
|
||||||
|
"""规划师一轮对话的完整产出:自由文案 + 结构化状态。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
action: str = "clarify"
|
||||||
|
reply: str = ""
|
||||||
|
questions: list[str] = Field(default_factory=list)
|
||||||
|
impact: list[str] = Field(default_factory=list)
|
||||||
|
assumptions: list[str] = Field(default_factory=list)
|
||||||
|
requirement: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class StrategyNodeDraft(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
slot_id: str
|
||||||
|
role_key: str
|
||||||
|
display_name: str
|
||||||
|
angle: str | None = None
|
||||||
|
mission: str
|
||||||
|
depends_on: list[str] = Field(default_factory=list)
|
||||||
|
output_artifact_type: str
|
||||||
|
suggested_tools: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class StrategyDraft(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
reply: str = ""
|
||||||
|
strategy: str = "custom"
|
||||||
|
title: str
|
||||||
|
summary: str
|
||||||
|
rationale: str
|
||||||
|
estimated_duration_seconds: int = 900
|
||||||
|
estimated_cost_level: str = "medium"
|
||||||
|
quality_gate_ids: list[str] = Field(default_factory=lambda: ["cite_required"])
|
||||||
|
nodes: list[StrategyNodeDraft] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
LlmFactory = Callable[[], Any]
|
||||||
|
|
||||||
|
|
||||||
|
def planner_model_snapshot(app_config: AppConfig | None = None) -> RoleModelSnapshot:
|
||||||
|
"""规划师身份沿用角色解析链:coordinator_model → planner_model → 默认模型。"""
|
||||||
|
|
||||||
|
return resolve_role_model("coordinator", app_config=app_config or get_app_config())
|
||||||
|
|
||||||
|
|
||||||
|
def build_planner_llm(app_config: AppConfig | None = None) -> Any:
|
||||||
|
from deerflow.models.factory import create_chat_model
|
||||||
|
|
||||||
|
snapshot = planner_model_snapshot(app_config)
|
||||||
|
return create_chat_model(snapshot.config_name, app_config=app_config or get_app_config())
|
||||||
|
|
||||||
|
|
||||||
|
_THINK_BLOCK_RE = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json(text: str) -> dict[str, Any] | None:
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
# 思考模型(MiniMax-M2.7 等)回复可能带 <think>…</think>;JSON 在思考块之后。
|
||||||
|
# 先剥思考块提取;剥完没有再回退用全文(极端情况 JSON 混在思考内)。
|
||||||
|
for candidate in (_THINK_BLOCK_RE.sub("", text).strip(), text.strip()):
|
||||||
|
if not candidate:
|
||||||
|
continue
|
||||||
|
cleaned = candidate
|
||||||
|
if cleaned.startswith("```"):
|
||||||
|
cleaned = cleaned.strip("`")
|
||||||
|
if cleaned.lower().startswith("json"):
|
||||||
|
cleaned = cleaned[4:]
|
||||||
|
cleaned = cleaned.strip()
|
||||||
|
start = cleaned.find("{")
|
||||||
|
end = cleaned.rfind("}")
|
||||||
|
if start < 0 or end <= start:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed = json.loads(cleaned[start : end + 1])
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
return parsed
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _invoke_json(llm: Any, messages: list[Any]) -> tuple[dict[str, Any] | None, str]:
|
||||||
|
"""调用模型并提取 JSON。返回 (解析结果, 原始文本);解析失败结果为 None。"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await llm.ainvoke(messages)
|
||||||
|
raw = getattr(response, "content", response)
|
||||||
|
if isinstance(raw, list):
|
||||||
|
raw = "".join(str(getattr(block, "text", block) if not isinstance(block, str) else block) for block in raw)
|
||||||
|
raw = str(raw)
|
||||||
|
return _extract_json(raw), raw
|
||||||
|
except Exception as exc: # noqa: BLE001 — 智能体故障必须显式失败
|
||||||
|
logger.warning("rc planner agent llm call failed: %s: %s", exc.__class__.__name__, exc)
|
||||||
|
return None, ""
|
||||||
|
|
||||||
|
|
||||||
|
async def _invoke_json_with_retry(llm: Any, system: str, user_prompt: str) -> dict[str, Any] | None:
|
||||||
|
"""带一次格式重试:模型输出不含 JSON 时,附原始输出要求只出 JSON 再问一次。"""
|
||||||
|
|
||||||
|
parsed, raw = await _invoke_json(llm, [("system", system), ("human", user_prompt)])
|
||||||
|
if parsed is None:
|
||||||
|
logger.warning("rc planner reply without JSON (raw head: %.200s)", raw[:200])
|
||||||
|
retry_prompt = (
|
||||||
|
user_prompt
|
||||||
|
+ "\n\n【格式提醒】你上一轮的输出没有包含合法 JSON。这次必须只输出一个 JSON 对象,"
|
||||||
|
+ f"不要在 JSON 之外输出任何文字。你上一轮的输出是:{raw[:500]}"
|
||||||
|
)
|
||||||
|
parsed, _ = await _invoke_json(llm, [("system", system), ("human", retry_prompt)])
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
# ── 规划师智能体 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class PlannerAgent:
|
||||||
|
"""需求对话与工作流设计的唯一主路径(真智能体)。"""
|
||||||
|
|
||||||
|
def __init__(self, llm: Any, *, app_config: AppConfig | None = None) -> None:
|
||||||
|
self._llm = llm
|
||||||
|
self._app_config = app_config
|
||||||
|
|
||||||
|
# ── 需求对话 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def converse(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_text: str,
|
||||||
|
history: list[dict[str, str]],
|
||||||
|
previous_snapshot: dict[str, Any] | None,
|
||||||
|
previous_revision: int,
|
||||||
|
clarification_round: int,
|
||||||
|
max_rounds: int,
|
||||||
|
) -> PlannerTurn | None:
|
||||||
|
"""一轮规划对话。返回 None 表示智能体不可用(调用方走最后防线)。"""
|
||||||
|
|
||||||
|
forced = clarification_round >= max_rounds
|
||||||
|
system = _CONVERSE_SYSTEM.format(
|
||||||
|
roles=", ".join(MEMBER_ROLES),
|
||||||
|
forced_note=(
|
||||||
|
"\n\n【重要】澄清轮次已达上限,本轮你必须判定需求就绪(action=ready):"
|
||||||
|
"自然地告诉用户你将按当前理解开始规划,把未能确认的点写进 assumptions。"
|
||||||
|
if forced
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
)
|
||||||
|
known = {k: v for k, v in (previous_snapshot or {}).items() if v not in (None, "", [])}
|
||||||
|
known.pop("revision", None)
|
||||||
|
history_text = "\n".join(
|
||||||
|
f"{'用户' if item.get('role') == 'human' else '规划师'}: {str(item.get('content') or '')[:400]}"
|
||||||
|
for item in history[-10:]
|
||||||
|
)
|
||||||
|
user_prompt = (
|
||||||
|
f"当前已确认的需求快照(JSON):{json.dumps(known, ensure_ascii=False)}\n\n"
|
||||||
|
f"最近的对话:\n{history_text or '(无)'}\n\n"
|
||||||
|
f"用户最新消息:{user_text}"
|
||||||
|
)
|
||||||
|
parsed = await _invoke_json_with_retry(self._llm, system, user_prompt)
|
||||||
|
if parsed is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
turn = PlannerTurn.model_validate(parsed)
|
||||||
|
except ValidationError:
|
||||||
|
logger.warning("rc planner turn invalid: %s", str(parsed)[:200])
|
||||||
|
return None
|
||||||
|
if not (turn.reply or "").strip():
|
||||||
|
return None
|
||||||
|
if forced and turn.action != "ready":
|
||||||
|
turn.action = "ready"
|
||||||
|
if turn.action == "ready" and not turn.assumptions:
|
||||||
|
turn.assumptions = ["按对话中已确认的信息直接规划"]
|
||||||
|
return turn
|
||||||
|
|
||||||
|
def merge_requirement(
|
||||||
|
self,
|
||||||
|
turn: PlannerTurn,
|
||||||
|
*,
|
||||||
|
previous_snapshot: dict[str, Any] | None,
|
||||||
|
previous_revision: int,
|
||||||
|
) -> ReportRequirementSnapshot:
|
||||||
|
"""把智能体本轮带回的需求字段合并进既有快照(revision 语义同规则路由)。"""
|
||||||
|
|
||||||
|
known = {k: v for k, v in (previous_snapshot or {}).items() if k != "revision"}
|
||||||
|
# LLM 契约是「只填能确认的字段」;再过滤一次空值,避免空串覆盖已确认信息。
|
||||||
|
patch = {
|
||||||
|
k: v
|
||||||
|
for k, v in (turn.requirement or {}).items()
|
||||||
|
if k != "revision" and v not in (None, "", [])
|
||||||
|
}
|
||||||
|
revision = (
|
||||||
|
max(1, int(previous_revision) + 1)
|
||||||
|
if (previous_snapshot or patch)
|
||||||
|
else max(1, int(previous_revision) or 1)
|
||||||
|
)
|
||||||
|
if not previous_snapshot:
|
||||||
|
revision = 1
|
||||||
|
merged = _merge_snapshot(known, patch, revision=revision)
|
||||||
|
if turn.action == "ready":
|
||||||
|
merged = apply_default_assumptions(merged)[0]
|
||||||
|
return merged
|
||||||
|
|
||||||
|
# ── 工作流设计 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def design_workflow(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
requirement: ReportRequirementSnapshot,
|
||||||
|
history: list[dict[str, str]],
|
||||||
|
max_team_members: int = 8,
|
||||||
|
) -> tuple[StrategyRoleSet, str] | None:
|
||||||
|
"""带着完整对话上下文设计工作流;返回 (策略, 展示文案) 或 None。"""
|
||||||
|
|
||||||
|
fields = {k: v for k, v in requirement.model_dump().items() if v not in (None, "", [])}
|
||||||
|
fields.pop("revision", None)
|
||||||
|
history_text = "\n".join(
|
||||||
|
f"{'用户' if item.get('role') == 'human' else '规划师'}: {str(item.get('content') or '')[:300]}"
|
||||||
|
for item in history[-12:]
|
||||||
|
)
|
||||||
|
system = _DESIGN_SYSTEM.format(
|
||||||
|
roles=", ".join(MEMBER_ROLES),
|
||||||
|
artifacts="\n".join(f" - {role}: {' / '.join(arts)}" for role, arts in ROLE_ARTIFACTS.items()),
|
||||||
|
gates=", ".join(QUALITY_GATES),
|
||||||
|
max_nodes=min(max_team_members, 8),
|
||||||
|
)
|
||||||
|
user_prompt = (
|
||||||
|
f"需求快照(JSON):{json.dumps(fields, ensure_ascii=False)}\n\n"
|
||||||
|
f"需求确认阶段的对话(你与用户):\n{history_text or '(无)'}\n\n请设计工作流。"
|
||||||
|
)
|
||||||
|
parsed = await _invoke_json_with_retry(self._llm, system, user_prompt)
|
||||||
|
if parsed is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
draft = StrategyDraft.model_validate(parsed)
|
||||||
|
except ValidationError:
|
||||||
|
logger.warning("rc planner strategy draft invalid: %s", str(parsed)[:200])
|
||||||
|
return None
|
||||||
|
strategy = _validate_strategy_dag(draft)
|
||||||
|
if strategy is None:
|
||||||
|
logger.warning("rc planner strategy failed vocabulary/DAG validation: %s", str(parsed)[:200])
|
||||||
|
return None
|
||||||
|
reply = (draft.reply or "").strip() or f"已按需求设计好协作工作流「{draft.title}」。"
|
||||||
|
return strategy, reply
|
||||||
|
|
||||||
|
def workflow_reply_fallback(self, strategy: StrategyRoleSet) -> str:
|
||||||
|
return (
|
||||||
|
f"我按确认的需求设计了「{strategy.title}」:{strategy.summary}\n"
|
||||||
|
"可以直接采用开始执行;想调整就告诉我(例如「加一条竞品对比线」)。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 提示词 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_CONVERSE_SYSTEM = """你是「报告协作工作台」的规划师智能体。用户想让你组织一个多智能体团队协作产出一份报告,你的任务是先通过自然对话把需求弄清楚,然后(由你自己)设计协作工作流。
|
||||||
|
|
||||||
|
你的对话风格:
|
||||||
|
- 像一位资深顾问:先回应用户刚说的话,再追问。回复里**不要**出现「收到您的报告需求」这类机械开场——用你自己的话自然表达。
|
||||||
|
- 每轮最多追问 2~3 个真正影响方案设计的问题:关注对象与范围、给谁看、必须覆盖的角度、篇幅与深度、时间范围、特殊约束。用户已说清的绝不重复问。
|
||||||
|
- 用户回复后先简短确认你的理解(一两句),再决定是否继续问。
|
||||||
|
- 信息足够时,自然地总结你对需求的把握并判定就绪(action=ready),告诉用户下一步你会为他们设计工作流。
|
||||||
|
|
||||||
|
可用的团队角色(供你构思,不必对用户罗列):{roles}{forced_note}
|
||||||
|
|
||||||
|
每轮严格输出一个 JSON 对象(不要输出 JSON 以外的文字):
|
||||||
|
{{
|
||||||
|
"reply": "给用户看的完整回复(自然对话,直接展示)",
|
||||||
|
"action": "clarify" 或 "ready",
|
||||||
|
"questions": ["追问的问题(与 reply 一致,供系统记录)"],
|
||||||
|
"impact": ["为什么需要问(与 questions 对应)"],
|
||||||
|
"assumptions": ["ready 时:你采用的默认假设"],
|
||||||
|
"requirement": {{"topic": "...", "objective": "...", "audience": "...", "required_angles": ["..."], "time_range": "...", "length_target": "...", "tone": "..."}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
requirement 只填你从对话中能确认的字段(含此前轮次已确认的),其余省略。reply 用中文。"""
|
||||||
|
|
||||||
|
_DESIGN_SYSTEM = """你是「报告协作工作台」的规划师智能体。需求已经与你确认完毕,现在请设计多智能体协作工作流。
|
||||||
|
|
||||||
|
词表约束(结构化部分必须严格遵守):
|
||||||
|
- role_key 只能取:{roles}
|
||||||
|
- 各角色的 output_artifact_type 只能取:
|
||||||
|
{artifacts}
|
||||||
|
- quality_gate_ids 只能取:{gates}
|
||||||
|
|
||||||
|
结构要求:
|
||||||
|
- 2~{max_nodes} 个节点;至少 1 个 writer;正式报告建议加 reviewer。
|
||||||
|
- 研究线(researcher)按角度并行;research → verify → analyze → write → review 是常见主干,可按需求裁剪。
|
||||||
|
- depends_on 引用其他节点的 slot_id;禁止环;至少一个节点 depends_on 为空(起点)。
|
||||||
|
- slot_id 用简短英文(如 research-market / write-main)。mission 写清楚该节点做什么、产出什么。
|
||||||
|
|
||||||
|
严格输出 JSON(不要输出 JSON 以外的文字):
|
||||||
|
{{
|
||||||
|
"reply": "给用户看的介绍:说明你为什么这样设计(对应需求的哪些要求),一两句即可",
|
||||||
|
"strategy": "custom",
|
||||||
|
"title": "方案标题",
|
||||||
|
"summary": "一句话概述团队构成与链路",
|
||||||
|
"rationale": "设计理由",
|
||||||
|
"estimated_duration_seconds": 1500,
|
||||||
|
"estimated_cost_level": "low|medium|high",
|
||||||
|
"quality_gate_ids": ["cite_required"],
|
||||||
|
"nodes": [
|
||||||
|
{{"slot_id": "research-market", "role_key": "researcher", "display_name": "研究员", "angle": "市场份额", "mission": "...", "depends_on": [], "output_artifact_type": "EvidenceBundle", "suggested_tools": ["web_search"]}}
|
||||||
|
]
|
||||||
|
}}"""
|
||||||
|
|
||||||
|
|
||||||
|
# ── 工作流草稿校验(词表 + DAG) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_strategy_dag(draft: StrategyDraft) -> StrategyRoleSet | None:
|
||||||
|
nodes = draft.nodes
|
||||||
|
if not 2 <= len(nodes) <= 8:
|
||||||
|
return None
|
||||||
|
slot_ids = {node.slot_id for node in nodes}
|
||||||
|
if len(slot_ids) != len(nodes):
|
||||||
|
return None
|
||||||
|
if not any(node.role_key == "writer" for node in nodes):
|
||||||
|
return None
|
||||||
|
for node in nodes:
|
||||||
|
if node.role_key not in MEMBER_ROLES:
|
||||||
|
return None
|
||||||
|
if node.output_artifact_type not in ROLE_ARTIFACTS.get(node.role_key, ()):
|
||||||
|
return None
|
||||||
|
for dep in node.depends_on:
|
||||||
|
if dep not in slot_ids or dep == node.slot_id:
|
||||||
|
return None
|
||||||
|
indegree = {node.slot_id: len(node.depends_on) for node in nodes}
|
||||||
|
children: dict[str, list[str]] = {node.slot_id: [] for node in nodes}
|
||||||
|
for node in nodes:
|
||||||
|
for dep in node.depends_on:
|
||||||
|
children[dep].append(node.slot_id)
|
||||||
|
queue = [slot for slot, degree in indegree.items() if degree == 0]
|
||||||
|
seen = 0
|
||||||
|
while queue:
|
||||||
|
current = queue.pop()
|
||||||
|
seen += 1
|
||||||
|
for child in children[current]:
|
||||||
|
indegree[child] -= 1
|
||||||
|
if indegree[child] == 0:
|
||||||
|
queue.append(child)
|
||||||
|
if seen != len(nodes):
|
||||||
|
return None
|
||||||
|
|
||||||
|
gates = [gate for gate in draft.quality_gate_ids if gate in QUALITY_GATES] or ["cite_required"]
|
||||||
|
return StrategyRoleSet(
|
||||||
|
strategy="custom" if draft.strategy == "custom" else f"custom_{draft.strategy[:24]}",
|
||||||
|
title=draft.title[:80],
|
||||||
|
summary=draft.summary[:200],
|
||||||
|
rationale=draft.rationale[:300],
|
||||||
|
estimated_duration_seconds=max(300, min(int(draft.estimated_duration_seconds or 1500), 7200)),
|
||||||
|
estimated_cost_level=draft.estimated_cost_level if draft.estimated_cost_level in {"low", "medium", "high"} else "medium",
|
||||||
|
recommended=True,
|
||||||
|
slots=[
|
||||||
|
RoleSlot(
|
||||||
|
slot_id=node.slot_id,
|
||||||
|
demand=RoleDemand(
|
||||||
|
role_key=node.role_key,
|
||||||
|
display_name=node.display_name[:32] or node.role_key,
|
||||||
|
angle=(node.angle or None),
|
||||||
|
mission=node.mission[:400],
|
||||||
|
depends_on_roles=[],
|
||||||
|
output_artifact_type=node.output_artifact_type,
|
||||||
|
suggested_tools=[tool for tool in node.suggested_tools if tool][:4],
|
||||||
|
),
|
||||||
|
depends_on=list(node.depends_on),
|
||||||
|
)
|
||||||
|
for node in nodes
|
||||||
|
],
|
||||||
|
quality_gate_ids=gates,
|
||||||
|
)
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
"""Fulfill a durable plan_request: catalog → select → assemble → validate → persist.
|
"""Fulfill a durable plan_request: catalog → select → assemble → validate → persist.
|
||||||
|
|
||||||
Does not start a run. Rule-based until the BE-7 model adapter is wired.
|
Does not start a run. P1.2 起优先由规划师 LLM 生成定制策略,模板为回退。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from app.report_collaboration.contracts.requirements import ReportRequirementSnapshot
|
from app.report_collaboration.contracts.requirements import ReportRequirementSnapshot
|
||||||
from app.report_collaboration.planning.assembler import PlanAssembler
|
from app.report_collaboration.planning.assembler import PlanAssembler
|
||||||
from app.report_collaboration.planning.catalog import project_agent_catalog
|
from app.report_collaboration.planning.catalog import project_agent_catalog
|
||||||
from app.report_collaboration.planning.controller import PlanController
|
from app.report_collaboration.planning.planner_agent import LlmFactory
|
||||||
from app.report_collaboration.planning.validator import apply_validation, validate_assembled_plan
|
from app.report_collaboration.planning.validator import apply_validation, validate_assembled_plan
|
||||||
from app.report_collaboration.requirement.defaults import COORDINATOR_NAME, DEFAULT_TITLE
|
from app.report_collaboration.requirement.defaults import COORDINATOR_NAME, DEFAULT_TITLE
|
||||||
from deerflow.config.app_config import get_app_config
|
from deerflow.config.app_config import get_app_config
|
||||||
@@ -22,6 +23,8 @@ from deerflow.persistence.report_collaboration import (
|
|||||||
ReportCollaborationValidationError,
|
ReportCollaborationValidationError,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_BUSY = frozenset({"running", "awaiting_input", "reviewing", "completed", "failed", "cancelled"})
|
_BUSY = frozenset({"running", "awaiting_input", "reviewing", "completed", "failed", "cancelled"})
|
||||||
|
|
||||||
|
|
||||||
@@ -51,12 +54,15 @@ class PlanProposalService:
|
|||||||
*,
|
*,
|
||||||
agent_store: Any | None = None,
|
agent_store: Any | None = None,
|
||||||
config: ReportCollaborationConfig | None = None,
|
config: ReportCollaborationConfig | None = None,
|
||||||
|
llm_factory: LlmFactory | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._store = store
|
self._store = store
|
||||||
self._agent_store = agent_store
|
self._agent_store = agent_store
|
||||||
self._config = config
|
self._config = config
|
||||||
self._controller = PlanController()
|
|
||||||
self._assembler = PlanAssembler()
|
self._assembler = PlanAssembler()
|
||||||
|
self._llm_factory = llm_factory
|
||||||
|
self._llm: Any | None = None
|
||||||
|
self._llm_tried = False
|
||||||
|
|
||||||
def _cfg(self) -> ReportCollaborationConfig:
|
def _cfg(self) -> ReportCollaborationConfig:
|
||||||
return self._config or get_app_config().report_collaboration
|
return self._config or get_app_config().report_collaboration
|
||||||
@@ -110,12 +116,29 @@ class PlanProposalService:
|
|||||||
requirement = requirement_from_session(record)
|
requirement = requirement_from_session(record)
|
||||||
cfg = self._cfg()
|
cfg = self._cfg()
|
||||||
catalog = await project_agent_catalog(user_id=user_id, config=cfg, agent_store=self._agent_store)
|
catalog = await project_agent_catalog(user_id=user_id, config=cfg, agent_store=self._agent_store)
|
||||||
strategies = self._controller.propose(
|
group_id = f"rpg_{uuid4().hex[:12]}"
|
||||||
requirement,
|
|
||||||
max_parallel_tasks=cfg.max_parallel_tasks,
|
# P1-R:规划师智能体是唯一路径 —— 带着完整对话上下文设计工作流。
|
||||||
|
# 模型不可用即无法继续(无模板兜底)。
|
||||||
|
from app.report_collaboration.planning.planner_agent import PlannerAgent
|
||||||
|
|
||||||
|
llm = self._planner_llm()
|
||||||
|
history = [
|
||||||
|
{"role": "human" if item.get("role") == "human" else "ai", "content": str(item.get("content") or "")}
|
||||||
|
for item in await self._store.list_messages(session_id)
|
||||||
|
]
|
||||||
|
designed = await PlannerAgent(llm).design_workflow(
|
||||||
|
requirement=requirement,
|
||||||
|
history=history,
|
||||||
max_team_members=cfg.max_team_members,
|
max_team_members=cfg.max_team_members,
|
||||||
)
|
)
|
||||||
group_id = f"rpg_{uuid4().hex[:12]}"
|
if designed is None:
|
||||||
|
raise ReportCollaborationValidationError(
|
||||||
|
"PLANNER_UNAVAILABLE", "规划师智能体未能生成有效工作流,请重试或换个说法再试"
|
||||||
|
)
|
||||||
|
strategies: list[Any] = [designed[0]]
|
||||||
|
planner_reply: str | None = designed[1]
|
||||||
|
|
||||||
assembled_list = [
|
assembled_list = [
|
||||||
self._assembler.assemble(
|
self._assembler.assemble(
|
||||||
strategy,
|
strategy,
|
||||||
@@ -140,13 +163,44 @@ class PlanProposalService:
|
|||||||
for candidate in persisted:
|
for candidate in persisted:
|
||||||
saved.append(await self._store.insert_plan(session_id, candidate.model_dump()))
|
saved.append(await self._store.insert_plan(session_id, candidate.model_dump()))
|
||||||
await self._store.update_command_status(command_id, status="completed")
|
await self._store.update_command_status(command_id, status="completed")
|
||||||
|
content = planner_reply or "工作流已生成,可开始执行。"
|
||||||
await self._store.create_message(
|
await self._store.create_message(
|
||||||
session_id,
|
session_id,
|
||||||
role="ai",
|
role="ai",
|
||||||
content="已根据当前需求生成 3 个候选协作方案。请选择其中一个后再开始执行;选择方案不会自动开跑。",
|
content=content,
|
||||||
idempotency_key=f"{idempotency_key}:plans",
|
idempotency_key=f"{idempotency_key}:plans",
|
||||||
name=COORDINATOR_NAME,
|
name=COORDINATOR_NAME,
|
||||||
metadata={"plan_request_command_id": command_id, "proposal_group_id": group_id},
|
metadata={"plan_request_command_id": command_id, "proposal_group_id": group_id, "plan_source": "planner_agent"},
|
||||||
completed=True,
|
completed=True,
|
||||||
)
|
)
|
||||||
return saved
|
return saved
|
||||||
|
|
||||||
|
def _planner_llm(self) -> Any:
|
||||||
|
"""规划师模型:注入优先(测试);否则按 planner_llm_enabled 懒构建。不可用即抛错。"""
|
||||||
|
|
||||||
|
if self._llm_factory is not None:
|
||||||
|
llm = self._llm_factory()
|
||||||
|
if llm is None:
|
||||||
|
raise ReportCollaborationValidationError("PLANNER_UNAVAILABLE", "规划师智能体不可用,请检查模型配置")
|
||||||
|
return llm
|
||||||
|
try:
|
||||||
|
if not (self._config or get_app_config().report_collaboration).planner_llm_enabled:
|
||||||
|
raise ReportCollaborationValidationError(
|
||||||
|
"PLANNER_UNAVAILABLE", "规划师智能体未启用(report_collaboration.planner_llm_enabled)"
|
||||||
|
)
|
||||||
|
except ReportCollaborationValidationError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise ReportCollaborationValidationError("PLANNER_UNAVAILABLE", f"规划师智能体配置不可读:{exc}") from None
|
||||||
|
if not self._llm_tried:
|
||||||
|
self._llm_tried = True
|
||||||
|
try:
|
||||||
|
from app.report_collaboration.planning.planner_agent import build_planner_llm
|
||||||
|
|
||||||
|
self._llm = build_planner_llm()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("rc planner agent llm build failed: %s", exc)
|
||||||
|
self._llm = None
|
||||||
|
if self._llm is None:
|
||||||
|
raise ReportCollaborationValidationError("PLANNER_UNAVAILABLE", "规划师智能体不可用,请检查模型配置")
|
||||||
|
return self._llm
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Pre-plan requirement resolution package."""
|
"""Pre-plan requirement resolution package."""
|
||||||
|
|
||||||
from app.report_collaboration.requirement.intent_router import PrePlanDecision, route_pre_plan_turn
|
from app.report_collaboration.requirement.intent_router import PrePlanDecision
|
||||||
from app.report_collaboration.requirement.resolver import RequirementResolver
|
from app.report_collaboration.requirement.resolver import RequirementResolver
|
||||||
|
|
||||||
__all__ = ["PrePlanDecision", "RequirementResolver", "route_pre_plan_turn"]
|
__all__ = ["PrePlanDecision", "RequirementResolver"]
|
||||||
+11
-79
@@ -1,4 +1,9 @@
|
|||||||
"""Pre-plan IntentRouterAgent: understand the user turn, never dispatch work."""
|
"""Pre-plan 数据结构与快照合并工具。
|
||||||
|
|
||||||
|
P1-R 之后需求确认完全由规划师智能体(``planning/planner_agent.py``)驱动;
|
||||||
|
本模块只保留被复用的数据结构与合并工具。原规则路由 ``route_pre_plan_turn``
|
||||||
|
已按设计 v2 移除 —— 模型不可用即报错,不再有规则兜底。
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -7,12 +12,11 @@ from typing import Any, Literal
|
|||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from app.report_collaboration.contracts.requirements import ReportRequirementSnapshot
|
from app.report_collaboration.contracts.requirements import ReportRequirementSnapshot
|
||||||
from app.report_collaboration.requirement.defaults import DEFAULT_ANGLES, DEFAULT_AUDIENCE, DEFAULT_LANGUAGE, DEFAULT_SOURCE_CONSTRAINTS, DEFAULT_TONE, PLAN_AFFECTING_GAPS
|
from app.report_collaboration.requirement.defaults import DEFAULT_ANGLES, DEFAULT_AUDIENCE, DEFAULT_LANGUAGE, DEFAULT_SOURCE_CONSTRAINTS, DEFAULT_TONE
|
||||||
from app.report_collaboration.requirement.extractor import extract_requirement_patch, wants_direct_start
|
|
||||||
|
|
||||||
|
|
||||||
class PrePlanDecision(BaseModel):
|
class PrePlanDecision(BaseModel):
|
||||||
"""Structured output of the pre-plan router. It never builds a graph or starts a run."""
|
"""规划师智能体一轮决策。It never builds a graph or starts a run."""
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -24,11 +28,13 @@ class PrePlanDecision(BaseModel):
|
|||||||
used_defaults: bool = False
|
used_defaults: bool = False
|
||||||
reason: str
|
reason: str
|
||||||
confidence: float = Field(ge=0, le=1)
|
confidence: float = Field(ge=0, le=1)
|
||||||
|
#: 规划师智能体产出的完整对话文案,直接落库展示。
|
||||||
|
reply: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def _merge_snapshot(previous: dict[str, Any] | None, patch: dict[str, Any], *, revision: int) -> ReportRequirementSnapshot:
|
def _merge_snapshot(previous: dict[str, Any] | None, patch: dict[str, Any], *, revision: int) -> ReportRequirementSnapshot:
|
||||||
base = dict(previous or {})
|
base = dict(previous or {})
|
||||||
base.update(patch)
|
base.update(patch or {})
|
||||||
topic = str(base.get("topic") or "").strip()
|
topic = str(base.get("topic") or "").strip()
|
||||||
return ReportRequirementSnapshot(
|
return ReportRequirementSnapshot(
|
||||||
topic=topic or "未指定主题",
|
topic=topic or "未指定主题",
|
||||||
@@ -49,21 +55,6 @@ def _merge_snapshot(previous: dict[str, Any] | None, patch: dict[str, Any], *, r
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _gaps(requirement: ReportRequirementSnapshot) -> list[tuple[str, str, str]]:
|
|
||||||
missing: list[tuple[str, str, str]] = []
|
|
||||||
if not requirement.topic or requirement.topic == "未指定主题":
|
|
||||||
missing.append(("topic", "这份报告要写什么主题?请用一两句话说明对象、时间和目的。", "没有主题无法选择方案"))
|
|
||||||
for field, question, impact in PLAN_AFFECTING_GAPS:
|
|
||||||
value = getattr(requirement, field, None)
|
|
||||||
if field == "required_angles":
|
|
||||||
empty = not value
|
|
||||||
else:
|
|
||||||
empty = not (value or "").strip()
|
|
||||||
if empty:
|
|
||||||
missing.append((field, question, impact))
|
|
||||||
return missing
|
|
||||||
|
|
||||||
|
|
||||||
def apply_default_assumptions(requirement: ReportRequirementSnapshot) -> tuple[ReportRequirementSnapshot, list[str]]:
|
def apply_default_assumptions(requirement: ReportRequirementSnapshot) -> tuple[ReportRequirementSnapshot, list[str]]:
|
||||||
assumptions: list[str] = []
|
assumptions: list[str] = []
|
||||||
data = requirement.model_dump()
|
data = requirement.model_dump()
|
||||||
@@ -82,62 +73,3 @@ def apply_default_assumptions(requirement: ReportRequirementSnapshot) -> tuple[R
|
|||||||
data["tone"] = DEFAULT_TONE
|
data["tone"] = DEFAULT_TONE
|
||||||
assumptions.append(DEFAULT_TONE)
|
assumptions.append(DEFAULT_TONE)
|
||||||
return ReportRequirementSnapshot.model_validate(data), assumptions
|
return ReportRequirementSnapshot.model_validate(data), assumptions
|
||||||
|
|
||||||
|
|
||||||
def route_pre_plan_turn(
|
|
||||||
text: str,
|
|
||||||
*,
|
|
||||||
previous_snapshot: dict[str, Any] | None,
|
|
||||||
previous_revision: int,
|
|
||||||
clarification_round: int,
|
|
||||||
max_clarification_rounds: int,
|
|
||||||
) -> PrePlanDecision:
|
|
||||||
"""Rule-based pre-plan routing. Does not call tools or create plans."""
|
|
||||||
|
|
||||||
patch = extract_requirement_patch(text, previous=previous_snapshot)
|
|
||||||
revision = max(1, int(previous_revision) + 1) if previous_snapshot or patch else max(1, int(previous_revision) or 1)
|
|
||||||
if not previous_snapshot:
|
|
||||||
revision = 1
|
|
||||||
requirement = _merge_snapshot(previous_snapshot, patch, revision=revision)
|
|
||||||
skip = wants_direct_start(text)
|
|
||||||
hit_cap = clarification_round >= max_clarification_rounds
|
|
||||||
gaps = _gaps(requirement)
|
|
||||||
|
|
||||||
if requirement.topic == "未指定主题" and skip:
|
|
||||||
return PrePlanDecision(
|
|
||||||
action="clarify",
|
|
||||||
requirement=requirement,
|
|
||||||
questions=["这份报告要写什么主题?请用一两句话说明对象、时间和目的。"],
|
|
||||||
impact=["没有主题无法选择方案"],
|
|
||||||
reason="用户要求直接开始,但还没有主题",
|
|
||||||
confidence=0.9,
|
|
||||||
)
|
|
||||||
|
|
||||||
if skip or (hit_cap and gaps):
|
|
||||||
filled, assumptions = apply_default_assumptions(requirement)
|
|
||||||
return PrePlanDecision(
|
|
||||||
action="ready",
|
|
||||||
requirement=filled,
|
|
||||||
assumptions=assumptions,
|
|
||||||
used_defaults=True,
|
|
||||||
reason="用户要求按默认假设继续" if skip else "已达澄清上限,使用明确假设继续",
|
|
||||||
confidence=0.86 if skip else 0.7,
|
|
||||||
)
|
|
||||||
|
|
||||||
if gaps:
|
|
||||||
return PrePlanDecision(
|
|
||||||
action="clarify",
|
|
||||||
requirement=requirement,
|
|
||||||
questions=[item[1] for item in gaps],
|
|
||||||
impact=[item[2] for item in gaps],
|
|
||||||
reason="缺少真正影响方案选择的信息",
|
|
||||||
confidence=0.8,
|
|
||||||
)
|
|
||||||
|
|
||||||
return PrePlanDecision(
|
|
||||||
action="ready",
|
|
||||||
requirement=requirement,
|
|
||||||
reason="必要信息已齐,可以进入规划",
|
|
||||||
confidence=0.84,
|
|
||||||
used_defaults=False,
|
|
||||||
)
|
|
||||||
+142
-67
@@ -1,18 +1,39 @@
|
|||||||
"""Validate and persist requirement revisions. Never starts planning or a run."""
|
"""Requirement resolver: the planner agent is the only path.
|
||||||
|
|
||||||
|
模型不可用即无法继续 —— 直接抛 ``PLANNER_UNAVAILABLE``,没有任何规则/
|
||||||
|
模板兜底(设计 v2:需求确认必须由真智能体完成)。
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from app.report_collaboration.contracts.clarifications import ClarificationRecord
|
from app.report_collaboration.contracts.clarifications import ClarificationRecord
|
||||||
from app.report_collaboration.contracts.requirements import ReportRequirementSnapshot
|
|
||||||
from app.report_collaboration.requirement.defaults import COORDINATOR_NAME, DEFAULT_TITLE
|
from app.report_collaboration.requirement.defaults import COORDINATOR_NAME, DEFAULT_TITLE
|
||||||
from app.report_collaboration.requirement.intent_router import PrePlanDecision, route_pre_plan_turn
|
from app.report_collaboration.requirement.intent_router import PrePlanDecision
|
||||||
from deerflow.config.app_config import get_app_config
|
from deerflow.config.app_config import get_app_config
|
||||||
from deerflow.persistence.report_collaboration import ReportCollaborationStore
|
from deerflow.persistence.report_collaboration import (
|
||||||
|
ReportCollaborationStore,
|
||||||
|
ReportCollaborationValidationError,
|
||||||
|
)
|
||||||
from deerflow.persistence.report_collaboration.codec import iso_now
|
from deerflow.persistence.report_collaboration.codec import iso_now
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 仅供类型注解;运行时惰性导入避免 requirement ↔ planning 循环依赖。
|
||||||
|
LlmFactory = Any
|
||||||
|
|
||||||
|
|
||||||
|
def _title_from_topic(topic: str, current: str | None) -> str | None:
|
||||||
|
if current and current != DEFAULT_TITLE:
|
||||||
|
return None
|
||||||
|
cleaned = (topic or "").strip()
|
||||||
|
if not cleaned or cleaned == "未指定主题":
|
||||||
|
return None
|
||||||
|
return cleaned[:40]
|
||||||
|
|
||||||
|
|
||||||
def _state_from_session(record: dict[str, Any]) -> dict[str, Any]:
|
def _state_from_session(record: dict[str, Any]) -> dict[str, Any]:
|
||||||
raw = record.get("requirement_json")
|
raw = record.get("requirement_json")
|
||||||
@@ -35,58 +56,56 @@ def _state_from_session(record: dict[str, Any]) -> dict[str, Any]:
|
|||||||
return {"snapshot": None, "clarifications": [], "assumptions": [], "clarification_round": 0, "ready_to_plan": False}
|
return {"snapshot": None, "clarifications": [], "assumptions": [], "clarification_round": 0, "ready_to_plan": False}
|
||||||
|
|
||||||
|
|
||||||
def _dump_state(
|
|
||||||
*,
|
|
||||||
snapshot: ReportRequirementSnapshot,
|
|
||||||
clarifications: list[dict[str, Any]],
|
|
||||||
assumptions: list[str],
|
|
||||||
clarification_round: int,
|
|
||||||
ready_to_plan: bool,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"snapshot": snapshot.model_dump(),
|
|
||||||
"clarifications": clarifications,
|
|
||||||
"assumptions": assumptions,
|
|
||||||
"clarification_round": clarification_round,
|
|
||||||
"ready_to_plan": ready_to_plan,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def format_clarification_message(questions: list[str], *, allow_skip: bool = True) -> str:
|
|
||||||
numbered = "\n".join(f"{index}. {question}" for index, question in enumerate(questions, start=1))
|
|
||||||
suffix = "\n\n也可以直接回复「直接开始」,我会按默认假设记录需求。澄清完成不会自动生成方案。" if allow_skip else ""
|
|
||||||
return f"收到您的报告需求。为了给出最合适的协作方案,请先确认:\n\n{numbered}{suffix}"
|
|
||||||
|
|
||||||
|
|
||||||
def format_ready_message(decision: PrePlanDecision) -> str:
|
|
||||||
req = decision.requirement
|
|
||||||
angles = "、".join(req.required_angles) if req.required_angles else "(未指定)"
|
|
||||||
lines = [
|
|
||||||
f"已写入需求版本 v{req.revision}。",
|
|
||||||
f"主题:{req.topic}",
|
|
||||||
f"读者:{req.audience or '未指定'}",
|
|
||||||
f"角度:{angles}",
|
|
||||||
]
|
|
||||||
if decision.assumptions:
|
|
||||||
lines.append("默认假设:" + ";".join(decision.assumptions) + "。")
|
|
||||||
lines.append("澄清完成不等于已经生成候选方案,需要显式发起规划后才会出现方案卡。")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def _title_from_topic(topic: str, current: str | None) -> str | None:
|
|
||||||
if current and current != DEFAULT_TITLE:
|
|
||||||
return None
|
|
||||||
cleaned = (topic or "").strip()
|
|
||||||
if not cleaned or cleaned == "未指定主题":
|
|
||||||
return None
|
|
||||||
return cleaned[:40]
|
|
||||||
|
|
||||||
|
|
||||||
class RequirementResolver:
|
class RequirementResolver:
|
||||||
"""Writes requirement revisions and clarification records. Does not enqueue plans."""
|
"""Writes requirement revisions and clarification records. Does not enqueue plans."""
|
||||||
|
|
||||||
def __init__(self, store: ReportCollaborationStore) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
store: ReportCollaborationStore,
|
||||||
|
*,
|
||||||
|
planner_llm_factory: LlmFactory | None = None,
|
||||||
|
config: Any | None = None,
|
||||||
|
) -> None:
|
||||||
self._store = store
|
self._store = store
|
||||||
|
self._planner_llm_factory = planner_llm_factory
|
||||||
|
self._config = config
|
||||||
|
self._default_llm: Any = None
|
||||||
|
self._default_llm_tried = False
|
||||||
|
|
||||||
|
def _planner_llm(self) -> Any:
|
||||||
|
"""规划师模型:注入优先(测试);否则按 planner_llm_enabled 懒构建。不可用即抛错。"""
|
||||||
|
|
||||||
|
if self._planner_llm_factory is not None:
|
||||||
|
llm = self._planner_llm_factory()
|
||||||
|
if llm is None:
|
||||||
|
raise ReportCollaborationValidationError("PLANNER_UNAVAILABLE", "规划师智能体不可用,请检查模型配置")
|
||||||
|
return llm
|
||||||
|
try:
|
||||||
|
from deerflow.config.report_collaboration_config import ReportCollaborationConfig
|
||||||
|
|
||||||
|
cfg: Any = self._config
|
||||||
|
if cfg is None:
|
||||||
|
cfg = get_app_config().report_collaboration
|
||||||
|
if not ReportCollaborationConfig.model_validate(cfg).planner_llm_enabled:
|
||||||
|
raise ReportCollaborationValidationError(
|
||||||
|
"PLANNER_UNAVAILABLE", "规划师智能体未启用(report_collaboration.planner_llm_enabled)"
|
||||||
|
)
|
||||||
|
except ReportCollaborationValidationError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise ReportCollaborationValidationError("PLANNER_UNAVAILABLE", f"规划师智能体配置不可读:{exc}") from None
|
||||||
|
if not self._default_llm_tried:
|
||||||
|
self._default_llm_tried = True
|
||||||
|
try:
|
||||||
|
from app.report_collaboration.planning.planner_agent import build_planner_llm
|
||||||
|
|
||||||
|
self._default_llm = build_planner_llm()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("rc planner agent llm build failed: %s", exc)
|
||||||
|
self._default_llm = None
|
||||||
|
if self._default_llm is None:
|
||||||
|
raise ReportCollaborationValidationError("PLANNER_UNAVAILABLE", "规划师智能体不可用,请检查模型配置")
|
||||||
|
return self._default_llm
|
||||||
|
|
||||||
async def handle_user_message(
|
async def handle_user_message(
|
||||||
self,
|
self,
|
||||||
@@ -111,16 +130,67 @@ class RequirementResolver:
|
|||||||
cfg_rounds = max_clarification_rounds
|
cfg_rounds = max_clarification_rounds
|
||||||
if cfg_rounds is None:
|
if cfg_rounds is None:
|
||||||
cfg_rounds = int(get_app_config().report_collaboration.max_clarification_rounds)
|
cfg_rounds = int(get_app_config().report_collaboration.max_clarification_rounds)
|
||||||
decision = route_pre_plan_turn(
|
|
||||||
str(user_message.get("content") or ""),
|
text = str(user_message.get("content") or "")
|
||||||
previous_snapshot=state["snapshot"],
|
history = [
|
||||||
|
{"role": "human" if item.get("role") == "human" else "ai", "content": str(item.get("content") or "")}
|
||||||
|
for item in messages
|
||||||
|
if item.get("message_id") != user_message["message_id"]
|
||||||
|
]
|
||||||
|
|
||||||
|
decision = await self._agent_decision(
|
||||||
|
text,
|
||||||
|
history=history,
|
||||||
|
state=state,
|
||||||
previous_revision=int(record.get("requirement_revision") or 0),
|
previous_revision=int(record.get("requirement_revision") or 0),
|
||||||
clarification_round=int(state["clarification_round"] or 0),
|
max_rounds=cfg_rounds,
|
||||||
max_clarification_rounds=cfg_rounds,
|
|
||||||
)
|
)
|
||||||
|
if decision is None:
|
||||||
|
# 模型调用失败/输出不合法 —— 无法继续,直接报错(无兜底)。
|
||||||
|
raise ReportCollaborationValidationError("PLANNER_UNAVAILABLE", "规划师智能体本轮未返回有效决策,请重试")
|
||||||
await self._apply(session_id, record, state, decision, user_message=user_message, idempotency_key=idempotency_key)
|
await self._apply(session_id, record, state, decision, user_message=user_message, idempotency_key=idempotency_key)
|
||||||
return decision
|
return decision
|
||||||
|
|
||||||
|
async def _agent_decision(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
history: list[dict[str, str]],
|
||||||
|
state: dict[str, Any],
|
||||||
|
previous_revision: int,
|
||||||
|
max_rounds: int,
|
||||||
|
) -> PrePlanDecision | None:
|
||||||
|
llm = self._planner_llm()
|
||||||
|
from app.report_collaboration.planning.planner_agent import PlannerAgent
|
||||||
|
|
||||||
|
agent = PlannerAgent(llm)
|
||||||
|
turn = await agent.converse(
|
||||||
|
user_text=text,
|
||||||
|
history=history,
|
||||||
|
previous_snapshot=state["snapshot"],
|
||||||
|
previous_revision=previous_revision,
|
||||||
|
clarification_round=int(state["clarification_round"] or 0),
|
||||||
|
max_rounds=max_rounds,
|
||||||
|
)
|
||||||
|
if turn is None:
|
||||||
|
return None
|
||||||
|
requirement = agent.merge_requirement(
|
||||||
|
turn,
|
||||||
|
previous_snapshot=state["snapshot"],
|
||||||
|
previous_revision=previous_revision,
|
||||||
|
)
|
||||||
|
return PrePlanDecision(
|
||||||
|
action="ready" if turn.action == "ready" else "clarify",
|
||||||
|
requirement=requirement,
|
||||||
|
questions=[item for item in turn.questions if item and item.strip()][:3],
|
||||||
|
impact=[item for item in turn.impact if item][:3],
|
||||||
|
assumptions=[item for item in turn.assumptions if item],
|
||||||
|
used_defaults=bool(turn.assumptions),
|
||||||
|
reason="planner_agent",
|
||||||
|
confidence=0.9,
|
||||||
|
reply=turn.reply.strip(),
|
||||||
|
)
|
||||||
|
|
||||||
async def _apply(
|
async def _apply(
|
||||||
self,
|
self,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
@@ -146,7 +216,7 @@ class RequirementResolver:
|
|||||||
created_at=iso_now(),
|
created_at=iso_now(),
|
||||||
)
|
)
|
||||||
clarifications.append(clarification.model_dump())
|
clarifications.append(clarification.model_dump())
|
||||||
content = format_clarification_message(decision.questions)
|
content = decision.reply or ""
|
||||||
metadata = {
|
metadata = {
|
||||||
"preplan_for": user_message["message_id"],
|
"preplan_for": user_message["message_id"],
|
||||||
"clarification": clarification.model_dump(),
|
"clarification": clarification.model_dump(),
|
||||||
@@ -155,17 +225,22 @@ class RequirementResolver:
|
|||||||
else:
|
else:
|
||||||
for item in clarifications:
|
for item in clarifications:
|
||||||
item["resolved"] = True
|
item["resolved"] = True
|
||||||
content = format_ready_message(decision)
|
content = decision.reply or ""
|
||||||
metadata = {"preplan_for": user_message["message_id"], "ready_to_plan": True}
|
metadata = {
|
||||||
|
"preplan_for": user_message["message_id"],
|
||||||
|
"ready_to_plan": True,
|
||||||
|
# 结构化需求快照:前端据此渲染「需求摘要卡」(计划模式 UI)。
|
||||||
|
"requirement": decision.requirement.model_dump(),
|
||||||
|
}
|
||||||
ready = True
|
ready = True
|
||||||
|
|
||||||
payload = _dump_state(
|
payload = {
|
||||||
snapshot=decision.requirement,
|
"snapshot": decision.requirement.model_dump(),
|
||||||
clarifications=clarifications,
|
"clarifications": clarifications,
|
||||||
assumptions=list(decision.assumptions),
|
"assumptions": list(decision.assumptions),
|
||||||
clarification_round=round_count,
|
"clarification_round": round_count,
|
||||||
ready_to_plan=ready,
|
"ready_to_plan": ready,
|
||||||
)
|
}
|
||||||
await self._store.save_requirement_state(
|
await self._store.save_requirement_state(
|
||||||
session_id,
|
session_id,
|
||||||
requirement_json=payload,
|
requirement_json=payload,
|
||||||
|
|||||||
@@ -456,7 +456,10 @@ auth_login:
|
|||||||
# 点击后在新标签页打开下面的 url。enabled 关掉则不显示该入口。
|
# 点击后在新标签页打开下面的 url。enabled 关掉则不显示该入口。
|
||||||
|
|
||||||
database:
|
database:
|
||||||
backend: sqlite
|
# 本地业务数据使用远程 MySQL;连接地址由 backend/.env.local 提供。
|
||||||
|
# LangGraph 对话检查点仍由下方 checkpointer 保存到本机 SQLite。
|
||||||
|
backend: mysql
|
||||||
|
mysql_url: $MYSQL_DATABASE_URL
|
||||||
sqlite_dir: .deer-flow/data
|
sqlite_dir: .deer-flow/data
|
||||||
# 高斯/OpenGauss 业务库示例(启用前先执行 uv sync --extra gauss):
|
# 高斯/OpenGauss 业务库示例(启用前先执行 uv sync --extra gauss):
|
||||||
# backend: gauss
|
# backend: gauss
|
||||||
@@ -605,7 +608,7 @@ llmwiki:
|
|||||||
enabled: true
|
enabled: true
|
||||||
# 免登录 POST /api/knowledge/vector-search 返回的 Wiki 一键跳转链接前缀。
|
# 免登录 POST /api/knowledge/vector-search 返回的 Wiki 一键跳转链接前缀。
|
||||||
frontend_base_url: "http://127.0.0.1:5174"
|
frontend_base_url: "http://127.0.0.1:5174"
|
||||||
auto_sync: true
|
auto_sync: false
|
||||||
sync_interval_seconds: 30
|
sync_interval_seconds: 30
|
||||||
database_batch_size: 50
|
database_batch_size: 50
|
||||||
embedding:
|
embedding:
|
||||||
@@ -748,6 +751,7 @@ workflows:
|
|||||||
report_collaboration:
|
report_collaboration:
|
||||||
enabled: true
|
enabled: true
|
||||||
worker_enabled: true
|
worker_enabled: true
|
||||||
|
planner_llm_enabled: true
|
||||||
planner_model: null
|
planner_model: null
|
||||||
coordinator_model: null
|
coordinator_model: null
|
||||||
research_model: null
|
research_model: null
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
*.log
|
||||||
|
run_summary*.json
|
||||||
|
runs/**
|
||||||
|
!runs/
|
||||||
|
!runs/placeholder_audit.md
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# asyncmy 离线安装说明
|
|
||||||
|
|
||||||
本目录用于给无法联网的后端服务器补装 MySQL 异步驱动 `asyncmy==0.2.11`。
|
|
||||||
|
|
||||||
项目的 `pyproject.toml` 已经声明该依赖;服务器启动报 `MySQL async driver is not installed`,说明实际运行后端的 Python 虚拟环境尚未安装它。
|
|
||||||
|
|
||||||
## 文件选择
|
|
||||||
|
|
||||||
- `asyncmy-0.2.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl`:Ubuntu、CentOS、RHEL、Debian 等常见 glibc Linux(x86_64)。
|
|
||||||
- `asyncmy-0.2.11-cp312-cp312-musllinux_1_2_x86_64.whl`:Alpine Linux(x86_64)。
|
|
||||||
|
|
||||||
这两个 wheel 都只适用于 **CPython 3.12、x86_64**。在服务器执行 `python -V` 和 `uname -m` 确认:应分别显示 Python 3.12 和 `x86_64`/`amd64`。
|
|
||||||
|
|
||||||
## 安装步骤
|
|
||||||
|
|
||||||
1. 将整个 `offline-wheels` 目录复制到后端目录(与 `pyproject.toml` 同级)。
|
|
||||||
2. 进入后端实际运行的虚拟环境;若项目使用默认虚拟环境:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /path/to/backend
|
|
||||||
source .venv/bin/activate
|
|
||||||
```
|
|
||||||
|
|
||||||
3. 普通 Linux(Ubuntu/CentOS/RHEL/Debian)执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pip install --no-index ./offline-wheels/asyncmy/asyncmy-0.2.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
|
|
||||||
```
|
|
||||||
|
|
||||||
Alpine Linux 则执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pip install --no-index ./offline-wheels/asyncmy/asyncmy-0.2.11-cp312-cp312-musllinux_1_2_x86_64.whl
|
|
||||||
```
|
|
||||||
|
|
||||||
4. 验证:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -c "import asyncmy; print(asyncmy.__version__)"
|
|
||||||
```
|
|
||||||
|
|
||||||
5. 重启后端服务。
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
- 必须使用**启动后端服务的同一个 Python/虚拟环境**安装,系统 Python 安装成功并不代表服务能找到该包。
|
|
||||||
- 不要使用 `--force-reinstall`;安装缺失的 `asyncmy` 即可。
|
|
||||||
- 若服务器是 Python 3.13、ARM/aarch64,或不是上述 Linux 类型,请不要安装本目录 wheel,需要下载对应平台版本。
|
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+4
@@ -18,6 +18,10 @@ class ReportCollaborationConfig(BaseModel):
|
|||||||
default=False,
|
default=False,
|
||||||
description="When true, a dispatcher/worker may claim run leases. Gateway-only processes keep this false.",
|
description="When true, a dispatcher/worker may claim run leases. Gateway-only processes keep this false.",
|
||||||
)
|
)
|
||||||
|
planner_llm_enabled: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="P1: LLM planner conversation (clarify + custom strategy). Off keeps the deterministic rule router and template plans.",
|
||||||
|
)
|
||||||
planner_model: str | None = Field(default=None, description="Model name from AppConfig.models for planning")
|
planner_model: str | None = Field(default=None, description="Model name from AppConfig.models for planning")
|
||||||
coordinator_model: str | None = Field(default=None, description="Model name from AppConfig.models for the constrained coordinator")
|
coordinator_model: str | None = Field(default=None, description="Model name from AppConfig.models for the constrained coordinator")
|
||||||
research_model: str | None = Field(default=None, description="Model name from AppConfig.models for research members")
|
research_model: str | None = Field(default=None, description="Model name from AppConfig.models for research members")
|
||||||
|
|||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
"""Merge assistant-wiki metadata and report-collaboration heads.
|
||||||
|
|
||||||
|
Revision ID: 20260909_01
|
||||||
|
Revises: 20260903_01, 20260907_01
|
||||||
|
Create Date: 2026-09-09
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
revision = "20260909_01"
|
||||||
|
down_revision = ("20260903_01", "20260907_01")
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
"""Persistence layer for custom skills (per-user ownership + publish state)."""
|
||||||
|
|
||||||
|
from deerflow.persistence.skills.base import SkillStore
|
||||||
|
from deerflow.persistence.skills.memory import MemorySkillStore
|
||||||
|
from deerflow.persistence.skills.sql import SkillRepository
|
||||||
|
|
||||||
|
__all__ = ["SkillStore", "MemorySkillStore", "SkillRepository", "make_skill_store"]
|
||||||
|
|
||||||
|
|
||||||
|
def make_skill_store(session_factory):
|
||||||
|
if session_factory is None:
|
||||||
|
return MemorySkillStore()
|
||||||
|
return SkillRepository(session_factory)
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
"""Abstract persistence API for custom skill ownership + publish state."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class SkillStore(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def list_visible(self, user_id: str) -> list[dict[str, Any]]:
|
||||||
|
"""List all skill records visible to ``user_id``.
|
||||||
|
|
||||||
|
Visible = owner, OR published by anyone, OR built-in (owner_user_id IS NULL).
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_visible(self, name: str, user_id: str) -> dict[str, Any] | None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_any(self, name: str) -> dict[str, Any] | None:
|
||||||
|
"""Lookup by name regardless of ownership/visibility (admin / cascade use)."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_owned(self, name: str, user_id: str) -> dict[str, Any] | None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def create(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Create a skill record. Caller is responsible for global-name uniqueness check."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def ensure_legacy(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Idempotently insert a legacy on-disk skill into the DB. Updates the row if it
|
||||||
|
already exists with ``owner_user_id IS NULL`` (so a re-run can migrate ownership).
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def update(self, name: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
"""Owner-only update. Returns None if the caller doesn't own the skill."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def update_admin(self, name: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
"""Admin override — caller is responsible for auth check.
|
||||||
|
|
||||||
|
Unlike ``update_builtin`` for agents, this accepts ANY skill including
|
||||||
|
user-owned ones, since the spec lets admin take down others' skills.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def delete(self, name: str, user_id: str) -> bool:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def delete_admin(self, name: str) -> bool:
|
||||||
|
"""Admin-side delete; bypasses ownership check (caller authorizes)."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def count_owned(self, user_id: str) -> int:
|
||||||
|
"""Return the number of skills owned by ``user_id`` (owner_user_id IS NOT NULL)."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_all(self) -> list[dict[str, Any]]:
|
||||||
|
"""Return every skill record regardless of ownership/visibility.
|
||||||
|
|
||||||
|
Caller is responsible for authorization (admin only).
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_published_names(self) -> set[str]:
|
||||||
|
"""Return the set of skill names with ``published = True`` (any owner).
|
||||||
|
|
||||||
|
Batch alternative to calling ``get_any`` per skill — the curator uses
|
||||||
|
this to filter out published skills in a single query instead of one
|
||||||
|
DB round-trip per candidate skill.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_always_on_names(self) -> set[str]:
|
||||||
|
"""Return the set of skill names marked 常驻(免压缩) (``always_on = True``).
|
||||||
|
|
||||||
|
Batch query used by the prompt builder to decide which skills bypass
|
||||||
|
skill compression — one query instead of a per-skill round-trip.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_favorite_skill_names(self, user_id: str) -> set[str]:
|
||||||
|
"""Return the skill names in ``user_id``'s "我的技能" collection.
|
||||||
|
|
||||||
|
These are skills the user does not own but that landed in their list —
|
||||||
|
currently only via their 岗位 (position) sync (``origin='position'``).
|
||||||
|
Owned skills are NOT included here (the caller detects those via
|
||||||
|
``owner_user_id``).
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
"""In-memory ``SkillStore`` implementation for tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from deerflow.persistence.skills.base import SkillStore
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _copy(record: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||||
|
return copy.deepcopy(record) if record is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _with_derived(record: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
out = dict(record)
|
||||||
|
out["builtin"] = out.get("owner_user_id") is None
|
||||||
|
out.setdefault("always_on", False)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class MemorySkillStore(SkillStore):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._skills: dict[str, dict[str, Any]] = {}
|
||||||
|
# user_id -> {skill_name}
|
||||||
|
self._favorites: dict[str, set[str]] = {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_visible(record: dict[str, Any], user_id: str) -> bool:
|
||||||
|
owner = record.get("owner_user_id")
|
||||||
|
if owner is None:
|
||||||
|
return True
|
||||||
|
if owner == user_id:
|
||||||
|
return True
|
||||||
|
return bool(record.get("published"))
|
||||||
|
|
||||||
|
async def list_visible(self, user_id: str) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
_with_derived(_copy(record)) # type: ignore[arg-type]
|
||||||
|
for record in self._skills.values()
|
||||||
|
if self._is_visible(record, user_id)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def get_visible(self, name: str, user_id: str) -> dict[str, Any] | None:
|
||||||
|
record = self._skills.get(name)
|
||||||
|
if record is None or not self._is_visible(record, user_id):
|
||||||
|
return None
|
||||||
|
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
async def get_any(self, name: str) -> dict[str, Any] | None:
|
||||||
|
record = self._skills.get(name)
|
||||||
|
if record is None:
|
||||||
|
return None
|
||||||
|
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
async def get_owned(self, name: str, user_id: str) -> dict[str, Any] | None:
|
||||||
|
record = self._skills.get(name)
|
||||||
|
if record is None or record.get("owner_user_id") != user_id:
|
||||||
|
return None
|
||||||
|
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
async def create(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
name = data["name"]
|
||||||
|
if name in self._skills:
|
||||||
|
raise ValueError(f"Skill '{name}' already exists")
|
||||||
|
record = {
|
||||||
|
"name": name,
|
||||||
|
"owner_user_id": data.get("owner_user_id"),
|
||||||
|
"published": bool(data.get("published", False)),
|
||||||
|
"detail": data.get("detail"),
|
||||||
|
"name_zh": data.get("name_zh"),
|
||||||
|
"always_on": bool(data.get("always_on", False)),
|
||||||
|
"square_id": str(data.get("square_id") or ""),
|
||||||
|
"created_at": _now(),
|
||||||
|
"updated_at": _now(),
|
||||||
|
}
|
||||||
|
self._skills[name] = record
|
||||||
|
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
async def ensure_legacy(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
name = data["name"]
|
||||||
|
record = self._skills.get(name)
|
||||||
|
if record is None:
|
||||||
|
record = {
|
||||||
|
"name": name,
|
||||||
|
"owner_user_id": data.get("owner_user_id"),
|
||||||
|
"published": bool(data.get("published", True)),
|
||||||
|
"detail": data.get("detail"),
|
||||||
|
"name_zh": data.get("name_zh"),
|
||||||
|
"created_at": _now(),
|
||||||
|
"updated_at": _now(),
|
||||||
|
}
|
||||||
|
self._skills[name] = record
|
||||||
|
elif record.get("owner_user_id") is None and data.get("owner_user_id") is not None:
|
||||||
|
record["owner_user_id"] = data["owner_user_id"]
|
||||||
|
record["updated_at"] = _now()
|
||||||
|
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
async def update(self, name: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
record = self._skills.get(name)
|
||||||
|
if record is None or record.get("owner_user_id") != user_id:
|
||||||
|
return None
|
||||||
|
if "published" in data:
|
||||||
|
record["published"] = bool(data["published"])
|
||||||
|
if "detail" in data:
|
||||||
|
record["detail"] = data["detail"]
|
||||||
|
if "name_zh" in data:
|
||||||
|
record["name_zh"] = data["name_zh"]
|
||||||
|
if "always_on" in data:
|
||||||
|
record["always_on"] = bool(data["always_on"])
|
||||||
|
if "square_id" in data:
|
||||||
|
record["square_id"] = str(data["square_id"] or "")
|
||||||
|
record["updated_at"] = _now()
|
||||||
|
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
async def update_admin(self, name: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
record = self._skills.get(name)
|
||||||
|
if record is None:
|
||||||
|
return None
|
||||||
|
if "published" in data:
|
||||||
|
record["published"] = bool(data["published"])
|
||||||
|
if "owner_user_id" in data:
|
||||||
|
record["owner_user_id"] = data["owner_user_id"]
|
||||||
|
if "detail" in data:
|
||||||
|
record["detail"] = data["detail"]
|
||||||
|
if "name_zh" in data:
|
||||||
|
record["name_zh"] = data["name_zh"]
|
||||||
|
if "always_on" in data:
|
||||||
|
record["always_on"] = bool(data["always_on"])
|
||||||
|
if "square_id" in data:
|
||||||
|
record["square_id"] = str(data["square_id"] or "")
|
||||||
|
record["updated_at"] = _now()
|
||||||
|
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
async def delete(self, name: str, user_id: str) -> bool:
|
||||||
|
record = self._skills.get(name)
|
||||||
|
if record is None or record.get("owner_user_id") != user_id:
|
||||||
|
return False
|
||||||
|
del self._skills[name]
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def delete_admin(self, name: str) -> bool:
|
||||||
|
if name not in self._skills:
|
||||||
|
return False
|
||||||
|
del self._skills[name]
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def count_owned(self, user_id: str) -> int:
|
||||||
|
return sum(1 for s in self._skills.values() if s.get("owner_user_id") == user_id)
|
||||||
|
|
||||||
|
async def list_all(self) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
_with_derived(_copy(record)) # type: ignore[arg-type]
|
||||||
|
for record in self._skills.values()
|
||||||
|
]
|
||||||
|
|
||||||
|
async def list_published_names(self) -> set[str]:
|
||||||
|
return {name for name, record in self._skills.items() if record.get("published")}
|
||||||
|
|
||||||
|
async def list_always_on_names(self) -> set[str]:
|
||||||
|
return {name for name, record in self._skills.items() if record.get("always_on")}
|
||||||
|
|
||||||
|
async def list_favorite_skill_names(self, user_id: str) -> set[str]:
|
||||||
|
return set(self._favorites.get(user_id, set()))
|
||||||
|
|
||||||
|
# test helper — not part of the abstract API
|
||||||
|
def add_favorite(self, user_id: str, skill_name: str) -> None:
|
||||||
|
self._favorites.setdefault(user_id, set()).add(skill_name)
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
"""ORM model for custom skill ownership + publish state.
|
||||||
|
|
||||||
|
Note: skill content lives on disk under ``skills/custom/{name}/``; this table
|
||||||
|
only stores the metadata (owner, published flag, timestamps). The skill
|
||||||
|
``name`` doubles as the primary key because the filesystem path is already
|
||||||
|
keyed by name, and we require global name uniqueness across all owners.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from deerflow.persistence.base import Base
|
||||||
|
from deerflow.persistence.types import BeijingDateTime
|
||||||
|
|
||||||
|
|
||||||
|
class SkillRow(Base):
|
||||||
|
__tablename__ = "skills"
|
||||||
|
|
||||||
|
# Global unique name — must match the on-disk folder name under skills/custom/.
|
||||||
|
# Hyphen-case or snake_case allowed; see deerflow.skills.validation.
|
||||||
|
name: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
published: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||||
|
# Human-authored long-form detail/introduction, editable by the skill owner
|
||||||
|
# or an admin. Distinct from the skill ``description`` parsed from SKILL.md;
|
||||||
|
# kept in the DB so admins can annotate built-in skills without rewriting
|
||||||
|
# the shared on-disk SKILL.md file.
|
||||||
|
detail: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
|
||||||
|
# User-configured or LLM-generated Chinese display name. Kept in the DB (not
|
||||||
|
# the on-disk SKILL.md) so it can be set for built-in skills too, and so the
|
||||||
|
# shared source file is never rewritten.
|
||||||
|
name_zh: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None)
|
||||||
|
# 常驻(免压缩):开启技能压缩后,标记此项的技能仍以全文形式注入系统提示词,而非
|
||||||
|
# 降级为索引 + search_skills 检索。存 DB(而非 extensions_config.json),内置技能
|
||||||
|
# 也能标记,且避免在某些部署里回写源码目录下的配置文件导致报错。
|
||||||
|
always_on: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0", index=True)
|
||||||
|
# Publish square (发布广场) this skill is filed under once published. Pure
|
||||||
|
# organizational category, no access control. Empty string = unassigned →
|
||||||
|
# the listing layer resolves it to the default square. One square per skill.
|
||||||
|
square_id: Mapped[str] = mapped_column(String(64), nullable=False, default="", server_default="", index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
|
|
||||||
|
class SkillFavoriteRow(Base):
|
||||||
|
"""A user's "我的技能" membership for a skill they don't own.
|
||||||
|
|
||||||
|
Mirrors :class:`deerflow.persistence.agents.model.AgentFavoriteRow`: lets a
|
||||||
|
skill land in a user's "我的" list without the user owning it. Skills are
|
||||||
|
keyed by ``name`` (the on-disk folder / DB primary key), so this row
|
||||||
|
references ``skill_name`` rather than an opaque id. One row per
|
||||||
|
(user, skill); the unique constraint makes the grant idempotent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "skill_favorites"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "skill_name", name="uq_skill_favorites_user_skill"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
skill_name: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
# ``"user"`` = the user added it themselves (reserved for a future favorite
|
||||||
|
# button); ``"position"`` = auto-granted by the user's 岗位 (position) via
|
||||||
|
# the subscription sync engine. Sync only ever adds/removes ``"position"``
|
||||||
|
# rows, so a user's own picks are never clobbered.
|
||||||
|
origin: Mapped[str] = mapped_column(String(16), nullable=False, default="user", server_default="user")
|
||||||
|
# The position that granted this favorite (only set when ``origin='position'``).
|
||||||
|
position_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
|
|
||||||
|
class SkillDisplayAdapterRow(Base):
|
||||||
|
__tablename__ = "skill_display_adapters"
|
||||||
|
|
||||||
|
skill_name: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
# Store JSON as text for maximum MySQL compatibility. Some deployments still
|
||||||
|
# run MySQL variants where native JSON columns or unquoted names such as
|
||||||
|
# ``display`` are not accepted consistently.
|
||||||
|
display_config: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
|
||||||
|
sample_payload: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""SQLAlchemy-backed custom skill repository."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import func, or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
|
from deerflow.persistence.skills.base import SkillStore
|
||||||
|
from deerflow.persistence.skills.model import SkillFavoriteRow, SkillRow
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_dict(row: SkillRow) -> dict[str, Any]:
|
||||||
|
data = row.to_dict()
|
||||||
|
data["builtin"] = data.get("owner_user_id") is None
|
||||||
|
for key in ("created_at", "updated_at"):
|
||||||
|
value = data.get(key)
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
data[key] = value.isoformat()
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class SkillRepository(SkillStore):
|
||||||
|
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||||
|
self._sf = session_factory
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _visible_filter(user_id: str):
|
||||||
|
return or_(
|
||||||
|
SkillRow.owner_user_id.is_(None),
|
||||||
|
SkillRow.owner_user_id == user_id,
|
||||||
|
SkillRow.published.is_(True),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_visible(self, user_id: str) -> list[dict[str, Any]]:
|
||||||
|
stmt = select(SkillRow).where(self._visible_filter(user_id)).order_by(SkillRow.created_at.asc())
|
||||||
|
async with self._sf() as session:
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return [_row_to_dict(row) for row in result.scalars()]
|
||||||
|
|
||||||
|
async def get_visible(self, name: str, user_id: str) -> dict[str, Any] | None:
|
||||||
|
stmt = select(SkillRow).where(SkillRow.name == name, self._visible_filter(user_id))
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = (await session.execute(stmt)).scalar_one_or_none()
|
||||||
|
return _row_to_dict(row) if row is not None else None
|
||||||
|
|
||||||
|
async def get_any(self, name: str) -> dict[str, Any] | None:
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = await session.get(SkillRow, name)
|
||||||
|
return _row_to_dict(row) if row is not None else None
|
||||||
|
|
||||||
|
async def get_owned(self, name: str, user_id: str) -> dict[str, Any] | None:
|
||||||
|
stmt = select(SkillRow).where(SkillRow.name == name, SkillRow.owner_user_id == user_id)
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = (await session.execute(stmt)).scalar_one_or_none()
|
||||||
|
return _row_to_dict(row) if row is not None else None
|
||||||
|
|
||||||
|
async def create(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
row = SkillRow(
|
||||||
|
name=data["name"],
|
||||||
|
owner_user_id=data.get("owner_user_id"),
|
||||||
|
published=bool(data.get("published", False)),
|
||||||
|
detail=data.get("detail"),
|
||||||
|
name_zh=data.get("name_zh"),
|
||||||
|
always_on=bool(data.get("always_on", False)),
|
||||||
|
square_id=str(data.get("square_id") or ""),
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
async with self._sf() as session:
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return _row_to_dict(row)
|
||||||
|
|
||||||
|
async def ensure_legacy(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = await session.get(SkillRow, data["name"])
|
||||||
|
if row is None:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
row = SkillRow(
|
||||||
|
name=data["name"],
|
||||||
|
owner_user_id=data.get("owner_user_id"),
|
||||||
|
published=bool(data.get("published", True)),
|
||||||
|
detail=data.get("detail"),
|
||||||
|
name_zh=data.get("name_zh"),
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
elif row.owner_user_id is None and data.get("owner_user_id") is not None:
|
||||||
|
# Only patch ownership if it was previously null — never overwrite
|
||||||
|
# an existing owner record on a re-migration.
|
||||||
|
row.owner_user_id = data["owner_user_id"]
|
||||||
|
row.updated_at = datetime.now(UTC)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return _row_to_dict(row)
|
||||||
|
|
||||||
|
async def update(self, name: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = await session.get(SkillRow, name)
|
||||||
|
if row is None or row.owner_user_id != user_id:
|
||||||
|
return None
|
||||||
|
if "published" in data:
|
||||||
|
row.published = bool(data["published"])
|
||||||
|
if "detail" in data:
|
||||||
|
row.detail = data["detail"]
|
||||||
|
if "name_zh" in data:
|
||||||
|
row.name_zh = data["name_zh"]
|
||||||
|
if "always_on" in data:
|
||||||
|
row.always_on = bool(data["always_on"])
|
||||||
|
if "square_id" in data:
|
||||||
|
row.square_id = str(data["square_id"] or "")
|
||||||
|
row.updated_at = datetime.now(UTC)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return _row_to_dict(row)
|
||||||
|
|
||||||
|
async def update_admin(self, name: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = await session.get(SkillRow, name)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
if "published" in data:
|
||||||
|
row.published = bool(data["published"])
|
||||||
|
if "owner_user_id" in data:
|
||||||
|
row.owner_user_id = data["owner_user_id"]
|
||||||
|
if "detail" in data:
|
||||||
|
row.detail = data["detail"]
|
||||||
|
if "name_zh" in data:
|
||||||
|
row.name_zh = data["name_zh"]
|
||||||
|
if "always_on" in data:
|
||||||
|
row.always_on = bool(data["always_on"])
|
||||||
|
if "square_id" in data:
|
||||||
|
row.square_id = str(data["square_id"] or "")
|
||||||
|
row.updated_at = datetime.now(UTC)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return _row_to_dict(row)
|
||||||
|
|
||||||
|
async def delete(self, name: str, user_id: str) -> bool:
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = await session.get(SkillRow, name)
|
||||||
|
if row is None or row.owner_user_id != user_id:
|
||||||
|
return False
|
||||||
|
await session.delete(row)
|
||||||
|
await session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def delete_admin(self, name: str) -> bool:
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = await session.get(SkillRow, name)
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
await session.delete(row)
|
||||||
|
await session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def count_owned(self, user_id: str) -> int:
|
||||||
|
stmt = select(func.count()).select_from(SkillRow).where(SkillRow.owner_user_id == user_id)
|
||||||
|
async with self._sf() as session:
|
||||||
|
return await session.scalar(stmt) or 0
|
||||||
|
|
||||||
|
async def list_all(self) -> list[dict[str, Any]]:
|
||||||
|
stmt = select(SkillRow).order_by(SkillRow.created_at.asc())
|
||||||
|
async with self._sf() as session:
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return [_row_to_dict(row) for row in result.scalars()]
|
||||||
|
|
||||||
|
async def list_published_names(self) -> set[str]:
|
||||||
|
stmt = select(SkillRow.name).where(SkillRow.published.is_(True))
|
||||||
|
async with self._sf() as session:
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return {row[0] for row in result.all()}
|
||||||
|
|
||||||
|
async def list_always_on_names(self) -> set[str]:
|
||||||
|
stmt = select(SkillRow.name).where(SkillRow.always_on.is_(True))
|
||||||
|
async with self._sf() as session:
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return {row[0] for row in result.all()}
|
||||||
|
|
||||||
|
async def list_favorite_skill_names(self, user_id: str) -> set[str]:
|
||||||
|
stmt = select(SkillFavoriteRow.skill_name).where(SkillFavoriteRow.user_id == user_id)
|
||||||
|
async with self._sf() as session:
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return {row[0] for row in result.all()}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .installer import SkillAlreadyExistsError, SkillSecurityScanError
|
||||||
|
from .storage import LocalSkillStorage, SkillStorage, get_or_new_skill_storage
|
||||||
|
from .types import Skill
|
||||||
|
from .validation import ALLOWED_FRONTMATTER_PROPERTIES, _validate_skill_frontmatter
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Skill",
|
||||||
|
"ALLOWED_FRONTMATTER_PROPERTIES",
|
||||||
|
"_validate_skill_frontmatter",
|
||||||
|
"SkillAlreadyExistsError",
|
||||||
|
"SkillSecurityScanError",
|
||||||
|
"SkillStorage",
|
||||||
|
"LocalSkillStorage",
|
||||||
|
"get_or_new_skill_storage",
|
||||||
|
]
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
"""Scan skill text for URL / IP addresses and apply safe batch replacements.
|
||||||
|
|
||||||
|
Pure-logic layer for the 技能地址(URL/IP)批量替换 feature. This module knows
|
||||||
|
**only about text** — it never walks the filesystem. The HTTP router
|
||||||
|
(``app/gateway/routers/skill_addresses.py``) walks ``skills/{public,custom}``,
|
||||||
|
reads each ``*.md`` / ``*.py`` file, and feeds the text here. Keeping the
|
||||||
|
extraction / replacement logic here (no ``app.*`` import) makes it unit-testable
|
||||||
|
in isolation and respects the harness/app boundary.
|
||||||
|
|
||||||
|
Two address kinds are supported (the feature scope):
|
||||||
|
|
||||||
|
* ``"url"`` — full ``http(s)://…`` URLs.
|
||||||
|
* ``"ip"`` — dotted-quad IPv4, optionally ``:port``. Each octet is range
|
||||||
|
checked (0–255) so version-number noise like ``1.2.3.400`` is not matched.
|
||||||
|
|
||||||
|
The single most important invariant is **substring-safe replacement**: we never
|
||||||
|
``str.replace`` raw file text (that would rewrite ``1.2.3.4`` *inside*
|
||||||
|
``11.2.3.40``). Instead we re-detect the exact token spans and only rewrite a
|
||||||
|
span whose text is *exactly* one of the requested ``from`` values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"VALID_KINDS",
|
||||||
|
"AddressSpan",
|
||||||
|
"Occurrence",
|
||||||
|
"normalize_kinds",
|
||||||
|
"find_spans",
|
||||||
|
"scan_occurrences",
|
||||||
|
"count_matches",
|
||||||
|
"apply_replacements",
|
||||||
|
"is_valid_replacement",
|
||||||
|
"infer_kind",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Supported address kinds (the configured feature scope: URL + IP).
|
||||||
|
VALID_KINDS: tuple[str, ...] = ("url", "ip")
|
||||||
|
|
||||||
|
# A URL body stops at whitespace, quotes, brackets, angle brackets, backticks
|
||||||
|
# and backslashes — everything that commonly *delimits* a URL in md / py. The
|
||||||
|
# trailing punctuation that often clings to the end (a sentence period, a
|
||||||
|
# closing paren already excluded, a Markdown comma) is stripped afterwards.
|
||||||
|
_URL_RE = re.compile(r"https?://[^\s'\"`<>)\]}\\]+", re.IGNORECASE)
|
||||||
|
|
||||||
|
# Dotted-quad IPv4 (optionally :port). Guarded so it is not glued to a longer
|
||||||
|
# number run (``(?<![\d.])`` / ``(?![\d.])``); octets are range-checked below.
|
||||||
|
_IP_RE = re.compile(r"(?<![\d.])(\d{1,3}(?:\.\d{1,3}){3})(?::(\d{1,5}))?(?![\d.])")
|
||||||
|
|
||||||
|
# Punctuation (ASCII + CJK) that should not be considered part of a URL token
|
||||||
|
# when it appears at the very end of the match.
|
||||||
|
_URL_TRAILING = set(".,;:!?'\")]}>`" + ")。,;:!?】》、…")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AddressSpan:
|
||||||
|
"""An address token located at an exact ``[start, end)`` offset in text."""
|
||||||
|
|
||||||
|
kind: str
|
||||||
|
value: str
|
||||||
|
start: int
|
||||||
|
end: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Occurrence:
|
||||||
|
"""A located address enriched with 1-based line/column + the source line."""
|
||||||
|
|
||||||
|
kind: str
|
||||||
|
value: str
|
||||||
|
line: int
|
||||||
|
col: int
|
||||||
|
snippet: str
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_kinds(kinds: object) -> tuple[str, ...]:
|
||||||
|
"""Coerce arbitrary input into an ordered, de-duped subset of ``VALID_KINDS``.
|
||||||
|
|
||||||
|
``None`` / empty means "all kinds". Unknown kinds are dropped.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if kinds is None:
|
||||||
|
return VALID_KINDS
|
||||||
|
if isinstance(kinds, str):
|
||||||
|
raw = [p.strip() for p in kinds.split(",")]
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
raw = [str(p).strip() for p in kinds] # type: ignore[assignment]
|
||||||
|
except TypeError:
|
||||||
|
return VALID_KINDS
|
||||||
|
seen: list[str] = []
|
||||||
|
for k in raw:
|
||||||
|
kl = k.lower()
|
||||||
|
if kl in VALID_KINDS and kl not in seen:
|
||||||
|
seen.append(kl)
|
||||||
|
return tuple(seen) if seen else VALID_KINDS
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_ipv4(quad: str) -> bool:
|
||||||
|
parts = quad.split(".")
|
||||||
|
if len(parts) != 4:
|
||||||
|
return False
|
||||||
|
for p in parts:
|
||||||
|
if not p.isdigit() or len(p) > 3:
|
||||||
|
return False
|
||||||
|
if int(p) > 255:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_port(port: str | None) -> bool:
|
||||||
|
if port is None:
|
||||||
|
return True
|
||||||
|
return port.isdigit() and 0 <= int(port) <= 65535
|
||||||
|
|
||||||
|
|
||||||
|
def _url_spans(text: str) -> list[AddressSpan]:
|
||||||
|
spans: list[AddressSpan] = []
|
||||||
|
for m in _URL_RE.finditer(text):
|
||||||
|
start, end = m.start(), m.end()
|
||||||
|
value = m.group(0)
|
||||||
|
# Strip clinging trailing punctuation.
|
||||||
|
while value and value[-1] in _URL_TRAILING:
|
||||||
|
value = value[:-1]
|
||||||
|
end -= 1
|
||||||
|
if value:
|
||||||
|
spans.append(AddressSpan(kind="url", value=value, start=start, end=end))
|
||||||
|
return spans
|
||||||
|
|
||||||
|
|
||||||
|
def _ip_spans(text: str, exclude: list[AddressSpan]) -> list[AddressSpan]:
|
||||||
|
spans: list[AddressSpan] = []
|
||||||
|
for m in _IP_RE.finditer(text):
|
||||||
|
quad = m.group(1)
|
||||||
|
port = m.group(2)
|
||||||
|
if not _valid_ipv4(quad) or not _valid_port(port):
|
||||||
|
continue
|
||||||
|
start, end = m.start(), m.end()
|
||||||
|
# Skip an IP that lives *inside* a URL (e.g. http://1.2.3.4:8080/x) —
|
||||||
|
# replacing just the IP there would corrupt the URL.
|
||||||
|
if any(s.start <= start < s.end for s in exclude):
|
||||||
|
continue
|
||||||
|
spans.append(AddressSpan(kind="ip", value=m.group(0), start=start, end=end))
|
||||||
|
return spans
|
||||||
|
|
||||||
|
|
||||||
|
def find_spans(text: str, kinds: object = None) -> list[AddressSpan]:
|
||||||
|
"""Return every address token span in *text*, ordered by position.
|
||||||
|
|
||||||
|
URL regions are always computed first so that IPs embedded inside a URL are
|
||||||
|
not separately surfaced, even when only ``"ip"`` is requested.
|
||||||
|
"""
|
||||||
|
|
||||||
|
wanted = normalize_kinds(kinds)
|
||||||
|
url_spans = _url_spans(text)
|
||||||
|
result: list[AddressSpan] = []
|
||||||
|
if "url" in wanted:
|
||||||
|
result.extend(url_spans)
|
||||||
|
if "ip" in wanted:
|
||||||
|
result.extend(_ip_spans(text, exclude=url_spans))
|
||||||
|
result.sort(key=lambda s: s.start)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def scan_occurrences(text: str, kinds: object = None, *, snippet_max: int = 240) -> list[Occurrence]:
|
||||||
|
"""Locate addresses with 1-based line/column and a trimmed source line."""
|
||||||
|
|
||||||
|
spans = find_spans(text, kinds)
|
||||||
|
if not spans:
|
||||||
|
return []
|
||||||
|
# Precompute line-start offsets for O(log n) line lookup.
|
||||||
|
line_starts = [0]
|
||||||
|
for i, ch in enumerate(text):
|
||||||
|
if ch == "\n":
|
||||||
|
line_starts.append(i + 1)
|
||||||
|
|
||||||
|
occ: list[Occurrence] = []
|
||||||
|
import bisect
|
||||||
|
|
||||||
|
for s in spans:
|
||||||
|
line_idx = bisect.bisect_right(line_starts, s.start) - 1
|
||||||
|
line_no = line_idx + 1
|
||||||
|
col = s.start - line_starts[line_idx] + 1
|
||||||
|
line_end = line_starts[line_idx + 1] - 1 if line_idx + 1 < len(line_starts) else len(text)
|
||||||
|
raw_line = text[line_starts[line_idx]:line_end].rstrip("\r")
|
||||||
|
snippet = raw_line.strip()
|
||||||
|
if len(snippet) > snippet_max:
|
||||||
|
snippet = snippet[: snippet_max - 1] + "…"
|
||||||
|
occ.append(Occurrence(kind=s.kind, value=s.value, line=line_no, col=col, snippet=snippet))
|
||||||
|
return occ
|
||||||
|
|
||||||
|
|
||||||
|
def count_matches(text: str, mapping: dict[str, str], kinds: object = None) -> int:
|
||||||
|
"""How many address spans in *text* would be rewritten by *mapping*."""
|
||||||
|
|
||||||
|
if not mapping:
|
||||||
|
return 0
|
||||||
|
return sum(1 for s in find_spans(text, kinds) if s.value in mapping)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_replacements(text: str, mapping: dict[str, str], kinds: object = None) -> tuple[str, int]:
|
||||||
|
"""Substring-safe batch replace.
|
||||||
|
|
||||||
|
Only spans whose text is *exactly* a key of *mapping* are rewritten, applied
|
||||||
|
right-to-left so earlier offsets stay valid. Returns ``(new_text, count)``.
|
||||||
|
A no-op mapping (or no hits) returns the original text and ``0``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not mapping:
|
||||||
|
return text, 0
|
||||||
|
spans = [s for s in find_spans(text, kinds) if s.value in mapping]
|
||||||
|
if not spans:
|
||||||
|
return text, 0
|
||||||
|
out = text
|
||||||
|
for s in sorted(spans, key=lambda s: s.start, reverse=True):
|
||||||
|
out = out[: s.start] + mapping[s.value] + out[s.end:]
|
||||||
|
return out, len(spans)
|
||||||
|
|
||||||
|
|
||||||
|
def infer_kind(value: str) -> str | None:
|
||||||
|
"""Return the address kind of *value* if it is a single, whole address.
|
||||||
|
|
||||||
|
Used to classify a user-supplied ``from`` value (so the matching ``to`` can
|
||||||
|
be validated against the right rules). Returns ``None`` when *value* is not
|
||||||
|
exactly one URL or IP token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
spans = find_spans(value, VALID_KINDS)
|
||||||
|
if len(spans) == 1 and spans[0].value == value:
|
||||||
|
return spans[0].kind
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_replacement(kind: str, value: str) -> bool:
|
||||||
|
"""Validate that a user-supplied replacement is well-formed for its kind.
|
||||||
|
|
||||||
|
Keeps an admin from turning a URL into garbage (e.g. dropping the scheme) or
|
||||||
|
an IP into a non-IP. Empty replacements are rejected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not value or value.strip() != value or any(c.isspace() for c in value):
|
||||||
|
return False
|
||||||
|
if kind == "url":
|
||||||
|
return bool(re.fullmatch(r"https?://[^\s'\"`<>]+", value, re.IGNORECASE))
|
||||||
|
if kind == "ip":
|
||||||
|
m = _IP_RE.fullmatch(value)
|
||||||
|
if not m:
|
||||||
|
return False
|
||||||
|
return _valid_ipv4(m.group(1)) and _valid_port(m.group(2))
|
||||||
|
return False
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"""Cascade handling when a published skill is removed from circulation.
|
||||||
|
|
||||||
|
Called from the Gateway skills router whenever a published skill is
|
||||||
|
unpublished, deleted, or admin-taken-down. Walks every agent on disk that
|
||||||
|
referenced the skill, strips the skill from its ``config.yaml`` ``skills``
|
||||||
|
list, and emits an in-app notification to that agent's owner.
|
||||||
|
|
||||||
|
The harness/app firewall means we cannot import the FastAPI app side; the
|
||||||
|
``AgentStore`` instance must be passed in by the caller (the router gets
|
||||||
|
it from ``app.gateway.deps.get_agent_store``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from deerflow.config.agents_config import load_agent_config
|
||||||
|
from deerflow.config.paths import get_paths
|
||||||
|
from deerflow.persistence.notifications import notify
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CascadeOperation = Literal["unpublish", "delete", "admin_takedown"]
|
||||||
|
|
||||||
|
|
||||||
|
def _list_agent_dirs() -> list[Path]:
|
||||||
|
agents_dir = get_paths().agents_dir
|
||||||
|
if not agents_dir.exists():
|
||||||
|
return []
|
||||||
|
return [entry for entry in sorted(agents_dir.iterdir()) if entry.is_dir()]
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_skill_from_yaml(config_file: Path, skill_name: str) -> bool:
|
||||||
|
"""Rewrite ``config.yaml`` removing ``skill_name`` from its ``skills`` list.
|
||||||
|
|
||||||
|
Returns True if the file was modified, False otherwise.
|
||||||
|
"""
|
||||||
|
if not config_file.exists():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
with config_file.open("r", encoding="utf-8") as f:
|
||||||
|
data: dict[str, Any] = yaml.safe_load(f) or {}
|
||||||
|
except yaml.YAMLError:
|
||||||
|
logger.warning("Cascade: failed to parse %s, skipping", config_file)
|
||||||
|
return False
|
||||||
|
|
||||||
|
skills = data.get("skills")
|
||||||
|
if not isinstance(skills, list) or skill_name not in skills:
|
||||||
|
return False
|
||||||
|
|
||||||
|
data["skills"] = [s for s in skills if s != skill_name]
|
||||||
|
with config_file.open("w", encoding="utf-8") as f:
|
||||||
|
yaml.safe_dump(data, f, default_flow_style=False, allow_unicode=True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
_OP_LABELS: dict[CascadeOperation, str] = {
|
||||||
|
"unpublish": "取消发布",
|
||||||
|
"delete": "删除",
|
||||||
|
"admin_takedown": "管理员强制下架",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def cascade_skill_unpublished_or_deleted(
|
||||||
|
skill_name: str,
|
||||||
|
*,
|
||||||
|
actor_user_id: str,
|
||||||
|
operation: CascadeOperation,
|
||||||
|
skill_owner_user_id: str | None,
|
||||||
|
agent_store: Any,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Detach ``skill_name`` from every referencing agent and notify owners.
|
||||||
|
|
||||||
|
Self-references (agent.user_id == actor_user_id) are still detached but
|
||||||
|
the owner gets no in-app notification — the user just did the action,
|
||||||
|
they know.
|
||||||
|
|
||||||
|
On ``admin_takedown`` we additionally notify ``skill_owner_user_id``.
|
||||||
|
|
||||||
|
Returns counters for testability: ``{checked, modified, notified}``.
|
||||||
|
"""
|
||||||
|
counters = {"checked": 0, "modified": 0, "notified": 0}
|
||||||
|
label = _OP_LABELS.get(operation, operation)
|
||||||
|
|
||||||
|
for agent_dir in _list_agent_dirs():
|
||||||
|
config_file = agent_dir / "config.yaml"
|
||||||
|
if not config_file.exists():
|
||||||
|
continue
|
||||||
|
counters["checked"] += 1
|
||||||
|
|
||||||
|
# Quick read to decide whether we need to mutate at all.
|
||||||
|
try:
|
||||||
|
agent_cfg = load_agent_config(agent_dir.name)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Cascade: failed to load %s, skipping", agent_dir.name, exc_info=True)
|
||||||
|
continue
|
||||||
|
if agent_cfg is None or not agent_cfg.skills or skill_name not in agent_cfg.skills:
|
||||||
|
continue
|
||||||
|
|
||||||
|
modified = _remove_skill_from_yaml(config_file, skill_name)
|
||||||
|
if not modified:
|
||||||
|
continue
|
||||||
|
counters["modified"] += 1
|
||||||
|
logger.info(
|
||||||
|
"Cascade: removed skill '%s' from agent '%s/config.yaml' (op=%s)",
|
||||||
|
skill_name,
|
||||||
|
agent_dir.name,
|
||||||
|
operation,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Look up agent owner from DB to decide where to send the notification.
|
||||||
|
record = await agent_store.get_any(agent_dir.name)
|
||||||
|
agent_owner = record.get("user_id") if record else None
|
||||||
|
if agent_owner and agent_owner != actor_user_id:
|
||||||
|
agent_display = (record.get("name") if record else None) or agent_dir.name
|
||||||
|
await notify(
|
||||||
|
agent_owner,
|
||||||
|
type="agent_skill_removed",
|
||||||
|
title=f"专家 “{agent_display}” 的一个技能已失效",
|
||||||
|
body=(
|
||||||
|
f"技能 “{skill_name}” 被 {label},已从该专家的技能列表中自动移除。"
|
||||||
|
),
|
||||||
|
payload={
|
||||||
|
"skill_name": skill_name,
|
||||||
|
"agent_id": agent_dir.name,
|
||||||
|
"agent_name": agent_display,
|
||||||
|
"operation": operation,
|
||||||
|
"actor_user_id": actor_user_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
counters["notified"] += 1
|
||||||
|
|
||||||
|
if operation == "admin_takedown" and skill_owner_user_id and skill_owner_user_id != actor_user_id:
|
||||||
|
await notify(
|
||||||
|
skill_owner_user_id,
|
||||||
|
type="skill_taken_down",
|
||||||
|
title=f"你的技能 “{skill_name}” 已被管理员下架",
|
||||||
|
body="管理员对该技能执行了强制下架。如有疑问请联系管理员。",
|
||||||
|
payload={
|
||||||
|
"skill_name": skill_name,
|
||||||
|
"operation": operation,
|
||||||
|
"actor_user_id": actor_user_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
counters["notified"] += 1
|
||||||
|
|
||||||
|
return counters
|
||||||
|
|
||||||
|
|
||||||
|
def sync_agent_skills_to_available(available_skill_names: set[str]) -> dict[str, Any]:
|
||||||
|
"""Strip skill references that no longer exist on disk from every agent.
|
||||||
|
|
||||||
|
Walks every agent dir and rewrites its ``config.yaml`` ``skills`` list to
|
||||||
|
contain only names that appear in ``available_skill_names``. Used by the
|
||||||
|
admin "agent-skills sync" endpoint to repair historical drift — skills
|
||||||
|
deleted before the cascade existed, deleted via a path that skipped the
|
||||||
|
cascade, or removed from the filesystem manually.
|
||||||
|
|
||||||
|
Synchronous because it only touches local files and the admin endpoint
|
||||||
|
runs it in a worker thread.
|
||||||
|
|
||||||
|
Returns ``{checked, modified, removals: [{agent_id, removed_skills}]}``.
|
||||||
|
"""
|
||||||
|
counters: dict[str, Any] = {"checked": 0, "modified": 0, "removals": []}
|
||||||
|
|
||||||
|
for agent_dir in _list_agent_dirs():
|
||||||
|
config_file = agent_dir / "config.yaml"
|
||||||
|
if not config_file.exists():
|
||||||
|
continue
|
||||||
|
counters["checked"] += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
with config_file.open("r", encoding="utf-8") as f:
|
||||||
|
data: dict[str, Any] = yaml.safe_load(f) or {}
|
||||||
|
except yaml.YAMLError:
|
||||||
|
logger.warning("Sync: failed to parse %s, skipping", config_file)
|
||||||
|
continue
|
||||||
|
|
||||||
|
skills = data.get("skills")
|
||||||
|
if not isinstance(skills, list) or not skills:
|
||||||
|
continue
|
||||||
|
|
||||||
|
kept: list[str] = []
|
||||||
|
removed: list[str] = []
|
||||||
|
for name in skills:
|
||||||
|
if isinstance(name, str) and name in available_skill_names:
|
||||||
|
kept.append(name)
|
||||||
|
else:
|
||||||
|
removed.append(name if isinstance(name, str) else str(name))
|
||||||
|
|
||||||
|
if not removed:
|
||||||
|
continue
|
||||||
|
|
||||||
|
data["skills"] = kept
|
||||||
|
with config_file.open("w", encoding="utf-8") as f:
|
||||||
|
yaml.safe_dump(data, f, default_flow_style=False, allow_unicode=True)
|
||||||
|
|
||||||
|
counters["modified"] += 1
|
||||||
|
counters["removals"].append({"agent_id": agent_dir.name, "removed_skills": removed})
|
||||||
|
logger.info("Sync: removed orphan skills %s from agent '%s'", removed, agent_dir.name)
|
||||||
|
|
||||||
|
return counters
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,111 @@
|
|||||||
|
"""In-memory ring buffer holding the most recent curator run's execution log.
|
||||||
|
|
||||||
|
Now sharded per-user — each ``user_id`` gets its own deque so users only see
|
||||||
|
their own curator activity from the Web UI.
|
||||||
|
|
||||||
|
The buffer is intentionally process-local and non-persistent. The Gateway
|
||||||
|
exposes it over ``GET /api/curator/logs`` with the active user id resolved
|
||||||
|
from the request context.
|
||||||
|
|
||||||
|
All public functions swallow internal exceptions so a logging failure can
|
||||||
|
never break the curator run itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from collections import deque
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_MAX_RECORDS = 200
|
||||||
|
# Reserved bucket key for "no user context" callers (legacy code paths or
|
||||||
|
# tests). Real curator runs always supply a concrete user_id.
|
||||||
|
_DEFAULT_BUCKET = "__default__"
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_buffers: dict[str, deque[dict[str, Any]]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _bucket_key(user_id: str | None) -> str:
|
||||||
|
return user_id or _DEFAULT_BUCKET
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create(user_id: str | None) -> deque[dict[str, Any]]:
|
||||||
|
key = _bucket_key(user_id)
|
||||||
|
buffer = _buffers.get(key)
|
||||||
|
if buffer is None:
|
||||||
|
buffer = deque(maxlen=_MAX_RECORDS)
|
||||||
|
_buffers[key] = buffer
|
||||||
|
return buffer
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
return datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def start_new_run(user_id: str | None = None) -> None:
|
||||||
|
"""Clear the user's buffer and emit a ``run_started`` record."""
|
||||||
|
try:
|
||||||
|
with _lock:
|
||||||
|
buffer = _get_or_create(user_id)
|
||||||
|
buffer.clear()
|
||||||
|
buffer.append({"ts": _now_iso(), "level": "info", "message": "run_started"})
|
||||||
|
except Exception:
|
||||||
|
logger.debug("curator_log.start_new_run failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def record(level: str, message: str, *, user_id: str | None = None, **fields: Any) -> None:
|
||||||
|
"""Append a single log record to the user's buffer. Never raises."""
|
||||||
|
try:
|
||||||
|
entry: dict[str, Any] = {
|
||||||
|
"ts": _now_iso(),
|
||||||
|
"level": str(level or "info"),
|
||||||
|
"message": str(message),
|
||||||
|
}
|
||||||
|
if fields:
|
||||||
|
entry.update({k: v for k, v in fields.items() if k not in entry})
|
||||||
|
with _lock:
|
||||||
|
buffer = _get_or_create(user_id)
|
||||||
|
buffer.append(entry)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("curator_log.record failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_recent(user_id: str | None = None) -> list[dict[str, Any]]:
|
||||||
|
"""Return a snapshot of records for ``user_id``. Never raises."""
|
||||||
|
try:
|
||||||
|
with _lock:
|
||||||
|
buffer = _buffers.get(_bucket_key(user_id))
|
||||||
|
if buffer is None:
|
||||||
|
return []
|
||||||
|
return list(buffer)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("curator_log.get_recent failed", exc_info=True)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def clear(user_id: str | None = None) -> None:
|
||||||
|
"""Drop a user's records. With ``user_id=None`` drops the default bucket.
|
||||||
|
|
||||||
|
Use ``clear_all()`` for cross-user cleanup (tests).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with _lock:
|
||||||
|
buffer = _buffers.get(_bucket_key(user_id))
|
||||||
|
if buffer is not None:
|
||||||
|
buffer.clear()
|
||||||
|
except Exception:
|
||||||
|
logger.debug("curator_log.clear failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_all() -> None:
|
||||||
|
"""Drop every user's records. Used by tests."""
|
||||||
|
try:
|
||||||
|
with _lock:
|
||||||
|
_buffers.clear()
|
||||||
|
except Exception:
|
||||||
|
logger.debug("curator_log.clear_all failed", exc_info=True)
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""Shared skill archive installation logic.
|
||||||
|
|
||||||
|
Pure business logic — no FastAPI/HTTP dependencies.
|
||||||
|
Both Gateway and Client delegate to these functions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import concurrent.futures
|
||||||
|
import logging
|
||||||
|
import posixpath
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||||
|
|
||||||
|
from deerflow.skills.security_scanner import scan_skill_content
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_PROMPT_INPUT_DIRS = {"references", "templates"}
|
||||||
|
_PROMPT_INPUT_SUFFIXES = frozenset({".json", ".markdown", ".md", ".rst", ".txt", ".yaml", ".yml"})
|
||||||
|
|
||||||
|
|
||||||
|
class SkillAlreadyExistsError(ValueError):
|
||||||
|
"""Raised when a skill with the same name is already installed."""
|
||||||
|
|
||||||
|
|
||||||
|
class SkillSecurityScanError(ValueError):
|
||||||
|
"""Raised when a skill archive fails security scanning."""
|
||||||
|
|
||||||
|
|
||||||
|
def is_unsafe_zip_member(info: zipfile.ZipInfo) -> bool:
|
||||||
|
"""Return True if the zip member path is absolute or attempts directory traversal."""
|
||||||
|
name = info.filename
|
||||||
|
if not name:
|
||||||
|
return False
|
||||||
|
normalized = name.replace("\\", "/")
|
||||||
|
if normalized.startswith("/"):
|
||||||
|
return True
|
||||||
|
path = PurePosixPath(normalized)
|
||||||
|
if path.is_absolute():
|
||||||
|
return True
|
||||||
|
if PureWindowsPath(name).is_absolute():
|
||||||
|
return True
|
||||||
|
if ".." in path.parts:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_symlink_member(info: zipfile.ZipInfo) -> bool:
|
||||||
|
"""Detect symlinks based on the external attributes stored in the ZipInfo."""
|
||||||
|
mode = info.external_attr >> 16
|
||||||
|
return stat.S_ISLNK(mode)
|
||||||
|
|
||||||
|
|
||||||
|
def should_ignore_archive_entry(path: Path) -> bool:
|
||||||
|
"""Return True for macOS metadata dirs and dotfiles."""
|
||||||
|
return path.name.startswith(".") or path.name == "__MACOSX"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_skill_dir_from_archive(temp_path: Path) -> Path:
|
||||||
|
"""Locate the skill root directory from extracted archive contents.
|
||||||
|
|
||||||
|
Filters out macOS metadata (__MACOSX) and dotfiles (.DS_Store).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the skill directory.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the archive is empty after filtering.
|
||||||
|
"""
|
||||||
|
items = [p for p in temp_path.iterdir() if not should_ignore_archive_entry(p)]
|
||||||
|
if not items:
|
||||||
|
raise ValueError("Skill archive is empty")
|
||||||
|
if len(items) == 1 and items[0].is_dir():
|
||||||
|
return items[0]
|
||||||
|
return temp_path
|
||||||
|
|
||||||
|
|
||||||
|
def safe_extract_skill_archive(
|
||||||
|
zip_ref: zipfile.ZipFile,
|
||||||
|
dest_path: Path,
|
||||||
|
max_total_size: int = 512 * 1024 * 1024,
|
||||||
|
) -> None:
|
||||||
|
"""Safely extract a skill archive with security protections.
|
||||||
|
|
||||||
|
Protections:
|
||||||
|
- Reject absolute paths and directory traversal (..).
|
||||||
|
- Skip symlink entries instead of materialising them.
|
||||||
|
- Enforce a hard limit on total uncompressed size (zip bomb defence).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If unsafe members or size limit exceeded.
|
||||||
|
"""
|
||||||
|
dest_root = dest_path.resolve()
|
||||||
|
total_written = 0
|
||||||
|
|
||||||
|
for info in zip_ref.infolist():
|
||||||
|
if is_unsafe_zip_member(info):
|
||||||
|
raise ValueError(f"Archive contains unsafe member path: {info.filename!r}")
|
||||||
|
|
||||||
|
if is_symlink_member(info):
|
||||||
|
logger.warning("Skipping symlink entry in skill archive: %s", info.filename)
|
||||||
|
continue
|
||||||
|
|
||||||
|
normalized_name = posixpath.normpath(info.filename.replace("\\", "/"))
|
||||||
|
member_path = dest_root.joinpath(*PurePosixPath(normalized_name).parts)
|
||||||
|
if not member_path.resolve().is_relative_to(dest_root):
|
||||||
|
raise ValueError(f"Zip entry escapes destination: {info.filename!r}")
|
||||||
|
member_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if info.is_dir():
|
||||||
|
member_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
with zip_ref.open(info) as src, member_path.open("wb") as dst:
|
||||||
|
while chunk := src.read(65536):
|
||||||
|
total_written += len(chunk)
|
||||||
|
if total_written > max_total_size:
|
||||||
|
raise ValueError("Skill archive is too large or appears highly compressed.")
|
||||||
|
dst.write(chunk)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_script_support_file(rel_path: Path) -> bool:
|
||||||
|
return bool(rel_path.parts) and rel_path.parts[0] == "scripts"
|
||||||
|
|
||||||
|
|
||||||
|
def _should_scan_support_file(rel_path: Path) -> bool:
|
||||||
|
if _is_script_support_file(rel_path):
|
||||||
|
return True
|
||||||
|
return bool(rel_path.parts) and rel_path.parts[0] in _PROMPT_INPUT_DIRS and rel_path.suffix.lower() in _PROMPT_INPUT_SUFFIXES
|
||||||
|
|
||||||
|
|
||||||
|
def _move_staged_skill_into_reserved_target(staging_target: Path, target: Path) -> None:
|
||||||
|
installed = False
|
||||||
|
reserved = False
|
||||||
|
try:
|
||||||
|
target.mkdir(mode=0o700)
|
||||||
|
reserved = True
|
||||||
|
for child in staging_target.iterdir():
|
||||||
|
shutil.move(str(child), target / child.name)
|
||||||
|
installed = True
|
||||||
|
except FileExistsError as e:
|
||||||
|
raise SkillAlreadyExistsError(f"Skill '{target.name}' already exists") from e
|
||||||
|
finally:
|
||||||
|
if reserved and not installed and target.exists():
|
||||||
|
shutil.rmtree(target)
|
||||||
|
|
||||||
|
|
||||||
|
async def _scan_skill_file_or_raise(skill_dir: Path, path: Path, skill_name: str, *, executable: bool) -> None:
|
||||||
|
rel_path = path.relative_to(skill_dir).as_posix()
|
||||||
|
location = f"{skill_name}/{rel_path}"
|
||||||
|
try:
|
||||||
|
content = path.read_text(encoding="utf-8")
|
||||||
|
except UnicodeDecodeError as e:
|
||||||
|
raise SkillSecurityScanError(f"Security scan failed for skill '{skill_name}': {location} must be valid UTF-8") from e
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await scan_skill_content(content, executable=executable, location=location)
|
||||||
|
except Exception as e:
|
||||||
|
raise SkillSecurityScanError(f"Security scan failed for {location}: {e}") from e
|
||||||
|
|
||||||
|
decision = getattr(result, "decision", None)
|
||||||
|
reason = str(getattr(result, "reason", "") or "No reason provided.")
|
||||||
|
if decision == "block":
|
||||||
|
if rel_path == "SKILL.md":
|
||||||
|
raise SkillSecurityScanError(f"Security scan blocked skill '{skill_name}': {reason}")
|
||||||
|
raise SkillSecurityScanError(f"Security scan blocked {location}: {reason}")
|
||||||
|
if executable and decision != "allow":
|
||||||
|
raise SkillSecurityScanError(f"Security scan rejected executable {location}: {reason}")
|
||||||
|
if decision not in {"allow", "warn"}:
|
||||||
|
raise SkillSecurityScanError(f"Security scan failed for {location}: invalid scanner decision {decision!r}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _scan_skill_archive_contents_or_raise(skill_dir: Path, skill_name: str) -> None:
|
||||||
|
"""Run the skill security scanner against all installable text and script files."""
|
||||||
|
skill_md = skill_dir / "SKILL.md"
|
||||||
|
await _scan_skill_file_or_raise(skill_dir, skill_md, skill_name, executable=False)
|
||||||
|
|
||||||
|
for path in sorted(skill_dir.rglob("*")):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
|
||||||
|
rel_path = path.relative_to(skill_dir)
|
||||||
|
if rel_path == Path("SKILL.md"):
|
||||||
|
continue
|
||||||
|
if path.name == "SKILL.md":
|
||||||
|
raise SkillSecurityScanError(f"Security scan failed for skill '{skill_name}': nested SKILL.md is not allowed at {skill_name}/{rel_path.as_posix()}")
|
||||||
|
if not _should_scan_support_file(rel_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
await _scan_skill_file_or_raise(skill_dir, path, skill_name, executable=_is_script_support_file(rel_path))
|
||||||
|
|
||||||
|
|
||||||
|
def _run_async_install(coro):
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
loop = None
|
||||||
|
|
||||||
|
if loop is not None and loop.is_running():
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||||
|
return executor.submit(asyncio.run, coro).result()
|
||||||
|
return asyncio.run(coro)
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from .types import SKILL_MD_FILE, Skill, SkillCategory
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_skill_file(skill_file: Path, category: SkillCategory, relative_path: Path | None = None) -> Skill | None:
|
||||||
|
"""Parse a SKILL.md file and extract metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
skill_file: Path to the SKILL.md file.
|
||||||
|
category: Category of the skill.
|
||||||
|
relative_path: Relative path from the category root to the skill
|
||||||
|
directory. Defaults to the skill directory name when omitted.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Skill object if parsing succeeds, None otherwise.
|
||||||
|
"""
|
||||||
|
if not skill_file.exists() or skill_file.name != SKILL_MD_FILE:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = skill_file.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# Extract YAML front-matter block between leading ``---`` fences.
|
||||||
|
front_matter_match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL)
|
||||||
|
if not front_matter_match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
front_matter_text = front_matter_match.group(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
metadata = yaml.safe_load(front_matter_text)
|
||||||
|
except yaml.YAMLError as exc:
|
||||||
|
logger.error("Invalid YAML front-matter in %s: %s", skill_file, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
logger.error("Front-matter in %s is not a YAML mapping", skill_file)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract required fields. Both must be non-empty strings.
|
||||||
|
name = metadata.get("name")
|
||||||
|
description = metadata.get("description")
|
||||||
|
|
||||||
|
if not name or not isinstance(name, str):
|
||||||
|
return None
|
||||||
|
if not description or not isinstance(description, str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Normalise: strip surrounding whitespace that YAML may preserve.
|
||||||
|
name = name.strip()
|
||||||
|
description = description.strip()
|
||||||
|
|
||||||
|
if not name or not description:
|
||||||
|
return None
|
||||||
|
|
||||||
|
license_text = metadata.get("license")
|
||||||
|
if license_text is not None:
|
||||||
|
license_text = str(license_text).strip() or None
|
||||||
|
|
||||||
|
return Skill(
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
license=license_text,
|
||||||
|
skill_dir=skill_file.parent,
|
||||||
|
skill_file=skill_file,
|
||||||
|
relative_path=relative_path or Path(skill_file.parent.name),
|
||||||
|
category=category,
|
||||||
|
enabled=True, # Actual state comes from the extensions config file.
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Unexpected error parsing skill file %s", skill_file)
|
||||||
|
return None
|
||||||
Loaded 100 of 336 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user