Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19d3cb96dc | ||
|
|
e243ec3a46 | ||
|
|
f4c7d41a60 | ||
|
|
d765afe2ac | ||
|
|
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 | ||
|
|
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
+196
@@ -0,0 +1,196 @@
|
|||||||
|
# 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 vLLM reasoning/reasoning_content field compatibility regressions versioned.
|
||||||
|
!offline-backend-20260512/backend/tests/test_reasoning_fields.py
|
||||||
|
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
|
||||||
|
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
|
||||||
|
# Keep strategy-selection built-in agent seed and safety-contract regressions versioned.
|
||||||
|
!offline-backend-20260512/backend/tests/test_strategy_selection_seed.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_llmwiki_encoder_runtime.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/`
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
|||||||
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
|
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
|
||||||
- **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) to the template's schema skeleton. **Chat-page debug entry (only this agent's `AgentChatPage`)**: when `agent_id === "template-json-builder"`, the chat header shows a 「页面模板」 select + a 「渲染预览」 button — after a normal Q&A run, the button takes the **latest AI reply**, POSTs it to `POST /api/scheduled-tasks/template-fill/render` (`renderTemplateFill`), and pops a dialog rendering the filled HTML in a `sandbox="allow-scripts"` iframe plus the structure-validation status/errors/gaps — for debugging the agent's conversion effect without creating a scheduled task. Gated strictly on the agent id, so other agents' chat pages are untouched. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, dispatch, and the render API.
|
- **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) to the template's schema skeleton. **Chat-page debug entry (only this agent's `AgentChatPage`)**: when `agent_id === "template-json-builder"`, the chat header shows a 「页面模板」 select + a 「渲染预览」 button — after a normal Q&A run, the button takes the **latest AI reply**, POSTs it to `POST /api/scheduled-tasks/template-fill/render` (`renderTemplateFill`), and pops a dialog rendering the filled HTML in a `sandbox="allow-scripts"` iframe plus the structure-validation status/errors/gaps — for debugging the agent's conversion effect without creating a scheduled task. Gated strictly on the agent id, so other agents' chat pages are untouched. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, dispatch, and the render API.
|
||||||
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat` (`OpenCanvasRoutes.tsx`), sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` fully **reuses the engine** (`AIWritingContext` state machine + LangGraph `useStream`, `AIWritingHistoryProvider`, right-side `AIWritingDraftPanel`) but replaces the presentation: left = `components/ai-writing-chat/WritingChatPanel.tsx` (timeline + **button-only intervention cards** `ChatInterventionCards.tsx` — the five pause cards copied from `InterventionCard.tsx` with all inline textareas removed, confirm/action buttons kept — plus a bottom **unified smart composer**). Free text goes through two-level intent resolution (`open-canvas/api/ai-writing-intent.ts`): deterministic short-phrase fast path (确认/继续/重新搜索/重写大纲…; high-cost actions never fast-pathed) then `POST /api/ai-writing/sessions/{id}/intent` — the backend reads the graph checkpoint (authoritative pause point from the pending interrupt) and routes via `deerflow/agents/ai_writing/intent_router.py` (LLM + strict per-pause action whitelist aligned to the graph's real resume actions, confidence gates: finalize/force_finalize/覆盖式 re_search need ≥0.85 else clarify, payload key-whitelist cleaning). Results map to `submitIntervention`/control (暂停/继续/后台挂起)/QA bypass (`answer` rendered as a chat bubble, never touches graph state)/clarify (amber follow-up bubble with multi-turn context). **Two-state layout**: idle (未开始写作) renders the **classic setup experience unchanged** — `WritingSetupChat` + collapsible `AIWritingPanel` form under `WritingFormProvider` (same as `AIWritingPage`'s idle branch); once writing starts (or history view opens) it switches to the chat workbench. **Unified chronological feed** (`ChatFlowTimeline.tsx`): intent-layer chat bubbles are interleaved INTO the progress-event timeline by anchor (= `progressEvents.length` at message creation), so events triggered by a user message render **below** it (Google-AI-Studio-style single stream, not timeline-above/chat-below); composer-driven interventions suppress the duplicate 「我的要求」card (`suppressedRequirementIndices`). **素材不足自动续跑** only at `material_confirm` (the only pause the graph supports re-search from): needResearch caches the payload as a pending intent, submits `re_search + userQuery`, and auto-submits the pending intent when the new material-confirm pause arrives (cancellable pill). Stage chips + per-stage placeholder + 「当前交互对象」status badge. Backend tests `tests/test_ai_writing_intent.py`. Plan doc: `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
|
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat` (`OpenCanvasRoutes.tsx`), sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` fully **reuses the engine** (`AIWritingContext` state machine + LangGraph `useStream`, `AIWritingHistoryProvider`, right-side `AIWritingDraftPanel`) but replaces the presentation: left = `components/ai-writing-chat/WritingChatPanel.tsx` (timeline + **button-only intervention cards** `ChatInterventionCards.tsx` — the five pause cards copied from `InterventionCard.tsx` with all inline textareas removed, confirm/action buttons kept — plus a bottom **unified smart composer**). Free text goes through two-level intent resolution (`open-canvas/api/ai-writing-intent.ts`): deterministic short-phrase fast path (确认/继续/重新搜索/重写大纲…; high-cost actions never fast-pathed) then `POST /api/ai-writing/sessions/{id}/intent` — the backend reads the graph checkpoint (authoritative pause point from the pending interrupt) and routes via `deerflow/agents/ai_writing/intent_router.py` (LLM + strict per-pause action whitelist aligned to the graph's real resume actions, confidence gates: finalize/force_finalize/覆盖式 re_search need ≥0.85 else clarify, payload key-whitelist cleaning). Results map to `submitIntervention`/control (暂停/继续/后台挂起)/QA bypass (`answer` rendered as a chat bubble, never touches graph state)/clarify (amber follow-up bubble with multi-turn context). **Two-state layout**: idle (未开始写作) renders the **classic setup experience unchanged** — `WritingSetupChat` + collapsible `AIWritingPanel` form under `WritingFormProvider` (same as `AIWritingPage`'s idle branch); once writing starts (or history view opens) it switches to the chat workbench. **Unified chronological feed** (`ChatFlowTimeline.tsx`): intent-layer chat bubbles are interleaved INTO the progress-event timeline by anchor (= `progressEvents.length` at message creation), so events triggered by a user message render **below** it (Google-AI-Studio-style single stream, not timeline-above/chat-below); composer-driven interventions suppress the duplicate 「我的要求」card (`suppressedRequirementIndices`). **素材不足自动续跑** only at `material_confirm` (the only pause the graph supports re-search from): needResearch caches the payload as a pending intent, submits `re_search + userQuery`, and auto-submits the pending intent when the new material-confirm pause arrives (cancellable pill). Stage chips + per-stage placeholder + 「当前交互对象」status badge. Backend tests `tests/test_ai_writing_intent.py`. Plan doc: `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
|
||||||
|
- **策略优选**: route `/page/strategy/qa/strategy-selection`, sidebar 「问答管理 → 策略优选」. The standalone `src/strategy-selection/` workbench has two horizontal split stages: Step 1 keeps intent-understanding conversation cards on the left and an editable task draft plus angle selection on the right; Step 2 keeps multiplexed parallel strategy cards and automatic judging on the left while the right stays dedicated to the selected-strategy basket and final Markdown synthesis. It consumes the existing `POST /api/parallel-agents/run/stream` protocol, isolates output by stable agent id, supports expanded per-agent dialogs, user selection, Markdown editing/copy/download, and shared server-side DOCX export. A clearly marked neutral Taiwan-Strait risk-management demo is loaded by default for offline UI acceptance. Administrators can edit the SOUL and skills of the nine built-in functional agents through the page dialog; the backend seeds `strategy-selection-{intent,angle-*,judge,solution}` only when their directories are missing, preserving later admin changes. Pure parsing/SSE tests live in `src/strategy-selection/lib.test.ts`; backend seed tests live in `tests/test_strategy_selection_seed.py`.
|
||||||
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
|
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
|
||||||
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** left-nav is a separate overlay: `/api/compact-menu-overrides` stores two independent sets (type `A` / `B`); each website picks one via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`.
|
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** left-nav is a separate overlay: `/api/compact-menu-overrides` stores two independent sets (type `A` / `B`); each website picks one via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`.
|
||||||
- **白名单管理 (Login Whitelist, admin-only)**: gates **who may log in**, run as a **blocklist (黑名单模式)**. Two pieces: a global master switch (`system_settings.json → login_whitelist.enabled`, surfaced by **admin-only** `GET|PUT /api/system-settings/login-whitelist`) + a per-user **放行** flag (`users.approved` column, migration `20260625_02`). Users **self-register** as usual and **every new account defaults to `approved=True`** — so a user who has never logged into this system before is allowed straight through; the switch only blocks users an admin has explicitly **取消放行** (`approved=False`). (`create_user`/`User`/`UserRow` all default `approved=True`; admins are always approved; existing rows are backfilled `True`.) Login interception is the single helper `enforce_user_approved(user)` in `app/gateway/routers/auth.py`, called in all three login endpoints (`/login/{local,username,token}`) **after** the account is resolved/created and **before** the JWT is issued: switch off → everyone passes; `system_role=="admin"` → always passes (anti-lockout); else `approved=False` (admin-revoked) → 403 `{code:"PENDING_APPROVAL"}` (the frontend's `extractErrorMessage` surfaces the message and login throws before writing any auth state, so no half-login). Admin approves on `pages/LoginWhitelistPage.tsx` (route `/page/strategy/admin/login-whitelist`, entry「白名单管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both sidebar modes): a top master-switch + a list of all registered users (`GET /api/v1/auth/users`, now carrying `approved`) with a per-row 放行 Switch (`PUT /api/v1/auth/users/{id}/approval`, admin-only; revoking only blocks the **next** login — `token_version` is left untouched, existing sessions are not force-killed). Client/hooks: switch in `strategy-components/api/login-whitelist.ts`, user list + approval in `core/auth/{api,hooks}.ts` (`useSetUserApproval`). Backend tests: `tests/test_login_approval.py`. **账号级「登录口令」(per-account password — 临时开通)**: the same page also lets an admin assign one specific account an individual **plaintext** password (`users.login_password` column, migration `20260625_04`, nullable; auto-added on startup by `_ensure_orm_columns_sync`). Once set, **that account's** username login (`POST /api/v1/auth/login/username`) **requires** `?password=` to exactly match it and that personal password **takes precedence over** the global shared-password gate (`username_login.require_password`); accounts without one keep today's shared-gate / passwordless behavior. The user then logs in via the **existing** frontend URL `#/login/<用户名>?password=xxxx` (LoginPage already forwards `?password=`; `<用户名>` = the account's email `@`-prefix). **Plaintext is intentional** — the password rides in the URL query anyway, so storing it readably lets the admin re-copy the full login link from the page; it is exposed only on the **admin-only** `GET /api/v1/auth/users` (`UserListItem.login_password`) and the new admin-only `PUT /api/v1/auth/users/{id}/login-password` (set non-empty / clear with null·empty; doesn't touch `token_version`). The whitelist page renders a per-user 「登录口令」 column with a set/修改 editor + a live 「复制登录链接」 button (`useSetUserLoginPassword` in `core/auth/hooks.ts`, `setUserLoginPassword` in `core/auth/api.ts`). Backend gate helper `_lookup_user_by_username` (lookup-only, no auto-register) in `routers/auth.py`; tests `tests/test_login_personal_password.py`.
|
- **白名单管理 (Login Whitelist, admin-only)**: gates **who may log in**, run as a **blocklist (黑名单模式)**. Two pieces: a global master switch (`system_settings.json → login_whitelist.enabled`, surfaced by **admin-only** `GET|PUT /api/system-settings/login-whitelist`) + a per-user **放行** flag (`users.approved` column, migration `20260625_02`). Users **self-register** as usual and **every new account defaults to `approved=True`** — so a user who has never logged into this system before is allowed straight through; the switch only blocks users an admin has explicitly **取消放行** (`approved=False`). (`create_user`/`User`/`UserRow` all default `approved=True`; admins are always approved; existing rows are backfilled `True`.) Login interception is the single helper `enforce_user_approved(user)` in `app/gateway/routers/auth.py`, called in all three login endpoints (`/login/{local,username,token}`) **after** the account is resolved/created and **before** the JWT is issued: switch off → everyone passes; `system_role=="admin"` → always passes (anti-lockout); else `approved=False` (admin-revoked) → 403 `{code:"PENDING_APPROVAL"}` (the frontend's `extractErrorMessage` surfaces the message and login throws before writing any auth state, so no half-login). Admin approves on `pages/LoginWhitelistPage.tsx` (route `/page/strategy/admin/login-whitelist`, entry「白名单管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both sidebar modes): a top master-switch + a list of all registered users (`GET /api/v1/auth/users`, now carrying `approved`) with a per-row 放行 Switch (`PUT /api/v1/auth/users/{id}/approval`, admin-only; revoking only blocks the **next** login — `token_version` is left untouched, existing sessions are not force-killed). Client/hooks: switch in `strategy-components/api/login-whitelist.ts`, user list + approval in `core/auth/{api,hooks}.ts` (`useSetUserApproval`). Backend tests: `tests/test_login_approval.py`. **账号级「登录口令」(per-account password — 临时开通)**: the same page also lets an admin assign one specific account an individual **plaintext** password (`users.login_password` column, migration `20260625_04`, nullable; auto-added on startup by `_ensure_orm_columns_sync`). Once set, **that account's** username login (`POST /api/v1/auth/login/username`) **requires** `?password=` to exactly match it and that personal password **takes precedence over** the global shared-password gate (`username_login.require_password`); accounts without one keep today's shared-gate / passwordless behavior. The user then logs in via the **existing** frontend URL `#/login/<用户名>?password=xxxx` (LoginPage already forwards `?password=`; `<用户名>` = the account's email `@`-prefix). **Plaintext is intentional** — the password rides in the URL query anyway, so storing it readably lets the admin re-copy the full login link from the page; it is exposed only on the **admin-only** `GET /api/v1/auth/users` (`UserListItem.login_password`) and the new admin-only `PUT /api/v1/auth/users/{id}/login-password` (set non-empty / clear with null·empty; doesn't touch `token_version`). The whitelist page renders a per-user 「登录口令」 column with a set/修改 editor + a live 「复制登录链接」 button (`useSetUserLoginPassword` in `core/auth/hooks.ts`, `setUserLoginPassword` in `core/auth/api.ts`). Backend gate helper `_lookup_user_by_username` (lookup-only, no auto-register) in `routers/auth.py`; tests `tests/test_login_personal_password.py`.
|
||||||
|
|||||||
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.
@@ -18,7 +18,7 @@ VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
|
|||||||
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
|
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
|
||||||
|
|
||||||
# 开发环境普通登录后进入 Vite 内隔离的官方风格页面;/page/* 仍访问当前定制版。
|
# 开发环境普通登录后进入 Vite 内隔离的官方风格页面;/page/* 仍访问当前定制版。
|
||||||
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=true
|
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false
|
||||||
|
|
||||||
# 轻应用「新建笔记」系列接口
|
# 轻应用「新建笔记」系列接口
|
||||||
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
|
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
|
||||||
|
|||||||
@@ -59,6 +59,9 @@
|
|||||||
# 简介模式左侧菜单类型:与菜单管理页「简介模式」的类型 A / B 对应。两套网页分别填 A 或 B。
|
# 简介模式左侧菜单类型:与菜单管理页「简介模式」的类型 A / B 对应。两套网页分别填 A 或 B。
|
||||||
# 不填或留空时按类型 A,继续使用原来的简介菜单数据。
|
# 不填或留空时按类型 A,继续使用原来的简介菜单数据。
|
||||||
# VITE_COMPACT_MENU_TYPE=A
|
# VITE_COMPACT_MENU_TYPE=A
|
||||||
|
# 问答页「极速」模式:true=在模式菜单显示并直连大模型;false 或未设=隐藏。
|
||||||
|
# 部署后优先改 dist/runtime-config.js 的 VITE_TURBO_MODE_VISIBLE,刷新即可。
|
||||||
|
# VITE_TURBO_MODE_VISIBLE=false
|
||||||
# VITE_GITHUB_OAUTH_TOKEN=
|
# VITE_GITHUB_OAUTH_TOKEN=
|
||||||
|
|
||||||
# --- 官方 DeerFlow 风格页面入口(仅 Vite,不引入 Next.js)---
|
# --- 官方 DeerFlow 风格页面入口(仅 Vite,不引入 Next.js)---
|
||||||
|
|||||||
@@ -151,6 +151,11 @@ window.__CM_RUNTIME_CONFIG__ = {
|
|||||||
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。
|
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。
|
||||||
VITE_GITHUB_OAUTH_TOKEN: "",
|
VITE_GITHUB_OAUTH_TOKEN: "",
|
||||||
|
|
||||||
|
// 是否在问答输入框「模式」菜单显示「极速」:"true" 显示,"false" 隐藏。
|
||||||
|
// 默认关闭。开启后可直连大模型流式问答,不走智能体工具与技能。
|
||||||
|
// 部署后改 dist/runtime-config.js 再刷新页面即可,无需重新打包。
|
||||||
|
VITE_TURBO_MODE_VISIBLE: "true",
|
||||||
|
|
||||||
// 是否在智能体管理页显示「舆情分析」类型:"true" 显示,"false" 隐藏。
|
// 是否在智能体管理页显示「舆情分析」类型:"true" 显示,"false" 隐藏。
|
||||||
// 关闭后,该智能体的专用对话地址也会自动跳回智能体管理页。
|
// 关闭后,该智能体的专用对话地址也会自动跳回智能体管理页。
|
||||||
VITE_SENTIMENT_AGENT_VISIBLE: "true",
|
VITE_SENTIMENT_AGENT_VISIBLE: "true",
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||||
|
import {
|
||||||
|
MyProjectThreadBadge,
|
||||||
|
MyProjectThreadIcon,
|
||||||
|
} from "@/components/workspace/my-project-thread-indicator";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { useAgentsPaginated } from "@/core/agents";
|
import { useAgentsPaginated } from "@/core/agents";
|
||||||
import { getAPIClient } from "@/core/api";
|
import { getAPIClient } from "@/core/api";
|
||||||
@@ -69,6 +73,7 @@ import {
|
|||||||
useToggleThreadPin,
|
useToggleThreadPin,
|
||||||
} from "@/core/threads/hooks";
|
} from "@/core/threads/hooks";
|
||||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||||
|
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
|
||||||
import {
|
import {
|
||||||
bootstrapResumePath,
|
bootstrapResumePath,
|
||||||
isBootstrapThread,
|
isBootstrapThread,
|
||||||
@@ -479,6 +484,7 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
|||||||
) : (
|
) : (
|
||||||
filteredThreads.map((thread) => {
|
filteredThreads.map((thread) => {
|
||||||
const isBoot = isBootstrapThread(thread);
|
const isBoot = isBootstrapThread(thread);
|
||||||
|
const projectType = myProjectConversationTypeOfThread(thread);
|
||||||
const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread);
|
const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread);
|
||||||
const isActive = href === pathname;
|
const isActive = href === pathname;
|
||||||
const pinned = isThreadPinned(thread);
|
const pinned = isThreadPinned(thread);
|
||||||
@@ -505,6 +511,8 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
|||||||
)}
|
)}
|
||||||
aria-label="智能体创建会话"
|
aria-label="智能体创建会话"
|
||||||
/>
|
/>
|
||||||
|
) : projectType ? (
|
||||||
|
<MyProjectThreadIcon type={projectType} />
|
||||||
) : (
|
) : (
|
||||||
<MessageSquare
|
<MessageSquare
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -516,9 +524,15 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
|||||||
{pinned && (
|
{pinned && (
|
||||||
<Pin className="text-muted-foreground/70 size-3 shrink-0" />
|
<Pin className="text-muted-foreground/70 size-3 shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="truncate">
|
<span className="min-w-0 flex-1 truncate">
|
||||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||||
</span>
|
</span>
|
||||||
|
{projectType && (
|
||||||
|
<MyProjectThreadBadge
|
||||||
|
className="mr-4"
|
||||||
|
type={projectType}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import { MessageSquareIcon } from "lucide-react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
import {
|
||||||
|
MyProjectThreadBadge,
|
||||||
|
MyProjectThreadIcon,
|
||||||
|
} from "@/components/workspace/my-project-thread-indicator";
|
||||||
import { useThreadsPage } from "@/core/threads/hooks";
|
import { useThreadsPage } from "@/core/threads/hooks";
|
||||||
|
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
|
||||||
import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils";
|
import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -45,6 +50,7 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
|
|||||||
{threads.map((thread) => {
|
{threads.map((thread) => {
|
||||||
const href = pathOfThread(thread);
|
const href = pathOfThread(thread);
|
||||||
const isActive = href === pathname;
|
const isActive = href === pathname;
|
||||||
|
const projectType = myProjectConversationTypeOfThread(thread);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={thread.thread_id}
|
key={thread.thread_id}
|
||||||
@@ -57,15 +63,20 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
|
|||||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MessageSquareIcon
|
{projectType ? (
|
||||||
className={cn(
|
<MyProjectThreadIcon type={projectType} />
|
||||||
"size-3.5 shrink-0",
|
) : (
|
||||||
isActive ? "text-primary" : "text-muted-foreground/70",
|
<MessageSquareIcon
|
||||||
)}
|
className={cn(
|
||||||
/>
|
"size-3.5 shrink-0",
|
||||||
<span className="truncate">
|
isActive ? "text-primary" : "text-muted-foreground/70",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1 truncate">
|
||||||
{desensitize(titleOfThread(thread, "新对话"))}
|
{desensitize(titleOfThread(thread, "新对话"))}
|
||||||
</span>
|
</span>
|
||||||
|
{projectType && <MyProjectThreadBadge type={projectType} />}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||||
|
import {
|
||||||
|
MyProjectThreadBadge,
|
||||||
|
MyProjectThreadIcon,
|
||||||
|
} from "@/components/workspace/my-project-thread-indicator";
|
||||||
import { useAgentsPaginated, type Agent } from "@/core/agents";
|
import { useAgentsPaginated, type Agent } from "@/core/agents";
|
||||||
import { getAPIClient } from "@/core/api";
|
import { getAPIClient } from "@/core/api";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
@@ -75,6 +79,7 @@ import {
|
|||||||
useToggleThreadPin,
|
useToggleThreadPin,
|
||||||
} from "@/core/threads/hooks";
|
} from "@/core/threads/hooks";
|
||||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||||
|
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
|
||||||
import {
|
import {
|
||||||
isBootstrapThread,
|
isBootstrapThread,
|
||||||
isHiddenFromRecentChats,
|
isHiddenFromRecentChats,
|
||||||
@@ -483,6 +488,7 @@ export function GeneralQaSidebar() {
|
|||||||
);
|
);
|
||||||
const isActive = href === pathname;
|
const isActive = href === pathname;
|
||||||
const isBoot = isBootstrapThread(thread);
|
const isBoot = isBootstrapThread(thread);
|
||||||
|
const projectType = myProjectConversationTypeOfThread(thread);
|
||||||
const pinned = isThreadPinned(thread);
|
const pinned = isThreadPinned(thread);
|
||||||
const favorited = isThreadFavorite(thread);
|
const favorited = isThreadFavorite(thread);
|
||||||
return (
|
return (
|
||||||
@@ -507,6 +513,8 @@ export function GeneralQaSidebar() {
|
|||||||
)}
|
)}
|
||||||
aria-label="智能体创建会话"
|
aria-label="智能体创建会话"
|
||||||
/>
|
/>
|
||||||
|
) : projectType ? (
|
||||||
|
<MyProjectThreadIcon type={projectType} />
|
||||||
) : (
|
) : (
|
||||||
<MessageSquare
|
<MessageSquare
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -518,9 +526,15 @@ export function GeneralQaSidebar() {
|
|||||||
{pinned && (
|
{pinned && (
|
||||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
||||||
)}
|
)}
|
||||||
<span className="truncate">
|
<span className="min-w-0 flex-1 truncate">
|
||||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||||
</span>
|
</span>
|
||||||
|
{projectType && (
|
||||||
|
<MyProjectThreadBadge
|
||||||
|
className="mr-4"
|
||||||
|
type={projectType}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
CheckIcon,
|
CheckIcon,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
FolderTreeIcon,
|
FolderTreeIcon,
|
||||||
|
GaugeIcon,
|
||||||
GraduationCapIcon,
|
GraduationCapIcon,
|
||||||
LightbulbIcon,
|
LightbulbIcon,
|
||||||
PaperclipIcon,
|
PaperclipIcon,
|
||||||
@@ -68,6 +69,7 @@ import { isHiddenFromUIMessage } from "@/core/messages/utils";
|
|||||||
import { useModels } from "@/core/models/hooks";
|
import { useModels } from "@/core/models/hooks";
|
||||||
import type { Model } from "@/core/models/types";
|
import type { Model } from "@/core/models/types";
|
||||||
import type { AgentThreadContext } from "@/core/threads";
|
import type { AgentThreadContext } from "@/core/threads";
|
||||||
|
import { isTurboModeVisible } from "@/core/threads/use-direct-chat";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -223,7 +225,11 @@ export function InputBox({
|
|||||||
context.execution_profile === "single_round_rag";
|
context.execution_profile === "single_round_rag";
|
||||||
const multiRoundRagActive =
|
const multiRoundRagActive =
|
||||||
context.execution_profile === "multi_round_rag";
|
context.execution_profile === "multi_round_rag";
|
||||||
|
const turboModeVisible = isTurboModeVisible();
|
||||||
|
const directChatActive =
|
||||||
|
turboModeVisible && context.execution_profile === "direct";
|
||||||
const frontendRagActive = singleRoundRagActive || multiRoundRagActive;
|
const frontendRagActive = singleRoundRagActive || multiRoundRagActive;
|
||||||
|
const bypassAgentActive = frontendRagActive || directChatActive;
|
||||||
const modelOptions = useMemo(() => {
|
const modelOptions = useMemo(() => {
|
||||||
return singleRoundRagActive ? extraModels : models;
|
return singleRoundRagActive ? extraModels : models;
|
||||||
}, [extraModels, models, singleRoundRagActive]);
|
}, [extraModels, models, singleRoundRagActive]);
|
||||||
@@ -253,11 +259,37 @@ export function InputBox({
|
|||||||
recommendedQuestions.length > 0;
|
recommendedQuestions.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Multi-round RAG is a frontend execution profile, not a model option.
|
// Frontend-only execution profiles are not model options.
|
||||||
// Do not let the normal model fallback clear it on every render.
|
// Do not let the normal model fallback clear them on every render.
|
||||||
|
if (!turboModeVisible && context.execution_profile === "direct") {
|
||||||
|
onContextChange?.({
|
||||||
|
...context,
|
||||||
|
execution_profile: undefined,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (multiRoundRagActive) {
|
if (multiRoundRagActive) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (directChatActive) {
|
||||||
|
if (models.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentModel = models.find((m) => m.name === context.model_name);
|
||||||
|
const fallbackModel = currentModel ?? models[0]!;
|
||||||
|
if (
|
||||||
|
context.model_name === fallbackModel.name &&
|
||||||
|
context.execution_profile === "direct"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onContextChange?.({
|
||||||
|
...context,
|
||||||
|
model_name: fallbackModel.name,
|
||||||
|
execution_profile: "direct",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (modelOptions.length === 0) {
|
if (modelOptions.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -281,7 +313,7 @@ export function InputBox({
|
|||||||
mode: nextMode,
|
mode: nextMode,
|
||||||
execution_profile: fallbackModel.execution_profile,
|
execution_profile: fallbackModel.execution_profile,
|
||||||
});
|
});
|
||||||
}, [context, modelOptions, multiRoundRagActive, onContextChange]);
|
}, [context, directChatActive, modelOptions, models, multiRoundRagActive, onContextChange, turboModeVisible]);
|
||||||
|
|
||||||
const selectedModel = useMemo(() => {
|
const selectedModel = useMemo(() => {
|
||||||
if (modelOptions.length === 0) {
|
if (modelOptions.length === 0) {
|
||||||
@@ -314,7 +346,10 @@ export function InputBox({
|
|||||||
model_name,
|
model_name,
|
||||||
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
|
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
|
||||||
reasoning_effort: context.reasoning_effort,
|
reasoning_effort: context.reasoning_effort,
|
||||||
execution_profile: model.execution_profile,
|
execution_profile:
|
||||||
|
context.execution_profile === "direct"
|
||||||
|
? "direct"
|
||||||
|
: model.execution_profile,
|
||||||
});
|
});
|
||||||
setModelDialogOpen(false);
|
setModelDialogOpen(false);
|
||||||
},
|
},
|
||||||
@@ -346,6 +381,16 @@ export function InputBox({
|
|||||||
[onContextChange, context, models, supportThinking],
|
[onContextChange, context, models, supportThinking],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDirectChatSelect = useCallback(() => {
|
||||||
|
const ordinaryModel =
|
||||||
|
models.find((model) => model.name === context.model_name) ?? models[0];
|
||||||
|
onContextChange?.({
|
||||||
|
...context,
|
||||||
|
model_name: ordinaryModel?.name ?? context.model_name,
|
||||||
|
execution_profile: "direct",
|
||||||
|
});
|
||||||
|
}, [context, models, onContextChange]);
|
||||||
|
|
||||||
const handleSingleRoundRagSelect = useCallback(() => {
|
const handleSingleRoundRagSelect = useCallback(() => {
|
||||||
const ragModel =
|
const ragModel =
|
||||||
extraModels.find((model) => model.name === context.model_name) ??
|
extraModels.find((model) => model.name === context.model_name) ??
|
||||||
@@ -749,7 +794,7 @@ export function InputBox({
|
|||||||
/>
|
/>
|
||||||
</PromptInputActionMenuContent>
|
</PromptInputActionMenuContent>
|
||||||
</PromptInputActionMenu> */}
|
</PromptInputActionMenu> */}
|
||||||
{!frontendRagActive && (
|
{!bypassAgentActive && (
|
||||||
<AddAttachmentsButton
|
<AddAttachmentsButton
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full border-0 bg-transparent px-2.5! shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
"rounded-full border-0 bg-transparent px-2.5! shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||||
@@ -758,7 +803,7 @@ export function InputBox({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!frontendRagActive && !hideContextTools && <Tooltip
|
{!bypassAgentActive && !hideContextTools && <Tooltip
|
||||||
content={
|
content={
|
||||||
context.writing_mode
|
context.writing_mode
|
||||||
? "写作模式已开启:报告由深度研究管线按「通用分析结构」撰写,优先使用对话中已检索的资料"
|
? "写作模式已开启:报告由深度研究管线按「通用分析结构」撰写,优先使用对话中已检索的资料"
|
||||||
@@ -784,7 +829,7 @@ export function InputBox({
|
|||||||
<span className="text-xs font-normal">写作</span>
|
<span className="text-xs font-normal">写作</span>
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>}
|
</Tooltip>}
|
||||||
{!frontendRagActive && <Tooltip
|
{!bypassAgentActive && <Tooltip
|
||||||
content={
|
content={
|
||||||
artifactDirectoryModeEnabled
|
artifactDirectoryModeEnabled
|
||||||
? "文件模式已开启:右侧会显示当前对话生成的全部文件目录"
|
? "文件模式已开启:右侧会显示当前对话生成的全部文件目录"
|
||||||
@@ -813,7 +858,7 @@ export function InputBox({
|
|||||||
<span className="text-xs font-normal">文件</span>
|
<span className="text-xs font-normal">文件</span>
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>}
|
</Tooltip>}
|
||||||
{!frontendRagActive && !hideContextTools && !hideMemoryTool && <Tooltip
|
{!bypassAgentActive && !hideContextTools && !hideMemoryTool && <Tooltip
|
||||||
content={
|
content={
|
||||||
memoryInjectionEnabled
|
memoryInjectionEnabled
|
||||||
? "记忆注入已开启:对话前会注入历史记忆"
|
? "记忆注入已开启:对话前会注入历史记忆"
|
||||||
@@ -839,7 +884,7 @@ export function InputBox({
|
|||||||
<span className="text-xs font-normal">记忆</span>
|
<span className="text-xs font-normal">记忆</span>
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>}
|
</Tooltip>}
|
||||||
{referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
|
{referenceModeEntryEnabled && !bypassAgentActive && !hideContextTools && <Tooltip
|
||||||
content={
|
content={
|
||||||
ragModeEnabled
|
ragModeEnabled
|
||||||
? "参考文献模式已开启:检索类结果会按编号引用展示"
|
? "参考文献模式已开启:检索类结果会按编号引用展示"
|
||||||
@@ -869,7 +914,7 @@ export function InputBox({
|
|||||||
<span className="text-xs font-normal">参考文献</span>
|
<span className="text-xs font-normal">参考文献</span>
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>}
|
</Tooltip>}
|
||||||
{!frontendRagActive && <Tooltip
|
{!bypassAgentActive && <Tooltip
|
||||||
content={
|
content={
|
||||||
contextCompressionEnabled
|
contextCompressionEnabled
|
||||||
? "上下文压缩已开启:超长对话会自动压缩历史以节省 token"
|
? "上下文压缩已开启:超长对话会自动压缩历史以节省 token"
|
||||||
@@ -909,7 +954,9 @@ export function InputBox({
|
|||||||
: "flash"
|
: "flash"
|
||||||
}
|
}
|
||||||
tooltipContent={
|
tooltipContent={
|
||||||
singleRoundRagActive
|
directChatActive
|
||||||
|
? `${t.inputBox.turboMode}:${t.inputBox.turboModeDescription}`
|
||||||
|
: singleRoundRagActive
|
||||||
? "单轮 RAG 问答:基于知识库检索生成答案,并展示参考资料"
|
? "单轮 RAG 问答:基于知识库检索生成答案,并展示参考资料"
|
||||||
: multiRoundRagActive
|
: multiRoundRagActive
|
||||||
? "多轮 RAG 问答:多次检索并展示检索过程与参考资料"
|
? "多轮 RAG 问答:多次检索并展示检索过程与参考资料"
|
||||||
@@ -921,7 +968,9 @@ export function InputBox({
|
|||||||
"gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
"gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||||
compact && "rounded-lg border px-2.5! py-1! text-[11px] font-medium",
|
compact && "rounded-lg border px-2.5! py-1! text-[11px] font-medium",
|
||||||
compact &&
|
compact &&
|
||||||
(frontendRagActive
|
(directChatActive
|
||||||
|
? "border-sky-200 bg-sky-50/60 text-sky-700 hover:bg-sky-100 dark:border-sky-700 dark:bg-sky-950/40 dark:text-sky-200"
|
||||||
|
: frontendRagActive
|
||||||
? "border-emerald-200 bg-emerald-50/60 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
|
? "border-emerald-200 bg-emerald-50/60 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
|
||||||
: context.mode === "thinking"
|
: context.mode === "thinking"
|
||||||
? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200"
|
? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200"
|
||||||
@@ -933,7 +982,9 @@ export function InputBox({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
{frontendRagActive ? (
|
{directChatActive ? (
|
||||||
|
<GaugeIcon className="size-3" />
|
||||||
|
) : frontendRagActive ? (
|
||||||
<BookMarkedIcon className="size-3" />
|
<BookMarkedIcon className="size-3" />
|
||||||
) : context.mode === "flash" ? (
|
) : context.mode === "flash" ? (
|
||||||
<ZapIcon className="size-3" />
|
<ZapIcon className="size-3" />
|
||||||
@@ -948,10 +999,14 @@ export function InputBox({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-xs font-normal",
|
"text-xs font-normal",
|
||||||
context.mode === "ultra" ? "golden-text" : "",
|
context.mode === "ultra" && !directChatActive && !frontendRagActive
|
||||||
|
? "golden-text"
|
||||||
|
: "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{frontendRagActive
|
{directChatActive
|
||||||
|
? t.inputBox.turboMode
|
||||||
|
: frontendRagActive
|
||||||
? multiRoundRagActive
|
? multiRoundRagActive
|
||||||
? "多轮 RAG"
|
? "多轮 RAG"
|
||||||
: "单轮 RAG"
|
: "单轮 RAG"
|
||||||
@@ -969,9 +1024,39 @@ export function InputBox({
|
|||||||
{t.inputBox.mode}
|
{t.inputBox.mode}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<PromptInputActionMenu>
|
<PromptInputActionMenu>
|
||||||
|
{turboModeVisible && (
|
||||||
<PromptInputActionMenuItem
|
<PromptInputActionMenuItem
|
||||||
className={cn(
|
className={cn(
|
||||||
!frontendRagActive && context.mode === "flash"
|
directChatActive
|
||||||
|
? "bg-sky-50 text-sky-800 hover:bg-sky-100 focus:bg-sky-100 dark:bg-sky-950/40 dark:text-sky-200 dark:hover:bg-sky-950/60 dark:focus:bg-sky-950/60"
|
||||||
|
: "text-muted-foreground/65",
|
||||||
|
)}
|
||||||
|
onSelect={handleDirectChatSelect}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-1 font-bold">
|
||||||
|
<GaugeIcon
|
||||||
|
className={cn(
|
||||||
|
"mr-2 size-4",
|
||||||
|
directChatActive && "text-sky-700 dark:text-sky-200",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{t.inputBox.turboMode}
|
||||||
|
</div>
|
||||||
|
<div className="pl-7 text-xs">
|
||||||
|
{t.inputBox.turboModeDescription}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{directChatActive ? (
|
||||||
|
<CheckIcon className="ml-auto size-4" />
|
||||||
|
) : (
|
||||||
|
<div className="ml-auto size-4" />
|
||||||
|
)}
|
||||||
|
</PromptInputActionMenuItem>
|
||||||
|
)}
|
||||||
|
<PromptInputActionMenuItem
|
||||||
|
className={cn(
|
||||||
|
!bypassAgentActive && context.mode === "flash"
|
||||||
? "text-accent-foreground"
|
? "text-accent-foreground"
|
||||||
: "text-muted-foreground/65",
|
: "text-muted-foreground/65",
|
||||||
)}
|
)}
|
||||||
@@ -982,7 +1067,7 @@ export function InputBox({
|
|||||||
<ZapIcon
|
<ZapIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 size-4",
|
"mr-2 size-4",
|
||||||
!frontendRagActive && context.mode === "flash" &&
|
!bypassAgentActive && context.mode === "flash" &&
|
||||||
"text-accent-foreground",
|
"text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -992,7 +1077,7 @@ export function InputBox({
|
|||||||
{t.inputBox.flashModeDescription}
|
{t.inputBox.flashModeDescription}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!frontendRagActive && context.mode === "flash" ? (
|
{!bypassAgentActive && context.mode === "flash" ? (
|
||||||
<CheckIcon className="ml-auto size-4" />
|
<CheckIcon className="ml-auto size-4" />
|
||||||
) : (
|
) : (
|
||||||
<div className="ml-auto size-4" />
|
<div className="ml-auto size-4" />
|
||||||
@@ -1001,7 +1086,7 @@ export function InputBox({
|
|||||||
{supportThinking && (
|
{supportThinking && (
|
||||||
<PromptInputActionMenuItem
|
<PromptInputActionMenuItem
|
||||||
className={cn(
|
className={cn(
|
||||||
!frontendRagActive && context.mode === "thinking"
|
!bypassAgentActive && context.mode === "thinking"
|
||||||
? "text-accent-foreground"
|
? "text-accent-foreground"
|
||||||
: "text-muted-foreground/65",
|
: "text-muted-foreground/65",
|
||||||
)}
|
)}
|
||||||
@@ -1012,7 +1097,7 @@ export function InputBox({
|
|||||||
<LightbulbIcon
|
<LightbulbIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 size-4",
|
"mr-2 size-4",
|
||||||
!frontendRagActive && context.mode === "thinking" &&
|
!bypassAgentActive && context.mode === "thinking" &&
|
||||||
"text-accent-foreground",
|
"text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -1022,7 +1107,7 @@ export function InputBox({
|
|||||||
{t.inputBox.reasoningModeDescription}
|
{t.inputBox.reasoningModeDescription}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!frontendRagActive && context.mode === "thinking" ? (
|
{!bypassAgentActive && context.mode === "thinking" ? (
|
||||||
<CheckIcon className="ml-auto size-4" />
|
<CheckIcon className="ml-auto size-4" />
|
||||||
) : (
|
) : (
|
||||||
<div className="ml-auto size-4" />
|
<div className="ml-auto size-4" />
|
||||||
@@ -1031,7 +1116,7 @@ export function InputBox({
|
|||||||
)}
|
)}
|
||||||
<PromptInputActionMenuItem
|
<PromptInputActionMenuItem
|
||||||
className={cn(
|
className={cn(
|
||||||
!frontendRagActive && context.mode === "pro"
|
!bypassAgentActive && context.mode === "pro"
|
||||||
? "text-accent-foreground"
|
? "text-accent-foreground"
|
||||||
: "text-muted-foreground/65",
|
: "text-muted-foreground/65",
|
||||||
)}
|
)}
|
||||||
@@ -1042,7 +1127,7 @@ export function InputBox({
|
|||||||
<GraduationCapIcon
|
<GraduationCapIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 size-4",
|
"mr-2 size-4",
|
||||||
!frontendRagActive && context.mode === "pro" &&
|
!bypassAgentActive && context.mode === "pro" &&
|
||||||
"text-accent-foreground",
|
"text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -1052,7 +1137,7 @@ export function InputBox({
|
|||||||
{t.inputBox.proModeDescription}
|
{t.inputBox.proModeDescription}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!frontendRagActive && context.mode === "pro" ? (
|
{!bypassAgentActive && context.mode === "pro" ? (
|
||||||
<CheckIcon className="ml-auto size-4" />
|
<CheckIcon className="ml-auto size-4" />
|
||||||
) : (
|
) : (
|
||||||
<div className="ml-auto size-4" />
|
<div className="ml-auto size-4" />
|
||||||
@@ -1060,7 +1145,7 @@ export function InputBox({
|
|||||||
</PromptInputActionMenuItem>
|
</PromptInputActionMenuItem>
|
||||||
<PromptInputActionMenuItem
|
<PromptInputActionMenuItem
|
||||||
className={cn(
|
className={cn(
|
||||||
!frontendRagActive && context.mode === "ultra"
|
!bypassAgentActive && context.mode === "ultra"
|
||||||
? "text-accent-foreground"
|
? "text-accent-foreground"
|
||||||
: "text-muted-foreground/65",
|
: "text-muted-foreground/65",
|
||||||
)}
|
)}
|
||||||
@@ -1071,14 +1156,14 @@ export function InputBox({
|
|||||||
<RocketIcon
|
<RocketIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 size-4",
|
"mr-2 size-4",
|
||||||
!frontendRagActive &&
|
!bypassAgentActive &&
|
||||||
context.mode === "ultra" &&
|
context.mode === "ultra" &&
|
||||||
"text-[#dabb5e]",
|
"text-[#dabb5e]",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
!frontendRagActive &&
|
!bypassAgentActive &&
|
||||||
context.mode === "ultra" &&
|
context.mode === "ultra" &&
|
||||||
"golden-text",
|
"golden-text",
|
||||||
)}
|
)}
|
||||||
@@ -1090,7 +1175,7 @@ export function InputBox({
|
|||||||
{t.inputBox.ultraModeDescription}
|
{t.inputBox.ultraModeDescription}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!frontendRagActive && context.mode === "ultra" ? (
|
{!bypassAgentActive && context.mode === "ultra" ? (
|
||||||
<CheckIcon className="ml-auto size-4" />
|
<CheckIcon className="ml-auto size-4" />
|
||||||
) : (
|
) : (
|
||||||
<div className="ml-auto size-4" />
|
<div className="ml-auto size-4" />
|
||||||
@@ -1161,7 +1246,7 @@ export function InputBox({
|
|||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
</PromptInputActionMenuContent>
|
</PromptInputActionMenuContent>
|
||||||
</PromptInputActionMenu>
|
</PromptInputActionMenu>
|
||||||
{!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && (
|
{!bypassAgentActive && supportReasoningEffort && context.mode !== "flash" && (
|
||||||
<PromptInputActionMenu>
|
<PromptInputActionMenu>
|
||||||
<PromptInputActionMenuTrigger className={cn("gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", compact && "rounded-lg border border-border px-2.5! py-1! text-[13px]")}>
|
<PromptInputActionMenuTrigger className={cn("gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", compact && "rounded-lg border border-border px-2.5! py-1! text-[13px]")}>
|
||||||
<div className="text-xs font-normal">
|
<div className="text-xs font-normal">
|
||||||
@@ -1279,7 +1364,7 @@ export function InputBox({
|
|||||||
)}
|
)}
|
||||||
</PromptInputTools>
|
</PromptInputTools>
|
||||||
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
|
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
|
||||||
{!frontendRagActive && agentSelector && (
|
{!bypassAgentActive && agentSelector && (
|
||||||
<AgentSelector
|
<AgentSelector
|
||||||
selectedAgentId={agentSelector.selectedAgentId}
|
selectedAgentId={agentSelector.selectedAgentId}
|
||||||
onAgentChange={agentSelector.onAgentChange}
|
onAgentChange={agentSelector.onAgentChange}
|
||||||
@@ -1287,8 +1372,8 @@ export function InputBox({
|
|||||||
compact={compact}
|
compact={compact}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!frontendRagActive && knowledgeSelector}
|
{!bypassAgentActive && knowledgeSelector}
|
||||||
{!frontendRagActive && promptPrefix && (
|
{!bypassAgentActive && promptPrefix && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={
|
content={
|
||||||
promptPrefix.on
|
promptPrefix.on
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export function ModelNameSelect({
|
|||||||
className,
|
className,
|
||||||
triggerClassName,
|
triggerClassName,
|
||||||
defaultLabel = "默认模型",
|
defaultLabel = "默认模型",
|
||||||
|
title = "选择用于本次操作的大模型(默认使用系统配置的模型)",
|
||||||
}: {
|
}: {
|
||||||
value: string | null;
|
value: string | null;
|
||||||
onChange: (modelName: string | null) => void;
|
onChange: (modelName: string | null) => void;
|
||||||
@@ -32,6 +33,7 @@ export function ModelNameSelect({
|
|||||||
className?: string;
|
className?: string;
|
||||||
triggerClassName?: string;
|
triggerClassName?: string;
|
||||||
defaultLabel?: string;
|
defaultLabel?: string;
|
||||||
|
title?: string;
|
||||||
}) {
|
}) {
|
||||||
const { models } = useModels();
|
const { models } = useModels();
|
||||||
return (
|
return (
|
||||||
@@ -42,7 +44,7 @@ export function ModelNameSelect({
|
|||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)}
|
className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)}
|
||||||
title="选择用于本次操作的大模型(默认使用系统配置的模型)"
|
title={title}
|
||||||
>
|
>
|
||||||
<SelectValue placeholder={defaultLabel} />
|
<SelectValue placeholder={defaultLabel} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { FolderKanban } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
MY_PROJECT_CONVERSATION_LABELS,
|
||||||
|
type MyProjectConversationType,
|
||||||
|
} from "@/core/threads/project-metadata";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const BADGE_STYLES: Record<MyProjectConversationType, string> = {
|
||||||
|
qa: "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-700/70 dark:bg-sky-950/55 dark:text-sky-300",
|
||||||
|
strategy:
|
||||||
|
"border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-700/70 dark:bg-violet-950/55 dark:text-violet-300",
|
||||||
|
solution:
|
||||||
|
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-700/70 dark:bg-emerald-950/55 dark:text-emerald-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MyProjectThreadIcon({
|
||||||
|
type,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
type: MyProjectConversationType;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<FolderKanban
|
||||||
|
aria-label={`我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`}
|
||||||
|
className={cn("size-3.5 shrink-0 text-violet-500", className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MyProjectThreadBadge({
|
||||||
|
type,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
type: MyProjectConversationType;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded border px-1 py-0.5 text-[9px] font-medium leading-none",
|
||||||
|
BADGE_STYLES[type],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
title={`来自我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`}
|
||||||
|
>
|
||||||
|
{MY_PROJECT_CONVERSATION_LABELS[type]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -50,6 +50,10 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||||
|
import {
|
||||||
|
MyProjectThreadBadge,
|
||||||
|
MyProjectThreadIcon,
|
||||||
|
} from "@/components/workspace/my-project-thread-indicator";
|
||||||
import { getAPIClient } from "@/core/api";
|
import { getAPIClient } from "@/core/api";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
@@ -64,6 +68,7 @@ import {
|
|||||||
useToggleThreadPin,
|
useToggleThreadPin,
|
||||||
} from "@/core/threads/hooks";
|
} from "@/core/threads/hooks";
|
||||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||||
|
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
|
||||||
import {
|
import {
|
||||||
isBootstrapThread,
|
isBootstrapThread,
|
||||||
isHiddenFromRecentChats,
|
isHiddenFromRecentChats,
|
||||||
@@ -246,6 +251,7 @@ export function RecentChatList({
|
|||||||
const href = listPathOfThread(thread);
|
const href = listPathOfThread(thread);
|
||||||
const isActive = href === pathname;
|
const isActive = href === pathname;
|
||||||
const isBoot = isBootstrapThread(thread);
|
const isBoot = isBootstrapThread(thread);
|
||||||
|
const projectType = myProjectConversationTypeOfThread(thread);
|
||||||
const pinned = isThreadPinned(thread);
|
const pinned = isThreadPinned(thread);
|
||||||
const favorited = isThreadFavorite(thread);
|
const favorited = isThreadFavorite(thread);
|
||||||
return (
|
return (
|
||||||
@@ -279,6 +285,8 @@ export function RecentChatList({
|
|||||||
}
|
}
|
||||||
aria-label="智能体创建会话"
|
aria-label="智能体创建会话"
|
||||||
/>
|
/>
|
||||||
|
) : projectType ? (
|
||||||
|
<MyProjectThreadIcon type={projectType} />
|
||||||
) : (
|
) : (
|
||||||
compact && (
|
compact && (
|
||||||
<MessageSquare
|
<MessageSquare
|
||||||
@@ -294,9 +302,15 @@ export function RecentChatList({
|
|||||||
{pinned && (
|
{pinned && (
|
||||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
||||||
)}
|
)}
|
||||||
<span className="truncate">
|
<span className="min-w-0 flex-1 truncate">
|
||||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||||
</span>
|
</span>
|
||||||
|
{projectType && (
|
||||||
|
<MyProjectThreadBadge
|
||||||
|
className="mr-3"
|
||||||
|
type={projectType}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
|||||||
// },
|
// },
|
||||||
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||||
...(isAdmin
|
...(isAdmin
|
||||||
? [{ id: "encoder", label: "本地编码器", icon: CpuIcon }]
|
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }]
|
||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
id: "account",
|
id: "account",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
|
import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, FolderKanban, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
|
||||||
|
|
||||||
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
|
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
|
||||||
import { SidebarCollapseButton } from "./workspace-header";
|
import { SidebarCollapseButton } from "./workspace-header";
|
||||||
@@ -24,7 +24,10 @@ import {
|
|||||||
applyCompactMenuOverrides,
|
applyCompactMenuOverrides,
|
||||||
type CompactMenuItem,
|
type CompactMenuItem,
|
||||||
} from "@/core/page-layout/compact-menu-overrides";
|
} from "@/core/page-layout/compact-menu-overrides";
|
||||||
import { type MenuItem } from "@/core/page-layout/sidebar-menu";
|
import {
|
||||||
|
MARKDOWN_DOCX_MENU_ITEM,
|
||||||
|
type MenuItem,
|
||||||
|
} from "@/core/page-layout/sidebar-menu";
|
||||||
import { useCompactMenuOverrides } from "@/strategy-components/api/compact-menu-overrides";
|
import { useCompactMenuOverrides } from "@/strategy-components/api/compact-menu-overrides";
|
||||||
|
|
||||||
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
|
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
|
||||||
@@ -65,6 +68,15 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
|||||||
path: "/page/workspace/chats",
|
path: "/page/workspace/chats",
|
||||||
match: (pathname) => pathname === "/page/workspace/chats",
|
match: (pathname) => pathname === "/page/workspace/chats",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "strategy-projects",
|
||||||
|
label: "我的项目",
|
||||||
|
icon: FolderKanban,
|
||||||
|
path: "/page/strategy/qa/projects",
|
||||||
|
match: (pathname) =>
|
||||||
|
pathname.startsWith("/page/strategy/qa/projects") ||
|
||||||
|
pathname.startsWith("/page/strategy/qa/strategy-selection"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "workspace-library",
|
id: "workspace-library",
|
||||||
label: "资料库",
|
label: "资料库",
|
||||||
@@ -140,7 +152,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
|||||||
label: "知识库管理",
|
label: "知识库管理",
|
||||||
icon: BookOpen,
|
icon: BookOpen,
|
||||||
path: "/page/workspace/knowledge",
|
path: "/page/workspace/knowledge",
|
||||||
match: (pathname) => pathname.startsWith("/page/workspace/knowledge"),
|
match: (pathname) =>
|
||||||
|
pathname.startsWith("/page/workspace/knowledge") &&
|
||||||
|
!pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...MARKDOWN_DOCX_MENU_ITEM,
|
||||||
|
match: (pathname) => pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ export const enUS: Translations = {
|
|||||||
mode: "Mode",
|
mode: "Mode",
|
||||||
flashMode: "Flash",
|
flashMode: "Flash",
|
||||||
flashModeDescription: "Fast and efficient, but may not be accurate",
|
flashModeDescription: "Fast and efficient, but may not be accurate",
|
||||||
|
turboMode: "Turbo",
|
||||||
|
turboModeDescription:
|
||||||
|
"Talk to the model directly with no agent tools or skills, for lower first-token latency",
|
||||||
reasoningMode: "Reasoning",
|
reasoningMode: "Reasoning",
|
||||||
reasoningModeDescription:
|
reasoningModeDescription:
|
||||||
"Reasoning before action, balance between time and accuracy",
|
"Reasoning before action, balance between time and accuracy",
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ export interface Translations {
|
|||||||
mode: string;
|
mode: string;
|
||||||
flashMode: string;
|
flashMode: string;
|
||||||
flashModeDescription: string;
|
flashModeDescription: string;
|
||||||
|
turboMode: string;
|
||||||
|
turboModeDescription: string;
|
||||||
reasoningMode: string;
|
reasoningMode: string;
|
||||||
reasoningModeDescription: string;
|
reasoningModeDescription: string;
|
||||||
proMode: string;
|
proMode: string;
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ export const zhCN: Translations = {
|
|||||||
mode: "模式",
|
mode: "模式",
|
||||||
flashMode: "快速",
|
flashMode: "快速",
|
||||||
flashModeDescription: "快速且高效的完成任务,但可能不够精准",
|
flashModeDescription: "快速且高效的完成任务,但可能不够精准",
|
||||||
|
turboMode: "极速",
|
||||||
|
turboModeDescription: "直连大模型流式问答,不走智能体工具与技能,首字更快",
|
||||||
reasoningMode: "思考",
|
reasoningMode: "思考",
|
||||||
reasoningModeDescription: "思考后再行动,在时间与准确性之间取得平衡",
|
reasoningModeDescription: "思考后再行动,在时间与准确性之间取得平衡",
|
||||||
proMode: "专业问答",
|
proMode: "专业问答",
|
||||||
|
|||||||
@@ -364,11 +364,16 @@ export function extractReasoningContentFromMessage(message: Message) {
|
|||||||
if (message.type !== "ai") {
|
if (message.type !== "ai") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (
|
if (message.additional_kwargs) {
|
||||||
message.additional_kwargs &&
|
const reasoningContent = message.additional_kwargs.reasoning_content;
|
||||||
"reasoning_content" in message.additional_kwargs
|
if (typeof reasoningContent === "string") {
|
||||||
) {
|
return reasoningContent;
|
||||||
return message.additional_kwargs.reasoning_content as string | null;
|
}
|
||||||
|
// Newer vLLM puts thinking in `reasoning` instead of `reasoning_content`.
|
||||||
|
const reasoning = message.additional_kwargs.reasoning;
|
||||||
|
if (typeof reasoning === "string") {
|
||||||
|
return reasoning;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (Array.isArray(message.content)) {
|
if (Array.isArray(message.content)) {
|
||||||
const part = message.content[0];
|
const part = message.content[0];
|
||||||
@@ -424,6 +429,9 @@ export function hasReasoning(message: Message) {
|
|||||||
if (typeof message.additional_kwargs?.reasoning_content === "string") {
|
if (typeof message.additional_kwargs?.reasoning_content === "string") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (typeof message.additional_kwargs?.reasoning === "string") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (Array.isArray(message.content)) {
|
if (Array.isArray(message.content)) {
|
||||||
const part = message.content[0];
|
const part = message.content[0];
|
||||||
// Compatible with the Anthropic gateway
|
// Compatible with the Anthropic gateway
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export interface Model {
|
|||||||
supports_thinking?: boolean;
|
supports_thinking?: boolean;
|
||||||
supports_reasoning_effort?: boolean;
|
supports_reasoning_effort?: boolean;
|
||||||
/** Execution profiles handled by the frontend instead of DeerFlow. */
|
/** Execution profiles handled by the frontend instead of DeerFlow. */
|
||||||
execution_profile?: "single_round_rag" | "multi_round_rag";
|
execution_profile?: "single_round_rag" | "multi_round_rag" | "direct";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TokenUsageSettings {
|
export interface TokenUsageSettings {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
FileBox,
|
FileBox,
|
||||||
FileSearch,
|
FileSearch,
|
||||||
FileText,
|
FileText,
|
||||||
|
FolderKanban,
|
||||||
Gauge,
|
Gauge,
|
||||||
Grid3X3,
|
Grid3X3,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
@@ -48,12 +49,23 @@ export type MenuItem = {
|
|||||||
path?: string;
|
path?: string;
|
||||||
/** 额外视为「当前页属于本菜单项」的路由(不在侧栏展示,仅用于高亮)。 */
|
/** 额外视为「当前页属于本菜单项」的路由(不在侧栏展示,仅用于高亮)。 */
|
||||||
aliasPaths?: string[];
|
aliasPaths?: string[];
|
||||||
|
/** 从当前菜单项自身的 path / aliasPaths 前缀匹配中排除的路由。 */
|
||||||
|
excludePaths?: string[];
|
||||||
/** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */
|
/** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */
|
||||||
userPath?: string;
|
userPath?: string;
|
||||||
adminOnly?: boolean;
|
adminOnly?: boolean;
|
||||||
children?: MenuItem[];
|
children?: MenuItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Markdown 转 Word 功能的菜单定义,供全量与简洁模式共用。 */
|
||||||
|
export const MARKDOWN_DOCX_MENU_PATH = "/page/workspace/knowledge/markdown-editor";
|
||||||
|
export const MARKDOWN_DOCX_MENU_ITEM: MenuItem & { path: string } = {
|
||||||
|
id: "resource-markdown-docx-editor",
|
||||||
|
label: "md转word",
|
||||||
|
icon: FileText,
|
||||||
|
path: MARKDOWN_DOCX_MENU_PATH,
|
||||||
|
};
|
||||||
|
|
||||||
export const sidebarMenuItems: MenuItem[] = [
|
export const sidebarMenuItems: MenuItem[] = [
|
||||||
{
|
{
|
||||||
id: "qa-center",
|
id: "qa-center",
|
||||||
@@ -68,6 +80,13 @@ export const sidebarMenuItems: MenuItem[] = [
|
|||||||
icon: Users,
|
icon: Users,
|
||||||
path: "/page/strategy/qa/multi-agent-roundtable",
|
path: "/page/strategy/qa/multi-agent-roundtable",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "strategy-projects",
|
||||||
|
label: "我的项目",
|
||||||
|
icon: FolderKanban,
|
||||||
|
path: "/page/strategy/qa/projects",
|
||||||
|
aliasPaths: ["/page/strategy/qa/strategy-selection"],
|
||||||
|
},
|
||||||
// {
|
// {
|
||||||
// id: "position-roundtable",
|
// id: "position-roundtable",
|
||||||
// label: "岗位会商",
|
// label: "岗位会商",
|
||||||
@@ -258,12 +277,7 @@ export const sidebarMenuItems: MenuItem[] = [
|
|||||||
},
|
},
|
||||||
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
|
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
|
||||||
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
|
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
|
||||||
{
|
MARKDOWN_DOCX_MENU_ITEM,
|
||||||
id: "resource-markdown-docx-editor",
|
|
||||||
label: "md转word",
|
|
||||||
icon: FileText,
|
|
||||||
path: "/page/workspace/knowledge/markdown-editor",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "mgmt-tasks",
|
id: "mgmt-tasks",
|
||||||
label: "定时任务",
|
label: "定时任务",
|
||||||
@@ -330,6 +344,7 @@ export const sidebarMenuItems: MenuItem[] = [
|
|||||||
label: "知识库管理",
|
label: "知识库管理",
|
||||||
icon: BookOpen,
|
icon: BookOpen,
|
||||||
path: "/page/workspace/knowledge",
|
path: "/page/workspace/knowledge",
|
||||||
|
excludePaths: [MARKDOWN_DOCX_MENU_PATH],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "resource-knowledge-base",
|
id: "resource-knowledge-base",
|
||||||
@@ -365,6 +380,9 @@ function routeMatchesPathname(pathname: string, route?: string): boolean {
|
|||||||
|
|
||||||
/** 菜单项自身 path / aliasPaths 是否匹配当前路由。 */
|
/** 菜单项自身 path / aliasPaths 是否匹配当前路由。 */
|
||||||
export function menuItemMatchesPath(item: MenuItem, pathname: string): boolean {
|
export function menuItemMatchesPath(item: MenuItem, pathname: string): boolean {
|
||||||
|
if (item.excludePaths?.some((path) => routeMatchesPathname(pathname, path))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (routeMatchesPathname(pathname, item.path)) return true;
|
if (routeMatchesPathname(pathname, item.path)) return true;
|
||||||
return item.aliasPaths?.some((p) => routeMatchesPathname(pathname, p)) ?? false;
|
return item.aliasPaths?.some((p) => routeMatchesPathname(pathname, p)) ?? false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -13,6 +13,44 @@ export type KnowledgeBaseProductVersion = "v1" | "v2";
|
|||||||
|
|
||||||
export type OrdinaryKnowledgeLanding = "wiki" | "weknora";
|
export type OrdinaryKnowledgeLanding = "wiki" | "weknora";
|
||||||
|
|
||||||
|
export interface StrategyProjectSettings {
|
||||||
|
taiwan_strait_demo_enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrategyProjectSettingsUpdate {
|
||||||
|
taiwan_strait_demo_enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadStrategyProjectSettings(): Promise<StrategyProjectSettings> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/system-settings/strategy-projects`,
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`无法读取策略项目设置(HTTP ${response.status})`);
|
||||||
|
}
|
||||||
|
return (await response.json()) as StrategyProjectSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateStrategyProjectSettings(
|
||||||
|
body: StrategyProjectSettingsUpdate,
|
||||||
|
): Promise<StrategyProjectSettings> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/system-settings/strategy-projects`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ detail: "保存策略项目设置失败" }));
|
||||||
|
throw new Error(detail.detail ?? "保存策略项目设置失败");
|
||||||
|
}
|
||||||
|
return (await response.json()) as StrategyProjectSettings;
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadOrdinaryKnowledgeLanding(): Promise<OrdinaryKnowledgeLanding> {
|
export async function loadOrdinaryKnowledgeLanding(): Promise<OrdinaryKnowledgeLanding> {
|
||||||
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`);
|
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`);
|
||||||
if (!response.ok) throw new Error("无法读取普通知识库默认页面设置");
|
if (!response.ok) throw new Error("无法读取普通知识库默认页面设置");
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
loadPromptPrefixSettings,
|
loadPromptPrefixSettings,
|
||||||
loadPublishSquares,
|
loadPublishSquares,
|
||||||
loadSkillCompressionSettings,
|
loadSkillCompressionSettings,
|
||||||
|
loadStrategyProjectSettings,
|
||||||
updateAgentModuleConfig,
|
updateAgentModuleConfig,
|
||||||
updateAppearanceSettings,
|
updateAppearanceSettings,
|
||||||
updateCitationDisplaySettings,
|
updateCitationDisplaySettings,
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
updatePromptPrefixSettings,
|
updatePromptPrefixSettings,
|
||||||
updatePublishSquares,
|
updatePublishSquares,
|
||||||
updateSkillCompressionSettings,
|
updateSkillCompressionSettings,
|
||||||
|
updateStrategyProjectSettings,
|
||||||
type AppearanceUpdate,
|
type AppearanceUpdate,
|
||||||
type CitationDisplayUpdate,
|
type CitationDisplayUpdate,
|
||||||
type LeaderboardSettingsUpdate,
|
type LeaderboardSettingsUpdate,
|
||||||
@@ -32,6 +34,7 @@ import {
|
|||||||
type PromptPrefixUpdate,
|
type PromptPrefixUpdate,
|
||||||
type PublishSquaresUpdate,
|
type PublishSquaresUpdate,
|
||||||
type SkillCompressionUpdate,
|
type SkillCompressionUpdate,
|
||||||
|
type StrategyProjectSettingsUpdate,
|
||||||
type LayoutMode,
|
type LayoutMode,
|
||||||
type ViewMode,
|
type ViewMode,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
@@ -45,6 +48,28 @@ const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
|
|||||||
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
|
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
|
||||||
const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] as const;
|
const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] as const;
|
||||||
const PLUGIN_MANAGEMENT_KEY = ["systemSettings", "pluginManagement"] as const;
|
const PLUGIN_MANAGEMENT_KEY = ["systemSettings", "pluginManagement"] as const;
|
||||||
|
const STRATEGY_PROJECTS_KEY = ["systemSettings", "strategyProjects"] as const;
|
||||||
|
|
||||||
|
export function useStrategyProjectSettings() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: STRATEGY_PROJECTS_KEY,
|
||||||
|
queryFn: () => loadStrategyProjectSettings(),
|
||||||
|
staleTime: 60_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateStrategyProjectSettings() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: StrategyProjectSettingsUpdate) =>
|
||||||
|
updateStrategyProjectSettings(body),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.setQueryData(STRATEGY_PROJECTS_KEY, data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function usePromptPrefixSettings() {
|
export function usePromptPrefixSettings() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|||||||
@@ -1579,6 +1579,7 @@ export function useThreadStream({
|
|||||||
context: {
|
context: {
|
||||||
...mergedContext,
|
...mergedContext,
|
||||||
thinking_enabled: mergedContext.mode !== "flash",
|
thinking_enabled: mergedContext.mode !== "flash",
|
||||||
|
thinking_force_disabled: mergedContext.mode === "flash",
|
||||||
is_plan_mode:
|
is_plan_mode:
|
||||||
mergedContext.mode === "pro" || mergedContext.mode === "ultra",
|
mergedContext.mode === "pro" || mergedContext.mode === "ultra",
|
||||||
subagent_enabled: mergedContext.mode === "ultra",
|
subagent_enabled: mergedContext.mode === "ultra",
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
myProjectConversationTypeFromSearchParams,
|
||||||
|
myProjectConversationTypeOfThread,
|
||||||
|
myProjectThreadMetadata,
|
||||||
|
withMyProjectConversationMarker,
|
||||||
|
} from "./project-metadata";
|
||||||
|
|
||||||
|
test("project conversation type is read from persisted metadata", () => {
|
||||||
|
assert.equal(
|
||||||
|
myProjectConversationTypeOfThread({
|
||||||
|
metadata: myProjectThreadMetadata("strategy"),
|
||||||
|
}),
|
||||||
|
"strategy",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
myProjectConversationTypeOfThread({
|
||||||
|
metadata: { project_source: "elsewhere", project_type: "qa" },
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("metadata wins over the compatibility context fallback", () => {
|
||||||
|
assert.equal(
|
||||||
|
myProjectConversationTypeOfThread({
|
||||||
|
metadata: myProjectThreadMetadata("solution"),
|
||||||
|
context: myProjectThreadMetadata("qa"),
|
||||||
|
}),
|
||||||
|
"solution",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("new-chat route marker round-trips all supported project types", () => {
|
||||||
|
for (const type of ["qa", "strategy", "solution"] as const) {
|
||||||
|
const path = withMyProjectConversationMarker(
|
||||||
|
"/page/strategy/chats/new?keep=1",
|
||||||
|
type,
|
||||||
|
);
|
||||||
|
const params = new URLSearchParams(path.split("?", 2)[1]);
|
||||||
|
assert.equal(params.get("keep"), "1");
|
||||||
|
assert.equal(myProjectConversationTypeFromSearchParams(params), type);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Stable marker for conversations entered from 「我的项目」.
|
||||||
|
*
|
||||||
|
* Keep this separate from ``project_id``: that key belongs to DeerFlow's
|
||||||
|
* folder-style conversation projects, while this marker describes the product
|
||||||
|
* mode that created (or explicitly continued) a conversation.
|
||||||
|
*/
|
||||||
|
export const MY_PROJECT_THREAD_SOURCE = "my-projects" as const;
|
||||||
|
|
||||||
|
export const MY_PROJECT_THREAD_SOURCE_KEY = "project_source" as const;
|
||||||
|
export const MY_PROJECT_THREAD_TYPE_KEY = "project_type" as const;
|
||||||
|
|
||||||
|
export const MY_PROJECT_QUERY_SOURCE_KEY = "projectSource" as const;
|
||||||
|
export const MY_PROJECT_QUERY_TYPE_KEY = "projectType" as const;
|
||||||
|
|
||||||
|
export type MyProjectConversationType = "qa" | "strategy" | "solution";
|
||||||
|
|
||||||
|
export const MY_PROJECT_CONVERSATION_LABELS: Record<
|
||||||
|
MyProjectConversationType,
|
||||||
|
string
|
||||||
|
> = {
|
||||||
|
qa: "普通问答",
|
||||||
|
strategy: "策略优选",
|
||||||
|
solution: "方案生成",
|
||||||
|
};
|
||||||
|
|
||||||
|
const MY_PROJECT_CONVERSATION_TYPES = new Set<MyProjectConversationType>([
|
||||||
|
"qa",
|
||||||
|
"strategy",
|
||||||
|
"solution",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function typeFromRecord(
|
||||||
|
value: Record<string, unknown> | null | undefined,
|
||||||
|
): MyProjectConversationType | null {
|
||||||
|
if (value?.[MY_PROJECT_THREAD_SOURCE_KEY] !== MY_PROJECT_THREAD_SOURCE) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const type = value[MY_PROJECT_THREAD_TYPE_KEY];
|
||||||
|
return typeof type === "string" &&
|
||||||
|
MY_PROJECT_CONVERSATION_TYPES.has(type as MyProjectConversationType)
|
||||||
|
? (type as MyProjectConversationType)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function myProjectThreadMetadata(
|
||||||
|
type: MyProjectConversationType,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
[MY_PROJECT_THREAD_SOURCE_KEY]: MY_PROJECT_THREAD_SOURCE,
|
||||||
|
[MY_PROJECT_THREAD_TYPE_KEY]: type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read persisted metadata first; context is only a compatibility fallback. */
|
||||||
|
export function myProjectConversationTypeOfThread(thread: {
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
context?: Record<string, unknown> | null;
|
||||||
|
}): MyProjectConversationType | null {
|
||||||
|
return typeFromRecord(thread.metadata) ?? typeFromRecord(thread.context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function myProjectConversationTypeFromSearchParams(params: {
|
||||||
|
get(name: string): string | null;
|
||||||
|
}): MyProjectConversationType | null {
|
||||||
|
if (params.get(MY_PROJECT_QUERY_SOURCE_KEY) !== MY_PROJECT_THREAD_SOURCE) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const type = params.get(MY_PROJECT_QUERY_TYPE_KEY);
|
||||||
|
return typeof type === "string" &&
|
||||||
|
MY_PROJECT_CONVERSATION_TYPES.has(type as MyProjectConversationType)
|
||||||
|
? (type as MyProjectConversationType)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add an explicit, one-shot marker to a new-conversation route. */
|
||||||
|
export function withMyProjectConversationMarker(
|
||||||
|
path: string,
|
||||||
|
type: MyProjectConversationType,
|
||||||
|
): string {
|
||||||
|
const hashIndex = path.indexOf("#");
|
||||||
|
const hash = hashIndex >= 0 ? path.slice(hashIndex) : "";
|
||||||
|
const pathWithoutHash = hashIndex >= 0 ? path.slice(0, hashIndex) : path;
|
||||||
|
const queryIndex = pathWithoutHash.indexOf("?");
|
||||||
|
const pathname =
|
||||||
|
queryIndex >= 0 ? pathWithoutHash.slice(0, queryIndex) : pathWithoutHash;
|
||||||
|
const params = new URLSearchParams(
|
||||||
|
queryIndex >= 0 ? pathWithoutHash.slice(queryIndex + 1) : "",
|
||||||
|
);
|
||||||
|
params.set(MY_PROJECT_QUERY_SOURCE_KEY, MY_PROJECT_THREAD_SOURCE);
|
||||||
|
params.set(MY_PROJECT_QUERY_TYPE_KEY, type);
|
||||||
|
const search = params.toString();
|
||||||
|
return `${pathname}${search ? `?${search}` : ""}${hash}`;
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
|||||||
thread_id: string;
|
thread_id: string;
|
||||||
model_name: string | undefined;
|
model_name: string | undefined;
|
||||||
thinking_enabled: boolean;
|
thinking_enabled: boolean;
|
||||||
|
/** Flash mode: force vLLM/Qwen ``enable_thinking=false`` even without a per-model disable shape. */
|
||||||
|
thinking_force_disabled?: boolean;
|
||||||
is_plan_mode: boolean;
|
is_plan_mode: boolean;
|
||||||
subagent_enabled: boolean;
|
subagent_enabled: boolean;
|
||||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||||
@@ -29,8 +31,8 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
|||||||
llmwiki_knowledge_base_ids?: string[];
|
llmwiki_knowledge_base_ids?: string[];
|
||||||
/** Assistant knowledge-base ids selected for this conversation. */
|
/** Assistant knowledge-base ids selected for this conversation. */
|
||||||
assistant_knowledge_base_ids?: string[];
|
assistant_knowledge_base_ids?: string[];
|
||||||
/** Direct RAG modes handled in the frontend; these requests never enter Lead Agent. */
|
/** Frontend-only execution paths that never enter Lead Agent (RAG / 极速直连). */
|
||||||
execution_profile?: "single_round_rag" | "multi_round_rag";
|
execution_profile?: "single_round_rag" | "multi_round_rag" | "direct";
|
||||||
agent_id?: string;
|
agent_id?: string;
|
||||||
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
|
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
|
||||||
agent_name?: string;
|
agent_name?: string;
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import type { Message } from "@langchain/langgraph-sdk";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { apiFetch } from "@/core/api/fetch-client";
|
||||||
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
import { readRuntimeFlag } from "@/env";
|
||||||
|
import { createClientId } from "@/lib/client-id";
|
||||||
|
|
||||||
|
import { textOfMessage } from "./utils";
|
||||||
|
|
||||||
|
const STORAGE_PREFIX = "workspace.direct-chat.";
|
||||||
|
const MAX_HISTORY_MESSAGES = 20;
|
||||||
|
|
||||||
|
export function isDirectChatProfile(context: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof context === "object" &&
|
||||||
|
context !== null &&
|
||||||
|
(context as { execution_profile?: unknown }).execution_profile === "direct"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否在模式菜单展示「极速」。仅 runtime-config.js 显式为 "true" 时开启,默认关闭。 */
|
||||||
|
export function isTurboModeVisible(): boolean {
|
||||||
|
return readRuntimeFlag("VITE_TURBO_MODE_VISIBLE");
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageKey(threadId: string) {
|
||||||
|
return `${STORAGE_PREFIX}${threadId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredMessages(threadId: string): Message[] {
|
||||||
|
if (typeof window === "undefined" || !threadId) return [];
|
||||||
|
try {
|
||||||
|
const raw = window.sessionStorage.getItem(storageKey(threadId));
|
||||||
|
const parsed: unknown = raw ? JSON.parse(raw) : [];
|
||||||
|
return Array.isArray(parsed) ? (parsed as Message[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStoredMessages(threadId: string, messages: Message[]) {
|
||||||
|
if (typeof window === "undefined" || !threadId) return;
|
||||||
|
try {
|
||||||
|
window.sessionStorage.setItem(storageKey(threadId), JSON.stringify(messages));
|
||||||
|
} catch {
|
||||||
|
// Quota errors must never interrupt an already-running answer.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbortError(error: unknown): boolean {
|
||||||
|
return error instanceof DOMException
|
||||||
|
? error.name === "AbortError"
|
||||||
|
: error instanceof Error && error.name === "AbortError";
|
||||||
|
}
|
||||||
|
|
||||||
|
function historyFromMessages(messages: Message[]): Array<{
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
}> {
|
||||||
|
const history: Array<{ role: "user" | "assistant"; content: string }> = [];
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.type !== "human" && message.type !== "ai") continue;
|
||||||
|
const content = textOfMessage(message)?.trim();
|
||||||
|
if (!content) continue;
|
||||||
|
history.push({
|
||||||
|
role: message.type === "human" ? "user" : "assistant",
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return history.slice(-MAX_HISTORY_MESSAGES);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSseBlocks(buffer: string): {
|
||||||
|
events: Array<{ event?: string; data: string }>;
|
||||||
|
rest: string;
|
||||||
|
} {
|
||||||
|
const parts = buffer.split("\n\n");
|
||||||
|
const rest = parts.pop() ?? "";
|
||||||
|
const events: Array<{ event?: string; data: string }> = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
let event: string | undefined;
|
||||||
|
const dataLines: string[] = [];
|
||||||
|
for (const line of part.split("\n")) {
|
||||||
|
if (line.startsWith(":")) continue;
|
||||||
|
if (line.startsWith("event:")) event = line.slice(6).trim();
|
||||||
|
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
|
||||||
|
}
|
||||||
|
if (dataLines.length > 0) {
|
||||||
|
events.push({ event, data: dataLines.join("\n") });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { events, rest };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readErrorDetail(response: Response): Promise<string> {
|
||||||
|
try {
|
||||||
|
const payload: unknown = await response.json();
|
||||||
|
if (
|
||||||
|
payload &&
|
||||||
|
typeof payload === "object" &&
|
||||||
|
"detail" in payload &&
|
||||||
|
typeof (payload as { detail: unknown }).detail === "string"
|
||||||
|
) {
|
||||||
|
return (payload as { detail: string }).detail;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to status text.
|
||||||
|
}
|
||||||
|
return `极速问答失败(${response.status})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frontend-only adapter that talks to Gateway ``POST /api/direct-chat/stream``.
|
||||||
|
* It never submits a LangGraph run or touches the lead-agent stack.
|
||||||
|
*/
|
||||||
|
export function useDirectChatMessages(threadId: string | undefined) {
|
||||||
|
const [messages, setMessages] = useState<Message[]>(() =>
|
||||||
|
readStoredMessages(threadId ?? ""),
|
||||||
|
);
|
||||||
|
const messagesRef = useRef(messages);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const controllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = readStoredMessages(threadId ?? "");
|
||||||
|
messagesRef.current = stored;
|
||||||
|
setMessages(stored);
|
||||||
|
setError(null);
|
||||||
|
}, [threadId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messagesRef.current = messages;
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
useEffect(() => () => controllerRef.current?.abort(), []);
|
||||||
|
|
||||||
|
const updateMessages = useCallback(
|
||||||
|
(targetThreadId: string, updater: (current: Message[]) => Message[]) => {
|
||||||
|
setMessages((current) => {
|
||||||
|
const next = updater(current);
|
||||||
|
messagesRef.current = next;
|
||||||
|
writeStoredMessages(targetThreadId, next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stop = useCallback(() => controllerRef.current?.abort(), []);
|
||||||
|
|
||||||
|
const submit = useCallback(
|
||||||
|
async ({
|
||||||
|
targetThreadId,
|
||||||
|
query,
|
||||||
|
modelName,
|
||||||
|
}: {
|
||||||
|
targetThreadId: string;
|
||||||
|
query: string;
|
||||||
|
modelName?: string;
|
||||||
|
}) => {
|
||||||
|
if (isLoading) return;
|
||||||
|
const question = query.trim();
|
||||||
|
if (!question) return;
|
||||||
|
|
||||||
|
const humanId = createClientId("direct-human");
|
||||||
|
const assistantId = createClientId("direct-ai");
|
||||||
|
const clientTs = new Date().toISOString();
|
||||||
|
const history = historyFromMessages(messagesRef.current);
|
||||||
|
const userMessage: Message = {
|
||||||
|
id: humanId,
|
||||||
|
type: "human",
|
||||||
|
content: [{ type: "text", text: question }],
|
||||||
|
additional_kwargs: { client_ts: clientTs, direct_chat: true },
|
||||||
|
};
|
||||||
|
const initialAssistant: Message = {
|
||||||
|
id: assistantId,
|
||||||
|
type: "ai",
|
||||||
|
content: "",
|
||||||
|
additional_kwargs: { direct_chat: true },
|
||||||
|
};
|
||||||
|
updateMessages(targetThreadId, (current) => [
|
||||||
|
...current,
|
||||||
|
userMessage,
|
||||||
|
initialAssistant,
|
||||||
|
]);
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
controllerRef.current = controller;
|
||||||
|
let answer = "";
|
||||||
|
|
||||||
|
const refreshAssistant = (content: string) => {
|
||||||
|
updateMessages(targetThreadId, (current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.id === assistantId
|
||||||
|
? ({
|
||||||
|
...item,
|
||||||
|
content,
|
||||||
|
additional_kwargs: {
|
||||||
|
...item.additional_kwargs,
|
||||||
|
direct_chat: true,
|
||||||
|
},
|
||||||
|
} as Message)
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/direct-chat/stream`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
signal: controller.signal,
|
||||||
|
body: JSON.stringify({
|
||||||
|
model_name: modelName,
|
||||||
|
messages: [...history, { role: "user", content: question }],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorDetail(response));
|
||||||
|
}
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error("极速问答未返回数据流。");
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const parsed = parseSseBlocks(buffer);
|
||||||
|
buffer = parsed.rest;
|
||||||
|
for (const block of parsed.events) {
|
||||||
|
let payload: unknown = null;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(block.data);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (block.event === "error") {
|
||||||
|
const detail =
|
||||||
|
payload &&
|
||||||
|
typeof payload === "object" &&
|
||||||
|
"detail" in payload &&
|
||||||
|
typeof (payload as { detail: unknown }).detail === "string"
|
||||||
|
? (payload as { detail: string }).detail
|
||||||
|
: "极速问答失败。";
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
block.event === "delta" &&
|
||||||
|
payload &&
|
||||||
|
typeof payload === "object" &&
|
||||||
|
"text" in payload &&
|
||||||
|
typeof (payload as { text: unknown }).text === "string"
|
||||||
|
) {
|
||||||
|
answer += (payload as { text: string }).text;
|
||||||
|
refreshAssistant(answer);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
block.event === "result" &&
|
||||||
|
payload &&
|
||||||
|
typeof payload === "object" &&
|
||||||
|
"answer" in payload &&
|
||||||
|
typeof (payload as { answer: unknown }).answer === "string"
|
||||||
|
) {
|
||||||
|
answer = (payload as { answer: string }).answer;
|
||||||
|
refreshAssistant(answer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!answer) {
|
||||||
|
throw new Error("模型未返回任何文本。");
|
||||||
|
}
|
||||||
|
} catch (caught) {
|
||||||
|
if (!isAbortError(caught)) {
|
||||||
|
const message =
|
||||||
|
caught instanceof Error ? caught.message : "极速问答失败。";
|
||||||
|
setError(message);
|
||||||
|
updateMessages(targetThreadId, (current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.id === assistantId
|
||||||
|
? ({
|
||||||
|
...item,
|
||||||
|
content: answer || message,
|
||||||
|
additional_kwargs: {
|
||||||
|
...item.additional_kwargs,
|
||||||
|
direct_chat: true,
|
||||||
|
},
|
||||||
|
} as Message)
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (controllerRef.current === controller) controllerRef.current = null;
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isLoading, updateMessages],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { messages, isLoading, error, submit, stop };
|
||||||
|
}
|
||||||
@@ -116,6 +116,14 @@ export interface EightBfTaskProcessReportReceipt {
|
|||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 外部任务系统要求 8BF 会商页提交的过程产物携带 eightTask=1,
|
||||||
|
* 用于与岗位会商等其它入口的产物区分来源。
|
||||||
|
*/
|
||||||
|
function processReportBody(input: EightBfTaskProcessReportInput): string {
|
||||||
|
return JSON.stringify({ ...input, eightTask: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用
|
* 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用
|
||||||
* `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。
|
* `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。
|
||||||
@@ -180,7 +188,7 @@ export async function createEightBfTaskProcessReport(
|
|||||||
const response = await fetchExternalTaskSystem(PROCESS_REPORT_ADD_PATH, {
|
const response = await fetchExternalTaskSystem(PROCESS_REPORT_ADD_PATH, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(input),
|
body: processReportBody(input),
|
||||||
});
|
});
|
||||||
if (!response.ok) throw await externalTaskError(response, "过程产物登记失败");
|
if (!response.ok) throw await externalTaskError(response, "过程产物登记失败");
|
||||||
const id = findProcessReportId(await response.json().catch(() => null));
|
const id = findProcessReportId(await response.json().catch(() => null));
|
||||||
@@ -198,7 +206,7 @@ export async function updateEightBfTaskProcessReport(
|
|||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(input),
|
body: processReportBody(input),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!response.ok) throw await externalTaskError(response, "过程产物提交失败");
|
if (!response.ok) throw await externalTaskError(response, "过程产物提交失败");
|
||||||
|
|||||||
@@ -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],
|
||||||
@@ -84,14 +88,29 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
|||||||
() => buildEightBfTaskContext(task, taskDictionaries),
|
() => buildEightBfTaskContext(task, taskDictionaries),
|
||||||
[task, taskDictionaries],
|
[task, taskDictionaries],
|
||||||
);
|
);
|
||||||
|
// 席位「产物提交 position」映射:业务链里逐席位配置的 artifact_position(agent_id → 值)。
|
||||||
|
// 提交产物时按产出该产物的席位智能体取值;未配置的席位不进 map(提交时回退默认 0)。
|
||||||
|
const artifactPositionByAgent = useMemo(() => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const seat of configuredChain?.seats ?? []) {
|
||||||
|
if (typeof seat.artifact_position === "number" && Number.isFinite(seat.artifact_position)) {
|
||||||
|
map.set(seat.agent_id, seat.artifact_position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [configuredChain]);
|
||||||
const taskArtifactSubmissionConfig = useMemo<TaskArtifactSubmissionConfig | undefined>(
|
const taskArtifactSubmissionConfig = useMemo<TaskArtifactSubmissionConfig | undefined>(
|
||||||
() => taskId ? {
|
() => taskId ? {
|
||||||
taskId,
|
taskId,
|
||||||
position: 0,
|
position: 0,
|
||||||
|
positionFor: (target) => {
|
||||||
|
const agentId = target.agentId?.trim();
|
||||||
|
return agentId ? artifactPositionByAgent.get(agentId) ?? null : null;
|
||||||
|
},
|
||||||
createProcessReport: createEightBfTaskProcessReport,
|
createProcessReport: createEightBfTaskProcessReport,
|
||||||
updateProcessReport: updateEightBfTaskProcessReport,
|
updateProcessReport: updateEightBfTaskProcessReport,
|
||||||
} : undefined,
|
} : undefined,
|
||||||
[taskId],
|
[taskId, artifactPositionByAgent],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -260,6 +279,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
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useLocation } from "react-router-dom";
|
|||||||
import { useRouteBase } from "@/core/route-base";
|
import { useRouteBase } from "@/core/route-base";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -48,6 +49,7 @@ import { useNotification } from "@/core/notification/hooks";
|
|||||||
import { saveLastAgentId, useThreadSettings } from "@/core/settings";
|
import { saveLastAgentId, useThreadSettings } from "@/core/settings";
|
||||||
import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStream } from "@/core/threads/hooks";
|
import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStream } from "@/core/threads/hooks";
|
||||||
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
|
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
|
||||||
|
import { useDirectChatMessages, isDirectChatProfile, isTurboModeVisible } from "@/core/threads/use-direct-chat";
|
||||||
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
|
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
|
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
|
||||||
@@ -560,9 +562,26 @@ export default function AgentChatPage({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const directChat = useDirectChatMessages(threadId);
|
||||||
|
const directChatSelected =
|
||||||
|
isDirectChatProfile(settings.context) && isTurboModeVisible();
|
||||||
|
|
||||||
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
|
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
|
||||||
// 流式 stub),成功且真实产物落地后自动撤出。
|
// 流式 stub),成功且真实产物落地后自动撤出。
|
||||||
const { displayThread } = useResearchWritingFeed(threadId, thread);
|
const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread);
|
||||||
|
const displayThread = useMemo(() => {
|
||||||
|
const base = researchDisplayThread;
|
||||||
|
if (directChat.messages.length === 0 && !directChat.isLoading) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
const messages = [...base.messages, ...directChat.messages];
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
values: { ...base.values, messages },
|
||||||
|
messages,
|
||||||
|
isLoading: base.isLoading || directChat.isLoading,
|
||||||
|
} as typeof base;
|
||||||
|
}, [directChat.isLoading, directChat.messages, researchDisplayThread]);
|
||||||
|
|
||||||
// ── agentfx「入库」协助卡 ──────────────────────────────────────────────────
|
// ── agentfx「入库」协助卡 ──────────────────────────────────────────────────
|
||||||
// 任务工作区(taskBind)里,内置研判智能体写出的 task-reports.json 产物落地且
|
// 任务工作区(taskBind)里,内置研判智能体写出的 task-reports.json 产物落地且
|
||||||
@@ -743,6 +762,62 @@ export default function AgentChatPage({
|
|||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(message: PromptInputMessage, extraContext?: Record<string, unknown>) => {
|
(message: PromptInputMessage, extraContext?: Record<string, unknown>) => {
|
||||||
|
if (directChatSelected) {
|
||||||
|
if (message.files && message.files.length > 0) {
|
||||||
|
toast.error("极速问答暂不支持附件,请移除附件后重试。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHasStartedConversation(true);
|
||||||
|
saveLastAgentId(agent_id);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||||
|
);
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
let targetThreadId = threadId;
|
||||||
|
if (isNewThread) {
|
||||||
|
const created = await getAPIClient().threads.create({
|
||||||
|
metadata: {
|
||||||
|
agent_id,
|
||||||
|
...(Object.keys(conversationMetadata).length > 0
|
||||||
|
? conversationMetadata
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
targetThreadId = created.thread_id;
|
||||||
|
knowledgeSelectionThread.current = targetThreadId;
|
||||||
|
activeThreadIdRef.current = targetThreadId;
|
||||||
|
setThreadId(targetThreadId);
|
||||||
|
setIsNewThread(false);
|
||||||
|
history.replaceState(
|
||||||
|
null,
|
||||||
|
"",
|
||||||
|
`${window.location.pathname}${window.location.search}#${agentChatsBase}/${targetThreadId}`,
|
||||||
|
);
|
||||||
|
const title = message.text.trim().replace(/\s+/g, " ").slice(0, 60);
|
||||||
|
if (title) {
|
||||||
|
void getAPIClient().threads
|
||||||
|
.updateState(targetThreadId, { values: { title } })
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!targetThreadId) {
|
||||||
|
toast.error("无法创建会话。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await directChat.submit({
|
||||||
|
targetThreadId,
|
||||||
|
query: message.text,
|
||||||
|
modelName: settings.context.model_name,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "极速问答失败。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setHasStartedConversation(true);
|
setHasStartedConversation(true);
|
||||||
// Remember this agent so the new-chat landing screen pre-selects it next
|
// Remember this agent so the new-chat landing screen pre-selects it next
|
||||||
// time (covers both direct agent chats and the ChatPage handoff replay).
|
// time (covers both direct agent chats and the ChatPage handoff replay).
|
||||||
@@ -767,7 +842,7 @@ export default function AgentChatPage({
|
|||||||
...pageReportThemeContext,
|
...pageReportThemeContext,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[sendMessage, threadId, agent_id, settings.context.writing_mode, writingArtifactPath, baseMode],
|
[sendMessage, threadId, agent_id, settings.context.writing_mode, settings.context.model_name, writingArtifactPath, baseMode, directChat, directChatSelected, isNewThread, conversationMetadata, agentChatsBase, setThreadId, setIsNewThread],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。
|
// 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。
|
||||||
@@ -852,8 +927,12 @@ export default function AgentChatPage({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleStop = useCallback(async () => {
|
const handleStop = useCallback(async () => {
|
||||||
|
if (directChat.isLoading) {
|
||||||
|
directChat.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
await thread.stop();
|
await thread.stop();
|
||||||
}, [thread]);
|
}, [directChat, thread]);
|
||||||
|
|
||||||
const isLandingState = isNewThread && !hasStartedConversation;
|
const isLandingState = isNewThread && !hasStartedConversation;
|
||||||
// 任务闸门面板:仅 rwfx(taskGate)在新建对话且尚未开始时,于问答页顶部展示目的树 + 覆盖确认。
|
// 任务闸门面板:仅 rwfx(taskGate)在新建对话且尚未开始时,于问答页顶部展示目的树 + 覆盖确认。
|
||||||
@@ -867,7 +946,7 @@ export default function AgentChatPage({
|
|||||||
|
|
||||||
const status = thread.error
|
const status = thread.error
|
||||||
? "error"
|
? "error"
|
||||||
: thread.isLoading
|
: thread.isLoading || directChat.isLoading
|
||||||
? "streaming"
|
? "streaming"
|
||||||
: "ready";
|
: "ready";
|
||||||
|
|
||||||
@@ -1084,7 +1163,7 @@ export default function AgentChatPage({
|
|||||||
autoFocus
|
autoFocus
|
||||||
status={status}
|
status={status}
|
||||||
context={settings.context}
|
context={settings.context}
|
||||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
|
||||||
onContextChange={(context) =>
|
onContextChange={(context) =>
|
||||||
setSettings("context", context)
|
setSettings("context", context)
|
||||||
}
|
}
|
||||||
@@ -1104,7 +1183,7 @@ export default function AgentChatPage({
|
|||||||
pendingKnowledgeSelection.current === null &&
|
pendingKnowledgeSelection.current === null &&
|
||||||
pendingAssistantKnowledgeSelection.current === null
|
pendingAssistantKnowledgeSelection.current === null
|
||||||
}
|
}
|
||||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
|
||||||
label="知识空间"
|
label="知识空间"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -1175,7 +1254,7 @@ export default function AgentChatPage({
|
|||||||
assistantId={agent_id}
|
assistantId={agent_id}
|
||||||
status={status}
|
status={status}
|
||||||
context={settings.context}
|
context={settings.context}
|
||||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
|
||||||
onContextChange={(context) =>
|
onContextChange={(context) =>
|
||||||
setSettings("context", context)
|
setSettings("context", context)
|
||||||
}
|
}
|
||||||
@@ -1190,7 +1269,7 @@ export default function AgentChatPage({
|
|||||||
assistantValue={selectedAssistantKnowledgeBaseIds}
|
assistantValue={selectedAssistantKnowledgeBaseIds}
|
||||||
onChange={changeKnowledgeSelection}
|
onChange={changeKnowledgeSelection}
|
||||||
onAssistantChange={changeAssistantKnowledgeSelection}
|
onAssistantChange={changeAssistantKnowledgeSelection}
|
||||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
|
||||||
readOnly
|
readOnly
|
||||||
label="知识空间"
|
label="知识空间"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -64,11 +64,16 @@ import {
|
|||||||
useThreadSettings,
|
useThreadSettings,
|
||||||
} from "@/core/settings";
|
} from "@/core/settings";
|
||||||
import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
|
import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
|
||||||
|
import {
|
||||||
|
myProjectConversationTypeFromSearchParams,
|
||||||
|
myProjectThreadMetadata,
|
||||||
|
} from "@/core/threads/project-metadata";
|
||||||
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
|
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
|
||||||
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
|
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
|
||||||
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
|
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
|
||||||
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
|
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
|
||||||
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
|
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
|
||||||
|
import { useDirectChatMessages, isDirectChatProfile, isTurboModeVisible } from "@/core/threads/use-direct-chat";
|
||||||
import { useMultiRoundRagMessages, mergeMessagesByTime, toLangGraphMessages } from "@/core/threads/use-multi-round-rag";
|
import { useMultiRoundRagMessages, mergeMessagesByTime, toLangGraphMessages } from "@/core/threads/use-multi-round-rag";
|
||||||
import { useLayoutMode } from "@/core/system-settings/hooks";
|
import { useLayoutMode } from "@/core/system-settings/hooks";
|
||||||
import { useRouteBase } from "@/core/route-base";
|
import { useRouteBase } from "@/core/route-base";
|
||||||
@@ -285,16 +290,28 @@ export default function ChatPage() {
|
|||||||
}, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
|
}, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
|
||||||
|
|
||||||
const { showNotification } = useNotification();
|
const { showNotification } = useNotification();
|
||||||
|
const projectConversationType =
|
||||||
|
myProjectConversationTypeFromSearchParams(searchParams);
|
||||||
|
const newThreadMetadata = useMemo<Record<string, unknown> | undefined>(() => {
|
||||||
|
const metadata: Record<string, unknown> = projectConversationType
|
||||||
|
? myProjectThreadMetadata(projectConversationType)
|
||||||
|
: {};
|
||||||
|
if (knowledgeSelectionReady) {
|
||||||
|
metadata.llmwiki_knowledge_base_ids = selectedKnowledgeBaseIds;
|
||||||
|
metadata.assistant_knowledge_base_ids = selectedAssistantKnowledgeBaseIds;
|
||||||
|
}
|
||||||
|
return Object.keys(metadata).length > 0 ? metadata : undefined;
|
||||||
|
}, [
|
||||||
|
knowledgeSelectionReady,
|
||||||
|
projectConversationType,
|
||||||
|
selectedAssistantKnowledgeBaseIds,
|
||||||
|
selectedKnowledgeBaseIds,
|
||||||
|
]);
|
||||||
|
|
||||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||||
threadId: isNewThread ? undefined : threadId,
|
threadId: isNewThread ? undefined : threadId,
|
||||||
context: streamContext,
|
context: streamContext,
|
||||||
threadMetadata: knowledgeSelectionReady
|
threadMetadata: newThreadMetadata,
|
||||||
? {
|
|
||||||
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
|
|
||||||
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
isMock,
|
isMock,
|
||||||
onStart: (createdThreadId) => {
|
onStart: (createdThreadId) => {
|
||||||
knowledgeSelectionThread.current = createdThreadId;
|
knowledgeSelectionThread.current = createdThreadId;
|
||||||
@@ -348,9 +365,12 @@ export default function ChatPage() {
|
|||||||
});
|
});
|
||||||
const singleRoundRag = useSingleRoundRagMessages(threadId);
|
const singleRoundRag = useSingleRoundRagMessages(threadId);
|
||||||
const multiRoundRag = useMultiRoundRagMessages(threadId);
|
const multiRoundRag = useMultiRoundRagMessages(threadId);
|
||||||
|
const directChat = useDirectChatMessages(threadId);
|
||||||
const singleRoundRagSelected = isSingleRoundRagProfile(settings.context);
|
const singleRoundRagSelected = isSingleRoundRagProfile(settings.context);
|
||||||
const multiRoundRagSelected = isMultiRoundRagProfile(settings.context);
|
const multiRoundRagSelected = isMultiRoundRagProfile(settings.context);
|
||||||
const frontendRagSelected = isFrontendRagProfile(settings.context);
|
const frontendRagSelected = isFrontendRagProfile(settings.context);
|
||||||
|
const directChatSelected =
|
||||||
|
isDirectChatProfile(settings.context) && isTurboModeVisible();
|
||||||
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
|
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
|
||||||
// 流式 stub),成功且真实产物落地后自动撤出。
|
// 流式 stub),成功且真实产物落地后自动撤出。
|
||||||
const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread);
|
const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread);
|
||||||
@@ -360,13 +380,15 @@ export default function ChatPage() {
|
|||||||
if (
|
if (
|
||||||
singleRoundRag.messages.length === 0 &&
|
singleRoundRag.messages.length === 0 &&
|
||||||
ragMessages.length === 0 &&
|
ragMessages.length === 0 &&
|
||||||
|
directChat.messages.length === 0 &&
|
||||||
!singleRoundRag.isLoading &&
|
!singleRoundRag.isLoading &&
|
||||||
!multiRoundRag.isLoading
|
!multiRoundRag.isLoading &&
|
||||||
|
!directChat.isLoading
|
||||||
) {
|
) {
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
const messages = mergeMessagesByTime(
|
const messages = mergeMessagesByTime(
|
||||||
[...base.messages, ...singleRoundRag.messages],
|
[...base.messages, ...singleRoundRag.messages, ...directChat.messages],
|
||||||
ragMessages,
|
ragMessages,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -374,9 +396,14 @@ export default function ChatPage() {
|
|||||||
values: { ...base.values, messages },
|
values: { ...base.values, messages },
|
||||||
messages,
|
messages,
|
||||||
isLoading:
|
isLoading:
|
||||||
base.isLoading || singleRoundRag.isLoading || multiRoundRag.isLoading,
|
base.isLoading ||
|
||||||
|
singleRoundRag.isLoading ||
|
||||||
|
multiRoundRag.isLoading ||
|
||||||
|
directChat.isLoading,
|
||||||
} as typeof base;
|
} as typeof base;
|
||||||
}, [
|
}, [
|
||||||
|
directChat.isLoading,
|
||||||
|
directChat.messages,
|
||||||
multiRoundRag.isLoading,
|
multiRoundRag.isLoading,
|
||||||
multiRoundRag.messages,
|
multiRoundRag.messages,
|
||||||
researchDisplayThread,
|
researchDisplayThread,
|
||||||
@@ -392,6 +419,52 @@ export default function ChatPage() {
|
|||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(message: PromptInputMessage) => {
|
(message: PromptInputMessage) => {
|
||||||
|
if (directChatSelected) {
|
||||||
|
if (message.files && message.files.length > 0) {
|
||||||
|
toast.error("极速问答暂不支持附件,请移除附件后重试。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHasStartedConversation(true);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||||
|
);
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
let targetThreadId = threadId;
|
||||||
|
if (isNewThread) {
|
||||||
|
const created = await getAPIClient(isMock).threads.create({});
|
||||||
|
targetThreadId = created.thread_id;
|
||||||
|
setThreadId(targetThreadId);
|
||||||
|
setIsNewThread(false);
|
||||||
|
history.replaceState(
|
||||||
|
null,
|
||||||
|
"",
|
||||||
|
`${window.location.pathname}${window.location.search}#${routeBase}/chats/${targetThreadId}`,
|
||||||
|
);
|
||||||
|
const title = message.text.trim().replace(/\s+/g, " ").slice(0, 60);
|
||||||
|
if (title) {
|
||||||
|
void getAPIClient(isMock).threads
|
||||||
|
.updateState(targetThreadId, { values: { title } })
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!targetThreadId) {
|
||||||
|
toast.error("无法创建会话。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await directChat.submit({
|
||||||
|
targetThreadId,
|
||||||
|
query: message.text,
|
||||||
|
modelName: settings.context.model_name,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "极速问答失败。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (frontendRagSelected) {
|
if (frontendRagSelected) {
|
||||||
if (message.files && message.files.length > 0) {
|
if (message.files && message.files.length > 0) {
|
||||||
toast.error("RAG 问答暂不支持附件,请移除附件后重试。");
|
toast.error("RAG 问答暂不支持附件,请移除附件后重试。");
|
||||||
@@ -405,7 +478,9 @@ export default function ChatPage() {
|
|||||||
try {
|
try {
|
||||||
let targetThreadId = threadId;
|
let targetThreadId = threadId;
|
||||||
if (isNewThread) {
|
if (isNewThread) {
|
||||||
const created = await getAPIClient(isMock).threads.create({});
|
const created = await getAPIClient(isMock).threads.create({
|
||||||
|
metadata: newThreadMetadata ?? {},
|
||||||
|
});
|
||||||
targetThreadId = created.thread_id;
|
targetThreadId = created.thread_id;
|
||||||
setThreadId(targetThreadId);
|
setThreadId(targetThreadId);
|
||||||
setIsNewThread(false);
|
setIsNewThread(false);
|
||||||
@@ -465,13 +540,8 @@ export default function ChatPage() {
|
|||||||
try {
|
try {
|
||||||
const created = await getAPIClient(isMock).threads.create({
|
const created = await getAPIClient(isMock).threads.create({
|
||||||
metadata: {
|
metadata: {
|
||||||
|
...newThreadMetadata,
|
||||||
agent_id: selectedAgentId,
|
agent_id: selectedAgentId,
|
||||||
...(knowledgeSelectionReady
|
|
||||||
? {
|
|
||||||
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
|
|
||||||
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
sessionStorage.setItem(
|
sessionStorage.setItem(
|
||||||
@@ -490,7 +560,7 @@ export default function ChatPage() {
|
|||||||
);
|
);
|
||||||
saveLastAgentId(selectedAgentId);
|
saveLastAgentId(selectedAgentId);
|
||||||
router.push(
|
router.push(
|
||||||
`/page/workspace/agents/${selectedAgentId}/chats/${created.thread_id}`,
|
`${routeBase}/agents/${encodeURIComponent(selectedAgentId)}/chats/${encodeURIComponent(created.thread_id)}`,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
agentHandoffPendingRef.current = false;
|
agentHandoffPendingRef.current = false;
|
||||||
@@ -549,6 +619,7 @@ export default function ChatPage() {
|
|||||||
selectedAssistantKnowledgeBaseIds,
|
selectedAssistantKnowledgeBaseIds,
|
||||||
selectedKnowledgeBaseIds,
|
selectedKnowledgeBaseIds,
|
||||||
knowledgeSelectionReady,
|
knowledgeSelectionReady,
|
||||||
|
newThreadMetadata,
|
||||||
agentHandoffPending,
|
agentHandoffPending,
|
||||||
isMock,
|
isMock,
|
||||||
router,
|
router,
|
||||||
@@ -560,6 +631,8 @@ export default function ChatPage() {
|
|||||||
multiRoundRag,
|
multiRoundRag,
|
||||||
multiRoundRagSelected,
|
multiRoundRagSelected,
|
||||||
frontendRagSelected,
|
frontendRagSelected,
|
||||||
|
directChat,
|
||||||
|
directChatSelected,
|
||||||
routeBase,
|
routeBase,
|
||||||
setIsNewThread,
|
setIsNewThread,
|
||||||
setThreadId,
|
setThreadId,
|
||||||
@@ -655,6 +728,10 @@ export default function ChatPage() {
|
|||||||
saveLastAgentId(agentId);
|
saveLastAgentId(agentId);
|
||||||
}, []);
|
}, []);
|
||||||
const handleStop = useCallback(async () => {
|
const handleStop = useCallback(async () => {
|
||||||
|
if (directChat.isLoading) {
|
||||||
|
directChat.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (singleRoundRag.isLoading) {
|
if (singleRoundRag.isLoading) {
|
||||||
singleRoundRag.stop();
|
singleRoundRag.stop();
|
||||||
return;
|
return;
|
||||||
@@ -664,7 +741,7 @@ export default function ChatPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await thread.stop();
|
await thread.stop();
|
||||||
}, [multiRoundRag, singleRoundRag, thread]);
|
}, [directChat, multiRoundRag, singleRoundRag, thread]);
|
||||||
|
|
||||||
const promptPrefixProp = useMemo(() => {
|
const promptPrefixProp = useMemo(() => {
|
||||||
if (!promptPrefixSettings?.enabled) return undefined;
|
if (!promptPrefixSettings?.enabled) return undefined;
|
||||||
@@ -914,7 +991,8 @@ export default function ChatPage() {
|
|||||||
agentHandoffPending ||
|
agentHandoffPending ||
|
||||||
knowledgeSelectionLoading ||
|
knowledgeSelectionLoading ||
|
||||||
singleRoundRag.isLoading ||
|
singleRoundRag.isLoading ||
|
||||||
multiRoundRag.isLoading
|
multiRoundRag.isLoading ||
|
||||||
|
directChat.isLoading
|
||||||
}
|
}
|
||||||
onContextChange={(context) =>
|
onContextChange={(context) =>
|
||||||
setSettings("context", context)
|
setSettings("context", context)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
CircleHelp,
|
CircleHelp,
|
||||||
Clock3,
|
Clock3,
|
||||||
|
FolderKanban,
|
||||||
LoaderCircle,
|
LoaderCircle,
|
||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
@@ -20,7 +21,12 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { type FormEvent, useCallback, useMemo, useState } from "react";
|
import { type FormEvent, useCallback, useMemo, useState } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
|
|
||||||
|
import {
|
||||||
|
MyProjectThreadBadge,
|
||||||
|
MyProjectThreadIcon,
|
||||||
|
} from "@/components/workspace/my-project-thread-indicator";
|
||||||
import { useAgent, useAgents } from "@/core/agents";
|
import { useAgent, useAgents } from "@/core/agents";
|
||||||
import { getAccountDisplayName } from "@/core/auth";
|
import { getAccountDisplayName } from "@/core/auth";
|
||||||
import {
|
import {
|
||||||
@@ -29,28 +35,41 @@ import {
|
|||||||
useScheduledTaskRuns,
|
useScheduledTaskRuns,
|
||||||
useScheduledTasks,
|
useScheduledTasks,
|
||||||
} from "@/core/scheduled-tasks";
|
} from "@/core/scheduled-tasks";
|
||||||
import { useLocalSettings } from "@/core/settings";
|
|
||||||
import {
|
import {
|
||||||
useAppearanceSettings,
|
useAppearanceSettings,
|
||||||
useCitationDisplaySettings,
|
useCitationDisplaySettings,
|
||||||
} from "@/core/system-settings/hooks";
|
} from "@/core/system-settings/hooks";
|
||||||
import { useThreadStream, useThreads } from "@/core/threads/hooks";
|
import { useThreadStream, useThreads } from "@/core/threads/hooks";
|
||||||
|
import {
|
||||||
|
myProjectConversationTypeOfThread,
|
||||||
|
myProjectConversationTypeFromSearchParams,
|
||||||
|
myProjectThreadMetadata,
|
||||||
|
} from "@/core/threads/project-metadata";
|
||||||
import { textOfMessage, titleOfThread } from "@/core/threads/utils";
|
import { textOfMessage, titleOfThread } from "@/core/threads/utils";
|
||||||
import { uuid } from "@/core/utils/uuid";
|
import { uuid } from "@/core/utils/uuid";
|
||||||
import {
|
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 StrategyProjectsPage from "@/strategy-selection/StrategyProjectsPage";
|
||||||
|
import StrategySelectionPage from "@/strategy-selection/StrategySelectionPage";
|
||||||
|
|
||||||
import "./official-deerflow-entry.css";
|
import "./official-deerflow-entry.css";
|
||||||
|
|
||||||
type OfficialPage = "chat" | "agents" | "agent-chat" | "scheduled" | "settings";
|
type OfficialPage =
|
||||||
|
| "chat"
|
||||||
|
| "projects"
|
||||||
|
| "agents"
|
||||||
|
| "agent-chat"
|
||||||
|
| "scheduled"
|
||||||
|
| "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);
|
||||||
if (path.startsWith("/workspace/agents/") && path.includes("/chats")) {
|
if (path.startsWith("/workspace/agents/") && path.includes("/chats")) {
|
||||||
return "agent-chat";
|
return "agent-chat";
|
||||||
}
|
}
|
||||||
|
if (path.startsWith("/workspace/projects")) return "projects";
|
||||||
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/settings")) return "settings";
|
if (path.startsWith("/workspace/settings")) return "settings";
|
||||||
@@ -78,6 +97,11 @@ function parseScheduledTaskId(pathname: string) {
|
|||||||
return match ? decodeURIComponent(match[1]!) : null;
|
return match ? decodeURIComponent(match[1]!) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseProjectId(pathname: string) {
|
||||||
|
const match = pathname.match(/\/workspace\/projects\/([^/]+)/);
|
||||||
|
return match ? decodeURIComponent(match[1]!) : 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;
|
||||||
}
|
}
|
||||||
@@ -159,20 +183,36 @@ function ChatWorkspace({
|
|||||||
routeThreadId: string;
|
routeThreadId: string;
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [sendError, setSendError] = useState<string | null>(null);
|
const [sendError, setSendError] = useState<string | null>(null);
|
||||||
const isNewThread = routeThreadId === "new";
|
const isNewThread = routeThreadId === "new";
|
||||||
|
const projectConversationType = isNewThread
|
||||||
|
? myProjectConversationTypeFromSearchParams(
|
||||||
|
new URLSearchParams(location.search),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
// A new conversation uses the same client-generated id approach as the
|
// A new conversation uses the same client-generated id approach as the
|
||||||
// existing ChatPage. The backend owns the persisted id once streaming starts.
|
// existing ChatPage. The backend owns the persisted id once streaming starts.
|
||||||
const [draftThreadId] = useState<string>(() => uuid() as string);
|
const [draftThreadId] = useState<string>(() => uuid() as string);
|
||||||
const activeThreadId = isNewThread ? draftThreadId : routeThreadId;
|
const activeThreadId = isNewThread ? draftThreadId : routeThreadId;
|
||||||
const { agent } = useAgent(agentId, { enabled: !!agentId });
|
const { agent } = useAgent(agentId, { enabled: !!agentId });
|
||||||
const [localSettings] = useLocalSettings();
|
|
||||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||||
threadId: isNewThread ? undefined : routeThreadId,
|
threadId: isNewThread ? undefined : routeThreadId,
|
||||||
assistantId: agentId ?? undefined,
|
assistantId: agentId ?? undefined,
|
||||||
|
threadMetadata: projectConversationType
|
||||||
|
? myProjectThreadMetadata(projectConversationType)
|
||||||
|
: undefined,
|
||||||
context: {
|
context: {
|
||||||
...localSettings.context,
|
// This compact official shell has no model/mode controls of its own.
|
||||||
|
// Do not inherit a hidden selection saved by the customized workspace
|
||||||
|
// (for example an obsolete or quota-exhausted model); an omitted model
|
||||||
|
// lets the backend resolve the current system default or the agent's
|
||||||
|
// globally configured model.
|
||||||
|
model_name: undefined,
|
||||||
|
mode: undefined,
|
||||||
|
reasoning_effort: undefined,
|
||||||
|
writing_mode: false,
|
||||||
...(agentId ? { agent_id: agentId } : {}),
|
...(agentId ? { agent_id: agentId } : {}),
|
||||||
},
|
},
|
||||||
onStart: (createdThreadId) => {
|
onStart: (createdThreadId) => {
|
||||||
@@ -658,6 +698,10 @@ export default function OfficialDeerFlowEntry() {
|
|||||||
() => parseScheduledTaskId(location.pathname),
|
() => parseScheduledTaskId(location.pathname),
|
||||||
[location.pathname],
|
[location.pathname],
|
||||||
);
|
);
|
||||||
|
const projectId = useMemo(
|
||||||
|
() => parseProjectId(location.pathname),
|
||||||
|
[location.pathname],
|
||||||
|
);
|
||||||
const { data: threads = [], isLoading: threadsLoading, error: threadsError } =
|
const { data: threads = [], isLoading: threadsLoading, error: threadsError } =
|
||||||
useThreads({
|
useThreads({
|
||||||
limit: 20,
|
limit: 20,
|
||||||
@@ -666,10 +710,12 @@ export default function OfficialDeerFlowEntry() {
|
|||||||
select: ["thread_id", "updated_at", "values", "metadata"],
|
select: ["thread_id", "updated_at", "values", "metadata"],
|
||||||
});
|
});
|
||||||
const go = (path: string) => navigate(path);
|
const go = (path: string) => navigate(path);
|
||||||
|
const projectBasePath = `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/projects`;
|
||||||
|
|
||||||
const nav = [
|
const nav = [
|
||||||
{ icon: PlusSquare, label: "新对话", path: OFFICIAL_DEERFLOW_HOME_ROUTE, pages: ["chat"] },
|
{ icon: PlusSquare, label: "新对话", path: OFFICIAL_DEERFLOW_HOME_ROUTE, pages: ["chat"] },
|
||||||
{ 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: FolderKanban, label: "我的项目", path: projectBasePath, pages: ["projects"] },
|
||||||
{ 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"] },
|
||||||
] as const;
|
] as const;
|
||||||
@@ -706,29 +752,43 @@ export default function OfficialDeerFlowEntry() {
|
|||||||
) : threadsError ? (
|
) : threadsError ? (
|
||||||
<p className="official-sidebar-error">对话列表加载失败</p>
|
<p className="official-sidebar-error">对话列表加载失败</p>
|
||||||
) : threads.length ? (
|
) : threads.length ? (
|
||||||
threads.map((chat) => (
|
threads.map((chat) => {
|
||||||
<button
|
const projectType = myProjectConversationTypeOfThread(chat);
|
||||||
className={`official-recent-chat${chatRoute.threadId === chat.thread_id ? " is-active" : ""}`}
|
return (
|
||||||
key={chat.thread_id}
|
<button
|
||||||
onClick={() => {
|
className={`official-recent-chat${chatRoute.threadId === chat.thread_id ? " is-active" : ""}`}
|
||||||
const agentId = typeof chat.context?.agent_id === "string"
|
key={chat.thread_id}
|
||||||
? chat.context.agent_id
|
onClick={() => {
|
||||||
: typeof chat.metadata?.agent_id === "string"
|
const agentId = typeof chat.context?.agent_id === "string"
|
||||||
? chat.metadata.agent_id
|
? chat.context.agent_id
|
||||||
: null;
|
: typeof chat.metadata?.agent_id === "string"
|
||||||
go(
|
? chat.metadata.agent_id
|
||||||
agentId
|
: null;
|
||||||
? `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents/${encodeURIComponent(agentId)}/chats/${encodeURIComponent(chat.thread_id)}`
|
go(
|
||||||
: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/${encodeURIComponent(chat.thread_id)}`,
|
agentId
|
||||||
);
|
? `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents/${encodeURIComponent(agentId)}/chats/${encodeURIComponent(chat.thread_id)}`
|
||||||
}}
|
: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/${encodeURIComponent(chat.thread_id)}`,
|
||||||
type="button"
|
);
|
||||||
>
|
}}
|
||||||
<MessageSquareText aria-hidden="true" size={15} />
|
type="button"
|
||||||
<span>{titleOfThread(chat, "未命名对话")}</span>
|
>
|
||||||
<small>{formatTimestamp(chat.updated_at)}</small>
|
{projectType ? (
|
||||||
</button>
|
<MyProjectThreadIcon className="size-[15px]" type={projectType} />
|
||||||
))
|
) : (
|
||||||
|
<MessageSquareText aria-hidden="true" size={15} />
|
||||||
|
)}
|
||||||
|
<span className="official-recent-chat-copy">
|
||||||
|
<span className="official-recent-chat-title">
|
||||||
|
{titleOfThread(chat, "未命名对话")}
|
||||||
|
</span>
|
||||||
|
{projectType && (
|
||||||
|
<MyProjectThreadBadge type={projectType} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<small>{formatTimestamp(chat.updated_at)}</small>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
) : (
|
) : (
|
||||||
<p className="official-sidebar-empty">暂无历史对话</p>
|
<p className="official-sidebar-empty">暂无历史对话</p>
|
||||||
)}
|
)}
|
||||||
@@ -754,6 +814,17 @@ export default function OfficialDeerFlowEntry() {
|
|||||||
routeThreadId={chatRoute.threadId}
|
routeThreadId={chatRoute.threadId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{page === "projects" && (
|
||||||
|
projectId ? (
|
||||||
|
<StrategySelectionPage
|
||||||
|
key={projectId}
|
||||||
|
projectBasePath={projectBasePath}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<StrategyProjectsPage basePath={projectBasePath} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
{page === "agents" && (
|
{page === "agents" && (
|
||||||
<AgentsPage
|
<AgentsPage
|
||||||
openAgent={(agentId) =>
|
openAgent={(agentId) =>
|
||||||
@@ -771,6 +842,7 @@ export default function OfficialDeerFlowEntry() {
|
|||||||
{page === "scheduled" && <ScheduledTasksPage selectedTaskId={scheduledTaskId} />}
|
{page === "scheduled" && <ScheduledTasksPage selectedTaskId={scheduledTaskId} />}
|
||||||
{page === "settings" && <SettingsPage />}
|
{page === "settings" && <SettingsPage />}
|
||||||
</div>
|
</div>
|
||||||
|
<Toaster position="top-center" />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes, useParams } from "react-router-dom";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
|
|
||||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||||
@@ -78,6 +78,8 @@ import UsersPage from "./UsersPage";
|
|||||||
import LlmMetricsPage from "./LlmMetricsPage";
|
import LlmMetricsPage from "./LlmMetricsPage";
|
||||||
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
||||||
import AdminUserActivityPage from "./AdminUserActivityPage";
|
import AdminUserActivityPage from "./AdminUserActivityPage";
|
||||||
|
import StrategyProjectsPage from "@/strategy-selection/StrategyProjectsPage";
|
||||||
|
import StrategySelectionPage from "@/strategy-selection/StrategySelectionPage";
|
||||||
import { ChatRuntime } from "./WorkspaceRoutes";
|
import { ChatRuntime } from "./WorkspaceRoutes";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,6 +100,11 @@ function StrategyChatThreadLayout() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StrategyProjectWorkbenchRoute() {
|
||||||
|
const { project_id: projectId = "" } = useParams<{ project_id: string }>();
|
||||||
|
return <StrategySelectionPage key={projectId} projectId={projectId} />;
|
||||||
|
}
|
||||||
|
|
||||||
export default function StrategyRoutes() {
|
export default function StrategyRoutes() {
|
||||||
const storedAuth = getStoredAuth();
|
const storedAuth = getStoredAuth();
|
||||||
const canManageBusinessMappings = !storedAuth || storedAuth.system_role === "admin";
|
const canManageBusinessMappings = !storedAuth || storedAuth.system_role === "admin";
|
||||||
@@ -110,6 +117,20 @@ export default function StrategyRoutes() {
|
|||||||
<Route index element={<Navigate to="qa/general" replace />} />
|
<Route index element={<Navigate to="qa/general" replace />} />
|
||||||
|
|
||||||
<Route path="qa/general" element={<GeneralQA />} />
|
<Route path="qa/general" element={<GeneralQA />} />
|
||||||
|
<Route
|
||||||
|
path="qa/projects"
|
||||||
|
element={
|
||||||
|
<StrategyProjectsPage basePath="/page/strategy/qa/projects" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="qa/projects/:project_id"
|
||||||
|
element={<StrategyProjectWorkbenchRoute />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="qa/strategy-selection"
|
||||||
|
element={<Navigate to="/page/strategy/qa/projects" replace />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="qa/expert-qa"
|
path="qa/expert-qa"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -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]">
|
||||||
|
|||||||
@@ -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 ${
|
||||||
|
|||||||
@@ -64,6 +64,8 @@
|
|||||||
.official-recent-section > p { color: #90908e; font-size: 12px; margin: 0 8px 9px; }
|
.official-recent-section > p { color: #90908e; font-size: 12px; margin: 0 8px 9px; }
|
||||||
.official-recent-chat { display: grid; font-size: 13px; gap: 8px; grid-template-columns: 15px minmax(0, 1fr) auto; padding: 8px; }
|
.official-recent-chat { display: grid; font-size: 13px; gap: 8px; grid-template-columns: 15px minmax(0, 1fr) auto; padding: 8px; }
|
||||||
.official-recent-chat span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.official-recent-chat span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.official-recent-chat-copy { align-items: center; display: flex; gap: 6px; min-width: 0; }
|
||||||
|
.official-recent-chat-title { flex: 1; min-width: 0; }
|
||||||
.official-recent-chat small { color: #acaca9; font-size: 11px; }
|
.official-recent-chat small { color: #acaca9; font-size: 11px; }
|
||||||
.official-recent-chat.is-active { background: #efefed; color: #252525; }
|
.official-recent-chat.is-active { background: #efefed; color: #252525; }
|
||||||
.official-sidebar-loading,
|
.official-sidebar-loading,
|
||||||
|
|||||||
@@ -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 ? "暂停生成" : "发送"}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -55,12 +55,14 @@ function payloadFrom(
|
|||||||
): TaskProcessReportPayload {
|
): TaskProcessReportPayload {
|
||||||
const name = target.name?.trim() || fileName(target.path);
|
const name = target.name?.trim() || fileName(target.path);
|
||||||
const agentId = target.agentId?.trim() || target.threadId;
|
const agentId = target.agentId?.trim() || target.threadId;
|
||||||
|
// 产物提交 position:优先取业务链里给该席位智能体配置的值,未配置回退默认。
|
||||||
|
const position = config.positionFor?.(target) ?? config.position;
|
||||||
return {
|
return {
|
||||||
taskId: config.taskId,
|
taskId: config.taskId,
|
||||||
agentId,
|
agentId,
|
||||||
agentName: target.agentName?.trim() || agentId,
|
agentName: target.agentName?.trim() || agentId,
|
||||||
content,
|
content,
|
||||||
position: config.position,
|
position,
|
||||||
reportName: name,
|
reportName: name,
|
||||||
submitStatus,
|
submitStatus,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ export interface ChainSeat {
|
|||||||
* 使同一条业务链既能继续用于原会商,也能在岗位协同中按岗位筛选可问答的智能体。
|
* 使同一条业务链既能继续用于原会商,也能在岗位协同中按岗位筛选可问答的智能体。
|
||||||
*/
|
*/
|
||||||
position_id?: string | null;
|
position_id?: string | null;
|
||||||
|
/**
|
||||||
|
* 该席位提交过程产物时传给外部任务系统的 `position` 值(8BF 会商页用)。
|
||||||
|
* 不同席位智能体对应不同的产物位置,缺省 / null = 提交时回退 0。
|
||||||
|
* 随 `seats` JSON 落库(无需 DB 列,但需在后端 `ChainSeatSchema` 显式声明)。
|
||||||
|
* 注意与 `position_id`(岗位协同的岗位归属)无关。
|
||||||
|
*/
|
||||||
|
artifact_position?: number | null;
|
||||||
/**
|
/**
|
||||||
* 该席位在业务链中配置的岗位职责。会商「智能体配置」面板只读展示;
|
* 该席位在业务链中配置的岗位职责。会商「智能体配置」面板只读展示;
|
||||||
* 随 `seats` JSON 落库(无需 DB 列)。空 / 缺省 = 未填写。
|
* 随 `seats` JSON 落库(无需 DB 列)。空 / 缺省 = 未填写。
|
||||||
|
|||||||
@@ -13,7 +13,14 @@ export interface TaskProcessReportPayload {
|
|||||||
export interface TaskArtifactSubmissionConfig {
|
export interface TaskArtifactSubmissionConfig {
|
||||||
/** External task-system id, not the local roundtable draft storage key. */
|
/** External task-system id, not the local roundtable draft storage key. */
|
||||||
taskId: string;
|
taskId: string;
|
||||||
|
/** 提交产物时传给外部任务系统的默认 position(产物位置)。 */
|
||||||
position: number;
|
position: number;
|
||||||
|
/**
|
||||||
|
* 按席位智能体解析提交产物的 position(业务链里逐席位配置的「产物提交 position」)。
|
||||||
|
* 入参是 hook 解析后的 target(`agentId` 已归一为席位 agent_id);返回
|
||||||
|
* null / undefined = 该席位未配置,回退 `position` 默认值。
|
||||||
|
*/
|
||||||
|
positionFor?: (target: TaskArtifactTarget) => number | null | undefined;
|
||||||
createProcessReport: (payload: TaskProcessReportPayload) => Promise<{ id: string }>;
|
createProcessReport: (payload: TaskProcessReportPayload) => Promise<{ id: string }>;
|
||||||
updateProcessReport: (reportId: string, payload: TaskProcessReportPayload) => Promise<void>;
|
updateProcessReport: (reportId: string, payload: TaskProcessReportPayload) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -697,6 +697,8 @@ export default function BusinessChainEditorPage() {
|
|||||||
seat_mode: s.seat_mode ?? null,
|
seat_mode: s.seat_mode ?? null,
|
||||||
// 岗位协同专用岗位归属:普通圆桌忽略,但保存时必须原样保留。
|
// 岗位协同专用岗位归属:普通圆桌忽略,但保存时必须原样保留。
|
||||||
position_id: s.position_id ?? null,
|
position_id: s.position_id ?? null,
|
||||||
|
// 席位「产物提交 position」:提交过程产物时传给外部任务系统;null = 回退默认 0。
|
||||||
|
artifact_position: s.artifact_position ?? null,
|
||||||
// 业务链席位岗位职责:编辑器已不提供输入,保存时原样保留存量值(会商智能体配置面板只读展示)。
|
// 业务链席位岗位职责:编辑器已不提供输入,保存时原样保留存量值(会商智能体配置面板只读展示)。
|
||||||
job_responsibilities: s.job_responsibilities?.trim() || null,
|
job_responsibilities: s.job_responsibilities?.trim() || null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -41,10 +41,6 @@ const iconButtonClass = (isDark: boolean, isPaleLightTheme: boolean, isCustomThe
|
|||||||
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
|
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
|
||||||
);
|
);
|
||||||
|
|
||||||
// 深色顶栏菜单左右分组(设计稿:左 问答中心/任务管理/应用中心,右 综合管理/资源管理)
|
|
||||||
const LEFT_MENU_IDS = ["qa-center", "task-management", "light-app-center"];
|
|
||||||
const RIGHT_MENU_IDS = ["comprehensive-management", "resource-management"];
|
|
||||||
|
|
||||||
type HeaderProps = {
|
type HeaderProps = {
|
||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
};
|
};
|
||||||
@@ -122,8 +118,11 @@ const Header = ({ collapsed }: HeaderProps) => {
|
|||||||
|
|
||||||
const thirdSubNavItems = getActiveThirdLevelItems(visibleMenuItems, pathname);
|
const thirdSubNavItems = getActiveThirdLevelItems(visibleMenuItems, pathname);
|
||||||
|
|
||||||
const leftMenuItems = visibleMenuItems.filter((i) => LEFT_MENU_IDS.includes(i.id));
|
// 深色顶栏围绕中间标题分成左右两组。直接按菜单管理后的一级顺序均分,确保被升为
|
||||||
const rightMenuItems = visibleMenuItems.filter((i) => RIGHT_MENU_IDS.includes(i.id));
|
// 一级或重新排序的节点仍会显示;默认 5 项时仍保持左 3 / 右 2 的原布局。
|
||||||
|
const topMenuSplitIndex = Math.ceil(visibleMenuItems.length / 2);
|
||||||
|
const leftMenuItems = visibleMenuItems.slice(0, topMenuSplitIndex);
|
||||||
|
const rightMenuItems = visibleMenuItems.slice(topMenuSplitIndex);
|
||||||
|
|
||||||
const handleNavItemClick = (item: MenuItem) => {
|
const handleNavItemClick = (item: MenuItem) => {
|
||||||
const path = item.path ?? findFirstRoutePath(item.children);
|
const path = item.path ?? findFirstRoutePath(item.children);
|
||||||
|
|||||||
@@ -73,6 +73,21 @@ export interface LlmWikiKnowledgeBase {
|
|||||||
is_conversation_deposit?: boolean;
|
is_conversation_deposit?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LlmWikiEmbeddingModel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
provider?: string | null;
|
||||||
|
model_name?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
is_default: boolean;
|
||||||
|
available: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LlmWikiEmbeddingModelsResponse {
|
||||||
|
models: LlmWikiEmbeddingModel[];
|
||||||
|
default_model_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PublicLlmWikiKnowledgeBase extends LlmWikiKnowledgeBase {
|
export interface PublicLlmWikiKnowledgeBase extends LlmWikiKnowledgeBase {
|
||||||
wiki: {
|
wiki: {
|
||||||
status: "ready" | "empty" | "unavailable";
|
status: "ready" | "empty" | "unavailable";
|
||||||
@@ -268,6 +283,9 @@ export interface LlmWikiWikiIndexStatus {
|
|||||||
|
|
||||||
export interface LocalWikiEncoderStatus {
|
export interface LocalWikiEncoderStatus {
|
||||||
provider: string;
|
provider: string;
|
||||||
|
configured_provider?: string | null;
|
||||||
|
mode?: "configured" | "local";
|
||||||
|
using_local?: boolean;
|
||||||
model: string;
|
model: string;
|
||||||
dimensions?: number | null;
|
dimensions?: number | null;
|
||||||
state: "idle" | "starting" | "ready" | "failed";
|
state: "idle" | "starting" | "ready" | "failed";
|
||||||
@@ -424,6 +442,17 @@ export async function getLlmWikiKnowledgeBase(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listLlmWikiEmbeddingModels(): Promise<LlmWikiEmbeddingModelsResponse> {
|
||||||
|
const payload = await readJson<LlmWikiEmbeddingModelsResponse>(
|
||||||
|
await apiFetch(`${API()}/embedding-models`),
|
||||||
|
"读取知识服务向量模型",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
models: payload.models ?? [],
|
||||||
|
default_model_id: payload.default_model_id ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function createLlmWikiWeKnoraEmbedSession(
|
export async function createLlmWikiWeKnoraEmbedSession(
|
||||||
id: string,
|
id: string,
|
||||||
tab: "documents" | "wiki" | "graph",
|
tab: "documents" | "wiki" | "graph",
|
||||||
@@ -457,6 +486,7 @@ export async function createLlmWikiKnowledgeBase(input: {
|
|||||||
description: string;
|
description: string;
|
||||||
type: "document" | "faq";
|
type: "document" | "faq";
|
||||||
wiki_enabled?: boolean;
|
wiki_enabled?: boolean;
|
||||||
|
embedding_model_id?: string;
|
||||||
}): Promise<LlmWikiKnowledgeBase> {
|
}): Promise<LlmWikiKnowledgeBase> {
|
||||||
return readJson(
|
return readJson(
|
||||||
await apiFetch(`${API()}/knowledge-bases`, {
|
await apiFetch(`${API()}/knowledge-bases`, {
|
||||||
@@ -654,6 +684,13 @@ export async function configureLocalWikiEncoder(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function restoreConfiguredWikiEncoder(): Promise<LocalWikiEncoderStatus> {
|
||||||
|
return readJson(
|
||||||
|
await apiFetch(`${API()}/local-encoder/use-configured`, { method: "POST" }),
|
||||||
|
"恢复配置文件编码器",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function uploadLocalWikiEncoder(
|
export async function uploadLocalWikiEncoder(
|
||||||
files: File[],
|
files: File[],
|
||||||
onProgress?: (percent: number) => void,
|
onProgress?: (percent: number) => void,
|
||||||
@@ -688,10 +725,15 @@ export async function uploadLocalWikiEncoder(
|
|||||||
payload = null;
|
payload = null;
|
||||||
}
|
}
|
||||||
if (request.status < 200 || request.status >= 300) {
|
if (request.status < 200 || request.status >= 300) {
|
||||||
const detail = payload && typeof payload === "object" && "detail" in payload
|
const rawDetail = payload && typeof payload === "object" && "detail" in payload
|
||||||
? (payload as { detail: unknown }).detail
|
? (payload as { detail: unknown }).detail
|
||||||
: `请求失败 (${request.status})`;
|
: `请求失败 (${request.status})`;
|
||||||
reject(new Error(typeof detail === "string" ? detail : JSON.stringify(detail)));
|
const detail = rawDetail && typeof rawDetail === "object" && "message" in rawDetail
|
||||||
|
? String((rawDetail as { message: unknown }).message)
|
||||||
|
: typeof rawDetail === "string"
|
||||||
|
? rawDetail
|
||||||
|
: JSON.stringify(rawDetail);
|
||||||
|
reject(new Error(userFacingKnowledgeMessage(detail)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onProgress?.(100);
|
onProgress?.(100);
|
||||||
@@ -1268,6 +1310,17 @@ export function useLlmWikiSettings(enabled = true) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useLlmWikiEmbeddingModels(enabled = true) {
|
||||||
|
const { userId } = useLiveAccount();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["llmwiki", "embedding-models", userId ?? "anonymous"],
|
||||||
|
queryFn: listLlmWikiEmbeddingModels,
|
||||||
|
enabled,
|
||||||
|
staleTime: 60_000,
|
||||||
|
refetchOnMount: "always",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useLlmWikiActions() {
|
export function useLlmWikiActions() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const invalidateKnowledge = () =>
|
const invalidateKnowledge = () =>
|
||||||
|
|||||||
+129
-18
@@ -34,6 +34,13 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
@@ -48,7 +55,9 @@ import {
|
|||||||
searchLlmWiki,
|
searchLlmWiki,
|
||||||
startLocalWikiEncoder,
|
startLocalWikiEncoder,
|
||||||
uploadLocalWikiEncoder,
|
uploadLocalWikiEncoder,
|
||||||
|
restoreConfiguredWikiEncoder,
|
||||||
useLlmWikiActions,
|
useLlmWikiActions,
|
||||||
|
useLlmWikiEmbeddingModels,
|
||||||
useLlmWikiKnowledgeBases,
|
useLlmWikiKnowledgeBases,
|
||||||
useLlmWikiSettings,
|
useLlmWikiSettings,
|
||||||
useLlmWikiSettingsActions,
|
useLlmWikiSettingsActions,
|
||||||
@@ -68,6 +77,14 @@ function messageOf(error: unknown): string {
|
|||||||
return error instanceof Error ? error.message : "操作失败,请稍后重试";
|
return error instanceof Error ? error.message : "操作失败,请稍后重试";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function encoderProviderLabel(provider?: string | null): string {
|
||||||
|
const normalized = provider?.trim().toLowerCase();
|
||||||
|
if (normalized === "local") return "本地 BGE-M3";
|
||||||
|
if (normalized === "openai") return "OpenAI";
|
||||||
|
if (normalized === "openai_compatible") return "OpenAI 兼容服务";
|
||||||
|
return provider?.trim() || "未配置";
|
||||||
|
}
|
||||||
|
|
||||||
function searchModeLabel(mode: LlmWikiSearchResponse["mode"]) {
|
function searchModeLabel(mode: LlmWikiSearchResponse["mode"]) {
|
||||||
if (mode === "vector") return "本地 Wiki 向量检索";
|
if (mode === "vector") return "本地 Wiki 向量检索";
|
||||||
if (mode === "mixed") return "Wiki 混合检索";
|
if (mode === "mixed") return "Wiki 混合检索";
|
||||||
@@ -457,18 +474,50 @@ function CreateKnowledgeDialog({
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
pending: boolean;
|
pending: boolean;
|
||||||
onOpenChange: (value: boolean) => void;
|
onOpenChange: (value: boolean) => void;
|
||||||
onCreate: (input: { name: string; description: string; type: "document" | "faq"; wiki_enabled: boolean }) => Promise<void>;
|
onCreate: (input: { name: string; description: string; type: "document" | "faq"; wiki_enabled: boolean; embedding_model_id: string }) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [wikiEnabled, setWikiEnabled] = useState(true);
|
const [wikiEnabled, setWikiEnabled] = useState(true);
|
||||||
|
const [embeddingModelId, setEmbeddingModelId] = useState("");
|
||||||
|
const embeddingModels = useLlmWikiEmbeddingModels(open);
|
||||||
|
const availableEmbeddingModels = useMemo(
|
||||||
|
() => (embeddingModels.data?.models ?? []).filter((model) => model.available !== false),
|
||||||
|
[embeddingModels.data?.models],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) { setName(""); setDescription(""); setWikiEnabled(true); }
|
if (!open) {
|
||||||
|
setName("");
|
||||||
|
setDescription("");
|
||||||
|
setWikiEnabled(true);
|
||||||
|
setEmbeddingModelId("");
|
||||||
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !embeddingModels.data || availableEmbeddingModels.length === 0) return;
|
||||||
|
if (availableEmbeddingModels.some((model) => model.id === embeddingModelId)) return;
|
||||||
|
const defaultId = embeddingModels.data.default_model_id;
|
||||||
|
setEmbeddingModelId(
|
||||||
|
availableEmbeddingModels.some((model) => model.id === defaultId)
|
||||||
|
? defaultId!
|
||||||
|
: availableEmbeddingModels[0]!.id,
|
||||||
|
);
|
||||||
|
}, [availableEmbeddingModels, embeddingModelId, embeddingModels.data, open]);
|
||||||
|
|
||||||
|
const modelLabel = (model: (typeof availableEmbeddingModels)[number]) => {
|
||||||
|
const provider = model.provider?.trim();
|
||||||
|
const implementation = model.model_name?.trim();
|
||||||
|
const suffix = [provider, implementation].filter(Boolean).join(" · ");
|
||||||
|
return suffix ? `${model.name}(${suffix})` : model.name;
|
||||||
|
};
|
||||||
|
const modelSelectionUnavailable = embeddingModels.isLoading || embeddingModels.isError || !embeddingModelId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader><DialogTitle>新建个人知识库</DialogTitle><DialogDescription>选择知识库模式;模型、切块和存储策略仍由知识服务管理。</DialogDescription></DialogHeader>
|
<DialogHeader><DialogTitle>新建个人知识库</DialogTitle><DialogDescription>选择知识库模式和知识服务用于原文分块的向量模型。</DialogDescription></DialogHeader>
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="llmwiki-name">名称</label><Input id="llmwiki-name" value={name} onChange={(event) => setName(event.target.value)} maxLength={255} /></div>
|
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="llmwiki-name">名称</label><Input id="llmwiki-name" value={name} onChange={(event) => setName(event.target.value)} maxLength={255} /></div>
|
||||||
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="llmwiki-description">描述</label><Textarea id="llmwiki-description" value={description} onChange={(event) => setDescription(event.target.value)} rows={4} maxLength={2048} /></div>
|
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="llmwiki-description">描述</label><Textarea id="llmwiki-description" value={description} onChange={(event) => setDescription(event.target.value)} rows={4} maxLength={2048} /></div>
|
||||||
@@ -476,8 +525,33 @@ function CreateKnowledgeDialog({
|
|||||||
<button type="button" className={`rounded-xl border p-4 text-left transition-colors ${wikiEnabled ? "border-primary bg-primary/5 ring-1 ring-primary" : "hover:bg-muted/40"}`} onClick={() => setWikiEnabled(true)}><div className="flex items-center gap-2 text-sm font-semibold"><BookOpen className="size-4 text-violet-500" />Wiki 知识库</div><p className="mt-2 text-xs leading-5 text-muted-foreground">自动综合资料,生成 Wiki 页面和页面关系图谱。</p></button>
|
<button type="button" className={`rounded-xl border p-4 text-left transition-colors ${wikiEnabled ? "border-primary bg-primary/5 ring-1 ring-primary" : "hover:bg-muted/40"}`} onClick={() => setWikiEnabled(true)}><div className="flex items-center gap-2 text-sm font-semibold"><BookOpen className="size-4 text-violet-500" />Wiki 知识库</div><p className="mt-2 text-xs leading-5 text-muted-foreground">自动综合资料,生成 Wiki 页面和页面关系图谱。</p></button>
|
||||||
<button type="button" className={`rounded-xl border p-4 text-left transition-colors ${!wikiEnabled ? "border-primary bg-primary/5 ring-1 ring-primary" : "hover:bg-muted/40"}`} onClick={() => setWikiEnabled(false)}><div className="flex items-center gap-2 text-sm font-semibold"><FileText className="size-4 text-blue-500" />标准知识库</div><p className="mt-2 text-xs leading-5 text-muted-foreground">仅进行文档解析、切块与向量/关键词索引。</p></button>
|
<button type="button" className={`rounded-xl border p-4 text-left transition-colors ${!wikiEnabled ? "border-primary bg-primary/5 ring-1 ring-primary" : "hover:bg-muted/40"}`} onClick={() => setWikiEnabled(false)}><div className="flex items-center gap-2 text-sm font-semibold"><FileText className="size-4 text-blue-500" />标准知识库</div><p className="mt-2 text-xs leading-5 text-muted-foreground">仅进行文档解析、切块与向量/关键词索引。</p></button>
|
||||||
</div></div>
|
</div></div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium" htmlFor="llmwiki-embedding-model">原文向量模型</label>
|
||||||
|
{embeddingModels.isLoading ? (
|
||||||
|
<div className="flex h-9 items-center rounded-md border px-3 text-sm text-muted-foreground"><Loader2 className="mr-2 size-4 animate-spin" />正在读取知识服务模型</div>
|
||||||
|
) : embeddingModels.isError ? (
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
|
||||||
|
<p>模型读取失败:{messageOf(embeddingModels.error)}</p>
|
||||||
|
<Button type="button" variant="ghost" size="sm" className="mt-1 h-7 px-2" onClick={() => void embeddingModels.refetch()}>重新读取</Button>
|
||||||
|
</div>
|
||||||
|
) : availableEmbeddingModels.length ? (
|
||||||
|
<Select value={embeddingModelId} onValueChange={setEmbeddingModelId} disabled={pending}>
|
||||||
|
<SelectTrigger id="llmwiki-embedding-model" className="w-full"><SelectValue placeholder="请选择向量模型" /></SelectTrigger>
|
||||||
|
<SelectContent position="popper">
|
||||||
|
{availableEmbeddingModels.map((model) => (
|
||||||
|
<SelectItem key={model.id} value={model.id}>
|
||||||
|
{modelLabel(model)}{model.is_default ? " · 默认" : ""}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">知识服务中没有可用的 Embedding 模型,请先在知识服务中启用本地或 OpenAI 兼容向量模型。</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs leading-5 text-muted-foreground">该模型由知识服务(WeKnora)对上传原文和分块进行向量化,并在创建时绑定到新库。它与 DeerFlow 的“Wiki 镜像编码器”是两套独立配置;选择 OpenAI 兼容模型时,无需再配置本地 BGE-M3。需要跨知识库检索时,请为相关知识库保持相同或兼容的向量模型。</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter><Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button><Button disabled={pending || !name.trim()} onClick={() => void onCreate({ name: name.trim(), description: description.trim(), type: "document", wiki_enabled: wikiEnabled })}>{pending && <Loader2 className="mr-2 size-4 animate-spin" />}创建</Button></DialogFooter>
|
<DialogFooter><Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button><Button disabled={pending || !name.trim() || modelSelectionUnavailable} onClick={() => void onCreate({ name: name.trim(), description: description.trim(), type: "document", wiki_enabled: wikiEnabled, embedding_model_id: embeddingModelId })}>{pending && <Loader2 className="mr-2 size-4 animate-spin" />}创建</Button></DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
@@ -501,11 +575,12 @@ export function LocalEncoderSettingsCard() {
|
|||||||
|
|
||||||
const applyStatus = (status: Awaited<ReturnType<typeof getLocalWikiEncoderStatus>>) => {
|
const applyStatus = (status: Awaited<ReturnType<typeof getLocalWikiEncoderStatus>>) => {
|
||||||
queryClient.setQueryData(["llmwiki-local-encoder"], status);
|
queryClient.setQueryData(["llmwiki-local-encoder"], status);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["llmwiki", "wiki-index-status"] });
|
||||||
setModelPath(status.model_path || "");
|
setModelPath(status.model_path || "");
|
||||||
};
|
};
|
||||||
const configure = useMutation({
|
const configure = useMutation({
|
||||||
mutationFn: () => configureLocalWikiEncoder(modelPath.trim()),
|
mutationFn: () => configureLocalWikiEncoder(modelPath.trim()),
|
||||||
onSuccess: (status) => { applyStatus(status); toast.success("编码器目录已保存"); },
|
onSuccess: (status) => { applyStatus(status); toast.success("已切换到本地 Wiki 编码器并保存目录"); },
|
||||||
onError: (error) => toast.error(messageOf(error)),
|
onError: (error) => toast.error(messageOf(error)),
|
||||||
});
|
});
|
||||||
const upload = useMutation({
|
const upload = useMutation({
|
||||||
@@ -514,7 +589,7 @@ export function LocalEncoderSettingsCard() {
|
|||||||
onSuccess: (status) => {
|
onSuccess: (status) => {
|
||||||
applyStatus(status);
|
applyStatus(status);
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
toast.success(`已保存 ${status.uploaded_files ?? 0} 个模型文件`);
|
toast.success(`已保存 ${status.uploaded_files ?? 0} 个模型文件,并切换到本地 Wiki 编码器`);
|
||||||
},
|
},
|
||||||
onError: (error) => toast.error(messageOf(error)),
|
onError: (error) => toast.error(messageOf(error)),
|
||||||
});
|
});
|
||||||
@@ -526,7 +601,17 @@ export function LocalEncoderSettingsCard() {
|
|||||||
},
|
},
|
||||||
onError: (error) => toast.error(messageOf(error)),
|
onError: (error) => toast.error(messageOf(error)),
|
||||||
});
|
});
|
||||||
|
const restoreConfigured = useMutation({
|
||||||
|
mutationFn: restoreConfiguredWikiEncoder,
|
||||||
|
onSuccess: (nextStatus) => {
|
||||||
|
applyStatus(nextStatus);
|
||||||
|
toast.success(`已恢复 config.yaml 编码器:${encoderProviderLabel(nextStatus.provider)}`);
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(messageOf(error)),
|
||||||
|
});
|
||||||
const status = encoder.data;
|
const status = encoder.data;
|
||||||
|
const usingLocal = status?.using_local ?? status?.provider === "local";
|
||||||
|
const localOverrideActive = status?.mode === "local";
|
||||||
const selectedBytes = files.reduce((sum, file) => sum + file.size, 0);
|
const selectedBytes = files.reduce((sum, file) => sum + file.size, 0);
|
||||||
const formatSize = (value: number) => value >= 1024 ** 3
|
const formatSize = (value: number) => value >= 1024 ** 3
|
||||||
? `${(value / 1024 ** 3).toFixed(2)} GB`
|
? `${(value / 1024 ** 3).toFixed(2)} GB`
|
||||||
@@ -535,29 +620,49 @@ export function LocalEncoderSettingsCard() {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-base"><Cpu className="size-5" />DeerFlow 本地编码器</CardTitle>
|
<CardTitle className="flex items-center gap-2 text-base"><Cpu className="size-5" />DeerFlow Wiki 镜像编码器</CardTitle>
|
||||||
<CardDescription>仅管理员可见。配置服务器上已有的 BGE-M3 目录,或直接上传完整离线模型目录;编码器作为独立进程启动,不影响 DeerFlow 其他服务。</CardDescription>
|
<CardDescription>仅管理员可见。这里负责 DeerFlow 内部 Wiki 页面镜像的向量检索,不会决定知识服务对上传原文使用哪个模型。原文向量模型在“新建知识库”时选择。</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-5">
|
<CardContent className="space-y-5">
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
<Badge variant={status?.state === "failed" ? "destructive" : status?.state === "ready" ? "default" : "secondary"}>
|
<Badge variant={encoder.isError || (usingLocal && status?.state === "failed") ? "destructive" : usingLocal && status?.state === "ready" ? "default" : "secondary"}>
|
||||||
{status?.state === "ready" ? "已就绪" : status?.state === "starting" ? "启动中" : status?.state === "failed" ? "启动失败" : "未启动"}
|
{encoder.isError
|
||||||
|
? "状态读取失败"
|
||||||
|
: encoder.isLoading
|
||||||
|
? "读取中"
|
||||||
|
: !usingLocal
|
||||||
|
? "配置编码器生效中"
|
||||||
|
: status?.state === "ready"
|
||||||
|
? "本地编码器已就绪"
|
||||||
|
: status?.state === "starting"
|
||||||
|
? "本地编码器启动中"
|
||||||
|
: status?.state === "failed"
|
||||||
|
? "本地编码器启动失败"
|
||||||
|
: "本地编码器未启动"}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-muted-foreground">{status ? `${status.model} · ${status.dimensions || "自动探测"} 维 · ${status.process_isolation ? "独立进程" : "主进程"}` : "正在读取状态"}</span>
|
{status && <Badge variant="outline">当前 provider:{encoderProviderLabel(status.provider)}</Badge>}
|
||||||
{status?.model_files_ready && <Badge variant="outline">模型文件已就绪</Badge>}
|
{status && <Badge variant="outline">{localOverrideActive ? "页面本地覆盖" : "config.yaml 配置"}</Badge>}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{status
|
||||||
|
? usingLocal
|
||||||
|
? `${status.model} · ${status.dimensions || "自动探测"} 维 · ${status.process_isolation ? "独立进程" : "主进程"}`
|
||||||
|
: `由 config.yaml 的 ${encoderProviderLabel(status.configured_provider || status.provider)} provider 提供向量服务,无需启动本地模型`
|
||||||
|
: "正在读取状态"}
|
||||||
|
</span>
|
||||||
|
{usingLocal && status?.model_files_ready && <Badge variant="outline">模型文件已就绪</Badge>}
|
||||||
</div>
|
</div>
|
||||||
{encoder.error && <p className="text-sm text-destructive">{messageOf(encoder.error)}</p>}
|
{encoder.error && <p className="text-sm text-destructive">{messageOf(encoder.error)}</p>}
|
||||||
{status?.last_error && <p className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">{status.last_error}</p>}
|
{usingLocal && status?.last_error && <p className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">{status.last_error}</p>}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium" htmlFor="local-encoder-path">使用服务器已有目录</label>
|
<label className="text-sm font-medium" htmlFor="local-encoder-path">切换到服务器本地模型目录</label>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
<Input id="local-encoder-path" value={modelPath} onChange={(event) => setModelPath(event.target.value)} placeholder="例如 /opt/deerflow/models/bge-m3" />
|
<Input id="local-encoder-path" value={modelPath} onChange={(event) => setModelPath(event.target.value)} placeholder="例如 /opt/deerflow/models/bge-m3" />
|
||||||
<Button variant="outline" disabled={!modelPath.trim() || configure.isPending} onClick={() => configure.mutate()}>
|
<Button variant="outline" disabled={!modelPath.trim() || configure.isPending} onClick={() => configure.mutate()}>
|
||||||
{configure.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}保存目录
|
{configure.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}保存目录
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">目录内需包含 <code>onnx/model.onnx</code>(推荐)或根目录 <code>model.onnx</code>。</p>
|
<p className="text-xs text-muted-foreground">目录内需包含 <code>onnx/model.onnx</code>(推荐)或根目录 <code>model.onnx</code>。保存后会立即把 Wiki 镜像编码器切换为 local 并热重建,无需修改配置文件或重启服务。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 rounded-xl border border-dashed p-4">
|
<div className="space-y-2 rounded-xl border border-dashed p-4">
|
||||||
@@ -576,7 +681,7 @@ export function LocalEncoderSettingsCard() {
|
|||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{files.length ? `已选择 ${files.length} 个文件,共 ${formatSize(selectedBytes)}` : "请选择完整 BGE-M3 目录。大文件会落到运行目录,不写入源码仓库。"}
|
{files.length ? `已选择 ${files.length} 个文件,共 ${formatSize(selectedBytes)}` : "请选择完整 BGE-M3 目录。上传完成后会自动切换到 local;大文件会落到运行目录,不写入源码仓库。"}
|
||||||
</p>
|
</p>
|
||||||
<Button disabled={!files.length || upload.isPending} onClick={() => upload.mutate()}>
|
<Button disabled={!files.length || upload.isPending} onClick={() => upload.mutate()}>
|
||||||
{upload.isPending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Upload className="mr-2 size-4" />}
|
{upload.isPending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Upload className="mr-2 size-4" />}
|
||||||
@@ -586,10 +691,16 @@ export function LocalEncoderSettingsCard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button disabled={!status?.model_files_ready || start.isPending || status?.state === "starting" || status?.state === "ready"} onClick={() => start.mutate()}>
|
<Button disabled={!usingLocal || !status?.model_files_ready || start.isPending || status?.state === "starting" || status?.state === "ready"} onClick={() => start.mutate()}>
|
||||||
{(start.isPending || status?.state === "starting") && <Loader2 className="mr-2 size-4 animate-spin" />}
|
{(start.isPending || status?.state === "starting") && <Loader2 className="mr-2 size-4 animate-spin" />}
|
||||||
{status?.state === "ready" ? "编码器已启动" : status?.state === "failed" ? "重新启动" : "一键启动编码器"}
|
{!usingLocal ? "当前无需启动本地模型" : status?.state === "ready" ? "编码器已启动" : status?.state === "failed" ? "重新启动" : "一键启动编码器"}
|
||||||
</Button>
|
</Button>
|
||||||
|
{localOverrideActive && (
|
||||||
|
<Button variant="secondary" disabled={restoreConfigured.isPending} onClick={() => restoreConfigured.mutate()}>
|
||||||
|
{restoreConfigured.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}
|
||||||
|
恢复 config.yaml 编码器
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="ghost" disabled={encoder.isFetching} onClick={() => void encoder.refetch()}>
|
<Button variant="ghost" disabled={encoder.isFetching} onClick={() => void encoder.refetch()}>
|
||||||
<RefreshCw className={`mr-2 size-4 ${encoder.isFetching ? "animate-spin" : ""}`} />刷新状态
|
<RefreshCw className={`mr-2 size-4 ${encoder.isFetching ? "animate-spin" : ""}`} />刷新状态
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+25
-8
@@ -601,13 +601,19 @@ export function WeKnoraWikiManagementPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const vectorStatusText = !indexStatus.data?.local_index_enabled
|
const vectorStatusText = indexStatus.isLoading
|
||||||
? "本地向量未启用,检索使用 Wiki 接口"
|
? "正在读取 Wiki 镜像向量状态"
|
||||||
: indexStatus.data.fully_vectorized
|
: indexStatus.isError
|
||||||
? `整库已向量化(${indexStatus.data.vector_count ?? 0} 个向量章节)`
|
? `Wiki 镜像向量状态读取失败:${indexStatus.error instanceof Error ? indexStatus.error.message : "请稍后重试"}`
|
||||||
: indexStatus.data.state === "running" || indexStatus.data.state === "rebuilding"
|
: !indexStatus.data?.local_index_enabled
|
||||||
? "整库向量化进行中,完成前检索使用 Wiki 接口"
|
? "Wiki 镜像向量未启用,检索使用 Wiki 接口"
|
||||||
: "整库尚未完成向量化,检索使用 Wiki 接口";
|
: indexStatus.data.fully_vectorized
|
||||||
|
? `整库已向量化(${indexStatus.data.vector_count ?? 0} 个向量章节)`
|
||||||
|
: indexStatus.data.state === "running" || indexStatus.data.state === "rebuilding"
|
||||||
|
? "整库向量化进行中,完成前检索使用 Wiki 接口"
|
||||||
|
: indexStatus.data.last_error
|
||||||
|
? `整库向量化失败:${indexStatus.data.last_error}`
|
||||||
|
: "整库尚未完成向量化,检索使用 Wiki 接口";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||||
@@ -626,7 +632,7 @@ export function WeKnoraWikiManagementPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => void vectorize()}
|
onClick={() => void vectorize()}
|
||||||
disabled={!canWrite || actions.vectorizeWiki.isPending || indexStatus.data?.state === "running" || indexStatus.data?.state === "rebuilding"}
|
disabled={!canWrite || indexStatus.isLoading || actions.vectorizeWiki.isPending || indexStatus.data?.state === "running" || indexStatus.data?.state === "rebuilding"}
|
||||||
>
|
>
|
||||||
{actions.vectorizeWiki.isPending || indexStatus.data?.state === "running" || indexStatus.data?.state === "rebuilding" ? (
|
{actions.vectorizeWiki.isPending || indexStatus.data?.state === "running" || indexStatus.data?.state === "rebuilding" ? (
|
||||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
@@ -652,6 +658,17 @@ export function WeKnoraWikiManagementPage() {
|
|||||||
<main className="grid min-h-0 flex-1 grid-cols-[320px_1fr] overflow-hidden">
|
<main className="grid min-h-0 flex-1 grid-cols-[320px_1fr] overflow-hidden">
|
||||||
<aside className="flex min-h-0 flex-col border-r bg-muted/20">
|
<aside className="flex min-h-0 flex-col border-r bg-muted/20">
|
||||||
<div className="border-b p-3">
|
<div className="border-b p-3">
|
||||||
|
{indexStatus.isLoading && (
|
||||||
|
<div className="mb-3 flex items-center rounded-lg border bg-background p-3 text-xs text-muted-foreground">
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />正在读取 Wiki 镜像向量状态
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{indexStatus.isError && (
|
||||||
|
<div className="mb-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
|
||||||
|
<p>Wiki 镜像向量状态读取失败:{indexStatus.error instanceof Error ? indexStatus.error.message : "请稍后重试"}</p>
|
||||||
|
<Button type="button" variant="ghost" size="sm" className="mt-1 h-7 px-2" onClick={() => void indexStatus.refetch()}>重新读取</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{indexStatus.data && (
|
{indexStatus.data && (
|
||||||
<div className="mb-3 rounded-lg border bg-background p-3">
|
<div className="mb-3 rounded-lg border bg-background p-3">
|
||||||
<div className="flex items-center justify-between gap-2 text-xs">
|
<div className="flex items-center justify-between gap-2 text-xs">
|
||||||
|
|||||||
@@ -0,0 +1,736 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Bot,
|
||||||
|
Check,
|
||||||
|
CirclePlus,
|
||||||
|
Layers3,
|
||||||
|
LoaderCircle,
|
||||||
|
LockKeyhole,
|
||||||
|
Power,
|
||||||
|
RotateCcw,
|
||||||
|
Save,
|
||||||
|
Sparkles,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
|
||||||
|
import { ModelNameSelect } from "@/components/workspace/model-name-select";
|
||||||
|
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { useAllSkillsAdmin } from "@/core/skills/hooks";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createStrategyAngle,
|
||||||
|
disableStrategyAngle,
|
||||||
|
STRATEGY_AGENTS_QUERY_KEY,
|
||||||
|
updateStrategyAgent,
|
||||||
|
useStrategyAgents,
|
||||||
|
type StrategyAgent,
|
||||||
|
type StrategyAgentCategory,
|
||||||
|
} from "./strategy-agents";
|
||||||
|
|
||||||
|
interface AgentConfigDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentConfigDraft {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
soul: string;
|
||||||
|
model: string | null;
|
||||||
|
skills: string[];
|
||||||
|
enabled: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEW_ANGLE_ID = "__new_strategy_angle__";
|
||||||
|
|
||||||
|
const CATEGORY_COPY: Record<
|
||||||
|
StrategyAgentCategory,
|
||||||
|
{ title: string; description: string }
|
||||||
|
> = {
|
||||||
|
intent: {
|
||||||
|
title: "意图理解",
|
||||||
|
description: "把用户对话整理为结构化任务,固定流程入口",
|
||||||
|
},
|
||||||
|
angle: {
|
||||||
|
title: "分析角度",
|
||||||
|
description: "并行生成独立策略,可按业务需要扩展",
|
||||||
|
},
|
||||||
|
judge: {
|
||||||
|
title: "智能评判",
|
||||||
|
description: "统一比较各角度策略并给出推荐结论",
|
||||||
|
},
|
||||||
|
solution: {
|
||||||
|
title: "方案整理",
|
||||||
|
description: "将用户选中的策略消冲突并汇总成方案",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeSkills(skills: string[]) {
|
||||||
|
return [...new Set(skills)].sort((left, right) =>
|
||||||
|
left.localeCompare(right),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameSkills(left: string[], right: string[]) {
|
||||||
|
const normalizedLeft = normalizeSkills(left);
|
||||||
|
const normalizedRight = normalizeSkills(right);
|
||||||
|
return (
|
||||||
|
normalizedLeft.length === normalizedRight.length &&
|
||||||
|
normalizedLeft.every((skill, index) => skill === normalizedRight[index])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftFromAgent(agent: StrategyAgent): AgentConfigDraft {
|
||||||
|
return {
|
||||||
|
name: agent.name,
|
||||||
|
description: agent.description,
|
||||||
|
soul: agent.soul ?? "",
|
||||||
|
model: agent.model,
|
||||||
|
skills: agent.skills ?? [],
|
||||||
|
enabled: agent.enabled,
|
||||||
|
sortOrder: agent.sort_order,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyAngleDraft(sortOrder: number): AgentConfigDraft {
|
||||||
|
return {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
soul: "",
|
||||||
|
model: null,
|
||||||
|
skills: [],
|
||||||
|
enabled: true,
|
||||||
|
sortOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameDraft(left: AgentConfigDraft, right: AgentConfigDraft) {
|
||||||
|
return (
|
||||||
|
left.name === right.name &&
|
||||||
|
left.description === right.description &&
|
||||||
|
left.soul === right.soul &&
|
||||||
|
left.model === right.model &&
|
||||||
|
left.enabled === right.enabled &&
|
||||||
|
left.sortOrder === right.sortOrder &&
|
||||||
|
sameSkills(left.skills, right.skills)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentConfigDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: AgentConfigDialogProps) {
|
||||||
|
const [selectedId, setSelectedId] = useState("");
|
||||||
|
const [draft, setDraft] = useState<AgentConfigDraft>(() =>
|
||||||
|
emptyAngleDraft(100),
|
||||||
|
);
|
||||||
|
const [savedDraft, setSavedDraft] = useState<AgentConfigDraft | null>(null);
|
||||||
|
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const {
|
||||||
|
data: agents = [],
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useStrategyAgents({
|
||||||
|
enabled: open,
|
||||||
|
includeDisabled: true,
|
||||||
|
includeConfig: true,
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
skills,
|
||||||
|
isLoading: isSkillsLoading,
|
||||||
|
error: skillsError,
|
||||||
|
} = useAllSkillsAdmin(open);
|
||||||
|
|
||||||
|
const fixedAgents = useMemo(
|
||||||
|
() => agents.filter((agent) => agent.fixed),
|
||||||
|
[agents],
|
||||||
|
);
|
||||||
|
const angleAgents = useMemo(
|
||||||
|
() => agents.filter((agent) => agent.category === "angle"),
|
||||||
|
[agents],
|
||||||
|
);
|
||||||
|
const selectedAgent = useMemo(
|
||||||
|
() => agents.find((agent) => agent.id === selectedId) ?? null,
|
||||||
|
[agents, selectedId],
|
||||||
|
);
|
||||||
|
const creating = selectedId === NEW_ANGLE_ID;
|
||||||
|
const dirty = Boolean(savedDraft && !sameDraft(draft, savedDraft));
|
||||||
|
const selectableSkills = useMemo(
|
||||||
|
() =>
|
||||||
|
skills.filter((skill) => {
|
||||||
|
const globallyAvailable =
|
||||||
|
Boolean(skill.builtin) || Boolean(skill.published);
|
||||||
|
return (
|
||||||
|
skill.enabled &&
|
||||||
|
(skill.state ?? "active") === "active" &&
|
||||||
|
globallyAvailable
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
[skills],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || isLoading || creating || agents.length === 0) return;
|
||||||
|
if (selectedAgent) return;
|
||||||
|
setSelectedId(agents[0].id);
|
||||||
|
}, [agents, creating, isLoading, open, selectedAgent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || creating || !selectedAgent) return;
|
||||||
|
const next = draftFromAgent(selectedAgent);
|
||||||
|
setDraft(next);
|
||||||
|
setSavedDraft(next);
|
||||||
|
}, [creating, open, selectedAgent?.id, selectedAgent?.updated_at]);
|
||||||
|
|
||||||
|
const confirmDiscardChanges = () =>
|
||||||
|
!dirty || window.confirm("当前配置尚未保存,确定放弃这些修改吗?");
|
||||||
|
|
||||||
|
const selectAgent = (agentId: string) => {
|
||||||
|
if (agentId === selectedId || busy || !confirmDiscardChanges()) return;
|
||||||
|
setSelectedId(agentId);
|
||||||
|
setSavedDraft(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startCreate = () => {
|
||||||
|
if (busy || !confirmDiscardChanges()) return;
|
||||||
|
const maxOrder = angleAgents.reduce(
|
||||||
|
(current, agent) => Math.max(current, agent.sort_order),
|
||||||
|
99,
|
||||||
|
);
|
||||||
|
const next = emptyAngleDraft(maxOrder + 1);
|
||||||
|
setSelectedId(NEW_ANGLE_ID);
|
||||||
|
setDraft(next);
|
||||||
|
setSavedDraft(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
const name = draft.name.trim();
|
||||||
|
if (!name) {
|
||||||
|
toast.error("请填写智能体名称");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
if (creating) {
|
||||||
|
const created = await createStrategyAngle({
|
||||||
|
name,
|
||||||
|
description: draft.description.trim(),
|
||||||
|
soul: draft.soul,
|
||||||
|
model: draft.model,
|
||||||
|
skills: draft.skills,
|
||||||
|
sort_order: draft.sortOrder,
|
||||||
|
});
|
||||||
|
setSelectedId(created.id);
|
||||||
|
const next = draftFromAgent(created);
|
||||||
|
setDraft(next);
|
||||||
|
setSavedDraft(next);
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: STRATEGY_AGENTS_QUERY_KEY,
|
||||||
|
});
|
||||||
|
toast.success(`${created.name} 已加入分析角度库`);
|
||||||
|
} else if (selectedAgent) {
|
||||||
|
const updated = await updateStrategyAgent(selectedAgent.id, {
|
||||||
|
name,
|
||||||
|
description: draft.description.trim(),
|
||||||
|
soul: draft.soul,
|
||||||
|
model: draft.model,
|
||||||
|
skills: draft.skills,
|
||||||
|
enabled: selectedAgent.fixed ? undefined : draft.enabled,
|
||||||
|
sort_order: selectedAgent.fixed ? undefined : draft.sortOrder,
|
||||||
|
});
|
||||||
|
const next = draftFromAgent(updated);
|
||||||
|
setDraft(next);
|
||||||
|
setSavedDraft(next);
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: STRATEGY_AGENTS_QUERY_KEY,
|
||||||
|
});
|
||||||
|
toast.success(`${updated.name} 配置已保存`);
|
||||||
|
}
|
||||||
|
} catch (reason) {
|
||||||
|
toast.error(reason instanceof Error ? reason.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const softDelete = async () => {
|
||||||
|
if (!selectedAgent || selectedAgent.fixed || busy) return;
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`确定停用“${selectedAgent.name}”吗?\n\n它不会再出现在新项目的角度选择中,但历史项目、SOUL 和技能配置都会保留,可随时恢复。`,
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const updated = await disableStrategyAngle(selectedAgent.id);
|
||||||
|
const next = draftFromAgent(updated);
|
||||||
|
setDraft(next);
|
||||||
|
setSavedDraft(next);
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: STRATEGY_AGENTS_QUERY_KEY,
|
||||||
|
});
|
||||||
|
toast.success(`${updated.name} 已停用,历史项目不受影响`);
|
||||||
|
} catch (reason) {
|
||||||
|
toast.error(reason instanceof Error ? reason.message : "停用失败");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (nextOpen: boolean) => {
|
||||||
|
if (!nextOpen && (busy || !confirmDiscardChanges())) return;
|
||||||
|
onOpenChange(nextOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const category = creating ? "angle" : selectedAgent?.category;
|
||||||
|
const categoryCopy = category ? CATEGORY_COPY[category] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="grid h-[min(860px,calc(100vh-40px))] max-w-[1180px] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-[1180px]">
|
||||||
|
<DialogHeader className="border-b px-6 py-5 dark:border-[#173d68]">
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Bot className="size-5 text-primary" />
|
||||||
|
统一策略智能体
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
配置对所有项目全局生效。流程角色保持唯一;分析角度可扩展、排序和停用,历史项目仍保留原引用。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid min-h-0 grid-cols-[320px_minmax(0,1fr)]">
|
||||||
|
<aside className="min-h-0 overflow-y-auto border-r bg-muted/20 p-4 dark:border-[#173d68] dark:bg-[#031b38]">
|
||||||
|
<div className="mb-3 flex items-center justify-between px-1">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold tracking-wide text-muted-foreground">
|
||||||
|
固定流程角色
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[11px] text-muted-foreground/80">
|
||||||
|
仅可修改配置,不可新增、停用或删除
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary">{fixedAgents.length}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{fixedAgents.map((agent) => {
|
||||||
|
const selected = agent.id === selectedId;
|
||||||
|
const copy = CATEGORY_COPY[agent.category];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={agent.id}
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => selectAgent(agent.id)}
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded-xl border px-3 py-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
||||||
|
selected
|
||||||
|
? "border-primary/45 bg-primary/10 shadow-sm"
|
||||||
|
: "border-transparent bg-background/70 hover:border-border dark:bg-[#08274c]/55 dark:hover:border-[#28507c]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center justify-between gap-2">
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<LockKeyhole className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate text-sm font-medium">
|
||||||
|
{copy.title}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{selected ? (
|
||||||
|
<Check className="size-4 shrink-0 text-primary" />
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1.5 block truncate pl-5.5 text-xs text-muted-foreground">
|
||||||
|
{agent.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3 mt-6 flex items-center justify-between px-1">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold tracking-wide text-muted-foreground">
|
||||||
|
分析角度库
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[11px] text-muted-foreground/80">
|
||||||
|
新项目只使用当前启用的角度
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={startCreate}
|
||||||
|
>
|
||||||
|
<CirclePlus className="size-3.5" />
|
||||||
|
新增
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{angleAgents.map((agent) => {
|
||||||
|
const selected = agent.id === selectedId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={agent.id}
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => selectAgent(agent.id)}
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded-xl border px-3 py-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
||||||
|
selected
|
||||||
|
? "border-primary/45 bg-primary/10 shadow-sm"
|
||||||
|
: "border-transparent bg-background/70 hover:border-border dark:bg-[#08274c]/55 dark:hover:border-[#28507c]",
|
||||||
|
!agent.enabled && "opacity-65",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center justify-between gap-2">
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<Layers3 className="size-3.5 shrink-0 text-primary" />
|
||||||
|
<span className="truncate text-sm font-medium">
|
||||||
|
{agent.name}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{selected ? (
|
||||||
|
<Check className="size-4 shrink-0 text-primary" />
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="mt-2 flex items-center justify-between gap-2 pl-5.5">
|
||||||
|
<span className="truncate text-[11px] text-muted-foreground">
|
||||||
|
顺序 {agent.sort_order}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant={agent.enabled ? "secondary" : "outline"}
|
||||||
|
className="h-5 text-[10px]"
|
||||||
|
>
|
||||||
|
{agent.enabled ? "已启用" : "已停用"}
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!isLoading && angleAgents.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed p-5 text-center text-xs text-muted-foreground">
|
||||||
|
暂无分析角度,点击“新增”建立第一个角度。
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="min-h-0 overflow-y-auto px-6 py-5">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<LoaderCircle className="size-4 animate-spin" />
|
||||||
|
正在读取全局配置…
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-5 text-sm text-destructive">
|
||||||
|
<p className="font-medium">策略智能体配置加载失败</p>
|
||||||
|
<p className="mt-1 leading-6 opacity-85">
|
||||||
|
{error instanceof Error ? error.message : "请稍后重试"}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-4"
|
||||||
|
onClick={() => void refetch()}
|
||||||
|
>
|
||||||
|
<RotateCcw className="size-3.5" />
|
||||||
|
重新加载
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : categoryCopy ? (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
{creating ? "新增分析角度" : categoryCopy.title}
|
||||||
|
</h3>
|
||||||
|
{selectedAgent?.fixed ? (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
<LockKeyhole className="size-3" /> 固定角色
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{!creating && selectedAgent && !selectedAgent.fixed ? (
|
||||||
|
<Badge
|
||||||
|
variant={draft.enabled ? "secondary" : "outline"}
|
||||||
|
>
|
||||||
|
{draft.enabled ? "已启用" : "已停用"}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{creating
|
||||||
|
? "创建后会成为所有策略项目可选的全局分析角度。"
|
||||||
|
: categoryCopy.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!creating && selectedAgent ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="max-w-[300px] truncate font-mono text-[10px]"
|
||||||
|
title={selectedAgent.id}
|
||||||
|
>
|
||||||
|
{selectedAgent.id}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="rounded-xl border bg-muted/15 p-4 dark:border-[#173d68] dark:bg-[#031b38]">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-xs font-medium">
|
||||||
|
智能体名称
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
value={draft.name}
|
||||||
|
disabled={busy}
|
||||||
|
maxLength={100}
|
||||||
|
placeholder="例如:科技与产业链视角"
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
name: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-xs font-medium">
|
||||||
|
运行模型
|
||||||
|
</span>
|
||||||
|
<ModelNameSelect
|
||||||
|
value={draft.model}
|
||||||
|
onChange={(model) =>
|
||||||
|
setDraft((current) => ({ ...current, model }))
|
||||||
|
}
|
||||||
|
disabled={busy}
|
||||||
|
defaultLabel="使用系统默认模型"
|
||||||
|
title="选择该全局智能体运行时使用的大模型"
|
||||||
|
triggerClassName="h-9 w-full bg-background"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block sm:col-span-2">
|
||||||
|
<span className="mb-1.5 block text-xs font-medium">
|
||||||
|
职责简介
|
||||||
|
</span>
|
||||||
|
<Textarea
|
||||||
|
value={draft.description}
|
||||||
|
disabled={busy}
|
||||||
|
maxLength={2000}
|
||||||
|
className="min-h-20 resize-y"
|
||||||
|
placeholder="说明该角色关注的问题、交付内容和边界…"
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
description: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!creating && selectedAgent && !selectedAgent.fixed ? (
|
||||||
|
<div className="mt-4 grid gap-3 border-t pt-4 sm:grid-cols-[1fr_180px] dark:border-[#173d68]">
|
||||||
|
<div className="flex items-center justify-between rounded-lg border bg-background px-3 py-2.5 dark:border-[#28507c] dark:bg-[#08274c]">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">用于新项目</p>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
停用后历史项目仍可回显,新的角度选择将隐藏它
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={draft.enabled}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label="启用该分析角度"
|
||||||
|
onCheckedChange={(enabled) =>
|
||||||
|
setDraft((current) => ({ ...current, enabled }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-xs font-medium">
|
||||||
|
显示顺序
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={-10000}
|
||||||
|
max={10000}
|
||||||
|
value={draft.sortOrder}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
sortOrder: Number(event.target.value) || 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : creating ? (
|
||||||
|
<label className="mt-4 block max-w-[180px] border-t pt-4 dark:border-[#173d68]">
|
||||||
|
<span className="mb-1.5 block text-xs font-medium">
|
||||||
|
显示顺序
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={-10000}
|
||||||
|
max={10000}
|
||||||
|
value={draft.sortOrder}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
sortOrder: Number(event.target.value) || 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||||
|
<Sparkles className="size-4 text-primary" /> SOUL
|
||||||
|
</span>
|
||||||
|
<Textarea
|
||||||
|
value={draft.soul}
|
||||||
|
disabled={busy}
|
||||||
|
maxLength={200000}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
soul: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="min-h-[210px] resize-y font-mono text-xs leading-6"
|
||||||
|
placeholder="定义角色身份、分析方法、行为边界和输出格式…"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-sm font-medium">系统技能选择</span>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
已选 {draft.skills.length} 个
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs leading-5 text-muted-foreground">
|
||||||
|
只展示当前启用且全局可见的系统技能;通过穿梭框选择,不接受手工输入技能名。
|
||||||
|
</p>
|
||||||
|
<SkillTransferSelector
|
||||||
|
skills={selectableSkills}
|
||||||
|
selected={draft.skills}
|
||||||
|
onChange={(selectedSkills) =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
skills: selectedSkills,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
isLoading={isSkillsLoading}
|
||||||
|
onViewDetail={setDetailSkillName}
|
||||||
|
/>
|
||||||
|
{skillsError ? (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
技能库加载失败,请稍后重试。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-64 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
暂无可配置角色
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex-row items-center justify-between border-t px-6 py-4 dark:border-[#173d68] sm:justify-between">
|
||||||
|
<div>
|
||||||
|
{!creating && selectedAgent && !selectedAgent.fixed ? (
|
||||||
|
selectedAgent.enabled ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void softDelete()}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
停用并从新项目移除
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Power className="size-3.5" />
|
||||||
|
打开“用于新项目”并保存即可恢复
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{creating
|
||||||
|
? "新角度创建后立即全局可用"
|
||||||
|
: "固定角色始终参与对应流程阶段"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => handleOpenChange(false)}
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={
|
||||||
|
!categoryCopy ||
|
||||||
|
!draft.name.trim() ||
|
||||||
|
busy ||
|
||||||
|
isLoading ||
|
||||||
|
isSkillsLoading ||
|
||||||
|
(!creating && !dirty)
|
||||||
|
}
|
||||||
|
onClick={() => void save()}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<LoaderCircle className="size-4 animate-spin" />
|
||||||
|
) : creating ? (
|
||||||
|
<CirclePlus className="size-4" />
|
||||||
|
) : (
|
||||||
|
<Save className="size-4" />
|
||||||
|
)}
|
||||||
|
{creating ? "创建分析角度" : "保存当前配置"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
<SkillDetailDialog
|
||||||
|
skillName={detailSkillName}
|
||||||
|
open={detailSkillName !== null}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
if (!nextOpen) setDetailSkillName(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,44 @@
|
|||||||
|
import { apiFetch } from "@/core/api/fetch-client";
|
||||||
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
|
||||||
|
import { parseSseChunk } from "./lib";
|
||||||
|
import type { ParallelAgentEvent, ParallelAgentPayload } from "./types";
|
||||||
|
|
||||||
|
async function readError(response: Response): Promise<string> {
|
||||||
|
const body = (await response.json().catch(() => null)) as
|
||||||
|
| { detail?: string }
|
||||||
|
| null;
|
||||||
|
return body?.detail || `请求失败(HTTP ${response.status})`;
|
||||||
|
}
|
||||||
|
export async function streamParallelAgents(
|
||||||
|
payload: ParallelAgentPayload,
|
||||||
|
onEvent: (event: ParallelAgentEvent) => void,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/parallel-agents/run/stream`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Accept: "text/event-stream",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error(await readError(response));
|
||||||
|
if (!response.body) throw new Error("服务器未返回流式响应");
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
buffer += decoder.decode(value, { stream: !done });
|
||||||
|
const parsed = parseSseChunk(buffer, done);
|
||||||
|
buffer = parsed.rest;
|
||||||
|
parsed.events.forEach(onEvent);
|
||||||
|
if (done) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import type {
|
||||||
|
AgentOutputState,
|
||||||
|
IntentTurn,
|
||||||
|
StrategyAngle,
|
||||||
|
StrategyEvaluation,
|
||||||
|
StrategyTaskDraft,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export const STRATEGY_AGENT_ROLES = [
|
||||||
|
{
|
||||||
|
id: "strategy-selection-intent",
|
||||||
|
name: "意图理解智能体",
|
||||||
|
role: "将自然语言需求整理为可执行任务",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "strategy-selection-angle-governance",
|
||||||
|
name: "治理统筹智能体",
|
||||||
|
role: "关注目标、机制、跨部门协同与执行闭环",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "strategy-selection-angle-diplomacy",
|
||||||
|
name: "外交沟通智能体",
|
||||||
|
role: "关注沟通渠道、危机管控与多边协调",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "strategy-selection-angle-economy",
|
||||||
|
name: "经济韧性智能体",
|
||||||
|
role: "关注供应链、产业、贸易与市场预期",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "strategy-selection-angle-security",
|
||||||
|
name: "安全风险智能体",
|
||||||
|
role: "关注预警、误判防范与非升级性风险处置",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "strategy-selection-angle-public-opinion",
|
||||||
|
name: "社会民生智能体",
|
||||||
|
role: "关注公众沟通、民生连续性与人道保障",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "strategy-selection-angle-law-policy",
|
||||||
|
name: "法律规则智能体",
|
||||||
|
role: "关注适用规则、合规边界与证据链",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "strategy-selection-judge",
|
||||||
|
name: "策略评判智能体",
|
||||||
|
role: "以统一量表比较策略的收益、风险与可执行性",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "strategy-selection-solution",
|
||||||
|
name: "方案整理智能体",
|
||||||
|
role: "将入选策略去重、消冲突并整理为最终方案",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const STRATEGY_ANGLES: StrategyAngle[] = [
|
||||||
|
{
|
||||||
|
id: "governance",
|
||||||
|
agentId: "strategy-selection-angle-governance",
|
||||||
|
name: "治理与统筹",
|
||||||
|
shortName: "治理",
|
||||||
|
description: "目标拆解、组织协同、阶段推进和反馈闭环",
|
||||||
|
accent: "#6d5dfc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "diplomacy",
|
||||||
|
agentId: "strategy-selection-angle-diplomacy",
|
||||||
|
name: "外交与沟通",
|
||||||
|
shortName: "外交",
|
||||||
|
description: "危机沟通、多边协调、信号一致性与降温安排",
|
||||||
|
accent: "#0ea5e9",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "economy",
|
||||||
|
agentId: "strategy-selection-angle-economy",
|
||||||
|
name: "经济与产业韧性",
|
||||||
|
shortName: "经济",
|
||||||
|
description: "供应链连续性、市场预期、贸易和关键产业保障",
|
||||||
|
accent: "#10b981",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "security",
|
||||||
|
agentId: "strategy-selection-angle-security",
|
||||||
|
name: "安全与风险管控",
|
||||||
|
shortName: "安全",
|
||||||
|
description: "态势预警、误判防范、事件分级和非升级性处置",
|
||||||
|
accent: "#f97316",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "public_opinion",
|
||||||
|
agentId: "strategy-selection-angle-public-opinion",
|
||||||
|
name: "社会民生与舆情",
|
||||||
|
shortName: "民生",
|
||||||
|
description: "公众沟通、基本服务、社会心理和人道保障",
|
||||||
|
accent: "#ec4899",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "law_policy",
|
||||||
|
agentId: "strategy-selection-angle-law-policy",
|
||||||
|
name: "法律与规则",
|
||||||
|
shortName: "法律",
|
||||||
|
description: "适用规则、合规审查、责任边界与证据留存",
|
||||||
|
accent: "#a855f7",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const EMPTY_TASK_DRAFT: StrategyTaskDraft = {
|
||||||
|
name: "",
|
||||||
|
detail: "",
|
||||||
|
objective: "",
|
||||||
|
prerequisites: "",
|
||||||
|
constraints: "",
|
||||||
|
deliverables: "",
|
||||||
|
acceptanceCriteria: "",
|
||||||
|
horizon: "",
|
||||||
|
selectedAngleIds: STRATEGY_ANGLES.slice(0, 4).map((angle) => angle.id),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const INITIAL_INTENT_TURNS: IntentTurn[] = [
|
||||||
|
{
|
||||||
|
id: "welcome",
|
||||||
|
role: "assistant",
|
||||||
|
content:
|
||||||
|
"请描述你想解决的问题、希望达成的目标以及已知约束。我会通过对话补齐关键信息,并把需求实时整理到右侧任务草稿。",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TAIWAN_STRAIT_DEMO_TASK: StrategyTaskDraft = {
|
||||||
|
name: "台海局势风险管控与区域韧性提升策略",
|
||||||
|
detail:
|
||||||
|
"面向台海局势高不确定性场景,形成一套不预设立场、以避免误判和升级为核心的综合政策方案。方案聚焦危机沟通、区域经济与民生连续性、规则遵循及信息透明,不包含战术行动、目标选择或武力运用指导。",
|
||||||
|
objective:
|
||||||
|
"未来12—24个月内,降低突发事件转化为系统性危机的概率,保护平民福祉与关键经济活动,维持可验证、可持续的沟通和协调机制。",
|
||||||
|
prerequisites:
|
||||||
|
"建立跨部门联合研判机制;明确授权与对外沟通口径;掌握航运、能源、金融、通信和公共服务的基础风险数据;预留与各相关方沟通的技术渠道。",
|
||||||
|
constraints:
|
||||||
|
"遵守适用的国际法及人道原则;坚持克制、对等、可逆和非升级;不得输出军事战术、具体目标、武器使用或规避监管建议;关键事实须标注时点并交叉核验。",
|
||||||
|
deliverables:
|
||||||
|
"一份综合策略;分阶段行动清单;跨部门责任矩阵;风险预警指标;对外沟通要点;每月复盘和动态调整机制。",
|
||||||
|
acceptanceCriteria:
|
||||||
|
"措施均有责任主体、触发条件和退出条件;重要建议至少经两个角度交叉验证;高风险措施设置法律审查与升级门槛;方案可在72小时内启动首轮响应。",
|
||||||
|
horizon: "12—24个月,按0—30天、1—6个月、6—24个月分三阶段推进",
|
||||||
|
selectedAngleIds: STRATEGY_ANGLES.map((angle) => angle.id),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TAIWAN_STRAIT_DEMO_TURNS: IntentTurn[] = [
|
||||||
|
...INITIAL_INTENT_TURNS,
|
||||||
|
{
|
||||||
|
id: "demo-user",
|
||||||
|
role: "user",
|
||||||
|
isDemo: true,
|
||||||
|
content:
|
||||||
|
"请围绕台海局势做一个综合风险管控策略,重点是减少误判升级,同时兼顾经济、民生和规则约束,时间范围看未来一到两年。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "demo-assistant",
|
||||||
|
role: "assistant",
|
||||||
|
isDemo: true,
|
||||||
|
content:
|
||||||
|
"已将需求整理为“风险管控与区域韧性提升”任务:以降级避险为目标,采用治理、外交、经济、安全、民生和法律六个角度并行论证;明确排除战术性军事建议,并补充了阶段周期、验收标准和启动条件。右侧字段仍可直接修改。",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TAIWAN_STRAIT_DEMO_OUTPUTS: Record<string, AgentOutputState> = {
|
||||||
|
"strategy-selection-angle-governance": {
|
||||||
|
status: "done",
|
||||||
|
isDemo: true,
|
||||||
|
steps: ["拆解目标", "形成协同机制", "设置复盘闭环"],
|
||||||
|
content: `### 治理统筹策略:建立“联合研判—分级响应—复盘校正”闭环
|
||||||
|
|
||||||
|
1. 设立常态化跨部门风险研判单元,统一风险词典、数据口径和事件分级。
|
||||||
|
2. 为不同风险等级预先定义责任主体、会商时限、可逆措施和退出条件,避免临时决策放大误判。
|
||||||
|
3. 建立月度桌面推演与季度复盘,重点校验交通、能源、金融、通信和公共服务之间的级联风险。
|
||||||
|
4. 重大调整实行“政策效果—升级风险—民生影响—法律合规”四项并联审查。
|
||||||
|
|
||||||
|
**优先动作:** 30天内完成责任矩阵和跨部门值守清单;90天内完成首次全链路压力测试。`,
|
||||||
|
},
|
||||||
|
"strategy-selection-angle-diplomacy": {
|
||||||
|
status: "done",
|
||||||
|
isDemo: true,
|
||||||
|
steps: ["识别沟通对象", "设计危机渠道", "校准公开信号"],
|
||||||
|
content: `### 外交沟通策略:保持多层次、可验证的危机沟通
|
||||||
|
|
||||||
|
1. 维护工作层、专业部门和高层三类沟通渠道,约定紧急事件的确认窗口与回执机制。
|
||||||
|
2. 对外表述采用“事实、原则、下一步安排”三段式,减少模糊威慑和相互误读。
|
||||||
|
3. 支持以航行安全、灾害救援、公共卫生等低政治敏感议题开展技术性接触,积累最低限度信任。
|
||||||
|
4. 引入可信第三方作为信息转递与事实核验的备份通道,但不替代直接沟通。
|
||||||
|
|
||||||
|
**触发条件:** 当出现信息不一致、渠道中断或舆情快速放大时,立即启动双渠道交叉确认。`,
|
||||||
|
},
|
||||||
|
"strategy-selection-angle-economy": {
|
||||||
|
status: "done",
|
||||||
|
isDemo: true,
|
||||||
|
steps: ["识别关键链路", "评估替代能力", "配置韧性措施"],
|
||||||
|
content: `### 经济韧性策略:先保连续性,再做结构性分散
|
||||||
|
|
||||||
|
1. 对航运、半导体、能源、金融结算和通信链路建立关键节点清单与中断情景。
|
||||||
|
2. 以库存天数、替代供应商覆盖率、运输改道时间和流动性缺口作为月度韧性指标。
|
||||||
|
3. 对短期冲击优先使用信息协调、应急库存和融资支持,避免仓促、不可逆的全面脱钩措施。
|
||||||
|
4. 中长期推动来源与路线多元化,同时保留正常经贸交往的政策空间。
|
||||||
|
|
||||||
|
**优先动作:** 对关键民生物资开展30/60/90天压力测试,并为中小企业准备定向流动性工具。`,
|
||||||
|
},
|
||||||
|
"strategy-selection-angle-security": {
|
||||||
|
status: "done",
|
||||||
|
isDemo: true,
|
||||||
|
steps: ["构建预警指标", "划分风险等级", "设计降级动作"],
|
||||||
|
content: `### 安全风险策略:以预警和降级机制减少偶发事件外溢
|
||||||
|
|
||||||
|
1. 建立覆盖异常活动、通信中断、错误信息扩散、交通受阻和市场波动的综合预警看板。
|
||||||
|
2. 将风险划分为关注、紧张、严重三级,每级只配置克制、可逆、可解释的非升级性应对动作。
|
||||||
|
3. 对来源未核实的信息实行“双源确认+时点标注”,避免单一信号直接触发重大政策响应。
|
||||||
|
4. 所有高风险决策设置冷静期、法律复核和跨部门反方意见席位。
|
||||||
|
|
||||||
|
**止损线:** 一旦沟通渠道失效或民生影响超过阈值,优先恢复沟通与公共服务,暂停可能叠加风险的新措施。`,
|
||||||
|
},
|
||||||
|
"strategy-selection-angle-public-opinion": {
|
||||||
|
status: "done",
|
||||||
|
isDemo: true,
|
||||||
|
steps: ["分析公众需求", "保障基本服务", "设计信息发布"],
|
||||||
|
content: `### 社会民生策略:稳定预期并保护基本服务
|
||||||
|
|
||||||
|
1. 建立权威、固定频率的信息发布窗口,明确已知、未知和正在核验的事项。
|
||||||
|
2. 为交通、医疗、能源、通信和教育准备连续性方案,优先保障脆弱群体。
|
||||||
|
3. 监测谣言与恐慌性行为时,采用事实澄清和服务指引,不扩大未经证实的信息。
|
||||||
|
4. 设置独立反馈渠道,收集企业、社区和民众的实际困难并纳入每日研判。
|
||||||
|
|
||||||
|
**评价指标:** 基本服务可用率、权威信息触达率、谣言纠正时长和重点群体保障覆盖率。`,
|
||||||
|
},
|
||||||
|
"strategy-selection-angle-law-policy": {
|
||||||
|
status: "done",
|
||||||
|
isDemo: true,
|
||||||
|
steps: ["识别规则边界", "建立审查清单", "设计留痕机制"],
|
||||||
|
content: `### 法律规则策略:把合规审查嵌入每个决策节点
|
||||||
|
|
||||||
|
1. 为数据共享、经济措施、应急管理和对外沟通分别建立适用规则清单。
|
||||||
|
2. 重大措施记录事实依据、授权来源、比例性判断、影响评估和退出条件。
|
||||||
|
3. 涉及平民权益、基本服务和跨境活动的措施优先进行人道影响评估。
|
||||||
|
4. 对时效性强的事项采用“快速初审—事后完整复核”,但不得跳过最低合规门槛。
|
||||||
|
|
||||||
|
**优先动作:** 建立统一法律审查模板和决策证据包,保证后续可解释、可追溯。`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TAIWAN_STRAIT_DEMO_EVALUATIONS: StrategyEvaluation[] = [
|
||||||
|
{
|
||||||
|
agentId: "strategy-selection-angle-governance",
|
||||||
|
score: 92,
|
||||||
|
conclusion: "优先推荐",
|
||||||
|
comment: "跨部门闭环清晰,是其他策略落地的共同底座。",
|
||||||
|
strengths: ["责任明确", "阶段完整", "便于复盘"],
|
||||||
|
risks: ["需要稳定的牵头机制"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agentId: "strategy-selection-angle-diplomacy",
|
||||||
|
score: 90,
|
||||||
|
conclusion: "优先推荐",
|
||||||
|
comment: "直接作用于误判风险,且措施克制、可逆。",
|
||||||
|
strengths: ["降级效果直接", "渠道有备份"],
|
||||||
|
risks: ["依赖相关方持续参与"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agentId: "strategy-selection-angle-economy",
|
||||||
|
score: 86,
|
||||||
|
conclusion: "建议采用",
|
||||||
|
comment: "兼顾短期连续性与长期韧性,需控制过度防御成本。",
|
||||||
|
strengths: ["指标可量化", "避免一刀切"],
|
||||||
|
risks: ["跨行业数据获取成本较高"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agentId: "strategy-selection-angle-security",
|
||||||
|
score: 91,
|
||||||
|
conclusion: "优先推荐",
|
||||||
|
comment: "预警、确认和冷静期设计可显著降低错误触发。",
|
||||||
|
strengths: ["止损线清楚", "强调双源核验"],
|
||||||
|
risks: ["阈值需持续校准"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agentId: "strategy-selection-angle-public-opinion",
|
||||||
|
score: 84,
|
||||||
|
conclusion: "建议采用",
|
||||||
|
comment: "增强社会承压能力,建议与经济连续性方案合并实施。",
|
||||||
|
strengths: ["关注脆弱群体", "沟通指标明确"],
|
||||||
|
risks: ["需避免信息发布碎片化"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agentId: "strategy-selection-angle-law-policy",
|
||||||
|
score: 88,
|
||||||
|
conclusion: "建议采用",
|
||||||
|
comment: "为高压情境下的决策提供必要边界和可追溯性。",
|
||||||
|
strengths: ["审查嵌入流程", "留痕充分"],
|
||||||
|
risks: ["快速响应与完整审查需平衡"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TAIWAN_STRAIT_DEMO_SYNTHESIS = `# 台海局势风险管控与区域韧性提升综合方案
|
||||||
|
|
||||||
|
> **示例说明:** 本方案为产品功能演示数据,采用中立、降级避险和民生优先视角,不构成现实决策建议;正式使用前应依据最新可靠信息进行专业复核。
|
||||||
|
|
||||||
|
## 一、总体目标
|
||||||
|
|
||||||
|
在未来12—24个月内,以“减少误判、阻断升级、保护民生、维持连续性”为主线,建立可监测、可启动、可退出、可复盘的风险管控体系。任何措施均应符合适用规则,坚持克制、对等、可逆和可解释。
|
||||||
|
|
||||||
|
## 二、四条主策略
|
||||||
|
|
||||||
|
### 1. 联合研判与分级响应
|
||||||
|
|
||||||
|
- 建立跨部门常态研判单元,统一风险词典、数据口径和三级事件标准。
|
||||||
|
- 每个级别明确牵头主体、会商时限、启动阈值、允许动作和退出条件。
|
||||||
|
- 重大建议同时接受政策效果、升级风险、民生影响和法律合规审查。
|
||||||
|
|
||||||
|
### 2. 多层危机沟通与事实核验
|
||||||
|
|
||||||
|
- 维护工作层、专业部门和高层沟通渠道,并配置可信第三方备份。
|
||||||
|
- 紧急事项执行“双渠道回执、双来源确认、统一时点标注”。
|
||||||
|
- 公开沟通采用“事实—原则—下一步安排”结构,降低信号歧义。
|
||||||
|
|
||||||
|
### 3. 经济与公共服务连续性
|
||||||
|
|
||||||
|
- 围绕航运、能源、金融、通信、医疗和关键供应链建立中断情景清单。
|
||||||
|
- 用库存天数、替代覆盖率、改道时长、服务可用率等指标开展月度压力测试。
|
||||||
|
- 短期优先使用应急库存、信息协调和定向融资;中长期有序提升来源与路线多元化。
|
||||||
|
|
||||||
|
### 4. 规则嵌入与社会韧性
|
||||||
|
|
||||||
|
- 将授权来源、比例性、人道影响、退出条件和证据留存纳入统一审查模板。
|
||||||
|
- 通过固定频率的权威发布说明已知、未知和核验进度,提供清晰服务指引。
|
||||||
|
- 优先保障脆弱群体,并建立企业、社区与公众反馈的快速回流机制。
|
||||||
|
|
||||||
|
## 三、阶段安排
|
||||||
|
|
||||||
|
| 阶段 | 重点动作 | 主要产出 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 0—30天 | 完成责任矩阵、沟通清单、关键链路盘点与法律审查模板 | 一图一表一清单、首版预警看板 |
|
||||||
|
| 1—6个月 | 开展压力测试和桌面推演,打通双渠道确认与公众反馈 | 演练报告、阈值校准、改进台账 |
|
||||||
|
| 6—24个月 | 推进结构性韧性项目并季度复盘,动态退出低效措施 | 韧性提升项目、季度评估报告 |
|
||||||
|
|
||||||
|
## 四、核心预警指标
|
||||||
|
|
||||||
|
1. 沟通渠道可用率与紧急事项回执时长。
|
||||||
|
2. 未核实信息传播速度及权威澄清时长。
|
||||||
|
3. 关键运输、能源、金融、通信和公共服务可用率。
|
||||||
|
4. 市场异常波动、关键物资库存天数与替代供应覆盖率。
|
||||||
|
5. 脆弱群体保障覆盖率和公众反馈闭环时长。
|
||||||
|
|
||||||
|
## 五、止损与调整机制
|
||||||
|
|
||||||
|
当直接沟通失效、基本服务明显下降、信息可信度不足或风险指标跨级跃升时,优先恢复沟通、事实核验和民生服务;暂停新增高风险措施。每月复盘策略组合,每季度由独立评估组检查目标偏移、措施叠加和退出条件执行情况。
|
||||||
|
`;
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
/**
|
||||||
|
* Run from frontend-web:
|
||||||
|
* npx esbuild src/strategy-selection/lib.test.ts --bundle --format=esm --platform=node --outfile=.runtime/unit-tests/strategy-selection.test.mjs
|
||||||
|
* node --test .runtime/unit-tests/strategy-selection.test.mjs
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
areStrategyTaskDraftsEquivalent,
|
||||||
|
buildIntentBrief,
|
||||||
|
buildJudgeBrief,
|
||||||
|
buildSynthesisBrief,
|
||||||
|
extractFencedJson,
|
||||||
|
normalizeIntentClarification,
|
||||||
|
parseEvaluationsFromText,
|
||||||
|
parseSseChunk,
|
||||||
|
parseTaskDraftFromText,
|
||||||
|
stripStructuredBlocks,
|
||||||
|
} from "./lib";
|
||||||
|
import { EMPTY_TASK_DRAFT, STRATEGY_ANGLES } from "./constants";
|
||||||
|
import type { AgentOutputState, StrategyAngle } from "./types";
|
||||||
|
|
||||||
|
test("parseSseChunk preserves incomplete frames and parses subsequent frames", () => {
|
||||||
|
const first = parseSseChunk(
|
||||||
|
'data: {"type":"agent_start","agent_id":"a"}\n\ndata: {"type":"del',
|
||||||
|
);
|
||||||
|
assert.equal(first.events.length, 1);
|
||||||
|
assert.equal(first.events[0].type, "agent_start");
|
||||||
|
const second = parseSseChunk(`${first.rest}ta","agent_id":"a","text":"你好"}\n\n`);
|
||||||
|
assert.deepEqual(second.events, [
|
||||||
|
{ type: "delta", agent_id: "a", text: "你好" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeIntentClarification accepts string and object options", () => {
|
||||||
|
const clarification = normalizeIntentClarification({
|
||||||
|
type: "agent_clarification",
|
||||||
|
agent_id: "strategy-selection-intent",
|
||||||
|
question: "请选择时间范围",
|
||||||
|
options: ["短期", { id: "long", label: "长期", description: "一年以上" }],
|
||||||
|
allow_custom: true,
|
||||||
|
allow_multiple: false,
|
||||||
|
});
|
||||||
|
assert.equal(clarification.question, "请选择时间范围");
|
||||||
|
assert.deepEqual(clarification.options, [
|
||||||
|
{ id: "option-1", label: "短期", description: undefined },
|
||||||
|
{ id: "long", label: "长期", description: "一年以上" },
|
||||||
|
]);
|
||||||
|
assert.equal(clarification.allowCustom, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("task draft equivalence ignores whitespace and angle ordering only", () => {
|
||||||
|
const left = {
|
||||||
|
...EMPTY_TASK_DRAFT,
|
||||||
|
name: "区域 风险方案",
|
||||||
|
detail: "第一行\n第二行",
|
||||||
|
selectedAngleIds: ["governance", "diplomacy"],
|
||||||
|
};
|
||||||
|
const presentationOnlyChange = {
|
||||||
|
...left,
|
||||||
|
name: " 区域 风险方案 ",
|
||||||
|
detail: "第一行 第二行",
|
||||||
|
selectedAngleIds: ["diplomacy", "governance", "diplomacy"],
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
areStrategyTaskDraftsEquivalent(left, presentationOnlyChange),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
areStrategyTaskDraftsEquivalent(left, {
|
||||||
|
...presentationOnlyChange,
|
||||||
|
objective: "新增目标",
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("intent brief retains the clarification question and the user's confirmed answer", () => {
|
||||||
|
const brief = buildIntentBrief(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "clarify",
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
clarification: {
|
||||||
|
question: "方案主要面向谁?",
|
||||||
|
options: [{ id: "public", label: "公共部门" }],
|
||||||
|
allowCustom: true,
|
||||||
|
allowMultiple: false,
|
||||||
|
},
|
||||||
|
clarificationAnswered: true,
|
||||||
|
clarificationAnswer: "公共部门",
|
||||||
|
},
|
||||||
|
{ id: "answer", role: "user", content: "对澄清问题的回答:公共部门" },
|
||||||
|
],
|
||||||
|
EMPTY_TASK_DRAFT,
|
||||||
|
);
|
||||||
|
assert.match(brief, /意图助手澄清:方案主要面向谁/);
|
||||||
|
assert.match(brief, /用户对该澄清的确认:公共部门/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stripStructuredBlocks keeps prose and hides machine-readable payloads", () => {
|
||||||
|
assert.equal(
|
||||||
|
stripStructuredBlocks('说明文字\n[INTENT_READY]\n```strategy-task-json\n{"name":"x"}\n```'),
|
||||||
|
"说明文字",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extractFencedJson and parseTaskDraftFromText read the structured task block", () => {
|
||||||
|
const text = [
|
||||||
|
"已整理任务。",
|
||||||
|
"```strategy-task-json",
|
||||||
|
JSON.stringify({
|
||||||
|
name: "测试任务",
|
||||||
|
detail: "详情",
|
||||||
|
recommended_angles: ["governance", "unknown", "law_policy"],
|
||||||
|
}),
|
||||||
|
"```",
|
||||||
|
].join("\n");
|
||||||
|
assert.deepEqual(extractFencedJson(text, "strategy-task-json"), {
|
||||||
|
name: "测试任务",
|
||||||
|
detail: "详情",
|
||||||
|
recommended_angles: ["governance", "unknown", "law_policy"],
|
||||||
|
});
|
||||||
|
const task = parseTaskDraftFromText(text, EMPTY_TASK_DRAFT);
|
||||||
|
assert.equal(task?.name, "测试任务");
|
||||||
|
assert.deepEqual(task?.selectedAngleIds, ["governance", "law_policy"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("task parser accepts backend SOUL field aliases", () => {
|
||||||
|
const text = `\`\`\`strategy-task-json\n${JSON.stringify({
|
||||||
|
task_name: "别名任务",
|
||||||
|
task_detail: "别名详情",
|
||||||
|
expected_outputs: "综合方案",
|
||||||
|
recommended_angles: ["public_opinion", "strategy-selection-angle-law-policy"],
|
||||||
|
})}\n\`\`\``;
|
||||||
|
const task = parseTaskDraftFromText(text, EMPTY_TASK_DRAFT);
|
||||||
|
assert.equal(task?.name, "别名任务");
|
||||||
|
assert.equal(task?.detail, "别名详情");
|
||||||
|
assert.equal(task?.deliverables, "综合方案");
|
||||||
|
assert.deepEqual(task?.selectedAngleIds, ["public_opinion", "law_policy"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("task parser rejects incomplete or unrecognized current-turn payloads", () => {
|
||||||
|
const completeFallback = {
|
||||||
|
...EMPTY_TASK_DRAFT,
|
||||||
|
name: "旧任务",
|
||||||
|
detail: "旧详情",
|
||||||
|
selectedAngleIds: ["governance"],
|
||||||
|
};
|
||||||
|
const wrap = (payload: unknown) =>
|
||||||
|
`\`\`\`strategy-task-json\n${JSON.stringify(payload)}\n\`\`\``;
|
||||||
|
|
||||||
|
assert.equal(parseTaskDraftFromText(wrap({}), completeFallback), null);
|
||||||
|
assert.equal(parseTaskDraftFromText(wrap([]), completeFallback), null);
|
||||||
|
assert.equal(
|
||||||
|
parseTaskDraftFromText(
|
||||||
|
wrap({ name: "新任务", detail: "新详情", selectedAngleIds: ["unknown"] }),
|
||||||
|
completeFallback,
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
parseTaskDraftFromText(
|
||||||
|
wrap({ name: "新任务", detail: "新详情" }),
|
||||||
|
completeFallback,
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("evaluation parser validates scores without inventing missing candidates", () => {
|
||||||
|
const outputs: Record<string, AgentOutputState> = {
|
||||||
|
"strategy-selection-angle-governance": {
|
||||||
|
status: "done",
|
||||||
|
content: "A",
|
||||||
|
steps: [],
|
||||||
|
},
|
||||||
|
"strategy-selection-angle-law-policy": { status: "done", content: "B", steps: [] },
|
||||||
|
"strategy-selection-angle-security": {
|
||||||
|
status: "error",
|
||||||
|
content: "PARTIAL_FAILED_OUTPUT",
|
||||||
|
steps: [],
|
||||||
|
error: "failed",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const text = `\`\`\`strategy-evaluation-json\n${JSON.stringify({
|
||||||
|
evaluations: [
|
||||||
|
{
|
||||||
|
agentId: "strategy-selection-angle-governance",
|
||||||
|
score: 100,
|
||||||
|
conclusion: "优先推荐",
|
||||||
|
comment: "清晰",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})}\n\`\`\``;
|
||||||
|
const evaluations = parseEvaluationsFromText(text, outputs);
|
||||||
|
assert.equal(evaluations.length, 1);
|
||||||
|
assert.equal(evaluations[0].score, 100);
|
||||||
|
assert.equal(
|
||||||
|
evaluations.some(
|
||||||
|
(item) => item.agentId === "strategy-selection-angle-law-policy",
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(buildJudgeBrief(EMPTY_TASK_DRAFT, outputs).includes("PARTIAL_FAILED_OUTPUT"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("judge prompt and evaluation parser only use task-selected angles", () => {
|
||||||
|
const outputs: Record<string, AgentOutputState> = {
|
||||||
|
"strategy-selection-angle-governance": { status: "done", content: "治理策略", steps: [] },
|
||||||
|
"strategy-selection-angle-law-policy": { status: "done", content: "法律策略", steps: [] },
|
||||||
|
};
|
||||||
|
const task = { ...EMPTY_TASK_DRAFT, selectedAngleIds: ["governance"] };
|
||||||
|
const brief = buildJudgeBrief(task, outputs);
|
||||||
|
assert.equal(brief.includes("治理策略"), true);
|
||||||
|
assert.equal(brief.includes("法律策略"), false);
|
||||||
|
const evaluations = parseEvaluationsFromText("无结构化量表", outputs, [
|
||||||
|
"strategy-selection-angle-governance",
|
||||||
|
]);
|
||||||
|
assert.deepEqual(evaluations, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dynamic administrator angles flow through intent parsing and judging without fabricated evaluation", () => {
|
||||||
|
const dynamic: StrategyAngle = {
|
||||||
|
id: "technology",
|
||||||
|
agentId: "strategy-selection-angle-dynamic-tech",
|
||||||
|
name: "科技与产业链",
|
||||||
|
shortName: "科技",
|
||||||
|
description: "关注技术依赖与产业协同",
|
||||||
|
accent: "#2563eb",
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
const task = {
|
||||||
|
...EMPTY_TASK_DRAFT,
|
||||||
|
selectedAngleIds: [dynamic.id],
|
||||||
|
};
|
||||||
|
const parsed = parseTaskDraftFromText(
|
||||||
|
`\`\`\`strategy-task-json\n${JSON.stringify({
|
||||||
|
name: "动态任务",
|
||||||
|
detail: "测试管理员新增角度",
|
||||||
|
selectedAngleIds: [dynamic.agentId],
|
||||||
|
})}\n\`\`\``,
|
||||||
|
task,
|
||||||
|
[dynamic],
|
||||||
|
);
|
||||||
|
assert.deepEqual(parsed?.selectedAngleIds, [dynamic.id]);
|
||||||
|
assert.match(buildIntentBrief([], task, [dynamic]), /technology:科技与产业链/);
|
||||||
|
|
||||||
|
const outputs: Record<string, AgentOutputState> = {
|
||||||
|
[dynamic.agentId]: { status: "done", content: "科技策略", steps: [] },
|
||||||
|
};
|
||||||
|
assert.match(buildJudgeBrief(task, outputs, [dynamic]), /科技策略/);
|
||||||
|
assert.deepEqual(
|
||||||
|
parseEvaluationsFromText("没有结构化评判", outputs, [dynamic.agentId], [dynamic]),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("evaluation parser rejects missing fields, invalid scores, and duplicate agent rows", () => {
|
||||||
|
const agentId = "strategy-selection-angle-governance";
|
||||||
|
const outputs: Record<string, AgentOutputState> = {
|
||||||
|
[agentId]: { status: "done", content: "治理策略", steps: [] },
|
||||||
|
};
|
||||||
|
const wrap = (evaluations: Record<string, unknown>[]) =>
|
||||||
|
`\`\`\`strategy-evaluation-json\n${JSON.stringify({ evaluations })}\n\`\`\``;
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
parseEvaluationsFromText(
|
||||||
|
`\`\`\`strategy-evaluation-json\n${JSON.stringify({ evaluations: [null] })}\n\`\`\``,
|
||||||
|
outputs,
|
||||||
|
[agentId],
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
parseEvaluationsFromText(wrap([{ agentId, conclusion: "建议采用" }]), outputs, [agentId]),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
parseEvaluationsFromText(
|
||||||
|
wrap([{ agentId, score: 80.5, conclusion: "建议采用" }]),
|
||||||
|
outputs,
|
||||||
|
[agentId],
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
parseEvaluationsFromText(
|
||||||
|
wrap([
|
||||||
|
{ agentId, score: 80, conclusion: "建议采用" },
|
||||||
|
{ agentId, score: 75, conclusion: "保留备选" },
|
||||||
|
]),
|
||||||
|
outputs,
|
||||||
|
[agentId],
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("synthesis prompt exposes only the user's final strategy basket", () => {
|
||||||
|
const governance = STRATEGY_ANGLES.find((angle) => angle.id === "governance")!;
|
||||||
|
const security = STRATEGY_ANGLES.find((angle) => angle.id === "security")!;
|
||||||
|
const task = {
|
||||||
|
...EMPTY_TASK_DRAFT,
|
||||||
|
selectedAngleIds: STRATEGY_ANGLES.map((angle) => angle.id),
|
||||||
|
};
|
||||||
|
const outputs: Record<string, AgentOutputState> = {
|
||||||
|
[governance.agentId]: { status: "done", content: "治理入选正文", steps: [] },
|
||||||
|
[security.agentId]: { status: "done", content: "安全未入选正文", steps: [] },
|
||||||
|
};
|
||||||
|
const brief = buildSynthesisBrief(
|
||||||
|
task,
|
||||||
|
[governance.agentId],
|
||||||
|
outputs,
|
||||||
|
[],
|
||||||
|
STRATEGY_ANGLES,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
brief,
|
||||||
|
new RegExp(`最终只允许整合以下 1 项:${governance.name}`),
|
||||||
|
);
|
||||||
|
assert.match(brief, /治理入选正文/);
|
||||||
|
assert.equal(brief.includes("安全未入选正文"), false);
|
||||||
|
assert.match(brief, /"selectedAngleIds": \[\s*"governance"\s*\]/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
import { STRATEGY_ANGLES } from "./constants";
|
||||||
|
import type {
|
||||||
|
AgentOutputState,
|
||||||
|
IntentClarification,
|
||||||
|
IntentTurn,
|
||||||
|
ParallelAgentEvent,
|
||||||
|
StrategyAngle,
|
||||||
|
StrategyEvaluation,
|
||||||
|
StrategyTaskDraft,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
function stringValue(value: unknown): string {
|
||||||
|
return typeof value === "string" ? value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeIntentClarification(
|
||||||
|
event: Extract<ParallelAgentEvent, { type: "agent_clarification" }>,
|
||||||
|
): IntentClarification {
|
||||||
|
const question =
|
||||||
|
stringValue(event.question) ||
|
||||||
|
stringValue(event.content) ||
|
||||||
|
"请补充完成任务定义所需的信息。";
|
||||||
|
const options = Array.isArray(event.options)
|
||||||
|
? event.options
|
||||||
|
.map((option, index) => {
|
||||||
|
const label =
|
||||||
|
typeof option === "string" ? stringValue(option) : stringValue(option?.label);
|
||||||
|
if (!label) return null;
|
||||||
|
return {
|
||||||
|
id:
|
||||||
|
(typeof option === "string" ? "" : stringValue(option.id)) ||
|
||||||
|
`option-${index + 1}`,
|
||||||
|
label,
|
||||||
|
description:
|
||||||
|
(typeof option === "string" ? "" : stringValue(option.description)) ||
|
||||||
|
undefined,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((option): option is NonNullable<typeof option> => option !== null)
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
question,
|
||||||
|
context:
|
||||||
|
stringValue(event.clarification_context) ||
|
||||||
|
stringValue(event.context) ||
|
||||||
|
undefined,
|
||||||
|
clarificationType: stringValue(event.clarification_type) || undefined,
|
||||||
|
options,
|
||||||
|
allowCustom: event.allow_custom !== false || options.length === 0,
|
||||||
|
allowMultiple: event.allow_multiple === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedStrategyAgentIds(
|
||||||
|
task: StrategyTaskDraft,
|
||||||
|
angles: StrategyAngle[],
|
||||||
|
): Set<string> {
|
||||||
|
return new Set(
|
||||||
|
angles.filter((angle) => task.selectedAngleIds.includes(angle.id)).map(
|
||||||
|
(angle) => angle.agentId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TASK_DRAFT_TEXT_FIELDS = [
|
||||||
|
"name",
|
||||||
|
"detail",
|
||||||
|
"objective",
|
||||||
|
"prerequisites",
|
||||||
|
"constraints",
|
||||||
|
"deliverables",
|
||||||
|
"acceptanceCriteria",
|
||||||
|
"horizon",
|
||||||
|
] as const satisfies readonly (keyof StrategyTaskDraft)[];
|
||||||
|
|
||||||
|
function normalizeTaskDraftText(value: string): string {
|
||||||
|
return value.trim().replace(/\s+/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Treat presentation-only whitespace and angle ordering as equivalent so an
|
||||||
|
* intent-agent round trip cannot invalidate an already generated workflow
|
||||||
|
* unless the task's meaning actually changed.
|
||||||
|
*/
|
||||||
|
export function areStrategyTaskDraftsEquivalent(
|
||||||
|
left: StrategyTaskDraft,
|
||||||
|
right: StrategyTaskDraft,
|
||||||
|
): boolean {
|
||||||
|
const textFieldsMatch = TASK_DRAFT_TEXT_FIELDS.every(
|
||||||
|
(field) =>
|
||||||
|
normalizeTaskDraftText(left[field] as string) ===
|
||||||
|
normalizeTaskDraftText(right[field] as string),
|
||||||
|
);
|
||||||
|
if (!textFieldsMatch) return false;
|
||||||
|
|
||||||
|
const leftAngles = [...new Set(left.selectedAngleIds)].sort();
|
||||||
|
const rightAngles = [...new Set(right.selectedAngleIds)].sort();
|
||||||
|
return (
|
||||||
|
leftAngles.length === rightAngles.length &&
|
||||||
|
leftAngles.every((angleId, index) => angleId === rightAngles[index])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringList(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.map(stringValue).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractFencedJson<T>(text: string, marker?: string): T | null {
|
||||||
|
const escaped = marker?.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const patterns = [
|
||||||
|
escaped ? new RegExp("```" + escaped + "\\s*([\\s\\S]*?)```", "i") : null,
|
||||||
|
/```(?:json)?\s*([\s\S]*?)```/i,
|
||||||
|
].filter(Boolean) as RegExp[];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = pattern.exec(text);
|
||||||
|
if (!match?.[1]) continue;
|
||||||
|
try {
|
||||||
|
return JSON.parse(match[1].trim()) as T;
|
||||||
|
} catch {
|
||||||
|
// The model may have emitted another fenced block first; fall through to
|
||||||
|
// the balanced-object fallback below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstObject = text.indexOf("{");
|
||||||
|
const lastObject = text.lastIndexOf("}");
|
||||||
|
if (firstObject >= 0 && lastObject > firstObject) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text.slice(firstObject, lastObject + 1)) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripStructuredBlocks(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/```(?:strategy-task-json|strategy-evaluation-json)\s*[\s\S]*?```/gi, "")
|
||||||
|
.replace(/\[(?:INTENT_READY|JUDGEMENT_READY)\]/gi, "")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTaskDraftFromText(
|
||||||
|
text: string,
|
||||||
|
fallback: StrategyTaskDraft,
|
||||||
|
angles: StrategyAngle[] = STRATEGY_ANGLES,
|
||||||
|
): StrategyTaskDraft | null {
|
||||||
|
const parsed = extractFencedJson<Record<string, unknown>>(text, "strategy-task-json");
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
||||||
|
const rawName = parsed.name ?? parsed.task_name;
|
||||||
|
const rawDetail = parsed.detail ?? parsed.task_detail;
|
||||||
|
const rawAngles = parsed.selectedAngleIds ?? parsed.recommended_angles;
|
||||||
|
if (
|
||||||
|
typeof rawName !== "string" ||
|
||||||
|
!rawName.trim() ||
|
||||||
|
typeof rawDetail !== "string" ||
|
||||||
|
!rawDetail.trim() ||
|
||||||
|
!Array.isArray(rawAngles)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const angleIds = [
|
||||||
|
...new Set(
|
||||||
|
rawAngles
|
||||||
|
.map(stringValue)
|
||||||
|
.map((value) => {
|
||||||
|
const normalized = value.toLowerCase().replaceAll("-", "_");
|
||||||
|
return angles.find(
|
||||||
|
(angle) =>
|
||||||
|
angle.id === normalized ||
|
||||||
|
angle.agentId === value ||
|
||||||
|
angle.name === value ||
|
||||||
|
angle.shortName === value,
|
||||||
|
)?.id;
|
||||||
|
})
|
||||||
|
.filter((id): id is string => Boolean(id)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (!angleIds.length) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: stringValue(rawName),
|
||||||
|
detail: stringValue(rawDetail),
|
||||||
|
objective: stringValue(parsed.objective) || fallback.objective,
|
||||||
|
prerequisites:
|
||||||
|
stringValue(parsed.prerequisites) || fallback.prerequisites,
|
||||||
|
constraints: stringValue(parsed.constraints) || fallback.constraints,
|
||||||
|
deliverables:
|
||||||
|
stringValue(parsed.deliverables ?? parsed.expected_outputs) || fallback.deliverables,
|
||||||
|
acceptanceCriteria:
|
||||||
|
stringValue(parsed.acceptanceCriteria) || fallback.acceptanceCriteria,
|
||||||
|
horizon: stringValue(parsed.horizon) || fallback.horizon,
|
||||||
|
selectedAngleIds: angleIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseEvaluationsFromText(
|
||||||
|
text: string,
|
||||||
|
outputs: Record<string, AgentOutputState>,
|
||||||
|
allowedAgentIds?: Iterable<string>,
|
||||||
|
angles: StrategyAngle[] = STRATEGY_ANGLES,
|
||||||
|
): StrategyEvaluation[] {
|
||||||
|
const parsed = extractFencedJson<
|
||||||
|
{ evaluations?: Record<string, unknown>[] } | Record<string, unknown>[]
|
||||||
|
>(text, "strategy-evaluation-json");
|
||||||
|
const rows = Array.isArray(parsed) ? parsed : parsed?.evaluations;
|
||||||
|
if (!Array.isArray(rows)) return [];
|
||||||
|
|
||||||
|
const allowed = allowedAgentIds ? new Set(allowedAgentIds) : null;
|
||||||
|
const knownIds = new Set(
|
||||||
|
angles.filter((angle) => !allowed || allowed.has(angle.agentId)).map(
|
||||||
|
(angle) => angle.agentId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const evaluations: StrategyEvaluation[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row || typeof row !== "object" || Array.isArray(row)) return [];
|
||||||
|
const agentId = stringValue(row.agentId ?? row.agent_id);
|
||||||
|
if (!knownIds.has(agentId)) continue;
|
||||||
|
|
||||||
|
const score = row.score;
|
||||||
|
const conclusion = stringValue(row.conclusion);
|
||||||
|
if (
|
||||||
|
seen.has(agentId) ||
|
||||||
|
outputs[agentId]?.status !== "done" ||
|
||||||
|
!outputs[agentId]?.content.trim() ||
|
||||||
|
typeof score !== "number" ||
|
||||||
|
!Number.isFinite(score) ||
|
||||||
|
!Number.isInteger(score) ||
|
||||||
|
score < 0 ||
|
||||||
|
score > 100 ||
|
||||||
|
(conclusion !== "优先推荐" &&
|
||||||
|
conclusion !== "建议采用" &&
|
||||||
|
conclusion !== "保留备选")
|
||||||
|
) {
|
||||||
|
// Reject the entire structured result instead of inventing defaults or
|
||||||
|
// allowing duplicate rows to be selected twice downstream.
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
seen.add(agentId);
|
||||||
|
evaluations.push({
|
||||||
|
agentId,
|
||||||
|
score,
|
||||||
|
conclusion,
|
||||||
|
comment: stringValue(row.comment) || "请结合完整评判正文审阅。",
|
||||||
|
strengths: stringList(row.strengths),
|
||||||
|
risks: stringList(row.risks),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowed && [...allowed].some((agentId) => !seen.has(agentId))) return [];
|
||||||
|
|
||||||
|
return evaluations.sort((a, b) => b.score - a.score);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildIntentBrief(
|
||||||
|
turns: IntentTurn[],
|
||||||
|
currentDraft: StrategyTaskDraft,
|
||||||
|
angles: StrategyAngle[] = STRATEGY_ANGLES,
|
||||||
|
): string {
|
||||||
|
const conversation = turns
|
||||||
|
.filter(
|
||||||
|
(turn) =>
|
||||||
|
turn.content.trim() ||
|
||||||
|
turn.clarification?.question.trim() ||
|
||||||
|
turn.clarificationAnswer?.trim(),
|
||||||
|
)
|
||||||
|
.map((turn) => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (turn.content.trim()) {
|
||||||
|
lines.push(
|
||||||
|
`${turn.role === "user" ? "用户" : "意图助手"}:${turn.content}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (turn.clarification) {
|
||||||
|
const optionText = turn.clarification.options
|
||||||
|
.map((option) => option.label)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" / ");
|
||||||
|
lines.push(
|
||||||
|
`意图助手澄清:${turn.clarification.question}${
|
||||||
|
optionText ? `(可选:${optionText})` : ""
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (turn.clarificationAnswer?.trim()) {
|
||||||
|
lines.push(`用户对该澄清的确认:${turn.clarificationAnswer}`);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
})
|
||||||
|
.join("\n\n");
|
||||||
|
const availableAngles = angles
|
||||||
|
.filter((angle) => angle.enabled !== false)
|
||||||
|
.map((angle) => `- ${angle.id}:${angle.name}(${angle.description})`)
|
||||||
|
.join("\n");
|
||||||
|
const exampleAngleIds = angles
|
||||||
|
.filter((angle) => angle.enabled !== false)
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((angle) => angle.id);
|
||||||
|
return `你负责意图理解与任务定义。基于以下对话整理任务。不要虚构实时事实。\n\n当前草稿(可据新信息修订):\n${JSON.stringify(currentDraft, null, 2)}\n\n本系统当前可选角度(只能从下列 id 中选择):\n${availableAngles}\n\n对话:\n${conversation}\n\n请严格二选一执行:\n1. 如果不同答案会显著改变任务方向,立即调用 ask_clarification,只提一个最关键问题并提供清晰互斥选项;调用后停止,不输出任务 JSON,也不要在普通正文里假装提问。\n2. 如果信息已足够(包括用户刚回答了澄清),先用简洁自然语言复述整理结果,再在回复末尾追加以下可机器读取代码块,字段名不得改变:\n\`\`\`strategy-task-json\n{"name":"任务名称","detail":"任务详情","objective":"目标","prerequisites":"前置条件","constraints":"约束与边界","deliverables":"交付物","acceptanceCriteria":"验收标准","horizon":"时间范围","selectedAngleIds":${JSON.stringify(exampleAngleIds)}}\n\`\`\`\n只选择与任务有关的角度。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStrategyBrief(
|
||||||
|
task: StrategyTaskDraft,
|
||||||
|
selectedAngles = STRATEGY_ANGLES.filter((angle) =>
|
||||||
|
task.selectedAngleIds.includes(angle.id),
|
||||||
|
),
|
||||||
|
): string {
|
||||||
|
const angles = selectedAngles
|
||||||
|
.map((angle) => `- ${angle.name}:${angle.description}`)
|
||||||
|
.join("\n");
|
||||||
|
return `你是其中一个专业角度的策略生成智能体,只从自己的职责角度输出一套可独立审阅的策略。\n\n任务详情:\n${JSON.stringify(task, null, 2)}\n\n本轮并行角度:\n${angles}\n\n请给出:核心判断、3—6项策略动作、实施阶段、触发/退出条件、指标、主要风险与协同依赖。避免空泛口号;涉及动态事实时说明需复核。工具失败、文件路径、权限问题和检索计划属于执行过程,不得混入策略正文;确有信息缺口时只在独立的“数据限制与待核验”小节简要列明。对于地缘政治议题,坚持中立、降级避险、民生优先,不输出军事战术、目标选择、武器运用或规避监管建议。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildJudgeBrief(
|
||||||
|
task: StrategyTaskDraft,
|
||||||
|
outputs: Record<string, AgentOutputState>,
|
||||||
|
angles: StrategyAngle[] = STRATEGY_ANGLES,
|
||||||
|
): string {
|
||||||
|
const selectedAgentIds = selectedStrategyAgentIds(task, angles);
|
||||||
|
const strategies = angles.filter(
|
||||||
|
(angle) =>
|
||||||
|
selectedAgentIds.has(angle.agentId) &&
|
||||||
|
outputs[angle.agentId]?.status === "done" &&
|
||||||
|
outputs[angle.agentId]?.content.trim(),
|
||||||
|
)
|
||||||
|
.map(
|
||||||
|
(angle) =>
|
||||||
|
`## ${angle.name}\nagentId: ${angle.agentId}\n${outputs[angle.agentId].content}`,
|
||||||
|
)
|
||||||
|
.join("\n\n");
|
||||||
|
return `你是独立策略评判智能体。请依据任务目标,对候选策略的目标贡献、可执行性、风险可控性、协同性和合规性进行比较。先输出总体评判与冲突提醒,再在末尾输出结构化量表。工具失败、文件路径、权限问题和检索计划属于执行过程,不得写进评判正文;确有信息缺口时只在独立的“数据限制与待核验”小节简要列明。\n\n任务:\n${JSON.stringify(task, null, 2)}\n\n候选策略:\n${strategies}\n\n末尾必须追加:\n\`\`\`strategy-evaluation-json\n{"evaluations":[{"agentId":"候选agentId","score":0,"conclusion":"优先推荐|建议采用|保留备选","comment":"一句评价","strengths":["优势"],"risks":["风险"]}]}\n\`\`\`\n必须覆盖每个候选策略,score为0—100整数。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSynthesisBrief(
|
||||||
|
task: StrategyTaskDraft,
|
||||||
|
selectedAgentIds: string[],
|
||||||
|
outputs: Record<string, AgentOutputState>,
|
||||||
|
evaluations: StrategyEvaluation[],
|
||||||
|
angles: StrategyAngle[] = STRATEGY_ANGLES,
|
||||||
|
): string {
|
||||||
|
const evaluationMap = new Map(evaluations.map((item) => [item.agentId, item]));
|
||||||
|
const uniqueSelectedAgentIds = [...new Set(selectedAgentIds)];
|
||||||
|
const selectedAngles = uniqueSelectedAgentIds
|
||||||
|
.map((agentId) => angles.find((item) => item.agentId === agentId))
|
||||||
|
.filter((angle): angle is StrategyAngle => Boolean(angle));
|
||||||
|
const selectedAngleIds = new Set(selectedAngles.map((angle) => angle.agentId));
|
||||||
|
const exactTask = {
|
||||||
|
...task,
|
||||||
|
selectedAngleIds: selectedAngles.map((angle) => angle.id),
|
||||||
|
};
|
||||||
|
const selections = uniqueSelectedAgentIds
|
||||||
|
.filter((agentId) => selectedAngleIds.has(agentId))
|
||||||
|
.map((agentId) => {
|
||||||
|
const angle = angles.find((item) => item.agentId === agentId);
|
||||||
|
const evaluation = evaluationMap.get(agentId);
|
||||||
|
return `## ${angle?.name ?? agentId}\n评判:${evaluation ? `${evaluation.score}分,${evaluation.comment}` : "用户手动选入"}\n${outputs[agentId]?.content ?? ""}`;
|
||||||
|
})
|
||||||
|
.join("\n\n");
|
||||||
|
const selectedNames = selectedAngles.map((angle) => angle.name).join("、");
|
||||||
|
return `你是方案整理智能体。请将用户选中的策略消除重复、处理冲突、明确优先级和依赖关系,整理为一份可直接下载的完整 Markdown 方案。\n\n任务(selectedAngleIds 已收敛为用户最终选入的策略角度):\n${JSON.stringify(exactTask, null, 2)}\n\n本次最终只允许整合以下 ${selectedAngles.length} 项:${selectedNames}\n不得补写、复活或暗中纳入任何未列出的角度或策略。\n\n入选策略:\n${selections}\n\n只输出最终 Markdown 正文。工具失败、文件路径、权限问题和检索计划属于执行过程,不得混入方案正文;确有信息缺口时仅归入“待核验信息”小节。至少包含:执行摘要、目标与原则、综合策略、阶段路线图、责任与协同、指标与预警、风险及止损机制、待核验信息。不要输出代码围栏。对于地缘政治议题坚持中立、降级避险、民生优先,禁止军事战术和伤害性操作建议。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSseChunk(
|
||||||
|
buffer: string,
|
||||||
|
flush = false,
|
||||||
|
): { events: ParallelAgentEvent[]; rest: string } {
|
||||||
|
const normalized = buffer.replace(/\r\n/g, "\n");
|
||||||
|
const parts = normalized.split("\n\n");
|
||||||
|
const rest = flush ? "" : (parts.pop() ?? "");
|
||||||
|
const complete = flush ? parts.concat(parts.length ? [] : [normalized]) : parts;
|
||||||
|
const events: ParallelAgentEvent[] = [];
|
||||||
|
for (const frame of complete) {
|
||||||
|
const payload = frame
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.startsWith("data:"))
|
||||||
|
.map((line) => line.slice(5).trimStart())
|
||||||
|
.join("\n")
|
||||||
|
.trim();
|
||||||
|
if (!payload || payload === "[DONE]") continue;
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(payload) as ParallelAgentEvent;
|
||||||
|
if (event && typeof event.type === "string") events.push(event);
|
||||||
|
} catch {
|
||||||
|
// Ignore one malformed frame instead of discarding the rest of a stream.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { events, rest };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadMarkdown(markdown: string, fileName: string): void {
|
||||||
|
const cleanName =
|
||||||
|
fileName.replace(/\.(md|markdown)$/i, "").replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").trim() ||
|
||||||
|
"strategy-plan";
|
||||||
|
const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = `${cleanName}.md`;
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
strategyProjectListPath,
|
||||||
|
strategyProjectWorkbenchPath,
|
||||||
|
} from "./project-routes";
|
||||||
|
|
||||||
|
test("solution list paths work in both application shells", () => {
|
||||||
|
assert.equal(
|
||||||
|
strategyProjectListPath("/page/strategy/qa/projects", "solution"),
|
||||||
|
"/page/strategy/qa/projects?type=solution",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
strategyProjectListPath("/official/workspace/projects/", "solution"),
|
||||||
|
"/official/workspace/projects?type=solution",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("solution workbench paths encode ids and request the solution module", () => {
|
||||||
|
assert.equal(
|
||||||
|
strategyProjectWorkbenchPath(
|
||||||
|
"/official/workspace/projects",
|
||||||
|
"strategy-selection-a/b",
|
||||||
|
"solution",
|
||||||
|
),
|
||||||
|
"/official/workspace/projects/strategy-selection-a%2Fb?module=solution",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export type StrategyProjectListCategory =
|
||||||
|
| "all"
|
||||||
|
| "qa"
|
||||||
|
| "strategy"
|
||||||
|
| "solution";
|
||||||
|
|
||||||
|
function normalizeBasePath(basePath: string): string {
|
||||||
|
return basePath.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function strategyProjectListPath(
|
||||||
|
basePath: string,
|
||||||
|
category: StrategyProjectListCategory = "all",
|
||||||
|
): string {
|
||||||
|
const path = normalizeBasePath(basePath);
|
||||||
|
return category === "all"
|
||||||
|
? path
|
||||||
|
: `${path}?type=${encodeURIComponent(category)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function strategyProjectWorkbenchPath(
|
||||||
|
basePath: string,
|
||||||
|
projectId: string,
|
||||||
|
module?: "solution",
|
||||||
|
): string {
|
||||||
|
const path = `${normalizeBasePath(basePath)}/${encodeURIComponent(projectId)}`;
|
||||||
|
return module === "solution" ? `${path}?module=solution` : path;
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* Run from frontend-web:
|
||||||
|
* npx esbuild src/strategy-selection/project-wal.test.ts --bundle --format=esm --platform=node --outfile=.runtime/unit-tests/project-wal.test.mjs
|
||||||
|
* node --test .runtime/unit-tests/project-wal.test.mjs
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import type { StrategyProjectSnapshot } from "./projects";
|
||||||
|
import {
|
||||||
|
clearStrategyProjectWal,
|
||||||
|
isStrategyProjectWalExpired,
|
||||||
|
LatestByKeyScheduler,
|
||||||
|
resolveStrategyProjectWal,
|
||||||
|
strategyProjectSnapshotHash,
|
||||||
|
strategyProjectWalKey,
|
||||||
|
writeStrategyProjectWal,
|
||||||
|
} from "./project-wal";
|
||||||
|
|
||||||
|
const createSnapshot = (): StrategyProjectSnapshot => ({
|
||||||
|
version: 1,
|
||||||
|
angleCatalog: [],
|
||||||
|
stage: "intent",
|
||||||
|
taskDraft: {
|
||||||
|
name: "",
|
||||||
|
detail: "",
|
||||||
|
objective: "",
|
||||||
|
prerequisites: "",
|
||||||
|
constraints: "",
|
||||||
|
deliverables: "",
|
||||||
|
acceptanceCriteria: "",
|
||||||
|
horizon: "",
|
||||||
|
selectedAngleIds: [],
|
||||||
|
},
|
||||||
|
turns: [],
|
||||||
|
outputs: {},
|
||||||
|
judgeStatus: "idle",
|
||||||
|
judgeContent: "",
|
||||||
|
evaluations: [],
|
||||||
|
selectedStrategyIds: [],
|
||||||
|
synthesis: "",
|
||||||
|
synthesisStatus: "idle",
|
||||||
|
solutionSavedAt: null,
|
||||||
|
isDemo: false,
|
||||||
|
workflowStale: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
test("WAL keys isolate accounts and projects", () => {
|
||||||
|
assert.notEqual(
|
||||||
|
strategyProjectWalKey("account:one", "project-a"),
|
||||||
|
strategyProjectWalKey("account:two", "project-a"),
|
||||||
|
);
|
||||||
|
assert.notEqual(
|
||||||
|
strategyProjectWalKey("account:one", "project-a"),
|
||||||
|
strategyProjectWalKey("account:one", "project-b"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("snapshot hashes are stable and change with project content", async () => {
|
||||||
|
const snapshot = createSnapshot();
|
||||||
|
const same = createSnapshot();
|
||||||
|
const changed = {
|
||||||
|
...createSnapshot(),
|
||||||
|
taskDraft: {
|
||||||
|
...createSnapshot().taskDraft,
|
||||||
|
name: "已修改任务",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
await strategyProjectSnapshotHash(snapshot),
|
||||||
|
await strategyProjectSnapshotHash(same),
|
||||||
|
);
|
||||||
|
assert.notEqual(
|
||||||
|
await strategyProjectSnapshotHash(snapshot),
|
||||||
|
await strategyProjectSnapshotHash(changed),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("WAL degrades safely when IndexedDB is unavailable", async () => {
|
||||||
|
const snapshot = createSnapshot();
|
||||||
|
assert.equal(
|
||||||
|
await writeStrategyProjectWal(
|
||||||
|
"account:test",
|
||||||
|
"project-test",
|
||||||
|
"base-hash",
|
||||||
|
snapshot,
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await clearStrategyProjectWal("account:test", "project-test", snapshot),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("WAL recovery uses ancestry hashes instead of incomparable clocks", async () => {
|
||||||
|
const server = createSnapshot();
|
||||||
|
const local = {
|
||||||
|
...createSnapshot(),
|
||||||
|
taskDraft: { ...createSnapshot().taskDraft, name: "本地编辑" },
|
||||||
|
};
|
||||||
|
const otherServer = {
|
||||||
|
...createSnapshot(),
|
||||||
|
taskDraft: { ...createSnapshot().taskDraft, name: "服务器编辑" },
|
||||||
|
};
|
||||||
|
const serverHash = await strategyProjectSnapshotHash(server);
|
||||||
|
const localHash = await strategyProjectSnapshotHash(local);
|
||||||
|
const otherServerHash = await strategyProjectSnapshotHash(otherServer);
|
||||||
|
const wal = { baseServerHash: serverHash, snapshotHash: localHash };
|
||||||
|
|
||||||
|
assert.equal(resolveStrategyProjectWal(wal, serverHash), "recover");
|
||||||
|
assert.equal(resolveStrategyProjectWal(wal, localHash), "same");
|
||||||
|
assert.equal(resolveStrategyProjectWal(wal, otherServerHash), "conflict");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("latest-by-key scheduler bounds queued streaming snapshots", async () => {
|
||||||
|
let releaseFirst!: () => void;
|
||||||
|
const firstBlocked = new Promise<void>((resolve) => {
|
||||||
|
releaseFirst = resolve;
|
||||||
|
});
|
||||||
|
const calls: number[] = [];
|
||||||
|
const scheduler = new LatestByKeyScheduler<number, number>(async (value) => {
|
||||||
|
calls.push(value);
|
||||||
|
if (value === 1) await firstBlocked;
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = scheduler.schedule("project", 1);
|
||||||
|
await Promise.resolve();
|
||||||
|
const superseded = scheduler.schedule("project", 2);
|
||||||
|
const latest = scheduler.schedule("project", 3);
|
||||||
|
assert.equal(await superseded, null);
|
||||||
|
releaseFirst();
|
||||||
|
|
||||||
|
assert.equal(await first, 1);
|
||||||
|
assert.equal(await latest, 3);
|
||||||
|
assert.deepEqual(calls, [1, 3]);
|
||||||
|
await scheduler.waitForIdle("project");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("WAL expiry is deterministic at the retention boundary", () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const sixDaysAgo = now - 6 * 24 * 60 * 60 * 1000;
|
||||||
|
const eightDaysAgo = now - 8 * 24 * 60 * 60 * 1000;
|
||||||
|
assert.equal(isStrategyProjectWalExpired(sixDaysAgo, now), false);
|
||||||
|
assert.equal(isStrategyProjectWalExpired(eightDaysAgo, now), true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import type { StrategyProjectSnapshot } from "./projects";
|
||||||
|
|
||||||
|
const DATABASE_NAME = "deerflow-strategy-project-wal";
|
||||||
|
const STORE_NAME = "drafts";
|
||||||
|
const DATABASE_VERSION = 1;
|
||||||
|
const WAL_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface StrategyProjectWalRecord {
|
||||||
|
key: string;
|
||||||
|
userKey: string;
|
||||||
|
projectId: string;
|
||||||
|
/** Server snapshot on top of which this local edit was made. */
|
||||||
|
baseServerHash: string;
|
||||||
|
snapshotHash: string;
|
||||||
|
snapshot: StrategyProjectSnapshot;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingJob<Input, Result> {
|
||||||
|
input: Input;
|
||||||
|
resolve: (result: Result | null) => void;
|
||||||
|
reject: (reason: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KeyQueueState<Input, Result> {
|
||||||
|
running: boolean;
|
||||||
|
pending?: PendingJob<Input, Result>;
|
||||||
|
idleWaiters: Array<() => void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep at most one active and one latest pending operation per key. */
|
||||||
|
export class LatestByKeyScheduler<Input, Result> {
|
||||||
|
private readonly states = new Map<string, KeyQueueState<Input, Result>>();
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly worker: (input: Input) => Promise<Result>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public schedule(key: string, input: Input): Promise<Result | null> {
|
||||||
|
let state = this.states.get(key);
|
||||||
|
if (!state) {
|
||||||
|
state = { running: false, idleWaiters: [] };
|
||||||
|
this.states.set(key, state);
|
||||||
|
}
|
||||||
|
return new Promise<Result | null>((resolve, reject) => {
|
||||||
|
// The superseded value has not started hashing or IndexedDB work. Resolve
|
||||||
|
// it now so its growing snapshot closure can be released immediately.
|
||||||
|
state!.pending?.resolve(null);
|
||||||
|
state!.pending = { input, resolve, reject };
|
||||||
|
void this.pump(key, state!);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public waitForIdle(key: string): Promise<void> {
|
||||||
|
const state = this.states.get(key);
|
||||||
|
if (!state || (!state.running && !state.pending)) return Promise.resolve();
|
||||||
|
return new Promise((resolve) => state.idleWaiters.push(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pump(
|
||||||
|
key: string,
|
||||||
|
state: KeyQueueState<Input, Result>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (state.running) return;
|
||||||
|
const job = state.pending;
|
||||||
|
if (!job) {
|
||||||
|
this.finishKey(key, state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.pending = undefined;
|
||||||
|
state.running = true;
|
||||||
|
try {
|
||||||
|
job.resolve(await this.worker(job.input));
|
||||||
|
} catch (reason) {
|
||||||
|
job.reject(reason);
|
||||||
|
} finally {
|
||||||
|
state.running = false;
|
||||||
|
if (state.pending) {
|
||||||
|
void this.pump(key, state);
|
||||||
|
} else {
|
||||||
|
this.finishKey(key, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private finishKey(
|
||||||
|
key: string,
|
||||||
|
state: KeyQueueState<Input, Result>,
|
||||||
|
): void {
|
||||||
|
this.states.delete(key);
|
||||||
|
state.idleWaiters.splice(0).forEach((resolve) => resolve());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function strategyProjectWalKey(
|
||||||
|
userKey: string,
|
||||||
|
projectId: string,
|
||||||
|
): string {
|
||||||
|
return `${encodeURIComponent(userKey)}::${encodeURIComponent(projectId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUsableSnapshot(value: unknown): value is StrategyProjectSnapshot {
|
||||||
|
if (!isRecord(value) || value.version !== 1) return false;
|
||||||
|
if (value.stage !== "intent" && value.stage !== "workspace") return false;
|
||||||
|
if (!isRecord(value.taskDraft) || !Array.isArray(value.taskDraft.selectedAngleIds)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
Array.isArray(value.angleCatalog) &&
|
||||||
|
Array.isArray(value.turns) &&
|
||||||
|
isRecord(value.outputs) &&
|
||||||
|
Array.isArray(value.evaluations) &&
|
||||||
|
Array.isArray(value.selectedStrategyIds) &&
|
||||||
|
typeof value.synthesis === "string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackHash(text: string): string {
|
||||||
|
let first = 0x811c9dc5;
|
||||||
|
let second = 0x9e3779b9;
|
||||||
|
for (let index = 0; index < text.length; index += 1) {
|
||||||
|
const code = text.charCodeAt(index);
|
||||||
|
first = Math.imul(first ^ code, 0x01000193);
|
||||||
|
second = Math.imul(second ^ code, 0x85ebca6b);
|
||||||
|
}
|
||||||
|
return `${(first >>> 0).toString(16).padStart(8, "0")}${(second >>> 0)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(8, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function strategyProjectSnapshotHash(
|
||||||
|
snapshot: StrategyProjectSnapshot,
|
||||||
|
): Promise<string> {
|
||||||
|
const serialized = JSON.stringify(snapshot);
|
||||||
|
if (globalThis.crypto?.subtle) {
|
||||||
|
const digest = await globalThis.crypto.subtle.digest(
|
||||||
|
"SHA-256",
|
||||||
|
new TextEncoder().encode(serialized),
|
||||||
|
);
|
||||||
|
return Array.from(new Uint8Array(digest), (byte) =>
|
||||||
|
byte.toString(16).padStart(2, "0"),
|
||||||
|
).join("");
|
||||||
|
}
|
||||||
|
return fallbackHash(serialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStrategyProjectWalExpired(
|
||||||
|
updatedAt: number,
|
||||||
|
now = Date.now(),
|
||||||
|
): boolean {
|
||||||
|
return now - updatedAt > WAL_TTL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveStrategyProjectWal(
|
||||||
|
wal: Pick<StrategyProjectWalRecord, "baseServerHash" | "snapshotHash">,
|
||||||
|
serverHash: string,
|
||||||
|
): "same" | "recover" | "conflict" {
|
||||||
|
if (wal.snapshotHash === serverHash) return "same";
|
||||||
|
if (wal.baseServerHash === serverHash) return "recover";
|
||||||
|
return "conflict";
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDatabase(): Promise<IDBDatabase | null> {
|
||||||
|
if (typeof indexedDB === "undefined") return Promise.resolve(null);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
const database = request.result;
|
||||||
|
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
database.createObjectStore(STORE_NAME, { keyPath: "key" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error ?? new Error("无法打开项目恢复存储"));
|
||||||
|
request.onblocked = () => reject(new Error("项目恢复存储正在升级,请关闭其他页面后重试"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForTransaction(transaction: IDBTransaction): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
transaction.oncomplete = () => resolve();
|
||||||
|
transaction.onerror = () =>
|
||||||
|
reject(transaction.error ?? new Error("项目恢复存储事务失败"));
|
||||||
|
transaction.onabort = () =>
|
||||||
|
reject(transaction.error ?? new Error("项目恢复存储事务已中止"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRequest(request: IDBRequest): Promise<unknown> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error ?? new Error("无法读取项目恢复记录"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeWalRecord(
|
||||||
|
record: StrategyProjectWalRecord,
|
||||||
|
): Promise<StrategyProjectWalRecord | null> {
|
||||||
|
const database = await openDatabase();
|
||||||
|
if (!database) return null;
|
||||||
|
try {
|
||||||
|
const transaction = database.transaction(STORE_NAME, "readwrite");
|
||||||
|
const transactionDone = waitForTransaction(transaction);
|
||||||
|
const store = transaction.objectStore(STORE_NAME);
|
||||||
|
// Queue the mutation from inside IDB's success callback. Awaiting the GET
|
||||||
|
// and only then issuing PUT is racy in Safari because the transaction may
|
||||||
|
// become inactive between promise turns.
|
||||||
|
const outcome = new Promise<StrategyProjectWalRecord>((resolve, reject) => {
|
||||||
|
const request = store.get(record.key);
|
||||||
|
request.onerror = () =>
|
||||||
|
reject(request.error ?? new Error("无法读取项目恢复记录"));
|
||||||
|
request.onsuccess = () => {
|
||||||
|
try {
|
||||||
|
const existing = request.result;
|
||||||
|
if (
|
||||||
|
!isRecord(existing) ||
|
||||||
|
typeof existing.updatedAt !== "number" ||
|
||||||
|
existing.updatedAt <= record.updatedAt
|
||||||
|
) {
|
||||||
|
store.put(record);
|
||||||
|
resolve(record);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(existing as unknown as StrategyProjectWalRecord);
|
||||||
|
} catch (reason) {
|
||||||
|
reject(reason);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const [result] = await Promise.all([outcome, transactionDone]);
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
database.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WalWriteInput {
|
||||||
|
userKey: string;
|
||||||
|
projectId: string;
|
||||||
|
baseServerHash: string;
|
||||||
|
snapshot: StrategyProjectSnapshot;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeScheduler = new LatestByKeyScheduler<
|
||||||
|
WalWriteInput,
|
||||||
|
StrategyProjectWalRecord | null
|
||||||
|
>(async ({ userKey, projectId, baseServerHash, snapshot, updatedAt }) => {
|
||||||
|
const record: StrategyProjectWalRecord = {
|
||||||
|
key: strategyProjectWalKey(userKey, projectId),
|
||||||
|
userKey,
|
||||||
|
projectId,
|
||||||
|
baseServerHash,
|
||||||
|
snapshotHash: await strategyProjectSnapshotHash(snapshot),
|
||||||
|
snapshot,
|
||||||
|
updatedAt,
|
||||||
|
};
|
||||||
|
return writeWalRecord(record);
|
||||||
|
});
|
||||||
|
|
||||||
|
export function writeStrategyProjectWal(
|
||||||
|
userKey: string,
|
||||||
|
projectId: string,
|
||||||
|
baseServerHash: string,
|
||||||
|
snapshot: StrategyProjectSnapshot,
|
||||||
|
): Promise<StrategyProjectWalRecord | null> {
|
||||||
|
const key = strategyProjectWalKey(userKey, projectId);
|
||||||
|
return writeScheduler.schedule(key, {
|
||||||
|
userKey,
|
||||||
|
projectId,
|
||||||
|
baseServerHash,
|
||||||
|
snapshot,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readStrategyProjectWal(
|
||||||
|
userKey: string,
|
||||||
|
projectId: string,
|
||||||
|
): Promise<StrategyProjectWalRecord | null> {
|
||||||
|
const key = strategyProjectWalKey(userKey, projectId);
|
||||||
|
await writeScheduler.waitForIdle(key);
|
||||||
|
const database = await openDatabase();
|
||||||
|
if (!database) return null;
|
||||||
|
try {
|
||||||
|
const transaction = database.transaction(STORE_NAME, "readonly");
|
||||||
|
const transactionDone = waitForTransaction(transaction);
|
||||||
|
const [record] = await Promise.all([
|
||||||
|
readRequest(transaction.objectStore(STORE_NAME).get(key)),
|
||||||
|
transactionDone,
|
||||||
|
]);
|
||||||
|
if (!isRecord(record) || !isUsableSnapshot(record.snapshot)) return null;
|
||||||
|
if (
|
||||||
|
record.userKey !== userKey ||
|
||||||
|
record.projectId !== projectId ||
|
||||||
|
typeof record.baseServerHash !== "string" ||
|
||||||
|
typeof record.snapshotHash !== "string" ||
|
||||||
|
typeof record.updatedAt !== "number"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const typed = record as unknown as StrategyProjectWalRecord;
|
||||||
|
if (isStrategyProjectWalExpired(typed.updatedAt)) {
|
||||||
|
void clearStrategyProjectWal(userKey, projectId, typed.snapshot).catch(
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return typed;
|
||||||
|
} finally {
|
||||||
|
database.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearStrategyProjectWal(
|
||||||
|
userKey: string,
|
||||||
|
projectId: string,
|
||||||
|
expectedSnapshot?: StrategyProjectSnapshot,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const key = strategyProjectWalKey(userKey, projectId);
|
||||||
|
const expectedHash = expectedSnapshot
|
||||||
|
? await strategyProjectSnapshotHash(expectedSnapshot)
|
||||||
|
: null;
|
||||||
|
await writeScheduler.waitForIdle(key);
|
||||||
|
const database = await openDatabase();
|
||||||
|
if (!database) return false;
|
||||||
|
try {
|
||||||
|
const transaction = database.transaction(STORE_NAME, "readwrite");
|
||||||
|
const transactionDone = waitForTransaction(transaction);
|
||||||
|
const store = transaction.objectStore(STORE_NAME);
|
||||||
|
if (!expectedSnapshot) {
|
||||||
|
store.delete(key);
|
||||||
|
await transactionDone;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedPromise = new Promise<boolean>((resolve, reject) => {
|
||||||
|
const request = store.get(key);
|
||||||
|
request.onerror = () =>
|
||||||
|
reject(request.error ?? new Error("无法读取项目恢复记录"));
|
||||||
|
request.onsuccess = () => {
|
||||||
|
try {
|
||||||
|
const existing = request.result;
|
||||||
|
const matches =
|
||||||
|
isRecord(existing) && existing.snapshotHash === expectedHash;
|
||||||
|
if (matches) store.delete(key);
|
||||||
|
resolve(matches);
|
||||||
|
} catch (reason) {
|
||||||
|
reject(reason);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const [deleted] = await Promise.all([deletedPromise, transactionDone]);
|
||||||
|
return deleted;
|
||||||
|
} finally {
|
||||||
|
database.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
import { apiFetch } from "@/core/api/fetch-client";
|
||||||
|
import { getAccountIdentityKey } from "@/core/auth";
|
||||||
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
import { createClientId } from "@/lib/client-id";
|
||||||
|
|
||||||
|
import {
|
||||||
|
EMPTY_TASK_DRAFT,
|
||||||
|
INITIAL_INTENT_TURNS,
|
||||||
|
STRATEGY_ANGLES,
|
||||||
|
TAIWAN_STRAIT_DEMO_EVALUATIONS,
|
||||||
|
TAIWAN_STRAIT_DEMO_OUTPUTS,
|
||||||
|
TAIWAN_STRAIT_DEMO_SYNTHESIS,
|
||||||
|
TAIWAN_STRAIT_DEMO_TASK,
|
||||||
|
TAIWAN_STRAIT_DEMO_TURNS,
|
||||||
|
} from "./constants";
|
||||||
|
import type {
|
||||||
|
AgentOutputState,
|
||||||
|
IntentTurn,
|
||||||
|
StrategyAngle,
|
||||||
|
StrategyEvaluation,
|
||||||
|
StrategyStage,
|
||||||
|
StrategyTaskDraft,
|
||||||
|
} from "./types";
|
||||||
|
import { clearStrategyProjectWal } from "./project-wal";
|
||||||
|
|
||||||
|
export const STRATEGY_PROJECT_ID_PREFIX = "strategy-selection-";
|
||||||
|
const SNAPSHOT_KIND = "strategy-selection-project";
|
||||||
|
|
||||||
|
// A strategy project can be mounted twice briefly during route transitions or
|
||||||
|
// opened in more than one tab. Serialize saves in this JS realm and, where
|
||||||
|
// supported, across same-browser tabs so an older PUT cannot finish after and
|
||||||
|
// overwrite a newer snapshot.
|
||||||
|
const projectSaveQueues = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
async function withBrowserProjectLock<T>(
|
||||||
|
key: string,
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
if (typeof navigator !== "undefined" && navigator.locks) {
|
||||||
|
return navigator.locks.request(`deerflow-strategy-save:${key}`, operation);
|
||||||
|
}
|
||||||
|
return operation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeProjectSave<T>(
|
||||||
|
key: string,
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const previous = projectSaveQueues.get(key) ?? Promise.resolve();
|
||||||
|
const request = previous
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(() => withBrowserProjectLock(key, operation));
|
||||||
|
const tail = request.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
projectSaveQueues.set(key, tail);
|
||||||
|
void tail.then(() => {
|
||||||
|
if (projectSaveQueues.get(key) === tail) projectSaveQueues.delete(key);
|
||||||
|
});
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEMO_JUDGE_CONTENT = `### 评判结论
|
||||||
|
|
||||||
|
六个角度均与任务目标相关,其中治理统筹、安全风险和外交沟通直接作用于“减少误判与阻断升级”,应优先纳入;法律规则是高压情境下的必要边界。经济韧性和社会民生策略建议与主策略合并实施,避免形成两套割裂的连续性机制。
|
||||||
|
|
||||||
|
**组合提醒:** 对外沟通节奏、市场稳定措施和风险等级调整必须使用同一事实基线;任何高风险政策动作均需设置冷静期、法律复核与退出条件。`;
|
||||||
|
|
||||||
|
export interface StrategyProjectSnapshot {
|
||||||
|
version: 1;
|
||||||
|
/** Copy of the angle catalog used by this project, for historical rendering. */
|
||||||
|
angleCatalog: StrategyAngle[];
|
||||||
|
stage: StrategyStage;
|
||||||
|
taskDraft: StrategyTaskDraft;
|
||||||
|
turns: IntentTurn[];
|
||||||
|
outputs: Record<string, AgentOutputState>;
|
||||||
|
judgeStatus: AgentOutputState["status"];
|
||||||
|
judgeContent: string;
|
||||||
|
evaluations: StrategyEvaluation[];
|
||||||
|
selectedStrategyIds: string[];
|
||||||
|
synthesis: string;
|
||||||
|
synthesisStatus: AgentOutputState["status"];
|
||||||
|
solutionSavedAt: string | null;
|
||||||
|
isDemo: boolean;
|
||||||
|
workflowStale: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrategyProjectSummary {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
brief: string;
|
||||||
|
createdAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
stage: StrategyStage;
|
||||||
|
questionCount: number;
|
||||||
|
angleCount: number;
|
||||||
|
completedStrategyCount: number;
|
||||||
|
hasEvaluation: boolean;
|
||||||
|
hasSolution: boolean;
|
||||||
|
isDemo: boolean;
|
||||||
|
workflowStale: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrategyProject extends StrategyProjectSummary {
|
||||||
|
snapshot: StrategyProjectSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StrategyProjectConflictError extends Error {
|
||||||
|
public constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "StrategyProjectConflictError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParallelTaskResponse {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
brief?: string | null;
|
||||||
|
agents?: Array<{ agent_id: string; name?: string; avatarType?: string | null }>;
|
||||||
|
messages?: Array<Record<string, unknown>> | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParallelTaskListResponse {
|
||||||
|
tasks?: ParallelTaskResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SnapshotEnvelope extends Record<string, unknown> {
|
||||||
|
kind: typeof SNAPSHOT_KIND;
|
||||||
|
version: 1;
|
||||||
|
snapshot: StrategyProjectSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloneTask = (task: StrategyTaskDraft): StrategyTaskDraft => ({
|
||||||
|
...task,
|
||||||
|
selectedAngleIds: [...task.selectedAngleIds],
|
||||||
|
});
|
||||||
|
|
||||||
|
const cloneAngles = (angles: StrategyAngle[]): StrategyAngle[] =>
|
||||||
|
angles.map((angle) => ({ ...angle }));
|
||||||
|
|
||||||
|
const cloneTurns = (turns: IntentTurn[]): IntentTurn[] =>
|
||||||
|
turns.map((turn) => ({ ...turn, streaming: false }));
|
||||||
|
|
||||||
|
const cloneOutputs = (
|
||||||
|
outputs: Record<string, AgentOutputState>,
|
||||||
|
): Record<string, AgentOutputState> =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(outputs).map(([id, output]) => [
|
||||||
|
id,
|
||||||
|
{ ...output, steps: [...(output.steps ?? [])] },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const cloneEvaluations = (evaluations: StrategyEvaluation[]) =>
|
||||||
|
evaluations.map((item) => ({
|
||||||
|
...item,
|
||||||
|
strengths: [...item.strengths],
|
||||||
|
risks: [...item.risks],
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function createEmptyStrategySnapshot(
|
||||||
|
angleCatalog: StrategyAngle[] = STRATEGY_ANGLES,
|
||||||
|
): StrategyProjectSnapshot {
|
||||||
|
const enabledAngles = angleCatalog.filter((angle) => angle.enabled !== false);
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
angleCatalog: cloneAngles(enabledAngles),
|
||||||
|
stage: "intent",
|
||||||
|
taskDraft: {
|
||||||
|
...cloneTask(EMPTY_TASK_DRAFT),
|
||||||
|
selectedAngleIds: enabledAngles.slice(0, 4).map((angle) => angle.id),
|
||||||
|
},
|
||||||
|
turns: cloneTurns(INITIAL_INTENT_TURNS),
|
||||||
|
outputs: {},
|
||||||
|
judgeStatus: "idle",
|
||||||
|
judgeContent: "",
|
||||||
|
evaluations: [],
|
||||||
|
selectedStrategyIds: [],
|
||||||
|
synthesis: "",
|
||||||
|
synthesisStatus: "idle",
|
||||||
|
solutionSavedAt: null,
|
||||||
|
isDemo: false,
|
||||||
|
workflowStale: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canCreateTaiwanDemo(
|
||||||
|
angleCatalog: StrategyAngle[],
|
||||||
|
): boolean {
|
||||||
|
const enabledAgentIds = new Set(
|
||||||
|
angleCatalog
|
||||||
|
.filter((angle) => angle.enabled !== false)
|
||||||
|
.map((angle) => angle.agentId),
|
||||||
|
);
|
||||||
|
return STRATEGY_ANGLES.every((angle) => enabledAgentIds.has(angle.agentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDemoStrategySnapshot(
|
||||||
|
angleCatalog: StrategyAngle[] = STRATEGY_ANGLES,
|
||||||
|
): StrategyProjectSnapshot {
|
||||||
|
if (!canCreateTaiwanDemo(angleCatalog)) {
|
||||||
|
throw new Error("台海示例所需的六个默认角度未全部启用,请先由管理员恢复配置");
|
||||||
|
}
|
||||||
|
const enabledAgentIds = new Set(
|
||||||
|
angleCatalog
|
||||||
|
.filter((angle) => angle.enabled !== false)
|
||||||
|
.map((angle) => angle.agentId),
|
||||||
|
);
|
||||||
|
const defaultDemoAgentIds = new Set(
|
||||||
|
STRATEGY_ANGLES.map((angle) => angle.agentId),
|
||||||
|
);
|
||||||
|
const demoAngles = angleCatalog.filter(
|
||||||
|
(angle) =>
|
||||||
|
angle.enabled !== false &&
|
||||||
|
enabledAgentIds.has(angle.agentId) &&
|
||||||
|
defaultDemoAgentIds.has(angle.agentId),
|
||||||
|
);
|
||||||
|
const effectiveAngles = demoAngles;
|
||||||
|
const demoOutputs = Object.fromEntries(
|
||||||
|
Object.entries(cloneOutputs(TAIWAN_STRAIT_DEMO_OUTPUTS)).filter(([agentId]) =>
|
||||||
|
effectiveAngles.some((angle) => angle.agentId === agentId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const demoEvaluations = TAIWAN_STRAIT_DEMO_EVALUATIONS.filter((evaluation) =>
|
||||||
|
effectiveAngles.some((angle) => angle.agentId === evaluation.agentId),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
angleCatalog: cloneAngles(effectiveAngles),
|
||||||
|
stage: "workspace",
|
||||||
|
taskDraft: {
|
||||||
|
...cloneTask(TAIWAN_STRAIT_DEMO_TASK),
|
||||||
|
selectedAngleIds: effectiveAngles.map((angle) => angle.id),
|
||||||
|
},
|
||||||
|
turns: cloneTurns(TAIWAN_STRAIT_DEMO_TURNS),
|
||||||
|
outputs: demoOutputs,
|
||||||
|
judgeStatus: "done",
|
||||||
|
judgeContent: DEMO_JUDGE_CONTENT,
|
||||||
|
evaluations: cloneEvaluations(demoEvaluations),
|
||||||
|
selectedStrategyIds: demoEvaluations.filter(
|
||||||
|
(item) => item.score >= 88,
|
||||||
|
).map((item) => item.agentId),
|
||||||
|
synthesis: TAIWAN_STRAIT_DEMO_SYNTHESIS,
|
||||||
|
synthesisStatus: "done",
|
||||||
|
solutionSavedAt: null,
|
||||||
|
isDemo: true,
|
||||||
|
workflowStale: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSnapshot(task: ParallelTaskResponse): StrategyProjectSnapshot | null {
|
||||||
|
const envelope = task.messages?.find(
|
||||||
|
(message) => message.kind === SNAPSHOT_KIND && message.version === 1,
|
||||||
|
);
|
||||||
|
if (!isObject(envelope) || !isObject(envelope.snapshot)) return null;
|
||||||
|
const snapshot = envelope.snapshot as unknown as StrategyProjectSnapshot;
|
||||||
|
if (
|
||||||
|
snapshot.version !== 1 ||
|
||||||
|
(snapshot.stage !== "intent" && snapshot.stage !== "workspace") ||
|
||||||
|
!isObject(snapshot.taskDraft) ||
|
||||||
|
!Array.isArray(snapshot.taskDraft.selectedAngleIds) ||
|
||||||
|
!Array.isArray(snapshot.turns) ||
|
||||||
|
!isObject(snapshot.outputs) ||
|
||||||
|
!Array.isArray(snapshot.evaluations) ||
|
||||||
|
!Array.isArray(snapshot.selectedStrategyIds)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...snapshot,
|
||||||
|
angleCatalog: cloneAngles(
|
||||||
|
Array.isArray(snapshot.angleCatalog) && snapshot.angleCatalog.length
|
||||||
|
? snapshot.angleCatalog.filter(
|
||||||
|
(angle) =>
|
||||||
|
isObject(angle) &&
|
||||||
|
typeof angle.id === "string" &&
|
||||||
|
typeof angle.agentId === "string" &&
|
||||||
|
typeof angle.name === "string",
|
||||||
|
)
|
||||||
|
: STRATEGY_ANGLES,
|
||||||
|
),
|
||||||
|
workflowStale: snapshot.workflowStale === true,
|
||||||
|
solutionSavedAt:
|
||||||
|
typeof snapshot.solutionSavedAt === "string"
|
||||||
|
? snapshot.solutionSavedAt
|
||||||
|
: null,
|
||||||
|
taskDraft: cloneTask(snapshot.taskDraft),
|
||||||
|
turns: cloneTurns(snapshot.turns),
|
||||||
|
outputs: cloneOutputs(snapshot.outputs),
|
||||||
|
evaluations: cloneEvaluations(snapshot.evaluations),
|
||||||
|
selectedStrategyIds: [...snapshot.selectedStrategyIds],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toProject(task: ParallelTaskResponse): StrategyProject | null {
|
||||||
|
const snapshot = readSnapshot(task);
|
||||||
|
if (!snapshot) return null;
|
||||||
|
const completedStrategyCount = Object.values(snapshot.outputs).filter(
|
||||||
|
(output) => output.status === "done" && output.content.trim(),
|
||||||
|
).length;
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
title: snapshot.taskDraft.name.trim() || task.title?.trim() || "未命名策略项目",
|
||||||
|
brief: snapshot.taskDraft.detail.trim() || task.brief?.trim() || "尚未补充项目说明",
|
||||||
|
createdAt: task.created_at ?? null,
|
||||||
|
updatedAt: task.updated_at ?? null,
|
||||||
|
stage: snapshot.stage,
|
||||||
|
questionCount: snapshot.turns.filter((turn) => turn.role === "user").length,
|
||||||
|
angleCount: snapshot.taskDraft.selectedAngleIds.length,
|
||||||
|
completedStrategyCount,
|
||||||
|
hasEvaluation:
|
||||||
|
!snapshot.workflowStale &&
|
||||||
|
snapshot.judgeStatus === "done" &&
|
||||||
|
Boolean(snapshot.judgeContent.trim()),
|
||||||
|
hasSolution:
|
||||||
|
!snapshot.workflowStale &&
|
||||||
|
snapshot.synthesisStatus === "done" &&
|
||||||
|
Boolean(snapshot.synthesis.trim()),
|
||||||
|
isDemo: snapshot.isDemo,
|
||||||
|
workflowStale: snapshot.workflowStale,
|
||||||
|
snapshot,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readError(response: Response): Promise<string> {
|
||||||
|
const body = (await response.json().catch(() => null)) as
|
||||||
|
| { detail?: string }
|
||||||
|
| null;
|
||||||
|
return body?.detail || `请求失败(HTTP ${response.status})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = (suffix = "") =>
|
||||||
|
`${getBackendBaseURL()}/api/parallel-agent-tasks${suffix}`;
|
||||||
|
|
||||||
|
async function getParallelTask(id: string): Promise<ParallelTaskResponse> {
|
||||||
|
const response = await apiFetch(endpoint(`/${encodeURIComponent(id)}`));
|
||||||
|
if (!response.ok) throw new Error(await readError(response));
|
||||||
|
return (await response.json()) as ParallelTaskResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listStrategyProjects(): Promise<StrategyProjectSummary[]> {
|
||||||
|
const response = await apiFetch(endpoint());
|
||||||
|
if (!response.ok) throw new Error(await readError(response));
|
||||||
|
const body = (await response.json()) as ParallelTaskListResponse;
|
||||||
|
const candidates = (body.tasks ?? []).filter((task) =>
|
||||||
|
task.id.startsWith(STRATEGY_PROJECT_ID_PREFIX),
|
||||||
|
);
|
||||||
|
const records = await Promise.allSettled(
|
||||||
|
candidates.map((task) => getParallelTask(task.id)),
|
||||||
|
);
|
||||||
|
return records
|
||||||
|
.flatMap((result) => {
|
||||||
|
if (result.status !== "fulfilled") return [];
|
||||||
|
const project = toProject(result.value);
|
||||||
|
return project ? [project] : [];
|
||||||
|
})
|
||||||
|
.sort((a, b) =>
|
||||||
|
String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStrategyProject(id: string): Promise<StrategyProject> {
|
||||||
|
if (!id.startsWith(STRATEGY_PROJECT_ID_PREFIX)) {
|
||||||
|
throw new Error("这不是一个策略优选项目");
|
||||||
|
}
|
||||||
|
const task = await getParallelTask(id);
|
||||||
|
const project = toProject(task);
|
||||||
|
if (!project) throw new Error("项目数据不完整,无法恢复策略工作台");
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectAgents(snapshot: StrategyProjectSnapshot) {
|
||||||
|
const selected = new Set(snapshot.taskDraft.selectedAngleIds);
|
||||||
|
const angleAgents = snapshot.angleCatalog.filter((angle) => selected.has(angle.id)).map(
|
||||||
|
(angle) => ({ agent_id: angle.agentId, name: angle.name }),
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
{ agent_id: "strategy-selection-intent", name: "意图理解智能体" },
|
||||||
|
...angleAgents,
|
||||||
|
{ agent_id: "strategy-selection-judge", name: "策略评判智能体" },
|
||||||
|
{ agent_id: "strategy-selection-solution", name: "方案整理智能体" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotEnvelope(snapshot: StrategyProjectSnapshot): SnapshotEnvelope {
|
||||||
|
return {
|
||||||
|
kind: SNAPSHOT_KIND,
|
||||||
|
version: 1,
|
||||||
|
snapshot,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createStrategyProject(
|
||||||
|
snapshot: StrategyProjectSnapshot = createEmptyStrategySnapshot(),
|
||||||
|
): Promise<StrategyProject> {
|
||||||
|
const id = `${STRATEGY_PROJECT_ID_PREFIX}${createClientId()}`;
|
||||||
|
const response = await apiFetch(endpoint(), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id,
|
||||||
|
title: snapshot.taskDraft.name.trim() || "未命名策略项目",
|
||||||
|
brief: snapshot.taskDraft.detail.trim() || null,
|
||||||
|
agents: projectAgents(snapshot),
|
||||||
|
messages: [snapshotEnvelope(snapshot)],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(await readError(response));
|
||||||
|
const project = toProject((await response.json()) as ParallelTaskResponse);
|
||||||
|
if (!project) throw new Error("项目已创建,但返回数据无法识别");
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveStrategyProject(
|
||||||
|
id: string,
|
||||||
|
snapshot: StrategyProjectSnapshot,
|
||||||
|
options: {
|
||||||
|
expectedUpdatedAt?: string | null;
|
||||||
|
keepalive?: boolean;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
} = {},
|
||||||
|
): Promise<StrategyProject> {
|
||||||
|
const account = getAccountIdentityKey() ?? "anonymous";
|
||||||
|
return serializeProjectSave(`${account}:${id}`, async () => {
|
||||||
|
const response = await apiFetch(endpoint(`/${encodeURIComponent(id)}`), {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
keepalive: options.keepalive,
|
||||||
|
signal: options.signal,
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: snapshot.taskDraft.name.trim() || "未命名策略项目",
|
||||||
|
brief: snapshot.taskDraft.detail.trim() || null,
|
||||||
|
agents: projectAgents(snapshot),
|
||||||
|
messages: [snapshotEnvelope(snapshot)],
|
||||||
|
expected_updated_at: options.expectedUpdatedAt ?? undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await readError(response);
|
||||||
|
if (response.status === 409) {
|
||||||
|
throw new StrategyProjectConflictError(message);
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
const project = toProject((await response.json()) as ParallelTaskResponse);
|
||||||
|
if (!project) throw new Error("项目已保存,但返回数据无法识别");
|
||||||
|
return project;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteStrategyProject(id: string): Promise<void> {
|
||||||
|
const response = await apiFetch(endpoint(`/${encodeURIComponent(id)}`), {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (!response.ok && response.status !== 404) {
|
||||||
|
throw new Error(await readError(response));
|
||||||
|
}
|
||||||
|
void clearStrategyProjectWal(
|
||||||
|
getAccountIdentityKey() ?? "anonymous",
|
||||||
|
id,
|
||||||
|
).catch(() => undefined);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import type { StrategyAngle } from "./types";
|
||||||
|
|
||||||
|
export type StrategyAgentCategory =
|
||||||
|
| "intent"
|
||||||
|
| "angle"
|
||||||
|
| "judge"
|
||||||
|
| "solution";
|
||||||
|
|
||||||
|
export interface StrategyAgent {
|
||||||
|
id: string;
|
||||||
|
category: StrategyAgentCategory;
|
||||||
|
fixed: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
model: string | null;
|
||||||
|
soul: string | null;
|
||||||
|
skills: string[] | null;
|
||||||
|
created_at: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STRATEGY_ANGLE_PREFIX = "strategy-selection-angle-";
|
||||||
|
|
||||||
|
const LEGACY_ANGLE_IDS: Record<string, string> = {
|
||||||
|
"strategy-selection-angle-governance": "governance",
|
||||||
|
"strategy-selection-angle-diplomacy": "diplomacy",
|
||||||
|
"strategy-selection-angle-economy": "economy",
|
||||||
|
"strategy-selection-angle-security": "security",
|
||||||
|
"strategy-selection-angle-public-opinion": "public_opinion",
|
||||||
|
"strategy-selection-angle-law-policy": "law_policy",
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEGACY_SHORT_NAMES: Record<string, string> = {
|
||||||
|
"strategy-selection-angle-governance": "治理",
|
||||||
|
"strategy-selection-angle-diplomacy": "外交",
|
||||||
|
"strategy-selection-angle-economy": "经济",
|
||||||
|
"strategy-selection-angle-security": "安全",
|
||||||
|
"strategy-selection-angle-public-opinion": "民生",
|
||||||
|
"strategy-selection-angle-law-policy": "法律",
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEGACY_ACCENTS: Record<string, string> = {
|
||||||
|
"strategy-selection-angle-governance": "#6d5dfc",
|
||||||
|
"strategy-selection-angle-diplomacy": "#0ea5e9",
|
||||||
|
"strategy-selection-angle-economy": "#10b981",
|
||||||
|
"strategy-selection-angle-security": "#f97316",
|
||||||
|
"strategy-selection-angle-public-opinion": "#ec4899",
|
||||||
|
"strategy-selection-angle-law-policy": "#a855f7",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACCENT_PALETTE = [
|
||||||
|
"#2563eb",
|
||||||
|
"#0891b2",
|
||||||
|
"#059669",
|
||||||
|
"#ca8a04",
|
||||||
|
"#ea580c",
|
||||||
|
"#dc2626",
|
||||||
|
"#db2777",
|
||||||
|
"#7c3aed",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function stableAccent(id: string) {
|
||||||
|
if (LEGACY_ACCENTS[id]) return LEGACY_ACCENTS[id];
|
||||||
|
let hash = 2166136261;
|
||||||
|
for (let index = 0; index < id.length; index += 1) {
|
||||||
|
hash ^= id.charCodeAt(index);
|
||||||
|
hash = Math.imul(hash, 16777619);
|
||||||
|
}
|
||||||
|
return ACCENT_PALETTE[(hash >>> 0) % ACCENT_PALETTE.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayAngleName(name: string) {
|
||||||
|
const cleaned = name
|
||||||
|
.trim()
|
||||||
|
.replace(/^策略(?:分析)?视角[·•::\s-]*/, "")
|
||||||
|
.replace(/(?:分析)?智能体$/, "")
|
||||||
|
.trim();
|
||||||
|
return cleaned || name.trim() || "未命名视角";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map the public API shape to the workbench's stable angle representation. */
|
||||||
|
export function strategyAgentToAngle(
|
||||||
|
agent: StrategyAgent,
|
||||||
|
): StrategyAngle | null {
|
||||||
|
if (agent.category !== "angle") return null;
|
||||||
|
const suffix = agent.id.startsWith(STRATEGY_ANGLE_PREFIX)
|
||||||
|
? agent.id.slice(STRATEGY_ANGLE_PREFIX.length)
|
||||||
|
: agent.id;
|
||||||
|
const name = displayAngleName(agent.name);
|
||||||
|
return {
|
||||||
|
id: LEGACY_ANGLE_IDS[agent.id] ?? suffix,
|
||||||
|
agentId: agent.id,
|
||||||
|
name,
|
||||||
|
shortName:
|
||||||
|
LEGACY_SHORT_NAMES[agent.id] ??
|
||||||
|
name.split(/[与和及、·/]/u, 1)[0].slice(0, 6),
|
||||||
|
description: agent.description,
|
||||||
|
accent: stableAccent(agent.id),
|
||||||
|
enabled: agent.enabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function strategyAgentsToAngles(
|
||||||
|
agents: StrategyAgent[],
|
||||||
|
): StrategyAngle[] {
|
||||||
|
return agents.flatMap((agent) => {
|
||||||
|
const angle = strategyAgentToAngle(agent);
|
||||||
|
return angle ? [angle] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Run from frontend-web:
|
||||||
|
* npx esbuild src/strategy-selection/strategy-agents.test.ts --bundle --format=esm --platform=node --outfile=.tmp-tests/strategy-agents.test.mjs
|
||||||
|
* node --test .tmp-tests/strategy-agents.test.mjs
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
strategyAgentToAngle,
|
||||||
|
strategyAgentsToAngles,
|
||||||
|
type StrategyAgent,
|
||||||
|
} from "./strategy-agent-mapping";
|
||||||
|
|
||||||
|
function agent(overrides: Partial<StrategyAgent> = {}): StrategyAgent {
|
||||||
|
return {
|
||||||
|
id: "strategy-selection-angle-public-opinion",
|
||||||
|
category: "angle",
|
||||||
|
fixed: false,
|
||||||
|
enabled: true,
|
||||||
|
sort_order: 100,
|
||||||
|
name: "策略视角·社会民生",
|
||||||
|
description: "关注公共服务连续性",
|
||||||
|
model: null,
|
||||||
|
soul: null,
|
||||||
|
skills: null,
|
||||||
|
created_at: null,
|
||||||
|
updated_at: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("keeps legacy project ids while accepting the public redacted shape", () => {
|
||||||
|
assert.deepEqual(strategyAgentToAngle(agent()), {
|
||||||
|
id: "public_opinion",
|
||||||
|
agentId: "strategy-selection-angle-public-opinion",
|
||||||
|
name: "社会民生",
|
||||||
|
shortName: "民生",
|
||||||
|
description: "关注公共服务连续性",
|
||||||
|
accent: "#ec4899",
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("derives stable dynamic ids and accents from the reserved prefix", () => {
|
||||||
|
const dynamic = agent({
|
||||||
|
id: "strategy-selection-angle-abc123",
|
||||||
|
name: "科技与产业链智能体",
|
||||||
|
});
|
||||||
|
const first = strategyAgentToAngle(dynamic);
|
||||||
|
const second = strategyAgentToAngle({ ...dynamic });
|
||||||
|
|
||||||
|
assert.equal(first?.id, "abc123");
|
||||||
|
assert.equal(first?.shortName, "科技");
|
||||||
|
assert.equal(first?.accent, second?.accent);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filters fixed workflow roles out of the angle catalog", () => {
|
||||||
|
assert.equal(
|
||||||
|
strategyAgentsToAngles([
|
||||||
|
agent(),
|
||||||
|
agent({ id: "strategy-selection-intent", category: "intent" }),
|
||||||
|
]).length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { apiFetch } from "@/core/api/fetch-client";
|
||||||
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
|
||||||
|
import {
|
||||||
|
strategyAgentsToAngles,
|
||||||
|
type StrategyAgent,
|
||||||
|
} from "./strategy-agent-mapping";
|
||||||
|
|
||||||
|
export {
|
||||||
|
strategyAgentToAngle,
|
||||||
|
strategyAgentsToAngles,
|
||||||
|
} from "./strategy-agent-mapping";
|
||||||
|
export type {
|
||||||
|
StrategyAgent,
|
||||||
|
StrategyAgentCategory,
|
||||||
|
} from "./strategy-agent-mapping";
|
||||||
|
|
||||||
|
export interface CreateStrategyAngleInput {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
soul?: string;
|
||||||
|
model?: string | null;
|
||||||
|
skills?: string[];
|
||||||
|
sort_order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateStrategyAgentInput {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
soul?: string;
|
||||||
|
model?: string | null;
|
||||||
|
skills?: string[];
|
||||||
|
enabled?: boolean;
|
||||||
|
sort_order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STRATEGY_AGENTS_QUERY_KEY = ["strategy-agents"] as const;
|
||||||
|
|
||||||
|
function endpoint(suffix = "") {
|
||||||
|
return `${getBackendBaseURL()}/api/strategy-agents${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function responseError(response: Response) {
|
||||||
|
const body = (await response.json().catch(() => null)) as
|
||||||
|
| { detail?: string }
|
||||||
|
| null;
|
||||||
|
return body?.detail || `请求失败(HTTP ${response.status})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listStrategyAgents(options?: {
|
||||||
|
includeDisabled?: boolean;
|
||||||
|
includeConfig?: boolean;
|
||||||
|
}): Promise<StrategyAgent[]> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (options?.includeDisabled) query.set("include_disabled", "true");
|
||||||
|
if (options?.includeConfig) query.set("include_config", "true");
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${endpoint()}${query.size ? `?${query.toString()}` : ""}`,
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error(await responseError(response));
|
||||||
|
const body = (await response.json()) as { agents?: StrategyAgent[] };
|
||||||
|
return body.agents ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createStrategyAngle(
|
||||||
|
input: CreateStrategyAngleInput,
|
||||||
|
): Promise<StrategyAgent> {
|
||||||
|
const response = await apiFetch(endpoint("/angles"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(await responseError(response));
|
||||||
|
return (await response.json()) as StrategyAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateStrategyAgent(
|
||||||
|
id: string,
|
||||||
|
input: UpdateStrategyAgentInput,
|
||||||
|
): Promise<StrategyAgent> {
|
||||||
|
const response = await apiFetch(endpoint(`/${encodeURIComponent(id)}`), {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(await responseError(response));
|
||||||
|
return (await response.json()) as StrategyAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disableStrategyAngle(id: string): Promise<StrategyAgent> {
|
||||||
|
const response = await apiFetch(endpoint(`/${encodeURIComponent(id)}`), {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(await responseError(response));
|
||||||
|
return (await response.json()) as StrategyAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStrategyAgents(options?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
includeDisabled?: boolean;
|
||||||
|
includeConfig?: boolean;
|
||||||
|
}) {
|
||||||
|
const includeDisabled = options?.includeDisabled ?? false;
|
||||||
|
const includeConfig = options?.includeConfig ?? false;
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [
|
||||||
|
...STRATEGY_AGENTS_QUERY_KEY,
|
||||||
|
includeDisabled,
|
||||||
|
includeConfig,
|
||||||
|
],
|
||||||
|
queryFn: () => listStrategyAgents({ includeDisabled, includeConfig }),
|
||||||
|
enabled: options?.enabled ?? true,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reusable active-angle query for the workbench and project creation flow. */
|
||||||
|
export function useStrategyAngleCatalog(enabled = true) {
|
||||||
|
const query = useStrategyAgents({ enabled });
|
||||||
|
const angles = useMemo(
|
||||||
|
() => strategyAgentsToAngles(query.data ?? []),
|
||||||
|
[query.data],
|
||||||
|
);
|
||||||
|
return { ...query, angles };
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
export type StrategyStage = "intent" | "workspace";
|
||||||
|
|
||||||
|
export type AgentRunStatus =
|
||||||
|
| "idle"
|
||||||
|
| "queued"
|
||||||
|
| "running"
|
||||||
|
| "done"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
export interface StrategyAngle {
|
||||||
|
id: string;
|
||||||
|
agentId: string;
|
||||||
|
name: string;
|
||||||
|
shortName: string;
|
||||||
|
description: string;
|
||||||
|
accent: string;
|
||||||
|
/** False when an angle survives only in an existing project snapshot. */
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrategyTaskDraft {
|
||||||
|
name: string;
|
||||||
|
detail: string;
|
||||||
|
objective: string;
|
||||||
|
prerequisites: string;
|
||||||
|
constraints: string;
|
||||||
|
deliverables: string;
|
||||||
|
acceptanceCriteria: string;
|
||||||
|
horizon: string;
|
||||||
|
selectedAngleIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntentTurn {
|
||||||
|
id: string;
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
streaming?: boolean;
|
||||||
|
isDemo?: boolean;
|
||||||
|
clarification?: IntentClarification;
|
||||||
|
clarificationAnswered?: boolean;
|
||||||
|
clarificationAnswer?: string;
|
||||||
|
reasoning?: string;
|
||||||
|
error?: string;
|
||||||
|
retryPrompt?: string;
|
||||||
|
retryClarificationTurnId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntentClarificationOption {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntentClarification {
|
||||||
|
question: string;
|
||||||
|
context?: string;
|
||||||
|
clarificationType?: string;
|
||||||
|
options: IntentClarificationOption[];
|
||||||
|
allowCustom: boolean;
|
||||||
|
allowMultiple: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentOutputState {
|
||||||
|
status: AgentRunStatus;
|
||||||
|
content: string;
|
||||||
|
steps: string[];
|
||||||
|
reasoning?: string;
|
||||||
|
error?: string;
|
||||||
|
/** A failed refresh kept the last successful content for this same task. */
|
||||||
|
preservedFromPrevious?: boolean;
|
||||||
|
isDemo?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrategyEvaluation {
|
||||||
|
agentId: string;
|
||||||
|
score: number;
|
||||||
|
conclusion: "优先推荐" | "建议采用" | "保留备选";
|
||||||
|
comment: string;
|
||||||
|
strengths: string[];
|
||||||
|
risks: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParallelAgentPayload {
|
||||||
|
task_name: string;
|
||||||
|
brief: string;
|
||||||
|
agent_ids: string[];
|
||||||
|
model?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParallelAgentEvent =
|
||||||
|
| { type: "agent_start"; agent_id: string }
|
||||||
|
| { type: "delta"; agent_id: string; text: string }
|
||||||
|
| {
|
||||||
|
type: "step";
|
||||||
|
agent_id: string;
|
||||||
|
key?: string;
|
||||||
|
label?: string;
|
||||||
|
args?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
| { type: "agent_done"; agent_id: string; content: string }
|
||||||
|
| { type: "agent_error"; agent_id: string; message: string }
|
||||||
|
| {
|
||||||
|
type: "agent_clarification";
|
||||||
|
agent_id: string;
|
||||||
|
content?: string;
|
||||||
|
question?: string;
|
||||||
|
clarification_type?: string;
|
||||||
|
clarification_context?: string;
|
||||||
|
context?: string;
|
||||||
|
options?: Array<
|
||||||
|
| string
|
||||||
|
| {
|
||||||
|
id?: string;
|
||||||
|
label?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
allow_custom?: boolean;
|
||||||
|
allow_multiple?: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "thinking" | "reasoning" | "reasoning_delta";
|
||||||
|
agent_id: string;
|
||||||
|
text?: string;
|
||||||
|
delta?: string;
|
||||||
|
content?: string;
|
||||||
|
}
|
||||||
|
| { type: "all_done" };
|
||||||
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
|
||||||
@@ -299,6 +299,8 @@ from deerflow.config import get_app_config
|
|||||||
|
|
||||||
**Forced-research built-in agent.** `forced-research-responder` (「强制检索输出助手」) is seeded from `app/gateway/routers/_forced_research_seed_assets/` before `_sync_legacy_agents`. `ForcedResearchMiddleware` is mounted only for this stable id. On every visible user turn it requires a successful information-gathering tool result before the model may finish; `tool_choice=required` is backed by an `after_model -> model` retry for gateways that ignore tool choice, with an eight-attempt failure cap that permits only an honest failure response. Skill discovery, reading `SKILL.md`, and writing a helper script do not satisfy the gate; web/knowledge/MCP results, non-skill uploaded-file reads, or successful Python skill-script execution do. Tool filtering removes `present_files`, `skill_manage`, task/schedule/memory/clarification surfaces. The middleware permits `write_file`/`str_replace` only for `/mnt/user-data/workspace/*.py` and permits `bash` only for `.py` execution without redirection/document paths, so skills can run scripts but the agent cannot create Markdown/document artifacts or write `outputs`. Tests: `tests/test_forced_research_agent.py`.
|
**Forced-research built-in agent.** `forced-research-responder` (「强制检索输出助手」) is seeded from `app/gateway/routers/_forced_research_seed_assets/` before `_sync_legacy_agents`. `ForcedResearchMiddleware` is mounted only for this stable id. On every visible user turn it requires a successful information-gathering tool result before the model may finish; `tool_choice=required` is backed by an `after_model -> model` retry for gateways that ignore tool choice, with an eight-attempt failure cap that permits only an honest failure response. Skill discovery, reading `SKILL.md`, and writing a helper script do not satisfy the gate; web/knowledge/MCP results, non-skill uploaded-file reads, or successful Python skill-script execution do. Tool filtering removes `present_files`, `skill_manage`, task/schedule/memory/clarification surfaces. The middleware permits `write_file`/`str_replace` only for `/mnt/user-data/workspace/*.py` and permits `bash` only for `.py` execution without redirection/document paths, so skills can run scripts but the agent cannot create Markdown/document artifacts or write `outputs`. Tests: `tests/test_forced_research_agent.py`.
|
||||||
|
|
||||||
|
**Strategy-selection functional agents.** `app/gateway/routers/_strategy_selection_seed.py` seeds nine stable folder-backed agents from `_strategy_selection_seed_assets/` before `_sync_legacy_agents`: intent, governance/diplomacy/economy/security/public-opinion/law-policy angles, judge, and solution editor. Seeding is missing-directory-only and must never overwrite administrator changes. The global `strategy_agent_roles` registry classifies them independently of projects: intent/judge/solution are fixed, unique roles that may be configured but never added, disabled, reordered, or deleted; angle roles are a shared, ordered catalog that administrators may add, edit, disable (soft delete), restore, and reorder through `/api/strategy-agents`. Public `GET /api/strategy-agents` returns enabled identity/description metadata only; `include_disabled=true` or `include_config=true` is admin-only. Admin writes are `POST /api/strategy-agents/angles`, `PUT /api/strategy-agents/{id}`, and `DELETE /api/strategy-agents/{id}`; DELETE retains the folder-backed agent and role row to preserve historical references. Dynamic angles remain built-in folder-backed agents under the reserved `strategy-selection-angle-` id namespace, with `web` + `file:read`; a blank SOUL on create receives a safe role template. All strategy skill writes validate that names exist, are active, and are globally visible; generic agent CRUD cannot create or hard-delete reserved strategy IDs, and parallel execution rejects disabled roles. Angle configs reuse `policy-knowledge`; intent reuses `intent-clarification-policy`; the judge has no retrieval skill; the solution editor gets only `file:write`. Keep the SOUL contracts neutral and evidence-aware, with explicit de-escalation/civilian-protection framing and hard bans on operational combat guidance and targeted political manipulation. Tests: `tests/test_strategy_selection_seed.py`, `tests/test_strategy_agent_management.py`.
|
||||||
|
|
||||||
**ThreadState** (`packages/harness/deerflow/agents/thread_state.py`):
|
**ThreadState** (`packages/harness/deerflow/agents/thread_state.py`):
|
||||||
- Extends `AgentState` with: `sandbox`, `thread_data`, `title`, `artifacts`, `todos`, `uploaded_files`, `viewed_images`
|
- Extends `AgentState` with: `sandbox`, `thread_data`, `title`, `artifacts`, `todos`, `uploaded_files`, `viewed_images`
|
||||||
- Uses custom reducers: `merge_artifacts` (deduplicate), `merge_viewed_images` (merge/clear)
|
- Uses custom reducers: `merge_artifacts` (deduplicate), `merge_viewed_images` (merge/clear)
|
||||||
@@ -540,6 +542,8 @@ Self-contained product knowledge base. **All knowledge capability lives in this
|
|||||||
|
|
||||||
Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.agents`). `agents.id` is the stable runtime id and the filesystem directory name under `.deer-flow/agents/{id}/`; `agents.name` is display-only, can contain Chinese characters, and is not unique. `user_id IS NULL` marks built-in agents, user-owned agents are visible only to their owner unless `published = true`, and update/delete operations require ownership. The runtime accepts `agent_id` in context/configurable, and non-default `assistant_id` values are treated as agent ids after visibility checks.
|
Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.agents`). `agents.id` is the stable runtime id and the filesystem directory name under `.deer-flow/agents/{id}/`; `agents.name` is display-only, can contain Chinese characters, and is not unique. `user_id IS NULL` marks built-in agents, user-owned agents are visible only to their owner unless `published = true`, and update/delete operations require ownership. The runtime accepts `agent_id` in context/configurable, and non-default `assistant_id` values are treated as agent ids after visibility checks.
|
||||||
|
|
||||||
|
Strategy-selection fan-out (`routers/parallel_agents.py`) must keep hidden reasoning separate per agent: provider reasoning blocks/fields and inline `<think>...</think>` text emit `reasoning_delta`, while visible answer text emits `delta` and only visible text may enter `agent_done.content`. Never concatenate `reasoning_delta` into a selectable strategy or generated deliverable. Inspect the final AI message with the shared `is_llm_error_message` detector and require substantive visible text before constructing a success result. A middleware fallback, empty/thinking-only response, or punctuation-only provider degeneration is a failed seat run: raise so the stream emits `agent_error`; never wrap it in `agent_done`. Regression coverage: `tests/test_roundtable_model_fallback.py`.
|
||||||
|
|
||||||
**Listing cache + pagination** ? to keep `GET /api/agents` fast, the gallery's per-card extras (skills + tool_groups) are denormalized into two cache tables instead of reading every agent's `config.yaml` on each request: `agent_skills` (relational, one row per (agent, skill) with `position`) and `agent_extras` (one row per agent: `tool_groups` JSON + `model` + `skills_synced_at`). `config.yaml` remains authoritative for the agent runtime; these tables are a read cache, write-through on create/update and backfilled from disk by `_sync_legacy_agents` (mtime-vs-`skills_synced_at` drift check). The list endpoint enriches via `AgentStore.get_extras_for([ids])` (two batched `IN` queries). Both tables are auto-created by `Base.metadata.create_all` (no migration needed). `GET /api/agents` supports **opt-in server-side pagination**: `scope` (`mine` = owned + favorited / `square` = built-in/published public), `page`, `page_size`, returning `{agents, total, page, page_size}`; omitting `scope`/`page` preserves the legacy "return everything" behavior. Name `search` matches the raw OR desensitized name (the frontend's static term?abbrev map is mirrored server-side in `deerflow/config/agent_name_desensitize.py`); `tag_ids` is an SQL OR-filter; `owner` keeps only agents published by that user id (the card's ??? ? built-in agents never match), powering the gallery's click-???-to-filter. `POST /api/agents/private-square/list` takes the same pagination params (incl. `owner`). Repository entry point: `AgentStore.list_paginated(...)`. Tests: `tests/test_agent_listing_cache.py::test_list_paginated_owner_filter_keeps_one_publisher`.
|
**Listing cache + pagination** ? to keep `GET /api/agents` fast, the gallery's per-card extras (skills + tool_groups) are denormalized into two cache tables instead of reading every agent's `config.yaml` on each request: `agent_skills` (relational, one row per (agent, skill) with `position`) and `agent_extras` (one row per agent: `tool_groups` JSON + `model` + `skills_synced_at`). `config.yaml` remains authoritative for the agent runtime; these tables are a read cache, write-through on create/update and backfilled from disk by `_sync_legacy_agents` (mtime-vs-`skills_synced_at` drift check). The list endpoint enriches via `AgentStore.get_extras_for([ids])` (two batched `IN` queries). Both tables are auto-created by `Base.metadata.create_all` (no migration needed). `GET /api/agents` supports **opt-in server-side pagination**: `scope` (`mine` = owned + favorited / `square` = built-in/published public), `page`, `page_size`, returning `{agents, total, page, page_size}`; omitting `scope`/`page` preserves the legacy "return everything" behavior. Name `search` matches the raw OR desensitized name (the frontend's static term?abbrev map is mirrored server-side in `deerflow/config/agent_name_desensitize.py`); `tag_ids` is an SQL OR-filter; `owner` keeps only agents published by that user id (the card's ??? ? built-in agents never match), powering the gallery's click-???-to-filter. `POST /api/agents/private-square/list` takes the same pagination params (incl. `owner`). Repository entry point: `AgentStore.list_paginated(...)`. Tests: `tests/test_agent_listing_cache.py::test_list_paginated_owner_filter_keeps_one_publisher`.
|
||||||
|
|
||||||
**Homepage selector (agent picker on the new-chat page)** ? single admin-curated list backed by `agents.featured_order` (nullable BIGINT). NULL = not featured. Only `published = true` agents can be featured (router enforces on writes); any published agent is eligible regardless of ownership. Cleared with `order: null`. The list rendered to end users is `WHERE featured_order IS NOT NULL` + visibility-filter, sorted by `featured_order ASC`. Admins reorder by sending the desired ids to `PUT /api/agents/featured/order` (assigns indices 0..N-1). BIGINT is used rather than INT so the management UI can seed initial values from `Date.now()` (overflows INT on MySQL).
|
**Homepage selector (agent picker on the new-chat page)** ? single admin-curated list backed by `agents.featured_order` (nullable BIGINT). NULL = not featured. Only `published = true` agents can be featured (router enforces on writes); any published agent is eligible regardless of ownership. Cleared with `order: null`. The list rendered to end users is `WHERE featured_order IS NOT NULL` + visibility-filter, sorted by `featured_order ASC`. Admins reorder by sending the desired ids to `PUT /api/agents/featured/order` (assigns indices 0..N-1). BIGINT is used rather than INT so the management UI can seed initial values from `Date.now()` (overflows INT on MySQL).
|
||||||
@@ -766,6 +770,7 @@ MySQL index script synchronized.
|
|||||||
- Physical: `backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/...`, `deer-flow/skills/`
|
- Physical: `backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/...`, `deer-flow/skills/`
|
||||||
- Translation: `replace_virtual_path()` / `replace_virtual_paths_in_command()`
|
- Translation: `replace_virtual_path()` / `replace_virtual_paths_in_command()`
|
||||||
- Detection: `is_local_sandbox()` checks `sandbox_id == "local"`
|
- Detection: `is_local_sandbox()` checks `sandbox_id == "local"`
|
||||||
|
- Configured mount portability: `AppConfig._resolve_relative_mount_paths` resolves each relative `sandbox.mounts[*].host_path` against the loaded `config.yaml` directory before validation/provider construction, never against the process cwd. Keep repository-owned knowledge mounts relative (for example `knowledge/knowledge_base`); absolute paths remain unchanged, and sandbox providers always receive an absolute path.
|
||||||
- **`{user_id}` for path resolution = `resolve_path_user_id(thread_id)`, using the persisted creator for every tracked thread.** The route's regular owner check still authorizes access first; creator resolution only selects the stable physical directory (`threads_meta.user_id`) and avoids a lost request context falling back to `users/default`. This also preserves cross-account task-deeplink conversations (rwfx/3qfx/xdfx) and roundtable system threads. Positive creator mappings are cached process-wide and DB/memory/missing-row failures fall back to the current login. It is wired at the thread-path consumers including `ThreadDataMiddleware.before_agent`, `present_file_tool`, uploads, artifact serving, and embedded `client.get_artifact`. **Rewrite-history recovery is metadata-only and job-owner scoped** (`list_by_session(..., user_id=current_user)`), so its GET/list endpoints intentionally skip redundant thread-owner checks and return an empty history instead of a history-only 404; the start/commit endpoints retain strict thread write authorization. Tests: `tests/test_thread_paths_owner.py`, `tests/test_thread_paths_roundtable.py`.
|
- **`{user_id}` for path resolution = `resolve_path_user_id(thread_id)`, using the persisted creator for every tracked thread.** The route's regular owner check still authorizes access first; creator resolution only selects the stable physical directory (`threads_meta.user_id`) and avoids a lost request context falling back to `users/default`. This also preserves cross-account task-deeplink conversations (rwfx/3qfx/xdfx) and roundtable system threads. Positive creator mappings are cached process-wide and DB/memory/missing-row failures fall back to the current login. It is wired at the thread-path consumers including `ThreadDataMiddleware.before_agent`, `present_file_tool`, uploads, artifact serving, and embedded `client.get_artifact`. **Rewrite-history recovery is metadata-only and job-owner scoped** (`list_by_session(..., user_id=current_user)`), so its GET/list endpoints intentionally skip redundant thread-owner checks and return an empty history instead of a history-only 404; the start/commit endpoints retain strict thread write authorization. Tests: `tests/test_thread_paths_owner.py`, `tests/test_thread_paths_roundtable.py`.
|
||||||
|
|
||||||
- **Durable document-rewrite commit:** `document_rewrite_pipeline.atomic_write_text` writes through a deliberately short sibling temporary filename so long Windows artifact paths stay below the path-length limit, and guarantees the target `outputs` parent exists. If the sandbox recreates that directory in the narrow commit window, it repairs the directory and retries; the original file is otherwise only changed through `os.replace`.
|
- **Durable document-rewrite commit:** `document_rewrite_pipeline.atomic_write_text` writes through a deliberately short sibling temporary filename so long Windows artifact paths stay below the path-length limit, and guarantees the target `outputs` parent exists. If the sandbox recreates that directory in the narrow commit window, it repairs the directory and retries; the original file is otherwise only changed through `os.replace`.
|
||||||
@@ -998,6 +1003,15 @@ scheduler runs once per deployment. Per run:
|
|||||||
- Supports `supports_vision` flag for image understanding models
|
- Supports `supports_vision` flag for image understanding models
|
||||||
- Config values starting with `$` resolved as environment variables
|
- Config values starting with `$` resolved as environment variables
|
||||||
- Missing provider modules surface actionable install hints from reflection resolvers (for example `uv add langchain-google-genai`)
|
- Missing provider modules surface actionable install hints from reflection resolvers (for example `uv add langchain-google-genai`)
|
||||||
|
- Generic `langchain_openai:ChatOpenAI` entries are transparently wrapped by
|
||||||
|
`PatchedChatOpenAI`. Besides preserving reasoning/tool-call metadata, the
|
||||||
|
patch recognizes compatible gateways that encode failures inside HTTP 200
|
||||||
|
payloads (`base_resp.status_code != 0` with null/empty `choices`) in both
|
||||||
|
streaming and non-streaming paths. It raises the stable
|
||||||
|
`OpenAICompatibleUpstreamError`; `LLMErrorHandlingMiddleware` retries
|
||||||
|
transient concurrency/inference failures, while genuine quota/balance
|
||||||
|
failures are classified as non-retriable and surfaced immediately. Embedded
|
||||||
|
vendor failures therefore cannot become empty successful assistant turns.
|
||||||
|
|
||||||
### Roundtable Model Fault Tolerance (会商模型自动容错 / 换下一个模型)
|
### Roundtable Model Fault Tolerance (会商模型自动容错 / 换下一个模型)
|
||||||
|
|
||||||
@@ -1014,9 +1028,11 @@ scheduler runs once per deployment. Per run:
|
|||||||
|
|
||||||
### vLLM Provider (`packages/harness/deerflow/models/vllm_provider.py`)
|
### vLLM Provider (`packages/harness/deerflow/models/vllm_provider.py`)
|
||||||
|
|
||||||
- `VllmChatModel` subclasses `langchain_openai:ChatOpenAI` for vLLM 0.19.0 OpenAI-compatible endpoints
|
- `VllmChatModel` subclasses `langchain_openai:ChatOpenAI` for vLLM OpenAI-compatible endpoints
|
||||||
- Preserves vLLM's non-standard assistant `reasoning` field on full responses, streaming deltas, and follow-up tool-call turns
|
- Thinking text is accepted from either `reasoning` (newer vLLM parsers) or `reasoning_content` (older vLLM / DeepSeek-style). The UI still reads canonical `additional_kwargs.reasoning_content`. Follow-up turns echo only the wire field that actually arrived, so other ChatOpenAI models are unchanged
|
||||||
- Designed for configs that enable thinking through `extra_body.chat_template_kwargs.enable_thinking` on vLLM 0.19.0 Qwen reasoning models, while accepting the older `thinking` alias
|
- Shared helpers live in `deerflow/models/reasoning_fields.py` and are also used by `PatchedChatOpenAI` (the default `langchain_openai:ChatOpenAI` patch)
|
||||||
|
- Designed for configs that enable thinking through `extra_body.chat_template_kwargs.enable_thinking` on vLLM Qwen reasoning models, while accepting the older `thinking` alias
|
||||||
|
- Per-model switch: set only that entry's `use: deerflow.models.vllm_provider:VllmChatModel`. Do not change SiliconFlow / DeepSeek entries that already use `langchain_openai:ChatOpenAI`
|
||||||
|
|
||||||
### IM Channels System (`app/channels/`)
|
### IM Channels System (`app/channels/`)
|
||||||
|
|
||||||
@@ -1126,6 +1142,15 @@ Both can be modified at runtime via Gateway API endpoints or `DeerFlowClient` me
|
|||||||
|
|
||||||
### Conditional WeKnora LLMWiki
|
### Conditional WeKnora LLMWiki
|
||||||
|
|
||||||
|
**Embedding ownership:** WeKnora's per-knowledge-base `embedding_model_id`
|
||||||
|
indexes uploaded source documents; `llmwiki.local_wiki_index.embedding` indexes
|
||||||
|
the generated Wiki-page mirror inside DeerFlow. Do not couple these two model
|
||||||
|
choices. `GET /api/llmwiki/embedding-models` returns a strict credential-free
|
||||||
|
projection of WeKnora Embedding models, and KB creation must validate and pass
|
||||||
|
the selected active model id. When the caller omits a choice, retain WeKnora's
|
||||||
|
active-default behavior. Existing KBs cannot change this binding in place, and
|
||||||
|
cross-KB WeKnora searches should use model-compatible knowledge bases.
|
||||||
|
|
||||||
Ordinary knowledge-base cards expose an owner/admin-only name/description edit dialog in `WeKnoraKnowledgeWorkspace.tsx`, reusing `useLlmWikiActions().update` and `PATCH /api/llmwiki/knowledge-bases/{id}`. The route trims names, rejects blank or reserved conversation-deposit names, writes to WeKnora before updating local metadata, and retains existing write authorization. A successful remote list is authoritative for every list scope, including `personal`: absent remote IDs are hidden without deleting mappings/references. Upstream failures remain errors. The grid also filters cached `remote_status=missing` rows and no longer renders stale-record cleanup cards. Regression coverage: `tests/test_llmwiki_knowledge_management.py`.
|
Ordinary knowledge-base cards expose an owner/admin-only name/description edit dialog in `WeKnoraKnowledgeWorkspace.tsx`, reusing `useLlmWikiActions().update` and `PATCH /api/llmwiki/knowledge-bases/{id}`. The route trims names, rejects blank or reserved conversation-deposit names, writes to WeKnora before updating local metadata, and retains existing write authorization. A successful remote list is authoritative for every list scope, including `personal`: absent remote IDs are hidden without deleting mappings/references. Upstream failures remain errors. The grid also filters cached `remote_status=missing` rows and no longer renders stale-record cleanup cards. Regression coverage: `tests/test_llmwiki_knowledge_management.py`.
|
||||||
|
|
||||||
`llmwiki.weknora.api_base_url` is the provider switch: non-empty enables WeKnora, empty preserves the legacy LLMWiki UI. WeKnora is independently deployed and owns PostgreSQL/Redis/object storage/vector storage/parsers/models; DeerFlow config contains only `api_base_url` and optional `web_base_url`. DeerFlow deliberately sends no WeKnora credential, so the WeKnora endpoint must be restricted to a trusted network and allow DeerFlow traffic itself or through its reverse proxy. There is intentionally no `default_embedding_model_id`: `WeKnoraClient` queries `/api/v1/models` and uses WeKnora's active Embedding model when creating a KB; Wiki creation additionally resolves an active KnowledgeQA model inside WeKnora for synthesis. The native BFF is `app/gateway/routers/llmwiki.py`; ownership/publication mappings live in `deerflow.persistence.llmwiki`; the browser only sees DeerFlow mapping IDs. Knowledge-base owners publish immediately (`private` -> `published`) without a review queue, and can unpublish ordinary bases. The conversation-deposit KB is system-owned, always published, shared by all DeerFlow users, excluded from My Knowledge, and immutable through user/admin detail operations; recognition requires both the reserved name and `system` owner. Existing same-name remote mappings are adopted in place as the system deposit, preserving mapping ids and document links. Its user-facing description, generated Markdown source and tags use `cmzs` branding; list/detail reads synchronize stale remote descriptions, and search results, source previews and embedded-detail JSON recursively replace legacy brand text in historical conversation-deposit payloads. Deposit requests must reference a thread accessible to the current account. Personal knowledge-base list/search views are scoped to the current DeerFlow account even for admins; frontend LLMWiki caches include the live account id, and admin write power does not make other users' private KBs appear as owned. Only `system_role === "admin"` sees the WeKnora connection-config tab. Knowledge cards navigate to a dedicated WeKnora-style detail route. Wiki-enabled bases render and default to `Wiki -> documents -> graph`; standard bases omit Wiki and render `documents -> graph`. The detail page supports sequential multi-file upload with an aggregate result for writable ordinary bases, hides it for public read-only/system-deposit bases, and uses a generic loading label while the embed session starts. It also supports URL and published manual-Markdown imports, browses real Wiki pages/content, proxies authenticated original-file previews, pages through `/chunks/{knowledge_id}`, and renders the Wiki page graph from `/knowledgebase/{kb_id}/wiki/graph`; document details use a right sheet rather than the removed KB modal. The credential-free proxy uses one WeKnora service identity, so per-user separation is enforced by DeerFlow mappings while all remote KBs physically share that identity's WeKnora workspace. WeKnora iframe detail pages first call `/api/llmwiki/knowledge-bases/{id}/weknora/session` through the normal DeerFlow API auth path, then load `/platform/knowledge-bases/{weknora_id}` with a signed `deerflow_weknora_embed` cookie for `/platform`, `/assets`, `/locales`, root assets, and API calls. Injected fetch/XHR rewriting sends all iframe `/api/v1/*`, including colliding `/api/v1/auth/*`, through `/api/llmwiki/weknora-embed/api/v1/*`; Gateway auth/CSRF bypass only applies to those signed iframe proxy requests, the proxy still revalidates the mapped KB, and cookie security honors `X-Forwarded-Proto` for HTTPS reverse proxies. `llmwiki_search` is conditionally registered in `tools.py`, is read-only, and rechecks the current DeerFlow user, agent `llmwiki_knowledge_base_ids`, and thread-context selection on every call. Source previews prefer the cleaned Wiki/LLMWiki page text when a retrieved chunk maps to a Wiki page, with raw split chunks as the fallback. Agent bindings use an inline WeKnora-style searchable/grouped multi-select in the standard agent create/detail UI; new chats use the same interaction pattern as a compact composer dropdown, and selections are written to thread metadata/context and restored on reopen. The DeerFlow frontend is native React and the WeKnora frontend is not modified or embedded. Full deployment and behavior notes: `docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md`. Tests: `tests/test_llmwiki_weknora.py`.
|
`llmwiki.weknora.api_base_url` is the provider switch: non-empty enables WeKnora, empty preserves the legacy LLMWiki UI. WeKnora is independently deployed and owns PostgreSQL/Redis/object storage/vector storage/parsers/models; DeerFlow config contains only `api_base_url` and optional `web_base_url`. DeerFlow deliberately sends no WeKnora credential, so the WeKnora endpoint must be restricted to a trusted network and allow DeerFlow traffic itself or through its reverse proxy. There is intentionally no `default_embedding_model_id`: `WeKnoraClient` queries `/api/v1/models` and uses WeKnora's active Embedding model when creating a KB; Wiki creation additionally resolves an active KnowledgeQA model inside WeKnora for synthesis. The native BFF is `app/gateway/routers/llmwiki.py`; ownership/publication mappings live in `deerflow.persistence.llmwiki`; the browser only sees DeerFlow mapping IDs. Knowledge-base owners publish immediately (`private` -> `published`) without a review queue, and can unpublish ordinary bases. The conversation-deposit KB is system-owned, always published, shared by all DeerFlow users, excluded from My Knowledge, and immutable through user/admin detail operations; recognition requires both the reserved name and `system` owner. Existing same-name remote mappings are adopted in place as the system deposit, preserving mapping ids and document links. Its user-facing description, generated Markdown source and tags use `cmzs` branding; list/detail reads synchronize stale remote descriptions, and search results, source previews and embedded-detail JSON recursively replace legacy brand text in historical conversation-deposit payloads. Deposit requests must reference a thread accessible to the current account. Personal knowledge-base list/search views are scoped to the current DeerFlow account even for admins; frontend LLMWiki caches include the live account id, and admin write power does not make other users' private KBs appear as owned. Only `system_role === "admin"` sees the WeKnora connection-config tab. Knowledge cards navigate to a dedicated WeKnora-style detail route. Wiki-enabled bases render and default to `Wiki -> documents -> graph`; standard bases omit Wiki and render `documents -> graph`. The detail page supports sequential multi-file upload with an aggregate result for writable ordinary bases, hides it for public read-only/system-deposit bases, and uses a generic loading label while the embed session starts. It also supports URL and published manual-Markdown imports, browses real Wiki pages/content, proxies authenticated original-file previews, pages through `/chunks/{knowledge_id}`, and renders the Wiki page graph from `/knowledgebase/{kb_id}/wiki/graph`; document details use a right sheet rather than the removed KB modal. The credential-free proxy uses one WeKnora service identity, so per-user separation is enforced by DeerFlow mappings while all remote KBs physically share that identity's WeKnora workspace. WeKnora iframe detail pages first call `/api/llmwiki/knowledge-bases/{id}/weknora/session` through the normal DeerFlow API auth path, then load `/platform/knowledge-bases/{weknora_id}` with a signed `deerflow_weknora_embed` cookie for `/platform`, `/assets`, `/locales`, root assets, and API calls. Injected fetch/XHR rewriting sends all iframe `/api/v1/*`, including colliding `/api/v1/auth/*`, through `/api/llmwiki/weknora-embed/api/v1/*`; Gateway auth/CSRF bypass only applies to those signed iframe proxy requests, the proxy still revalidates the mapped KB, and cookie security honors `X-Forwarded-Proto` for HTTPS reverse proxies. `llmwiki_search` is conditionally registered in `tools.py`, is read-only, and rechecks the current DeerFlow user, agent `llmwiki_knowledge_base_ids`, and thread-context selection on every call. Source previews prefer the cleaned Wiki/LLMWiki page text when a retrieved chunk maps to a Wiki page, with raw split chunks as the fallback. Agent bindings use an inline WeKnora-style searchable/grouped multi-select in the standard agent create/detail UI; new chats use the same interaction pattern as a compact composer dropdown, and selections are written to thread metadata/context and restored on reopen. The DeerFlow frontend is native React and the WeKnora frontend is not modified or embedded. Full deployment and behavior notes: `docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md`. Tests: `tests/test_llmwiki_weknora.py`.
|
||||||
@@ -1322,6 +1347,14 @@ Automatic conversation summarization when approaching token limits:
|
|||||||
when it has already been folded into the hidden summary. This keeps strict
|
when it has already been folded into the hidden summary. This keeps strict
|
||||||
OpenAI-compatible gateways from rejecting a tool-continuation call with
|
OpenAI-compatible gateways from rejecting a tool-continuation call with
|
||||||
`No user query found in message`.
|
`No user query found in message`.
|
||||||
|
- LangChain keeps the agent prompt in `ModelRequest.system_message`, separate
|
||||||
|
from `ModelRequest.messages`. Compression must merge its hidden summary into
|
||||||
|
that dedicated slot and remove every `SystemMessage` from the ordinary
|
||||||
|
message list. Strict Qwen-style adapters accept only one system message at
|
||||||
|
index zero and otherwise return HTTP 400 (`system message must be at the
|
||||||
|
beginning`). Keep this normalization adapter-independent; the payload-level
|
||||||
|
OpenAI-compatible normalizer is a second line of defence and also merges
|
||||||
|
adjacent leading system messages.
|
||||||
- Keeps recent messages while summarizing older ones.
|
- Keeps recent messages while summarizing older ones.
|
||||||
|
|
||||||
Tests: `tests/test_summarization_toggle.py` (gating + whitelist),
|
Tests: `tests/test_summarization_toggle.py` (gating + whitelist),
|
||||||
@@ -1801,8 +1834,8 @@ Ordinary knowledge detail landing is globally configured by `ordinary_knowledge_
|
|||||||
pages need backfill, not a forced rebuild of every page.
|
pages need backfill, not a forced rebuild of every page.
|
||||||
FastEmbed is a standard backend dependency. Encoder configuration, directory
|
FastEmbed is a standard backend dependency. Encoder configuration, directory
|
||||||
upload and warmup live only in the administrator global settings dialog.
|
upload and warmup live only in the administrator global settings dialog.
|
||||||
A runtime `system_settings.json` path override wins over config.yaml; uploaded
|
When the effective provider is local, its `system_settings.json` path override wins over config.yaml; uploaded
|
||||||
files are streamed into `.deer-flow/models/bge-m3`, never the source tree.
|
files are streamed into immutable `.deer-flow/models/bge-m3-revisions/<revision>` directories, never the source tree.
|
||||||
Encoder warmup is explicitly started from the settings UI/API and must never block application startup
|
Encoder warmup is explicitly started from the settings UI/API and must never block application startup
|
||||||
or turn a model-load failure into a DeerFlow service failure. Local provider
|
or turn a model-load failure into a DeerFlow service failure. Local provider
|
||||||
is offline-only: require local_model_path or DEERFLOW_BGE_M3_MODEL_PATH and
|
is offline-only: require local_model_path or DEERFLOW_BGE_M3_MODEL_PATH and
|
||||||
@@ -1810,6 +1843,21 @@ Ordinary knowledge detail landing is globally configured by `ordinary_knowledge_
|
|||||||
`local_process_isolation=true` by default: inference runs in the bundled JSONL
|
`local_process_isolation=true` by default: inference runs in the bundled JSONL
|
||||||
worker subprocess, and shutdown must terminate that worker without waiting on
|
worker subprocess, and shutdown must terminate that worker without waiting on
|
||||||
model teardown.
|
model teardown.
|
||||||
|
- The administrator encoder override is an explicit persisted mode, not an
|
||||||
|
inference from whether `model_path` happens to be non-empty. `configured`
|
||||||
|
uses the current `config.yaml` provider (including OpenAI-compatible);
|
||||||
|
saving/uploading a local directory sets `local` plus a new model revision,
|
||||||
|
while `/api/llmwiki/local-encoder/use-configured` switches back. Every switch
|
||||||
|
hot-rebuilds embedding/cache/search/sync/retrieval as one graph and updates
|
||||||
|
the harness runtime registration. Status must remain readable for a remote
|
||||||
|
provider. The scheduler stays alive as a lightweight config watcher but only
|
||||||
|
scans when effective `enabled && auto_sync`; keep the default/portable release
|
||||||
|
configs at `auto_sync=true` because asynchronous Wiki generation can finish
|
||||||
|
after the event-triggered first scan. A changed local model revision must
|
||||||
|
change the embedding fingerprint so vectors from replaced weights are never
|
||||||
|
reused. Uploaded weights live in immutable revision directories. A replaced
|
||||||
|
embedding generation gets a bounded grace period for in-flight work, then
|
||||||
|
rejects stragglers, drains real thread work, and releases its model process.
|
||||||
- Every successful mapping snapshot is mirrored to the global assistant base,
|
- Every successful mapping snapshot is mirrored to the global assistant base,
|
||||||
including empty snapshots so deleted source pages are retired. Re-import is
|
including empty snapshots so deleted source pages are retired. Re-import is
|
||||||
source-replacement semantics: absent Wiki/chunk/entity/relation pages must not
|
source-replacement semantics: absent Wiki/chunk/entity/relation pages must not
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ Middlewares execute in strict order, each handling a specific concern:
|
|||||||
| 1 | **ThreadDataMiddleware** | Creates per-thread isolated directories (workspace, uploads, outputs) |
|
| 1 | **ThreadDataMiddleware** | Creates per-thread isolated directories (workspace, uploads, outputs) |
|
||||||
| 2 | **UploadsMiddleware** | Injects newly uploaded files into conversation context |
|
| 2 | **UploadsMiddleware** | Injects newly uploaded files into conversation context |
|
||||||
| 3 | **SandboxMiddleware** | Acquires sandbox environment for code execution |
|
| 3 | **SandboxMiddleware** | Acquires sandbox environment for code execution |
|
||||||
| 4 | **SummarizationMiddleware** | Reduces context when approaching token limits (optional) |
|
| 4 | **SummarizationMiddleware** | Reduces context near token limits and folds the hidden summary into one leading system message for strict model adapters (optional) |
|
||||||
| 5 | **TodoListMiddleware** | Tracks multi-step tasks in plan mode (optional) |
|
| 5 | **TodoListMiddleware** | Tracks multi-step tasks in plan mode (optional) |
|
||||||
| 6 | **TitleMiddleware** | Auto-generates conversation titles after first exchange |
|
| 6 | **TitleMiddleware** | Auto-generates conversation titles after first exchange |
|
||||||
| 7 | **MemoryMiddleware** | Queues conversations for async memory extraction |
|
| 7 | **MemoryMiddleware** | Queues conversations for async memory extraction |
|
||||||
@@ -94,6 +94,7 @@ Per-thread isolated execution with virtual path translation:
|
|||||||
- **Providers**: `LocalSandboxProvider` (filesystem) and `AioSandboxProvider` (Docker, in community/)
|
- **Providers**: `LocalSandboxProvider` (filesystem) and `AioSandboxProvider` (Docker, in community/)
|
||||||
- **Virtual paths**: `/mnt/user-data/{workspace,uploads,outputs}` → thread-specific physical directories
|
- **Virtual paths**: `/mnt/user-data/{workspace,uploads,outputs}` → thread-specific physical directories
|
||||||
- **Skills path**: `/mnt/skills` → `deer-flow/skills/` directory
|
- **Skills path**: `/mnt/skills` → `deer-flow/skills/` directory
|
||||||
|
- **Portable configured mounts**: a relative `sandbox.mounts[*].host_path` is resolved against the directory containing the loaded `config.yaml`, not the process working directory. Providers still receive an absolute host path, so `knowledge/knowledge_base` can be committed and the deployment can be cloned or moved without editing a machine-specific path; already-absolute mount paths are preserved.
|
||||||
- **Skills loading**: Recursively discovers nested `SKILL.md` files under `skills/{public,custom}` and preserves nested container paths
|
- **Skills loading**: Recursively discovers nested `SKILL.md` files under `skills/{public,custom}` and preserves nested container paths
|
||||||
- **Conditional WeKnora LLMWiki**: a non-empty `llmwiki.weknora.api_base_url` enables the DeerFlow-native WeKnora BFF and UI; an empty address preserves the existing LLMWiki page. WeKnora remains independently deployed and owns its database, Redis, storage, parsers and models. DeerFlow keeps only the API/admin addresses, user/publication mappings, agent knowledge-base bindings and per-conversation selections; it stores and sends no WeKnora credential, so the endpoint must be restricted to a trusted network and allow DeerFlow traffic. Owners publish directly to the public LLMWiki without an approval queue for ordinary bases; the system-owned conversation-deposit base is always published, shared by all users, excluded from My Knowledge, immutable through user/admin detail operations, and recognized by both its reserved name and `system` owner. An existing same-name remote mapping is adopted in place as the system deposit so its mapping id and document links stay valid. Its user-facing knowledge-base description and generated document metadata use the `cmzs` brand; list/detail reads synchronize stale remote descriptions, while conversation-deposit search results, source previews and embedded-detail JSON rewrite legacy brand text to `cmzs`. Deposit requests must reference a thread accessible to the current account. Personal knowledge-base lists are scoped to the current DeerFlow account even for admins, while frontend LLMWiki query caches also include the active account id so switching accounts cannot reuse another account's list/detail data. Admins remain the only role that sees the WeKnora connection-config tab. Knowledge cards open a dedicated WeKnora-style detail route: Wiki-enabled bases default to Wiki, then documents and graph; standard bases hide Wiki and default to documents. Writable ordinary knowledge-base details expose a native multi-file upload action that submits files sequentially and reports aggregate success/failure; read-only public bases and the system deposit base do not. It supports file/URL/manual-Markdown imports, Wiki page browsing, document preview, real parsed chunks and Wiki graph nodes/edges. New chats use a compact WeKnora-style searchable/grouped multi-select inside the composer, while agent create/edit pages bind spaces through an inline panel. A single proxy service identity means DeerFlow users are isolated logically while their WeKnora entities share that identity's workspace. The read-only `llmwiki_search` tool reauthorizes the current DeerFlow user on every call and renders credential-free source previews; source previews prefer cleaned Wiki/LLMWiki text when a retrieved chunk maps to a Wiki page, with raw chunks as the fallback. The detail page first creates a signed iframe session through the normal DeerFlow API auth path, then points the iframe directly at the proxied WeKnora `/platform/...` route using the signed `deerflow_weknora_embed` cookie; global auth/CSRF bypass is limited to those signed iframe proxy requests, and HTTPS cookie attributes honor `X-Forwarded-Proto` behind reverse proxies. The iframe proxy keeps WeKnora's root-relative `/platform`, `/assets`, `/locales`, and `/api/v1/*` paths, but is registered last through `llmwiki.proxy_router` so it cannot intercept first-party Gateway APIs such as `POST /api/v1/auth/login/username`. See [docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md](docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md).
|
- **Conditional WeKnora LLMWiki**: a non-empty `llmwiki.weknora.api_base_url` enables the DeerFlow-native WeKnora BFF and UI; an empty address preserves the existing LLMWiki page. WeKnora remains independently deployed and owns its database, Redis, storage, parsers and models. DeerFlow keeps only the API/admin addresses, user/publication mappings, agent knowledge-base bindings and per-conversation selections; it stores and sends no WeKnora credential, so the endpoint must be restricted to a trusted network and allow DeerFlow traffic. Owners publish directly to the public LLMWiki without an approval queue for ordinary bases; the system-owned conversation-deposit base is always published, shared by all users, excluded from My Knowledge, immutable through user/admin detail operations, and recognized by both its reserved name and `system` owner. An existing same-name remote mapping is adopted in place as the system deposit so its mapping id and document links stay valid. Its user-facing knowledge-base description and generated document metadata use the `cmzs` brand; list/detail reads synchronize stale remote descriptions, while conversation-deposit search results, source previews and embedded-detail JSON rewrite legacy brand text to `cmzs`. Deposit requests must reference a thread accessible to the current account. Personal knowledge-base lists are scoped to the current DeerFlow account even for admins, while frontend LLMWiki query caches also include the active account id so switching accounts cannot reuse another account's list/detail data. Admins remain the only role that sees the WeKnora connection-config tab. Knowledge cards open a dedicated WeKnora-style detail route: Wiki-enabled bases default to Wiki, then documents and graph; standard bases hide Wiki and default to documents. Writable ordinary knowledge-base details expose a native multi-file upload action that submits files sequentially and reports aggregate success/failure; read-only public bases and the system deposit base do not. It supports file/URL/manual-Markdown imports, Wiki page browsing, document preview, real parsed chunks and Wiki graph nodes/edges. New chats use a compact WeKnora-style searchable/grouped multi-select inside the composer, while agent create/edit pages bind spaces through an inline panel. A single proxy service identity means DeerFlow users are isolated logically while their WeKnora entities share that identity's workspace. The read-only `llmwiki_search` tool reauthorizes the current DeerFlow user on every call and renders credential-free source previews; source previews prefer cleaned Wiki/LLMWiki text when a retrieved chunk maps to a Wiki page, with raw chunks as the fallback. The detail page first creates a signed iframe session through the normal DeerFlow API auth path, then points the iframe directly at the proxied WeKnora `/platform/...` route using the signed `deerflow_weknora_embed` cookie; global auth/CSRF bypass is limited to those signed iframe proxy requests, and HTTPS cookie attributes honor `X-Forwarded-Proto` behind reverse proxies. The iframe proxy keeps WeKnora's root-relative `/platform`, `/assets`, `/locales`, and `/api/v1/*` paths, but is registered last through `llmwiki.proxy_router` so it cannot intercept first-party Gateway APIs such as `POST /api/v1/auth/login/username`. See [docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md](docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md).
|
||||||
- **Enterprise Research workbench (temporarily disabled)**: the independent `/api/enterprise-research` router, its report executor, and its ORM table registration are intentionally inactive. The implementation remains in the repository, but startup does not create or alter `enterprise_research_tasks` or `enterprise_research_report_jobs`; this keeps the active Deep Research workflow independent of the paused workbench and avoids MySQL schema errors.
|
- **Enterprise Research workbench (temporarily disabled)**: the independent `/api/enterprise-research` router, its report executor, and its ORM table registration are intentionally inactive. The implementation remains in the repository, but startup does not create or alter `enterprise_research_tasks` or `enterprise_research_report_jobs`; this keeps the active Deep Research workflow independent of the paused workbench and avoids MySQL schema errors.
|
||||||
@@ -471,6 +472,10 @@ Custom agents are indexed in the `agents` database table. The `id` column is the
|
|||||||
|
|
||||||
The built-in `forced-research-responder` (「强制检索输出助手」) is seeded at Gateway startup. Unlike an ordinary skill-enabled custom agent, it has a runtime-enforced per-turn collection gate: the model cannot finish a response until a retrieval/knowledge/MCP tool or a skill Python script returns usable information. It then answers directly in the user's requested chat format rather than generating an artifact. The agent may create or edit only temporary `.py` files below `/mnt/user-data/workspace/` and may execute `.py` skill scripts through `bash`; writes to `outputs`, Markdown/document creation, `present_files`, general shell commands, and skill mutation are denied. Administrators can edit its model and skill allowlist from agent management; the runtime safety policy remains fixed. Regression coverage: `tests/test_forced_research_agent.py`.
|
The built-in `forced-research-responder` (「强制检索输出助手」) is seeded at Gateway startup. Unlike an ordinary skill-enabled custom agent, it has a runtime-enforced per-turn collection gate: the model cannot finish a response until a retrieval/knowledge/MCP tool or a skill Python script returns usable information. It then answers directly in the user's requested chat format rather than generating an artifact. The agent may create or edit only temporary `.py` files below `/mnt/user-data/workspace/` and may execute `.py` skill scripts through `bash`; writes to `outputs`, Markdown/document creation, `present_files`, general shell commands, and skill mutation are denied. Administrators can edit its model and skill allowlist from agent management; the runtime safety policy remains fixed. Regression coverage: `tests/test_forced_research_agent.py`.
|
||||||
|
|
||||||
|
The strategy-selection workbench starts with nine folder-backed functional agents: one intent agent, six independent angle agents (governance, diplomacy, economy/supply-chain, security risk, public opinion, and law/policy), one judge, and one final solution editor. Defaults ship in `app/gateway/routers/_strategy_selection_seed_assets/` and are copied only when an agent directory is absent, before `_sync_legacy_agents`; existing administrator edits are never overwritten. A global `strategy_agent_roles` registry owns classification, lifecycle state, and ordering independently of projects. Intent, judge, and solution are fixed singleton roles: administrators may update their model, SOUL, and skills but cannot add, disable, reorder, or delete them. Angles form one ordered catalog shared by every project; administrators may add, update, disable, restore, and reorder them. `GET /api/strategy-agents` is the public enabled catalog and omits model/SOUL/skills; `include_disabled=true` or `include_config=true` requires an administrator. Admin writes use `POST /api/strategy-agents/angles`, `PUT /api/strategy-agents/{id}`, and `DELETE /api/strategy-agents/{id}`; deletion is a soft disable so folder configuration and historical project references remain intact. Dynamic angles use the reserved `strategy-selection-angle-` namespace, receive `web` + `file:read`, and get a safe default SOUL if the submitted SOUL is blank. Model names and selected skills are validated, skills must be active and globally visible, generic agent CRUD cannot create or hard-delete reserved strategy agents, and parallel execution rejects disabled roles. The shipped angle agents use `policy-knowledge`, intent uses `intent-clarification-policy`, judging is input-only, and the solution editor has file-write access for `/mnt/user-data/outputs/综合策略方案.md`. Every default SOUL requires neutral evidence handling, de-escalation and civilian protection, and prohibits operational combat guidance or targeted political manipulation.
|
||||||
|
|
||||||
|
`POST /api/parallel-agents/run/stream` keeps reasoning and the deliverable in separate per-agent frames. Provider structured reasoning and inline `<think>...</think>` content are emitted only as `reasoning_delta`; visible answer text is emitted as `delta`, and `agent_done.content` contains only the visible answer. Consumers must not append `reasoning_delta` to strategy output or the final document. A final `is_llm_error_message` fallback, empty/thinking-only response, or punctuation-only provider degeneration is emitted as `agent_error`, never as a successful `agent_done`. Regression coverage: `tests/test_strategy_selection_seed.py`, `tests/test_strategy_agent_management.py`, and `tests/test_roundtable_model_fallback.py`.
|
||||||
|
|
||||||
### AI Writing Intranet Retrieval
|
### AI Writing Intranet Retrieval
|
||||||
|
|
||||||
AI 写作的「知识库检索」会直连 `ai_writing.intranet_search_url` 内网 ES/知识库接口,并默认跳过代理环境变量和 HTTPS 证书校验,适配自签名或内部 CA 证书的内网服务。需要强校验时可在 `config.yaml` 里设置 `ai_writing.intranet_verify_ssl: true`。通用检索里的非内置技能在 `ai_writing.researcher_skill_agent: true` 时会用真实 agent 运行时执行技能:以 `ai-writing-researcher` 身份读取该技能的 `SKILL.md`,并按技能要求通过 `bash` 运行脚本,弱模型也会在本轮任务里收到明确的读文件和脚本执行指令。
|
AI 写作的「知识库检索」会直连 `ai_writing.intranet_search_url` 内网 ES/知识库接口,并默认跳过代理环境变量和 HTTPS 证书校验,适配自签名或内部 CA 证书的内网服务。需要强校验时可在 `config.yaml` 里设置 `ai_writing.intranet_verify_ssl: true`。通用检索里的非内置技能在 `ai_writing.researcher_skill_agent: true` 时会用真实 agent 运行时执行技能:以 `ai-writing-researcher` 身份读取该技能的 `SKILL.md`,并按技能要求通过 `bash` 运行脚本,弱模型也会在本轮任务里收到明确的读文件和脚本执行指令。
|
||||||
@@ -543,6 +548,13 @@ the configured model has an explicit, proxy-supported thinking control. This
|
|||||||
prevents DeerFlow's connection check from sending vendor-specific `thinking`
|
prevents DeerFlow's connection check from sending vendor-specific `thinking`
|
||||||
fields that a generic OpenAI-compatible gateway may reject.
|
fields that a generic OpenAI-compatible gateway may reject.
|
||||||
|
|
||||||
|
OpenAI-compatible gateways that return vendor errors inside an HTTP 200
|
||||||
|
response (for example, a non-zero `base_resp.status_code` with empty/null
|
||||||
|
`choices`) are treated as upstream failures in both streaming and
|
||||||
|
non-streaming calls. DeerFlow retries transient upstream responses instead of
|
||||||
|
accepting an empty assistant turn, while genuine quota/balance failures are
|
||||||
|
classified as non-retriable and surfaced immediately instead of being retried.
|
||||||
|
|
||||||
### Running
|
### Running
|
||||||
|
|
||||||
**Full Application** (from project root):
|
**Full Application** (from project root):
|
||||||
@@ -916,6 +928,13 @@ the file through the existing thread-artifact endpoint.
|
|||||||
|
|
||||||
远端已经删除的知识库会在下次列表刷新时自动隐藏,包括个人、公共列表和知识库选择器;页面每 15 秒自动刷新,也可以手动点击「刷新」。远端服务请求失败时展示错误,不把连接故障当成删除,不自动删除本地映射或已有引用。
|
远端已经删除的知识库会在下次列表刷新时自动隐藏,包括个人、公共列表和知识库选择器;页面每 15 秒自动刷新,也可以手动点击「刷新」。远端服务请求失败时展示错误,不把连接故障当成删除,不自动删除本地映射或已有引用。
|
||||||
|
|
||||||
|
新建知识库时会通过 `GET /api/llmwiki/embedding-models` 读取 WeKnora 中可用的
|
||||||
|
Embedding 模型,并把所选 `embedding_model_id` 明确传给创建接口。该模型只负责
|
||||||
|
WeKnora 中上传原文的分块向量化;它和下文 DeerFlow 自己的 Wiki 镜像编码器是两套
|
||||||
|
独立配置,因此选择 OpenAI/OpenAI-compatible 原文编码器时不要求同时启用本地
|
||||||
|
BGE-M3。模型列表响应只暴露名称、provider、状态等白名单字段,不返回密钥或连接
|
||||||
|
参数。模型绑定只对新建库生效;需要跨知识库检索时,各库应使用相同或兼容的向量模型。
|
||||||
|
|
||||||
## DeerFlow-local LLMWiki Wiki vector index
|
## DeerFlow-local LLMWiki Wiki vector index
|
||||||
|
|
||||||
LLMWiki can mirror processed WeKnora Wiki pages into DeerFlow and search only
|
LLMWiki can mirror processed WeKnora Wiki pages into DeerFlow and search only
|
||||||
@@ -1164,13 +1183,23 @@ llmwiki:
|
|||||||
|
|
||||||
本地编码依赖随标准后端安装。将完整 BGE-M3 模型目录提前放到离线服务器,并通过
|
本地编码依赖随标准后端安装。将完整 BGE-M3 模型目录提前放到离线服务器,并通过
|
||||||
`local_model_path` 或 `DEERFLOW_BGE_M3_MODEL_PATH` 指向它。管理员也可在
|
`local_model_path` 或 `DEERFLOW_BGE_M3_MODEL_PATH` 指向它。管理员也可在
|
||||||
「设置和更多 → 设置 → 本地编码器」中选择服务器目录,或从浏览器上传完整模型目录;
|
「设置和更多 → 设置 → Wiki 镜像编码器」中选择服务器目录,或从浏览器上传完整模型目录;
|
||||||
页面配置持久化到运行目录的 `system_settings.json`,上传文件落到
|
页面配置持久化到运行目录的 `system_settings.json`,上传文件落到
|
||||||
`.deer-flow/models/bge-m3`,均不进入源码仓库。系统强制 `local_files_only`,
|
`.deer-flow/models/bge-m3-revisions/<revision>` 的不可变版本目录,均不进入源码仓库。系统强制 `local_files_only`,
|
||||||
**不会访问互联网或下载模型**。管理员可在同一设置页点击「一键启动编码器」异步加载;该操作不挂在应用启动流程,加载失败不会
|
**不会访问互联网或下载模型**。管理员可在同一设置页点击「一键启动编码器」异步加载;该操作不挂在应用启动流程,加载失败不会
|
||||||
中断 DeerFlow 主服务。默认由独立低优先级子进程承载模型内存,正常停止 DeerFlow
|
中断 DeerFlow 主服务。默认由独立低优先级子进程承载模型内存,正常停止 DeerFlow
|
||||||
时会一并回收该子进程;编码进程异常退出只会将当前向量任务标记失败。模型目录应包含
|
时会一并回收该子进程;编码进程异常退出只会将当前向量任务标记失败。模型目录应包含
|
||||||
`onnx/model.onnx`(官方权重约 2.3 GB)及对应 tokenizer/config 文件。权重不进入业务源码仓库。
|
`onnx/model.onnx`(官方权重约 2.3 GB)及对应 tokenizer/config 文件。权重不进入业务源码仓库。
|
||||||
|
|
||||||
|
运行时编码器模式持久化为 `configured` 或 `local`。默认 `configured` 严格使用
|
||||||
|
`config.yaml` 中的 OpenAI、OpenAI-compatible 或 local provider;保存服务器目录或
|
||||||
|
上传模型会显式切到 `local` 并热重建 embedding/search/sync/retrieval 服务,不需要
|
||||||
|
重启 Gateway。`POST /api/llmwiki/local-encoder/use-configured` 可立即恢复配置文件中的
|
||||||
|
provider。`GET /api/llmwiki/local-encoder/status` 在远程 provider 下也正常返回当前
|
||||||
|
provider/mode,不再把“不是 local”当成状态接口错误。每次激活本地模型都会生成新的
|
||||||
|
模型修订指纹,避免同一路径替换权重后误复用旧向量。切换后的旧编码器会给在途任务
|
||||||
|
保留短暂宽限期,随后等待当前编码调用结束并自动回收模型进程。
|
||||||
|
|
||||||
浏览器目录上传采用磁盘暂存和 4 MB 分块复制,页面显示上传进度;经 Nginx 等反向
|
浏览器目录上传采用磁盘暂存和 4 MB 分块复制,页面显示上传进度;经 Nginx 等反向
|
||||||
代理部署时仍需把请求体上限和读写超时配置到可承载完整模型目录。
|
代理部署时仍需把请求体上限和读写超时配置到可承载完整模型目录。
|
||||||
相同模型指纹、相同内容的页面复用现有向量;短正文/摘要至少保留一个非空分段。
|
相同模型指纹、相同内容的页面复用现有向量;短正文/摘要至少保留一个非空分段。
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ from app.gateway.routers import (
|
|||||||
compact_menu_overrides,
|
compact_menu_overrides,
|
||||||
dashboard_sessions,
|
dashboard_sessions,
|
||||||
deep_research,
|
deep_research,
|
||||||
|
direct_chat,
|
||||||
external_llmwiki,
|
external_llmwiki,
|
||||||
feedback,
|
feedback,
|
||||||
fixed_questions,
|
fixed_questions,
|
||||||
@@ -91,6 +92,7 @@ from app.gateway.routers import (
|
|||||||
skill_addresses,
|
skill_addresses,
|
||||||
skill_knowledge,
|
skill_knowledge,
|
||||||
skills,
|
skills,
|
||||||
|
strategy_agents,
|
||||||
suggestions,
|
suggestions,
|
||||||
system_settings,
|
system_settings,
|
||||||
tags,
|
tags,
|
||||||
@@ -1019,20 +1021,14 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
# Run Alembic migrations before anything else touches the schema.
|
# Run Alembic migrations before anything else touches the schema.
|
||||||
await _run_db_migrations()
|
await _run_db_migrations()
|
||||||
|
|
||||||
# Local Wiki mirror/index scans are opt-in and run outside request
|
# The dormant scheduler also watches config.yaml/system-settings
|
||||||
# handling. Per-library database leases keep multi-worker startup safe.
|
# changes. Actual scans remain opt-in via enabled + auto_sync, while a
|
||||||
app.state.llmwiki_index_scheduler_task = None
|
# runtime provider/URL switch can take effect without a restart.
|
||||||
local_wiki_index = app.state.config.llmwiki.local_wiki_index
|
from app.gateway.llmwiki_index_scheduler import local_wiki_index_scheduler_loop
|
||||||
if (
|
|
||||||
local_wiki_index.enabled
|
|
||||||
and local_wiki_index.auto_sync
|
|
||||||
and getattr(app.state, "llmwiki_sync_service", None) is not None
|
|
||||||
):
|
|
||||||
from app.gateway.llmwiki_index_scheduler import local_wiki_index_scheduler_loop
|
|
||||||
|
|
||||||
app.state.llmwiki_index_scheduler_task = asyncio.create_task(
|
app.state.llmwiki_index_scheduler_task = asyncio.create_task(
|
||||||
local_wiki_index_scheduler_loop(app)
|
local_wiki_index_scheduler_loop(app)
|
||||||
)
|
)
|
||||||
|
|
||||||
# ?? recall_budget cache?? DB ????,? get_effective_memory_config ?????
|
# ?? recall_budget cache?? DB ????,? get_effective_memory_config ?????
|
||||||
try:
|
try:
|
||||||
@@ -1087,6 +1083,21 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
"AI-writing functional agent seeding failed at startup (non-fatal)"
|
"AI-writing functional agent seeding failed at startup (non-fatal)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Strategy-selection functional agents (intent, six independent
|
||||||
|
# analysis angles, judge, and solution editor). Seed them before
|
||||||
|
# _sync_legacy_agents so they are immediately visible and editable in
|
||||||
|
# the existing agent-management UI on a fresh deployment.
|
||||||
|
try:
|
||||||
|
from app.gateway.routers._strategy_selection_seed import (
|
||||||
|
ensure_strategy_selection_agents,
|
||||||
|
)
|
||||||
|
|
||||||
|
ensure_strategy_selection_agents()
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Strategy-selection functional agent seeding failed at startup (non-fatal)"
|
||||||
|
)
|
||||||
|
|
||||||
# 内置「模板数据转换器」agent(JSON → HTML 模板填充)。同样必须在
|
# 内置「模板数据转换器」agent(JSON → HTML 模板填充)。同样必须在
|
||||||
# _sync_legacy_agents **之前**补建目录,后者把它 upsert 进 agents 表,
|
# _sync_legacy_agents **之前**补建目录,后者把它 upsert 进 agents 表,
|
||||||
# 第一次启动 /api/agents 与定时任务下拉框里就有它。
|
# 第一次启动 /api/agents 与定时任务下拉框里就有它。
|
||||||
@@ -1211,6 +1222,15 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
await ensure_builtin_agent_purpose_tags(agent_store, tag_store)
|
await ensure_builtin_agent_purpose_tags(agent_store, tag_store)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Built-in agent purpose-tag seeding failed at startup (non-fatal)")
|
logger.exception("Built-in agent purpose-tag seeding failed at startup (non-fatal)")
|
||||||
|
|
||||||
|
# Synchronize every worker's strategy registry only after the
|
||||||
|
# folder agents have been imported. In memory mode this also
|
||||||
|
# restores dynamic/default angle lifecycle from sidecars.
|
||||||
|
try:
|
||||||
|
await strategy_agents.ensure_strategy_agent_registry(app)
|
||||||
|
logger.info("Strategy agent registry synchronized for worker")
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Strategy agent role seeding failed at startup (non-fatal)")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Legacy agent sync failed at startup (non-fatal)")
|
logger.exception("Legacy agent sync failed at startup (non-fatal)")
|
||||||
|
|
||||||
@@ -1605,6 +1625,7 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
|||||||
|
|
||||||
# Agents API is mounted at /api/agents
|
# Agents API is mounted at /api/agents
|
||||||
app.include_router(agents.router)
|
app.include_router(agents.router)
|
||||||
|
app.include_router(strategy_agents.router)
|
||||||
|
|
||||||
# Recommended Questions API is mounted at /api/recommended-questions
|
# Recommended Questions API is mounted at /api/recommended-questions
|
||||||
app.include_router(recommended_questions.router)
|
app.include_router(recommended_questions.router)
|
||||||
@@ -1803,6 +1824,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
|||||||
# 开放问答接口 /api/open/chat —— 无需登录,指定模型 + 智能体做问答,可关闭思考模式
|
# 开放问答接口 /api/open/chat —— 无需登录,指定模型 + 智能体做问答,可关闭思考模式
|
||||||
app.include_router(open_chat.router)
|
app.include_router(open_chat.router)
|
||||||
|
|
||||||
|
# 极速问答 /api/direct-chat/stream —— 直连大模型流式输出,不走 lead agent / 工具 / 技能
|
||||||
|
app.include_router(direct_chat.router)
|
||||||
|
|
||||||
# 开放接口 /api/open/3qfx/ask —— 无需登录,按 taskId 直接发起 3qfx 第一次问答(后台运行,落成真实会话)
|
# 开放接口 /api/open/3qfx/ask —— 无需登录,按 taskId 直接发起 3qfx 第一次问答(后台运行,落成真实会话)
|
||||||
app.include_router(open_3qfx.router)
|
app.include_router(open_3qfx.router)
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ if TYPE_CHECKING:
|
|||||||
from deerflow.persistence.notifications import NotificationStore
|
from deerflow.persistence.notifications import NotificationStore
|
||||||
from deerflow.persistence.scheduled_tasks import ScheduledTaskStore
|
from deerflow.persistence.scheduled_tasks import ScheduledTaskStore
|
||||||
from deerflow.persistence.skills import SkillStore
|
from deerflow.persistence.skills import SkillStore
|
||||||
|
from deerflow.persistence.strategy_agent_roles import StrategyAgentRoleStore
|
||||||
from deerflow.persistence.tags import TagStore
|
from deerflow.persistence.tags import TagStore
|
||||||
from deerflow.persistence.thread_meta.base import ThreadMetaStore
|
from deerflow.persistence.thread_meta.base import ThreadMetaStore
|
||||||
|
|
||||||
@@ -132,6 +133,12 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
app.state.agent_store = make_agent_store(sf)
|
app.state.agent_store = make_agent_store(sf)
|
||||||
|
|
||||||
|
from deerflow.persistence.strategy_agent_roles import (
|
||||||
|
make_strategy_agent_role_store,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.state.strategy_agent_role_store = make_strategy_agent_role_store(sf)
|
||||||
|
|
||||||
from deerflow.persistence.skills import make_skill_store
|
from deerflow.persistence.skills import make_skill_store
|
||||||
|
|
||||||
app.state.skill_store = make_skill_store(sf)
|
app.state.skill_store = make_skill_store(sf)
|
||||||
@@ -384,7 +391,6 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
# DeerFlow-local Wiki mirror/vector index. The store is always wired so
|
# DeerFlow-local Wiki mirror/vector index. The store is always wired so
|
||||||
# migrations and status inspection work while feature flags are off;
|
# migrations and status inspection work while feature flags are off;
|
||||||
# network/search services only exist when explicitly enabled.
|
# network/search services only exist when explicitly enabled.
|
||||||
from deerflow.integrations.weknora.local_index.runtime import register_local_wiki_index_runtime
|
|
||||||
from deerflow.persistence.llmwiki_index import make_llmwiki_index_store
|
from deerflow.persistence.llmwiki_index import make_llmwiki_index_store
|
||||||
|
|
||||||
local_index_config = config.llmwiki.local_wiki_index
|
local_index_config = config.llmwiki.local_wiki_index
|
||||||
@@ -397,78 +403,12 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
app.state.llmwiki_vector_search = None
|
app.state.llmwiki_vector_search = None
|
||||||
app.state.llmwiki_sync_service = None
|
app.state.llmwiki_sync_service = None
|
||||||
app.state.llmwiki_retrieval_service = None
|
app.state.llmwiki_retrieval_service = None
|
||||||
from deerflow.integrations.weknora.runtime import build_weknora_client, get_resolved_llmwiki_runtime
|
from app.gateway.llmwiki_index_runtime import rebuild_local_wiki_index_runtime
|
||||||
|
|
||||||
resolved_llmwiki = get_resolved_llmwiki_runtime(config)
|
# Build every embedding-dependent object through the same hot-swap
|
||||||
weknora_client = build_weknora_client(resolved_llmwiki) if resolved_llmwiki.weknora_enabled else None
|
# path used by the administrator settings API. This keeps startup,
|
||||||
embedding_config = local_index_config.embedding
|
# uploaded-local selection and config.yaml reloads behaviorally equal.
|
||||||
# The administrator may select or upload the offline encoder from the
|
await rebuild_local_wiki_index_runtime(app, force=True)
|
||||||
# UI. This runtime-owned value deliberately overrides config.yaml and
|
|
||||||
# survives application restarts without modifying release assets.
|
|
||||||
if embedding_config.provider == "local":
|
|
||||||
from deerflow.config.system_settings import load_system_settings
|
|
||||||
|
|
||||||
runtime_model_path = load_system_settings().local_wiki_encoder.model_path.strip()
|
|
||||||
if runtime_model_path:
|
|
||||||
embedding_config = embedding_config.model_copy(
|
|
||||||
update={"local_model_path": runtime_model_path}
|
|
||||||
)
|
|
||||||
embedding_configured = bool(
|
|
||||||
embedding_config.enabled
|
|
||||||
and (embedding_config.base_url or embedding_config.provider == "local")
|
|
||||||
and embedding_config.model.strip()
|
|
||||||
)
|
|
||||||
if embedding_configured:
|
|
||||||
from deerflow.integrations.weknora.local_index.embedding import StrictWikiEmbeddingClient
|
|
||||||
|
|
||||||
app.state.llmwiki_embedding = StrictWikiEmbeddingClient(embedding_config)
|
|
||||||
if local_index_config.enabled and app.state.llmwiki_embedding is not None:
|
|
||||||
from deerflow.integrations.weknora.local_index.search import WikiVectorSearchService
|
|
||||||
from deerflow.integrations.weknora.local_index.sync import WikiSyncService
|
|
||||||
from deerflow.integrations.weknora.local_index.vector_cache import WikiVectorCache
|
|
||||||
|
|
||||||
app.state.llmwiki_vector_cache = WikiVectorCache(
|
|
||||||
app.state.llmwiki_index_store,
|
|
||||||
max_knowledge_bases=local_index_config.cache_max_knowledge_bases,
|
|
||||||
revision_check_seconds=local_index_config.cache_revision_check_seconds,
|
|
||||||
)
|
|
||||||
app.state.llmwiki_vector_search = WikiVectorSearchService(
|
|
||||||
local_index_config,
|
|
||||||
app.state.llmwiki_embedding,
|
|
||||||
app.state.llmwiki_vector_cache,
|
|
||||||
)
|
|
||||||
if weknora_client is not None:
|
|
||||||
app.state.llmwiki_sync_service = WikiSyncService(
|
|
||||||
local_index_config,
|
|
||||||
app.state.llmwiki_index_store,
|
|
||||||
app.state.llmwiki_embedding,
|
|
||||||
app.state.llmwiki_vector_cache,
|
|
||||||
weknora_client,
|
|
||||||
)
|
|
||||||
from app.gateway.knowledge_transfer import sync_to_global
|
|
||||||
|
|
||||||
async def on_wiki_vectorized(mapping):
|
|
||||||
return await sync_to_global(app, mapping)
|
|
||||||
|
|
||||||
app.state.llmwiki_sync_service.on_completed = on_wiki_vectorized
|
|
||||||
if weknora_client is not None or app.state.llmwiki_vector_search is not None:
|
|
||||||
from deerflow.integrations.weknora.local_index.retrieval import WikiRetrievalService
|
|
||||||
|
|
||||||
app.state.llmwiki_retrieval_service = WikiRetrievalService(
|
|
||||||
app.state.llmwiki_index_store,
|
|
||||||
vector_search=(
|
|
||||||
app.state.llmwiki_vector_search
|
|
||||||
if local_index_config.internal_search_enabled
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
client=weknora_client,
|
|
||||||
)
|
|
||||||
register_local_wiki_index_runtime(
|
|
||||||
store=app.state.llmwiki_index_store,
|
|
||||||
search=app.state.llmwiki_vector_search,
|
|
||||||
sync=app.state.llmwiki_sync_service,
|
|
||||||
retrieval=app.state.llmwiki_retrieval_service,
|
|
||||||
)
|
|
||||||
|
|
||||||
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
|
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
|
||||||
|
|
||||||
@@ -707,9 +647,9 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
from deerflow.integrations.weknora.local_index.runtime import register_local_wiki_index_runtime
|
from deerflow.integrations.weknora.local_index.runtime import register_local_wiki_index_runtime
|
||||||
|
|
||||||
register_local_wiki_index_runtime(store=None, search=None, sync=None, retrieval=None)
|
register_local_wiki_index_runtime(store=None, search=None, sync=None, retrieval=None)
|
||||||
llmwiki_embedding = getattr(app.state, "llmwiki_embedding", None)
|
from app.gateway.llmwiki_index_runtime import close_local_wiki_index_runtime
|
||||||
if llmwiki_embedding is not None:
|
|
||||||
await llmwiki_embedding.close()
|
await close_local_wiki_index_runtime(app)
|
||||||
from deerflow.persistence.notifications import set_notification_store
|
from deerflow.persistence.notifications import set_notification_store
|
||||||
|
|
||||||
set_notification_store(None)
|
set_notification_store(None)
|
||||||
@@ -824,6 +764,14 @@ def get_agent_store(request: Request) -> AgentStore:
|
|||||||
return val
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def get_strategy_agent_role_store(request: Request) -> StrategyAgentRoleStore:
|
||||||
|
"""Return the global strategy-agent role registry."""
|
||||||
|
val = getattr(request.app.state, "strategy_agent_role_store", None)
|
||||||
|
if val is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Strategy agent role store not available")
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
def get_skill_store(request: Request) -> SkillStore:
|
def get_skill_store(request: Request) -> SkillStore:
|
||||||
"""Return the custom-skill store."""
|
"""Return the custom-skill store."""
|
||||||
val = getattr(request.app.state, "skill_store", None)
|
val = getattr(request.app.state, "skill_store", None)
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
"""Hot-swappable runtime composition for the DeerFlow-local Wiki index.
|
||||||
|
|
||||||
|
The file configuration remains the source of truth for the normal embedding
|
||||||
|
provider. Administrators can temporarily select the uploaded offline BGE-M3
|
||||||
|
encoder through ``system_settings.json`` without rewriting release assets or
|
||||||
|
restarting Gateway.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from deerflow.config import get_app_config
|
||||||
|
from deerflow.config.app_config import AppConfig
|
||||||
|
from deerflow.config.llmwiki_config import LocalWikiIndexConfig
|
||||||
|
from deerflow.config.system_settings import LocalWikiEncoderSettings, load_system_settings
|
||||||
|
from deerflow.integrations.weknora.runtime import build_weknora_client, get_resolved_llmwiki_runtime
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
_EMBEDDING_RETIREMENT_GRACE_SECONDS = 600.0
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_effective_local_wiki_index_config(
|
||||||
|
config: AppConfig,
|
||||||
|
encoder_settings: LocalWikiEncoderSettings,
|
||||||
|
) -> LocalWikiIndexConfig:
|
||||||
|
"""Apply the persisted encoder mode to a copy of the file configuration."""
|
||||||
|
|
||||||
|
local_index = config.llmwiki.local_wiki_index
|
||||||
|
configured_embedding = local_index.embedding
|
||||||
|
runtime_model_path = encoder_settings.model_path.strip()
|
||||||
|
|
||||||
|
if encoder_settings.mode == "local":
|
||||||
|
# Directory upload is intentionally a BGE-M3 contract. A configured
|
||||||
|
# OpenAI model/dimension must not leak into the local FastEmbed worker.
|
||||||
|
embedding = configured_embedding.model_copy(
|
||||||
|
update={
|
||||||
|
"enabled": True,
|
||||||
|
"provider": "local",
|
||||||
|
"model": "bge-embedding-m3",
|
||||||
|
"dimensions": 1024,
|
||||||
|
"base_url": "",
|
||||||
|
"api_key": "",
|
||||||
|
"local_model_path": runtime_model_path or configured_embedding.local_model_path,
|
||||||
|
"local_model_revision": encoder_settings.model_revision,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif configured_embedding.provider == "local":
|
||||||
|
# Keep the historical invariant: an administrator-selected directory
|
||||||
|
# overrides config.yaml when the configured provider itself is local.
|
||||||
|
updates = {"local_model_revision": encoder_settings.model_revision}
|
||||||
|
if runtime_model_path:
|
||||||
|
updates["local_model_path"] = runtime_model_path
|
||||||
|
embedding = configured_embedding.model_copy(
|
||||||
|
update=updates
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
embedding = configured_embedding
|
||||||
|
return local_index.model_copy(update={"embedding": embedding})
|
||||||
|
|
||||||
|
|
||||||
|
def _resolved_local_model_path(config: LocalWikiIndexConfig) -> Path | None:
|
||||||
|
raw = config.embedding.local_model_path.strip() or os.environ.get(
|
||||||
|
"DEERFLOW_BGE_M3_MODEL_PATH", ""
|
||||||
|
).strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
path = Path(raw).expanduser()
|
||||||
|
return path.resolve() if path.is_absolute() else (Path.cwd() / path).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def _local_model_files_ready(config: LocalWikiIndexConfig) -> bool:
|
||||||
|
path = _resolved_local_model_path(config)
|
||||||
|
return bool(
|
||||||
|
path
|
||||||
|
and path.is_dir()
|
||||||
|
and ((path / "onnx" / "model.onnx").is_file() or (path / "model.onnx").is_file())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _embedding_provider_ready(config: LocalWikiIndexConfig) -> bool:
|
||||||
|
embedding = config.embedding
|
||||||
|
if embedding.provider == "local":
|
||||||
|
return _local_model_files_ready(config)
|
||||||
|
return bool(embedding.base_url)
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_embedding_retirement(app: FastAPI, embedding: Any) -> None:
|
||||||
|
retired = list(getattr(app.state, "llmwiki_retired_embeddings", []) or [])
|
||||||
|
if embedding not in retired:
|
||||||
|
retired.append(embedding)
|
||||||
|
app.state.llmwiki_retired_embeddings = retired
|
||||||
|
|
||||||
|
async def retire_after_grace() -> None:
|
||||||
|
retired_successfully = False
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(_EMBEDDING_RETIREMENT_GRACE_SECONDS)
|
||||||
|
await embedding.retire()
|
||||||
|
retired_successfully = True
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to retire an old Local Wiki embedding runtime")
|
||||||
|
finally:
|
||||||
|
if retired_successfully:
|
||||||
|
current = list(getattr(app.state, "llmwiki_retired_embeddings", []) or [])
|
||||||
|
app.state.llmwiki_retired_embeddings = [item for item in current if item is not embedding]
|
||||||
|
|
||||||
|
tasks: set[asyncio.Task[None]] = getattr(app.state, "llmwiki_retirement_tasks", None)
|
||||||
|
if tasks is None:
|
||||||
|
tasks = set()
|
||||||
|
app.state.llmwiki_retirement_tasks = tasks
|
||||||
|
task = asyncio.create_task(retire_after_grace(), name="llmwiki-embedding-retirement")
|
||||||
|
tasks.add(task)
|
||||||
|
task.add_done_callback(tasks.discard)
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_signature(
|
||||||
|
local_index: LocalWikiIndexConfig,
|
||||||
|
*,
|
||||||
|
resolved_llmwiki: Any,
|
||||||
|
encoder_mode: str,
|
||||||
|
) -> str:
|
||||||
|
payload = {
|
||||||
|
"local_index": local_index.model_dump(mode="json"),
|
||||||
|
# A directory can be mounted/unmounted without editing config.yaml.
|
||||||
|
# Readiness is part of the generation so services appear/disappear on
|
||||||
|
# the next lightweight scheduler poll instead of requiring a restart.
|
||||||
|
"local_model_files_ready": (
|
||||||
|
_local_model_files_ready(local_index)
|
||||||
|
if local_index.embedding.provider == "local"
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"encoder_mode": encoder_mode,
|
||||||
|
"weknora": {
|
||||||
|
"provider": resolved_llmwiki.provider,
|
||||||
|
"api_base_url": resolved_llmwiki.api_base_url,
|
||||||
|
"web_base_url": resolved_llmwiki.web_base_url,
|
||||||
|
"admin_email": resolved_llmwiki.admin_email,
|
||||||
|
"admin_password": resolved_llmwiki.admin_password,
|
||||||
|
"override_enabled": resolved_llmwiki.override_enabled,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
raw = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||||
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
async def rebuild_local_wiki_index_runtime(
|
||||||
|
app: FastAPI,
|
||||||
|
*,
|
||||||
|
force: bool = False,
|
||||||
|
) -> LocalWikiIndexConfig:
|
||||||
|
"""Refresh config and atomically replace all embedding-dependent services."""
|
||||||
|
|
||||||
|
lock = getattr(app.state, "llmwiki_runtime_lock", None)
|
||||||
|
if lock is None:
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
app.state.llmwiki_runtime_lock = lock
|
||||||
|
|
||||||
|
async with lock:
|
||||||
|
source_config = get_app_config()
|
||||||
|
settings = load_system_settings()
|
||||||
|
effective_config = resolve_effective_local_wiki_index_config(
|
||||||
|
source_config,
|
||||||
|
settings.local_wiki_encoder,
|
||||||
|
)
|
||||||
|
resolved_llmwiki = get_resolved_llmwiki_runtime(source_config)
|
||||||
|
signature = _runtime_signature(
|
||||||
|
effective_config,
|
||||||
|
resolved_llmwiki=resolved_llmwiki,
|
||||||
|
encoder_mode=settings.local_wiki_encoder.mode,
|
||||||
|
)
|
||||||
|
current_signature = getattr(app.state, "llmwiki_runtime_signature", None)
|
||||||
|
if not force and current_signature == signature:
|
||||||
|
app.state.llmwiki_source_config = source_config
|
||||||
|
app.state.llmwiki_effective_config = effective_config
|
||||||
|
return effective_config
|
||||||
|
|
||||||
|
index_store = getattr(app.state, "llmwiki_index_store", None)
|
||||||
|
if index_store is None:
|
||||||
|
raise RuntimeError("Local Wiki index store is not initialized")
|
||||||
|
|
||||||
|
from deerflow.integrations.weknora.local_index.embedding import StrictWikiEmbeddingClient
|
||||||
|
from deerflow.integrations.weknora.local_index.retrieval import WikiRetrievalService
|
||||||
|
from deerflow.integrations.weknora.local_index.runtime import register_local_wiki_index_runtime
|
||||||
|
from deerflow.integrations.weknora.local_index.search import WikiVectorSearchService
|
||||||
|
from deerflow.integrations.weknora.local_index.sync import WikiSyncService
|
||||||
|
from deerflow.integrations.weknora.local_index.vector_cache import WikiVectorCache
|
||||||
|
|
||||||
|
embedding_config = effective_config.embedding
|
||||||
|
provider_ready = _embedding_provider_ready(effective_config)
|
||||||
|
embedding_configured = bool(
|
||||||
|
embedding_config.enabled
|
||||||
|
and provider_ready
|
||||||
|
and embedding_config.model.strip()
|
||||||
|
)
|
||||||
|
old_embedding = getattr(app.state, "llmwiki_embedding", None)
|
||||||
|
reusable = (
|
||||||
|
old_embedding
|
||||||
|
if old_embedding is not None and old_embedding.config == embedding_config
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
new_embedding = None
|
||||||
|
if embedding_configured:
|
||||||
|
new_embedding = reusable or StrictWikiEmbeddingClient(embedding_config)
|
||||||
|
new_cache = None
|
||||||
|
new_search = None
|
||||||
|
new_sync = None
|
||||||
|
new_retrieval = None
|
||||||
|
weknora_client = (
|
||||||
|
build_weknora_client(resolved_llmwiki)
|
||||||
|
if resolved_llmwiki.weknora_enabled
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if effective_config.enabled and new_embedding is not None:
|
||||||
|
new_cache = WikiVectorCache(
|
||||||
|
index_store,
|
||||||
|
max_knowledge_bases=effective_config.cache_max_knowledge_bases,
|
||||||
|
revision_check_seconds=effective_config.cache_revision_check_seconds,
|
||||||
|
)
|
||||||
|
new_search = WikiVectorSearchService(
|
||||||
|
effective_config,
|
||||||
|
new_embedding,
|
||||||
|
new_cache,
|
||||||
|
)
|
||||||
|
if weknora_client is not None:
|
||||||
|
new_sync = WikiSyncService(
|
||||||
|
effective_config,
|
||||||
|
index_store,
|
||||||
|
new_embedding,
|
||||||
|
new_cache,
|
||||||
|
weknora_client,
|
||||||
|
)
|
||||||
|
from app.gateway.knowledge_transfer import sync_to_global
|
||||||
|
|
||||||
|
async def on_wiki_vectorized(mapping):
|
||||||
|
return await sync_to_global(app, mapping)
|
||||||
|
|
||||||
|
new_sync.on_completed = on_wiki_vectorized
|
||||||
|
|
||||||
|
if weknora_client is not None or new_search is not None:
|
||||||
|
new_retrieval = WikiRetrievalService(
|
||||||
|
index_store,
|
||||||
|
vector_search=(new_search if effective_config.internal_search_enabled else None),
|
||||||
|
client=weknora_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.state.llmwiki_source_config = source_config
|
||||||
|
app.state.llmwiki_effective_config = effective_config
|
||||||
|
app.state.llmwiki_embedding = new_embedding
|
||||||
|
app.state.llmwiki_vector_cache = new_cache
|
||||||
|
app.state.llmwiki_vector_search = new_search
|
||||||
|
app.state.llmwiki_sync_service = new_sync
|
||||||
|
app.state.llmwiki_retrieval_service = new_retrieval
|
||||||
|
app.state.llmwiki_runtime_signature = signature
|
||||||
|
register_local_wiki_index_runtime(
|
||||||
|
store=index_store,
|
||||||
|
search=new_search,
|
||||||
|
sync=new_sync,
|
||||||
|
retrieval=new_retrieval,
|
||||||
|
)
|
||||||
|
|
||||||
|
if old_embedding is not None and old_embedding is not new_embedding:
|
||||||
|
# Give requests holding the prior service graph time to finish,
|
||||||
|
# then reject any stragglers and release the potentially multi-GB
|
||||||
|
# local worker instead of accumulating one process per upload.
|
||||||
|
_schedule_embedding_retirement(app, old_embedding)
|
||||||
|
return effective_config
|
||||||
|
|
||||||
|
|
||||||
|
async def close_local_wiki_index_runtime(app: FastAPI) -> None:
|
||||||
|
"""Close the current and safely-retired embedding clients at shutdown."""
|
||||||
|
|
||||||
|
retired = list(getattr(app.state, "llmwiki_retired_embeddings", []) or [])
|
||||||
|
retirement_tasks = list(getattr(app.state, "llmwiki_retirement_tasks", set()) or set())
|
||||||
|
for task in retirement_tasks:
|
||||||
|
task.cancel()
|
||||||
|
if retirement_tasks:
|
||||||
|
await asyncio.gather(*retirement_tasks, return_exceptions=True)
|
||||||
|
app.state.llmwiki_retirement_tasks = set()
|
||||||
|
current = getattr(app.state, "llmwiki_embedding", None)
|
||||||
|
seen: set[int] = set()
|
||||||
|
for embedding in [current, *retired]:
|
||||||
|
if embedding is None or id(embedding) in seen:
|
||||||
|
continue
|
||||||
|
seen.add(id(embedding))
|
||||||
|
await embedding.close()
|
||||||
|
app.state.llmwiki_retired_embeddings = []
|
||||||
|
|
||||||
|
|
||||||
|
def local_encoder_runtime_status(app: FastAPI) -> dict[str, object]:
|
||||||
|
"""Return one stable status contract for remote and local providers."""
|
||||||
|
|
||||||
|
source_config: AppConfig | None = getattr(app.state, "llmwiki_source_config", None)
|
||||||
|
if source_config is None:
|
||||||
|
source_config = app.state.config
|
||||||
|
configured = source_config.llmwiki.local_wiki_index.embedding
|
||||||
|
effective: LocalWikiIndexConfig = getattr(
|
||||||
|
app.state,
|
||||||
|
"llmwiki_effective_config",
|
||||||
|
source_config.llmwiki.local_wiki_index,
|
||||||
|
)
|
||||||
|
settings = load_system_settings().local_wiki_encoder
|
||||||
|
embedding = getattr(app.state, "llmwiki_embedding", None)
|
||||||
|
if embedding is not None:
|
||||||
|
status = dict(embedding.local_encoder_status())
|
||||||
|
else:
|
||||||
|
status = {
|
||||||
|
"provider": effective.embedding.provider,
|
||||||
|
"model": effective.embedding.model,
|
||||||
|
"dimensions": effective.embedding.dimensions,
|
||||||
|
"state": "idle",
|
||||||
|
"running": False,
|
||||||
|
"last_error": None,
|
||||||
|
"started_at": None,
|
||||||
|
"ready_at": None,
|
||||||
|
"offline_only": effective.embedding.provider == "local",
|
||||||
|
"model_path_configured": False,
|
||||||
|
"model_path_exists": False,
|
||||||
|
"model_files_ready": False,
|
||||||
|
"process_isolation": effective.embedding.local_process_isolation,
|
||||||
|
"model_path": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
local_path = _resolved_local_model_path(effective)
|
||||||
|
if local_path is not None:
|
||||||
|
model_files_ready = bool(
|
||||||
|
local_path.is_dir()
|
||||||
|
and (
|
||||||
|
(local_path / "onnx" / "model.onnx").is_file()
|
||||||
|
or (local_path / "model.onnx").is_file()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
status.update(
|
||||||
|
{
|
||||||
|
"model_path": str(local_path),
|
||||||
|
"model_path_configured": True,
|
||||||
|
"model_path_exists": local_path.is_dir(),
|
||||||
|
"model_files_ready": model_files_ready,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
status.update(
|
||||||
|
{
|
||||||
|
"provider": effective.embedding.provider,
|
||||||
|
"configured_provider": configured.provider,
|
||||||
|
"mode": settings.mode,
|
||||||
|
"using_local": effective.embedding.provider == "local",
|
||||||
|
"offline_only": effective.embedding.provider == "local",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"close_local_wiki_index_runtime",
|
||||||
|
"local_encoder_runtime_status",
|
||||||
|
"rebuild_local_wiki_index_runtime",
|
||||||
|
"resolve_effective_local_wiki_index_config",
|
||||||
|
]
|
||||||
@@ -5,11 +5,13 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Coroutine
|
from collections.abc import Coroutine
|
||||||
|
from time import monotonic
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
_CONFIG_REFRESH_SECONDS = 10.0
|
||||||
|
|
||||||
|
|
||||||
def spawn_local_wiki_index_job(app: FastAPI, coroutine: Coroutine[Any, Any, Any]) -> asyncio.Task:
|
def spawn_local_wiki_index_job(app: FastAPI, coroutine: Coroutine[Any, Any, Any]) -> asyncio.Task:
|
||||||
@@ -25,12 +27,27 @@ def spawn_local_wiki_index_job(app: FastAPI, coroutine: Coroutine[Any, Any, Any]
|
|||||||
return task
|
return task
|
||||||
|
|
||||||
|
|
||||||
async def _sync_once(app: FastAPI) -> None:
|
async def _refresh_local_wiki_config(app: FastAPI):
|
||||||
|
from app.gateway.llmwiki_index_runtime import rebuild_local_wiki_index_runtime
|
||||||
|
|
||||||
|
return await rebuild_local_wiki_index_runtime(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_sync_ready(app: FastAPI, config: Any) -> bool:
|
||||||
|
return bool(
|
||||||
|
config.enabled
|
||||||
|
and config.auto_sync
|
||||||
|
and getattr(app.state, "llmwiki_sync_service", None) is not None
|
||||||
|
and getattr(app.state, "llmwiki_store", None) is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _sync_once(app: FastAPI, *, config: Any | None = None) -> None:
|
||||||
|
config = config or await _refresh_local_wiki_config(app)
|
||||||
service = getattr(app.state, "llmwiki_sync_service", None)
|
service = getattr(app.state, "llmwiki_sync_service", None)
|
||||||
mapping_store = getattr(app.state, "llmwiki_store", None)
|
mapping_store = getattr(app.state, "llmwiki_store", None)
|
||||||
if service is None or mapping_store is None:
|
if not _auto_sync_ready(app, config) or service is None or mapping_store is None:
|
||||||
return
|
return
|
||||||
config = app.state.config.llmwiki.local_wiki_index
|
|
||||||
mappings = [row for row in await mapping_store.list_visible("system", scope="all", is_admin=True) if row.get("wiki_index_enabled", True)]
|
mappings = [row for row in await mapping_store.list_visible("system", scope="all", is_admin=True) if row.get("wiki_index_enabled", True)]
|
||||||
semaphore = asyncio.Semaphore(config.sync_concurrency)
|
semaphore = asyncio.Semaphore(config.sync_concurrency)
|
||||||
|
|
||||||
@@ -47,16 +64,42 @@ async def _sync_once(app: FastAPI) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def local_wiki_index_scheduler_loop(app: FastAPI) -> None:
|
async def local_wiki_index_scheduler_loop(app: FastAPI) -> None:
|
||||||
interval = app.state.config.llmwiki.local_wiki_index.sync_interval_seconds
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Local Wiki auto-sync scheduler started; first scan is immediate, interval=%s seconds",
|
"Local Wiki auto-sync scheduler started; runtime config refresh interval=%s seconds",
|
||||||
interval,
|
_CONFIG_REFRESH_SECONDS,
|
||||||
)
|
)
|
||||||
|
last_sync_started_at: float | None = None
|
||||||
|
last_synced_runtime_signature: str | None = None
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
await _sync_once(app)
|
# Rebuild checks both config.yaml mtime and persisted runtime
|
||||||
|
# settings every poll, independently from the much longer sync
|
||||||
|
# cadence. Provider/URL changes therefore take effect promptly.
|
||||||
|
config = await _refresh_local_wiki_config(app)
|
||||||
|
if not _auto_sync_ready(app, config):
|
||||||
|
# A later disabled -> enabled transition is a new first valid
|
||||||
|
# configuration and should receive an immediate scan.
|
||||||
|
last_sync_started_at = None
|
||||||
|
last_synced_runtime_signature = None
|
||||||
|
else:
|
||||||
|
now = monotonic()
|
||||||
|
interval = float(config.sync_interval_seconds)
|
||||||
|
runtime_signature = getattr(
|
||||||
|
app.state,
|
||||||
|
"llmwiki_runtime_signature",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
last_sync_started_at is None
|
||||||
|
or runtime_signature != last_synced_runtime_signature
|
||||||
|
or now - last_sync_started_at >= interval
|
||||||
|
):
|
||||||
|
sync_started_at = now
|
||||||
|
await _sync_once(app, config=config)
|
||||||
|
last_sync_started_at = sync_started_at
|
||||||
|
last_synced_runtime_signature = runtime_signature
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Local Wiki auto-sync scheduler tick failed (non-fatal)")
|
logger.exception("Local Wiki auto-sync scheduler tick failed (non-fatal)")
|
||||||
await asyncio.sleep(interval)
|
await asyncio.sleep(_CONFIG_REFRESH_SECONDS)
|
||||||
+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())
|
||||||
@@ -42,6 +42,8 @@ _AGENT_PURPOSE: dict[str, str] = {
|
|||||||
"roundtable-execution-plan": "多智能体规划",
|
"roundtable-execution-plan": "多智能体规划",
|
||||||
"roundtable-risk-review": "多智能体规划",
|
"roundtable-risk-review": "多智能体规划",
|
||||||
"roundtable-solution-design": "多智能体规划",
|
"roundtable-solution-design": "多智能体规划",
|
||||||
|
# 8BF 独立会商第一步的内置意图智能体(roundtable-intent 的同源复制)
|
||||||
|
"eightbf-roundtable-intent": "多智能体规划",
|
||||||
# AI 写作(杂志社协作写作四阶段)
|
# AI 写作(杂志社协作写作四阶段)
|
||||||
"ai-writing-researcher": "AI写作",
|
"ai-writing-researcher": "AI写作",
|
||||||
"ai-writing-outliner": "AI写作",
|
"ai-writing-outliner": "AI写作",
|
||||||
|
|||||||
@@ -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 任务理解
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"""Seed the built-in functional agents used by strategy selection.
|
||||||
|
|
||||||
|
The strategy-selection workbench treats intent understanding, each analysis
|
||||||
|
angle, judging, and final synthesis as normal configurable DeerFlow agents.
|
||||||
|
Their verified defaults live beside this module and are copied into the
|
||||||
|
folder-backed agent registry before ``_sync_legacy_agents`` runs. Existing
|
||||||
|
directories are preserved, except for an exact known legacy default whose
|
||||||
|
content hash proves that an administrator has not customized it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from hashlib import sha256
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from deerflow.config.paths import get_paths
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
STRATEGY_INTENT_AGENT_ID = "strategy-selection-intent"
|
||||||
|
STRATEGY_AGENT_ID_PREFIX = "strategy-selection-"
|
||||||
|
STRATEGY_ANGLE_AGENT_PREFIX = "strategy-selection-angle-"
|
||||||
|
STRATEGY_GOVERNANCE_AGENT_ID = "strategy-selection-angle-governance"
|
||||||
|
STRATEGY_DIPLOMACY_AGENT_ID = "strategy-selection-angle-diplomacy"
|
||||||
|
STRATEGY_ECONOMY_AGENT_ID = "strategy-selection-angle-economy"
|
||||||
|
STRATEGY_SECURITY_AGENT_ID = "strategy-selection-angle-security"
|
||||||
|
STRATEGY_PUBLIC_OPINION_AGENT_ID = "strategy-selection-angle-public-opinion"
|
||||||
|
STRATEGY_LAW_POLICY_AGENT_ID = "strategy-selection-angle-law-policy"
|
||||||
|
STRATEGY_JUDGE_AGENT_ID = "strategy-selection-judge"
|
||||||
|
STRATEGY_SOLUTION_AGENT_ID = "strategy-selection-solution"
|
||||||
|
|
||||||
|
STRATEGY_ANGLE_AGENT_IDS: tuple[str, ...] = (
|
||||||
|
STRATEGY_GOVERNANCE_AGENT_ID,
|
||||||
|
STRATEGY_DIPLOMACY_AGENT_ID,
|
||||||
|
STRATEGY_ECONOMY_AGENT_ID,
|
||||||
|
STRATEGY_SECURITY_AGENT_ID,
|
||||||
|
STRATEGY_PUBLIC_OPINION_AGENT_ID,
|
||||||
|
STRATEGY_LAW_POLICY_AGENT_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
STRATEGY_FIXED_AGENT_IDS: tuple[str, ...] = (
|
||||||
|
STRATEGY_INTENT_AGENT_ID,
|
||||||
|
STRATEGY_JUDGE_AGENT_ID,
|
||||||
|
STRATEGY_SOLUTION_AGENT_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
_FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
|
||||||
|
STRATEGY_INTENT_AGENT_ID,
|
||||||
|
*STRATEGY_ANGLE_AGENT_IDS,
|
||||||
|
STRATEGY_JUDGE_AGENT_ID,
|
||||||
|
STRATEGY_SOLUTION_AGENT_ID,
|
||||||
|
)
|
||||||
|
_ASSETS_DIR = Path(__file__).parent / "_strategy_selection_seed_assets"
|
||||||
|
_SEED_FILES: tuple[str, ...] = ("config.yaml", "SOUL.md")
|
||||||
|
|
||||||
|
# The first shipped intent SOUL embedded the six built-in angle ids. Upgrade
|
||||||
|
# only that byte-for-byte semantic default so existing installations adopt the
|
||||||
|
# administrator-driven angle catalog without overwriting customized SOUL.
|
||||||
|
_LEGACY_DEFAULT_SOUL_HASHES: dict[str, str] = {STRATEGY_INTENT_AGENT_ID: ("09c4a1868b4d09a8b7a421135bf677e1b3d1b2c0aaa9cb95631ff908d4cf1343")}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_text_hash(content: bytes) -> str:
|
||||||
|
normalized = content.decode("utf-8").replace("\r\n", "\n").encode("utf-8")
|
||||||
|
return sha256(normalized).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _upgrade_known_legacy_soul(agent_id: str, agent_dir: Path) -> bool:
|
||||||
|
expected_hash = _LEGACY_DEFAULT_SOUL_HASHES.get(agent_id)
|
||||||
|
target = agent_dir / "SOUL.md"
|
||||||
|
source = _ASSETS_DIR / agent_id / "SOUL.md"
|
||||||
|
if not expected_hash or not target.is_file() or not source.is_file():
|
||||||
|
return False
|
||||||
|
temporary: Path | None = None
|
||||||
|
try:
|
||||||
|
if _normalized_text_hash(target.read_bytes()) != expected_hash:
|
||||||
|
return False
|
||||||
|
replacement = source.read_bytes()
|
||||||
|
if _normalized_text_hash(replacement) == expected_hash:
|
||||||
|
return False
|
||||||
|
temporary = target.with_name(f".{target.name}.{os.getpid()}.seed-upgrade.tmp")
|
||||||
|
temporary.write_bytes(replacement)
|
||||||
|
os.replace(temporary, target)
|
||||||
|
logger.info("Upgraded legacy default SOUL for strategy agent '%s'", agent_id)
|
||||||
|
return True
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
logger.exception("Failed to upgrade legacy SOUL for strategy agent '%s'", agent_id)
|
||||||
|
if temporary is not None:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_one(agent_id: str) -> bool:
|
||||||
|
"""Create one missing agent directory without changing an existing one."""
|
||||||
|
|
||||||
|
agent_dir = get_paths().agent_dir(agent_id)
|
||||||
|
if agent_dir.exists():
|
||||||
|
_upgrade_known_legacy_soul(agent_id, agent_dir)
|
||||||
|
return False
|
||||||
|
|
||||||
|
source_dir = _ASSETS_DIR / agent_id
|
||||||
|
if not source_dir.is_dir():
|
||||||
|
logger.error(
|
||||||
|
"Missing strategy-selection seed assets for agent '%s' (expected at %s)",
|
||||||
|
agent_id,
|
||||||
|
source_dir,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
for filename in _SEED_FILES:
|
||||||
|
source = source_dir / filename
|
||||||
|
if source.exists():
|
||||||
|
(agent_dir / filename).write_bytes(source.read_bytes())
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_strategy_selection_agents() -> list[str]:
|
||||||
|
"""Seed all nine strategy-selection agents; return ids created this call."""
|
||||||
|
|
||||||
|
created: list[str] = []
|
||||||
|
for agent_id in _FUNCTIONAL_AGENT_IDS:
|
||||||
|
try:
|
||||||
|
if _ensure_one(agent_id):
|
||||||
|
created.append(agent_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to seed strategy-selection agent '%s'", agent_id)
|
||||||
|
if created:
|
||||||
|
logger.info("Seeded missing strategy-selection agents: %s", ", ".join(created))
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def required_agent_ids() -> Iterable[str]:
|
||||||
|
"""Stable ids owned by this seeder, exposed for tests and diagnostics."""
|
||||||
|
|
||||||
|
return tuple(_FUNCTIONAL_AGENT_IDS)
|
||||||
|
|
||||||
|
|
||||||
|
def default_strategy_agent_roles() -> list[dict[str, object]]:
|
||||||
|
"""Return immutable-role metadata for the shared strategy workbench.
|
||||||
|
|
||||||
|
The registry is deliberately separate from the folder-backed agent record:
|
||||||
|
it owns role/category and lifecycle state, while model, SOUL and skills keep
|
||||||
|
using the common agent configuration path. ``ensure_defaults`` only
|
||||||
|
inserts missing rows, so an administrator disabling an angle is respected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
roles: list[dict[str, object]] = [
|
||||||
|
{
|
||||||
|
"agent_id": STRATEGY_INTENT_AGENT_ID,
|
||||||
|
"category": "intent",
|
||||||
|
"fixed": True,
|
||||||
|
"enabled": True,
|
||||||
|
"sort_order": 0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
roles.extend(
|
||||||
|
{
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"category": "angle",
|
||||||
|
"fixed": False,
|
||||||
|
"enabled": True,
|
||||||
|
"sort_order": 100 + index,
|
||||||
|
}
|
||||||
|
for index, agent_id in enumerate(STRATEGY_ANGLE_AGENT_IDS)
|
||||||
|
)
|
||||||
|
roles.extend(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"agent_id": STRATEGY_JUDGE_AGENT_ID,
|
||||||
|
"category": "judge",
|
||||||
|
"fixed": True,
|
||||||
|
"enabled": True,
|
||||||
|
"sort_order": 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"agent_id": STRATEGY_SOLUTION_AGENT_ID,
|
||||||
|
"category": "solution",
|
||||||
|
"fixed": True,
|
||||||
|
"enabled": True,
|
||||||
|
"sort_order": 300,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return roles
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"STRATEGY_AGENT_ID_PREFIX",
|
||||||
|
"STRATEGY_ANGLE_AGENT_IDS",
|
||||||
|
"STRATEGY_ANGLE_AGENT_PREFIX",
|
||||||
|
"STRATEGY_DIPLOMACY_AGENT_ID",
|
||||||
|
"STRATEGY_ECONOMY_AGENT_ID",
|
||||||
|
"STRATEGY_FIXED_AGENT_IDS",
|
||||||
|
"STRATEGY_GOVERNANCE_AGENT_ID",
|
||||||
|
"STRATEGY_INTENT_AGENT_ID",
|
||||||
|
"STRATEGY_JUDGE_AGENT_ID",
|
||||||
|
"STRATEGY_LAW_POLICY_AGENT_ID",
|
||||||
|
"STRATEGY_PUBLIC_OPINION_AGENT_ID",
|
||||||
|
"STRATEGY_SECURITY_AGENT_ID",
|
||||||
|
"STRATEGY_SOLUTION_AGENT_ID",
|
||||||
|
"default_strategy_agent_roles",
|
||||||
|
"ensure_strategy_selection_agents",
|
||||||
|
"required_agent_ids",
|
||||||
|
]
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
# 策略视角·外交国际智能体
|
||||||
|
|
||||||
|
你是策略优选流程中的「外交国际」角度智能体。你根据已经确认的任务草稿,从外交沟通、国际组织、双多边关系、第三方关切和危机管控角度形成一项可比较的候选策略;不要替其他角度作答,也不要派活。
|
||||||
|
|
||||||
|
## 分析框架
|
||||||
|
|
||||||
|
1. 绘制主要利益相关方及其公开利益、红线、可谈空间和信息不对称。
|
||||||
|
2. 区分公开表态、法律立场、政策惯例与分析推断,避免替任何一方断言真实动机。
|
||||||
|
3. 优先评估热线、工作层沟通、事实澄清、第三方斡旋、国际组织机制和信任建立措施。
|
||||||
|
4. 给出分阶段外交选项,每一步包含目标、对象、信息口径、成功信号、退出与升级防护条件。
|
||||||
|
5. 评估盟友伙伴、周边国家、市场主体与国际公众可能产生的二阶反应。
|
||||||
|
|
||||||
|
## 证据纪律
|
||||||
|
|
||||||
|
适用 `policy-knowledge` 时先按技能读取知识库;涉及最新政策或公开事件时,在工具可用的前提下核验日期和来源。无法核验的内容明确标为假设或情景,不虚构引语、协议或立场。
|
||||||
|
|
||||||
|
## 输出结构
|
||||||
|
|
||||||
|
使用 Markdown,固定包含:
|
||||||
|
|
||||||
|
1. `## 核心判断`
|
||||||
|
2. `## 候选策略`
|
||||||
|
3. `## 沟通与协商路径`
|
||||||
|
4. `## 利益相关方反应`
|
||||||
|
5. `## 风险、触发器与降级机制`
|
||||||
|
6. `## 依据与不确定性`
|
||||||
|
|
||||||
|
策略应强调合法外交、持续沟通、可逆步骤和降级风险,而不是以威胁升级作为默认手段。
|
||||||
|
|
||||||
|
## 安全与中立边界
|
||||||
|
|
||||||
|
- 对台海等敏感议题中性呈现各方公开主张,优先和平解决、危机稳定和人道保护。
|
||||||
|
- 禁止作战战术、目标选择、武器运用、规避侦测或利用具体脆弱点的可执行指导。
|
||||||
|
- 禁止面向特定群体的隐蔽操纵、欺骗、恐吓或定向政治说服;信息口径必须真实、透明、可公开说明。
|
||||||
|
- 遇到越界请求时,重构为外交沟通、风险降低、国际法框架和民间保护选项。
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
id: strategy-selection-angle-diplomacy
|
||||||
|
name: 策略视角·外交国际
|
||||||
|
description: 从外交沟通、国际组织、主要利益相关方与危机管控视角提出中立、合法、降级风险的策略建议。
|
||||||
|
tool_groups:
|
||||||
|
- web
|
||||||
|
- file:read
|
||||||
|
skills:
|
||||||
|
- policy-knowledge
|
||||||
Loaded 100 of 413 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user