Compare commits
777
Commits
98393b4908
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0317873a5b | ||
|
|
eec5978fa7 | ||
|
|
25ec9cf69a | ||
|
|
302c4bcf52 | ||
|
|
05d0f89c16 | ||
|
|
a273a86a7d | ||
|
|
45f0560f85 | ||
|
|
f74d51b905 | ||
|
|
e08b0f4311 | ||
|
|
3bb662f092 | ||
|
|
a644e93604 | ||
|
|
795935bf6a | ||
|
|
7548896f6d | ||
|
|
5faaa88302 | ||
|
|
8c4aa5dc5b | ||
|
|
9142e30714 | ||
|
|
9e26d6b19c | ||
|
|
cb23061088 | ||
|
|
6f5c6b7532 | ||
|
|
c7d0ed939c | ||
|
|
fda991cbe1 | ||
|
|
f9d3b4c76b | ||
|
|
3a1b8ccda9 | ||
|
|
10cd9d2465 | ||
|
|
96bab8cd49 | ||
|
|
423c1df398 | ||
|
|
c2809bd927 | ||
|
|
b4b18e351b | ||
|
|
461354d7f4 | ||
|
|
3dcb3c6fea | ||
|
|
f894196924 | ||
|
|
78dd079344 | ||
|
|
cd77c1d6c7 | ||
|
|
3818a63522 | ||
|
|
31510d3555 | ||
|
|
18e50c5af7 | ||
|
|
8aefe3254c | ||
|
|
39e0eac39d | ||
|
|
8df4e33656 | ||
|
|
b37279f3a2 | ||
|
|
9c68f98d89 | ||
|
|
2a3a6cd25b | ||
|
|
9d83a06300 | ||
|
|
bcb0b82f33 | ||
|
|
e61fcdea79 | ||
|
|
73dec866a4 | ||
|
|
bdd723f62c | ||
|
|
da036db1f2 | ||
|
|
58834c9443 | ||
|
|
a95e205eb6 | ||
|
|
d918237327 | ||
|
|
1f5e6cce90 | ||
|
|
c71d0c4657 | ||
|
|
f4ca01f2f2 | ||
|
|
d7e626a813 | ||
|
|
b283622c64 | ||
|
|
40c4a21a7e | ||
|
|
a8c92e9619 | ||
|
|
c70faa7bb8 | ||
|
|
46521124de | ||
|
|
0aac14ed9a | ||
|
|
6bfcf01641 | ||
|
|
2441101978 | ||
|
|
e60bd38444 | ||
|
|
a2c69be4a2 | ||
|
|
c2382325cb | ||
|
|
76ace953be | ||
|
|
8c06076873 | ||
|
|
e36b55a2f6 | ||
|
|
19ecadf3f6 | ||
|
|
ece7cd3fa8 | ||
|
|
1ce1360a0b | ||
|
|
9520c198a2 | ||
|
|
9ff26f0c8f | ||
|
|
438d47bc9f | ||
|
|
7c252cff96 | ||
|
|
40392ebded | ||
|
|
d2f586fc52 | ||
|
|
71e548aafc | ||
|
|
0dfb50be9b | ||
|
|
04db8c0d3b | ||
|
|
70d6e98f82 | ||
|
|
03dc27bc14 | ||
|
|
28e6acbf3f | ||
|
|
9e81182b4f | ||
|
|
0f3c3ce1aa | ||
|
|
cb260aba4b | ||
|
|
8233ba095e | ||
|
|
d5766c2142 | ||
|
|
2eea0118db | ||
|
|
24d6803ecc | ||
|
|
63c5c3d4f0 | ||
|
|
7c76ee6a60 | ||
|
|
d4d3a8deaf | ||
|
|
8e1e56b3e5 | ||
|
|
26cc3c3df9 | ||
|
|
753de25696 | ||
|
|
6a50193b34 | ||
|
|
c83ea0c315 | ||
|
|
c10f0e46f0 | ||
|
|
83e86cd6b7 | ||
|
|
c6ebf15407 | ||
|
|
f4d31e884a | ||
|
|
c8c13d302a | ||
|
|
7dea2b86c4 | ||
|
|
e492f03191 | ||
|
|
91773da602 | ||
|
|
b994cf2c54 | ||
|
|
08e9d0292d | ||
|
|
d9e0ce73d1 | ||
|
|
7aa0da32f2 | ||
|
|
01d231c39d | ||
|
|
b2d44f85e9 | ||
|
|
8396849d04 | ||
|
|
f67e65633b | ||
|
|
890ceef0ec | ||
|
|
8e09112d59 | ||
|
|
3e3c6f4d0f | ||
|
|
31f7a9570b | ||
|
|
8710a09be2 | ||
|
|
ed678262b6 | ||
|
|
0b57126ac5 | ||
|
|
2d43f90e50 | ||
|
|
b4fafa0e9a | ||
|
|
d406a31722 | ||
|
|
c1e5b96d75 | ||
|
|
499aa49e49 | ||
|
|
3f4c914dbf | ||
|
|
db8c240d95 | ||
|
|
79ca55620b | ||
|
|
ea20a465d6 | ||
|
|
74cad6442b | ||
|
|
dd0afcf0d1 | ||
|
|
26d9a1af1e | ||
|
|
1e6db6f310 | ||
|
|
bf0db10ef7 | ||
|
|
7dcbdb1feb | ||
|
|
4417b3ddd3 | ||
|
|
b8416dea63 | ||
|
|
423acfdd00 | ||
|
|
2187ffe173 | ||
|
|
524120b110 | ||
|
|
e1dd0bc3a3 | ||
|
|
66c6553cdf | ||
|
|
0560137840 | ||
|
|
6ef15a0940 | ||
|
|
75c5e23523 | ||
|
|
3861e13b32 | ||
|
|
e3a8a8cffb | ||
|
|
57d9fd8ad3 | ||
|
|
04ce127a96 | ||
|
|
bff5ae9d7a | ||
|
|
7b5a2c83b0 | ||
|
|
4b445e96b8 | ||
|
|
a06786fd8e | ||
|
|
bacec20c3c | ||
|
|
7c62d8dbe7 | ||
|
|
cfccba863c | ||
|
|
73fe49b70c | ||
|
|
47a75a4d49 | ||
|
|
378047f1ff | ||
|
|
756d2840df | ||
|
|
4efadf4952 | ||
|
|
ab05be7e4c | ||
|
|
3f3f5053a4 | ||
|
|
c978d9cb60 | ||
|
|
efdf1e0282 | ||
|
|
df3ed55d01 | ||
|
|
04acc4297d | ||
|
|
e7cd035c62 | ||
|
|
9b89610e38 | ||
|
|
3e239a108f | ||
|
|
63779e8d69 | ||
|
|
bd2e94b54f | ||
|
|
861aad56f4 | ||
|
|
0ca6250ee1 | ||
|
|
671020b511 | ||
|
|
71811807d5 | ||
|
|
9962e17710 | ||
|
|
8a1675b58c | ||
|
|
0168617503 | ||
|
|
864a03d2e1 | ||
|
|
1d1111ebe7 | ||
|
|
378c363632 | ||
|
|
7cb32d1380 | ||
|
|
dfa38f6259 | ||
|
|
f57e25a277 | ||
|
|
4840f7ebc6 | ||
|
|
9753a6f902 | ||
|
|
13ecd7eba4 | ||
|
|
871416637a | ||
|
|
5ac815b9ca | ||
|
|
5a4c821629 | ||
|
|
4ad78abad6 | ||
|
|
7a1e80eac2 | ||
|
|
d31a27512c | ||
|
|
9407f821b9 | ||
|
|
256af4e736 | ||
|
|
5a1b7a871a | ||
|
|
6141a67b0e | ||
|
|
dedb9a8862 | ||
|
|
2473dcfe68 | ||
|
|
40224d603a | ||
|
|
cf00345c35 | ||
|
|
6bafdcd5a5 | ||
|
|
11f4978ec1 | ||
|
|
fbd8fae44b | ||
|
|
8bc5380ecb | ||
|
|
7eb2e25ed5 | ||
|
|
4df91ed155 | ||
|
|
4a9f77872e | ||
|
|
5a92e6056c | ||
|
|
e099eff4cb | ||
|
|
37040616bd | ||
|
|
cad76ffdf2 | ||
|
|
180d8e339f | ||
|
|
e7228231d1 | ||
|
|
9eb2995a74 | ||
|
|
6ba6daf188 | ||
|
|
65c0f6b2b6 | ||
|
|
4092a8d5bb | ||
|
|
97525a3143 | ||
|
|
fe780c0084 | ||
|
|
02c8d8e5ea | ||
|
|
6aa334fc9b | ||
|
|
3dfa043bb6 | ||
|
|
60cc163d62 | ||
|
|
9f75db5b15 | ||
|
|
795e05f42d | ||
|
|
92903702e2 | ||
|
|
b408b2d7a1 | ||
|
|
d19784a414 | ||
|
|
4ce6a859e7 | ||
|
|
c1f2fe16c8 | ||
|
|
75d5a7091a | ||
|
|
e68d283651 | ||
|
|
28de5d6143 | ||
|
|
71fdaac1b4 | ||
|
|
a91e950874 | ||
|
|
c480627ba6 | ||
|
|
5667da6cf4 | ||
|
|
ec9f0bf1ea | ||
|
|
8e4c7c68cf | ||
|
|
a031521776 | ||
|
|
864c696b70 | ||
|
|
f736f435bc | ||
|
|
caaabf0c15 | ||
|
|
ffb202e2e9 | ||
|
|
d1321af880 | ||
|
|
9e85cadb3f | ||
|
|
7f0edced01 | ||
|
|
a28c00b1e5 | ||
|
|
b7111fcabc | ||
|
|
8eb689af37 | ||
|
|
3f2cf5fa3a | ||
|
|
ea309c607b | ||
|
|
f1773dde4f | ||
|
|
ef77120912 | ||
|
|
f719546b12 | ||
|
|
fc249adf17 | ||
|
|
dffc4e4851 | ||
|
|
953a3fbba1 | ||
|
|
50c232643d | ||
|
|
b999f79fdf | ||
|
|
e4f7b432aa | ||
|
|
023377ab24 | ||
|
|
57d6a2d066 | ||
|
|
c76e77bd3c | ||
|
|
31aba79512 | ||
|
|
3afaff9efe | ||
|
|
d664bdc309 | ||
|
|
0e0c6862ba | ||
|
|
1a5d543fa1 | ||
|
|
2b08e32c26 | ||
|
|
7193901bc4 | ||
|
|
8c0ff80cd4 | ||
|
|
a69057a1ef | ||
|
|
dbd226b403 | ||
|
|
1ddf48d40f | ||
|
|
7d33442199 | ||
|
|
db99e7107c | ||
|
|
ba578dfe6c | ||
|
|
6b68aa63e8 | ||
|
|
c27fd75d20 | ||
|
|
b68dc363d4 | ||
|
|
1920bcbd38 | ||
|
|
c002f0b352 | ||
|
|
438f9692f2 | ||
|
|
c34ad062e7 | ||
|
|
484080ac12 | ||
|
|
143b859727 | ||
|
|
d7e2845881 | ||
|
|
20ec571dbb | ||
|
|
c4ba920cf5 | ||
|
|
468950616b | ||
|
|
bd9031d35d | ||
|
|
365af554da | ||
|
|
0b68c5bed9 | ||
|
|
7594a5b7f3 | ||
|
|
c8f35a7211 | ||
|
|
e222d38e7c | ||
|
|
1eb2ca7dc5 | ||
|
|
3943d68430 | ||
|
|
faa61a9ba5 | ||
|
|
b1d7deece1 | ||
|
|
e9e3578d26 | ||
|
|
42efb31bbf | ||
|
|
0dfaeb0f5b | ||
|
|
aa0701a2ea | ||
|
|
89cfbc7499 | ||
|
|
3d8b755229 | ||
|
|
249b3b9ea8 | ||
|
|
eee0f06e1a | ||
|
|
46246880b3 | ||
|
|
b18740405c | ||
|
|
d99a65f034 | ||
|
|
8fa3a93eec | ||
|
|
3f22fd3673 | ||
|
|
34944df764 | ||
|
|
c3b47b5771 | ||
|
|
acf0ed091d | ||
|
|
54ffc868b2 | ||
|
|
d5b0459a8a | ||
|
|
f1fb8655c3 | ||
|
|
d10d7ff330 | ||
|
|
61d3e9c148 | ||
|
|
840949ad05 | ||
|
|
3e93a2cd70 | ||
|
|
6e44878021 | ||
|
|
27b4268acb | ||
|
|
f40287bb00 | ||
|
|
1d580dccc3 | ||
|
|
9947e6c1fe | ||
|
|
3c9077b043 | ||
|
|
4483358f6b | ||
|
|
ae6d3d0043 | ||
|
|
f4fc0feff5 | ||
|
|
6833792de2 | ||
|
|
af2e085bea | ||
|
|
583058acd6 | ||
|
|
f04d47b09d | ||
|
|
fa410e6843 | ||
|
|
a9ee9b1f74 | ||
|
|
0a65ea2c5a | ||
|
|
91d80841ea | ||
|
|
c89742cb9a | ||
|
|
2af24f0db8 | ||
|
|
535525c2f6 | ||
|
|
3abdfb489f | ||
|
|
2c2b2d710e | ||
|
|
6771d396f0 | ||
|
|
0338210ba2 | ||
|
|
36ea090d9b | ||
|
|
89912155a6 | ||
|
|
dd0e059365 | ||
|
|
bce2fea4e5 | ||
|
|
3758bea0b5 | ||
|
|
3c2fa91fc6 | ||
|
|
78ef5dcc44 | ||
|
|
976ce29ad9 | ||
|
|
4dac9fe5f1 | ||
|
|
76aec62d36 | ||
|
|
27eb0a9f6b | ||
|
|
948d6e644b | ||
|
|
aa4d5213eb | ||
|
|
2780f6affa | ||
|
|
773ebb6ef6 | ||
|
|
15eaa0c637 | ||
|
|
f5f101d88a | ||
|
|
b19b0f3679 | ||
|
|
abc6880936 | ||
|
|
6b131f3dc1 | ||
|
|
78bd8d4344 | ||
|
|
093fe172f8 | ||
|
|
ff3f153d45 | ||
|
|
dfed588b54 | ||
|
|
63936e3016 | ||
|
|
7a2a2e13a2 | ||
|
|
49c6f3360a | ||
|
|
9b6353c561 | ||
|
|
0cfea6da22 | ||
|
|
7b5b62d3ae | ||
|
|
50aad375eb | ||
|
|
b72df78462 | ||
|
|
3c18dea45b | ||
|
|
8d18918e39 | ||
|
|
cb7e1fce82 | ||
|
|
cc3ef5bc1d | ||
|
|
996f1d9e5f | ||
|
|
696e34407c | ||
|
|
19b647e650 | ||
|
|
10629f88e8 | ||
|
|
b5e159f190 | ||
|
|
55542abf41 | ||
|
|
3fac0a618d | ||
|
|
9839935782 | ||
|
|
4a396f4f88 | ||
|
|
7adaf97377 | ||
|
|
6ffcba7e4d | ||
|
|
b157bc9077 | ||
|
|
f776336eb1 | ||
|
|
ad1821bc14 | ||
|
|
c4b02b5370 | ||
|
|
fee856129c | ||
|
|
ead490783f | ||
|
|
dcc3f0d230 | ||
|
|
d1b9488853 | ||
|
|
48c966f6f7 | ||
|
|
7e3fe1961a | ||
|
|
8723075360 | ||
|
|
53e1c1da77 | ||
|
|
e4ceb0015b | ||
|
|
22362a77b8 | ||
|
|
cf223fc08b | ||
|
|
b7b004dd68 | ||
|
|
b7b54eb2a6 | ||
|
|
6212002270 | ||
|
|
7cd833b1e5 | ||
|
|
5c539fe764 | ||
|
|
6e1485e4f9 | ||
|
|
9c3f27f4ca | ||
|
|
a6d692270f | ||
|
|
9189877a59 | ||
|
|
bb2cfcbb62 | ||
|
|
80239500f1 | ||
|
|
4b754fe230 | ||
|
|
e33e8f86d3 | ||
|
|
0c348f2311 | ||
|
|
5a893680b7 | ||
|
|
744b68da5b | ||
|
|
40b74e11bf | ||
|
|
df8ed7e74f | ||
|
|
549f198cea | ||
|
|
e7c9ed0805 | ||
|
|
6d979d6646 | ||
|
|
005079f762 | ||
|
|
308775cf4d | ||
|
|
8ce18cb482 | ||
|
|
c011f864fd | ||
|
|
fc705443bd | ||
|
|
d7e0d7ac55 | ||
|
|
c409a675cf | ||
|
|
4c54288a8d | ||
|
|
047b3b91fa | ||
|
|
24c3fa08f1 | ||
|
|
56a08fb5c1 | ||
|
|
953863af8e | ||
|
|
7d2998e04e | ||
|
|
93172a6844 | ||
|
|
8df7e248b4 | ||
|
|
ba811ead96 | ||
|
|
ebba9735ed | ||
|
|
a7dbd50a4d | ||
|
|
7f6aa1e782 | ||
|
|
40a97a7655 | ||
|
|
7c2e3b23c1 | ||
|
|
1ffa023f3d | ||
|
|
61bad4fa28 | ||
|
|
bdf3f96491 | ||
|
|
bb8b3c6c59 | ||
|
|
96e554ce22 | ||
|
|
0a8db6504a | ||
|
|
78b48d871b | ||
|
|
698d981962 | ||
|
|
0c7c5e6068 | ||
|
|
f4b65f6c2e | ||
|
|
59f07bf060 | ||
|
|
a9703c3a1f | ||
|
|
ed8e2fc155 | ||
|
|
612de4cd7c | ||
|
|
60b73377f6 | ||
|
|
030ddb668a | ||
|
|
0fb5af50c7 | ||
|
|
8ccdce282c | ||
|
|
5337c7d5e3 | ||
|
|
d38d1af68d | ||
|
|
057fd0994e | ||
|
|
da886fa1c8 | ||
|
|
e8468c5bc6 | ||
|
|
36a8391626 | ||
|
|
a5a4fc887a | ||
|
|
280baeaee0 | ||
|
|
25d65652cd | ||
|
|
2b8b30ef41 | ||
|
|
2fe1fe57c8 | ||
|
|
d1d3871a88 | ||
|
|
2a13f45650 | ||
|
|
83cee61476 | ||
|
|
82059bac4a | ||
|
|
cf7fe867ee | ||
|
|
5d1749a58f | ||
|
|
566bbcb394 | ||
|
|
eda2a5f887 | ||
|
|
c9b6e28433 | ||
|
|
72e57c6232 | ||
|
|
cdb227f999 | ||
|
|
d2cada97cd | ||
|
|
bd6a41fe6e | ||
|
|
330bb7f505 | ||
|
|
4b5da38e28 | ||
|
|
44ecdf825e | ||
|
|
126bee5b24 | ||
|
|
e2ece2d898 | ||
|
|
e5a89165e6 | ||
|
|
b22e9ae912 | ||
|
|
e4c2e310a5 | ||
|
|
7911bc292c | ||
|
|
de049704c6 | ||
|
|
4e3a11f925 | ||
|
|
4746288a70 | ||
|
|
5811991887 | ||
|
|
4f8f1cf794 | ||
|
|
5c395f4825 | ||
|
|
70de99ae77 | ||
|
|
26765a050a | ||
|
|
c61a222db8 | ||
|
|
ad25925af7 | ||
|
|
74f38609e0 | ||
|
|
ba47f44ab8 | ||
|
|
4a87c552cc | ||
|
|
e5a8fec1fe | ||
|
|
6a7a8cbaac | ||
|
|
7d5402951b | ||
|
|
19eb14ffc8 | ||
|
|
6034e58b43 | ||
|
|
2f8b13ff7c | ||
|
|
d3e7640b8d | ||
|
|
cea0c71546 | ||
|
|
ef465625ed | ||
|
|
353b07427f | ||
|
|
f217e39bfe | ||
|
|
e981c1492a | ||
|
|
44d1c6a00c | ||
|
|
db129fd78f | ||
|
|
8da6bdcec2 | ||
|
|
968c3df5ab | ||
|
|
ec1daa8844 | ||
|
|
c38cefe47b | ||
|
|
5feeaa6cb0 | ||
|
|
998a2f243d | ||
|
|
f1a06732fd | ||
|
|
b42cc66e7a | ||
|
|
b237f716dd | ||
|
|
874cad22db | ||
|
|
11b579eb87 | ||
|
|
cfae6027d2 | ||
|
|
7748e3ecbc | ||
|
|
64ece1d3a0 | ||
|
|
03f0effcf2 | ||
|
|
55209cfbf9 | ||
|
|
e32eca3572 | ||
|
|
f5fd580215 | ||
|
|
a2330de2a8 | ||
|
|
356c8f27f8 | ||
|
|
3794b3d333 | ||
|
|
a1ad93ab31 | ||
|
|
35c976793b | ||
|
|
3e97fff328 | ||
|
|
a5d125847f | ||
|
|
2d6eff66a7 | ||
|
|
4dee706631 | ||
|
|
c908a570e5 | ||
|
|
a81cab074a | ||
|
|
a0987bad7d | ||
|
|
f5f2358799 | ||
|
|
d6e45f7bc6 | ||
|
|
a3835e2acb | ||
|
|
f3a287bbd4 | ||
|
|
55bc1e1b23 | ||
|
|
ed35c51a47 | ||
|
|
e0ffd98223 | ||
|
|
f026880fa7 | ||
|
|
4eaff04ac1 | ||
|
|
6a75c11dfa | ||
|
|
96a05638b1 | ||
|
|
63fe0a6375 | ||
|
|
f1aa7ac33d | ||
|
|
6769100b57 | ||
|
|
86f6c9685b | ||
|
|
e6dbb14c5d | ||
|
|
d899c58682 | ||
|
|
2b40f65ef2 | ||
|
|
7c708a6e01 | ||
|
|
6ad4ec2848 | ||
|
|
5c15b72d3d | ||
|
|
d96041721a | ||
|
|
2a4d745c5b | ||
|
|
2c8764abee | ||
|
|
d817386418 | ||
|
|
0142654be3 | ||
|
|
1dc8b12ec4 | ||
|
|
6d2244c03b | ||
|
|
60b01d03ee | ||
|
|
a2871a66e0 | ||
|
|
0ca5d9805f | ||
|
|
02bee38738 | ||
|
|
8abcd567a6 | ||
|
|
36cde88b80 | ||
|
|
ecff4cc23d | ||
|
|
b4f738bada | ||
|
|
3055b06734 | ||
|
|
aa75454878 | ||
|
|
f6e4e824fa | ||
|
|
645f73d1a3 | ||
|
|
74003bc04f | ||
|
|
1cd7652a34 | ||
|
|
924158cff2 | ||
|
|
0633aa6614 | ||
|
|
6b67214395 | ||
|
|
2065335c8c | ||
|
|
ca9b3187b5 | ||
|
|
9d1e5c2737 | ||
|
|
a622bdca61 | ||
|
|
6b2dd8fb5d | ||
|
|
bac3c76fa2 | ||
|
|
cf829a1933 | ||
|
|
9c60c8fb78 | ||
|
|
4317d02e0f | ||
|
|
e1d396dbf2 | ||
|
|
e3cd44802a | ||
|
|
71718c1cd8 | ||
|
|
e38474b794 | ||
|
|
3f160464ea | ||
|
|
4be15912cd | ||
|
|
a19f535134 | ||
|
|
0289d8c37c | ||
|
|
3f90aad2d6 | ||
|
|
e16d038bb2 | ||
|
|
32878e9ceb | ||
|
|
2c8004df65 | ||
|
|
80c0955a1a | ||
|
|
b3684f4d1f | ||
|
|
247dc5da4a | ||
|
|
2d02cff7a6 | ||
|
|
83cd8ed256 | ||
|
|
79b6a43095 | ||
|
|
31ea151bb2 | ||
|
|
332a6bba1a | ||
|
|
b6fe405431 | ||
|
|
31e048faf7 | ||
|
|
e23b316faf | ||
|
|
f2a56b6982 | ||
|
|
6ec191e750 | ||
|
|
0202b5195e | ||
|
|
5ca178436f | ||
|
|
fb4b067728 | ||
|
|
de9809688e | ||
|
|
4194842561 | ||
|
|
7fd29e68c3 | ||
|
|
c191d122bd | ||
|
|
4c84fbc1b6 | ||
|
|
63bff8bf96 | ||
|
|
76d823ded0 | ||
|
|
8667e850e3 | ||
|
|
2446d1b876 | ||
|
|
2ddfea67c7 | ||
|
|
5b7f342e14 | ||
|
|
a26b86e003 | ||
|
|
8a142c212c | ||
|
|
afcdf5e17d | ||
|
|
313459b8e4 | ||
|
|
8dad15ecc1 | ||
|
|
44bc18294c | ||
|
|
bebf2ec329 | ||
|
|
107b8486ae | ||
|
|
5c2122ac3b | ||
|
|
22d866d4de | ||
|
|
b3e78e6061 | ||
|
|
d3e6f80d2b | ||
|
|
84a6fc41d1 | ||
|
|
766f235ab7 | ||
|
|
36f70b3229 | ||
|
|
d3c9b4ad47 | ||
|
|
741b0b9d0c | ||
|
|
c10adaf0b0 | ||
|
|
25409e5329 | ||
|
|
539b5ed63e | ||
|
|
b5a9557b50 | ||
|
|
129f0f0156 | ||
|
|
d242e8c69c | ||
|
|
4bdaac5983 | ||
|
|
b94e74a96d | ||
|
|
dfe0096498 | ||
|
|
35b8eac46b | ||
|
|
2069f79198 | ||
|
|
d00b30f0ba | ||
|
|
ba7f35552b | ||
|
|
7d5cd4c89a | ||
|
|
212a927eee | ||
|
|
fc767e1db6 | ||
|
|
49e61d4153 | ||
|
|
4dc5219a3c | ||
|
|
38c7180365 | ||
|
|
73ed4bd637 | ||
|
|
10e4945e5a | ||
|
|
d2cb38cdac | ||
|
|
f30df333b3 | ||
|
|
672d677046 | ||
|
|
b880a976c2 | ||
|
|
2196c7748f | ||
|
|
6254d06c7a | ||
|
|
d6eb8551da | ||
|
|
d8f1d97804 | ||
|
|
f95fa5ee8f | ||
|
|
fb68c731a4 | ||
|
|
b9bdd2eb8b | ||
|
|
8382730bbd | ||
|
|
be38a44b0c | ||
|
|
dcd8b9359a | ||
|
|
c7a1d5665f | ||
|
|
2ddd2940cc | ||
|
|
6116c9bb4a | ||
|
|
b300bb721a | ||
|
|
40461b260a | ||
|
|
ac8dab3855 | ||
|
|
5e6aa7920a | ||
|
|
7637f297c9 | ||
|
|
ef2d033ad6 | ||
|
|
16aeacc37b | ||
|
|
94cdcdcb6a | ||
|
|
f574562a8d | ||
|
|
dc27e79fd2 | ||
|
|
b08adcb9da | ||
|
|
06a2dea1d7 | ||
|
|
8710d6cfd1 | ||
|
|
d809cf4b18 | ||
|
|
19d64fc421 | ||
|
|
892a642bd4 | ||
|
|
fddca9daf1 | ||
|
|
0e196ee86f | ||
|
|
9bf9e12058 | ||
|
|
dfb459a8d6 | ||
|
|
4665e910ee | ||
|
|
af8f74e29e | ||
|
|
f93b7584ff | ||
|
|
8720b9424d | ||
|
|
c7386a12c5 | ||
|
|
80b9243528 | ||
|
|
2de0c6ecb7 | ||
|
|
04032a2a8d | ||
|
|
4b5f096d1c | ||
|
|
4a95f6a1e2 | ||
|
|
36d68ddb26 | ||
|
|
49ac0601e1 | ||
|
|
f58743efbc | ||
|
|
f82dd8ba90 | ||
|
|
b195a38d64 | ||
|
|
b47d45ea4e | ||
|
|
f3c0967f41 | ||
|
|
7e27370923 | ||
|
|
5367f19f1c | ||
|
|
8dbe3d277b | ||
|
|
698a874709 | ||
|
|
3c5f702288 | ||
|
|
cf18678634 | ||
|
|
3e1f119003 | ||
|
|
0ec2b3fd56 | ||
|
|
0bb96fc509 | ||
|
|
3e042f049b | ||
|
|
65c475b549 | ||
|
|
d6cd2805be | ||
|
|
406b9216de | ||
|
|
9e2aeffbe4 | ||
|
|
6a6f9829e3 | ||
|
|
bf8e105440 | ||
|
|
057a2121a5 | ||
|
|
f0c525d0e7 | ||
|
|
422cef00e5 | ||
|
|
89da9fad9a | ||
|
|
4aa689e110 | ||
|
|
0789a36030 | ||
|
|
d9d4a0fcf6 | ||
|
|
22964a2e20 | ||
|
|
02ff88fc61 | ||
|
|
d5a641797c | ||
|
|
3f0839ace1 | ||
|
|
cf017f81e2 |
@@ -0,0 +1,21 @@
|
||||
# musl linux 交叉编译(Windows 主机无 cc/musl-gcc,用 rust-lld self-contained link)
|
||||
# df-relay 云端部署:本地交叉产 static linux binary → scp 测试机普通运行
|
||||
#
|
||||
# ⚠️ 默认不要跑交叉编译,产物体积大且会污染 target/ 影响日常 build/rust-analyzer 性能。
|
||||
# 需要部署 df-relay 时再显式执行,并把产物隔离到独立 target dir:
|
||||
#
|
||||
# CARGO_TARGET_DIR=target-musl cargo build --release --target x86_64-unknown-linux-musl -p df-relay
|
||||
#
|
||||
# 这样 musl 产物落到 target-musl/(已 gitignore),与主 target/ 完全隔离。
|
||||
[target.x86_64-unknown-linux-musl]
|
||||
linker = "rust-lld"
|
||||
rustflags = ["-C", "link-self-contained=y"]
|
||||
|
||||
# ── 主机编译加速(Windows MSVC) ────────────────────────────
|
||||
# rust-lld 比 MSVC link.exe 快 3-5x,且 Rust 自带零安装
|
||||
[target.x86_64-pc-windows-msvc]
|
||||
linker = "rust-lld.exe"
|
||||
|
||||
# 并行编译单元,留部分核心给 Zed UI 和系统
|
||||
[build]
|
||||
jobs = 8
|
||||
Generated
+105
@@ -0,0 +1,105 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter"
|
||||
version = "0.23.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0203df02a3b6dd63575cc1d6e609edc2181c9a11867a271b25cfd2abff3ec5ca"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"regex",
|
||||
"regex-syntax",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-language"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-rust"
|
||||
version = "0.23.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca8ccb3e3a3495c8a943f6c3fd24c3804c471fd7f4f16087623c7fa4c0068e8a"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ts-bench"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"tree-sitter",
|
||||
"tree-sitter-rust",
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "ts-bench"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tree-sitter = "0.23"
|
||||
tree-sitter-rust = "0.23"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
[workspace]
|
||||
@@ -0,0 +1,184 @@
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::hint::black_box;
|
||||
use std::time::Instant;
|
||||
use tree_sitter::{Node, Parser, Query, QueryCursor};
|
||||
|
||||
fn char_to_token_est(chars: usize) -> usize {
|
||||
(chars + 3) / 4
|
||||
}
|
||||
|
||||
fn extract_skeleton(root: Node, src: &str, q: &Query) -> (String, usize) {
|
||||
let mut out = String::new();
|
||||
let mut count = 0usize;
|
||||
let mut cursor = QueryCursor::new();
|
||||
let matches = cursor.matches(q, root, src.as_bytes());
|
||||
for mat in matches {
|
||||
for cap in mat.captures {
|
||||
let n = cap.node;
|
||||
let start_byte = n.start_byte();
|
||||
let header_end = {
|
||||
let from_start = &src[start_byte..];
|
||||
let mut depth: i32 = 0;
|
||||
let mut end = from_start.len();
|
||||
for (i, b) in from_start.char_indices() {
|
||||
match b {
|
||||
'{' => {
|
||||
if depth == 0 {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
depth += 1;
|
||||
}
|
||||
';' => {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let _ = depth;
|
||||
}
|
||||
start_byte + end.min(from_start.len())
|
||||
};
|
||||
let header = &src[start_byte..header_end].trim_end();
|
||||
out.push_str(header);
|
||||
out.push_str(" { ... }\n");
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
(out, count)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
let path = args.get(1).expect("usage: ts-bench <file.rs>");
|
||||
let src = fs::read_to_string(path).expect("read");
|
||||
let src_chars = src.chars().count();
|
||||
let src_tokens_est = char_to_token_est(src.len());
|
||||
|
||||
let mut parser = Parser::new();
|
||||
parser.set_language(&tree_sitter_rust::LANGUAGE.into()).unwrap();
|
||||
|
||||
// warmup
|
||||
let _ = parser.parse(&src, None).unwrap();
|
||||
|
||||
// ---- PURE PARSE timing ----
|
||||
let n = 200;
|
||||
let mut times_us = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
let t0 = Instant::now();
|
||||
let tree = parser.parse(black_box(&src), None).unwrap();
|
||||
let t1 = Instant::now();
|
||||
drop(tree);
|
||||
times_us.push(t1.duration_since(t0).as_nanos() as f64 / 1000.0);
|
||||
}
|
||||
times_us.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let min = times_us[0];
|
||||
let med = times_us[n / 2];
|
||||
let p95 = times_us[(n as f64 * 0.95) as usize];
|
||||
let mean = times_us.iter().sum::<f64>() / n as f64;
|
||||
|
||||
// ---- INCREMENTAL PARSE (edit one byte at end) ----
|
||||
let mut src_edited = src.clone();
|
||||
src_edited.push_str("\n// comment\n");
|
||||
let tree0 = parser.parse(&src, None).unwrap();
|
||||
let mut times_inc = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
let t0 = Instant::now();
|
||||
let t2 = parser.parse(&src_edited, Some(&tree0)).unwrap();
|
||||
let t1 = Instant::now();
|
||||
drop(t2);
|
||||
times_inc.push(t1.duration_since(t0).as_nanos() as f64 / 1000.0);
|
||||
}
|
||||
times_inc.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let inc_med = times_inc[n / 2];
|
||||
|
||||
// ---- Query compilation timing ----
|
||||
let query_text = r#"
|
||||
[
|
||||
(function_item name: (identifier) @name)
|
||||
(struct_item name: (type_identifier) @name)
|
||||
(enum_item name: (type_identifier) @name)
|
||||
(trait_item name: (type_identifier) @name)
|
||||
(impl_item trait: (type_identifier)? @trait type: (type_identifier)? @type)
|
||||
(const_item name: (identifier) @name)
|
||||
(static_item name: (identifier) @name)
|
||||
(type_item name: (type_identifier) @name)
|
||||
(mod_item name: (identifier) @name)
|
||||
(macro_definition name: (identifier) @name)
|
||||
]
|
||||
"#;
|
||||
let mut qtimes = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
let t0 = Instant::now();
|
||||
let q = Query::new(&tree_sitter_rust::LANGUAGE.into(), query_text).unwrap();
|
||||
let t1 = Instant::now();
|
||||
drop(q);
|
||||
qtimes.push(t1.duration_since(t0).as_nanos() as f64 / 1000.0);
|
||||
}
|
||||
qtimes.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let q_med = qtimes[n / 2];
|
||||
|
||||
// ---- Skeleton extraction (compiled query reused) ----
|
||||
let tree = parser.parse(&src, None).unwrap();
|
||||
let root = tree.root_node();
|
||||
let q = Query::new(&tree_sitter_rust::LANGUAGE.into(), query_text).unwrap();
|
||||
|
||||
let m = 200;
|
||||
let mut ext_us = Vec::with_capacity(m);
|
||||
let mut last_skel = String::new();
|
||||
let mut last_count = 0;
|
||||
for _ in 0..m {
|
||||
let t0 = Instant::now();
|
||||
let (skel, cnt) = extract_skeleton(root, &src, &q);
|
||||
let t1 = Instant::now();
|
||||
ext_us.push(t1.duration_since(t0).as_nanos() as f64 / 1000.0);
|
||||
last_skel = skel;
|
||||
last_count = cnt;
|
||||
}
|
||||
ext_us.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let ext_med = ext_us[m / 2];
|
||||
|
||||
let skel_chars = last_skel.chars().count();
|
||||
let skel_tokens_est = char_to_token_est(last_skel.len());
|
||||
|
||||
println!("=== FILE ===");
|
||||
println!("path : {}", path);
|
||||
println!("lines : {}", src.lines().count());
|
||||
println!("chars : {}", src_chars);
|
||||
println!("bytes : {}", src.len());
|
||||
println!("tokens est (~/4c) : {}", src_tokens_est);
|
||||
println!();
|
||||
println!("=== PARSE SPEED (pure tree-sitter-rust, release, {} iters) ===", n);
|
||||
println!("min={:.2}us median={:.2}us p95={:.2}us mean={:.2}us", min, med, p95, mean);
|
||||
println!("median ms : {:.3} ms", med / 1000.0);
|
||||
println!();
|
||||
println!("=== INCREMENTAL PARSE (small edit) ===");
|
||||
println!("median : {:.2}us ({:.3}ms)", inc_med, inc_med / 1000.0);
|
||||
println!();
|
||||
println!("=== QUERY COMPILATION (once, should be cached) ===");
|
||||
println!("median : {:.2}us ({:.3}ms)", q_med, q_med / 1000.0);
|
||||
println!();
|
||||
println!("=== SKELETON EXTRACTION (compiled query reused) ===");
|
||||
println!("symbols extracted : {}", last_count);
|
||||
println!("skeleton chars : {}", skel_chars);
|
||||
println!("skeleton tokens : {}", skel_tokens_est);
|
||||
println!("extract median : {:.2}us ({:.3}ms)", ext_med, ext_med / 1000.0);
|
||||
println!();
|
||||
println!("=== DENSITY (full vs skeleton) ===");
|
||||
println!(
|
||||
"chars : {:.1}x ({:.1}% reduction)",
|
||||
src_chars as f64 / skel_chars as f64,
|
||||
(1.0 - skel_chars as f64 / src_chars as f64) * 100.0
|
||||
);
|
||||
println!(
|
||||
"tokens : {:.1}x ({:.1}% reduction)",
|
||||
src_tokens_est as f64 / skel_tokens_est as f64,
|
||||
(1.0 - skel_tokens_est as f64 / src_tokens_est as f64) * 100.0
|
||||
);
|
||||
println!();
|
||||
println!("=== SKELETON PREVIEW (first 30 lines) ===");
|
||||
for line in last_skel.lines().take(30) {
|
||||
println!("{}", line);
|
||||
}
|
||||
}
|
||||
+64
@@ -3,6 +3,9 @@ logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# 换装脚本(scripts/swap-install.ps1)运行期锁文件
|
||||
/swap-install.lock
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
@@ -10,6 +13,9 @@ node_modules/
|
||||
dist/
|
||||
dist-ssr/
|
||||
|
||||
# uni-app 编译产物(微信小程序/H5 打包输出)
|
||||
unpackage/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
@@ -22,12 +28,17 @@ dist-ssr/
|
||||
|
||||
# Rust / Cargo
|
||||
target/
|
||||
target-musl/ # df-relay 交叉编译产物隔离目录(见 .cargo/config.toml)
|
||||
!Cargo.lock
|
||||
|
||||
# Tauri
|
||||
src-tauri/target/
|
||||
src-tauri/Cargo.lock
|
||||
|
||||
# 签名私钥(应用内升级,设计 §七 高风险项:私钥绝不入仓)
|
||||
*.key
|
||||
*.key.pub
|
||||
|
||||
# Tauri 自动生成的 schema(每次构建变,可重建)
|
||||
src-tauri/gen/schemas/
|
||||
|
||||
@@ -47,3 +58,56 @@ coverage/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# 损坏备份(FR-S7 write_file 事故残留)+临时文件
|
||||
*.broken.bak
|
||||
tmp/
|
||||
.temp/
|
||||
|
||||
# 临时杂项(2026-08-05 误入的开发机临时物:网易爬虫/播放脚本/mp3,勿提交)
|
||||
archive/
|
||||
|
||||
# AI 编排脚本(Claude Code Workflow 临时产物,非产品代码)
|
||||
workflows/
|
||||
|
||||
# 临时分析脚本(团队分析/报告生成,非产品代码根目录级)
|
||||
/analysis_*.txt
|
||||
/analysis_*.py
|
||||
/core_domains.txt
|
||||
/core_domains.py
|
||||
/final_report.txt
|
||||
/grand_output.txt
|
||||
/im_live_verify.txt
|
||||
/liang_deep.py
|
||||
/liangxianyou_deep.txt
|
||||
/member_profiles.py
|
||||
/read_all.py
|
||||
/report_p2.txt
|
||||
/report_wall.txt
|
||||
/team_analysis.py
|
||||
/team_deep_analysis.py
|
||||
/team_member_profiles.txt
|
||||
/team_report_final.txt
|
||||
/tmp_*.py
|
||||
/tmp_*.txt
|
||||
/verify_im_live*.py
|
||||
|
||||
# MCP/SQLite 测试库(mcp_test2 --db 残留)
|
||||
devflow-dev.db
|
||||
devflow-dev.db-shm
|
||||
devflow-dev.db-wal
|
||||
|
||||
# 微信小程序 appid(真实 appid 本地保留,不提交仓库)
|
||||
apps/df-miniapp/src/manifest.json
|
||||
# 微信小程序 appid(真实 appid 本地保留,不提交仓库)
|
||||
temp/
|
||||
|
||||
# 本地工具链缓存(protoc 等,不入库)
|
||||
.tools/
|
||||
|
||||
# df-im-gateway 运行时数据(冒烟测试残留)
|
||||
crates/df-im-gateway/data/
|
||||
|
||||
# Playwright 运行产物
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# DevFlow 提交规范
|
||||
|
||||
## 提交注释格式
|
||||
|
||||
```
|
||||
<动词>: <简短描述>
|
||||
|
||||
<详细说明(可选,仅在需要补充上下文时)>
|
||||
```
|
||||
|
||||
## 动词词表
|
||||
|
||||
| 动词 | 使用场景 |
|
||||
|------|---------|
|
||||
| `新增` | 新功能、新特性、新文件 |
|
||||
| `修复` | Bug 修复、问题修正 |
|
||||
| `更新` | 文档更新、配置变更、依赖升级、非功能性的已有内容修改 |
|
||||
| `重构` | 代码重构(行为不变的结构调整) |
|
||||
| `删除` | 移除功能、删除文件 |
|
||||
| `优化` | 性能优化、体验改进 |
|
||||
| `合并` | 分支合并 |
|
||||
|
||||
## 规范要求
|
||||
|
||||
1. **动词 + 冒号 + 空格**:`修复: 审批按钮永久转圈`
|
||||
2. **主题行不超过 72 字符**
|
||||
3. **主语用中文**,描述清楚改动内容
|
||||
4. **不要用内部代号**:禁止 Batch/P1/P2/L1/F-XX 等代号
|
||||
5. **不要用分隔线**:禁止 `---` 或 `===` 等装饰线
|
||||
6. **不要写无意义提交**:如"更新"、"修复"、"提交"等无法看出内容的单字提交
|
||||
7. **改动较多的提交**:主题行概括后,空一行用列表展开具体条目
|
||||
8. **文档和代码合并提交**:文档更新和对应代码改动放在同一个提交中,不分散
|
||||
|
||||
## 示例
|
||||
|
||||
```
|
||||
修复: 审批按钮永久转圈和等待计数不停
|
||||
```
|
||||
|
||||
```
|
||||
新增: 文件浏览器增强(窗口分离/Git 变更/行号/Diff/分页提交历史)
|
||||
|
||||
- 窗口分离: FileExplorer 可弹出独立 Tauri 窗口
|
||||
- 行号显示: 文件预览左侧显示行号列
|
||||
- Diff 视图: 有 Git 变更的文件可切换 diff 红绿视图
|
||||
- 提交详情: 点击提交查看变更文件列表及文件级 diff
|
||||
- 中文编码修复: git 命令注入 LC_ALL 环境变量
|
||||
```
|
||||
@@ -0,0 +1,29 @@
|
||||
# DevFlow 开发规则
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 桌面端: Tauri v2 + Vue 3 + TypeScript + Pinia
|
||||
- 后端: Rust Workspace (8 crate) / SQLite
|
||||
- 包管理: bun (前端) / cargo (Rust)
|
||||
- 路径: `E:/wk-lab/devflow`
|
||||
|
||||
## 架构原则
|
||||
|
||||
1. **治本不治标**:修复问题时找到根因,不做表面补丁
|
||||
2. **DRY 原则**:不重复代码,抽象出可复用逻辑
|
||||
3. **长远考虑**:不追求局部最优,考虑整体架构演进
|
||||
4. **修改收敛**:同类改动合并到一个批次提交,不零散提交
|
||||
|
||||
## 代码规范
|
||||
|
||||
1. **Rust 后端**:提交前运行 `cd src-tauri && cargo check`
|
||||
2. **前端 TypeScript/Vue**:提交前运行 `npx vue-tsc --noEmit`
|
||||
3. **完整构建**:提交前运行 `npx vite build`(验证构建不报错)
|
||||
4. **TypeScript 文件名**:camelCase(`fileExplorer.ts`)
|
||||
5. **Vue 组件文件名**:PascalCase(`FileExplorer.vue`)
|
||||
|
||||
## i18n
|
||||
|
||||
- 中英文双语言同步:`src/i18n/en/` 和 `src/i18n/zh-CN/`
|
||||
- 新增组件时同时添加对应语言的翻译文件
|
||||
- 国际化文件自动聚合(`src/i18n/*/index.ts` 自动导入所有模块文件)
|
||||
@@ -0,0 +1,181 @@
|
||||
{
|
||||
// ── 项目级 Zed 配置: rust-analyzer 性能极限优化 ──────────────
|
||||
//
|
||||
// workspace: src-tauri + 7 个 df-* crate 的 Rust Monorepo
|
||||
// flcheck 单次 600+ 行 artifact JSON → Zed 解析+渲染冻结 UI
|
||||
// 核心策略: 关保存即 check · 限制检查范围 · 裁切 LSP 负载 · 排 IO 争抢
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
"lsp": {
|
||||
"rust-analyzer": {
|
||||
"initialization_options": {
|
||||
// ── cargo 编译参数 ───────────────────────────────────
|
||||
"cargo": {
|
||||
"features": [],
|
||||
"allTargets": false, // 只为 host target 解析
|
||||
"noDefaultFeatures": false,
|
||||
"targetDir": null,
|
||||
"buildScripts": {
|
||||
"enable": true,
|
||||
"invocationStrategy": "once", // 只跑一次,不反复
|
||||
"rerun": "on-save"
|
||||
},
|
||||
"extraArgs": []
|
||||
},
|
||||
|
||||
// ── check / flycheck ────────────────────────────────
|
||||
"check": {
|
||||
"onSave": false, // ★ 核心!关保存即 check
|
||||
"workspace": false, // 手动 check 也只查当前 crate
|
||||
"command": "check", // 用 check,非 clippy
|
||||
"invocationStrategy": "once", // 只跑一轮,不反复
|
||||
"features": [],
|
||||
"allTargets": false,
|
||||
"ignore": [ // 跳过不常用的目录
|
||||
"benches",
|
||||
"examples",
|
||||
"xtask",
|
||||
"tests"
|
||||
]
|
||||
},
|
||||
|
||||
// ── 缓存预热(关掉,省首次启动 CPU 爆发) ──────────────
|
||||
"cachePriming": {
|
||||
"enable": false
|
||||
},
|
||||
|
||||
// ── 诊断 ────────────────────────────────────────────
|
||||
"diagnostics": {
|
||||
"enable": true,
|
||||
"experimental": {
|
||||
"enable": false
|
||||
},
|
||||
"disabled": [ // 关掉非关键诊断,降 CPU
|
||||
"unresolved-proc-macro",
|
||||
"inactive-code",
|
||||
"macro-error"
|
||||
]
|
||||
},
|
||||
|
||||
// ── proc-macro(必须开,否则 tauri 全报红) ────────────
|
||||
"procMacro": {
|
||||
"enable": true,
|
||||
"attributes": {
|
||||
"enable": true
|
||||
}
|
||||
},
|
||||
|
||||
// ── 符号检索 ────────────────────────────────────────
|
||||
"workspace": {
|
||||
"symbol": {
|
||||
"search": {
|
||||
"kind": "only_types",
|
||||
"limit": 64
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── 补全(限流减少 LSP 计算量) ──────────────────────
|
||||
"completion": {
|
||||
"autoclose": true,
|
||||
"autoimport": {
|
||||
"enable": true
|
||||
},
|
||||
"callable": {
|
||||
"snippets": "fill_arguments"
|
||||
},
|
||||
"fullFunction": {
|
||||
"enable": false
|
||||
},
|
||||
"limit": 200, // 补全条目上限
|
||||
"postfix": {
|
||||
"enable": false // 关 postfix snippet 减少候选
|
||||
}
|
||||
},
|
||||
|
||||
// ── 内联提示(大文件里关掉多数减少 UI 重绘) ──────────
|
||||
"inlayHints": {
|
||||
"bindingModeHints": {
|
||||
"enable": false
|
||||
},
|
||||
"chainingHints": {
|
||||
"enable": false
|
||||
},
|
||||
"closingBraceHints": {
|
||||
"enable": false
|
||||
},
|
||||
"closureReturnTypeHints": {
|
||||
"enable": "never"
|
||||
},
|
||||
"constructorHints": "never",
|
||||
"discriminantHints": {
|
||||
"enable": "never"
|
||||
},
|
||||
"expressionAdjustmentHints": {
|
||||
"mode": "never"
|
||||
},
|
||||
"implicitDrops": "never",
|
||||
"lifetimeElisionHints": {
|
||||
"enable": "skip_trivial"
|
||||
},
|
||||
"maxLength": 100,
|
||||
"parameterHints": {
|
||||
"enable": true
|
||||
},
|
||||
"reborrowHints": {
|
||||
"enable": "never"
|
||||
},
|
||||
"renderColons": true,
|
||||
"typeHints": {
|
||||
"enable": true,
|
||||
"hideNamedConstructor": false
|
||||
}
|
||||
},
|
||||
|
||||
// ── 语义高亮(大文件关掉字符串部分减少重绘) ──────────
|
||||
"semanticHighlights": {
|
||||
"strings": {
|
||||
"enable": false
|
||||
}
|
||||
},
|
||||
|
||||
// ── 高亮关联(关掉减少 LSP 图构建) ──────────────────
|
||||
"highlightRelated": {
|
||||
"references": {
|
||||
"enable": false
|
||||
}
|
||||
},
|
||||
|
||||
// ── 测试解释器(关掉) ───────────────────────────────
|
||||
"interpret": {
|
||||
"tests": false
|
||||
},
|
||||
|
||||
// ── rust-analyzer 文件扫描排除 ─────────────────────
|
||||
"files": {
|
||||
"excludeDirs": [
|
||||
".cargo",
|
||||
"target",
|
||||
"target-musl",
|
||||
"node_modules",
|
||||
"dist",
|
||||
".zed",
|
||||
".git"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Zed 文件监视排除(减少 File Watcher IO 争抢) ────────────
|
||||
// 和全局 ~/.config/zed/settings.json 的 file_scan_exclusions 叠加
|
||||
"file_scan_exclusions": [
|
||||
"**/target/**",
|
||||
"**/target-musl/**",
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"**/.cargo/**",
|
||||
"**/.git/**",
|
||||
"**/build/**"
|
||||
]
|
||||
}
|
||||
+479
-228
@@ -1,6 +1,6 @@
|
||||
# DevFlow — 产研全流程工作流平台
|
||||
|
||||
> 版本: v0.1.0 | 创建: 2026-06-10 | 状态: 设计阶段
|
||||
> 版本: v0.1.0 | 创建: 2026-06-10 | 状态: Phase 2 本地优先开发流程验证
|
||||
|
||||
## 一、项目定位
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
- **经验进化 (Evolution)**:开发过程中的模式自动沉淀为知识库(审查规则/Prompt模板/踩坑经验),持续进化复用
|
||||
- **阶段插件**:想法→需求→编码→测试→发布,阶段即模板
|
||||
|
||||
### 层级模型
|
||||
### 层级模型(业务层级)
|
||||
|
||||
DevFlow 的业务抽象分三层,自上而下层层实例化:
|
||||
|
||||
```
|
||||
💡 Idea Pool (想法池) — 独立运转,持续捕捉和评估
|
||||
@@ -31,6 +33,82 @@
|
||||
└→ 🎯 Release (发布) — 合并多个 Task → 集成测试 → 发布
|
||||
```
|
||||
|
||||
### 层级模型(执行层级)
|
||||
|
||||
Workflow DAG 的执行层进一步拆分为三层,这是 AI Factory 的核心抽象:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ 模板层 (Template) │
|
||||
│ "应该做什么" — 阶段蓝图、行业最佳实践 │
|
||||
│ │
|
||||
│ 职责: 定义节点拓扑 + 产出物规范 + 质量门禁 │
|
||||
│ 生命周期: 长期存在,跨项目复用 │
|
||||
│ 存储: YAML 文件 / DB 模板库 │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ 工作流层 (Workflow) │
|
||||
│ "怎么执行" — DAG 实例、状态机、运行时 │
|
||||
│ │
|
||||
│ 职责: 拓扑排序 + 节点调度 + 状态流转 + 持久化 │
|
||||
│ 生命周期: 随项目启动/结束,单次执行后归档 │
|
||||
│ 载体: df-workflow (DAG + Executor + StateMachine) │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ 人设层 (Persona) │
|
||||
│ "谁来做" — Agent 角色、能力边界、行为风格 │
|
||||
│ │
|
||||
│ 职责: 定义 system prompt + 可用工具 + 输出格式 │
|
||||
│ 生命周期: 长期存在,跨节点复用 │
|
||||
│ 注入点: AINode 执行时载入对应人设 │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**关键设计原则**:三层各自独立演化,在 AINode 执行时交汇。
|
||||
- 模板 = 可复用的蓝图(定义节点拓扑 + 建议人设 + 质量门禁)
|
||||
- 工作流 = 模板的运行时实例(含状态、数据绑定、执行记录)
|
||||
- 人设 = Agent 的角色卡(system prompt + 工具集 + 行为规则)
|
||||
|
||||
### AI Working 定位体系
|
||||
|
||||
DevFlow 的终极交互模型是 **AI 驱动 (AI Working)**:**AI 是系统的主要操作者,人是监督者与决策者**。
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ AI Chat(唯一对话入口) │
|
||||
│ 工具注册表 ←→ Agentic Loop ←→ 审批 │
|
||||
└──────────────┬───────────────────┘
|
||||
│
|
||||
┌───────┬───────┬───┴───┬───────┬───────┐
|
||||
│ 查看 │ 驱动 │ 创意 │ 决策 │ 录入 │ ← 人的五种行为
|
||||
└───────┴───────┴───┬───┴───────┴───────┘
|
||||
│
|
||||
┌──────────────┴──────────────┐
|
||||
│ IPC 命令层 │
|
||||
│ project / task / idea / │
|
||||
│ knowledge / workflow / ai │
|
||||
└──────────────┬──────────────┘
|
||||
│
|
||||
┌──────────────┴──────────────┐
|
||||
│ 数据层 (SQLite) │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 人的五种行为(均通过 AI Chat 完成)
|
||||
|
||||
| 行为 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| **查看数据** | 不点页面翻列表,自然语言查询 | "最近 blocked 的任务有哪些?" → AI 调 `list_tasks` |
|
||||
| **驱动执行** | 不手动推进状态,对话指令驱动 | "把子1推进到 in_progress" → AI 调 `advance_task` |
|
||||
| **创意捕获** | 不打开灵感表单,对话中自然捕获 | "这个想法记一下" → AI 调 `create_idea` |
|
||||
| **决策审批** | 不逐条点批准,Chat 内确认/拒绝 | 工具审批卡片(Agentic Loop 内) |
|
||||
| **录入信息** | 不填 CRUD 表单,自然语言录入 | "建个项目叫 foo,在 E:/bar" → AI 调 `create_project` |
|
||||
|
||||
#### 关键设计原则
|
||||
|
||||
1. **AI 工具注册表是主接口**:IPC 就绪后必须立刻注册为 AI 工具,页面是旁路查看器
|
||||
2. **页面降级为「查看层」**:Dashboard / Projects / Tasks / Ideas 等页面用于事后查看、审计、回放
|
||||
3. **功能优先级 = AI 工具覆盖度**:人对模块的操作能力取决于 AI 工具注册表中该模块的工具是否完整
|
||||
4. **灵感 / 项目 / 任务 三模块的 AI 工具必须完全覆盖 CRUD + 状态推进 + 升级/评估**
|
||||
|
||||
## 二、技术栈
|
||||
|
||||
| 层 | 技术 | 说明 |
|
||||
@@ -63,7 +141,7 @@
|
||||
│ Workflow Engine (DAG · Node · State · Event · Persist) │
|
||||
├──────────────────────┬───────────────────────────────────────────┤
|
||||
│ AI Orchestrator │ Execution Runtime │
|
||||
│ Multi-Provider │ Shell · Docker · SSH · Git (libgit2) │
|
||||
│ Multi-Provider │ Shell (tokio::process) │
|
||||
│ Agent Coordinator │ Merge · Conflict Resolve │
|
||||
├──────────────────────┴───────────────────────────────────────────┤
|
||||
│ Storage Layer (SQLite) │
|
||||
@@ -77,29 +155,27 @@
|
||||
devflow/
|
||||
├── Cargo.toml # Workspace 根
|
||||
├── crates/
|
||||
│ ├── df-core/ # 核心类型、错误、常量、事件
|
||||
│ ├── df-types/ # 核心类型、错误、常量、事件、augmentation
|
||||
│ ├── df-workflow/ # 工作流 DAG 引擎 (核心)
|
||||
│ ├── df-nodes/ # 内置节点集合 (AI/Script/Docker/Git/Human/HTTP/Subflow)
|
||||
│ ├── df-ai/ # AI 编排层 (Multi-Provider/Router/Coordinator)
|
||||
│ ├── df-execute/ # 执行运行时 (Shell/Docker/SSH/Git)
|
||||
│ ├── df-nodes/ # 内置节点集合 (AI/Script/Human/Git/HTTP/Notify/Subflow/Docker/自审/推进)
|
||||
│ ├── df-ai/ # AI 编排层 (Multi-Provider/Coordinator)
|
||||
│ ├── df-execute/ # 执行运行时 (Shell)
|
||||
│ ├── df-storage/ # 存储层 (SQLite)
|
||||
│ ├── df-ideas/ # 想法池引擎 (捕捉/评估/评分/晋升)
|
||||
│ ├── df-project/ # 多项目管理 (调度/上下文/时间线)
|
||||
│ ├── df-task/ # 任务/分支管理 (并行任务/分支/合并/冲突)
|
||||
│ ├── df-traceability/ # 可追溯性 (标注/决策留痕/需求-测试映射)
|
||||
│ ├── df-evolve/ # 经验进化 (知识沉淀/Prompt模板/审查规则/踩坑经验)
|
||||
│ ├── df-stages/ # 阶段插件 (5 阶段内置模板)
|
||||
│ └── df-plugin/ # 插件系统 (WASM/动态库)
|
||||
├── src/ # Tauri 主入口
|
||||
│ ├── main.rs
|
||||
│ ├── state.rs
|
||||
│ └── commands/ # IPC 命令
|
||||
├── frontend/ # Vue 3 前端
|
||||
│ └── df-project/ # 多项目管理 (调度/上下文/时间线)
|
||||
│
|
||||
│ 注: df-task / df-traceability / df-evolve / df-stages / df-plugin
|
||||
│ 5 个 crate 已移除(2026-06-14 零引用清理,推翻原"保留骨架"取舍)
|
||||
├── src-tauri/ # Tauri 主入口 (Rust)
|
||||
│ └── src/
|
||||
│ ├── views/ # 页面组件
|
||||
│ ├── components/ # 业务组件
|
||||
│ ├── stores/ # Pinia 状态
|
||||
│ └── composables/ # 组合式函数
|
||||
│ ├── main.rs
|
||||
│ ├── state.rs
|
||||
│ └── commands/ # IPC 命令
|
||||
├── src/ # Vue 3 前端
|
||||
│ ├── views/ # 页面组件
|
||||
│ ├── components/ # 业务组件
|
||||
│ ├── stores/ # Pinia 状态
|
||||
│ └── composables/ # 组合式函数
|
||||
├── templates/ # 工作流模板 (YAML)
|
||||
└── plugins/ # 外部插件目录
|
||||
```
|
||||
@@ -137,7 +213,9 @@ devflow/
|
||||
|
||||
项目状态:`Planning / InProgress / Testing / Releasing / Completed / Paused / Cancelled`
|
||||
|
||||
### 5.3.1 Task & Branch Manager (df-task)
|
||||
### 5.3.1 ~~Task & Branch Manager (df-task)~~ — 已移除
|
||||
|
||||
> **2026-06-14 零引用清理**:df-task crate 已删除。以下内容保留作为历史设计参考,不再对应实际代码。
|
||||
|
||||
项目内部的并行任务管理,每个任务绑定一个 Git 分支。
|
||||
|
||||
@@ -165,7 +243,9 @@ Task 生命周期:
|
||||
2. AI 辅助解决:冲突文件交给 AI 分析并建议解决方案
|
||||
3. 发布编排:选择多个 Task → 创建 release 分支 → 合并 → 集成测试 → 发布
|
||||
|
||||
### 5.4 Traceability & Annotation (df-traceability)
|
||||
### 5.4 ~~Traceability & Annotation (df-traceability)~~ — 已移除
|
||||
|
||||
> **2026-06-14 零引用清理**:df-traceability crate 已删除。以下内容保留作为历史设计参考,不再对应实际代码。
|
||||
|
||||
贯穿所有阶段的可追溯性引擎。
|
||||
|
||||
@@ -238,253 +318,420 @@ pub struct Decision {
|
||||
|
||||
### 5.4 AI Orchestrator (df-ai)
|
||||
|
||||
- **LlmProvider trait**:统一接口,各模型实现
|
||||
- **ModelRouter**:按任务类型路由到最优模型 + 降级链
|
||||
- **AgentCoordinator**:多 Agent 协作(Planner/Coder/Reviewer/Fixer)
|
||||
- **ContextManager**:Token 预算管理
|
||||
- **ToolRegistry**:工具注册(供 Agent 调用)
|
||||
- **LlmProvider trait**:统一接口,各模型实现(`provider.rs`)✅
|
||||
- **ModelRouter**:按任务类型路由到最优模型 + 降级链 — ❌ 已删(R-PD-7),模型选择改由调用方在 `LlmProvider` 实现间直接指定
|
||||
- **AgentCoordinator**:多 Agent 协作(Plan DAG 拆解 + JoinSet 层内并行 + Token 预算池 + Reviewer 仲裁) — ✅ 已实现
|
||||
- **Git Worktree 隔离**:子 Agent 文件隔离(git worktree + 独立分支) — ✅ 已实现
|
||||
- **ContextManager**:Token 预算管理(`context/mod.rs`,拆分为 sanitize/manager_tests 子模块)✅
|
||||
- **ToolRegistry**:工具注册(`ai_tools.rs`,供 Agent 调用)✅
|
||||
|
||||
### 5.5 内置节点 (df-nodes)
|
||||
|
||||
| 节点 | 功能 |
|
||||
|------|------|
|
||||
| AINode | 调用 LLM,支持流式输出、工具调用 |
|
||||
| ScriptNode | Shell/脚本执行 |
|
||||
| DockerNode | Docker 容器操作 |
|
||||
| GitNode | Git 操作 (libgit2) |
|
||||
| HumanNode | 人工审批/确认 (阻塞) |
|
||||
| NotifyNode | 通知 (桌面/飞书/Webhook) |
|
||||
| HTTPNode | HTTP 请求 |
|
||||
| SubflowNode | 嵌套子工作流 |
|
||||
| 节点 | 功能 | 状态 |
|
||||
|------|------|------|
|
||||
| AINode | 调用 LLM,支持流式输出、工具调用 | ✅ 已实现 |
|
||||
| AiSelfReviewNode | AI 对任务产出自审(verdict 闸门) | ✅ 已实现 |
|
||||
| ScriptNode | Shell/脚本执行 | ✅ 已实现 |
|
||||
| HumanNode | 人工审批/确认 (阻塞) | ✅ 已实现 |
|
||||
| TaskAdvanceNode | 任务状态推进链唯一 status 写入路径 | ✅ 已实现 |
|
||||
| GitNode | Git 操作(branch/checkout/commit/merge/push/status/log) | ✅ 已实现 |
|
||||
| HTTPNode | HTTP 请求(GET/POST/PUT/DELETE) | ✅ 已实现 |
|
||||
| NotifyNode | 通知(桌面/Webhook) | ✅ 已实现 |
|
||||
| SubflowNode | 嵌套子工作流(深度限制防递归) | ✅ 已实现 |
|
||||
| DockerNode | Docker 容器内构建/测试(环境检测+授权) | ✅ 已实现 |
|
||||
|
||||
> `crates/df-nodes/src/` 含 10 个 `impl Node`(ai/ai_self_review/script/human/task_advance/git/http/notify/subflow/docker,全部已实现)。
|
||||
|
||||
## 六、数据模型
|
||||
|
||||
### SQLite 表结构
|
||||
|
||||
> 真相源:`crates/df-storage/src/migrations.rs`(V1-V41,幂等迁移)。下表对齐真实 schema,字段/类型/默认值/外键与迁移文件一致。Rust 结构体见 `crates/df-storage/src/models.rs`。
|
||||
|
||||
共 **24 张业务表**(另含内部表 `schema_version` 记录迁移版本)。
|
||||
|
||||
```sql
|
||||
-- 想法池
|
||||
-- 想法池 (V1 建表 / V2 补 promoted_to + ai_analysis + scores)
|
||||
CREATE TABLE ideas (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
tags TEXT, -- JSON array
|
||||
source TEXT NOT NULL, -- Manual/Clipboard/AI
|
||||
status TEXT NOT NULL, -- Draft/PendingReview/Approved/Rejected/Promoted/Archived
|
||||
scores TEXT, -- JSON: {market_potential, competition, feasibility, roi, overall}
|
||||
ai_analysis TEXT,
|
||||
related_ideas TEXT, -- JSON array of idea ids
|
||||
promoted_to TEXT, -- project id
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'draft', -- draft/pending_review/approved/rejected/promoted/archived
|
||||
priority INTEGER NOT NULL DEFAULT 1,
|
||||
score REAL,
|
||||
tags TEXT, -- JSON 数组
|
||||
source TEXT,
|
||||
promoted_to TEXT, -- 晋升后的 project_id (V2)
|
||||
ai_analysis TEXT, -- AI 分析结果 JSON (V2)
|
||||
scores TEXT, -- 多维评分 JSON: feasibility/impact/urgency/overall (V2)
|
||||
related_ids TEXT, -- 关联灵感 ID JSON 数组 (V24)
|
||||
deleted_at TEXT, -- 软删回收站 (V28)
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 项目
|
||||
-- 项目 (V1 建表 / V11 补 deleted_at / V12 补 path + stack)
|
||||
CREATE TABLE projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
idea_id TEXT, -- 来源想法
|
||||
repo_path TEXT, -- 仓库路径
|
||||
tech_stack TEXT, -- JSON array
|
||||
status TEXT NOT NULL, -- Planning/InProgress/Testing/Releasing/Completed/Paused/Cancelled
|
||||
priority INTEGER NOT NULL DEFAULT 1, -- 0=Low, 1=Medium, 2=High, 3=Critical
|
||||
current_stage TEXT, -- Idea/Requirement/Coding/Testing/Release
|
||||
config TEXT, -- JSON: 项目级配置
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'planning',
|
||||
idea_id TEXT REFERENCES ideas(id),
|
||||
path TEXT, -- 绑定的本地代码目录绝对路径 (V12,可空=未绑定)
|
||||
stack TEXT, -- 技术栈 JSON 数组字符串 (V12,探测填充)
|
||||
deleted_at TEXT, -- 软删回收站: NULL=正常, 非空=已进回收站 (V11)
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 工作流定义
|
||||
CREATE TABLE workflow_defs (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
task_id TEXT, -- 关联的任务 (可为空,如项目级工作流)
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
dag TEXT NOT NULL, -- JSON: DAG 结构
|
||||
template_id TEXT, -- 来源模板
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- 任务 (项目内的并行工作单元)
|
||||
-- 任务 (V1 建表 / V2 补 workflow_def_id + base_branch / V20 补 idea_id / V14 补 deleted_at /
|
||||
-- V15 补 review_rounds / V17 补 output_json / V29 补 queue+parent_id+content_json / V41 补 module_id)
|
||||
CREATE TABLE tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL, -- Todo/InProgress/InReview/Testing/Done/Blocked/Cancelled
|
||||
priority INTEGER NOT NULL DEFAULT 1, -- 0=Low, 1=Medium, 2=High, 3=Critical
|
||||
branch_name TEXT, -- Git 分支名
|
||||
base_branch TEXT DEFAULT 'main', -- 基于哪个分支创建
|
||||
workflow_def_id TEXT, -- 关联的工作流定义
|
||||
assignee TEXT, -- AI agent 或人工
|
||||
merge_conflict TEXT, -- JSON: 冲突信息
|
||||
merged_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'todo', -- todo/in_progress/in_review/testing/done/blocked/cancelled
|
||||
priority INTEGER NOT NULL DEFAULT 2, -- 0=critical..3=low,默认 2=medium
|
||||
branch_name TEXT,
|
||||
assignee TEXT,
|
||||
workflow_def_id TEXT, -- 关联的工作流定义 ID (V2)
|
||||
base_branch TEXT, -- 基础分支 (V2)
|
||||
idea_id TEXT REFERENCES ideas(id), -- 关联灵感 (V20)
|
||||
review_rounds INTEGER NOT NULL DEFAULT 0, -- review 退回累计轮数 (V15)
|
||||
output_json TEXT, -- AiNode 自审闭环产出 (V17)
|
||||
queue TEXT NOT NULL DEFAULT 'todo', -- 管理池 backlog/todo/decision/active/done (V29)
|
||||
parent_id TEXT REFERENCES tasks(id), -- 父任务(限 1 级嵌套) (V29)
|
||||
content_json TEXT, -- 结构化需求规格 JSON (V29)
|
||||
module_id TEXT REFERENCES project_modules(id) ON DELETE SET NULL, -- 关联工程 (V41)
|
||||
deleted_at TEXT, -- 软删回收站 (V14)
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 发布计划 (合并多个 Task → 发布)
|
||||
-- 分支 (V2 建表) — 任务与 Git 分支绑定
|
||||
CREATE TABLE branches (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
task_id TEXT REFERENCES tasks(id),
|
||||
name TEXT NOT NULL,
|
||||
base TEXT NOT NULL DEFAULT 'main',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
merged_at TEXT
|
||||
);
|
||||
|
||||
-- 发布计划 (V1 建表)
|
||||
CREATE TABLE releases (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
version TEXT NOT NULL, -- e.g. v2.3.0
|
||||
branch_name TEXT, -- release 分支
|
||||
task_ids TEXT NOT NULL, -- JSON array: 包含的 Task ID 列表
|
||||
status TEXT NOT NULL, -- Planning/Integrating/Testing/Ready/Published
|
||||
workflow_def_id TEXT, -- 发布工作流
|
||||
changelog TEXT,
|
||||
published_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
version TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'planned',
|
||||
task_ids TEXT NOT NULL DEFAULT '[]', -- JSON 数组: 包含的 Task ID 列表
|
||||
changelog TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
released_at TEXT
|
||||
);
|
||||
|
||||
-- 工作流执行
|
||||
CREATE TABLE workflow_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
workflow_def_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
task_id TEXT, -- 关联的任务 (可为空)
|
||||
release_id TEXT, -- 关联的发布 (可为空)
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- Pending/Running/Paused/Completed/Failed/Cancelled
|
||||
node_states TEXT, -- JSON: {node_id: {status(Pending/Running/Completed/Failed/Skipped/Waiting), output, started_at, finished_at}}
|
||||
current_layer INTEGER,
|
||||
started_at INTEGER,
|
||||
finished_at INTEGER,
|
||||
error TEXT
|
||||
-- 工作流执行 (V1 建表 / V2 补 project_id + task_id)
|
||||
-- 注: 不区分 def/run, DAG 直接序列化进 dag_json, 一次执行一行
|
||||
CREATE TABLE workflow_executions (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
dag_json TEXT NOT NULL, -- DAG 的 JSON 序列化
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending/running/paused/completed/failed/cancelled
|
||||
triggered_by TEXT,
|
||||
project_id TEXT, -- 关联项目 ID (V2)
|
||||
task_id TEXT, -- 关联任务 ID (V2)
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
updated_at TEXT -- 更新时间戳,排序/增量同步 (V33)
|
||||
);
|
||||
|
||||
-- 产出物
|
||||
CREATE TABLE artifacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
workflow_run_id TEXT,
|
||||
node_id TEXT,
|
||||
kind TEXT NOT NULL, -- PRD/CodeChange/TestReport/ReleaseNote/Image
|
||||
title TEXT,
|
||||
content TEXT,
|
||||
file_path TEXT,
|
||||
metadata TEXT, -- JSON
|
||||
created_at INTEGER NOT NULL
|
||||
-- 节点执行 (V1 建表) — 工作流执行过程中每个节点的运行快照
|
||||
CREATE TABLE node_executions (
|
||||
id TEXT PRIMARY KEY,
|
||||
workflow_id TEXT NOT NULL REFERENCES workflow_executions(id),
|
||||
node_id TEXT NOT NULL,
|
||||
node_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
input_json TEXT,
|
||||
output_json TEXT,
|
||||
error_message TEXT,
|
||||
started_at TEXT,
|
||||
completed_at TEXT
|
||||
);
|
||||
|
||||
-- 连接配置
|
||||
CREATE TABLE connections (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL, -- MySQL/SSH/MongoDB/Redis/HTTP
|
||||
config TEXT NOT NULL, -- JSON (加密存储)
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- AI 模型配置
|
||||
-- AI 提供商配置 (V9 建表 / V18 补 model_configs / V19 补 enabled + weight)
|
||||
CREATE TABLE ai_providers (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider TEXT NOT NULL, -- Claude/GLM/DeepSeek/OpenAI
|
||||
api_key TEXT, -- 加密存储
|
||||
base_url TEXT,
|
||||
models TEXT, -- JSON: 可用模型列表
|
||||
is_default INTEGER DEFAULT 0,
|
||||
config TEXT -- JSON: 额外配置
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
provider_type TEXT NOT NULL DEFAULT 'openai_compat',
|
||||
api_key TEXT NOT NULL,
|
||||
base_url TEXT NOT NULL,
|
||||
default_model TEXT NOT NULL,
|
||||
models TEXT, -- JSON array of model names
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
config TEXT, -- JSON extra config
|
||||
model_configs TEXT, -- 模型能力配置 JSON (V18)
|
||||
enabled INTEGER NOT NULL DEFAULT 1, -- 负载均衡池开关 (V19)
|
||||
weight INTEGER NOT NULL DEFAULT 50, -- 负载均衡权重 (V19)
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 需求功能清单
|
||||
CREATE TABLE features (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
task_id TEXT, -- 关联任务
|
||||
requirement_id TEXT, -- 来源需求
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'selected', -- Selected/Deferred/Rejected/Completed
|
||||
priority INTEGER NOT NULL DEFAULT 1, -- 0=Low, 1=Medium, 2=High, 3=Critical
|
||||
rejection_reason TEXT, -- 不做的原因(自动进入决策留痕)
|
||||
test_case_count INTEGER DEFAULT 0,
|
||||
order_index INTEGER NOT NULL, -- 排序
|
||||
metadata TEXT, -- JSON: 额外属性
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
-- AI 对话 (V3 建表 / V4 补 archived / V5 补 prompt_tokens + completion_tokens / V6 补 models /
|
||||
-- V16 补 pinned / V32 补 pinned_goals / V33 补 pending_approvals)
|
||||
CREATE TABLE ai_conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
messages TEXT NOT NULL DEFAULT '[]', -- JSON array of ChatMessage (V21 起消息拆分到 ai_messages)
|
||||
provider_id TEXT,
|
||||
model TEXT,
|
||||
models TEXT, -- 用过的所有 model JSON 数组字符串 (去重, V6)
|
||||
archived INTEGER NOT NULL DEFAULT 0, -- 是否归档(侧栏折叠展示, V4)
|
||||
pinned INTEGER NOT NULL DEFAULT 0, -- 侧栏置顶 (V16)
|
||||
prompt_tokens INTEGER, -- 输入 token 累计 (流式 usage 落库, V5)
|
||||
completion_tokens INTEGER, -- 输出 token 累计 (流式 usage 落库, V5)
|
||||
pinned_goals TEXT DEFAULT '[]', -- 目标钉扎持久化 (V32)
|
||||
pending_approvals TEXT DEFAULT '{}', -- 挂起审批快照,重启恢复 (V33)
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 测试用例
|
||||
CREATE TABLE test_cases (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
feature_id TEXT NOT NULL, -- 关联功能
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
preconditions TEXT, -- 前置条件
|
||||
steps TEXT NOT NULL, -- JSON: 测试步骤 [{step, expected}]
|
||||
kind TEXT NOT NULL DEFAULT 'Manual', -- Manual/Auto/AI-Generated
|
||||
priority INTEGER NOT NULL DEFAULT 1, -- 0=Low, 1=Medium, 2=High, 3=Critical
|
||||
-- AI 工具执行审计 (V9 建表 / V21 补 message_id / V27 状态统一 executed→completed / V36 补 subtask_id)
|
||||
-- Agent 工具调用的审批/执行留痕
|
||||
CREATE TABLE ai_tool_executions (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT,
|
||||
message_id TEXT, -- 关联消息 (V21)
|
||||
tool_call_id TEXT NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
arguments TEXT NOT NULL,
|
||||
result TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending/approved/rejected/executing/completed/failed
|
||||
risk_level TEXT NOT NULL DEFAULT 'medium', -- low/medium/high
|
||||
requested_at TEXT NOT NULL,
|
||||
executed_at TEXT,
|
||||
decided_by TEXT, -- human/auto
|
||||
subtask_id TEXT -- 归属子任务 (V36)
|
||||
);
|
||||
|
||||
-- 测试执行记录
|
||||
CREATE TABLE test_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
task_id TEXT, -- 关联任务
|
||||
feature_id TEXT, -- 关联功能
|
||||
test_case_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL, -- Passed/Failed/Skipped/Blocked
|
||||
actual_result TEXT,
|
||||
error_detail TEXT, -- 失败详情
|
||||
executed_by TEXT, -- AI/Human
|
||||
duration_ms INTEGER,
|
||||
executed_at INTEGER NOT NULL
|
||||
-- 知识条目 (V7 建表 / V8 补 embedding / V10 补 reasoning) — 经验沉淀基本单元, 共享记忆层
|
||||
-- 状态机: candidate → pending_review → published → archived
|
||||
CREATE TABLE knowledges (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL DEFAULT 'pitfall', -- 7 种: review_rule/prompt_template/pitfall/architecture_pattern/diagnosis/deployment_note/workflow_optimization
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
tags TEXT, -- JSON 数组字符串
|
||||
status TEXT NOT NULL DEFAULT 'candidate', -- candidate|pending_review|published|archived
|
||||
confidence TEXT, -- high|medium|low (AI 提炼自评)
|
||||
reuse_count INTEGER NOT NULL DEFAULT 0, -- 唯一客观排序信号
|
||||
verified INTEGER NOT NULL DEFAULT 0, -- 发布审核时一次性人工标
|
||||
source_project TEXT, -- 来源项目(仅溯源不过滤)
|
||||
source_ref TEXT, -- 来源实体引用(如 conv:{id})
|
||||
reasoning TEXT, -- AI 提炼判断依据("为何值得沉淀"), 手动录入为 NULL (V10)
|
||||
embedding BLOB, -- Vec<f32> 小端字节序列化, NULL=未嵌入走 LIKE 降级 (V8)
|
||||
embedding_status TEXT, -- 嵌入状态,失败可补偿重试 (V23)
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 标注 (贯穿所有内容)
|
||||
CREATE TABLE annotations (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL, -- Requirement/Feature/Code/TestCase/TestReport/DesignDoc
|
||||
entity_id TEXT NOT NULL, -- 关联实体 ID
|
||||
marker TEXT NOT NULL, -- FIXME/TODO/QUESTION/RISK/DECISION/OPTIMIZE
|
||||
content TEXT NOT NULL, -- 标注内容
|
||||
location TEXT, -- 文件路径+行号 / 文档段落
|
||||
status TEXT NOT NULL DEFAULT 'Open', -- Open/AI-Processing/Resolved/WontFix
|
||||
resolved_by TEXT, -- AI/Human
|
||||
resolution TEXT, -- 处理结果
|
||||
created_at INTEGER NOT NULL,
|
||||
resolved_at INTEGER
|
||||
-- 知识生命线事件 (V10 建表) — 追加型审计: 产生/审核/引用/归档
|
||||
CREATE TABLE knowledge_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
knowledge_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL, -- created | extracted | status_changed | referenced | archived
|
||||
source_ref TEXT, -- 触发来源: conv:{id} / manual / system
|
||||
context_json TEXT, -- JSON: 因 event_type 而异
|
||||
timestamp TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 决策留痕
|
||||
CREATE TABLE decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
context TEXT NOT NULL, -- 决策背景
|
||||
question TEXT NOT NULL, -- 需要决定的问题
|
||||
alternatives TEXT, -- JSON array: 考虑过的方案
|
||||
decision TEXT NOT NULL, -- 最终决定
|
||||
reason TEXT NOT NULL, -- 决策原因
|
||||
decided_by TEXT NOT NULL, -- AI/Human
|
||||
entity_type TEXT, -- 关联实体类型
|
||||
entity_id TEXT, -- 关联实体 ID
|
||||
impact TEXT, -- 影响范围
|
||||
stage TEXT, -- Idea/Requirement/Coding/Testing/Release
|
||||
created_at INTEGER NOT NULL
|
||||
-- 通用应用设置 (V13 建表) — 前端 localStorage 迁移目标 KV 表
|
||||
CREATE TABLE app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL, -- JSON 字符串
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- AI 消息 (V9 新库建表 / V21 老库补建 / V36 补 subtask_id / V38 补 token 列 / V39 补 cache/reason 列)
|
||||
-- V21 起 ChatMessage 逐条拆行存储,支撑消息级 token / 分页 / 溯源
|
||||
CREATE TABLE ai_messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
parts TEXT,
|
||||
tool_call_id TEXT,
|
||||
tool_calls TEXT,
|
||||
model TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
reasoning_content TEXT,
|
||||
timestamp INTEGER,
|
||||
subtask_id TEXT, -- 归属子任务 (V36)
|
||||
created_at TEXT NOT NULL,
|
||||
prompt_tokens INTEGER, -- 消息级输入 token (V38)
|
||||
completion_tokens INTEGER, -- 消息级输出 token (V38)
|
||||
prompt_cache_hit_tokens INTEGER, -- 缓存命中 token (V39)
|
||||
prompt_cache_miss_tokens INTEGER, -- 缓存未命中 token (V39)
|
||||
reasoning_tokens INTEGER, -- 思考 token (V39)
|
||||
UNIQUE(conversation_id, seq)
|
||||
);
|
||||
|
||||
-- 灵感评估历史 (V22 建表 / V25 补唯一约束) — 追加型审计,version 单调递增
|
||||
CREATE TABLE idea_evaluations (
|
||||
id TEXT PRIMARY KEY,
|
||||
idea_id TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
ai_analysis TEXT,
|
||||
scores TEXT,
|
||||
score REAL,
|
||||
evaluated_by TEXT,
|
||||
evaluated_at TEXT NOT NULL,
|
||||
UNIQUE(idea_id, version) -- 评估版本并发重复兜底 (V25)
|
||||
);
|
||||
|
||||
-- 任务横向关联 (V29 建表) — 任务间 depends_on/blocks/relates_to,循环检测走应用层
|
||||
CREATE TABLE task_links (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_id TEXT NOT NULL REFERENCES tasks(id),
|
||||
target_id TEXT NOT NULL REFERENCES tasks(id),
|
||||
link_type TEXT NOT NULL,
|
||||
remark TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 项目事件流 (V30 建表) — 跨实体统一事件,追加型审计(只增不改)
|
||||
CREATE TABLE project_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
event_type TEXT NOT NULL,
|
||||
entity_type TEXT,
|
||||
entity_id TEXT,
|
||||
from_state TEXT,
|
||||
to_state TEXT,
|
||||
context_json TEXT,
|
||||
source TEXT, -- ai/human/system
|
||||
conversation_id TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 项目基础设施配置 (V31 建表) — 记录"项目用了什么基础设施",不存敏感凭证
|
||||
CREATE TABLE project_services (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
name TEXT NOT NULL,
|
||||
service_type TEXT NOT NULL, -- mysql/postgresql/sqlite/redis/mongodb/mq/api/other
|
||||
endpoint TEXT,
|
||||
config_json TEXT,
|
||||
environment TEXT NOT NULL DEFAULT 'development',
|
||||
remark TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 工程 (V34 建表 / V40 补 description + status) — 项目下多工程(Monorepo/微服务/前后端分离)
|
||||
CREATE TABLE project_modules (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
git_url TEXT,
|
||||
stack TEXT,
|
||||
auto_detected BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
description TEXT, -- 工程职责描述 (V40)
|
||||
status TEXT, -- active/archived (V40)
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 工程依赖 (V35 建表) — 工程间依赖边,支撑依赖图
|
||||
CREATE TABLE module_dependencies (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
from_module_id TEXT NOT NULL REFERENCES project_modules(id),
|
||||
to_module_id TEXT NOT NULL REFERENCES project_modules(id),
|
||||
dep_type TEXT NOT NULL DEFAULT 'library', -- library/api/mq/shared/custom
|
||||
label TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 多 Agent 并行执行 (V36 建表) — ai_plans/ai_subtasks/ai_conflicts 三表 + ai_messages/ai_tool_executions 补 subtask_id
|
||||
CREATE TABLE ai_plans (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
user_message_id TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'planning',
|
||||
subtask_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE ai_subtasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
plan_id TEXT NOT NULL REFERENCES ai_plans(id),
|
||||
persona_id TEXT,
|
||||
intent TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
layer INTEGER NOT NULL DEFAULT 0,
|
||||
deps TEXT,
|
||||
branch TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE ai_conflicts (
|
||||
id TEXT PRIMARY KEY,
|
||||
plan_id TEXT NOT NULL REFERENCES ai_plans(id),
|
||||
file_path TEXT NOT NULL DEFAULT '',
|
||||
conflict_type TEXT NOT NULL DEFAULT 'file',
|
||||
subtask_a TEXT,
|
||||
subtask_b TEXT,
|
||||
diff_a TEXT,
|
||||
diff_b TEXT,
|
||||
resolution TEXT NOT NULL DEFAULT 'pending',
|
||||
resolved_by TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
resolved_at TEXT
|
||||
);
|
||||
|
||||
-- 对话版本化快照 (V37 建表)
|
||||
CREATE TABLE conversation_checkpoints (
|
||||
id TEXT PRIMARY KEY,
|
||||
conv_id TEXT NOT NULL,
|
||||
snapshot TEXT NOT NULL,
|
||||
token_total INTEGER NOT NULL,
|
||||
label TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 内部表: schema_version (version INTEGER PRIMARY KEY) — 迁移版本记录
|
||||
```
|
||||
|
||||
## 七、阶段模板
|
||||
## 七、三层模型:模板 → 工作流 → 人设
|
||||
|
||||
5 个内置阶段作为工作流模板(YAML 定义),用户可自定义。
|
||||
> 本章详细设计已迁至专项文档,详见 [docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md](./docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md)。此处仅保留摘要性定义。
|
||||
|
||||
- 💡 **想法**:市场分析 → 竞品调研 → 可行性评分
|
||||
- 📋 **需求**:AI 生成 PRD → 人工审阅 → 任务拆解
|
||||
- 💻 **编码**:AI 编码 → 代码审查 → 自动修复
|
||||
- 🧪 **测试**:运行测试 → AI 分析失败 → 回归验证
|
||||
- 🚀 **发布**:构建 → 人工确认 → 部署 → 健康检查
|
||||
### 7.1 三层定义
|
||||
|
||||
| 层 | 回答的问题 | 本质 | 生命周期 | 当前状态 |
|
||||
|----|-----------|------|---------|---------|
|
||||
| **流程模板 (Template)** | 应该做什么 | 可复用的蓝图(节点拓扑 + 建议人设 + 质量门禁) | 长期存在,跨项目复用 | ⚡ 需重新设计(原 df-stages 已移除) |
|
||||
| **工作流 (Workflow)** | 怎么执行 | 模板的运行时实例(DAG + 状态 + 数据绑定) | 随项目启停,单次执行归档 | ✅ df-workflow 核心完成 |
|
||||
| **人设 (Persona)** | 谁来做 | Agent 角色卡(system prompt + 工具集 + 行为规则) | 长期存在,跨节点复用 | ⬜ 待设计 |
|
||||
|
||||
**关键原则**:模板不绑定具体人设、工作流不感知人设、人设与模板解耦。
|
||||
|
||||
详细定义、三者关系、实例化流程、数据结构及 YAML 模板示例见 [专项设计文档](./docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md)。
|
||||
|
||||
## 八、Phase 规划
|
||||
|
||||
### Phase 1 — 引擎骨架 (4-6 周)
|
||||
- df-core + df-workflow (DAG + Node trait + Executor)
|
||||
- df-types + df-workflow (DAG + Node trait + Executor)
|
||||
- df-storage (SQLite 基础表)
|
||||
- df-execute (Shell 执行)
|
||||
- 最小前端:项目列表 + 工作流执行日志
|
||||
@@ -502,9 +749,10 @@ CREATE TABLE decisions (
|
||||
- 前端:想法池视图 + 多项目 Tab
|
||||
- 验证:想法捕捉 → AI 评估 → 立项 → 工作流执行
|
||||
|
||||
### Phase 4 — 节点丰富 + 阶段插件 (3-4 周)
|
||||
### Phase 4 — 节点丰富 + 三层模型落地 (3-4 周)
|
||||
- df-nodes (Docker/Git/Human/HTTP)
|
||||
- df-stages (5 阶段模板)
|
||||
- 流程模板系统(YAML 定义 + 模板库 + 实例化引擎)
|
||||
- 人设系统(AgentPersona 数据结构 + 内置人设 + 工具过滤)
|
||||
- 条件分支 + 断点续跑
|
||||
- 验证:跑通标准产研流程模板
|
||||
|
||||
@@ -540,3 +788,6 @@ CREATE TABLE decisions (
|
||||
8. **本地优先**:SQLite 嵌入,不依赖云服务
|
||||
9. **多模型并行**:统一抽象,按任务路由,不锁定单一模型
|
||||
10. **流式优先**:AI 输出、Shell 输出全部流式推送到前端
|
||||
11. **模板/工作流/人设三层分离**:模板是蓝图,工作流是实例,人设是角色卡。三层各自独立演化,在 AINode 执行时交汇
|
||||
12. **人设与模板解耦**:模板标注建议人设但不绑定,同一个人设可用于不同模板的同类节点
|
||||
13. **模板实例化**:模板 → 工作流实例 + 人设分配,允许实例化时按项目覆盖人设
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# DevFlow 工作规范
|
||||
|
||||
## 任务看板自动领单(最高优先级纪律)
|
||||
|
||||
1. **会话启动即读板**:任何会话开工前,先读 `docs/todo.md`——按优先级领任务:🔴 高危项 > B 批次/编号项 > C 类候选。
|
||||
2. **批收敛即回读**:每完成一个任务批(提交后),重读看板领下一项——挂机/连续工作模式下每批必回读,禁凭记忆连续开工。
|
||||
3. **领单动作**:领单后将条目改为 `[→]`(进行中),完成后改 `[x]` 并附验收命令输出(G4);无法完成的改 `[!]` 附卡点,禁静默跳过。
|
||||
4. **🔴 门禁(G2)**:看板存在未清零 🔴 条目时,只允许领 🔴 销账任务,新功能任务禁领(除非用户明示)。
|
||||
5. **提交纪律(G3/G4)**:提交描述与 diff 机械对应;一笔一事或逐件列全;分组提交按看板标注的主题分组。
|
||||
6. **汇报实测**:任何进度/完成汇报,先跑 `git log -1 --format="%h %s"` 与 `git status --short | wc -l` 实测再报,禁凭记忆报状态。
|
||||
7. **看板顶部「挂机必读」警示**:启动时若存在,视为最高优先级上下文覆盖。
|
||||
|
||||
## 自主修复纪律
|
||||
|
||||
- 明确 bug(实证在案/用户指出/唯一正确解或用户已给修法方向):直接修并报告,禁反问「要不要修」(2026-10-06 用户纠正:发现换装脚本缺陷后反问=自主性差)。
|
||||
- 边界对齐意志卡 forbidden:发版/部署/重启线上、git push、生产数据操作、对外发消息,攒齐等拍板。
|
||||
- 本机工具/脚本的行为变更属 report_after 级(干完通报、可回滚),不属确认级。
|
||||
|
||||
## 命名与代码风格(2026-10-10 用户重申)
|
||||
|
||||
- **变量/方法名:精简 + 准确**——一眼看出「是什么/做什么」且不超必要长度。忌把实现细节写进名字(`..._after_tool_run`)、忌无信息缩写(`tmp2`/`h`)、忌同概念多叫法。名字过长通常说明该拆函数或把说明下沉到注释(注释承载「为什么」,名字只承载「是什么」)。
|
||||
- **DRY · 简洁 · 扩展性 · 高内聚低耦合**(同期重申):新增能力先找共用底座(如定时消息与定时任务共用一张 `ai_outbox` + 一个调度器),不造双轨;同一事实只留**单一真值来源**(如 token 估值口径)。
|
||||
|
||||
## 其他规范
|
||||
- 架构设计文档在 `docs/02-架构设计/`;当前活跃专项:无人值守可靠性(B1-B4)+ 异步任务会话(13-17),设计见 `temp/` 与 docs。
|
||||
Generated
+2007
-98
File diff suppressed because it is too large
Load Diff
+18
@@ -16,3 +16,21 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
tracing = "0.1"
|
||||
regex = "1"
|
||||
# keyring v3 默认不带任何 platform 后端,必须显式启 feature——
|
||||
# 否则 Entry 走 noop store:set_password 静默成功不持久化,get_password 永远空。
|
||||
# Win+mac 启用原生后端;linux 用 async-persistent 变体避同步阻塞 Secret Service。
|
||||
# 由 df-storage(密钥解析下沉层) + src-tauri(build_provider_for 转发壳)共同引用。
|
||||
keyring = { version = "3", features = ["windows-native", "apple-native", "linux-native-async-persistent"] }
|
||||
# dashmap: ConvStateStore 无锁并发 HashMap(session 锁重构方案 B-Phase0)。
|
||||
# ConvState 提到 ConvStateStore(DashMap),guard.reset/ai_is_generating 不依赖 session lock,
|
||||
# 治卡死连环(AiCompleted 延迟/工具后中断/第二条进队列同源根因)。详见 workflow session-lock-redesign。
|
||||
dashmap = "6"
|
||||
|
||||
# release 增量编译:小改动重打包显著提速(2026-10-04 打包提速项);
|
||||
# ⚠️ 2026-10-09 回退:release + incremental 触发链接期匿名符号丢失
|
||||
# (anon.*.llvm.* undefined symbol,rust-lld 与 MSVC link.exe 均复现),
|
||||
# 根因是增量编译 CGU 划分与跨 crate 内联冲突。cargo check 不链接故不暴露,
|
||||
# cargo build/tauri build 链接期必挂。release 禁用 incremental 为 Rust 官方推荐。
|
||||
# [profile.release]
|
||||
# incremental = true
|
||||
|
||||
-397
@@ -1,397 +0,0 @@
|
||||
# DevFlow — 项目进展与工作交接
|
||||
|
||||
> 创建: 2026-06-10 | 最后更新: 2026-06-12 | 当前阶段: Phase 2 aichat UX 快赢(A 路线)+ 技能联想需求调研(Sprint 8)
|
||||
|
||||
---
|
||||
|
||||
## 一、项目速览
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| 定位 | AI 原生创作流程驾驶舱,从想法到创作成果的全流程管理 |
|
||||
| 技术栈 | Tauri v2 + Vue 3 + TypeScript + Pinia / Rust Workspace (13 crate) / SQLite |
|
||||
| 路径 | `E:/wk-lab/devflow/` |
|
||||
| 架构文档 | `ARCHITECTURE.md` (22,745 字) |
|
||||
| Git 状态 | 未首次 commit,代码全在 untracked |
|
||||
| AI 能力 | df-ai OpenAI 兼容 Provider + 12 工具 + Agentic Loop |
|
||||
|
||||
---
|
||||
|
||||
## 二、代码规模统计
|
||||
|
||||
### Rust 后端 (71 个 .rs 文件)
|
||||
|
||||
| Crate | 文件数 | 总行数 | 有效行 | 实现程度 |
|
||||
|-------|--------|--------|--------|---------|
|
||||
| df-core | 4 | 259 | 151 | ✅ 完整 — 错误/事件/状态枚举/ID生成 |
|
||||
| df-workflow | 7 | 489 | 333 | ✅ 核心 — DAG拓扑排序/执行器/状态机/事件总线 可用 |
|
||||
| df-storage | 4 | 294 | 217 | ✅ 部分 — SQLite连接/迁移/建表完整,**缺 CRUD 层** |
|
||||
| df-execute | 5 | 203 | 129 | ⚡ 混合 — Shell 执行器真实可用,Docker/SSH/Git 骨架 |
|
||||
| df-ideas | 6 | 421 | 278 | ⚡ 大部分 — 捕获/评估/晋升/关联图 有逻辑,评分固定值 |
|
||||
| df-task | 5 | 390 | 252 | ⚡ 大部分 — Task/Branch/Semver 可用,合并冲突 骨架 |
|
||||
| df-project | 5 | 272 | 175 | ⚡ 混合 — 数据模型/Timeline 可用,调度器 骨架 |
|
||||
| df-ai | 6 | 281 | 173 | 🔧 接口 — 类型/Provider trait 完整,无实际 LLM 调用 |
|
||||
| df-traceability | 4 | 312 | 201 | ⚡ 混合 — 决策记录/标注构建 可用,SQLite查询 骨架 |
|
||||
| df-plugin | 4 | 255 | 158 | ⚡ 混合 — Host/Builder 有逻辑,核心加载 骨架 |
|
||||
| df-evolve | 6 | 301 | 172 | ⚡ 混合 — Knowledge/PromptTemplate 可用,引擎/提取器 骨架 |
|
||||
| df-nodes | 9 | 344 | 277 | ⚡ 混合 — script_node 真实可用(接 Shell),human_node 半实现(审批响应 TODO),其余 6 种骨架 |
|
||||
| df-stages | 6 | 287 | 209 | 🔩 全骨架 — 11个阶段节点 Schema 完整,execute() 全空 |
|
||||
| **合计** | **71** | **~4,108** | **~2,625** | |
|
||||
|
||||
### 前端 (9 页面 + Store + i18n)
|
||||
|
||||
| 类别 | 文件数 | 行数 | 状态 |
|
||||
|------|--------|------|------|
|
||||
| Vue 页面 | 9 | ~3,372 | ✅ UI 全部真实 — 无空壳 |
|
||||
| Pinia Store | 4 | ~362 | ⚠️ 有 state/getter 但**无 action,View 未引用** |
|
||||
| Router | 1 | 63 | ✅ 9 条路由含动态路由 |
|
||||
| i18n | 2 | ~94 | ⚠️ 翻译完整但**未接入 main.ts** |
|
||||
| 设计系统 | 1 | 105 | ✅ CSS 变量/动画/Arco 暗色覆盖 |
|
||||
| Tauri Commands | 1 | 13 | 🔩 仅 `greet` 示例,无业务逻辑 |
|
||||
|
||||
**前端关键问题**:
|
||||
- 所有 View 组件数据**硬编码** (`ref([...])`),与 Store 完全独立
|
||||
- 所有按钮事件处理函数为**空函数** `{}`
|
||||
- i18n 插件未注册到 Vue app
|
||||
- Arco Design 组件库未实际使用(仅 CSS 变量覆盖)
|
||||
|
||||
---
|
||||
|
||||
## 三、架构分层与依赖关系
|
||||
|
||||
```
|
||||
真实可用路径(有端到端逻辑):
|
||||
df-core (类型基础)
|
||||
→ df-workflow (DAG 拓扑排序 + 执行器 + 状态机 + EventBus)
|
||||
→ df-execute/shell (tokio::process Shell 执行)
|
||||
→ df-storage (SQLite 连接 + 迁移 + 建表)
|
||||
|
||||
待打通路径(骨架就位,需填充):
|
||||
df-workflow → df-nodes (8种节点 execute() 待实现)
|
||||
df-workflow → df-stages (11种阶段节点 execute() 待实现)
|
||||
df-ai → LlmProvider 实现 (无 HTTP client)
|
||||
df-storage → 所有 crate 的 CRUD 操作
|
||||
Tauri commands → Rust 业务逻辑
|
||||
Vue Views → Pinia Store → Tauri IPC → Rust
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、全局性问题
|
||||
|
||||
| # | 问题 | 影响 | 优先级 | 状态 |
|
||||
|---|------|------|--------|------|
|
||||
| 1 | **无 CRUD 层** — df-storage 只有建表 | 所有 crate 无法持久化数据 | P0 | ✅ Sprint 2 |
|
||||
| 2 | **Tauri IPC 未建** — 只有 greet 示例 command | 前端无法调用后端 | P0 | ✅ Sprint 3(17 commands) |
|
||||
| 3 | **Store 未接入 View** — 两套独立硬编码数据 | 数据流断裂 | P0 | ✅ Sprint 4 |
|
||||
| 4 | **AI Provider 无实现** — trait 定义完整但无 HTTP client | AI 功能全部不可用 | P1 | ✅ Sprint 5 |
|
||||
| 5 | **同层节点未并行** — DagExecutor 有 TODO 注释 | 工作流执行效率 | P1 | ✅ Sprint 3(join_all) |
|
||||
| 6 | **条件表达式引擎** — 仅支持 true/false 字面量 | 条件分支不可用 | P2 | ⬜ 待办 |
|
||||
| 7 | **i18n 未注册** — 翻译文件存在但未挂载 | 多语言不生效 | P2 | ✅ Sprint 7 |
|
||||
| 8 | **未 git commit** — 代码全 untracked | 无版本基线 | P0 | ✅ Sprint 2 |
|
||||
|
||||
---
|
||||
|
||||
## 五、Phase 规划与当前进度
|
||||
|
||||
### Phase 1 — 引擎骨架 ✅ 完成
|
||||
|
||||
| 任务 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| df-core 类型系统 | ✅ 完成 | 错误/事件/状态枚举/ID生成 |
|
||||
| df-workflow DAG 引擎 | ✅ 完成 | 拓扑排序/执行器/状态机/EventBus |
|
||||
| df-storage SQLite 基础表 | ✅ 完成 | 6 张表 + 4 索引,缺 CRUD |
|
||||
| df-execute Shell 执行 | ✅ 完成 | tokio::process 实现 |
|
||||
| 最小前端:项目列表 + 工作流日志 | ✅ 完成 | Sprint 4:5 页面接 Store + 创建/运行交互 |
|
||||
| 验证:3 节点简单工作流 | ⬜ 待验证 | 后端就绪,需端到端跑通 |
|
||||
| df-storage CRUD 层 | ✅ 完成 | Sprint 2:6 Repo + impl_repo! 宏;Sprint 3 增 BranchRepo |
|
||||
| 对抗式评估系统 | ✅ 完成 | Sprint 6:AdversarialEngine + 正反方辩论 + AI 分析师 |
|
||||
| Migrations V2(关联字段 + branches 表) | ✅ 完成 | Sprint 3 |
|
||||
| Executor 同层并行 + 状态转换校验 | ✅ 完成 | Sprint 3,4 单测全绿 |
|
||||
| Tauri IPC 命令 | ✅ 完成 | Sprint 3:17 commands + 事件转发 |
|
||||
| Store 接入 View | ✅ 完成 | Sprint 4:API 层 + composable Store + 5 页面改造 |
|
||||
|
||||
### Phase 2 — AI 集成 ✅ 核心完成
|
||||
|
||||
| 任务 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| df-ai Provider 实现 | ✅ | OpenAI 兼容 HTTP client,流式 SSE 解析 |
|
||||
| AI 工具注册 + V3 迁移 | ✅ | AiToolRegistry + 12 工具(list/create/update/delete + 文件系统) |
|
||||
| AI IPC 命令 | ✅ | 17 个 AI command(对话/工具执行/审批门控) |
|
||||
| AI Chat 面板 | ✅ | 侧边栏 + 分离窗口 + 拖拽调宽 + 对话管理 |
|
||||
| Agentic Loop | ✅ | 多轮工具调用循环(max 10 轮)+ 中/高风险人工审批 |
|
||||
| AI Node 实现 | ✅ | df-nodes 接 df-ai:config 驱动 provider(base_url/api_key/model/prompt)→ OpenAICompatProvider.complete → 输出 text/model/usage;注册 "ai" 节点;未端到端实测 |
|
||||
| **AI Chat LLM 优化** | 🚧 P0 完成 | 任务 #43:P0 可靠性(超时/断连/停止生成)✅;P1 token 控制(usage采集/滑动窗口/摘要)⬜ | |
|
||||
|
||||
### Phase 3 — 想法池 + 多项目 (3-4 周) | ⬜ 未开始
|
||||
### Phase 4 — 节点丰富 + 阶段插件 (3-4 周) | ⬜ 未开始
|
||||
### Phase 5 — 体验打磨 (4-6 周) | ⬜ 未开始
|
||||
|
||||
---
|
||||
|
||||
## 六、工作迭代日志
|
||||
|
||||
### [Sprint 1] 2026-06-10 — 项目初始化
|
||||
|
||||
**工作内容**:
|
||||
- 创建项目骨架:`bun create vite` + Tauri v2 + Cargo Workspace
|
||||
- 编写完整架构文档 `ARCHITECTURE.md` (22,745 字)
|
||||
- 13 个 Rust crate 全部创建,类型系统和接口设计完整
|
||||
- 9 个 Vue 页面 UI 全部编写(~3,372 行),设计系统完善
|
||||
- 4 个 Pinia Store 定义(state + computed)
|
||||
- i18n 中英文翻译
|
||||
|
||||
**代码规模**:Rust ~4,108 行 / Vue+TS ~3,896 行 / 总计 ~8,000 行
|
||||
|
||||
**遗留问题**:
|
||||
1. 所有数据硬编码,无持久化
|
||||
2. 前后端未桥接
|
||||
3. 未做 git commit
|
||||
|
||||
**下一步建议**:
|
||||
1. 首次 git commit 建立基线
|
||||
2. 优先实现 df-storage CRUD 层(所有持久化的前置条件)
|
||||
3. 实现 Tauri IPC 命令层
|
||||
4. 将 Store 接入 View,替换硬编码数据
|
||||
5. 端到端验证:3 节点简单工作流跑通
|
||||
|
||||
---
|
||||
|
||||
### [Sprint 2] 2026-06-10/11 — 设计审查 + 对抗论证 + 阻塞项修复
|
||||
|
||||
**工作内容**:
|
||||
- 三路功能审查(架构/前端/数据模型):发现 4 条核心路径全断、缺 10 张表、状态枚举三套不一致
|
||||
- 三路对抗论证(市场/技术/需求"魔法打败魔法"):综合结论 = 方向有价值、scope 必须砍 60%
|
||||
- 修复 Phase 1 阻塞项:Shell Windows 兼容(cmd /C)、状态枚举统一(types.rs 为准)、DAG 序列化(DagDef + NodeRegistry)
|
||||
- 实现 df-storage CRUD 层(6 Repo + impl_repo! 宏 + spawn_blocking + 列名白名单)
|
||||
- ScriptNode 接入 df-execute Shell 执行器
|
||||
- 建立 docs/ 8 层文档体系(中文命名)
|
||||
- 新增设计文档:业务系统设计、对抗论证裁决报告、想法探索-对抗式评估
|
||||
|
||||
**关键决策(详见 docs/02-架构设计/对抗论证裁决报告.md)**:
|
||||
- 定位调整:"产研操作系统" → "本地优先的个人开发流程驾驶舱"
|
||||
- 砍掉:插件系统、经验进化(自动)、需求-测试追溯、多模型路由、Agent 协作
|
||||
- 降级:想法池(简化+对抗式评估)、标注(TODO 扫描器)、决策留痕(字段级)
|
||||
- Phase 1 只激活 6 个 crate,其余 7 个标记预留
|
||||
- 验证策略:先自用 3 个月再决定是否对外
|
||||
|
||||
**遗留问题**:
|
||||
1. Executor 同层并行化 + StateMachine 转换校验(任务 #8)
|
||||
2. Migrations V2 补字段(promoted_to/workflow_def_id/project_id 等)+ branches 表(任务 #9)
|
||||
3. Tauri IPC / 前端 Store 对接尚未开始
|
||||
4. Sprint 2 的代码变更未 commit
|
||||
|
||||
**下一步**:
|
||||
1. commit Sprint 2 变更
|
||||
2. Migrations V2 + Executor 并行化
|
||||
3. Tauri IPC(AppState + project/task/workflow commands)
|
||||
4. 前端类型对齐 + Store 接 View
|
||||
|
||||
### [Sprint 3] 2026-06-11 — Migrations V2 + Executor 并行化 + Tauri IPC(三代理并行)
|
||||
|
||||
**工作内容**(三个子代理并行,目录互不重叠):
|
||||
- **Migrations V2**(df-storage/df-core):版本推进到 2 — ideas 补 promoted_to/ai_analysis/scores、tasks 补 workflow_def_id/base_branch、workflow_executions 补 project_id/task_id、新建 branches 表(含 2 索引);BranchRecord/BranchRepo/BranchStatus 枚举同步落地,ALLOWED_COLUMNS 白名单 +8 列
|
||||
- **Executor 并行化**(df-workflow):同层节点改 futures::join_all 并发,三阶段模式(串行准备→并发执行→串行收尾)规避借用冲突;StateMachine 增加转换校验(仅 Pending→Running、Running→Completed/Failed 合法,set_xxx 改返回 Result);4 个单测全绿(含并行耗时断言 <180ms、失败中止下游层)
|
||||
- **Tauri IPC**(src-tauri):AppState(db + 6 Repo + EventBus + NodeRegistry)+ 17 个 command(project/task/idea CRUD + run_workflow/list/get executions);run_workflow 先落库 status=running 立即返回 execution_id,事件经 app.emit("workflow-event") 转发前端,完成后回写状态。注意:NodeRegistry 不能用 default()(script 工厂是 unimplemented! 占位),改为 new() + 手动注册真实 ScriptNode
|
||||
|
||||
**验证**:cargo check 通过(仅 1 个 dead_code 警告:releases/node_executions Repo 暂无 command 使用);cargo test -p df-workflow 4 passed
|
||||
|
||||
**遗留问题**:
|
||||
1. executor 失败路径只发 NodeFailed 不发 WorkflowFailed,目前由 run_workflow 在 IPC 层补发——后续应下沉到 executor
|
||||
2. 前端 Store/View 仍硬编码,未调用任何 command
|
||||
3. NodeRegistry::default() 的 unimplemented! 占位是隐患,应改为注册真实节点或移除
|
||||
|
||||
**下一步**:
|
||||
1. 前端类型对齐(id: string、状态枚举对齐 types.rs)+ src/api/ 封装层
|
||||
2. Store 加 action 接 API,核心 4 页面(Dashboard/Projects/ProjectDetail/Tasks)接 Store
|
||||
3. 端到端验证:tauri dev 创建项目 → 运行 3 节点工作流 → 日志实时展示
|
||||
|
||||
### [Sprint 4] 2026-06-11 — 前端交互功能(API 层 + Store + 5 页面)
|
||||
|
||||
**工作内容**:
|
||||
- **API 层**:新建 `src/api/` 目录 — types.ts(与 Rust Record 严格对齐)+ project/task/idea/workflow 四个 invoke 封装 + 事件监听(listen workflow-event)
|
||||
- **Store 重写**:`stores/project.ts` 改为 composable 模式(reactive 全局状态 + 12 个 actions + computed stats),不依赖 Pinia
|
||||
- **Projects.vue**:接 Store 真数据 + 创建项目模态框(名称/描述)+ 卡片点击跳详情页 + status 中文映射
|
||||
- **ProjectDetail.vue**:路由参数加载项目 + 真实任务列表 + **3 节点 Shell DAG 工作流运行**(环境检查→运行测试→构建产物)+ 实时事件日志 + 创建任务模态框 + onUnmounted 清理监听
|
||||
- **Tasks.vue**:项目/状态双筛选联动 + 创建任务模态框(选择项目+标题)+ 状态/优先级中文映射
|
||||
- **Ideas.vue**:捕捉想法模态框 + 选中详情(tags JSON 解析、scores 展示)+ 删除功能
|
||||
- **Dashboard.vue**:统计卡从 Store computed 计算 + Active Projects/Idea Pool 接真数据 + quickCapture 跳 Ideas
|
||||
|
||||
**代码变更**:13 files, +1059/-486
|
||||
|
||||
**遗留问题**:
|
||||
1. 端到端验证未完成:需 tauri dev 实际创建项目→运行工作流→确认日志展示
|
||||
2. 无数据时空状态展示较简陋
|
||||
3. ~~Settings/Knowledge/Decisions 三个页面仍为硬编码 demo~~ ✅ Sprint 7 已解决(三 view 接 stores/{knowledge,settings}.ts,无 ref([ 硬编码)
|
||||
|
||||
**下一步**:
|
||||
1. 端到端验证:tauri dev → 创建项目 → 运行 3 节点工作流 → 日志实时展示
|
||||
2. 修复验证中发现的问题
|
||||
3. 考虑 Phase 2 规划
|
||||
|
||||
### [Sprint 5] 2026-06-11 — AI Chat 全功能(Provider + 工具执行 + Agentic Loop)
|
||||
|
||||
**工作内容**:
|
||||
- **df-ai Provider**:OpenAI 兼容 HTTP client,SSE 流式解析,ChatMessage/CompletionRequest 类型系统
|
||||
- **V3 存储迁移**:ai_providers、ai_conversations、ai_tool_executions 三张表 + 对应 Repo
|
||||
- **AI IPC 命令层**:17 个 command — 对话发送/流式推送、工具执行/审批门控(低风险自动/中高风险人工)、对话管理(CRUD + 切换)
|
||||
- **AI 工具注册**:12 个工具 — list_projects/tasks/ideas(只读)、create_project/task/idea(创建)、update_project(更新)、delete_project(删除)、read_file/list_directory/write_file(文件系统)
|
||||
- **Agentic Loop**:多轮工具调用循环(max 10 轮),流式输出→检测工具调用→执行→结果回传→继续,中高风险暂停等审批后恢复
|
||||
- **AI Chat 前端**:侧边栏面板 + 拖拽调宽 + 分离独立窗口 + 对话列表管理 + 工具审批弹窗 + AgentRound 多轮消息
|
||||
- **Tauri Capabilities**:ai-detached 窗口权限配置
|
||||
|
||||
**代码变更**:20+ files,新增 ~2000 行
|
||||
|
||||
**遗留问题**:
|
||||
1. ~~空白屏问题:Vite 启动正常(122s)、HTML 200,但 webview 显示空白~~ ✅ Sprint 7 已修复(index.html 主题防闪烁脚本 + 启动计时埋点 __APP_T0 + body 背景色)
|
||||
2. AI Node 未实现 — 目前只有骨架,需接入 df-ai 的 LlmProvider(任务 #44)
|
||||
3. AI Chat LLM 调用逻辑待优化:长对话可靠性、大任务执行稳定性、token 消耗控制(任务 #43)— **P0 可靠性已在 Sprint 6 完成,P1 token 控制待办**
|
||||
|
||||
**下一步**:
|
||||
1. 排查并修复空白屏问题
|
||||
2. 实际验证 AI Chat 端到端(选 Provider → 发消息 → 工具调用 → 审批)
|
||||
3. HumanNode + Settings 页面
|
||||
4. AI Chat LLM 调用逻辑优化(任务 #43)
|
||||
5. AI Node 实现(任务 #44)
|
||||
|
||||
### [Sprint 6] 2026-06-11 — AI Chat 调用可靠性 P0 + 前端类型修复
|
||||
|
||||
**工作内容**:
|
||||
|
||||
**A. AI Chat 调用可靠性 P0(df-ai + src-tauri,跨前后端 6 文件)** — 三路代理核对(调用链/上下文-token/前端)后定位可靠性死锁类问题,本轮按"先保命"只做 P0 五项(P1 token 控制设计已定,留待下轮):
|
||||
- **P0-1 连接超时**:`openai_compat.rs` Client 加 `connect_timeout(30s)` 防连接阶段无限 hang;不设总 timeout(避免 reqwest 总时长限制误砍流式长生成任务)
|
||||
- **P0-2 流式 idle timeout**:`stream_llm` 用 `tokio::time::timeout(120s)` 包 `stream.next()`,防"连上后中途静默"无限 hang
|
||||
- **P0-3 断连检测丢弃残缺**:维护 `finished_received` 标志,流尽未收到 finished 信号 → emit AiError + **丢弃残缺响应不当完整入库**(防脏历史污染)
|
||||
- **P0-4 停止生成**:`AiSession.stop_flag: Arc<AtomicBool>` + 新 `ai_chat_stop` command + 前端停止按钮(streaming 时切换为珊瑚红方块按钮);run_agentic_loop/stream_llm 多检查点响应,停止后保留已生成文本
|
||||
- **P0-5 切对话防护**:`ai_conversation_switch` 生成中拒绝切换(防 active_conversation_id 被改导致旧 loop 的 save_conversation 串台写库)
|
||||
|
||||
**B. 前端既有类型错误修复(14 处,让 `bun run build` 通过)**:
|
||||
- `types.ts`:删重复的 `WorkflowEventPayload`(declaration merging 冲突)
|
||||
- `stores/project.ts`:补 `invoke` import + `payload.event.data` 类型断言 + **return 包 `reactive()` 自动解包 computed**(一处解决 Dashboard/ProjectDetail 的 store.stats/pendingApproval 解包,消费端零改动)
|
||||
- `Ideas/Tasks/ProjectDetail`:去掉 `parseInt(number)` 误用 → `new Date(timestamp)`
|
||||
- `ProjectDetail.vue`:删 unused `nextTick` import
|
||||
- `AiChat.vue`:删死代码 `TOOL_STATUS_MAP` + `toolStatusLabel`
|
||||
|
||||
**C. AI Chat 代码审查修复(9 处,ai.rs + openai_compat.rs)** — 对 P0 代码做 /review 审查后修复,cargo check ✓:
|
||||
- 🔴① `finish_reason="length"`(max_tokens 截断)纳入 finished — 修「大任务输出撞 4096 上限被误判断连、丢弃整段响应」(与 ④ 配合是本轮关键)
|
||||
- 🔴② 路径校验最小加固:正斜杠→反斜杠规范化 + 拒 `..` 遍历 + 扩 `.aws`/`.gnupg`(根治 workspace 白名单 + canonicalize 待定边界后再做)
|
||||
- 🔴③ tool_calls 按 index 排序(assistant 消息 line506 + tool_result line709 两处),消 HashMap 迭代乱序
|
||||
- 🔴④ max_tokens 4096→8192,贴合大任务
|
||||
- 🟡⑤ `ai_chat_stop` 在审批等待态清 pending_approvals + 复位 generating(原 stop_flag 无人读取,停止按钮表面无反应、会话卡 generating=true)
|
||||
- ⚪⑨ 4 个 i18n 小函数(language_instruction/capability_section/behavior_rules/context_label)合并为 `system_prompt_parts`
|
||||
- ⚪⑩ `extract_title` 单次 chars 遍历
|
||||
- ⚪⑪ reqwest builder 降级加 `warn!`
|
||||
|
||||
**代码变更**:df-ai/openai_compat.rs、src-tauri/{commands/ai.rs, lib.rs}、前端 7 文件(+ C 轮 review:ai.rs、openai_compat.rs)
|
||||
|
||||
**验证**:cargo check -p devflow ✓(仅既有 warning)、vue-tsc --noEmit ✓、bun run build ✓(built in 2.47s)
|
||||
|
||||
**遗留问题 / 下一步**:
|
||||
1. **P1 token 控制(任务 #43 续)**:流式 usage 采集(`stream_options:{include_usage:true}` + SSE usage 字段)+ token 预算自适应窗口(chars≈/3.5 启发式估算)+ 历史摘要压缩(早期摘要 + 近期全文)—— 设计已定,未实施。**⚠️ usage 采集 2026-06-11 用户叫停暂缓(结构层写一半已回退),P1 从 token 估算/裁剪/摘要续**
|
||||
2. **实测验证**:`cargo tauri dev` 跑起来验证停止按钮/超时/断连真实生效(本轮仅编译 + 类型验证)
|
||||
3. 停止生成边界:LLM 正常输出时停止立即生效(<1s);idle(无输出)时最多等 120s idle timeout —— P2 可用 `tokio::sync::Notify` 优化为绝对即时
|
||||
4. ~~Sprint 5 遗留的空白屏问题仍待排查~~ ✅ Sprint 7 已修复(用户实测确认)
|
||||
|
||||
### [Sprint 7] 2026-06-11 — 空白屏修复 + i18n 接入 + 剩余页面接 Store
|
||||
|
||||
**工作内容**:
|
||||
- **空白屏修复**(前端):`index.html` 加主题防闪烁脚本(localStorage `df-theme` 渲染前置 `data-theme`)+ 启动计时埋点 `window.__APP_T0` + body 背景 `#0c0e1a`;`main.ts` 加 `.use(i18n)` 并打印 mount 耗时日志。用户实测确认空白屏消失
|
||||
- **i18n 接入**:`main.ts` 注册 `i18n` 插件;`i18n/index.ts` 完整(legacy:false / globalInjection / zh-CN 默认 + en fallback / localStorage `df-language` 持久化);App.vue 导航与各 view 改 `$t()`
|
||||
- **剩余 3 页面接 Store**:`Knowledge.vue`/`Settings.vue`/`Decisions` 去硬编码 `ref([...])`,对接新建 `stores/{knowledge,settings,ai}.ts`
|
||||
- **新路由**:`/` redirect → `/ai-home`,新增 `AiHome.vue`(默认落地页)/`AiDetached.vue`(分离窗口);路由 9→11 条
|
||||
- **df-nodes 实现度修正**:核对发现 `script_node.rs` 早为真实实现(接 df_execute Shell + 非零退出码判断),`human_node.rs` 半实现(发 `HumanApprovalRequest` 事件 + 超时循环,但审批响应 TODO 直接返回"同意")—— 非文档原记"全骨架"
|
||||
|
||||
**遗留问题 / 下一步**:
|
||||
1. **P1 token 控制(任务 #43 续)**:仍待办(usage 采集用户叫停,从 token 估算/裁剪/摘要续)
|
||||
2. **条件表达式引擎**(conditions.rs 仍 true/false 字面量)— P2 未动
|
||||
3. ~~AI Node 实现(任务 #44)~~ ✅ 本轮已实现:`ai_node.rs` 接 df-ai `OpenAICompatProvider`,从 `ctx.config` 读 base_url/api_key/model/prompt/system_prompt/temperature/max_tokens 调 `complete()`,prompt 支持上游输入优先;`df-nodes/Cargo.toml` 加 df-ai 依赖;`state.rs` build_registry 注册 "ai"。cargo check ✓(未端到端实测真实 LLM 调用)
|
||||
4. **df-stages** 11 个 execute() 全空骨架 — Phase 4
|
||||
5. **HumanNode 审批响应闭环**:目前直接返回"同意",需前端审批弹窗回写结果
|
||||
|
||||
### [Sprint 8] 2026-06-12 — Anthropic 协议 + 切对话不中断 + 技能联想需求调研
|
||||
|
||||
**工作内容**:
|
||||
|
||||
**A. aichat 升级 A 路线(UX 快赢,已实现并验证)** — 详见 memory `aichat-roadmap-ab-split`:
|
||||
- **Anthropic 协议 Provider**:新增 `crates/df-ai/src/anthropic_compat.rs`,实现 `LlmProvider` trait 覆盖 Anthropic Messages API(`x-api-key` + `anthropic-version`、顶层 `system`、必填 `max_tokens`、SSE event 类型解析、`content_block` tool_use)。按 `provider_type` 路由分发(`run_agentic_loop` / `ai_node`)。支持 GLM 订阅端点 `https://open.bigmodel.cn/api/anthropic` — **用户实测对话流式 OK**
|
||||
- **切对话不打断生成**:后端 `AiChatEvent` 全变体加 `conversation_id`,agentic loop spawn 前快照 conv_id,所有 emit 填充;`ai_conversation_switch` 生成中只读返回;前端 `stores/ai.ts` 按 conversation_id 路由(后台对话事件不污染当前视图,完成/错误时刷新侧边栏)
|
||||
- **审查项落地**:删全屏 Tool Approval Modal(保留行内卡片审批)、错误友好化(`friendlyError` 正则映射 404/401/timeout/network → 中文提示 + `isError` 红色气泡)、智能滚动(`isNearBottom` < 80px 阈值)、provider bar 点击切换、未配 provider 空状态引导、删死 class
|
||||
- **B 路线(规划式多智能体)单独立项**:填 `coordinator.rs` + `conditions.rs` 空壳,详见 memory `aichat-decision-capability`
|
||||
|
||||
**B. 技能联想功能需求(本轮重点,未实现,需求阶段)**:
|
||||
- **需求**:aichat 输入框输入 `/` → 联想列出本机 Claude 技能(SKILL.md),按输入过滤,选中插入 `/name`;发送时后端读 SKILL.md 全文注入 system prompt / context
|
||||
- **首批数据源 = Claude 3 类**(SKILL.md frontmatter 统一 `name` / `description` / `user_invocable` / `metadata.argument-hint` / `triggers[]`,只取 `user_invocable: true`):
|
||||
1. Claude skills:`~/.claude/skills/*/SKILL.md`(23 个:sleep / mission-control / idea / cpa 等)
|
||||
2. Claude commands:`~/.claude/commands/*.md`(1 个:idea)
|
||||
3. Claude plugins:`~/.claude/plugins/marketplaces/**/skills/*/SKILL.md`(caveman 全家、官方 frontend-design / skill-creator / mcp-server-dev、wuming prompt-optimizer;`cache/` 与 `marketplaces/` 重复需按 path 去重)
|
||||
- 加项目级 `.claude/skills` + `.claude/commands`(DevFlow 暂无)
|
||||
- **实现要点**:① 后端新 IPC `list_skills()` 扫 Claude 3 类来源解析 frontmatter + 按 path 去重,返回 `[{name, description, argument_hint, triggers, source}]`;② 前端输入框 `/` 触发联想浮层(名称 + description);③ `/name` 发送 → 后端读 SKILL.md 注入 context;④ 跨平台 home 路径(dirs crate)
|
||||
|
||||
**C. 多工具 skill 体系调研结论(仅记录,后续按需扩展)**:
|
||||
| 工具 | 本机状态 | skill 机制 | 是否纳入 |
|
||||
|------|---------|-----------|---------|
|
||||
| Claude Code | ✅ 已装 | SKILL.md(skills + commands + plugins 三类) | ✅ 首批数据源 |
|
||||
| Codex (OpenAI) | ✅ 已装 | SKILL.md(`~/.codex/vendor_imports/skills/skills/.curated/*/SKILL.md`,39 个:figma / notion / playwright / security / vercel·netlify·render-deploy / latex 等;frontmatter 与 Claude 一致,可统一解析) | ⏳ 后续可扩展 |
|
||||
| openclaw | ✅ 已装 | 多 agent gateway(`~/.openclaw/openclaw.json` 注册 8 agent 绑各 workspace + glm-5,经飞书 channel binding),非 SKILL.md;`agents/` 实体目录暂空 | ❌ 属 agent 选择层,非 skill 联想,暂不纳入 |
|
||||
| opencode | ❌ 未装 | AGENTS.md + custom agents + slash commands(机制待确认) | ⏳ 待装后调研 |
|
||||
|
||||
**代码变更**:A 路线 — `crates/df-ai/src/{anthropic_compat.rs, lib.rs}`、`src-tauri/src/commands/ai.rs`、`crates/df-nodes/src/ai_node.rs`、`src/views/Settings.vue`、`src/stores/ai.ts`、`src/components/AiChat.vue`、`src/api/types.ts`
|
||||
|
||||
**遗留问题 / 下一步**:
|
||||
1. **技能联想(B)未实现** — 需求已记,首批聚焦 Claude 3 类,等排期(工作量中:后端扫文件 + IPC、前端联想浮层)
|
||||
2. **A 路线剩余场景实测**:切对话不中断(场景 2/3)、错误友好化(场景 4)待用户实测
|
||||
3. **B 路线(规划式能力)**:coordinator / conditions 空壳,单独立项
|
||||
|
||||
<!--
|
||||
|
||||
### [Sprint N] YYYY-MM-DD — 简述
|
||||
|
||||
**工作内容**:
|
||||
- 具体做了什么
|
||||
|
||||
**代码变更**:
|
||||
- 修改了哪些文件/Crate
|
||||
|
||||
**遗留问题**:
|
||||
- 1. ...
|
||||
|
||||
**下一步**:
|
||||
- 1. ...
|
||||
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
## 七、开发约定
|
||||
|
||||
### 构建命令
|
||||
|
||||
```bash
|
||||
# Rust 检查
|
||||
cd E:/wk-lab/devflow && cargo check
|
||||
|
||||
# 前端开发
|
||||
cd E:/wk-lab/devflow && bun run dev
|
||||
|
||||
# Tauri 开发(必须用这个,不能 cargo build)
|
||||
cd E:/wk-lab/devflow && bun run tauri dev
|
||||
|
||||
# Tauri 构建
|
||||
cd E:/wk-lab/devflow && bun run tauri build
|
||||
```
|
||||
|
||||
### 关键设计决策
|
||||
|
||||
1. **引擎不绑定业务** — Workflow Engine 只做 DAG 执行,阶段是插件
|
||||
2. **无 panic** — 所有占位代码返回空/默认值,不使用 `todo!`/`unimplemented!`
|
||||
3. **本地优先** — SQLite 嵌入,不依赖云服务
|
||||
4. **想法第一公民** — 想法池独立于项目
|
||||
5. **多任务/分支并行** — 同一项目多任务各绑 Git 分支
|
||||
|
||||
### 复用资源
|
||||
|
||||
| 资源 | 路径 | 复用点 |
|
||||
|------|------|--------|
|
||||
| u-desk-rust Tauri 经验 | `E:/wk-lab/u-desk-rust/` | Workspace + crate 拆分 + Tauri v2 |
|
||||
| Rust proxy 工具链 | `E:/wk-oth/rust-work/` | 连接管理,内嵌调用 |
|
||||
| Tauri 开发铁律 | Memory: `feedback_tauri_dev_rules` | 必须用 `cargo tauri dev/build` |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,minimum-scale=1.0,user-scalable=no" />
|
||||
<title>DevFlow Mini</title>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.documentElement.style.fontSize = window.innerWidth / 20 + 'px'
|
||||
})
|
||||
</script>
|
||||
<link rel="stylesheet" href="<%= BASE_URL %>static/index.<%= VUE_APP_INDEX_CSS_HASH %>.css" />
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>本应用需 JavaScript 启用。</strong>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+13927
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "df-miniapp",
|
||||
"version": "0.1.0",
|
||||
"description": "DevFlow 微信小程序(uni-app Vue3)—— 远程 AI Chat 操作桌面端",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev:h5": "uni",
|
||||
"dev:mp-weixin": "uni -p mp-weixin",
|
||||
"build:h5": "uni build",
|
||||
"build:mp-weixin": "uni build -p mp-weixin && node scripts/fix-mp-build.cjs",
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dcloudio/uni-app": "3.0.0-alpha-4080720251125001",
|
||||
"@dcloudio/uni-components": "3.0.0-alpha-4080720251125001",
|
||||
"@dcloudio/uni-h5": "3.0.0-alpha-4080720251125001",
|
||||
"@dcloudio/uni-mp-weixin": "3.0.0-alpha-4080720251125001",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^18.0.5",
|
||||
"vue": "^3.4.0",
|
||||
"vue-i18n": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dcloudio/types": "^3.4.0",
|
||||
"@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001",
|
||||
"@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001",
|
||||
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001",
|
||||
"@swc/helpers": "^0.5.23",
|
||||
"@types/node": "^20.0.0",
|
||||
"@vue/runtime-core": "^3.4.0",
|
||||
"@vue/tsconfig": "^0.5.0",
|
||||
"sass": "^1.70.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.2.8",
|
||||
"vue-tsc": "^2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! post-build 修复脚本:处理微信小程序构建产物的兼容性问题
|
||||
//!
|
||||
//! 背景:marked v18+ 含 lookbehind 检测 `new RegExp("(?<=1)(?<!1)")`,
|
||||
//! 微信开发者工具转译器静态检测到 `(?<=` 字符串 → 误判需 @swc/helpers/_wrap_reg_exp
|
||||
//! helper → 注入 require → 产物无此模块 → 小程序白屏。
|
||||
//!
|
||||
//! 微信基础库运行时不支持 lookbehind,marked 本就走降级路径(Te=false)。
|
||||
//! 本脚本把产物里的 lookbehind 检测替换为 Te=false,绕过微信工具静态误判,
|
||||
//! 同时保留 marked 完整功能(运行时本就降级)。
|
||||
//!
|
||||
//! 用法:build 后运行 `node scripts/fix-mp-build.cjs`
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const BUILD_DIR = path.join(__dirname, '..', 'dist', 'build', 'mp-weixin')
|
||||
const VENDOR = path.join(BUILD_DIR, 'common', 'vendor.js')
|
||||
|
||||
function fixLookbehind() {
|
||||
if (!fs.existsSync(VENDOR)) {
|
||||
console.warn('[fix-mp-build] vendor.js 不存在,跳过:', VENDOR)
|
||||
return
|
||||
}
|
||||
let src = fs.readFileSync(VENDOR, 'utf8')
|
||||
const before = src
|
||||
// marked v18 lookbehind 检测 `var Te=((l="")=>{try{return!!new RegExp("(?<=1)(?<!1)"+l)}catch{return!1}})()`
|
||||
// 替换为 `var Te=false`(强制降级,微信基础库不支持 lookbehind)
|
||||
src = src.replace(
|
||||
/var Te=\(\([^)]*\)=>\{try\{return!!new RegExp\("\(\?<=1\)\(\?<!1\)"[^}]*\}\}\)\)\(\),?/,
|
||||
'var Te=false,'
|
||||
)
|
||||
if (src === before) {
|
||||
// 未命中(压缩名可能不是 Te),退而按字符串替换 lookbehind 检测
|
||||
src = src.replace(/new RegExp\("\(\?<=1\)\(\?<!1\)"[^)]*\)/g, 'false')
|
||||
}
|
||||
// 关键:marked 条件分支里还有 `(?<!`)` lookbehind 字面量(Te 为 true 时的分支)。
|
||||
// 微信工具静态检测到任何 `(?<=` / `(?<!` 字符都会注入 swc helper 误判。
|
||||
// 把所有 lookbehind 字面量替换为非 lookbehind 等价形式。
|
||||
// (?:x) 合法不 throw;但该分支仅 Te=true 时用,运行时 Te=false 恒走降级分支,
|
||||
// 替换后行为不变,同时消除微信工具的静态触发器。
|
||||
src = src.replace(/\(\?<!`\)/g, '(?:x)')
|
||||
src = src.replace(/\(\?<=1\)\(\?<!1\)/g, '(?:1)')
|
||||
// 命名捕获组(ES2018)也触发 _wrap_reg_exp。marked v18 用 `(?<a>`+)[^`]+\k<a>` 匹配代码围栏。
|
||||
// 转为编号捕获组 `(`+)\1`(ES5 兼容,无命名捕获组,行为等价)。
|
||||
// 通用: (?<name>...) → (...), \k<name> → \N(按捕获组序编号,这里只有 a/b 两组)
|
||||
src = src.replace(/\(\?<a>\`\+\)\[^\`\]\+\\k<a>/g, '(`+)\\1')
|
||||
src = src.replace(/\(\?<b>\`\+\)\[^\`\]\+\\k<b>/g, '(`+)\\1')
|
||||
// 兜底:任何残留命名捕获组 (?<name> 或 \k<name> 转为无命名
|
||||
src = src.replace(/\(\?<[a-z][a-z0-9_]*>/g, '(')
|
||||
src = src.replace(/\\k<[a-z]>/g, '\\1')
|
||||
if (src !== before) {
|
||||
fs.writeFileSync(VENDOR, src)
|
||||
console.log('[fix-mp-build] vendor.js lookbehind 已全部替换(检测+条件分支)')
|
||||
} else {
|
||||
console.warn('[fix-mp-build] 未找到 lookbehind(可能已处理或格式不同)')
|
||||
}
|
||||
}
|
||||
|
||||
// 清理手动补的 @swc/helpers(不再需要,避免污染)
|
||||
function cleanupSwcHelpers() {
|
||||
const swcDir = path.join(BUILD_DIR, 'common', '@swc', 'helpers')
|
||||
if (fs.existsSync(swcDir)) {
|
||||
fs.rmSync(swcDir, { recursive: true, force: true })
|
||||
console.log('[fix-mp-build] 已清理手动补的 @swc/helpers')
|
||||
}
|
||||
}
|
||||
|
||||
fixLookbehind()
|
||||
cleanupSwcHelpers()
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
|
||||
import { useAiChat } from '@/composables/useAiChat'
|
||||
|
||||
// 应用启动:连接 relay。useAiChat 单例管理 WS 生命周期 + 状态。
|
||||
const aiChat = useAiChat()
|
||||
|
||||
onLaunch(() => {
|
||||
console.log('[DevFlow Mini] 应用启动')
|
||||
aiChat.connect()
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
// 切回前台:重启心跳/重连 + 恢复流式看门狗(P1-9/P1-10)
|
||||
aiChat.resumeBackground()
|
||||
})
|
||||
|
||||
onHide(() => {
|
||||
// 切后台:停心跳 + 流式看门狗,防后台定时器节流触发写状态(P1-9/P1-10)
|
||||
console.log('[DevFlow Mini] 应用进入后台')
|
||||
aiChat.pauseBackground()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
page {
|
||||
background-color: $uni-bg-color;
|
||||
color: $uni-text-color;
|
||||
font-size: $uni-font-size-base;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,601 @@
|
||||
/**
|
||||
* u-relay WS 客户端(uni-app 跨端适配)
|
||||
*
|
||||
* 协议(对齐 E:/wk-lab/u-relay/src/relay.rs handle_connection):
|
||||
* 1. 建立 WS 连接到 `/ws/commander`
|
||||
* 2. 首帧发 Hello{kind:'commander', channel_id, token} 身份宣告(relay 10s 超时)
|
||||
* 3. 校验通过 → relay 回 hello_ack;失败 relay 发 error 帧 + Close
|
||||
* (错误码:handshake_failed/kind_mismatch/invalid_channel_id/server_misconfigured/auth_failed)
|
||||
*
|
||||
* 跨端适配:
|
||||
* - 微信小程序:uni.connectSocket(微信原生 API,非浏览器 WebSocket)
|
||||
* - H5:uni.connectSocket 封装浏览器 WebSocket
|
||||
* - 统一经 uni API 封装,业务层不直接 new WebSocket
|
||||
*
|
||||
* 连接生命周期(P0/P1 审计后重构,根本性,非补丁):
|
||||
* - 所有"建连"经 reconnectNow() 幂等入口:clear 退避 timer + cleanup + openSocket
|
||||
* connect()/resumeIfDisconnected()/scheduleReconnect 回调全部经此,杜绝双连竞态 + socket 泄漏
|
||||
* - openSocket() 幂等:首行 destroySocket 残留 socket,保证 this.socket 引用唯一
|
||||
* - destroySocket(task):显式注销 onOpen/onMessage/onClose/onError(覆盖空函数,微信无 offXxx)
|
||||
* + close,防旧 socket 闭包回调污染新连接(单例 this 句柄被多 socket 捕获)
|
||||
* - cleanup() 彻底化:destroySocket + 停心跳 + 清所有 timer + 清 pendingQueue
|
||||
* 清队列防跨连接/跨会话陈旧命令补发(旧命令上下文已失效)
|
||||
* - 超时三重兜底:连接超时(onOpen 未到)/握手超时(hello_ack 丢失,>relay 10s)/
|
||||
* onError 兜底(微信 socket onError 不保证 onClose 跟随,DNS/TLS/域名未加白场景)
|
||||
* - 心跳活性检测:lastInboundAt + 2x 间隔判定半开死连接(NAT 超时/网络切换)
|
||||
* - pendingQueue 上限 + TTL:防握手永卡时无限堆积内存泄漏
|
||||
*
|
||||
* 重连:指数退避(baseDelay * 2^min(n,6),封顶 maxDelay,带随机抖动)
|
||||
* 心跳:固定间隔发 ControlMessage{control_kind:'ping'},防空闲断开 + 在线状态
|
||||
* JSON 透传:入站解析为 BroadcastMessage,payload 是 AiChatEvent JSON
|
||||
* 出站直接发 Command JSON(relay 自动包 payload,见 relay.rs:332-365)
|
||||
*/
|
||||
|
||||
import { getConfig } from '@/config'
|
||||
import type { BroadcastMessage, Hello, MiniCommand, ControlMessage } from '@/types/relay'
|
||||
|
||||
/** 调试日志门控(P1 走查:生产 console.log 过微信 bridge,热路径成本)。dev=true / build=false。 */
|
||||
const DEBUG = import.meta.env.DEV
|
||||
|
||||
/** WS 连接状态 */
|
||||
export type WsStatus = 'disconnected' | 'connecting' | 'connected' | 'handshaking' | 'reconnecting'
|
||||
|
||||
/** 入站事件回调(Event/Control 消息分派) */
|
||||
export interface WsHandlers {
|
||||
/** 收到 Event 消息(payload 已解为 AiChatEvent JSON,业务层按 type discriminator 处理) */
|
||||
onEvent: (msg: BroadcastMessage) => void
|
||||
/** 收到 Control 消息(心跳响应 / 在线状态等) */
|
||||
onControl: (msg: ControlMessage) => void
|
||||
/** 状态变更(连接 / 断开 / 重连中) */
|
||||
onStatus: (status: WsStatus, detail?: string) => void
|
||||
}
|
||||
|
||||
/** pendingQueue 带入队时间戳,flush 时按 TTL 丢弃陈旧命令 */
|
||||
interface QueuedCmd {
|
||||
cmd: MiniCommand
|
||||
ts: number
|
||||
}
|
||||
|
||||
/** 握手超时(>relay.rs:321 服务端 10s,留余量) */
|
||||
const HANDSHAKE_TIMEOUT_MS = 12000
|
||||
/** 连接超时(openSocket 后 onOpen 未到则重连;治 onError 不触发 onClose 场景) */
|
||||
const CONNECT_TIMEOUT_MS = 8000
|
||||
/** pendingQueue 上限(防握手永卡时无限堆积内存泄漏) */
|
||||
const PENDING_MAX = 50
|
||||
/** pendingQueue 单条 TTL(flush 时超时丢弃,防跨连接陈旧命令补发) */
|
||||
const PENDING_TTL_MS = 60000
|
||||
/** 心跳活性判定因子:超过 N 倍心跳间隔无入站消息判半开死连接 */
|
||||
const HEARTBEAT_DEAD_FACTOR = 2
|
||||
/** 最大重连次数(永久性故障如 relayHost 错/中继下线,停止无限退避,提示用户检查) */
|
||||
const MAX_RECONNECT_ATTEMPTS = 20
|
||||
|
||||
/**
|
||||
* WS 客户端单例(miniapp 全局一个连接)
|
||||
*
|
||||
* 设计:单 socket + 单 handlers 回调集合(composable 订阅,业务层不直接持 socket)。
|
||||
* 握手/心跳/重连机制内部封装,业务层仅关心 send(Command) + onEvent。
|
||||
*/
|
||||
class WsClient {
|
||||
private socket: UniApp.SocketTask | null = null
|
||||
private handlers: WsHandlers | null = null
|
||||
private status: WsStatus = 'disconnected'
|
||||
/** 握手是否已完成(收到首个广播或握手成功信号) */
|
||||
private handshaked = false
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
/** 握手超时定时器(handleOpen 发 Hello 后启,收 hello_ack 后清) */
|
||||
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
/** 连接超时定时器(openSocket 启,onOpen 到达后清) */
|
||||
private connectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private reconnectAttempts = 0
|
||||
/** 主动关闭标志(用户调用 disconnect 时置 true,不再触发重连) */
|
||||
private manualClose = false
|
||||
/** 可恢复标志:达上限 disconnected 时置 true,允许 connect/resumeIfDisconnected 重置重连 */
|
||||
private recoverable = false
|
||||
/**
|
||||
* 握手完成前积压的命令队列(send 在 !handshaked 时不丢弃,入队等 hello_ack 后补发)。
|
||||
* 治「握手成功到首帧竞态丢命令」:握手刚翻 true 时 relay 设备路由表可能未就绪,
|
||||
* 业务层快速重发会被 send 守卫放行后静默丢失;入队保证握手稳定后顺序补发。
|
||||
* 带时间戳 + 上限 + TTL,防握手永卡时无限堆积(见 PENDING_MAX/PENDING_TTL_MS)。
|
||||
*/
|
||||
private pendingQueue: QueuedCmd[] = []
|
||||
/** 最后入站消息时间戳(心跳活性检测,relay 无 pong 故用任意入站消息替代) */
|
||||
private lastInboundAt = 0
|
||||
|
||||
/** 订阅(单订阅,composable 注册一次) */
|
||||
subscribe(h: WsHandlers): void {
|
||||
this.handlers = h
|
||||
}
|
||||
|
||||
/** 读取当前状态 */
|
||||
getStatus(): WsStatus {
|
||||
return this.status
|
||||
}
|
||||
|
||||
/** 主动连接 */
|
||||
connect(): void {
|
||||
if (this.status === 'connected' || this.status === 'connecting' || this.status === 'handshaking') {
|
||||
return
|
||||
}
|
||||
this.manualClose = false
|
||||
this.recoverable = false
|
||||
// 用户主动连接复位重连计数(可达上限后的 disconnected 也允许手动重试)
|
||||
this.reconnectAttempts = 0
|
||||
// 统一经 reconnectNow(幂等 clear timer + cleanup + openSocket),杜绝 reconnecting 态双连
|
||||
this.reconnectNow()
|
||||
}
|
||||
|
||||
/** 主动断开(用户手动,不重连) */
|
||||
disconnect(): void {
|
||||
this.manualClose = true
|
||||
this.reconnectAttempts = 0
|
||||
// cleanup 内部清 pendingQueue + 注销 socket + 清所有 timer
|
||||
this.cleanup()
|
||||
this.setStatus('disconnected', 'manual close')
|
||||
}
|
||||
|
||||
/**
|
||||
* 若已断开或正在退避重连则立即重连(onShow 时调用,小程序后台回前台)。
|
||||
*
|
||||
* 覆盖 `disconnected` 与 `reconnecting` 两种态:
|
||||
* - disconnected:onClose 已触发,直接重连;
|
||||
* - reconnecting:退避计时未到,抢占退避立即重连(治「切后台回前台卡在 reconnecting」头号症状)。
|
||||
*
|
||||
* 统一经 reconnectNow(幂等清理),保证抢占时不残留旧 socket/timer。
|
||||
*/
|
||||
resumeIfDisconnected(): void {
|
||||
if (this.manualClose) return
|
||||
// 已达上限 disconnected(recoverable=true)也允许重置计数并重连
|
||||
if (this.status === 'disconnected' || this.status === 'reconnecting' || this.recoverable) {
|
||||
this.recoverable = false
|
||||
// 用户触发的立即重连(onShow 抢占退避)复位计数,允许达上限后手动恢复
|
||||
this.reconnectAttempts = 0
|
||||
this.reconnectNow()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停后台定时器(小程序 onHide 调,P1-9):停心跳 setInterval。
|
||||
*
|
||||
* 微信后台定时器节流但仍触发,心跳持续发 ping 浪费 + 后台无入站致半开检测误判。
|
||||
* 不 disconnect(disconnect 清队列 + manualClose,不适合后台暂离),仅停心跳保留连接。
|
||||
* 重连 setTimeout 若后台断连触发,单次非循环,影响可接受(MVP 不暂停)。
|
||||
*/
|
||||
pauseBackground(): void {
|
||||
this.stopHeartbeat()
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复前台(小程序 onShow 调):连接在则重启心跳,断则重连。
|
||||
*/
|
||||
resumeBackground(): void {
|
||||
if (this.status === 'connected' && this.handshaked) {
|
||||
this.startHeartbeat()
|
||||
} else {
|
||||
this.resumeIfDisconnected()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 Command(commander → agent)
|
||||
*
|
||||
* 不包装 BroadcastMessage —— relay.rs:332-365 入站文本帧直接当 payload 包成
|
||||
* BroadcastMessage(channel_id/kind/source/from 由 relay 填),故客户端仅发业务 JSON。
|
||||
*/
|
||||
send(cmd: MiniCommand): boolean {
|
||||
if (DEBUG) console.log('[dbg:ws] send', cmd.cmd, 'socket=', !!this.socket, 'handshaked=', this.handshaked)
|
||||
if (!this.socket || !this.handshaked) {
|
||||
// 握手未完成不丢弃,入队待 hello_ack 后补发(治握手成功到首帧竞态丢命令)。
|
||||
// 仅在未主动断开(manualClose=false)且连接尚有恢复预期时入队,
|
||||
// 否则(manualClose=true / 长期 disconnected)不堆积陈旧命令。
|
||||
if (this.manualClose) {
|
||||
console.warn('[WsClient] 已主动断开,丢弃命令', cmd.cmd)
|
||||
return false
|
||||
}
|
||||
this.enqueuePending(cmd)
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const text = JSON.stringify(cmd)
|
||||
this.socket.send({ data: text })
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('[WsClient] send 失败', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 入队(带上限 + 时间戳,TTL 丢弃见 flushPendingQueue) */
|
||||
private enqueuePending(cmd: MiniCommand): void {
|
||||
if (this.pendingQueue.length >= PENDING_MAX) {
|
||||
// 超上限丢弃最旧,防握手永卡时无限堆积内存泄漏
|
||||
this.pendingQueue.shift()
|
||||
console.warn('[WsClient] pendingQueue 超上限,丢弃最旧命令')
|
||||
}
|
||||
this.pendingQueue.push({ cmd, ts: Date.now() })
|
||||
console.log('[WsClient] 握手未完成,命令入队待补发', cmd.cmd, 'queue=', this.pendingQueue.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* 补发握手期间积压的命令队列(收到 hello_ack 后调)。
|
||||
*
|
||||
* list_conversations 已由握手逻辑自己发(不进队列),此处仅补发业务 send 积压。
|
||||
* 补发前置 handshaked=true,send 守卫放行,逐条直发 socket。
|
||||
* TTL 丢弃:入队超 PENDING_TTL_MS 的视为陈旧(上下文已失效),不补发。
|
||||
*/
|
||||
private flushPendingQueue(): void {
|
||||
if (this.pendingQueue.length === 0) return
|
||||
const now = Date.now()
|
||||
console.log('[WsClient] 补发握手积压命令', this.pendingQueue.length)
|
||||
while (this.pendingQueue.length > 0) {
|
||||
const item = this.pendingQueue.shift()!
|
||||
if (now - item.ts > PENDING_TTL_MS) {
|
||||
console.warn('[WsClient] 补发时丢弃超时陈旧命令', item.cmd.cmd)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
if (this.socket) {
|
||||
this.socket.send({ data: JSON.stringify(item.cmd) })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[WsClient] 补发命令失败', item.cmd.cmd, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 连接生命周期(统一入口 + 幂等清理) ───────────────────
|
||||
|
||||
/**
|
||||
* 幂等立即建连入口(clear 退避 timer + cleanup + openSocket)。
|
||||
*
|
||||
* 所有需要"立即开新连接"的路径(connect/resumeIfDisconnected/scheduleReconnect 回调)
|
||||
* 统一经此,保证:
|
||||
* - 不残留挂起的 reconnectTimer(防退避 timer 与 openSocket 双触发建两个 socket);
|
||||
* - 旧 socket 被 cleanup 注销+关闭(防 this.socket 引用覆盖后句柄泄漏);
|
||||
* - pendingQueue 被清(防跨连接/跨会话陈旧命令补发)。
|
||||
*/
|
||||
private reconnectNow(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
this.cleanup()
|
||||
this.openSocket()
|
||||
}
|
||||
|
||||
private openSocket(): void {
|
||||
// 幂等:残留 socket 先注销+关闭(reconnectNow 已 cleanup,此为双保险)
|
||||
if (this.socket) {
|
||||
this.destroySocket(this.socket)
|
||||
this.socket = null
|
||||
}
|
||||
const cfg = getConfig()
|
||||
this.setStatus('connecting')
|
||||
console.log('[WsClient] 连接', cfg.relayHost)
|
||||
|
||||
let task: UniApp.SocketTask
|
||||
try {
|
||||
task = uni.connectSocket({
|
||||
url: cfg.relayHost,
|
||||
complete: () => {
|
||||
// complete 回调在 uni-app 仅表示"任务创建完成",非连接完成(onOpen 才是)
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[WsClient] connectSocket 异常', e)
|
||||
this.scheduleReconnect()
|
||||
return
|
||||
}
|
||||
this.socket = task
|
||||
|
||||
task.onOpen(() => this.handleOpen())
|
||||
task.onMessage((res) => this.handleMessage(res))
|
||||
task.onClose((res) => this.handleClose(res))
|
||||
task.onError((res) => this.handleError(res))
|
||||
|
||||
// 连接超时:onOpen 未到则重连(治 onError 不触发 onClose 的场景:
|
||||
// DNS 解析失败/TLS 握手失败/relayHost 不可达/wss 域名未加白名单)
|
||||
this.connectTimer = setTimeout(() => {
|
||||
this.connectTimer = null
|
||||
if (this.status === 'connecting') {
|
||||
console.warn('[WsClient] 连接超时,onOpen 未到,重连')
|
||||
this.cleanup()
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}, CONNECT_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
/** WS 已建立连接 → 立即发 Hello 握手帧 + 启握手超时 */
|
||||
private handleOpen(): void {
|
||||
// onOpen 到达,取消连接超时
|
||||
if (this.connectTimer) {
|
||||
clearTimeout(this.connectTimer)
|
||||
this.connectTimer = null
|
||||
}
|
||||
this.setStatus('handshaking')
|
||||
const cfg = getConfig()
|
||||
const hello: Hello = {
|
||||
kind: 'commander',
|
||||
channel_id: cfg.channelId,
|
||||
token: cfg.token,
|
||||
}
|
||||
try {
|
||||
this.socket?.send({ data: JSON.stringify(hello) })
|
||||
} catch (e) {
|
||||
console.error('[WsClient] 发送 Hello 失败', e)
|
||||
// 失败先 cleanup(关坏 socket + 清监听器)再退避,与 handleHandshakeResponse 失败路径对齐
|
||||
this.cleanup()
|
||||
this.scheduleReconnect()
|
||||
return
|
||||
}
|
||||
// 握手超时:hello_ack 丢失(网络抖动/relay send_text 失败吞错 relay.rs:253-257)则重连。
|
||||
// >relay.rs:321 服务端 10s,留余量。握手成功/失败时 handleHandshakeResponse 会清此 timer。
|
||||
this.handshakeTimer = setTimeout(() => {
|
||||
this.handshakeTimer = null
|
||||
if (!this.handshaked) {
|
||||
console.warn('[WsClient] 握手超时,hello_ack 未收到,重连')
|
||||
this.cleanup()
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}, HANDSHAKE_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
/** 入站消息分派:握手阶段首帧=控制(握手成功/失败),握手后=BroadcastMessage */
|
||||
private handleMessage(res: { data: string | ArrayBuffer }): void {
|
||||
// 协议纯 JSON 文本帧(relay 发文本),ArrayBuffer 非预期 —— 加 warn 可观测
|
||||
// (防二进制帧静默丢弃致握手期 hello_ack 被吃却无日志)
|
||||
if (typeof res.data !== 'string') {
|
||||
console.warn('[WsClient] 收到非预期二进制帧,丢弃', (res.data as ArrayBuffer).byteLength)
|
||||
return
|
||||
}
|
||||
const text = res.data
|
||||
if (!text) return
|
||||
// 活性:任意入站帧更新(含 relay 回的 pong 心跳响应,用于半连接检测)
|
||||
this.lastInboundAt = Date.now()
|
||||
|
||||
// 握手阶段可能收到 relay 发的错误控制帧(relay.rs:200-238)
|
||||
if (!this.handshaked) {
|
||||
this.handleHandshakeResponse(text)
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 BroadcastMessage(device 事件经 relay 包成完整 BroadcastMessage 下发)
|
||||
let msg: BroadcastMessage
|
||||
try {
|
||||
msg = JSON.parse(text) as BroadcastMessage
|
||||
} catch (e) {
|
||||
console.warn('[WsClient] 入站消息 JSON 解析失败', e, text.slice(0, 200))
|
||||
return
|
||||
}
|
||||
|
||||
// 按 kind 分派:Event → 业务层;Control → 心跳响应等
|
||||
if (msg.kind === 'event') {
|
||||
this.handlers?.onEvent(msg)
|
||||
} else if (msg.kind === 'control') {
|
||||
this.handlers?.onControl(msg.payload as ControlMessage)
|
||||
}
|
||||
// command 是 device→miniapp 反向(不应在此端收到,忽略)
|
||||
}
|
||||
|
||||
/** 处理握手响应(relay 发 hello_ack 显式 ack;失败时发 error 帧 + 关连接) */
|
||||
private handleHandshakeResponse(text: string): void {
|
||||
// 结构化解析(优先):对齐 relay.rs:255 实发 hello_ack 字面量
|
||||
// 成功:{"kind":"control","payload":{"control_kind":"hello_ack"}}
|
||||
// 失败:{"kind":"control","error":"handshake_failed|kind_mismatch|auth_failed"} (relay.rs:200-238)
|
||||
// 注:hello_ack 的 control_kind 在 payload 内(与 error 顶层字段结构不同,须分别判)。
|
||||
let parsedOk = false
|
||||
let parsed: { kind?: string; error?: string; payload?: { control_kind?: string } } | null = null
|
||||
try {
|
||||
parsed = JSON.parse(text) as { kind?: string; error?: string; payload?: { control_kind?: string } }
|
||||
parsedOk = true
|
||||
} catch (e) {
|
||||
// JSON.parse 失败:降级回旧 includes 兜底,保证非 JSON 帧不误判
|
||||
console.warn('[WsClient] 握手响应 JSON 解析失败,降级 includes 兜底', e, text.slice(0, 200))
|
||||
}
|
||||
|
||||
if (parsedOk && parsed) {
|
||||
// 显式 error 字段存在即握手失败(对齐 relay.rs:203/220/233 error 顶层字段)
|
||||
if (parsed.error !== undefined) {
|
||||
console.error('[WsClient] 握手失败', parsed.error, text)
|
||||
this.setStatus('disconnected', `handshake failed: ${parsed.error}`)
|
||||
this.clearHandshakeTimer()
|
||||
// 握手失败不重连(token 错则重连也错),等用户修配置后手动 connect
|
||||
this.cleanup()
|
||||
return
|
||||
}
|
||||
// 显式 ack 信号:kind==='control' 且 payload.control_kind==='hello_ack'
|
||||
// (对齐 relay.rs:255 实发字面量;ControlMessage 枚举未含 hello_ack 故不强转类型)
|
||||
if (parsed.kind === 'control' && parsed.payload?.control_kind === 'hello_ack') {
|
||||
this.clearHandshakeTimer()
|
||||
this.handshaked = true
|
||||
this.reconnectAttempts = 0
|
||||
this.setStatus('connected')
|
||||
this.startHeartbeat()
|
||||
// F-#95:握手成功后立即发 list_conversations —— device 在线探测 + 首屏会话列表同步。
|
||||
// device 在线则 route_list_conversations 推回 AiConversationList(useAiChat 置 deviceOnline=true);
|
||||
// device 离线则无响应,文案保持「已连接中继」诚实表述。
|
||||
// 此时 handshaked=true,send 守卫放行;relay 纯透传入站文本帧作 payload。
|
||||
this.send({ cmd: 'list_conversations', args: {} })
|
||||
// 补发握手期间积压的业务命令(治握手成功到首帧竞态丢命令)
|
||||
this.flushPendingQueue()
|
||||
// hello_ack 是纯控制帧无业务 payload,无需转 handleMessage 处理
|
||||
return
|
||||
}
|
||||
// 解析成功但既非 error 也非 hello_ack:异常帧,丢弃等下一条(保守不误判握手通过)
|
||||
console.warn('[WsClient] 握手阶段收到非 ack/error 控制帧,丢弃', text.slice(0, 200))
|
||||
return
|
||||
}
|
||||
|
||||
// 降级兜底(JSON.parse 失败走此分支):保留旧 includes 判定,防协议演进/非 JSON 帧
|
||||
if (text.includes('"error"')) {
|
||||
console.error('[WsClient] 握手失败(includes 兜底)', text)
|
||||
this.setStatus('disconnected', `handshake failed: ${text}`)
|
||||
this.clearHandshakeTimer()
|
||||
this.cleanup()
|
||||
return
|
||||
}
|
||||
// 兜底:非错误消息视为握手通过(覆盖旧实现行为,防解析失败的 hello_ack 帧)
|
||||
this.clearHandshakeTimer()
|
||||
this.handshaked = true
|
||||
this.reconnectAttempts = 0
|
||||
this.setStatus('connected')
|
||||
this.startHeartbeat()
|
||||
this.send({ cmd: 'list_conversations', args: {} })
|
||||
// 补发握手期间积压的业务命令(降级兜底路径同样补发)
|
||||
this.flushPendingQueue()
|
||||
this.handleMessage({ data: text })
|
||||
}
|
||||
|
||||
private handleClose(res: { code: number; reason: string }): void {
|
||||
console.log('[WsClient] 连接关闭', res.code, res.reason)
|
||||
this.cleanup()
|
||||
if (!this.manualClose) {
|
||||
this.scheduleReconnect()
|
||||
} else {
|
||||
this.setStatus('disconnected', 'manual close')
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(res: { errMsg: string }): void {
|
||||
console.error('[WsClient] socket 错误', res.errMsg)
|
||||
// 微信 socket onError 不保证 onClose 跟随(DNS/TLS 失败/域名未加白名单/onOpen 也不触发)。
|
||||
// 若尚在 connecting/handshaking(未进过 connected),主动兜底重连,防永卡。
|
||||
// connected 态的半开死连接由心跳活性检测(P1-2)兜底,此处不重连(可能 onClose 随后跟随)。
|
||||
if (this.status === 'connecting' || this.status === 'handshaking') {
|
||||
this.cleanup()
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动心跳(固定间隔发 ControlMessage{control_kind:'heartbeat'} + 半开死连接检测) */
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat()
|
||||
const cfg = getConfig()
|
||||
// 连接成功初始化活性时间(首次心跳检查不立即判死)
|
||||
this.lastInboundAt = Date.now()
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
// 死连接检测:NAT 超时/网络切换致 TCP 半开(onClose 不触发,socket 仍 open)。
|
||||
// relay 无 pong,用「任意入站消息」更新 lastInboundAt;超过 N 倍间隔无入站判死,主动重连。
|
||||
const now = Date.now()
|
||||
if (now - this.lastInboundAt > cfg.heartbeatInterval * HEARTBEAT_DEAD_FACTOR) {
|
||||
console.warn('[WsClient] 心跳活性检测:连接疑似半开死连接,主动重连')
|
||||
this.cleanup()
|
||||
this.scheduleReconnect()
|
||||
return
|
||||
}
|
||||
const ping: ControlMessage = { control_kind: 'ping' }
|
||||
// 直接发 ControlMessage JSON(relay 透传,device 可回 pong)
|
||||
try {
|
||||
this.socket?.send({ data: JSON.stringify(ping) })
|
||||
} catch (e) {
|
||||
console.warn('[WsClient] 心跳发送失败', e)
|
||||
}
|
||||
// 注:cfg.heartbeatInterval 读但不显式用(已通过 setInterval 传入),保 cfg 引用
|
||||
void cfg
|
||||
}, cfg.heartbeatInterval)
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer)
|
||||
this.heartbeatTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 指数退避重连(baseDelay * 2^min(attempt,6),封顶 maxDelay,带随机抖动)。
|
||||
*
|
||||
* - attempt 软上限 6:超 6 不再翻倍,防长尾退避爬到 maxDelay 后卡死;
|
||||
* - 抖动因子 0.5~1.0:防多客户端重连风暴(雷同退避同步触发);
|
||||
* - manualClose/disconnect 时复位 reconnectAttempts(见 disconnect)。
|
||||
*
|
||||
* 回调统一经 reconnectNow(幂等清理 + openSocket),不裸调 openSocket。
|
||||
*/
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectTimer) return
|
||||
// 永久性故障上限:relayHost 错/中继下线时停止无限退避,避免长跑空耗 + 误导用户「重连中」。
|
||||
// 用户手动重连(connect/resumeIfDisconnected)复位计数后可重新开始。
|
||||
if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
console.warn(`[WsClient] 已达最大重连次数 ${MAX_RECONNECT_ATTEMPTS},停止重连`)
|
||||
// 不清 cleanup(保留 manualClose 不变),仅设状态 + 停退避 timer
|
||||
// 让 connect()/resumeIfDisconnected() 可重置计数后恢复
|
||||
this.setStatus('disconnected', '已达最大重连次数,请检查中继地址/网络后手动重连')
|
||||
this.recoverable = true
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
return
|
||||
}
|
||||
const cfg = getConfig()
|
||||
const attempt = this.reconnectAttempts++
|
||||
const exp = Math.min(attempt, 6)
|
||||
const base = Math.min(cfg.reconnectBaseDelay * Math.pow(2, exp), cfg.reconnectMaxDelay)
|
||||
const jitter = 0.5 + Math.random() * 0.5
|
||||
const delay = Math.round(base * jitter)
|
||||
this.setStatus('reconnecting', `attempt ${attempt + 1}, delay ${delay}ms`)
|
||||
console.log(`[WsClient] ${delay}ms 后重连(attempt ${attempt + 1})`)
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.reconnectNow()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/** 清握手超时定时器(握手成功/失败时调) */
|
||||
private clearHandshakeTimer(): void {
|
||||
if (this.handshakeTimer) {
|
||||
clearTimeout(this.handshakeTimer)
|
||||
this.handshakeTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销并关闭指定 socket task(防旧 socket 闭包回调污染新连接)。
|
||||
*
|
||||
* 微信 SocketTask 无 offXxx API,用「覆盖空函数」解绑:onXxx 多次调用覆盖最后者。
|
||||
* 必须对传入 task 操作(非 this.socket),避免 this.socket 时序指向新 socket 时误注销。
|
||||
*/
|
||||
private destroySocket(task: UniApp.SocketTask): void {
|
||||
try { task.onOpen(() => {}) } catch { /* 注销忽略 */ }
|
||||
try { task.onMessage(() => {}) } catch { /* 注销忽略 */ }
|
||||
try { task.onClose(() => {}) } catch { /* 注销忽略 */ }
|
||||
try { task.onError(() => {}) } catch { /* 注销忽略 */ }
|
||||
try {
|
||||
task.close({})
|
||||
} catch {
|
||||
// 已关闭的 socket close 抛错忽略
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 彻底清理:停心跳 + 清所有 timer + 注销关闭 socket + 清队列 + 复位 handshaked。
|
||||
*
|
||||
* 清 pendingQueue:断连/重连/抢占时,旧命令上下文已失效,不补发到新连接(防串话)。
|
||||
*/
|
||||
private cleanup(): void {
|
||||
this.stopHeartbeat()
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
this.clearHandshakeTimer()
|
||||
if (this.connectTimer) {
|
||||
clearTimeout(this.connectTimer)
|
||||
this.connectTimer = null
|
||||
}
|
||||
if (this.socket) {
|
||||
this.destroySocket(this.socket)
|
||||
this.socket = null
|
||||
}
|
||||
this.handshaked = false
|
||||
this.pendingQueue = []
|
||||
}
|
||||
|
||||
private setStatus(status: WsStatus, detail?: string): void {
|
||||
this.status = status
|
||||
this.handlers?.onStatus(status, detail)
|
||||
}
|
||||
}
|
||||
|
||||
/** 全局单例(miniapp 进程内一个连接) */
|
||||
export const wsClient = new WsClient()
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<!-- rich-text 小程序原生组件(无子组件依赖,稳)。marked md→html → rich-text :nodes 渲染。
|
||||
alpha 根因已查实(uni-app 2026 alpha 致 tap 失效),rich-text 非元凶,降级后正常用。 -->
|
||||
<rich-text :nodes="html" :selectable="true" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import { styleMarkdown } from '@/utils/mdRenderer'
|
||||
|
||||
defineOptions({ name: 'MdView' })
|
||||
|
||||
/**
|
||||
* Markdown 渲染气泡(miniapp,rich-text 原生方案)。
|
||||
*
|
||||
* 链路:marked.parse(md) → styleMarkdown 注入 inline style → rich-text :nodes(原生渲染 html 节点)。
|
||||
* - breaks:true 聊天换行(单换行转 <br>)
|
||||
* - P1-G:styleMarkdown 给代码块/链接/表格加 inline style(rich-text 不认 class)
|
||||
* - parse 失败回退原文
|
||||
* - 空内容返空(rich-text 不渲染)
|
||||
*
|
||||
* 流式:currentText 变化 → html computed 重算 → rich-text 重渲染。
|
||||
*/
|
||||
const props = defineProps<{ content: string }>()
|
||||
|
||||
const html = computed(() => {
|
||||
const src = props.content || ''
|
||||
if (!src) return ''
|
||||
try {
|
||||
return styleMarkdown(marked.parse(src, { breaks: true, async: false }) as string)
|
||||
} catch {
|
||||
return src
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<!-- @/ 联想弹层(MVP)。
|
||||
固定占位列表:不接真实技能路由/实体检索,仅作 chip 插入。
|
||||
/file /web /task /memory + @file @web @task 四类技能占位。
|
||||
渲染对齐 chat/index.vue 的 mp-weixin 稳定风格(原生 view/text + 绝对定位浮层)。 -->
|
||||
<view v-if="visible" class="mention-mask" @tap="onClose">
|
||||
<view class="mention-pop" @tap.stop>
|
||||
<view class="mention-head">
|
||||
<text class="mention-title">{{ trigger === '/' ? '技能' : '提及' }}</text>
|
||||
<text class="mention-hint">MVP 占位 · 点击插入</text>
|
||||
</view>
|
||||
<scroll-view class="mention-list" scroll-y>
|
||||
<view
|
||||
v-for="(item, idx) in items"
|
||||
:key="idx"
|
||||
class="mention-item"
|
||||
@tap="onSelect(item)"
|
||||
>
|
||||
<text class="mention-item-label">{{ item.label }}</text>
|
||||
<text class="mention-item-insert">{{ item.insert.trim() }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* MentionInput 联想弹层组件(MVP)。
|
||||
*
|
||||
* 设计:
|
||||
* - 纯展示:接收 visible/trigger/items,选中后 emit select(insert) + close
|
||||
* - 不查后端/不调路由:items 由父组件固定注入
|
||||
* - 触发字符 `/`(技能)或 `@`(提及)由父组件依据光标位置判定
|
||||
* - 选中即在 inputText 末尾插入对应字面量(如 `/file `),发送时整体纯文本走 ai.send
|
||||
*
|
||||
* props:
|
||||
* - visible:是否显示
|
||||
* - trigger:触发类型(`/` 技能 / `@` 提及)
|
||||
* - items:占位项列表 {label, insert}
|
||||
* emits:
|
||||
* - select(insert):插入文本
|
||||
* - close():关闭弹层
|
||||
*/
|
||||
defineOptions({ name: 'MentionInput' })
|
||||
|
||||
interface MentionItem {
|
||||
label: string
|
||||
insert: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
trigger: '/' | '@'
|
||||
items: MentionItem[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', insert: string): void
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
function onSelect(item: MentionItem): void {
|
||||
emit('select', item.insert)
|
||||
}
|
||||
|
||||
function onClose(): void {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mention-mask {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
z-index: 999;
|
||||
}
|
||||
.mention-pop {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 56px;
|
||||
max-height: 260px;
|
||||
background-color: #1f1f1f;
|
||||
border: 1px solid #333333;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.mention-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background-color: #262626;
|
||||
border-bottom: 1px solid #2e2e2e;
|
||||
}
|
||||
.mention-title {
|
||||
color: #e0e0e0;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.mention-hint {
|
||||
color: #777777;
|
||||
font-size: 11px;
|
||||
}
|
||||
.mention-list {
|
||||
max-height: 220px;
|
||||
}
|
||||
.mention-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
.mention-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.mention-item-label {
|
||||
color: #e0e0e0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.mention-item-insert {
|
||||
color: #4a9eff;
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* miniapp 运行时配置(channel_id/token 占位,由用户在设置页或 build env 填)
|
||||
*
|
||||
* 这些值在真实部署前是占位:
|
||||
* - RELAY_HOST:ws/wss 地址,需替换为实际部署的 u-relay 地址
|
||||
* - CHANNEL_ID:u-relay 配对通道 ID(与桌面端 df-tunnel 共享同键)
|
||||
* - TOKEN:对齐 u-relay 的 U_RELAY_TOKEN(生产建议 per-channel 独立 token,Phase3)
|
||||
*/
|
||||
|
||||
export interface MiniappConfig {
|
||||
/** relay WS 地址(含协议),如 'ws://192.168.1.100:9180/ws/commander' 或 'wss://relay.example.com/ws/commander' */
|
||||
relayHost: string
|
||||
/** 配对绑定的通道 ID(u-relay channel_id,与桌面端共享同键配对) */
|
||||
channelId: string
|
||||
/** 配对 token(对齐 u-relay U_RELAY_TOKEN) */
|
||||
token: string
|
||||
/** 心跳间隔(ms) */
|
||||
heartbeatInterval: number
|
||||
/** 重连初始间隔(ms,指数退避) */
|
||||
reconnectBaseDelay: number
|
||||
/** 重连最大间隔(ms) */
|
||||
reconnectMaxDelay: number
|
||||
/** file.1216.top 文件服务上传地址(不带路径,上传时拼 /upload) */
|
||||
fileUploadBaseUrl: string
|
||||
/** 上传 X-Source 头(项目隔离,文件存到 file.1216.top 对应目录) */
|
||||
fileUploadSource: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认配置(占位值,需替换)
|
||||
*
|
||||
* 真实部署:用户在设置页填 relayHost/channelId/token,或通过 build-time env 注入。
|
||||
* MVP 阶段留占位让类型自洽,实际连接会失败(token 校验不通过 → relay 关连接,
|
||||
* 这符合预期,提示用户配置)。
|
||||
*/
|
||||
export const defaultConfig: MiniappConfig = {
|
||||
// u-relay 现行协议 commander 端点:wss://u-work.1216.top → nginx 终止 TLS → u-relay :9180 明文 ws
|
||||
// 微信小程序真机要求 wss + socket 合法域名白名单(微信公众平台→开发设置→服务器域名)
|
||||
relayHost: 'wss://u-work.1216.top/ws/commander',
|
||||
// 配对桌面端 channel_id(查桌面端 KV app_settings:channel_id;填错则握手过但路由无对端)
|
||||
// 当前填本机桌面端 channel_id(联调期硬编码,后续做配对绑定流程)
|
||||
channelId: '1b92cbbd-d03e-4a0c-b226-9fadf8dcf0f0',
|
||||
token: 'devflow-relay-default-token',
|
||||
heartbeatInterval: 30000,
|
||||
reconnectBaseDelay: 1000,
|
||||
reconnectMaxDelay: 30000,
|
||||
fileUploadBaseUrl: 'https://file.1216.top',
|
||||
fileUploadSource: 'devflow',
|
||||
}
|
||||
|
||||
/** storage key(持久化完整 MiniappConfig JSON) */
|
||||
const STORAGE_KEY = 'df-miniapp-config'
|
||||
|
||||
/** 单例配置(null 表示尚未从 storage 加载) */
|
||||
let _config: MiniappConfig | null = null
|
||||
|
||||
/**
|
||||
* 旧版字段迁移(df-relay 一代 → u-relay):
|
||||
* storage 里的旧 JSON 含 deviceId 字段,映射为 channelId,避免老用户升级后配置丢失。
|
||||
*/
|
||||
function migrateLegacyFields(parsed: Record<string, unknown>): Partial<MiniappConfig> {
|
||||
const out = { ...parsed } as Record<string, unknown>
|
||||
if (out.deviceId !== undefined && out.channelId === undefined) {
|
||||
out.channelId = out.deviceId
|
||||
}
|
||||
delete out.deviceId
|
||||
// 旧 relayHost 指向已下线的 /ws/miniapp 端点,升级后强制重置为新端点
|
||||
if (typeof out.relayHost === 'string' && out.relayHost.includes('/ws/miniapp')) {
|
||||
out.relayHost = defaultConfig.relayHost
|
||||
}
|
||||
return out as Partial<MiniappConfig>
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 storage 读取并合并默认配置
|
||||
*
|
||||
* uni.getStorageSync(key) 未命中时返回 ''(空字符串),命中返回原写入值。
|
||||
* 容错:读取/解析失败仅 warn,不抛出,回落到 defaultConfig。
|
||||
*/
|
||||
function loadFromStorage(): MiniappConfig {
|
||||
try {
|
||||
const raw = uni.getStorageSync(STORAGE_KEY)
|
||||
if (!raw) return { ...defaultConfig }
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>
|
||||
// 合并默认值 + 旧字段迁移,避免旧版本字段缺失/更名导致 undefined
|
||||
const migrated = migrateLegacyFields(parsed)
|
||||
return { ...defaultConfig, ...migrated }
|
||||
} catch (e) {
|
||||
console.warn('[df-miniapp] load config from storage failed:', e)
|
||||
return { ...defaultConfig }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步写入 storage(JSON 序列化完整配置)
|
||||
*
|
||||
* 容错:写入失败仅 warn,不影响内存配置。
|
||||
*/
|
||||
function saveToStorage(config: MiniappConfig): void {
|
||||
try {
|
||||
uni.setStorageSync(STORAGE_KEY, JSON.stringify(config))
|
||||
} catch (e) {
|
||||
console.warn('[df-miniapp] save config to storage failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfig(): MiniappConfig {
|
||||
// 首次调用时从 storage 加载并缓存
|
||||
if (_config === null) {
|
||||
_config = loadFromStorage()
|
||||
}
|
||||
return _config
|
||||
}
|
||||
|
||||
export function setConfig(patch: Partial<MiniappConfig>): void {
|
||||
// 确保已加载(避免在 getConfig 前调用 setConfig 丢失 storage 旧值)
|
||||
if (_config === null) {
|
||||
_config = loadFromStorage()
|
||||
}
|
||||
_config = { ..._config, ...patch }
|
||||
saveToStorage(_config)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置为默认配置并清除 storage 持久化数据
|
||||
*
|
||||
* 用于用户重新配对或恢复出厂占位值的场景。
|
||||
*/
|
||||
export function resetConfig(): void {
|
||||
_config = { ...defaultConfig }
|
||||
try {
|
||||
uni.removeStorageSync(STORAGE_KEY)
|
||||
} catch (e) {
|
||||
console.warn('[df-miniapp] remove config from storage failed:', e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createSSRApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
/**
|
||||
* uni-app 入口:createSSRApp 规范(uni-app Vue3 必需,非 createApp)。
|
||||
* 桌面端 main.ts 用 createApp(Tauri 浏览器环境),小程序用 createSSRApp 对齐 uni 跨端。
|
||||
*/
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
return {
|
||||
app,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/chat/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "AI 对话",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/conversations/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "会话列表",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/settings/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "设置"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "white",
|
||||
"navigationBarTitleText": "DevFlow",
|
||||
"navigationBarBackgroundColor": "#1a1a1a",
|
||||
"backgroundColor": "#0f0f0f",
|
||||
"backgroundTextStyle": "light"
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "#999999",
|
||||
"selectedColor": "#4a9eff",
|
||||
"backgroundColor": "#1a1a1a",
|
||||
"borderStyle": "black",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/chat/index",
|
||||
"text": "对话"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/conversations/index",
|
||||
"text": "会话"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/settings/index",
|
||||
"text": "设置"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,236 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { useAiChat } from '@/composables/useAiChat'
|
||||
import type { Conversation } from '@/types/events'
|
||||
|
||||
const ai = useAiChat()
|
||||
|
||||
/**
|
||||
* mp-weixin 模板对嵌套在对象中的 reactive/ref 响应性追踪有问题。
|
||||
* 用本地 ref + watch 同步,确保模板直接拿到响应式数组。
|
||||
*/
|
||||
const conversations = ref<Conversation[]>([])
|
||||
/**
|
||||
* 监听 ai.conversations 整体替换(比 deep watch 更可靠,也比只盯 length 完整)。
|
||||
* 用 `[...ai.conversations]` 创建普通数组赋值给 ref,确保每次变化都触发模板更新。
|
||||
*
|
||||
* 为什么监听 ref 而非 length:device 回推 AiConversationList 是「整体替换」且条数常不变
|
||||
* (改名后条数不变),只盯 length 时 watch 回调不触发 → 本地副本滞留旧标题。
|
||||
* 监听 ref 则在赋值(含同长度替换)时即触发刷新。
|
||||
*/
|
||||
watch(ai.conversations, (list) => {
|
||||
conversations.value = [...(list || [])]
|
||||
}, { immediate: true })
|
||||
|
||||
/** 点击会话项切换 */
|
||||
function handleSelect(convId: string): void {
|
||||
ai.switchConversation(convId)
|
||||
// 切换后跳回对话页
|
||||
uni.switchTab({ url: '/pages/chat/index' })
|
||||
}
|
||||
|
||||
/** 新建会话 */
|
||||
function handleNew(): void {
|
||||
ai.newConversation()
|
||||
uni.switchTab({ url: '/pages/chat/index' })
|
||||
}
|
||||
|
||||
/** 长按会话项重命名(对齐桌面端改会话名) */
|
||||
function handleRename(conv: Conversation): void {
|
||||
uni.showModal({
|
||||
title: '重命名会话',
|
||||
editable: true,
|
||||
placeholderText: '输入新名称',
|
||||
content: conv.title || '',
|
||||
success: (r) => {
|
||||
if (r.confirm && r.content) {
|
||||
const newTitle = r.content.trim()
|
||||
if (newTitle && newTitle !== conv.title) {
|
||||
ai.renameConversation(conv.id, newTitle)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 下拉刷新:发 list_conversations 同步 device 会话列表(F-#95)。
|
||||
* 命令异步回流经 AiConversationList 事件替换 ai.conversations(handleEvent),
|
||||
* 此处仅发命令 + 收尾停止下拉动画(列表更新非同步返回,故立即停止动画即可)。 */
|
||||
onPullDownRefresh(() => {
|
||||
ai.refreshConversations()
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
|
||||
/** 格式化时间戳为可读字符串 */
|
||||
function formatTime(ts?: number): string {
|
||||
if (!ts) return ''
|
||||
const d = new Date(ts)
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
const hh = String(d.getHours()).padStart(2, '0')
|
||||
const mi = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${mm}-${dd} ${hh}:${mi}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="conv-page">
|
||||
<!-- 顶部新建按钮 -->
|
||||
<view class="new-bar" @click="handleNew">
|
||||
<text class="new-icon">+</text>
|
||||
<text class="new-text">新建会话</text>
|
||||
</view>
|
||||
|
||||
<!-- 会话列表 -->
|
||||
<scroll-view class="conv-list" scroll-y>
|
||||
<!-- 加载中骨架屏 -->
|
||||
<view v-if="conversations.length === 0 && ai.loading.value" class="conv-skeleton">
|
||||
<view v-for="i in 5" :key="i" class="conv-sk-item">
|
||||
<view class="conv-sk-line conv-sk-w70"></view>
|
||||
<view class="conv-sk-line conv-sk-w40"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="conversations.length === 0" class="empty-hint">
|
||||
<text>暂无历史会话</text>
|
||||
<text class="empty-sub">在桌面端创建后,此处同步</text>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-for="conv in conversations"
|
||||
:key="conv.id"
|
||||
class="conv-item"
|
||||
:class="{ active: conv.id === ai.activeConversationId.value }"
|
||||
@click="handleSelect(conv.id)"
|
||||
@longpress="handleRename(conv)"
|
||||
>
|
||||
<view class="conv-main">
|
||||
<text class="conv-title">{{ conv.title || '(未命名会话)' }}</text>
|
||||
<text v-if="conv.lastMessage" class="conv-last">{{ conv.lastMessage }}</text>
|
||||
</view>
|
||||
<text v-if="conv.updatedAt" class="conv-time">{{ formatTime(conv.updatedAt) }}</text>
|
||||
</view>
|
||||
<view v-if="conversations.length > 0" class="list-hint">
|
||||
<text>长按会话可重命名</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.conv-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background-color: $uni-bg-color;
|
||||
}
|
||||
|
||||
.new-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24rpx;
|
||||
background-color: $uni-bg-color-dark;
|
||||
border-bottom: 1rpx solid $uni-border-color;
|
||||
}
|
||||
|
||||
.new-icon {
|
||||
font-size: 40rpx;
|
||||
color: $uni-color-primary;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.new-text {
|
||||
font-size: $uni-font-size-base;
|
||||
color: $uni-color-primary;
|
||||
}
|
||||
|
||||
.conv-list {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 200rpx 24rpx;
|
||||
text {
|
||||
color: $uni-text-color-disable;
|
||||
font-size: $uni-font-size-base;
|
||||
}
|
||||
.empty-sub {
|
||||
margin-top: 12rpx;
|
||||
font-size: $uni-font-size-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.conv-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24rpx;
|
||||
border-bottom: 1rpx solid $uni-border-color;
|
||||
&.active {
|
||||
background-color: $uni-bg-color-light;
|
||||
border-left: 6rpx solid $uni-color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.conv-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.conv-title {
|
||||
font-size: $uni-font-size-base;
|
||||
color: $uni-text-color;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.conv-last {
|
||||
margin-top: 8rpx;
|
||||
font-size: $uni-font-size-sm;
|
||||
color: $uni-text-color-grey;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.conv-time {
|
||||
font-size: $uni-font-size-sm;
|
||||
color: $uni-text-color-disable;
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
.list-hint {
|
||||
padding: 24rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.list-hint text {
|
||||
color: $uni-text-color-disable;
|
||||
font-size: $uni-font-size-sm;
|
||||
}
|
||||
/* 骨架屏(下拉刷新/冷启动占位) */
|
||||
.conv-skeleton {
|
||||
padding: 24rpx;
|
||||
}
|
||||
.conv-sk-item {
|
||||
margin-bottom: 32rpx;
|
||||
padding: 24rpx;
|
||||
}
|
||||
.conv-sk-line {
|
||||
height: 24rpx;
|
||||
margin: 8rpx 0;
|
||||
background: linear-gradient(90deg, $uni-bg-color-light 25%, #3a3a3a 50%, $uni-bg-color-light 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: conv-sk-shimmer 1.5s infinite;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
.conv-sk-w70 { width: 70%; }
|
||||
.conv-sk-w40 { width: 40%; }
|
||||
@keyframes conv-sk-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,322 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { getConfig, setConfig } from '@/config'
|
||||
import { useAiChat } from '@/composables/useAiChat'
|
||||
|
||||
/**
|
||||
* 设置页(连接配置查看/编辑 + 连接状态 + 连接操作)。
|
||||
*
|
||||
* 默认查看模式(只读展示 relayHost/channelId/token,token 脱敏);点「编辑」进入编辑模式,
|
||||
* 保存时 setConfig 持久化并 resumeIfDisconnected 用新配置重连。连接态可「断开」,
|
||||
* 断开态可「重连」,另展示最近状态详情(wsStatusDetail)辅助排查。
|
||||
*
|
||||
* 数据源:
|
||||
* - 连接配置:config.ts getConfig()(storage 持久化,含默认值合并)
|
||||
* - 连接状态:useAiChat() 单例(wsStatus + deviceOnline,实时)
|
||||
* - 版本号:manifest.json versionName(手动维护此处常量,对齐 0.1.0)
|
||||
*/
|
||||
|
||||
// 连接配置(只读快照,页面 onShow 时刷新,切 tab 回来看最新值)
|
||||
const cfg = computed(() => getConfig())
|
||||
|
||||
// 连接状态(实时,useAiChat 单例 ref 直接响应)
|
||||
const ai = useAiChat()
|
||||
const wsStatus = computed(() => ai.wsStatus.value)
|
||||
const wsStatusDetail = computed(() => ai.wsStatusDetail.value)
|
||||
const deviceOnline = computed(() => ai.deviceOnline.value)
|
||||
const isWsConnected = computed(() => ai.isWsConnected.value)
|
||||
|
||||
/** 连接状态中文文案 + 颜色 class(对齐 chat 页 STATUS_TEXT 口径) */
|
||||
const STATUS_META: Record<string, { label: string; cls: string }> = {
|
||||
connected: { label: '已连接', cls: 'st-ok' },
|
||||
connecting: { label: '连接中…', cls: 'st-wait' },
|
||||
handshaking: { label: '握手中…', cls: 'st-wait' },
|
||||
reconnecting: { label: '重连中…', cls: 'st-warn' },
|
||||
disconnected: { label: '已断开', cls: 'st-err' },
|
||||
}
|
||||
const statusText = computed(() => {
|
||||
if (isWsConnected.value) {
|
||||
return deviceOnline.value ? '已连接桌面端' : '已连接中继'
|
||||
}
|
||||
return STATUS_META[wsStatus.value]?.label || wsStatus.value
|
||||
})
|
||||
const statusCls = computed(() => {
|
||||
if (isWsConnected.value) return deviceOnline.value ? 'st-ok' : 'st-warn'
|
||||
return STATUS_META[wsStatus.value]?.cls || 'st-err'
|
||||
})
|
||||
|
||||
/** token 脱敏(只显前 4 位 + 星号,查看页不暴露全量凭据) */
|
||||
function maskToken(token: string): string {
|
||||
if (!token) return '(未设置)'
|
||||
if (token.length <= 8) return '*'.repeat(token.length)
|
||||
return `${token.slice(0, 4)}${'*'.repeat(Math.min(token.length - 4, 12))}`
|
||||
}
|
||||
|
||||
/** 毫秒转可读(30000 → 30s) */
|
||||
function fmtMs(ms: number): string {
|
||||
return ms >= 1000 ? `${ms / 1000}s` : `${ms}ms`
|
||||
}
|
||||
|
||||
/** 版本号(对齐 manifest.json versionName,改版时同步) */
|
||||
const APP_VERSION = '0.1.0'
|
||||
|
||||
/** 手动重连(断开态可点)。用 ai.connect() 而非 resumeIfDisconnected:
|
||||
* 用户先点「断开」(manualClose=true)后 resumeIfDisconnected 会因 manualClose 早退,
|
||||
* connect() 复位 manualClose 强制开新连接(非连接态才生效,连接态自动幂等跳过)。 */
|
||||
function onReconnect(): void {
|
||||
ai.connect()
|
||||
}
|
||||
|
||||
/** 手动断开(连接态可点,disconnect 置 manualClose 后不再自动重连) */
|
||||
function onDisconnect(): void {
|
||||
ai.disconnect()
|
||||
uni.showToast({ title: '已断开连接', icon: 'none' })
|
||||
}
|
||||
|
||||
/** 连接操作按钮入口(连接态→断开,断开态→重连) */
|
||||
function onConnOp(): void {
|
||||
if (isWsConnected.value) onDisconnect()
|
||||
else onReconnect()
|
||||
}
|
||||
|
||||
/** 编辑态(false=查看模式,true=编辑模式;草稿保存时才 setConfig,取消丢弃) */
|
||||
const editing = ref(false)
|
||||
const draftRelayHost = ref('')
|
||||
const draftChannelId = ref('')
|
||||
const draftToken = ref('')
|
||||
|
||||
/** 进入编辑模式:以当前配置填充草稿 */
|
||||
function enterEdit(): void {
|
||||
draftRelayHost.value = cfg.value.relayHost
|
||||
draftChannelId.value = cfg.value.channelId
|
||||
draftToken.value = cfg.value.token
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
/** 取消编辑:丢弃草稿回查看模式(不保存) */
|
||||
function cancelEdit(): void {
|
||||
editing.value = false
|
||||
}
|
||||
|
||||
/** 保存编辑:setConfig 持久化 + 提示 + resumeIfDisconnected 用新配置重连 */
|
||||
function saveEdit(): void {
|
||||
const relayHost = draftRelayHost.value.trim()
|
||||
const channelId = draftChannelId.value.trim()
|
||||
if (!relayHost || !channelId) {
|
||||
uni.showToast({ title: '中继地址和通道 ID 不能为空', icon: 'none' })
|
||||
return
|
||||
}
|
||||
setConfig({ relayHost, channelId, token: draftToken.value.trim() })
|
||||
editing.value = false
|
||||
uni.showToast({ title: '已保存,正在重连…', icon: 'none' })
|
||||
ai.resumeIfDisconnected()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="settings-page">
|
||||
<!-- 连接状态卡片(实时,断开可点重连) -->
|
||||
<view class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">连接状态</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="row-label">当前状态</text>
|
||||
<text class="row-value status" :class="statusCls" @tap="onReconnect">{{ statusText }}</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="row-label">中继地址</text>
|
||||
<text class="row-value mono">{{ cfg.relayHost }}</text>
|
||||
</view>
|
||||
<view class="row" v-if="wsStatusDetail">
|
||||
<text class="row-label">最近状态</text>
|
||||
<text class="row-value">{{ wsStatusDetail }}</text>
|
||||
</view>
|
||||
<view class="ops">
|
||||
<view class="btn btn-block" :class="isWsConnected ? 'btn-danger' : 'btn-primary'" @tap="onConnOp">
|
||||
{{ isWsConnected ? '断开' : '重连' }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 连接配置(查看/编辑双模式:编辑可改 relayHost/channelId/token) -->
|
||||
<view class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">连接配置</text>
|
||||
<view v-if="editing" class="card-actions">
|
||||
<text class="btn btn-sm btn-ghost" @tap="cancelEdit">取消</text>
|
||||
<text class="btn btn-sm btn-primary" @tap="saveEdit">保存</text>
|
||||
</view>
|
||||
<text v-else class="btn btn-sm btn-ghost" @tap="enterEdit">编辑</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="row-label">中继地址</text>
|
||||
<input v-if="editing" v-model="draftRelayHost" class="row-input" placeholder="ws://…/ws/commander" placeholder-class="row-ph" />
|
||||
<text v-else class="row-value mono">{{ cfg.relayHost }}</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="row-label">通道 ID</text>
|
||||
<input v-if="editing" v-model="draftChannelId" class="row-input" placeholder="u-relay 通道 ID" placeholder-class="row-ph" />
|
||||
<text v-else class="row-value mono">{{ cfg.channelId }}</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="row-label">配对 Token</text>
|
||||
<input v-if="editing" v-model="draftToken" class="row-input" placeholder="配对 token" placeholder-class="row-ph" />
|
||||
<text v-else class="row-value mono">{{ maskToken(cfg.token) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 高级参数 -->
|
||||
<view class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">高级参数</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="row-label">心跳间隔</text>
|
||||
<text class="row-value">{{ fmtMs(cfg.heartbeatInterval) }}</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="row-label">重连初始间隔</text>
|
||||
<text class="row-value">{{ fmtMs(cfg.reconnectBaseDelay) }}</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="row-label">重连最大间隔</text>
|
||||
<text class="row-value">{{ fmtMs(cfg.reconnectMaxDelay) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 关于 -->
|
||||
<view class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">关于</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="row-label">版本</text>
|
||||
<text class="row-value">DevFlow Mini v{{ APP_VERSION }}</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="row-label">说明</text>
|
||||
<text class="row-value">远程 AI Chat · 跨端操作桌面端开发助手</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.settings-page {
|
||||
min-height: 100vh;
|
||||
padding: 24rpx;
|
||||
box-sizing: border-box;
|
||||
background-color: $uni-bg-color;
|
||||
}
|
||||
.card {
|
||||
margin-bottom: 24rpx;
|
||||
padding: 24rpx;
|
||||
background-color: $uni-bg-color-light;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
.card-head {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
.card-title {
|
||||
font-size: $uni-font-size-base;
|
||||
font-weight: bold;
|
||||
color: $uni-text-color;
|
||||
}
|
||||
.card-sub {
|
||||
font-size: $uni-font-size-sm;
|
||||
color: $uni-text-color-disable;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 1rpx solid $uni-border-color;
|
||||
}
|
||||
.row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.row-label {
|
||||
font-size: $uni-font-size-sm;
|
||||
color: $uni-text-color-grey;
|
||||
}
|
||||
.row-value {
|
||||
flex: 1;
|
||||
margin-left: 24rpx;
|
||||
font-size: $uni-font-size-sm;
|
||||
color: $uni-text-color;
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
}
|
||||
.row-value.mono {
|
||||
font-family: monospace;
|
||||
}
|
||||
.row-value.status {
|
||||
font-weight: bold;
|
||||
}
|
||||
.st-ok { color: #6bd99a; }
|
||||
.st-wait { color: $uni-color-primary; }
|
||||
.st-warn { color: #f0c75e; }
|
||||
.st-err { color: #e85a4f; }
|
||||
|
||||
/* 连接操作按钮(连接状态卡:重连/断开) */
|
||||
.ops {
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8rpx 24rpx;
|
||||
font-size: $uni-font-size-sm;
|
||||
line-height: 1.6;
|
||||
border-radius: 10rpx;
|
||||
}
|
||||
.btn-block {
|
||||
display: block;
|
||||
padding: 20rpx 32rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.btn-primary {
|
||||
background-color: $uni-color-primary;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-danger {
|
||||
background-color: #e85a4f;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-ghost {
|
||||
color: $uni-color-primary;
|
||||
border: 1rpx solid $uni-color-primary;
|
||||
background-color: transparent;
|
||||
}
|
||||
/* 卡片头部按钮组(编辑/取消/保存) */
|
||||
.card-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.card-actions .btn {
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
.btn-sm {
|
||||
padding: 4rpx 20rpx;
|
||||
}
|
||||
/* 编辑态输入(label 左 + input 右,横向一行对齐 .row-value) */
|
||||
.row-input {
|
||||
flex: 1;
|
||||
margin-left: 24rpx;
|
||||
font-size: $uni-font-size-sm;
|
||||
color: $uni-text-color;
|
||||
text-align: right;
|
||||
}
|
||||
.row-ph {
|
||||
color: $uni-text-color-disable;
|
||||
}
|
||||
</style>
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="@dcloudio/types" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* AiChatEvent 类型镜像(对齐 DevFlow 后端 Rust 定义)
|
||||
*
|
||||
* 类型证据:
|
||||
* - Rust 定义:src-tauri/src/commands/ai/mod.rs:105-216 `pub enum AiChatEvent`
|
||||
* (19 个变体,#[serde(tag = "type")] → TS 用 type discriminator 镜像)
|
||||
* - Rust 字段 snake_case:Rust 默认 snake_case 字段,TS 直接对齐(不加 camelCase 转换)
|
||||
* - 桌面 TS:src/api/types.ts(未在本批核验,但变体名/字段从 Rust 源码逐字对齐)
|
||||
*
|
||||
* 注意:TS 联合类型 discriminator 名为 `type`,与 Rust #[serde(tag = "type")] 一致。
|
||||
* 变体名 PascalCase(Rust enum 变体名原样,serde 默认 PascalCase 序列化)。
|
||||
*/
|
||||
|
||||
// SkillInfo 复用 relay.ts 同协议层定义(`/` 联想浮层渲染 + 选中后 name 透传 SendMessageArgs.skill)
|
||||
import type { SkillInfo } from './relay'
|
||||
|
||||
/** 错误源分类(对齐 Rust ErrorType,mod.rs:89-102,snake_case 序列化) */
|
||||
export type ErrorType = 'auth' | 'network' | 'timeout' | 'provider_config' | 'unknown'
|
||||
|
||||
/** 会话生命周期状态(对齐 Rust ConvState,L2 统一状态机,snake_case 序列化) */
|
||||
export type ConvState = 'idle' | 'generating' | 'stopping' | 'error' | 'compressed'
|
||||
|
||||
/**
|
||||
* AI 聊天事件联合类型(19 个变体,严格对齐 Rust mod.rs:107-216)
|
||||
*
|
||||
* 字段全部对齐 Rust serde(rename_all 默认 snake_case 字段名,tag = "type")。
|
||||
* 变体名 PascalCase 来自 Rust enum 变体名(serde 默认 PascalCase 序列化 enum variant)。
|
||||
*
|
||||
* 注:AiConversationList 的 conversations 字段对齐 Rust ConvSummary(#[serde(rename_all="camelCase")]),
|
||||
* 元素结构同本文件 Conversation interface(id/title/lastMessage?/updatedAt?/createdAt?)。
|
||||
*/
|
||||
export type AiChatEvent =
|
||||
| { type: 'AiTextDelta'; delta: string; conversation_id?: string | null }
|
||||
| { type: 'AiToolCallStarted'; id: string; name: string; args: unknown; conversation_id?: string | null }
|
||||
| { type: 'AiToolCallCompleted'; id: string; result: unknown; conversation_id?: string | null }
|
||||
| { type: 'AiToolAutoApproved'; id: string; tool: string; dir: string; conversation_id?: string | null }
|
||||
| {
|
||||
type: 'AiApprovalRequired'
|
||||
id: string
|
||||
name: string
|
||||
args: unknown
|
||||
reason: string
|
||||
diff?: string | null
|
||||
conversation_id?: string | null
|
||||
}
|
||||
| { type: 'AiApprovalResult'; id: string; approved: boolean; conversation_id?: string | null }
|
||||
| {
|
||||
type: 'AiCompleted'
|
||||
total_tokens: number
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
/** token 分项(2026-08-02):cache 命中(低价)/未命中(全价真实,前端 in 显示用此)/思考 */
|
||||
prompt_cache_hit_tokens: number
|
||||
prompt_cache_miss_tokens: number
|
||||
reasoning_tokens: number
|
||||
/** G4.3:该轮 token 用量是否估算值(provider 未报 prompt_tokens → estimated 兜底打标) */
|
||||
is_estimated: boolean
|
||||
incomplete?: boolean | null
|
||||
conversation_id?: string | null
|
||||
}
|
||||
| { type: 'AiError'; error: string; error_type?: ErrorType | null; conversation_id?: string | null }
|
||||
| { type: 'AiUserMessage'; message: string; conversation_id?: string | null }
|
||||
| { type: 'AiAgentRound'; round: number; conversation_id?: string | null }
|
||||
| { type: 'AiHeartbeat'; conversation_id?: string | null }
|
||||
| { type: 'AiMaxRoundsReached'; conversation_id?: string | null }
|
||||
| { type: 'AiStreamRetry'; attempt: number; max_attempts: number; conversation_id?: string | null }
|
||||
| {
|
||||
type: 'AiDirAuthRequired'
|
||||
id: string
|
||||
tool: string
|
||||
path: string
|
||||
dir: string
|
||||
conversation_id?: string | null
|
||||
}
|
||||
| { type: 'AiContextCleared'; conversation_id?: string | null }
|
||||
| { type: 'AiCompressing'; conversation_id?: string | null }
|
||||
// 治 Task#1:拆分手动/自动两变体(字段相同仅 type 字面量不同),桌面按 type 分流,
|
||||
// miniapp 两变体都插摘要气泡(历史可见性,见 useAiChat.ts:618 case fallthrough)。
|
||||
| { type: 'AiManualCompressed'; conversation_id?: string | null; summary: string }
|
||||
| { type: 'AiAutoCompressed'; conversation_id?: string | null; summary: string }
|
||||
| {
|
||||
type: 'AiHelpRequired'
|
||||
reason: string
|
||||
context: string
|
||||
options: string[]
|
||||
conversation_id?: string | null
|
||||
}
|
||||
| { type: 'AiConvStateChanged'; conv_state: ConvState; conversation_id?: string | null }
|
||||
| { type: 'AiConversationList'; conversations: Conversation[] }
|
||||
| { type: 'AiMessageHistory'; conversation_id: string; messages: ChatMessage[] }
|
||||
| { type: 'AiSkillList'; skills: SkillInfo[] }
|
||||
| {
|
||||
type: 'AiEntityList'
|
||||
projects: ProjectRecord[]
|
||||
tasks: TaskRecord[]
|
||||
ideas: IdeaRecord[]
|
||||
}
|
||||
// 每会话独立模型:对齐后端 list_models(device 读活跃 provider enabled 模型,跨端回推)。
|
||||
// 全局事件(无 conversation_id),miniapp 顶栏模型选择器消费 models + 默认 default_model。
|
||||
| {
|
||||
type: 'AiModelList'
|
||||
provider_id: string
|
||||
default_model: string
|
||||
models: ModelInfo[]
|
||||
}
|
||||
|
||||
/** AiChatEvent 的 type 字面量集合(用于 narrowing / 分派) */
|
||||
export type AiChatEventType = AiChatEvent['type']
|
||||
|
||||
/**
|
||||
* 模型信息(每会话独立模型,对齐后端 list_models 返回)。
|
||||
* provider 当前 enabled 模型列表,label 为展示名(可选,缺省用 model_id)。
|
||||
*/
|
||||
export interface ModelInfo {
|
||||
/** 模型 id(选中后透传 model_override) */
|
||||
model_id: string
|
||||
/** 展示名(可选) */
|
||||
label?: string
|
||||
}
|
||||
|
||||
/** 会话信息(对齐桌面端 list_conversations 返回结构,字段名 camelCase 兼容桌面 IPC 约定) */
|
||||
export interface Conversation {
|
||||
id: string
|
||||
title: string
|
||||
/** 最近一条消息摘要(可选) */
|
||||
lastMessage?: string
|
||||
/** 最近更新时间戳(ms) */
|
||||
updatedAt?: number
|
||||
/** 创建时间(ms) */
|
||||
createdAt?: number
|
||||
}
|
||||
|
||||
/** 本地渲染消息气泡(对齐桌面端 AiMessage 结构,简化为 miniapp 必需字段) */
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
timestamp: number
|
||||
isError?: boolean
|
||||
/** 工具调用列表(对齐 AiToolCallInfo) */
|
||||
toolCalls?: AiToolCallInfo[]
|
||||
/** 不完整标记(AiCompleted{incomplete:true} 对应系统提示气泡) */
|
||||
incomplete?: boolean
|
||||
/** 图片输入:消息携带的图片 URL 列表(预览用,发送时透传) */
|
||||
images?: string[]
|
||||
}
|
||||
|
||||
/** 工具调用信息(对齐桌面 useAiEvents.ts:399 的 AiToolCallInfo) */
|
||||
export interface AiToolCallInfo {
|
||||
id: string
|
||||
name: string
|
||||
args: unknown
|
||||
result?: unknown
|
||||
status: 'running' | 'completed' | 'failed' | 'pending_approval' | 'rejected'
|
||||
reason?: string
|
||||
diff?: string
|
||||
/** 审批类型(risk 普通 / path 路径授权,对齐后端 ApprovalKind) */
|
||||
kind?: 'risk' | 'path'
|
||||
dir?: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
/** 挂起审批项(复用 AiToolCallInfo 结构,miniapp 不实现审批 UI 仅展示) */
|
||||
export type PendingApproval = AiToolCallInfo
|
||||
|
||||
/** Token 用量(对齐桌面端 state.lastTokenUsage) */
|
||||
export interface TokenUsage {
|
||||
prompt: number
|
||||
completion: number
|
||||
total: number
|
||||
/** token 分项(2026-08-02):cache 命中(低价)/未命中(全价)/思考,对齐桌面端 */
|
||||
cache_hit?: number
|
||||
cache_miss?: number
|
||||
reasoning?: number
|
||||
/** 该轮 token 是否估算值(AiCompleted.is_estimated 透传,reload 历史消息可能无) */
|
||||
is_estimated?: boolean
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 实体列表类型(F-#95 联想扩展,AiEntityList 事件载荷)
|
||||
// ============================================================
|
||||
//
|
||||
// 精简字段对齐桌面端 src/api/types.ts(后端 Record 全量字段经 Tauri command Serialize 直传,
|
||||
// miniapp 仅声明联想浮层展示所需字段;多余字段 JSON 解析保留不报错)。
|
||||
// SkillInfo 从 ./relay 复用(同协议层定义,避免重复)。
|
||||
|
||||
/** 项目记录(对齐桌面端 src/api/types.ts:80 ProjectRecord,miniapp 精简版) */
|
||||
export interface ProjectRecord {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
status: string
|
||||
idea_id: string | null
|
||||
/** 绑定的本地代码目录绝对路径(null=未绑定) */
|
||||
path: string | null
|
||||
/** 技术栈 JSON 数组字符串(如 ["rust","vue"],null=未探测) */
|
||||
stack: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 任务记录(对齐桌面端 src/api/types.ts:135 TaskRecord,miniapp 精简版) */
|
||||
export interface TaskRecord {
|
||||
id: string
|
||||
project_id: string
|
||||
title: string
|
||||
description: string
|
||||
status: string
|
||||
/** 优先级:0=critical, 1=high, 2=medium, 3=low */
|
||||
priority: number
|
||||
branch_name: string | null
|
||||
assignee: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 灵感记录(对齐桌面端 src/api/types.ts:11 IdeaRecord,miniapp 精简版) */
|
||||
export interface IdeaRecord {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
status: string
|
||||
priority: number
|
||||
score: number | null
|
||||
tags: string | null
|
||||
source: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* u-relay 协议类型镜像(对齐 E:/wk-lab/u-relay/src/)
|
||||
*
|
||||
* 类型证据:
|
||||
* - ClientKind / MessageKind:u-relay/src/broadcast.rs
|
||||
* (#[serde(rename_all = "snake_case")] → 'agent'/'commander', 'event'/'command'/'control')
|
||||
* - BroadcastMessage:broadcast.rs(channel_id/kind/source/from/payload/ts)
|
||||
* - Hello 握手帧:u-relay/src/relay.rs(kind/channel_id/token)
|
||||
* kind 用 ClientKindWire,序列化为 'agent'/'commander'
|
||||
* - 路由端点:relay.rs `/ws/agent`(执行端)/ `/ws/commander`(控制端)
|
||||
*
|
||||
* 关键协议点(relay.rs handle_inbound_text):
|
||||
* relay 不解析 payload —— commander 入站文本帧直接当 payload 包成 BroadcastMessage 路由给 agent。
|
||||
* 故 miniapp 发送时,直接序列化 Command JSON(relay 自动包 payload),不需客户端构造 BroadcastMessage。
|
||||
* 接收时,agent 事件经 relay 包成完整 BroadcastMessage 序列化下发,miniapp 需先解 BroadcastMessage
|
||||
* 再从 payload 取 AiChatEvent。
|
||||
*
|
||||
* 历史注:df-relay 一代协议(kind:'device'/'miniapp' + device_id)已在 u-relay 全部下线,
|
||||
* 旧名握手期直接拒绝(端点 /ws/miniapp、/ws/device 均已失效)。
|
||||
*/
|
||||
|
||||
/** 客户端类型(对齐 Rust ClientKind,snake_case 序列化;u-relay 现行仅此两值) */
|
||||
export type ClientKind = 'agent' | 'commander'
|
||||
|
||||
/** 消息语义分类(对齐 Rust MessageKind,snake_case 序列化) */
|
||||
export type MessageKind = 'event' | 'command' | 'control'
|
||||
|
||||
/** 在线状态(对齐 Rust PresenceState,snake_case 序列化) */
|
||||
export type PresenceState = 'online' | 'offline'
|
||||
|
||||
/** ConnId 是 Rust u64(对齐 conn.rs ConnId struct),TS 用 number;0 为 NIL 伪值 */
|
||||
export type ConnId = number
|
||||
|
||||
/**
|
||||
* 中继路由消息骨架(对齐 Rust BroadcastMessage)
|
||||
*
|
||||
* miniapp 接收时 payload 为 AiChatEvent JSON(relay 透传不解析)。
|
||||
*/
|
||||
export interface BroadcastMessage {
|
||||
/** 配对绑定的通道 ID */
|
||||
channel_id: string
|
||||
/** 消息语义 */
|
||||
kind: MessageKind
|
||||
/** 消息来源连接 ID(relay 分配,u64;0=relay 自构造 NIL 伪值) */
|
||||
source: ConnId
|
||||
/** 来源客户端类型 */
|
||||
from: ClientKind
|
||||
/** 载荷(原始 JSON,relay 透传不解析 —— Event 时为 AiChatEvent) */
|
||||
payload: unknown
|
||||
/** 时间戳(ms) */
|
||||
ts: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端首消息:身份宣告(对齐 Rust Hello)
|
||||
*
|
||||
* 小程序连 `/ws/commander` 后首帧必须发此结构(10s 内),否则 relay 关连接。
|
||||
* kind 恒为 'commander'(与端点匹配,不匹配 relay 回 kind_mismatch 错误帧 + Close)。
|
||||
*/
|
||||
export interface Hello {
|
||||
/** 客户端类型,小程序(控制端)恒 'commander'(对齐 ClientKindWire) */
|
||||
kind: 'commander'
|
||||
/** 配对绑定的通道 ID(非空,≤128B,无控制字符) */
|
||||
channel_id: string
|
||||
/** 配对 token(env U_RELAY_TOKEN,与桌面端共享) */
|
||||
token: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 控制面消息(对齐 Rust ControlMessage,tag = "control_kind")
|
||||
*
|
||||
* miniapp 主要发 Ping 心跳(relay 本地直回 pong,不经对端);Pair/Heartbeat/Presence 骨架保留。
|
||||
*/
|
||||
export type ControlMessage =
|
||||
| { control_kind: 'pair'; channel_id: string; token: string }
|
||||
| { control_kind: 'heartbeat'; channel_id: string }
|
||||
| { control_kind: 'presence'; channel_id: string; state: PresenceState }
|
||||
| { control_kind: 'ping' }
|
||||
| { control_kind: 'pong' }
|
||||
|
||||
/**
|
||||
* 小程序(控制端)→ 桌面端(执行端)Command 协议
|
||||
*
|
||||
* 类型为对齐 Tauri command 名,relay 透传 channel_id 路由到桌面端,桌面端 invoke 对应 command。
|
||||
* 字段:
|
||||
* - cmd:命令名(对齐 src-tauri command 函数名)
|
||||
* - args:命令参数(对齐各 command 的参数)
|
||||
*
|
||||
* 路由表全覆盖(14 条):
|
||||
* - send_message / stop / switch_conversation / regenerate(MVP 已发)
|
||||
* - approve / authorize_dir / continue_loop / stop_loop(阶段3 补齐,见 useAiChat.ts)
|
||||
* - list_conversations / load_messages(F-#95 扩展,会话列表/历史消息读取)
|
||||
* - list_skills / list_entities(F-#95 联想扩展,技能/项目·任务·灵感列表,见 useAiChat.ts)
|
||||
* - rename_conversation(会话重命名,2026-06-23)/ sync_pending(重连审批恢复,2026-06-23)
|
||||
* - list_models(每会话独立模型,模型选择器拉取 provider enabled 模型列表,见 useAiChat.ts)
|
||||
*/
|
||||
export interface MiniCommand {
|
||||
/** Tauri command 名 */
|
||||
cmd: string
|
||||
/** 命令参数(原始对象,对齐各 command 参数签名) */
|
||||
args: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** send_message 命令参数(对齐 src-tauri ai_chat_send command) */
|
||||
export interface SendMessageArgs {
|
||||
message: string
|
||||
conversation_id?: string | null
|
||||
model_override?: string | null
|
||||
/**
|
||||
* 跨端新会话语义(2026-08-05):miniapp 新建会话(active=null)后首次发送置 true,
|
||||
* device 端据此强制创建新 conv id(不走 active 兜底,防串旧会话)。
|
||||
* 普通发送/已传 conversation_id 时不传(默认 false)。
|
||||
*/
|
||||
new_conversation?: boolean
|
||||
/**
|
||||
* 选中技能名(`/<skill>` 联想选中后透传,对齐 ai_chat_send 的 skill 参数)。
|
||||
* null/undefined=无技能(普通对话);非空=后端注入对应 SKILL.md 全文到 prompt。
|
||||
*/
|
||||
skill?: string | null
|
||||
/**
|
||||
* @ mention 区间元数据(用户选中 @项目/@任务/@灵感 后透传,
|
||||
* 对齐 ai_chat_send 的 mention_spans 参数,后端 resolve 投影成 Augmentation 注入)。
|
||||
* null/undefined/空=无 mention(纯文本消息)。
|
||||
*/
|
||||
mention_spans?: MentionSpan[] | null
|
||||
/**
|
||||
* 多模态片段(图片输入):miniapp 选图转 base64 构造 ContentPart Image 数组透传,
|
||||
* 对齐后端 ai_chat_send 的 parts 参数(df-ai-core ContentPart,serde 内部标签 tag=type)。
|
||||
* null/undefined/空=纯文本消息(零回归)。
|
||||
*/
|
||||
parts?: MiniContentPart[] | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 多模态内容片段(对齐后端 df-ai-core/src/types.rs ContentPart)。
|
||||
* serde 内部标签 `#[serde(tag="type", rename_all="snake_case")]`:Image 片段 JSON 形如
|
||||
* `{"type":"image","url":...,"media_type":...}`。
|
||||
*/
|
||||
export type MiniContentPart =
|
||||
| { type: 'text'; text: string }
|
||||
| {
|
||||
type: 'image'
|
||||
url?: string | null
|
||||
base64?: string | null
|
||||
media_type?: string | null
|
||||
alt?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户消息内 mention 区间的元数据
|
||||
* (对齐后端 MentionSpanDto,crates/df-types/src/augmentation.rs:235)。
|
||||
*
|
||||
* 字段名对齐后端 serde:refId(camelCase,后端 serde rename)。
|
||||
* 前端按 {start, length} 在原文中切出区间替换为 chip。
|
||||
*/
|
||||
export interface MentionSpan {
|
||||
/** 区间起点(字符偏移,前端约定,后端仅透传不解释) */
|
||||
start: number
|
||||
/** 区间长度 */
|
||||
length: number
|
||||
/** kind 标签:project / task / idea / skill(对齐后端 MentionKind serde snake_case) */
|
||||
kind: 'project' | 'task' | 'idea' | 'skill'
|
||||
/** 引用稳定标识(Project/Task/Idea 为 id,Skill 为 name),前端用于反查 */
|
||||
refId: string
|
||||
/** chip 展示文本(项目名/任务标题/技能名) */
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 技能信息(对齐桌面端 src/api/types.ts:614 SkillInfo + 后端 SkillInfo,
|
||||
* src-tauri/src/commands/ai/skills.rs:19-34)。
|
||||
*
|
||||
* 供 `/` 联想浮层渲染 + 选中后 name 透传 SendMessageArgs.skill。
|
||||
* duplicates 可选:后端 #[serde(skip_serializing_if=Option::is_none)] 无冲突不序列化,
|
||||
* miniapp 加 `?` 兜底防 prod 反序列化宽松报错。
|
||||
*/
|
||||
export interface SkillInfo {
|
||||
/** 技能名(联想浮层展示 + 选中后透传 skill 参数) */
|
||||
name: string
|
||||
/** 技能描述(浮层副标题) */
|
||||
description: string
|
||||
/** 参数提示(可选,如 "<url>") */
|
||||
argument_hint?: string
|
||||
/** skill | command | plugin(来源分类) */
|
||||
source: string
|
||||
/** SKILL.md 绝对路径(后端注入时读全文) */
|
||||
path: string
|
||||
/** 同名冲突的其他来源路径(可选,无冲突不序列化) */
|
||||
duplicates?: string[]
|
||||
}
|
||||
|
||||
/** switch_conversation 命令参数 */
|
||||
export interface SwitchConversationArgs {
|
||||
conversation_id: string
|
||||
}
|
||||
|
||||
/** stop 命令参数(对齐 ai_chat_stop) */
|
||||
export interface StopArgs {
|
||||
conversation_id?: string | null
|
||||
}
|
||||
|
||||
/** approve 命令参数(对齐 src-tauri ai_approve,Phase3 §2.2 路由表) */
|
||||
export interface ApproveArgs {
|
||||
/** 待审批的工具调用 id(对应 AiApprovalRequired 事件的 id) */
|
||||
tool_call_id: string
|
||||
/** 是否批准(true=批准执行,false=拒绝) */
|
||||
approved: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* authorize_dir 命令参数(对齐 src-tauri ai_authorize_dir,Phase3 §2.2 路由表)
|
||||
*
|
||||
* decision 取值:对齐 src-tauri ai_authorize_dir 的 decision 参数
|
||||
* - 'once':本次授权(下次同路径仍需问)
|
||||
* - 'always':永久授权(写入会话级信任目录)
|
||||
* - 'deny':拒绝(本次不授权)
|
||||
*/
|
||||
export interface AuthorizeDirArgs {
|
||||
/** 待授权的工具调用 id(对应 AiDirAuthRequired 事件的 id) */
|
||||
tool_call_id: string
|
||||
/** 授权决策('once' / 'always' / 'deny') */
|
||||
decision: 'once' | 'always' | 'deny'
|
||||
}
|
||||
|
||||
/** continue_loop 命令参数(对齐 src-tauri ai_continue_loop,Phase3 §2.2 路由表) */
|
||||
export interface ContinueLoopArgs {
|
||||
/** 需继续运行的会话 id */
|
||||
conversation_id: string
|
||||
}
|
||||
|
||||
/** stop_loop 命令参数(对齐 src-tauri ai_stop_loop,Phase3 §2.2 路由表) */
|
||||
export interface StopLoopArgs {
|
||||
/** 需停止循环的会话 id */
|
||||
conversation_id: string
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 全局样式变量(uni-app 规范:uni.scss 由 uni-app 自动引入到各页面/组件)
|
||||
*
|
||||
* 颜色基调对齐桌面端暗色主题(便于跨端视觉一致)。
|
||||
*/
|
||||
|
||||
/* 主色调 */
|
||||
$uni-color-primary: #4a9eff;
|
||||
$uni-color-success: #4cd964;
|
||||
$uni-color-warning: #f0ad4e;
|
||||
$uni-color-error: #dd524d;
|
||||
|
||||
/* 中性色(暗色背景适配) */
|
||||
$uni-bg-color: #0f0f0f;
|
||||
$uni-bg-color-dark: #1a1a1a;
|
||||
$uni-bg-color-light: #2a2a2a;
|
||||
$uni-text-color: #e0e0e0;
|
||||
$uni-text-color-grey: #999999;
|
||||
$uni-text-color-disable: #666666;
|
||||
$uni-border-color: #333333;
|
||||
|
||||
/* 字号 */
|
||||
$uni-font-size-sm: 24rpx;
|
||||
$uni-font-size-base: 28rpx;
|
||||
$uni-font-size-lg: 32rpx;
|
||||
|
||||
/* 气泡角色配色 */
|
||||
$uni-bubble-user-bg: #4a9eff;
|
||||
$uni-bubble-user-text: #ffffff;
|
||||
$uni-bubble-assistant-bg: #2a2a2a;
|
||||
$uni-bubble-assistant-text: #e0e0e0;
|
||||
$uni-bubble-error-bg: #3a1a1a;
|
||||
$uni-bubble-error-text: #ff6b6b;
|
||||
@@ -0,0 +1,34 @@
|
||||
/** file.1216.top 文件服务上传客户端。
|
||||
* POST /upload multipart 'file' 字段 + 'X-Source' 头 → {"result":{"url":"https://..."},"retcode":0,"success":true}
|
||||
*/
|
||||
import { getConfig } from '@/config'
|
||||
|
||||
export interface UploadResult { url: string }
|
||||
|
||||
/** 上传图片到 file.1216.top,返回公开 URL。失败 reject(调用方回退 base64)。 */
|
||||
export function uploadImage(tempFilePath: string): Promise<UploadResult> {
|
||||
const cfg = getConfig()
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url: `${cfg.fileUploadBaseUrl}/upload`,
|
||||
filePath: tempFilePath,
|
||||
name: 'file',
|
||||
header: { 'X-Source': cfg.fileUploadSource },
|
||||
timeout: 15000,
|
||||
success: (res) => {
|
||||
try {
|
||||
const data = JSON.parse(res.data as string)
|
||||
const url = data?.result?.url as string | undefined
|
||||
if (data?.success === true && data?.retcode === 0 && url) {
|
||||
resolve({ url })
|
||||
} else {
|
||||
reject(new Error(`上传响应异常: ${res.data}`))
|
||||
}
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
},
|
||||
fail: (err) => reject(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* marked HTML 后处理:为代码块/内联 code/链接/表格/图片/任务列表注入 inline style。
|
||||
*
|
||||
* 为什么后处理而非 marked 自定义 renderer:
|
||||
* - mp-weixin rich-text 不链接页面 CSS class,仅认节点 inline style → 必须把样式写进 HTML 属性。
|
||||
* - 纯字符串后处理规避 marked renderer 版本签名差异(v5 前 (text,info) / v5+ Token 对象),
|
||||
* 版本无关,build 不依赖 renderer TS 签名。
|
||||
*
|
||||
* mp-weixin rich-text 限制(已核官方文档,非臆测):
|
||||
* - <a> 节点事件屏蔽,无法 bindtap 跳转 → 链接仅展示 + 长按复制(rich-text selectable=true);
|
||||
* 跳转需第三方 mp-html(本次不做,记后续)。
|
||||
* - rich-text 非滚动容器,overflow-y:auto/max-height 不创建滚动区 → 长代码靠
|
||||
* white-space:pre-wrap + word-break:break-all 换行防撑爆,不限高滚动。
|
||||
* - <img> 网络图须域名进微信「downloadFile 合法域名」白名单,未配置时真机不显示
|
||||
* (基础库限制,非渲染代码问题);开发者后台配白名单后即正常。
|
||||
* - <input> 不在 rich-text 节点白名单 → GFM 任务列表勾选框被剥离成纯文本,见下替换。
|
||||
*/
|
||||
|
||||
// 语法高亮:highlight.js core + 按需注册常用语言(包体积敏感,不引全量)。
|
||||
// 无 DOM 不可用 highlightElement,hljs 产出的 token class 由 inlineHljs 转 inline style。
|
||||
import hljs from 'highlight.js/lib/core'
|
||||
import javascript from 'highlight.js/lib/languages/javascript'
|
||||
import typescript from 'highlight.js/lib/languages/typescript'
|
||||
import json from 'highlight.js/lib/languages/json'
|
||||
import xml from 'highlight.js/lib/languages/xml'
|
||||
import css from 'highlight.js/lib/languages/css'
|
||||
import markdown from 'highlight.js/lib/languages/markdown'
|
||||
import rust from 'highlight.js/lib/languages/rust'
|
||||
import go from 'highlight.js/lib/languages/go'
|
||||
import python from 'highlight.js/lib/languages/python'
|
||||
import java from 'highlight.js/lib/languages/java'
|
||||
import bash from 'highlight.js/lib/languages/bash'
|
||||
import sql from 'highlight.js/lib/languages/sql'
|
||||
import yaml from 'highlight.js/lib/languages/yaml'
|
||||
import diff from 'highlight.js/lib/languages/diff'
|
||||
|
||||
hljs.registerLanguage('javascript', javascript)
|
||||
hljs.registerLanguage('typescript', typescript)
|
||||
hljs.registerLanguage('json', json)
|
||||
hljs.registerLanguage('xml', xml)
|
||||
hljs.registerLanguage('css', css)
|
||||
hljs.registerLanguage('markdown', markdown)
|
||||
hljs.registerLanguage('rust', rust)
|
||||
hljs.registerLanguage('go', go)
|
||||
hljs.registerLanguage('python', python)
|
||||
hljs.registerLanguage('java', java)
|
||||
hljs.registerLanguage('bash', bash)
|
||||
hljs.registerLanguage('sql', sql)
|
||||
hljs.registerLanguage('yaml', yaml)
|
||||
hljs.registerLanguage('diff', diff)
|
||||
|
||||
// 围栏 info 常见别名 → 已注册语言(hljs.getLanguage 自带部分别名,这里补齐业务常用)
|
||||
const LANG_ALIASES: Record<string, string> = {
|
||||
js: 'javascript', ts: 'typescript', py: 'python',
|
||||
sh: 'bash', shell: 'bash', yml: 'yaml', md: 'markdown',
|
||||
html: 'xml', vue: 'xml', svg: 'xml',
|
||||
}
|
||||
|
||||
/** hljs token class → inline style(rich-text 认 style 不认 class;色板对齐桌面 github-dark) */
|
||||
const TOKEN_STYLE: Record<string, string> = {
|
||||
'hljs-keyword': 'color:#ff7b72',
|
||||
'hljs-doctag': 'color:#ff7b72',
|
||||
'hljs-meta': 'color:#ff7b72',
|
||||
'hljs-type': 'color:#ff7b72',
|
||||
'hljs-template-tag': 'color:#ff7b72',
|
||||
'hljs-template-variable': 'color:#ff7b72',
|
||||
'hljs-title': 'color:#d2a8ff',
|
||||
'hljs-attr': 'color:#79c0ff',
|
||||
'hljs-attribute': 'color:#79c0ff',
|
||||
'hljs-literal': 'color:#79c0ff',
|
||||
'hljs-number': 'color:#79c0ff',
|
||||
'hljs-operator': 'color:#79c0ff',
|
||||
'hljs-variable': 'color:#79c0ff',
|
||||
'hljs-selector-attr': 'color:#79c0ff',
|
||||
'hljs-selector-class': 'color:#79c0ff',
|
||||
'hljs-selector-id': 'color:#79c0ff',
|
||||
'hljs-regexp': 'color:#a5d6ff',
|
||||
'hljs-string': 'color:#a5d6ff',
|
||||
'hljs-built_in': 'color:#ffa657',
|
||||
'hljs-symbol': 'color:#ffa657',
|
||||
'hljs-comment': 'color:#8b949e',
|
||||
'hljs-code': 'color:#8b949e',
|
||||
'hljs-formula': 'color:#8b949e',
|
||||
'hljs-name': 'color:#7ee787',
|
||||
'hljs-quote': 'color:#7ee787',
|
||||
'hljs-selector-tag': 'color:#7ee787',
|
||||
'hljs-selector-pseudo': 'color:#7ee787',
|
||||
'hljs-section': 'color:#1f6feb;font-weight:bold',
|
||||
'hljs-bullet': 'color:#f2cc60',
|
||||
'hljs-addition': 'color:#aff5b4;background-color:#033a16',
|
||||
'hljs-deletion': 'color:#ffdcd7;background-color:#67060c',
|
||||
'hljs-emphasis': 'font-style:italic',
|
||||
'hljs-strong': 'font-weight:bold',
|
||||
}
|
||||
|
||||
/** 给指定标签注入 inline style(已有 style 则合并,无则新增)。 */
|
||||
function styleTag(html: string, tag: string, style: string): string {
|
||||
const re = new RegExp(`<${tag}(\\s[^>]*)?>`, 'g')
|
||||
return html.replace(re, (m, attrs: string) => {
|
||||
if (attrs && /\sstyle\s*=/.test(attrs)) {
|
||||
// 已有 style:追加(在现有 style 值末尾加分号 + 新 style)
|
||||
return m.replace(/style\s*=\s*"([^"]*)"/, (_full, prev: string) => {
|
||||
const merged = prev.endsWith(';') ? prev : `${prev};`
|
||||
return `style="${merged}${style}"`
|
||||
})
|
||||
}
|
||||
return `<${tag}${attrs || ''} style="${style}">`
|
||||
})
|
||||
}
|
||||
|
||||
/** 反转 marked 对代码块内容的 HTML 转义,还原原始代码文本供 hljs 高亮 */
|
||||
function unescapeCode(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
|
||||
/** hljs token class → inline style(无 DOM 不能 highlightElement,逐 span 转内联) */
|
||||
function inlineHljs(html: string): string {
|
||||
return html.replace(/<span class="([^"]+)">/g, (_m, cls: string) => {
|
||||
const style = cls
|
||||
.split(/\s+/)
|
||||
.map((c) => TOKEN_STYLE[c])
|
||||
.filter(Boolean)
|
||||
.join(';')
|
||||
return style ? `<span style="${style}">` : '<span>'
|
||||
})
|
||||
}
|
||||
|
||||
/** 代码块语言徽标(rich-text 内联 style;块代码顶部展示 fence lang,无 lang 不调用)。 */
|
||||
function codeLangBadge(lang: string): string {
|
||||
// 块级 div(pre 深底上浅灰标签 + 圆角,pre 自身已有内边距),放 <code> 前占据顶部一行
|
||||
return `<div style="font-size:10px;color:#9e9e9e;background-color:#2e2e2e;padding:1px 8px;border-radius:4px;margin-bottom:6px">${lang}</div>`
|
||||
}
|
||||
|
||||
/**
|
||||
* 块代码语法高亮:仅处理已知语言,未知/无语言兜底原样(确定性强,不做自动猜测)。
|
||||
* marked 已对代码内容做 HTML 转义,先反转还原再交给 hljs(hljs 输出自带转义)。
|
||||
* 同时补语言徽标:围栏有 lang 即显示(用原始标识如 ts,更贴近用户书写),无 lang 不显示。
|
||||
*/
|
||||
function highlightCodeBlocks(html: string): string {
|
||||
return html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/gi, (m, attrs: string, inner: string) => {
|
||||
const langMatch = /class="language-([^"]+)"/i.exec(attrs)
|
||||
const langRaw = langMatch ? langMatch[1] : ''
|
||||
const lang = LANG_ALIASES[langRaw] ?? langRaw
|
||||
const badge = langRaw ? codeLangBadge(langRaw) : ''
|
||||
if (!lang || !hljs.getLanguage(lang)) {
|
||||
// 未注册语言:不高亮但保留徽标(fence 有 lang 时),避免 ` ```未知lang ` 丢语言标签
|
||||
return badge ? `<pre><code${attrs}>${badge}${inner}</code></pre>` : m
|
||||
}
|
||||
let value: string
|
||||
try {
|
||||
value = hljs.highlight(unescapeCode(inner), { language: lang, ignoreIllegals: true }).value
|
||||
} catch {
|
||||
return badge ? `<pre><code${attrs}>${badge}${inner}</code></pre>` : m
|
||||
}
|
||||
return `<pre><code${attrs}>${badge}${inlineHljs(value)}</code></pre>`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 对 marked.parse 产出的 HTML 注入 inline style。
|
||||
*
|
||||
* 设计取舍:pre(块代码)给深色背景;<code> 不给背景(透明),仅等宽 + 橙色 —— 避免块代码内层 code
|
||||
* 与 pre 背景冲突的双层色块(块代码内 code 透明继承 pre 深底,内联 code 橙色等宽在消息底上也可读);
|
||||
* 块代码经 hljs 高亮后,关键字/字符串等 token 由内联色覆盖,未命中 token 的文本保留 code 基色。
|
||||
*
|
||||
* 正文颜色(真机确认):rich-text 只认节点 inline style,不继承页面 CSS `.msg text`
|
||||
* (模板的 `.msg text{color:#e0e0e0}` 仅命中 `<text>` 子元素)。marked 产出的 p/h1-6/li/td/th
|
||||
* 等不带 style 时,文本在暗色气泡上呈近黑 → 暗底黑字。故给承载正文的块级元素统一注入颜色。
|
||||
* strong/em/b 等行内元素继承父级(p/li)颜色,无需逐一注入。
|
||||
*/
|
||||
export function styleMarkdown(html: string): string {
|
||||
let out = html
|
||||
// 任务列表勾选框:rich-text 节点白名单不含 input,替换为字符符号(勾选/未勾选)
|
||||
out = out.replace(/<input[^>]*type="checkbox"[^>]*>/gi, (m) => {
|
||||
const checked = /checked/i.test(m)
|
||||
const color = checked ? '#6bd99a' : '#666666'
|
||||
return `<span style="color:${color};margin-right:4px">${checked ? '☑' : '☐'}</span>`
|
||||
})
|
||||
// 块代码语法高亮(须在下方 pre/code styleTag 前执行,hljs 输出 token span 后再合并基样式)
|
||||
out = highlightCodeBlocks(out)
|
||||
// 正文着色:块级文本容器统一浅色(rich-text 样式继承链从容器传子文本)
|
||||
// word-break:break-all 防长 URL/长单词撑开气泡(rich-text 内文本不继承外层 .msg 的
|
||||
// word-break,须 inline 注入;对齐文件头"rich-text 只认 inline style")。
|
||||
const BODY_STYLE = 'color:#e0e0e0;word-break:break-all'
|
||||
for (const tag of ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'blockquote', 'td']) {
|
||||
out = styleTag(out, tag, BODY_STYLE)
|
||||
}
|
||||
// 块代码 <pre>:深色背景 + 等宽 + 圆角 + pre-wrap 换行防撑爆
|
||||
out = styleTag(
|
||||
out,
|
||||
'pre',
|
||||
'display:block;background-color:#1a1a1a;color:#d4d4d4;padding:10px;border-radius:6px;white-space:pre-wrap;word-break:break-all;font-family:monospace;font-size:12px;margin:6px 0',
|
||||
)
|
||||
// code(块内 + 内联):等宽橙色,透明背景(避与 pre 双层色块;块代码 token 由高亮色覆盖)
|
||||
out = styleTag(
|
||||
out,
|
||||
'code',
|
||||
'font-family:monospace;color:#e0a070;background-color:transparent;font-size:12px',
|
||||
)
|
||||
// 链接:蓝下划线(rich-text 不可点击,仅展示 + selectable 长按复制);word-break 防长 URL 撑开
|
||||
out = styleTag(out, 'a', 'color:#4a9eff;text-decoration:underline;word-break:break-all')
|
||||
// 图片:max-width 防超宽撑爆气泡;block 去基线间隙;圆角与气泡一致。
|
||||
// 网络图显示依赖微信「downloadFile 合法域名」白名单(见文件头),非渲染问题。
|
||||
out = styleTag(out, 'img', 'max-width:100%;display:block;border-radius:6px')
|
||||
// 表格:显式 table 布局 + 等宽字列。注意不能用 display:block(会失去表格布局语义、
|
||||
// 列宽错乱),故显式声明 display:table;rich-text 非滚动容器(见文件头注释),无法建横向
|
||||
// 滚动区 → 不做滚动容器,靠 max-width:100% + word-break:break-all 防超宽撑爆气泡;
|
||||
// border 提升可读。
|
||||
out = styleTag(
|
||||
out,
|
||||
'table',
|
||||
'display:table;border-collapse:collapse;width:100%;max-width:100%;font-size:12px;word-break:break-all',
|
||||
)
|
||||
// th/td:边框 + 内边距保留,字号与 table 对齐(font-size:12px)
|
||||
out = styleTag(out, 'th', 'border:1px solid #333;padding:4px 6px;font-size:12px;background-color:#1a1a1a')
|
||||
out = styleTag(out, 'td', 'border:1px solid #333;padding:4px 6px;font-size:12px')
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"jsx": "preserve",
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"types": ["@dcloudio/types", "@types/node", "vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"exclude": ["node_modules", "dist", "unpackage"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import uni from '@dcloudio/vite-plugin-uni'
|
||||
|
||||
// uni-app Vue3 vite 配置:vite-plugin-uni 注入跨端编译(mp-weixin / h5)。
|
||||
// Phase2 落地遗漏本文件致 `uni build -p mp-weixin` 走纯 vite h5(只产 index.html),
|
||||
// 补回后 -p mp-weixin 正确产出小程序文件(app.js/app.json/pages/*)。
|
||||
export default defineConfig({
|
||||
plugins: [uni()],
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
# Bug 修复模板
|
||||
name: Bug 修复
|
||||
description: AI 驱动的 bug 修复流程 — 定位、修复、验证、通知
|
||||
|
||||
nodes:
|
||||
locate:
|
||||
type: ai
|
||||
label: 定位 Bug
|
||||
config:
|
||||
prompt: |
|
||||
根据错误描述和堆栈信息,定位 bug 的根因。
|
||||
使用 search_files 和 read_file 工具查找相关代码。
|
||||
persona_id: analyst
|
||||
|
||||
fix:
|
||||
type: ai
|
||||
label: 修复 Bug
|
||||
config:
|
||||
prompt: |
|
||||
基于定位结果,修复 bug。
|
||||
使用 patch_file 工具进行最小化修改。
|
||||
persona_id: coder
|
||||
|
||||
verify:
|
||||
type: ai
|
||||
label: 验证修复
|
||||
config:
|
||||
prompt: |
|
||||
验证修复是否有效。
|
||||
使用 run_command 工具运行相关测试。
|
||||
persona_id: tester
|
||||
|
||||
notify:
|
||||
type: notify
|
||||
label: 通知结果
|
||||
config:
|
||||
type: desktop
|
||||
title: Bug 修复完成
|
||||
message: 修复已验证
|
||||
|
||||
edges:
|
||||
- from: locate
|
||||
to: fix
|
||||
- from: fix
|
||||
to: verify
|
||||
- from: verify
|
||||
to: notify
|
||||
@@ -0,0 +1,36 @@
|
||||
# 代码审查模板
|
||||
name: 代码审查
|
||||
description: AI 驱动的代码审查流程 — 分析变更、审查质量、通知结果
|
||||
|
||||
nodes:
|
||||
analyze:
|
||||
type: ai
|
||||
label: 分析代码变更
|
||||
config:
|
||||
prompt: |
|
||||
分析以下代码变更,识别:
|
||||
1. 潜在 bug 和逻辑错误
|
||||
2. 安全漏洞
|
||||
3. 性能问题
|
||||
4. 代码规范
|
||||
persona_id: reviewer
|
||||
|
||||
report:
|
||||
type: ai
|
||||
label: 生成审查报告
|
||||
config:
|
||||
prompt: 基于分析结果,生成结构化审查报告,含优先级排序的改进建议
|
||||
|
||||
notify:
|
||||
type: notify
|
||||
label: 通知完成
|
||||
config:
|
||||
type: desktop
|
||||
title: 代码审查完成
|
||||
message: 审查报告已生成
|
||||
|
||||
edges:
|
||||
- from: analyze
|
||||
to: report
|
||||
- from: report
|
||||
to: notify
|
||||
@@ -0,0 +1,62 @@
|
||||
# 功能开发模板
|
||||
name: 功能开发
|
||||
description: AI 驱动的功能开发全流程 — 设计、编码、测试、审查、通知
|
||||
|
||||
nodes:
|
||||
design:
|
||||
type: ai
|
||||
label: 方案设计
|
||||
config:
|
||||
prompt: |
|
||||
分析需求,设计实现方案:
|
||||
1. 模块划分和接口定义
|
||||
2. 数据结构和核心算法
|
||||
3. 影响范围评估
|
||||
persona_id: architect
|
||||
|
||||
implement:
|
||||
type: ai
|
||||
label: 编码实现
|
||||
config:
|
||||
prompt: |
|
||||
按照设计方案实现功能。
|
||||
使用 write_file 和 patch_file 工具编写代码。
|
||||
persona_id: coder
|
||||
|
||||
test:
|
||||
type: ai
|
||||
label: 编写测试
|
||||
config:
|
||||
prompt: |
|
||||
为新功能编写单元测试和集成测试。
|
||||
使用 run_command 工具运行测试验证。
|
||||
persona_id: tester
|
||||
|
||||
review:
|
||||
type: ai
|
||||
label: 代码审查
|
||||
config:
|
||||
prompt: |
|
||||
审查实现代码,关注:
|
||||
1. 代码质量和可维护性
|
||||
2. 潜在 bug
|
||||
3. 安全性
|
||||
persona_id: reviewer
|
||||
|
||||
notify:
|
||||
type: notify
|
||||
label: 通知完成
|
||||
config:
|
||||
type: desktop
|
||||
title: 功能开发完成
|
||||
message: 设计→编码→测试→审查 全流程完成
|
||||
|
||||
edges:
|
||||
- from: design
|
||||
to: implement
|
||||
- from: implement
|
||||
to: test
|
||||
- from: test
|
||||
to: review
|
||||
- from: review
|
||||
to: notify
|
||||
@@ -5,10 +5,18 @@
|
||||
"": {
|
||||
"name": "devflow",
|
||||
"dependencies": {
|
||||
"@antv/layout": "^2.0.0",
|
||||
"@antv/x6": "^3.1.7",
|
||||
"@antv/x6-vue-shape": "^3.0.2",
|
||||
"@arco-design/web-vue": "^2.58.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"dompurify": "^3.4.9",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@types/dagre": "^0.7.54",
|
||||
"dagre": "^0.8.5",
|
||||
"dompurify": "^3.4.10",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^18.0.5",
|
||||
"tslib": "^2.8.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "9",
|
||||
"vue-router": "4",
|
||||
@@ -24,6 +32,20 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@antv/event-emitter": ["@antv/event-emitter@0.1.3", "https://registry.npmmirror.com/@antv/event-emitter/-/event-emitter-0.1.3.tgz", {}, "sha512-4ddpsiHN9Pd4UIlWuKVK1C4IiZIdbwQvy9i7DUSI3xNJ89FPUFt8lxDYj8GzzfdllV0NkJTRxnG+FvLk0llidg=="],
|
||||
|
||||
"@antv/expr": ["@antv/expr@1.0.2", "https://registry.npmmirror.com/@antv/expr/-/expr-1.0.2.tgz", {}, "sha512-vrfdmPHkTuiS5voVutKl2l06w1ihBh9A8SFdQPEE+2KMVpkymzGOF1eWpfkbGZ7tiFE15GodVdhhHomD/hdIwg=="],
|
||||
|
||||
"@antv/graphlib": ["@antv/graphlib@2.0.4", "https://registry.npmmirror.com/@antv/graphlib/-/graphlib-2.0.4.tgz", { "dependencies": { "@antv/event-emitter": "^0.1.3" } }, "sha512-zc/5oQlsdk42Z0ib1mGklwzhJ5vczLFiPa1v7DgJkTbgJ2YxRh9xdarf86zI49sKVJmgbweRpJs7Nu5bIiwv4w=="],
|
||||
|
||||
"@antv/layout": ["@antv/layout@2.0.0", "https://registry.npmmirror.com/@antv/layout/-/layout-2.0.0.tgz", { "dependencies": { "@antv/event-emitter": "^0.1.3", "@antv/expr": "^1.0.2", "@antv/graphlib": "^2.0.0", "@antv/util": "^3.3.2", "comlink": "^4.4.1", "d3-force": "^3.0.0", "d3-force-3d": "^3.0.5", "d3-octree": "^1.0.2", "d3-quadtree": "^3.0.1", "dagre": "^0.8.5", "ml-matrix": "^6.10.4", "tslib": "^2.8.1" } }, "sha512-aCZ3UdNc40SfT7meFV7QTADY2HCnc0DShVw56CJNTI6oExUIVU736grPuL5Dhb8/JrVaU4Y83QPN/P7KafBzlw=="],
|
||||
|
||||
"@antv/util": ["@antv/util@3.3.11", "https://registry.npmmirror.com/@antv/util/-/util-3.3.11.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "gl-matrix": "^3.3.0", "tslib": "^2.3.1" } }, "sha512-FII08DFM4ABh2q5rPYdr0hMtKXRgeZazvXaFYCs7J7uTcWDHUhczab2qOCJLNDugoj8jFag1djb7wS9ehaRYBg=="],
|
||||
|
||||
"@antv/x6": ["@antv/x6@3.1.7", "https://registry.npmmirror.com/@antv/x6/-/x6-3.1.7.tgz", { "dependencies": { "dom-align": "^1.12.4", "lodash-es": "^4.17.15", "mousetrap": "^1.6.5", "utility-types": "^3.10.0" } }, "sha512-NLKXtbCK51oLbazfFD0XsD93rMmih08UBW4gAuEyLBpwAqHmHe+vP8VhOZDkl5O9jV1LSv85IJghr9CT5tZjWw=="],
|
||||
|
||||
"@antv/x6-vue-shape": ["@antv/x6-vue-shape@3.0.2", "https://registry.npmmirror.com/@antv/x6-vue-shape/-/x6-vue-shape-3.0.2.tgz", { "dependencies": { "vue-demi": "latest" }, "peerDependencies": { "@antv/x6": "^3.x", "@vue/composition-api": "^1.0.0-rc.1", "vue": "^2.0.0 || >=3.0.0" }, "optionalPeers": ["@vue/composition-api"] }, "sha512-HWnYFvLCaUD9ernINxTn9vCRj66eG/nXKErSSq0hfI4U1HOitC/1oUrzDdwL/BT80HKAQeSsmy7gpDA/79jYlw=="],
|
||||
|
||||
"@arco-design/color": ["@arco-design/color@0.4.0", "https://registry.npmmirror.com/@arco-design/color/-/color-0.4.0.tgz", { "dependencies": { "color": "^3.1.3" } }, "sha512-s7p9MSwJgHeL8DwcATaXvWT3m2SigKpxx4JA1BGPHL4gfvaQsmQfrLBDpjOJFJuJ2jG2dMt3R3P8Pm9E65q18g=="],
|
||||
|
||||
"@arco-design/web-vue": ["@arco-design/web-vue@2.58.0", "https://registry.npmmirror.com/@arco-design/web-vue/-/web-vue-2.58.0.tgz", { "dependencies": { "@arco-design/color": "^0.4.0", "b-tween": "^0.3.3", "b-validate": "^1.5.3", "compute-scroll-into-view": "^1.0.20", "dayjs": "^1.11.13", "number-precision": "^1.6.0", "resize-observer-polyfill": "^1.5.1", "scroll-into-view-if-needed": "^2.2.31", "vue": "^3.1.0" } }, "sha512-b1vdPYOmjG5VAkVa7jlVwCb+WynBK+rnKN8zH3yKohpZObZbostRd3HgYNtjjZjGVU3OqR0Yy2FX7ftgF0bcOw=="],
|
||||
@@ -172,6 +194,10 @@
|
||||
|
||||
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.2", "https://registry.npmmirror.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA=="],
|
||||
|
||||
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.1", "https://registry.npmmirror.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ=="],
|
||||
|
||||
"@types/dagre": ["@types/dagre@0.7.54", "https://registry.npmmirror.com/@types/dagre/-/dagre-0.7.54.tgz", {}, "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ=="],
|
||||
|
||||
"@types/dompurify": ["@types/dompurify@3.2.0", "https://registry.npmmirror.com/@types/dompurify/-/dompurify-3.2.0.tgz", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
@@ -228,15 +254,35 @@
|
||||
|
||||
"color-string": ["color-string@1.9.1", "https://registry.npmmirror.com/color-string/-/color-string-1.9.1.tgz", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
|
||||
|
||||
"comlink": ["comlink@4.4.2", "https://registry.npmmirror.com/comlink/-/comlink-4.4.2.tgz", {}, "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g=="],
|
||||
|
||||
"compute-scroll-into-view": ["compute-scroll-into-view@1.0.20", "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz", {}, "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"d3-binarytree": ["d3-binarytree@1.0.2", "https://registry.npmmirror.com/d3-binarytree/-/d3-binarytree-1.0.2.tgz", {}, "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw=="],
|
||||
|
||||
"d3-dispatch": ["d3-dispatch@3.0.1", "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
|
||||
|
||||
"d3-force": ["d3-force@3.0.0", "https://registry.npmmirror.com/d3-force/-/d3-force-3.0.0.tgz", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="],
|
||||
|
||||
"d3-force-3d": ["d3-force-3d@3.0.6", "https://registry.npmmirror.com/d3-force-3d/-/d3-force-3d-3.0.6.tgz", { "dependencies": { "d3-binarytree": "1", "d3-dispatch": "1 - 3", "d3-octree": "1", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA=="],
|
||||
|
||||
"d3-octree": ["d3-octree@1.1.0", "https://registry.npmmirror.com/d3-octree/-/d3-octree-1.1.0.tgz", {}, "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A=="],
|
||||
|
||||
"d3-quadtree": ["d3-quadtree@3.0.1", "https://registry.npmmirror.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="],
|
||||
|
||||
"d3-timer": ["d3-timer@3.0.1", "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
||||
|
||||
"dagre": ["dagre@0.8.5", "https://registry.npmmirror.com/dagre/-/dagre-0.8.5.tgz", { "dependencies": { "graphlib": "^2.1.8", "lodash": "^4.17.15" } }, "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw=="],
|
||||
|
||||
"dayjs": ["dayjs@1.11.21", "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="],
|
||||
|
||||
"de-indent": ["de-indent@1.0.2", "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="],
|
||||
|
||||
"dompurify": ["dompurify@3.4.9", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.9.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ=="],
|
||||
"dom-align": ["dom-align@1.12.4", "https://registry.npmmirror.com/dom-align/-/dom-align-1.12.4.tgz", {}, "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw=="],
|
||||
|
||||
"dompurify": ["dompurify@3.4.11", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.11.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="],
|
||||
|
||||
"entities": ["entities@7.0.1", "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
@@ -244,20 +290,44 @@
|
||||
|
||||
"estree-walker": ["estree-walker@2.0.2", "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"gl-matrix": ["gl-matrix@3.4.4", "https://registry.npmmirror.com/gl-matrix/-/gl-matrix-3.4.4.tgz", {}, "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ=="],
|
||||
|
||||
"graphlib": ["graphlib@2.1.8", "https://registry.npmmirror.com/graphlib/-/graphlib-2.1.8.tgz", { "dependencies": { "lodash": "^4.17.15" } }, "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A=="],
|
||||
|
||||
"he": ["he@1.2.0", "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
|
||||
|
||||
"highlight.js": ["highlight.js@11.11.1", "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
|
||||
|
||||
"is-any-array": ["is-any-array@3.0.0", "https://registry.npmmirror.com/is-any-array/-/is-any-array-3.0.0.tgz", {}, "sha512-o4h+tylWykC4BD1vaejp6gDxoM13bwW8FGuNs4yIKpj8xbBJcRxJx8vZpq0dCr7ZDEfeKjmsi/euolKhX6f/ww=="],
|
||||
|
||||
"is-arrayish": ["is-arrayish@0.3.4", "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.3.4.tgz", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
|
||||
|
||||
"lodash": ["lodash@4.18.1", "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
|
||||
|
||||
"lodash-es": ["lodash-es@4.18.1", "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"marked": ["marked@18.0.5", "https://registry.npmmirror.com/marked/-/marked-18.0.5.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="],
|
||||
|
||||
"minimatch": ["minimatch@9.0.9", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
||||
|
||||
"ml-array-max": ["ml-array-max@2.0.0", "https://registry.npmmirror.com/ml-array-max/-/ml-array-max-2.0.0.tgz", { "dependencies": { "is-any-array": "^3.0.0" } }, "sha512-QQZ4kENwpWmyNb98UXRDFXrmtIXuXtt1+bSbda/2KA85+F+rrJP8hZk6QOkCQXM2Th9mUDYdq/PNByPdT9ID4A=="],
|
||||
|
||||
"ml-array-min": ["ml-array-min@2.0.0", "https://registry.npmmirror.com/ml-array-min/-/ml-array-min-2.0.0.tgz", { "dependencies": { "is-any-array": "^3.0.0" } }, "sha512-GRj6Ky6sW9vGL6yIjgsHmXZ9YgrdmcQ8nCxPqEGeKc6dkfYg1XDYxGFxADUjNuZyoCd5PUscWAS4N+cFaX6hFg=="],
|
||||
|
||||
"ml-array-rescale": ["ml-array-rescale@2.0.0", "https://registry.npmmirror.com/ml-array-rescale/-/ml-array-rescale-2.0.0.tgz", { "dependencies": { "is-any-array": "^3.0.0", "ml-array-max": "^2.0.0", "ml-array-min": "^2.0.0" } }, "sha512-2GGtKfSno94/kIloWGvpp/U5Q5vLvLrza+SAaGsLeo6Xj4mEbA6Gqx+oTfZFkxnd1grT2X007HfJNs3T5BsiVg=="],
|
||||
|
||||
"ml-matrix": ["ml-matrix@6.13.0", "https://registry.npmmirror.com/ml-matrix/-/ml-matrix-6.13.0.tgz", { "dependencies": { "is-any-array": "^3.0.0", "ml-array-rescale": "^2.0.0" } }, "sha512-QpV0UTUkglg6vPUgThKGBEtit2ac6habSoZ33bwI9rU0UHZLqw6G3ukTIE8zWiUF3sjK8YAlhx/o/b9layzH8A=="],
|
||||
|
||||
"mousetrap": ["mousetrap@1.6.5", "https://registry.npmmirror.com/mousetrap/-/mousetrap-1.6.5.tgz", {}, "sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA=="],
|
||||
|
||||
"muggle-string": ["muggle-string@0.4.1", "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.12", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
|
||||
@@ -284,18 +354,26 @@
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@5.6.3", "https://registry.npmmirror.com/typescript/-/typescript-5.6.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
|
||||
|
||||
"utility-types": ["utility-types@3.11.0", "https://registry.npmmirror.com/utility-types/-/utility-types-3.11.0.tgz", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="],
|
||||
|
||||
"vite": ["vite@6.4.3", "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
|
||||
|
||||
"vscode-uri": ["vscode-uri@3.1.0", "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
|
||||
|
||||
"vue": ["vue@3.5.35", "https://registry.npmmirror.com/vue/-/vue-3.5.35.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.35", "@vue/compiler-sfc": "3.5.35", "@vue/runtime-dom": "3.5.35", "@vue/server-renderer": "3.5.35", "@vue/shared": "3.5.35" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q=="],
|
||||
|
||||
"vue-demi": ["vue-demi@0.14.10", "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="],
|
||||
|
||||
"vue-i18n": ["vue-i18n@9.14.5", "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-9.14.5.tgz", { "dependencies": { "@intlify/core-base": "9.14.5", "@intlify/shared": "9.14.5", "@vue/devtools-api": "^6.5.0" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g=="],
|
||||
|
||||
"vue-router": ["vue-router@4.6.4", "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="],
|
||||
|
||||
"vue-tsc": ["vue-tsc@2.2.12", "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", { "dependencies": { "@volar/typescript": "2.4.15", "@vue/language-core": "2.2.12" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "./bin/vue-tsc.js" } }, "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw=="],
|
||||
|
||||
"@types/dompurify/dompurify": ["dompurify@3.4.9", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.9.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
[package]
|
||||
name = "df-traceability"
|
||||
name = "df-ai-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
df-core = { path = "../df-core" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
futures = "0.3"
|
||||
@@ -0,0 +1,13 @@
|
||||
//! df-ai-core: LLM Provider trait + AI 数据结构(轻量 crate,零 HTTP 依赖)
|
||||
//!
|
||||
//! 从 df-ai 下沉的全局 AI 接入标准。df-ai 保留 HTTP impl 与业务逻辑
|
||||
//! (ContextManager / AiToolRegistry / build_provider 等),通过 re-export
|
||||
//! 保持 `df_ai::provider::*` 路径不变。df-ideas 等轻消费方直接依赖本 crate
|
||||
//! 的 trait 即可接 LLM,不引入 reqwest / eventsource-stream 等重依赖。
|
||||
|
||||
pub mod model;
|
||||
pub mod provider;
|
||||
pub mod types;
|
||||
|
||||
pub use model::*;
|
||||
pub use provider::*;
|
||||
@@ -0,0 +1,481 @@
|
||||
//! 模型能力数据模型。
|
||||
//!
|
||||
//! 单模型的完整描述(4 维度 + 路由控制)。纯数据结构,零 IO / 零 DB 依赖。
|
||||
//! df-storage 反序列化 DB 行时直接消费本模块类型;df-ai 探测器/路由器阶段 2-4 再用。
|
||||
//!
|
||||
//! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §2
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ============================================================
|
||||
// 4 维度枚举
|
||||
// ============================================================
|
||||
|
||||
/// 维度 1 — 模态(模型能接收什么输入)
|
||||
///
|
||||
/// 设计文档 §2.2 仅列出 Text/Vision,Audio/Video 为未来扩展占位。
|
||||
/// 序列化为 snake_case(`text` / `vision` / `audio` / `video`)。
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Modality {
|
||||
Text,
|
||||
Vision,
|
||||
/// 未来扩展:音频输入(预留,Phase 2 多模态)
|
||||
Audio,
|
||||
/// 未来扩展:视频输入(预留)
|
||||
Video,
|
||||
}
|
||||
|
||||
/// 维度 2 — 能力(模型能做什么)
|
||||
///
|
||||
/// 设计文档 §2.2 列出 ToolUse/Embedding/CodeGen。序列化为 snake_case。
|
||||
/// §3.4 提到未来可加 Streaming/Custom(String),当前 Phase 1 仅 3 个确定变体。
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Capability {
|
||||
/// function calling / tool use
|
||||
ToolUse,
|
||||
/// 向量嵌入(知识库)
|
||||
Embedding,
|
||||
/// 代码生成强项
|
||||
CodeGen,
|
||||
}
|
||||
|
||||
/// 维度 3 — 价格分级(成本)
|
||||
///
|
||||
/// 序列化为 snake_case。原设计文档 §2.2 列 Free/Low/Medium/High,但 Free 变体形同虚设:
|
||||
/// 预设表(presets/models.json)0 条 free + 启发式从不赋 Free(只 Low/Medium/High),
|
||||
/// Free 死档变体已删除。
|
||||
///
|
||||
/// 路由已解耦:provider /v1/models API 不返回 cost_tier,
|
||||
/// 此维度 100% 靠预设表写死 + 模型名启发式猜,数据无客观依据不可信,不再参与硬路由
|
||||
/// (原 §6.1 `cost_tier <= max_cost` 过滤已删除)。枚举保留供未来出现真实判别源时再接回。
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CostTier {
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
}
|
||||
|
||||
/// 维度 4 — 聪明程度(智力分级)
|
||||
///
|
||||
/// 设计文档 §2.2:Lite/Standard/Plus/Ultra。序列化为 snake_case。
|
||||
/// 派生 Ord:Lite < Standard < Plus < Ultra。
|
||||
///
|
||||
/// 路由已解耦:provider /v1/models API 不返回 intelligence,
|
||||
/// 此维度 100% 靠预设表写死 + 模型名启发式猜,数据无客观依据不可信,不再参与硬路由
|
||||
/// (原 §6.1 `intelligence >= min_intelligence` 过滤已删除)。枚举保留供未来出现真实判别源时再接回。
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum IntelligenceTier {
|
||||
/// flash/mini/lite/nano — 快但简单任务
|
||||
Lite,
|
||||
/// air/standard — 日常够用
|
||||
Standard,
|
||||
/// plus/pro/max — 复杂推理
|
||||
Plus,
|
||||
/// 最强模型 — 兜底用
|
||||
Ultra,
|
||||
}
|
||||
|
||||
/// 探测元数据来源(只读,由 ModelProbe 填充,Phase 2)
|
||||
///
|
||||
/// 设计文档 §2.1 的 ProbeSource 字段。Phase 1 仅定义占位,Phase 2 探测器填充。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProbeSource {
|
||||
/// 用户手动设定(最高优先)
|
||||
UserSet,
|
||||
/// 内置预设表精确/模糊匹配
|
||||
PresetTable,
|
||||
/// 模型名启发式推断
|
||||
Heuristic,
|
||||
/// 默认值兜底
|
||||
Default,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 核心结构 ModelConfig
|
||||
// ============================================================
|
||||
|
||||
/// 单个模型的完整配置(4 维度 + 路由控制)。
|
||||
///
|
||||
/// 设计文档 §2.1。serde 默认值保证老库/部分字段缺失时向后兼容。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ModelConfig {
|
||||
// ── 基础 ──
|
||||
/// 模型名(如 "glm-4v-flash")
|
||||
pub model_id: String,
|
||||
/// 启用/禁用(false = 配了但不参与路由,相当于"备档")
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// 用户自定义别名(可选)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<String>,
|
||||
|
||||
// ── 维度 1:模态(能接收什么输入)──
|
||||
#[serde(default)]
|
||||
pub modalities: Vec<Modality>,
|
||||
|
||||
// ── 维度 2:能力(能做什么)──
|
||||
#[serde(default)]
|
||||
pub capabilities: Vec<Capability>,
|
||||
|
||||
// ── 维度 3:价格(成本分级)──
|
||||
#[serde(default)]
|
||||
pub cost_tier: CostTier,
|
||||
|
||||
// ── 维度 4:聪明程度(智力分级)──
|
||||
#[serde(default)]
|
||||
pub intelligence: IntelligenceTier,
|
||||
|
||||
// ── 路由控制 ──
|
||||
/// 权重(0-100,同能力候选中优先选权重高的)
|
||||
#[serde(default = "default_weight")]
|
||||
pub weight: u32,
|
||||
/// 上下文窗口大小(tokens)
|
||||
#[serde(default = "default_context_window")]
|
||||
pub context_window: usize,
|
||||
|
||||
/// 是否支持 temperature 参数(默认 true)。
|
||||
///
|
||||
/// 部分模型仅接受固定温度值(如商汤 sensenova 系列仅允许 1.0),透传其它温度(标题 0.3/
|
||||
/// 压缩 0.2 等)→ 上游 HTTP 400,实测 9 次。false 时协议转换层省略 temperature 字段
|
||||
/// (交由上游默认),避免无效往返;true 保持原样透传(零回归)。
|
||||
#[serde(default = "default_true")]
|
||||
pub temperature_supported: bool,
|
||||
|
||||
// ── 探测元数据(只读,由 ModelProbe 填充,Phase 2)──
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub probe_source: Option<ProbeSource>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_weight() -> u32 {
|
||||
50
|
||||
}
|
||||
|
||||
fn default_context_window() -> usize {
|
||||
8192
|
||||
}
|
||||
|
||||
impl ModelConfig {
|
||||
/// 用模型名构造默认配置(老格式字符串数组 `["glm-4-flash"]` 升级时逐项调用)。
|
||||
///
|
||||
/// 设计文档 §2.3 向后兼容:老 `models: ["model-a"]` → 每个名字转默认 ModelConfig。
|
||||
/// 默认值:enabled=true / modalities=[Text] / capabilities=[ToolUse] /
|
||||
/// cost_tier=Medium / intelligence=Standard / weight=50 / context_window=8192。
|
||||
/// (ToolUse 默认开:主流对话模型支持 function calling;精确能力由 Phase 2 探测器修正)
|
||||
pub fn with_defaults(model_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
model_id: model_id.into(),
|
||||
enabled: true,
|
||||
label: None,
|
||||
modalities: vec![Modality::Text],
|
||||
capabilities: vec![Capability::ToolUse],
|
||||
cost_tier: CostTier::Medium,
|
||||
intelligence: IntelligenceTier::Standard,
|
||||
weight: default_weight(),
|
||||
context_window: default_context_window(),
|
||||
// 默认支持 temperature(老格式升级/未知模型保持原样透传,零回归);
|
||||
// 明确不支持的模型由 presets 表 temperature_supported=false 标注。
|
||||
temperature_supported: true,
|
||||
probe_source: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 默认值(#[serde(default)] 用于 Vec/Option 字段时 serde 要求 Default impl)
|
||||
impl Default for Modality {
|
||||
fn default() -> Self {
|
||||
Modality::Text
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CostTier {
|
||||
fn default() -> Self {
|
||||
CostTier::Medium
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for IntelligenceTier {
|
||||
fn default() -> Self {
|
||||
IntelligenceTier::Standard
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 向后兼容反序列化:老格式字符串数组 → ModelConfig 数组
|
||||
// ============================================================
|
||||
|
||||
/// 反序列化 `models` JSON 字段,兼容两种格式:
|
||||
///
|
||||
/// - 老格式:`["glm-4-flash", "glm-4v"]`(纯字符串数组)→ 每个名字转 `ModelConfig::with_defaults`
|
||||
/// - 新格式:`[{ "model_id": ..., "modalities": [...] }, ...]`(ModelConfig 对象数组)
|
||||
/// - 空/非数组:null / 缺失 / 空字符串 → 空 Vec
|
||||
///
|
||||
/// 设计文档 §2.3。挂在 AiProviderRecord.models 字段上(#[serde(default, deserialize_with = ...)])。
|
||||
pub fn deserialize_model_configs<'de, D>(deserializer: D) -> Result<Vec<ModelConfig>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de::Error;
|
||||
|
||||
let opt: Option<serde_json::Value> = Option::deserialize(deserializer)?;
|
||||
let Some(value) = opt else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
match value {
|
||||
// null / 空字符串 / 空数组 → 空
|
||||
serde_json::Value::Null => Ok(Vec::new()),
|
||||
serde_json::Value::String(s) if s.trim().is_empty() => Ok(Vec::new()),
|
||||
serde_json::Value::String(s) => {
|
||||
// 字符串内容可能是 JSON 文本(DB TEXT 列存 JSON 字符串)
|
||||
match serde_json::from_str::<serde_json::Value>(s.trim()) {
|
||||
Ok(inner) => parse_models_value(inner).map_err(D::Error::custom),
|
||||
Err(_) => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(arr) => parse_models_value(serde_json::Value::Array(arr)).map_err(D::Error::custom),
|
||||
other => parse_models_value(other).map_err(D::Error::custom),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_models_value(value: serde_json::Value) -> Result<Vec<ModelConfig>, serde_json::Error> {
|
||||
match value {
|
||||
serde_json::Value::Array(arr) => {
|
||||
// 老格式:全为字符串 → 每个转默认配置
|
||||
if arr.iter().all(|v| v.is_string()) {
|
||||
return Ok(arr
|
||||
.into_iter()
|
||||
.filter_map(|v| v.as_str().map(ModelConfig::with_defaults))
|
||||
.collect());
|
||||
}
|
||||
// 新格式:ModelConfig 对象数组
|
||||
serde_json::from_value::<Vec<ModelConfig>>(serde_json::Value::Array(arr))
|
||||
}
|
||||
serde_json::Value::Null => Ok(Vec::new()),
|
||||
_ => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── 枚举 serde 映射 ──
|
||||
|
||||
#[test]
|
||||
fn modality_serde_snake_case() {
|
||||
let cases = [
|
||||
(Modality::Text, "\"text\""),
|
||||
(Modality::Vision, "\"vision\""),
|
||||
(Modality::Audio, "\"audio\""),
|
||||
(Modality::Video, "\"video\""),
|
||||
];
|
||||
for (variant, expected) in cases {
|
||||
let s = serde_json::to_string(&variant).unwrap();
|
||||
assert_eq!(s, expected, "serialize {variant:?}");
|
||||
let back: Modality = serde_json::from_str(expected).unwrap();
|
||||
assert_eq!(back, variant, "deserialize {variant:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_serde_snake_case() {
|
||||
let cases = [
|
||||
(Capability::ToolUse, "\"tool_use\""),
|
||||
(Capability::Embedding, "\"embedding\""),
|
||||
(Capability::CodeGen, "\"code_gen\""),
|
||||
];
|
||||
for (variant, expected) in cases {
|
||||
let s = serde_json::to_string(&variant).unwrap();
|
||||
assert_eq!(s, expected, "serialize {variant:?}");
|
||||
let back: Capability = serde_json::from_str(expected).unwrap();
|
||||
assert_eq!(back, variant, "deserialize {variant:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_tier_serde_and_ordering() {
|
||||
let cases = [
|
||||
(CostTier::Low, "\"low\""),
|
||||
(CostTier::Medium, "\"medium\""),
|
||||
(CostTier::High, "\"high\""),
|
||||
];
|
||||
for (variant, expected) in cases {
|
||||
assert_eq!(serde_json::to_string(&variant).unwrap(), expected);
|
||||
assert_eq!(serde_json::from_str::<CostTier>(expected).unwrap(), variant);
|
||||
}
|
||||
// Ord:Low < Medium < High(枚举序,路由已解耦不再用于过滤,保留供未来判别源)。
|
||||
// Free 变体已删除(预设/启发式从不赋 Free,死档)。
|
||||
assert!(CostTier::Low < CostTier::Medium);
|
||||
assert!(CostTier::Medium < CostTier::High);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intelligence_tier_serde_and_ordering() {
|
||||
let cases = [
|
||||
(IntelligenceTier::Lite, "\"lite\""),
|
||||
(IntelligenceTier::Standard, "\"standard\""),
|
||||
(IntelligenceTier::Plus, "\"plus\""),
|
||||
(IntelligenceTier::Ultra, "\"ultra\""),
|
||||
];
|
||||
for (variant, expected) in cases {
|
||||
assert_eq!(serde_json::to_string(&variant).unwrap(), expected);
|
||||
assert_eq!(serde_json::from_str::<IntelligenceTier>(expected).unwrap(), variant);
|
||||
}
|
||||
// Ord:Lite < Standard < Plus < Ultra(枚举序,路由已解耦不再用于过滤,保留供未来判别源)
|
||||
assert!(IntelligenceTier::Lite < IntelligenceTier::Standard);
|
||||
assert!(IntelligenceTier::Standard < IntelligenceTier::Plus);
|
||||
assert!(IntelligenceTier::Plus < IntelligenceTier::Ultra);
|
||||
}
|
||||
|
||||
// ── ModelConfig serde roundtrip ──
|
||||
|
||||
#[test]
|
||||
fn model_config_full_roundtrip() {
|
||||
let mc = ModelConfig {
|
||||
model_id: "glm-4v".into(),
|
||||
enabled: true,
|
||||
label: Some("我的视觉".into()),
|
||||
modalities: vec![Modality::Text, Modality::Vision],
|
||||
capabilities: vec![Capability::ToolUse],
|
||||
cost_tier: CostTier::Medium,
|
||||
intelligence: IntelligenceTier::Plus,
|
||||
weight: 70,
|
||||
context_window: 128_000,
|
||||
temperature_supported: true,
|
||||
probe_source: Some(ProbeSource::PresetTable),
|
||||
};
|
||||
let json = serde_json::to_string(&mc).unwrap();
|
||||
let back: ModelConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, mc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_config_skip_none_label_and_probe() {
|
||||
let mc = ModelConfig::with_defaults("glm-4-flash");
|
||||
let json = serde_json::to_string(&mc).unwrap();
|
||||
// label / probe_source 为 None 应被跳过
|
||||
assert!(!json.contains("label"), "json={json}");
|
||||
assert!(!json.contains("probe_source"), "json={json}");
|
||||
// 反序列化回来相等(skip_serializing 不影响 deserialize)
|
||||
let back: ModelConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, mc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_config_with_defaults_values() {
|
||||
let mc = ModelConfig::with_defaults("test-model");
|
||||
assert_eq!(mc.model_id, "test-model");
|
||||
assert!(mc.enabled);
|
||||
assert_eq!(mc.modalities, vec![Modality::Text]);
|
||||
assert_eq!(mc.capabilities, vec![Capability::ToolUse]);
|
||||
assert_eq!(mc.cost_tier, CostTier::Medium);
|
||||
assert_eq!(mc.intelligence, IntelligenceTier::Standard);
|
||||
assert_eq!(mc.weight, 50);
|
||||
assert_eq!(mc.context_window, 8192);
|
||||
assert_eq!(mc.label, None);
|
||||
assert_eq!(mc.probe_source, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_config_defaults_applied_on_missing_fields() {
|
||||
// 最小 JSON,仅 model_id — 其余字段靠 #[serde(default)] / default fn 填充
|
||||
let json = r#"{"model_id":"minimal"}"#;
|
||||
let mc: ModelConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(mc.model_id, "minimal");
|
||||
assert!(mc.enabled, "enabled 默认 true");
|
||||
assert_eq!(mc.weight, 50, "weight 默认 50");
|
||||
assert_eq!(mc.context_window, 8192, "context_window 默认 8192");
|
||||
assert!(mc.modalities.is_empty(), "Vec 字段 default 空");
|
||||
assert!(mc.capabilities.is_empty());
|
||||
assert_eq!(mc.cost_tier, CostTier::Medium);
|
||||
assert_eq!(mc.intelligence, IntelligenceTier::Standard);
|
||||
}
|
||||
|
||||
// ── deserialize_model_configs 向后兼容 ──
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Wrap {
|
||||
#[serde(default, deserialize_with = "deserialize_model_configs")]
|
||||
models: Vec<ModelConfig>,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_old_format_string_array() {
|
||||
// 老格式:["glm-4-flash", "glm-4v"]
|
||||
let json = r#"{"models":["glm-4-flash","glm-4v"]}"#;
|
||||
let w: Wrap = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(w.models.len(), 2);
|
||||
assert_eq!(w.models[0].model_id, "glm-4-flash");
|
||||
assert!(w.models[0].enabled);
|
||||
assert_eq!(w.models[0].modalities, vec![Modality::Text]);
|
||||
assert_eq!(w.models[1].model_id, "glm-4v");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_new_format_object_array() {
|
||||
let json = r#"{"models":[{"model_id":"glm-4v","enabled":true,"modalities":["text","vision"],"capabilities":["tool_use"],"cost_tier":"medium","intelligence":"plus","weight":70,"context_window":128000}]}"#;
|
||||
let w: Wrap = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(w.models.len(), 1);
|
||||
let m = &w.models[0];
|
||||
assert_eq!(m.model_id, "glm-4v");
|
||||
assert_eq!(m.modalities, vec![Modality::Text, Modality::Vision]);
|
||||
assert_eq!(m.intelligence, IntelligenceTier::Plus);
|
||||
assert_eq!(m.context_window, 128_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_null_or_missing_yields_empty() {
|
||||
// null
|
||||
let w: Wrap = serde_json::from_str(r#"{"models":null}"#).unwrap();
|
||||
assert!(w.models.is_empty());
|
||||
// 缺失字段
|
||||
let w: Wrap = serde_json::from_str(r#"{}"#).unwrap();
|
||||
assert!(w.models.is_empty());
|
||||
// 空字符串(DB TEXT 列可能存空串)
|
||||
let w: Wrap = serde_json::from_str(r#"{"models":""}"#).unwrap();
|
||||
assert!(w.models.is_empty());
|
||||
// 空数组
|
||||
let w: Wrap = serde_json::from_str(r#"{"models":[]}"#).unwrap();
|
||||
assert!(w.models.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_json_string_text_column() {
|
||||
// DB TEXT 列存的是 JSON 文本字符串:`"[\"model-a\"]"`
|
||||
let json = r#"{"models":"[\"glm-4-flash\"]"}"#;
|
||||
let w: Wrap = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(w.models.len(), 1);
|
||||
assert_eq!(w.models[0].model_id, "glm-4-flash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_via_text_column() {
|
||||
// 模拟 DB 落库:Vec<ModelConfig> → serde_json 序列化 → 存 TEXT 列
|
||||
// 读出来:TEXT 列值 → 反序列化回 Vec<ModelConfig>
|
||||
let original = vec![
|
||||
ModelConfig::with_defaults("glm-4-flash"),
|
||||
ModelConfig {
|
||||
model_id: "glm-4v".into(),
|
||||
modalities: vec![Modality::Text, Modality::Vision],
|
||||
intelligence: IntelligenceTier::Plus,
|
||||
weight: 70,
|
||||
context_window: 128_000,
|
||||
..ModelConfig::with_defaults("glm-4v")
|
||||
},
|
||||
];
|
||||
let text = serde_json::to_string(&original).unwrap();
|
||||
let wrapped = format!(r#"{{"models":"{}"}}"#, text.replace('"', "\\\""));
|
||||
let w: Wrap = serde_json::from_str(&wrapped).unwrap();
|
||||
assert_eq!(w.models.len(), 2);
|
||||
assert_eq!(w.models[1].intelligence, IntelligenceTier::Plus);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
//! LLM Provider trait — 统一的 LLM 调用抽象
|
||||
//!
|
||||
//! 支持 OpenAI 兼容 API(覆盖 OpenAI / GLM / DeepSeek / Claude 兼容模式),
|
||||
//! 含 function calling / tool use 能力。
|
||||
//!
|
||||
//! 本文件含 trait + 类型 impl 块(零 IO)。纯类型定义(CompletionRequest /
|
||||
//! ChatMessage / ContentPart 等 `#[derive]` struct/enum)已拆分到 `types.rs`,
|
||||
//! 经下方 `pub use crate::types::*` re-export 保持外部路径
|
||||
//! `df_ai_core::provider::*` / `df_ai::provider::*` 不变(编译期验证)。
|
||||
//! HTTP impl(OpenAICompatProvider / AnthropicCompatProvider)+ 业务逻辑
|
||||
//! (ContextManager / AiToolRegistry / build_provider 工厂)留在 df-ai crate。
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
// 透明 re-export:types.rs 中所有 pub 类型经此回流到 provider::* glob,
|
||||
// 使 df_ai::provider::*(df-ai/src/provider.rs:15 的 `pub use df_ai_core::provider::*`)
|
||||
// 与直接 `df_ai_core::provider::Type` 限定路径全部继续可用。
|
||||
pub use crate::types::*;
|
||||
use crate::types::{new_message_id, now_millis_i64};
|
||||
|
||||
impl ContentPart {
|
||||
pub fn text(text: impl Into<String>) -> Self {
|
||||
ContentPart::Text { text: text.into() }
|
||||
}
|
||||
pub fn image_base64(media_type: impl Into<String>, data: impl Into<String>) -> Self {
|
||||
ContentPart::Image {
|
||||
url: None,
|
||||
base64: Some(data.into()),
|
||||
media_type: Some(media_type.into()),
|
||||
alt: None,
|
||||
}
|
||||
}
|
||||
pub fn image_url(url: impl Into<String>) -> Self {
|
||||
ContentPart::Image { url: Some(url.into()), base64: None, media_type: None, alt: None }
|
||||
}
|
||||
|
||||
/// 是否图片片
|
||||
pub fn is_image(&self) -> bool {
|
||||
matches!(self, ContentPart::Image { .. })
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
pub fn system(content: impl Into<String>) -> Self {
|
||||
Self { id: Some(new_message_id()), role: MessageRole::System, content: content.into(), parts: None, source: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
|
||||
}
|
||||
pub fn user(content: impl Into<String>) -> Self {
|
||||
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: None, source: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
|
||||
}
|
||||
pub fn assistant(content: impl Into<String>) -> Self {
|
||||
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, source: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
|
||||
}
|
||||
pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
|
||||
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, source: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
|
||||
}
|
||||
pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self {
|
||||
Self { id: Some(new_message_id()), role: MessageRole::Tool, content: content.into(), source: None, parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
|
||||
}
|
||||
|
||||
/// 多模态 user 消息:content 文本 + parts(含 Image 片)。
|
||||
/// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。
|
||||
pub fn user_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self {
|
||||
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: Some(parts), source: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
|
||||
}
|
||||
|
||||
/// 是否含图片片(供 provider 判定走多模态分支)。
|
||||
pub fn has_image(&self) -> bool {
|
||||
self.parts.as_ref().map(|ps| ps.iter().any(|p| p.is_image())).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// parts 若存在则返回引用,否则 None。
|
||||
pub fn parts(&self) -> Option<&[ContentPart]> {
|
||||
self.parts.as_deref()
|
||||
}
|
||||
|
||||
/// 把 content + parts 拍平为有序 ContentPart 序列:先 content 作 Text 片(非空时),
|
||||
/// 再追加 parts(若有)。供 provider 生成 content blocks(保证文本在前、图片在后)。
|
||||
pub fn flattened_parts(&self) -> Vec<ContentPart> {
|
||||
let mut out: Vec<ContentPart> = Vec::new();
|
||||
if !self.content.is_empty() {
|
||||
out.push(ContentPart::Text { text: self.content.clone() });
|
||||
}
|
||||
if let Some(ps) = &self.parts {
|
||||
for p in ps {
|
||||
out.push(p.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 是否处于 active 态(status 为 None 或 "active")。其余状态一律 false。
|
||||
///
|
||||
/// 正面白名单:仅认 None / "active",新状态
|
||||
/// (如"archived_segment" / "compressed")自动落入不 active 分支,
|
||||
/// 无需每加一个状态就来这里改。当前取值 None/Some("active")/Some("truncated")
|
||||
/// 行为与旧反面排除完全等价(None=true / "active"=true / "truncated"=false)。
|
||||
pub fn is_active(&self) -> bool {
|
||||
matches!(self.status, None | Some(MessageStatus::Active))
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolDefinition {
|
||||
pub fn function(name: impl Into<String>, description: impl Into<String>, parameters: serde_json::Value) -> Self {
|
||||
Self {
|
||||
tool_type: ToolType::new("function"),
|
||||
function: ToolFunction { name: name.into(), description: description.into(), parameters },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolCall {
|
||||
pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
call_type: "function".into(),
|
||||
function: ToolCallFunction { name: name.into(), arguments: arguments.into() },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析点统一兜底:tool_call.id 空 → 生成唯一 fallback,非空原样。
|
||||
///
|
||||
/// 根因(实证会话 01f05167 SenseNova flash-lite):某些 provider(SenseNova 兼容缺陷)
|
||||
/// 返回空 `tool_call.id`("")。OpenAI 协议要求 id 唯一。DevFlow 多 tool_call 按 id
|
||||
/// 路由结果,id 空时所有结果落到同一 key(`audit/mod.rs:203` 的 `seen_ids` 去重把空 id
|
||||
/// 视为相同,只留首个 tool_call)→ AI 看到「所有调用同一结果」,工具全失败。
|
||||
///
|
||||
/// 兜底在**解析点**生成 fallback id:raw 非空用 raw,空用 `format!("{prefix}_{n}")`
|
||||
/// (n 取自下方 `FALLBACK_ID_COUNTER` **全局递增计数器**,跨轮跨 assistant 唯一)。
|
||||
/// 下游(工具执行 / tool 结果回填 tool_call_id)从解析后的 `ToolCall.id` 取,不重复生成,
|
||||
/// 确保 assistant tool_call.id 与 tool 结果 tool_call_id 匹配(防 sanitize 三元组断裂)。
|
||||
///
|
||||
/// 三处解析点共用本 helper(DRY):OpenAI 同步 `parse_tool_calls`(prefix=`gen_tool`)、
|
||||
/// OpenAI 流式 chunk(prefix=`gen_stream`)、Anthropic 同步 + 流式(prefix=`gen_anthropic` /
|
||||
/// `gen_anthropic_stream`)。正常 provider(OpenAI/Claude/GLM id 非空)原样透传零介入。
|
||||
///
|
||||
/// # 为何用全局计数器而非单轮 index(实证 af2fab4e)
|
||||
///
|
||||
/// 旧实现 fallback 用 `format!("{prefix}_{index}")`,index 是**单轮** tool_call 数组
|
||||
/// 位置。跨轮(不同 assistant)index 都从 0 起 → `gen_stream_0` 跨轮重复。agentic 的
|
||||
/// `id_to_name`(`insert(id, name)`)后者覆盖前者 → run_command 的 exit=1 被误标
|
||||
/// grep::exit=1 → L1 误熔断 grep(冤枉)→ loop 停 → 最后 assistant 空 content tool_calls
|
||||
/// 没执行(空气泡)。更严重:id 重复 → tool 结果配错 tool_call(三元组配对错位)。
|
||||
///
|
||||
/// 全局 `AtomicU64`(SeqCst)跨轮跨 assistant 严格递增,fallback id 永不重复。`index`
|
||||
/// 参数保留仅为签名兼容(4 处调用点 parse_tool_calls / 流式 chunk / push / agentic 都传),
|
||||
/// fallback 内部不再使用 index。
|
||||
///
|
||||
/// 单测跨进程实例计数器从 0 起;并发场景下两线程拿到的 fallback id 也严格递增(SeqCst),
|
||||
/// 保证全局唯一。
|
||||
pub fn tool_call_id_or_fallback(raw: &str, _index: usize, prefix: &str) -> String {
|
||||
if !raw.is_empty() {
|
||||
raw.to_string()
|
||||
} else {
|
||||
let n = FALLBACK_ID_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
format!("{prefix}_{n}")
|
||||
}
|
||||
}
|
||||
|
||||
/// fallback id 全局计数器:跨轮跨 assistant 严格递增,保证空 id fallback 永不重复。
|
||||
///
|
||||
/// 见 `tool_call_id_or_fallback` 文档说明(实证 af2fab4e 跨轮重复根因)。
|
||||
static FALLBACK_ID_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// LLM Provider trait
|
||||
#[async_trait]
|
||||
pub trait LlmProvider: Send + Sync {
|
||||
/// 同步调用
|
||||
async fn complete(&self, request: CompletionRequest) -> anyhow::Result<CompletionResponse>;
|
||||
|
||||
/// 流式调用(返回异步流)
|
||||
async fn stream(
|
||||
&self,
|
||||
request: CompletionRequest,
|
||||
) -> anyhow::Result<StreamResult>;
|
||||
|
||||
/// 文本嵌入:批量文本 → 语义向量(供知识库向量检索)
|
||||
///
|
||||
/// 默认实现返回 Err(协议不支持)。OpenAI 兼容协议覆盖实现(/v1/embeddings);
|
||||
/// Anthropic 无 embedding API,保持默认。
|
||||
async fn embed(&self, _model: &str, _texts: Vec<String>) -> anyhow::Result<Vec<Vec<f32>>> {
|
||||
anyhow::bail!("该 Provider 不支持 embedding({})", self.name())
|
||||
}
|
||||
|
||||
/// Provider 名称
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// 实际请求端点(含 base_url + 关键路径,如 chat completions / messages)。
|
||||
/// 默认回落 `name()`,provider 实现覆盖返真实 URL,供 401/网络错误诊断打印
|
||||
/// —— 旧路径只能近似打印 provider_type,看不到实际请求端点。
|
||||
fn endpoint(&self) -> String {
|
||||
self.name().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_active_whitelist() {
|
||||
// 正面白名单:仅 None / "active" 为 true,其余一律 false。
|
||||
// 零行为变化:None / "active" / "truncated" 与旧反面排除完全等价;
|
||||
// "archived_segment" / "compressed" 由白名单 matches! 只认 None/active 自动落入 false。
|
||||
|
||||
// None(构造默认值,向前兼容老 JSON)
|
||||
let m = ChatMessage::user("hi");
|
||||
assert!(m.is_active(), "None 应 active");
|
||||
|
||||
// "active"
|
||||
let mut m = ChatMessage::user("hi");
|
||||
m.status = Some(MessageStatus::Active);
|
||||
assert!(m.is_active(), "Some(active) 应 active");
|
||||
|
||||
// "truncated" — 当前取值,与旧实现等价(false)
|
||||
let mut m = ChatMessage::user("hi");
|
||||
m.status = Some(MessageStatus::Truncated);
|
||||
assert!(!m.is_active(), "truncated 应不 active");
|
||||
|
||||
// "archived_segment" — 白名单自动隔离
|
||||
let mut m = ChatMessage::user("hi");
|
||||
m.status = Some(MessageStatus::ArchivedSegment);
|
||||
assert!(!m.is_active(), "archived_segment 应不 active(白名单隔离)");
|
||||
|
||||
// "compressed" — 白名单自动隔离
|
||||
let mut m = ChatMessage::user("hi");
|
||||
m.status = Some(MessageStatus::Compressed);
|
||||
assert!(!m.is_active(), "compressed 应不 active(白名单隔离)");
|
||||
}
|
||||
|
||||
// ---------- ContentPart ----------
|
||||
|
||||
/// 老 JSON(无 parts 字段)反序列化时 parts 应为 None(向前兼容)
|
||||
#[test]
|
||||
fn contentpart_legacy_json_no_parts() {
|
||||
let json = r#"{"role":"user","content":"hello"}"#;
|
||||
let m: ChatMessage = serde_json::from_str(json).expect("老 JSON 应可反序列化");
|
||||
assert_eq!(m.content, "hello");
|
||||
assert!(m.parts.is_none(), "老 JSON 无 parts 字段 → None");
|
||||
assert!(!m.has_image());
|
||||
}
|
||||
|
||||
/// 新 JSON 带 parts(含 Image 片)反序列化 round-trip
|
||||
#[test]
|
||||
fn contentpart_with_image_roundtrip() {
|
||||
let m = ChatMessage::user_parts(
|
||||
"看这张图",
|
||||
vec![
|
||||
ContentPart::image_base64("image/png", "iVBORw0KGgo="),
|
||||
ContentPart::text("说明"),
|
||||
],
|
||||
);
|
||||
let json = serde_json::to_string(&m).expect("序列化");
|
||||
// parts 顺序:[image_base64, text] → 首元素是 image
|
||||
assert!(json.contains(r#""parts":[{"type":"image""#));
|
||||
assert!(json.contains(r#""type":"text""#));
|
||||
assert!(json.contains(r#""base64":"iVBORw0KGgo=""#));
|
||||
assert!(json.contains(r#""media_type":"image/png""#));
|
||||
|
||||
let back: ChatMessage = serde_json::from_str(&json).expect("反序列化 round-trip");
|
||||
assert_eq!(back.content, "看这张图");
|
||||
assert!(back.has_image());
|
||||
let parts = back.parts.expect("parts 应存在");
|
||||
assert_eq!(parts.len(), 2);
|
||||
assert!(parts[0].is_image());
|
||||
}
|
||||
|
||||
/// has_image 判定
|
||||
#[test]
|
||||
fn contentpart_has_image_detection() {
|
||||
assert!(!ChatMessage::user("纯文本").has_image());
|
||||
let m = ChatMessage::user_parts("t", vec![ContentPart::text("只文本片")]);
|
||||
assert!(!m.has_image(), "仅 Text 片不算 has_image");
|
||||
let m = ChatMessage::user_parts(
|
||||
"t",
|
||||
vec![ContentPart::text("前缀"), ContentPart::image_url("https://x/a.png")],
|
||||
);
|
||||
assert!(m.has_image());
|
||||
}
|
||||
|
||||
/// flattened_parts:content 非空 → 前置 Text 片 + parts 追加
|
||||
#[test]
|
||||
fn contentpart_flattened_parts() {
|
||||
let m = ChatMessage::user("hi");
|
||||
let flat = m.flattened_parts();
|
||||
assert_eq!(flat.len(), 1);
|
||||
assert_eq!(flat[0], ContentPart::Text { text: "hi".into() });
|
||||
|
||||
let m = ChatMessage::user_parts(
|
||||
"cap",
|
||||
vec![ContentPart::image_url("u"), ContentPart::text("尾")],
|
||||
);
|
||||
let flat = m.flattened_parts();
|
||||
assert_eq!(flat.len(), 3);
|
||||
assert_eq!(flat[0], ContentPart::Text { text: "cap".into() });
|
||||
assert!(flat[1].is_image());
|
||||
assert_eq!(flat[2], ContentPart::Text { text: "尾".into() });
|
||||
|
||||
let mut m = ChatMessage::user("");
|
||||
m.parts = Some(vec![ContentPart::text("x")]);
|
||||
let flat = m.flattened_parts();
|
||||
assert_eq!(flat.len(), 1, "空 content 不应产生空 Text 片");
|
||||
}
|
||||
|
||||
/// 向后兼容:旧代码 ChatMessage 字面量构造仍合法(audit/title/commands 零回归)
|
||||
#[test]
|
||||
fn contentpart_struct_literal_compat() {
|
||||
let m = ChatMessage {
|
||||
id: None,
|
||||
role: MessageRole::User,
|
||||
content: "字面量构造".into(),
|
||||
parts: None,
|
||||
source: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
model: None,
|
||||
status: None,
|
||||
reasoning_content: None,
|
||||
prompt_tokens: None,
|
||||
completion_tokens: None,
|
||||
prompt_cache_hit_tokens: None,
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
is_estimated: None,
|
||||
timestamp: None,
|
||||
};
|
||||
assert_eq!(m.content, "字面量构造");
|
||||
assert!(m.parts.is_none());
|
||||
}
|
||||
|
||||
// ---------- DeepSeek reasoning_content ----------
|
||||
|
||||
/// 有 reasoning_content 时应序列化出来;None 时不出现
|
||||
#[test]
|
||||
fn completion_request_reasoning_content_serialization() {
|
||||
let req = CompletionRequest {
|
||||
model: "deepseek-r1".to_string(),
|
||||
messages: vec![ChatMessage::user("hello")],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: Some("let me think...".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(json.contains("reasoning_content"), "reasoning_content 应出现在 JSON 中, got: {}", json);
|
||||
|
||||
// None 时不应出现(skip_serializing_if)
|
||||
let req_none = CompletionRequest { reasoning_content: None, ..req };
|
||||
let json_none = serde_json::to_string(&req_none).unwrap();
|
||||
assert!(!json_none.contains("reasoning_content"), "None 的 reasoning_content 不应序列化, got: {}", json_none);
|
||||
}
|
||||
|
||||
/// 所有便捷构造函数默认 reasoning_content 为 None
|
||||
#[test]
|
||||
fn chat_message_reasoning_content_constructors() {
|
||||
assert!(ChatMessage::system("sys").reasoning_content.is_none());
|
||||
assert!(ChatMessage::user("hi").reasoning_content.is_none());
|
||||
assert!(ChatMessage::assistant("resp").reasoning_content.is_none());
|
||||
assert!(ChatMessage::tool_result("call_1", "result").reasoning_content.is_none());
|
||||
}
|
||||
|
||||
/// ChatMessage reasoning_content 序列化 round-trip
|
||||
#[test]
|
||||
fn chat_message_reasoning_content_roundtrip() {
|
||||
let m = ChatMessage {
|
||||
id: None,
|
||||
role: MessageRole::Assistant,
|
||||
content: "answer".to_string(),
|
||||
parts: None,
|
||||
source: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
model: None,
|
||||
status: None,
|
||||
reasoning_content: Some("thinking process".to_string()),
|
||||
prompt_tokens: None,
|
||||
completion_tokens: None,
|
||||
prompt_cache_hit_tokens: None,
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
is_estimated: None,
|
||||
timestamp: None,
|
||||
};
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
assert!(json.contains("reasoning_content"));
|
||||
|
||||
let deserialized: ChatMessage = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.reasoning_content, Some("thinking process".to_string()));
|
||||
}
|
||||
|
||||
// ---------- 消息级溯源:id 字段 ----------
|
||||
|
||||
/// 所有便捷构造函数默认生成非 None 的 id(ULID 风格)
|
||||
#[test]
|
||||
fn chat_message_id_generated_by_constructors() {
|
||||
assert!(ChatMessage::system("sys").id.is_some(), "system 应有 id");
|
||||
assert!(ChatMessage::user("hi").id.is_some(), "user 应有 id");
|
||||
assert!(ChatMessage::assistant("resp").id.is_some(), "assistant 应有 id");
|
||||
assert!(
|
||||
ChatMessage::assistant_with_tools("r", vec![]).id.is_some(),
|
||||
"assistant_with_tools 应有 id"
|
||||
);
|
||||
assert!(
|
||||
ChatMessage::tool_result("call_1", "result").id.is_some(),
|
||||
"tool_result 应有 id"
|
||||
);
|
||||
assert!(
|
||||
ChatMessage::user_parts("t", vec![ContentPart::text("x")]).id.is_some(),
|
||||
"user_parts 应有 id"
|
||||
);
|
||||
}
|
||||
|
||||
/// id 全局唯一性:连续构造 100 条不重复(AtomicU64 计数器保证)
|
||||
#[test]
|
||||
fn chat_message_id_uniqueness() {
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
for _ in 0..100 {
|
||||
let m = ChatMessage::user("x");
|
||||
let id = m.id.expect("构造的消息应有 id");
|
||||
assert!(ids.insert(id), "100 条消息 id 应全部唯一");
|
||||
}
|
||||
}
|
||||
|
||||
/// id 序列化 round-trip:有 id 时序列化保留,反序列化回来一致
|
||||
#[test]
|
||||
fn chat_message_id_roundtrip() {
|
||||
let m = ChatMessage {
|
||||
id: Some("msg_1718800000000_42".to_string()),
|
||||
role: MessageRole::Assistant,
|
||||
content: "answer".to_string(),
|
||||
parts: None,
|
||||
source: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
model: None,
|
||||
status: None,
|
||||
reasoning_content: None,
|
||||
prompt_tokens: None,
|
||||
completion_tokens: None,
|
||||
prompt_cache_hit_tokens: None,
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
is_estimated: None,
|
||||
timestamp: None,
|
||||
};
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
assert!(json.contains(r#""id":"msg_1718800000000_42""#), "id 应序列化, got: {}", json);
|
||||
|
||||
let back: ChatMessage = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.id.as_deref(), Some("msg_1718800000000_42"));
|
||||
}
|
||||
|
||||
/// 向前兼容:老 JSON 无 id 字段 → 反序列化为 None(serde default)
|
||||
#[test]
|
||||
fn chat_message_id_legacy_json_no_id() {
|
||||
let json = r#"{"role":"user","content":"hello"}"#;
|
||||
let m: ChatMessage = serde_json::from_str(json).expect("老 JSON 应可反序列化");
|
||||
assert_eq!(m.content, "hello");
|
||||
assert!(m.id.is_none(), "老 JSON 无 id 字段 → None");
|
||||
|
||||
// 重新序列化:id=None 时不应出现 id 字段(skip_serializing_if)
|
||||
let re = serde_json::to_string(&m).unwrap();
|
||||
assert!(!re.contains(r#""id""#), "id=None 不应序列化, got: {}", re);
|
||||
}
|
||||
|
||||
/// StreamChunk reasoning_content 序列化
|
||||
#[test]
|
||||
fn stream_chunk_reasoning_content() {
|
||||
let chunk = StreamChunk {
|
||||
delta: "text".to_string(),
|
||||
finished: false,
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
error: None,
|
||||
reasoning_content: Some("thought".to_string()),
|
||||
truncated_by_max_tokens: false,
|
||||
};
|
||||
let json = serde_json::to_string(&chunk).unwrap();
|
||||
assert!(json.contains("reasoning_content"));
|
||||
|
||||
let chunk_none = StreamChunk { reasoning_content: None, ..chunk };
|
||||
let json_none = serde_json::to_string(&chunk_none).unwrap();
|
||||
assert!(!json_none.contains("reasoning_content"));
|
||||
}
|
||||
|
||||
/// CompletionResponse reasoning_content 序列化
|
||||
#[test]
|
||||
fn completion_response_reasoning_content() {
|
||||
let resp = CompletionResponse {
|
||||
text: "ok".to_string(),
|
||||
model: "r1".to_string(),
|
||||
usage: TokenUsage { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30, ..Default::default() },
|
||||
tool_calls: None,
|
||||
reasoning_content: Some("r1 thought".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&resp).unwrap();
|
||||
assert!(json.contains("reasoning_content"));
|
||||
|
||||
let deserialized: CompletionResponse = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.reasoning_content, Some("r1 thought".to_string()));
|
||||
}
|
||||
|
||||
/// CR-空 id:tool_call_id_or_fallback 共享 helper —— 空 raw → fallback,非空原样。
|
||||
#[test]
|
||||
fn tool_call_id_or_fallback_non_empty_passthrough() {
|
||||
// 非空 raw 原样透传(provider 真 id 如 call_xxx 保留),与 index/prefix 无关
|
||||
assert_eq!(tool_call_id_or_fallback("call_abc", 0, "gen_tool"), "call_abc");
|
||||
assert_eq!(tool_call_id_or_fallback("x", 5, "p"), "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_id_or_fallback_empty_starts_with_prefix() {
|
||||
// 空 raw → "{prefix}_{n}",n 取自全局计数器(跨进程实例从 0 起,单测不假设具体值)
|
||||
let a = tool_call_id_or_fallback("", 0, "gen_tool");
|
||||
assert!(a.starts_with("gen_tool_"), "空 fallback 应以 gen_tool_ 开头, got: {a}");
|
||||
let b = tool_call_id_or_fallback("", 7, "gen_stream");
|
||||
assert!(b.starts_with("gen_stream_"), "空 fallback 应以 gen_stream_ 开头, got: {b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_id_or_fallback_empty_globally_unique() {
|
||||
// 跨轮跨 assistant 唯一:连续两次空 fallback id 必不同(全局计数器递增)。
|
||||
// 这是修复 af2fab4e 跨轮重复(旧单轮 index 跨轮都从 0 起 → 重复)的核心断言。
|
||||
let a = tool_call_id_or_fallback("", 0, "gen_tool");
|
||||
let b = tool_call_id_or_fallback("", 0, "gen_tool");
|
||||
assert_ne!(a, b, "两次空 fallback 应不同(全局计数器跨轮唯一): {a} vs {b}");
|
||||
// 即使同 index(模拟跨轮 index 都从 0 起),fallback 也必唯一
|
||||
let c = tool_call_id_or_fallback("", 0, "gen_tool");
|
||||
let mut set = std::collections::HashSet::new();
|
||||
assert!(set.insert(a), "fallback a 应唯一");
|
||||
assert!(set.insert(b), "fallback b 应唯一");
|
||||
assert!(set.insert(c), "fallback c 应唯一");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_id_or_fallback_index_unused() {
|
||||
// index 参数仅为签名兼容保留(4 处调用点都传),fallback 不再使用 index。
|
||||
// 同 prefix + 同 index 连续两次 → 不同 fallback(全局计数器递增,与 index 无关)。
|
||||
let a = tool_call_id_or_fallback("", 3, "gen_tool");
|
||||
let b = tool_call_id_or_fallback("", 3, "gen_tool");
|
||||
assert_ne!(a, b, "同 index 两次空 fallback 应不同: {a} vs {b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_id_or_fallback_prefix_distinguishes_sources() {
|
||||
// 不同 prefix 区分来源(同步 gen_tool / 流式 gen_stream / anthropic gen_anthropic)
|
||||
// 注意:两次空 fallback 因全局计数器递增 id 不同,故只比 prefix 前缀
|
||||
let a = tool_call_id_or_fallback("", 0, "gen_tool");
|
||||
let b = tool_call_id_or_fallback("", 0, "gen_stream");
|
||||
assert!(a.starts_with("gen_tool_"));
|
||||
assert!(b.starts_with("gen_stream_"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
//! LLM Provider 数据结构定义(纯类型,零 IO)
|
||||
//!
|
||||
//! 从 provider.rs 拆分的核心数据类型层:请求/响应/消息/工具/Token 等
|
||||
//! `#[derive]` struct 与 enum + 一个内联时间戳辅助函数。所有 impl 块与
|
||||
//! `LlmProvider` trait 留在 provider.rs,本文件只承载类型定义。
|
||||
//!
|
||||
//! 经 `provider.rs` 的 `pub use crate::types::*` re-export,外部路径
|
||||
//! `df_ai_core::provider::*` / `df_ai::provider::*` 保持不变(编译期验证)。
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use futures::Stream;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ============================================================
|
||||
// 核心数据结构
|
||||
// ============================================================
|
||||
|
||||
/// LLM 调用请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CompletionRequest {
|
||||
/// 模型名称
|
||||
pub model: String,
|
||||
/// 提示消息列表
|
||||
pub messages: Vec<ChatMessage>,
|
||||
/// 温度(0.0 ~ 2.0)
|
||||
pub temperature: Option<f32>,
|
||||
/// 最大生成 token 数
|
||||
pub max_tokens: Option<u32>,
|
||||
/// 是否流式输出
|
||||
pub stream: bool,
|
||||
/// 可调用的工具定义
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<ToolDefinition>>,
|
||||
/// 工具调用策略: "auto" | "none" | {"type":"function","name":"xxx"}
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<serde_json::Value>,
|
||||
/// DeepSeek thinking 模式:需把上一轮 response 的 reasoning_content 回传
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
/// 多模态消息内容片。Text 片为字符串;Image 片可走 url 或 base64(二选一,base64 非空时 url 忽略)。
|
||||
///
|
||||
/// ContentPart 作为 `ChatMessage.parts` 的元素类型。
|
||||
/// 设计上 `content: String`(纯文本主载荷)保持不变,多模态片挂在 `parts`:
|
||||
/// 这样未接入多模态的调用方(audit/title/commands/knowledge_inject 等读 content 当字符串)
|
||||
/// 零回归,避免一次性改全仓。provider 转换层在 `has_image()` 为真时把 parts 透传给
|
||||
/// vision 端点,否则只用 content 文本(保持纯文本端点兼容)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ContentPart {
|
||||
/// 文本片
|
||||
Text { text: String },
|
||||
/// 图片片。
|
||||
/// - url:http(s) 可达 URL(provider 直接转发,不读字节)。
|
||||
/// - base64:data URI 之外的纯 base64 字符串 + media_type(provider 内嵌转发)。
|
||||
/// base64 非空时 url 忽略,便于前端上行无网络回拉的本地粘贴图。
|
||||
Image {
|
||||
url: Option<String>,
|
||||
base64: Option<String>,
|
||||
/// base64 模式必填(image/png | image/jpeg | image/webp | image/gif);url 模式可空。
|
||||
media_type: Option<String>,
|
||||
/// 可选 alt(vision 模型/降级文本时用)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
alt: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 消息来源(#958 溯源):外部渠道进入会话的原始消息定位。序列化为 JSON 随消息
|
||||
/// 落库(ai_messages.source 列);桌面本端消息恒 None 不落
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct MessageSource {
|
||||
/// 渠道标识:"feishu"(后续可扩 openapi/webhook 等)
|
||||
pub channel: String,
|
||||
/// 渠道侧原始消息 ID(飞书 om_/oc_ 前缀,可跳原消息追溯)
|
||||
pub message_id: String,
|
||||
/// 渠道侧会话/群 ID(可选,群聊定位)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub chat_id: Option<String>,
|
||||
/// 发送者标识(飞书 open_id,可选)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sender: Option<String>,
|
||||
}
|
||||
|
||||
/// 聊天消息
|
||||
///
|
||||
/// ⚠️ **迁移耦合点**:下方各字段名(`role`/`content`/`parts`/`tool_call_id`/
|
||||
/// `tool_calls`/`model`/`status`/`reasoning_content`/`timestamp`)被
|
||||
/// `df-storage/src/migrations.rs` 的 `migrate_v21` 硬编码提取(裸 JSON 反序列化,
|
||||
/// 因 df-storage 不依赖 df-ai-core)。**改字段名必须同步更新迁移函数**,否则老库
|
||||
/// 迁移漏数据。详见消息拆分存储设计 §4.2.3 迁移耦合点。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
/// 消息全局唯一 ID。用于消息级溯源(source_ref / audit message_id / idea source)。
|
||||
/// 构造时由 `new_message_id()` 生成;老 JSON 反序列化为 None(向前兼容)。
|
||||
/// 消息拆分存储后,此 ID 即 `ai_messages.id` 列主键。
|
||||
/// 临时/派生消息(如 title 摘要)可显式赋 None(不溯源不落库)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
pub role: MessageRole,
|
||||
pub content: String,
|
||||
/// 消息来源(#958 溯源):消息从哪个渠道/端点进入会话。None=桌面本端(既有语义,
|
||||
/// 老数据反序列化为 None 零回归);Some=外部渠道(飞书 message_id 可跳原消息追溯,
|
||||
/// 多端混发可区分来源端)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source: Option<MessageSource>,
|
||||
/// 多模态内容片。
|
||||
///
|
||||
/// `None`/空 → 纯文本消息(绝大多数场景,content 即全部载荷)。
|
||||
/// `Some(含 Image 片)` → 多模态消息,provider 在 `has_image()` 为真时把 parts
|
||||
/// 连同 content(作为前置 Text 片)一起转成 OpenAI/Anthropic 的 content blocks。
|
||||
/// content 字段始终保留人类可读文本(文本消息的 Text 片内容与 content 一致),
|
||||
/// 保证 audit/title/export 等读 content 当字符串的调用方零回归。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parts: Option<Vec<ContentPart>>,
|
||||
/// 工具调用 ID(role=Tool 时必填)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
/// AI 发起的工具调用列表(role=Assistant 时可能有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
/// 生成该消息的 model(仅 assistant 消息有,消息级 model 追溯)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
/// 消息状态(UX-09 编辑重生成):None 正常可见;
|
||||
/// "truncated" 软删(编辑某条 user 消息后其后续消息标记,保留 DB 可追溯但不进 LLM 上下文、前端视图过滤)
|
||||
/// 默认 None(向前兼容老 JSON 反序列化)。落库随 messages JSON 序列化,无需独立列。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<MessageStatus>,
|
||||
/// DeepSeek thinking 模式的推理内容(多轮需回传)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
/// 消息创建时间(Unix 毫秒)。仅前端时间戳展示用,序列化进 messages JSON 持久化;
|
||||
/// provider 请求映射不读此字段(构造器打戳→映射忽略,不进 LLM 请求),老数据反序列化为 None。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timestamp: Option<i64>,
|
||||
/// 本轮 LLM 调用的输入 token 用量(仅 assistant 消息,消息级 token 持久化)。
|
||||
/// 双轨:消息级(本字段,新)+ 会话级累计(ai_conversations.prompt_tokens,旧,保留)。
|
||||
/// 用于 reload/压缩/切会话后历史 assistant 消息 token 显示;老 JSON 反序列化为 None(向前兼容)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_tokens: Option<u32>,
|
||||
/// 本轮 LLM 调用的输出 token 用量(仅 assistant 消息,消息级 token 持久化)。
|
||||
/// 语义同 prompt_tokens;provider 流式 usage 缺失时(GLM 等)可能为 0。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub completion_tokens: Option<u32>,
|
||||
/// 缓存命中 token(低价,deepseek prompt_cache_hit / anthropic cache_read)。
|
||||
/// token 分项显示(2026-08-02):前端 in=miss(全价真实)+ cache=hit(命中) 分计费展示。
|
||||
/// 老 JSON 反序列化为 None(向前兼容);非 cache provider 恒为 0。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_hit_tokens: Option<u32>,
|
||||
/// 未命中 token(全价真实输入,deepseek prompt_cache_miss / anthropic cache_creation)。
|
||||
/// 前端 in 显示用此字段(非 prompt_tokens 总,避免掩盖命中比例)。
|
||||
/// 老 JSON 反序列化为 None(向前兼容)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_miss_tokens: Option<u32>,
|
||||
/// 思考 token(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)。
|
||||
/// 前端仅 > 0 时显示(reason 后缀);老 JSON 反序列化为 None。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_tokens: Option<u32>,
|
||||
/// 本轮 prompt 是否估算值(round_usage.prompt_tokens==0 → estimated_prompt 兜底打标)。
|
||||
/// 供 reload 逐条回显「估算」标注,对齐 live 态 AiCompleted.is_estimated;老消息 None(向前兼容)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub is_estimated: Option<bool>,
|
||||
}
|
||||
|
||||
/// 当前 Unix 毫秒(ChatMessage 打戳用;df-ai-core 不依赖 df-types,内联避免新增依赖)。
|
||||
pub(crate) fn now_millis_i64() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// ChatMessage.id 生成器:全局唯一 + 单调递增 + 零依赖。
|
||||
///
|
||||
/// 格式 `msg_{ts_ms}_{counter}` —— 时间戳(毫秒)+ 进程内 AtomicU64 计数器双保险。
|
||||
/// 决策:不用 ULID crate(workspace 无此依赖,df-ai-core 也不依赖 df-types::new_id),
|
||||
/// 沿用 `now_millis_i64` 内联模式;AtomicU64 保证并发安全(多线程构造消息 ID 不冲突),
|
||||
/// 计数器即便时间戳回拨(系统时钟调整)也单调。可读性好(含时间戳,便于排查)。
|
||||
pub(crate) fn new_message_id() -> String {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let ts = now_millis_i64();
|
||||
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
format!("msg_{}_{n}", ts)
|
||||
}
|
||||
|
||||
/// 消息角色
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MessageRole {
|
||||
System,
|
||||
User,
|
||||
Assistant,
|
||||
Tool,
|
||||
}
|
||||
|
||||
/// 消息状态枚举(IPC 边界序列化为小写 snake_case 字符串)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MessageStatus {
|
||||
/// 正常可见
|
||||
Active,
|
||||
/// 软删(编辑某条 user 消息后其后续消息标记)
|
||||
Truncated,
|
||||
/// 已压缩
|
||||
Compressed,
|
||||
/// 已归档段
|
||||
ArchivedSegment,
|
||||
}
|
||||
|
||||
impl MessageStatus {
|
||||
/// DB 存储用的小写 snake_case 字符串
|
||||
pub fn as_db_str(&self) -> &'static str {
|
||||
match self {
|
||||
MessageStatus::Active => "active",
|
||||
MessageStatus::Truncated => "truncated",
|
||||
MessageStatus::Compressed => "compressed",
|
||||
MessageStatus::ArchivedSegment => "archived_segment",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 DB 字符串解析
|
||||
pub fn from_db_str(s: &str) -> Option<Self> {
|
||||
Some(match s {
|
||||
"active" => MessageStatus::Active,
|
||||
"truncated" => MessageStatus::Truncated,
|
||||
"compressed" => MessageStatus::Compressed,
|
||||
"archived_segment" => MessageStatus::ArchivedSegment,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// ChatMessage 结构体
|
||||
/// 工具类型(IPC 边界透明序列化为字符串,如 "function")
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(transparent)]
|
||||
pub struct ToolType(String);
|
||||
|
||||
impl ToolType {
|
||||
/// 构造新工具类型
|
||||
pub fn new(s: impl Into<String>) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ToolType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ToolType {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for ToolType {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<&str> for ToolType {
|
||||
fn eq(&self, other: &&str) -> bool {
|
||||
self.0 == *other
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<str> for ToolType {
|
||||
fn eq(&self, other: &str) -> bool {
|
||||
self.0 == other
|
||||
}
|
||||
}
|
||||
|
||||
/// 工具定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolDefinition {
|
||||
#[serde(rename = "type")]
|
||||
pub tool_type: ToolType,
|
||||
pub function: ToolFunction,
|
||||
}
|
||||
|
||||
/// 函数定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolFunction {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
/// 工具调用(AI 发起)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCall {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub call_type: ToolType,
|
||||
pub function: ToolCallFunction,
|
||||
}
|
||||
|
||||
/// 工具调用函数部分
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallFunction {
|
||||
pub name: String,
|
||||
pub arguments: String,
|
||||
}
|
||||
|
||||
/// LLM 调用响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CompletionResponse {
|
||||
/// 生成的文本
|
||||
pub text: String,
|
||||
/// 使用的模型
|
||||
pub model: String,
|
||||
/// 消耗的 token 数
|
||||
pub usage: TokenUsage,
|
||||
/// AI 发起的工具调用(如有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
/// DeepSeek thinking 模式响应中的推理内容(需回传到下一轮请求)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
/// Token 用量
|
||||
///
|
||||
/// 分项字段(token 分项显示 + 详情面板,2026-08-02):
|
||||
/// - `prompt_tokens`:输入总(= cache_hit + cache_miss,兼容老链路保留;前端不再单独展示,
|
||||
/// 改用 cache_miss 作 in 真实全价消耗)。
|
||||
/// - `completion_tokens`:输出。
|
||||
/// - `total_tokens`:总计(各 provider 计费不同,相加无意义,前端不显;保留供老链路/日志)。
|
||||
/// - `prompt_cache_hit_tokens`:缓存命中(低价,deepseek/anthropic cache_read)。
|
||||
/// - `prompt_cache_miss_tokens`:未命中(全价真实输入,deepseek/openai 扩展字段)。
|
||||
/// - `reasoning_tokens`:思考(deepseek-reasoner/o1 隐藏输出 token)。
|
||||
///
|
||||
/// 不同 provider 字段名不同,serde default 兜底(无则 0),向前兼容老响应。
|
||||
/// 构造点全用 `..Default::default()` 补缺,详见各 provider 解析点。
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TokenUsage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
pub total_tokens: u32,
|
||||
/// 缓存命中 token(低价,deepseek prompt_cache_hit / anthropic cache_read_input_tokens)
|
||||
#[serde(default)]
|
||||
pub prompt_cache_hit_tokens: u32,
|
||||
/// 未命中 token(全价真实输入,deepseek prompt_cache_miss / anthropic cache_creation)
|
||||
#[serde(default)]
|
||||
pub prompt_cache_miss_tokens: u32,
|
||||
/// 思考 token(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)
|
||||
#[serde(default)]
|
||||
pub reasoning_tokens: u32,
|
||||
}
|
||||
|
||||
/// 流式输出的 chunk
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StreamChunk {
|
||||
/// 增量文本
|
||||
pub delta: String,
|
||||
/// 是否结束
|
||||
pub finished: bool,
|
||||
/// 工具调用增量(如有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCallDelta>>,
|
||||
/// Token 用量(流末 chunk 携带,由 provider 解析自 SSE usage 事件)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage: Option<TokenUsage>,
|
||||
/// provider 流式错误事件(如 Anthropic SSE `type=="error"`)。
|
||||
/// 非空表示流中途出错,不应视为正常完成(finished 路径),由 stream_llm 转 AiError。
|
||||
#[serde(skip)]
|
||||
pub error: Option<String>,
|
||||
/// 本流因 provider 输出上限截断收尾(openai finish_reason=length /
|
||||
/// anthropic stop_reason=max_tokens)。finished 仍为 true(正常终止非断连),但文本/
|
||||
/// tool_calls 可能不完整;消费方跨帧 OR 累积该标志,轮产出据此标记截断,不冒充正常完成。
|
||||
#[serde(default)]
|
||||
pub truncated_by_max_tokens: bool,
|
||||
/// DeepSeek thinking 模式推理内容增量
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
/// 工具调用增量(流式中的片段)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallDelta {
|
||||
/// 索引
|
||||
pub index: u32,
|
||||
/// 工具调用 ID(仅第一个 chunk 有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
/// 函数名片段
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub function_name: Option<String>,
|
||||
/// 函数参数片段
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub function_arguments: Option<String>,
|
||||
}
|
||||
|
||||
/// 异步流类型别名
|
||||
pub type StreamResult = Pin<Box<dyn Stream<Item = anyhow::Result<StreamChunk>> + Send>>;
|
||||
+10
-4
@@ -4,15 +4,21 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
df-core = { path = "../df-core" }
|
||||
df-types = { path = "../df-types" }
|
||||
df-ai-core = { path = "../df-ai-core" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync"] }
|
||||
tokio = { workspace = true, features = ["sync", "time"] }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
base64 = "0.22"
|
||||
|
||||
# HTTP + 流式
|
||||
reqwest = { version = "0.12", features = ["stream", "json"] }
|
||||
reqwest = { version = "0.12", features = ["stream", "json", "rustls-tls"] }
|
||||
bytes = "1"
|
||||
futures = "0.3"
|
||||
eventsource-stream = "0.2"
|
||||
rand = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//! 独立诊断:用 df-ai 真实调用 GLM anthropic 流式端点,验证
|
||||
//! sse_parser + apply_anthropic_event + provider.stream() 整条链路。
|
||||
//!
|
||||
//! 二分定位「发消息卡掉」: 若本例能正常吐 chunk → provider 层(df-ai)OK,
|
||||
//! 问题在 devflow 应用层(provider 配置/emit/前端); 若卡/空/Err → df-ai 有 bug。
|
||||
//!
|
||||
//! 运行: cd crates/df-ai && cargo run --example glm_stream_test
|
||||
use df_ai::build_provider;
|
||||
use df_ai_core::CompletionRequest;
|
||||
use futures::StreamExt;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let token = std::env::var("ANTHROPIC_AUTH_TOKEN")
|
||||
.or_else(|_| std::env::var("ANTHROPIC_API_KEY"))
|
||||
.expect("需要环境变量 ANTHROPIC_AUTH_TOKEN");
|
||||
eprintln!("[glm-test] token len={}", token.len());
|
||||
|
||||
let provider = build_provider(
|
||||
"anthropic",
|
||||
"https://open.bigmodel.cn/api/anthropic",
|
||||
&token,
|
||||
"glm-5.2",
|
||||
);
|
||||
|
||||
// 用 JSON 反序列化构造请求,绕开字段列表(devflow 实际用 glm-5.2)
|
||||
let req: CompletionRequest = serde_json::from_str(
|
||||
r#"{"model":"glm-5.2","stream":true,"max_tokens":16,"messages":[{"role":"user","content":"说你好"}]}"#,
|
||||
)
|
||||
.expect("parse CompletionRequest");
|
||||
|
||||
eprintln!("[glm-test] 调用 provider.stream() ...");
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut s = match provider.stream(req).await {
|
||||
Ok(s) => {
|
||||
eprintln!("[glm-test] stream() Ok, 建连耗时 {:?}", t0.elapsed());
|
||||
s
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[glm-test] stream() Err: {:#}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut n = 0;
|
||||
let mut got_text = false;
|
||||
while let Some(chunk_result) = s.next().await {
|
||||
n += 1;
|
||||
match chunk_result {
|
||||
Ok(chunk) => {
|
||||
if !chunk.delta.is_empty() {
|
||||
got_text = true;
|
||||
}
|
||||
eprintln!(
|
||||
"[glm-test] chunk#{} delta={:?} reasoning={:?} finished={} usage={:?} err={:?}",
|
||||
n, chunk.delta,
|
||||
chunk.reasoning_content.as_deref().map(|s| if s.len() > 30 { format!("{}..", &s[..30]) } else { s.to_string() }),
|
||||
chunk.finished, chunk.usage, chunk.error
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[glm-test] chunk#{} Err: {}", n, e);
|
||||
}
|
||||
}
|
||||
if n > 60 {
|
||||
eprintln!("[glm-test] 超 60 chunk 截断");
|
||||
break;
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
"[glm-test] 流结束 共 {} chunk, 是否拿到文本={}, 总耗时 {:?}",
|
||||
n,
|
||||
got_text,
|
||||
t0.elapsed()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
[
|
||||
{
|
||||
"model_id": "glm-4",
|
||||
"enabled": true,
|
||||
"modalities": ["text"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 60,
|
||||
"context_window": 128000
|
||||
},
|
||||
{
|
||||
"model_id": "glm-4-air",
|
||||
"enabled": true,
|
||||
"modalities": ["text"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 80,
|
||||
"context_window": 128000
|
||||
},
|
||||
{
|
||||
"model_id": "glm-4-flash",
|
||||
"enabled": true,
|
||||
"modalities": ["text"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 90,
|
||||
"context_window": 128000
|
||||
},
|
||||
{
|
||||
"model_id": "glm-4-plus",
|
||||
"enabled": true,
|
||||
"modalities": ["text"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 50,
|
||||
"context_window": 128000
|
||||
},
|
||||
{
|
||||
"model_id": "glm-4v",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 70,
|
||||
"context_window": 128000
|
||||
},
|
||||
{
|
||||
"model_id": "glm-4v-flash",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 85,
|
||||
"context_window": 128000
|
||||
},
|
||||
{
|
||||
"model_id": "gpt-4o",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 50,
|
||||
"context_window": 128000
|
||||
},
|
||||
{
|
||||
"model_id": "gpt-4o-mini",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 80,
|
||||
"context_window": 128000
|
||||
},
|
||||
{
|
||||
"model_id": "claude-3-5-sonnet-20241022",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 50,
|
||||
"context_window": 200000
|
||||
},
|
||||
{
|
||||
"model_id": "claude-3-opus-20240229",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 45,
|
||||
"context_window": 200000
|
||||
},
|
||||
{
|
||||
"model_id": "claude-3-haiku-20240307",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 80,
|
||||
"context_window": 200000
|
||||
},
|
||||
{
|
||||
"model_id": "gemini-1.5-pro",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 55,
|
||||
"context_window": 1000000
|
||||
},
|
||||
{
|
||||
"model_id": "gemini-1.5-flash",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 85,
|
||||
"context_window": 1000000
|
||||
},
|
||||
{
|
||||
"model_id": "deepseek-chat",
|
||||
"enabled": true,
|
||||
"modalities": ["text"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 75,
|
||||
"context_window": 64000
|
||||
},
|
||||
{
|
||||
"model_id": "deepseek-coder",
|
||||
"enabled": true,
|
||||
"modalities": ["text"],
|
||||
"capabilities": ["tool_use", "code_gen"],
|
||||
"weight": 75,
|
||||
"context_window": 64000
|
||||
},
|
||||
{
|
||||
"model_id": "qwen-2.5",
|
||||
"enabled": true,
|
||||
"modalities": ["text"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 65,
|
||||
"context_window": 128000
|
||||
},
|
||||
{
|
||||
"model_id": "sensenova-6.7-flash-lite",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 85,
|
||||
"context_window": 262144,
|
||||
"temperature_supported": false
|
||||
},
|
||||
{
|
||||
"model_id": "sensenova-6.8-flash-lite",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 85,
|
||||
"context_window": 262144,
|
||||
"temperature_supported": false
|
||||
},
|
||||
{
|
||||
"model_id": "sn2-kimi",
|
||||
"enabled": true,
|
||||
"modalities": ["text"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 80,
|
||||
"context_window": 131072,
|
||||
"temperature_supported": false
|
||||
},
|
||||
{
|
||||
"model_id": "deepseek-v4-flash",
|
||||
"enabled": true,
|
||||
"modalities": ["text"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 80,
|
||||
"context_window": 1048576
|
||||
},
|
||||
{
|
||||
"model_id": "embedding-3",
|
||||
"enabled": true,
|
||||
"modalities": [],
|
||||
"capabilities": ["embedding"],
|
||||
"weight": 50,
|
||||
"context_window": 8192
|
||||
}
|
||||
]
|
||||
+621
-22
@@ -8,6 +8,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{LazyLock, RwLock};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -40,10 +41,328 @@ pub struct AiTool {
|
||||
pub definition: ToolDefinition,
|
||||
/// 风险级别
|
||||
pub risk_level: RiskLevel,
|
||||
/// 工具元数据(DRY 债 #5 收敛:注册点声明,消费方按条目或全局表查询,不再散布 match 表)
|
||||
pub meta: ToolMeta,
|
||||
/// 执行处理器
|
||||
pub handler: AiToolHandler,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具元数据(DRY 债 #5)
|
||||
//
|
||||
// 此前工具元数据以 5 处手写 match 表散布在调用侧:
|
||||
// path_auth.rs(路径参数键) / agentic/helpers.rs 两处(目标描述 + 漂移签名) /
|
||||
// audit/data_change.rs(entity-action) / audit/cache.rs(只读缓存白名单 + 目录级 key) /
|
||||
// tool_registry.rs(审批文案模板 + 轻量动词标签),新增工具需人肉同步 5-6 处且已漂移。
|
||||
// 收敛后元数据仅在 declare_tool! 注册点声明一次,上述消费方全部改查本表;
|
||||
// 未注册工具走各消费方原保守 default(路径不校验/不缓存/不联动)。
|
||||
//
|
||||
// 维度按 5 处旧表的实际消费盘点,不为用不上的维度加字段:
|
||||
// - path_args:路径授权预校验提取哪些参数键(rename_file 为 from+to,其余文件工具为 path)
|
||||
// - readonly:只读幂等工具(结果可缓存)
|
||||
// - cache_path_key:结果缓存 key 只取 path(目录级去重,同目录换递归参数也判重复)
|
||||
// - deny_unauthorized:路径未授权时硬拒而非弹窗(search_files 盲搜语义应拒不应询)
|
||||
// - entity_action:数据变更联动刷新的 (entity, action)
|
||||
// - approval_hint:审批卡文案模板 + 关键参数键
|
||||
// - display_verb:轻量中文动作标签
|
||||
// - goal:从工具调用推理目标描述(动词 + 参数键 + 取值风格 + 缺参兜底文案)
|
||||
// - sig:漂移检测签名取哪些参数组(组内多键为取值回退序,数值键按 JSON 形态序列化;
|
||||
// 注册点未覆盖但换值即换操作的键由 SIG_EXTRA_GROUPS 补参,见 tool_signature)
|
||||
// ============================================================
|
||||
|
||||
/// 取值风格(goal 维度):从参数值到展示文本的取法
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GoalStyle {
|
||||
/// 取路径末段文件名(与旧实现一致按 / 切分)
|
||||
FileName,
|
||||
/// 原样
|
||||
Raw,
|
||||
/// 超 25 字节截断(字符边界回退,防多字节字符中间切 panic)
|
||||
Truncate,
|
||||
}
|
||||
|
||||
/// 目标描述元数据:infer_goal_from_tool_calls 消费
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct GoalMeta {
|
||||
/// 动词前缀(如 "修改"/"查看"/"搜索")
|
||||
pub verb: &'static str,
|
||||
/// 参数键,按序取第一个存在的键
|
||||
pub keys: &'static [&'static str],
|
||||
/// 取值风格
|
||||
pub style: GoalStyle,
|
||||
/// 键缺失/解析失败时的兜底文案(如 "搜索文件");None 表示返回 None
|
||||
pub fallback: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// 签名取值类型:字符串键取原始串,数值键按 JSON 序列化形态
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SigValKind {
|
||||
/// 字符串取值(as_str,缺失为空串)
|
||||
Str,
|
||||
/// Value::to_string 形态(数字 50 -> "50",布尔 true -> "true");组内全键参与归一化
|
||||
/// (排序拼接,缺失键空占位)
|
||||
Json,
|
||||
}
|
||||
|
||||
/// 漂移检测签名元数据:tool_call_signature 消费
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct SigMeta {
|
||||
/// 有序参数组;组内多键:Str 组为取值回退序(如 pattern 缺失落 query),Json 组为
|
||||
/// 全键参与(排序拼接,防同组其他键换值被压成同签名)
|
||||
pub groups: &'static [(&'static [&'static str], SigValKind)],
|
||||
}
|
||||
|
||||
/// 工具元数据(全部为编译期静态值,Copy 廉价传递)
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct ToolMeta {
|
||||
/// 路径授权预校验的参数键;空 = 非文件工具(不预校验,保守 default)
|
||||
pub path_args: &'static [&'static str],
|
||||
/// 只读幂等工具(结果可安全缓存)
|
||||
pub readonly: bool,
|
||||
/// 结果缓存 key 只取 path(目录级去重)
|
||||
pub cache_path_key: bool,
|
||||
/// 路径未授权时硬拒(不弹授权窗)
|
||||
pub deny_unauthorized: bool,
|
||||
/// 数据变更联动 (entity, action)
|
||||
pub entity_action: Option<(&'static str, &'static str)>,
|
||||
/// 审批文案模板 + 关键参数键(模板 {} 按位置填充)
|
||||
pub approval_hint: Option<(&'static str, &'static [&'static str])>,
|
||||
/// 轻量中文动作标签
|
||||
pub display_verb: Option<&'static str>,
|
||||
/// 目标描述元数据
|
||||
pub goal: Option<GoalMeta>,
|
||||
/// 漂移检测签名元数据
|
||||
pub sig: Option<SigMeta>,
|
||||
}
|
||||
|
||||
/// 进程级工具元数据表。register_meta 写入(注册点 = 唯一写入口),
|
||||
/// path_auth / cache / agentic helpers 等无 registry 句柄的自由函数按名查询。
|
||||
static GLOBAL_TOOL_META: LazyLock<RwLock<HashMap<String, ToolMeta>>> =
|
||||
LazyLock::new(|| RwLock::new(HashMap::new()));
|
||||
|
||||
/// 按工具名查元数据(查全局表,未注册返回 None)
|
||||
pub fn tool_meta(name: &str) -> Option<ToolMeta> {
|
||||
GLOBAL_TOOL_META
|
||||
.read()
|
||||
.expect("全局工具元数据表锁")
|
||||
.get(name)
|
||||
.copied()
|
||||
}
|
||||
|
||||
/// 全局元数据表是否为空(测试种子判定用;生产注册先于任何查询,表恒非空)
|
||||
#[doc(hidden)]
|
||||
pub fn meta_table_is_empty() -> bool {
|
||||
GLOBAL_TOOL_META.read().expect("全局工具元数据表锁").is_empty()
|
||||
}
|
||||
|
||||
/// 路径授权预校验参数键(未注册工具返回空切片,即非文件工具)
|
||||
pub fn path_arg_keys(name: &str) -> &'static [&'static str] {
|
||||
tool_meta(name).map(|m| m.path_args).unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// 是否只读幂等工具(未注册返回 false,即不缓存)
|
||||
pub fn is_readonly(name: &str) -> bool {
|
||||
tool_meta(name).map(|m| m.readonly).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 结果缓存 key 是否只取 path(未注册返回 false)
|
||||
pub fn cache_path_key_only(name: &str) -> bool {
|
||||
tool_meta(name).map(|m| m.cache_path_key).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 路径未授权是否硬拒(未注册返回 false,走弹窗)
|
||||
pub fn denies_unauthorized(name: &str) -> bool {
|
||||
tool_meta(name).map(|m| m.deny_unauthorized).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 数据变更联动 (entity, action)(未注册返回 None,不 emit)
|
||||
pub fn entity_action(name: &str) -> Option<(&'static str, &'static str)> {
|
||||
tool_meta(name).and_then(|m| m.entity_action)
|
||||
}
|
||||
|
||||
/// 审批文案模板(未注册返回 None,调用方走 fallback)
|
||||
pub fn approval_hint(name: &str) -> Option<(&'static str, &'static [&'static str])> {
|
||||
tool_meta(name).and_then(|m| m.approval_hint)
|
||||
}
|
||||
|
||||
/// 轻量中文动作标签(未注册返回 None)
|
||||
pub fn display_verb(name: &str) -> Option<&'static str> {
|
||||
tool_meta(name).and_then(|m| m.display_verb)
|
||||
}
|
||||
|
||||
/// 目标描述元数据(未注册返回 None)
|
||||
pub fn goal_of(name: &str) -> Option<GoalMeta> {
|
||||
tool_meta(name).and_then(|m| m.goal)
|
||||
}
|
||||
|
||||
/// 漂移检测签名元数据(未注册返回 None,调用方走全参兜底)
|
||||
pub fn sig_of(name: &str) -> Option<SigMeta> {
|
||||
tool_meta(name).and_then(|m| m.sig)
|
||||
}
|
||||
|
||||
/// 按路径参数键从 args 提取路径列表(键序即提取序,缺失键跳过)
|
||||
pub fn extract_paths(keys: &'static [&'static str], args: &Value) -> Vec<String> {
|
||||
keys.iter()
|
||||
.filter_map(|k| args.get(*k).and_then(|x| x.as_str()).map(|s| s.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 由目标元数据渲染目标描述。args 为 None(调用参数 JSON 解析失败)时仅兜底文案可产出。
|
||||
pub fn goal_text(goal: GoalMeta, args: Option<&Value>) -> Option<String> {
|
||||
let value = args
|
||||
.and_then(|a| goal.keys.iter().find_map(|k| a.get(k)))
|
||||
.and_then(|v| v.as_str());
|
||||
let rendered = value.map(|v| match goal.style {
|
||||
GoalStyle::FileName => {
|
||||
// 按旧实现仅按 / 取末段;rsplit 恒有首项,反斜杠路径在此保持原样(行为对齐)
|
||||
let f = v.rsplit('/').next().unwrap_or(v);
|
||||
format!("{} {}", goal.verb, f)
|
||||
}
|
||||
GoalStyle::Raw => format!("{} {}", goal.verb, v),
|
||||
GoalStyle::Truncate => {
|
||||
let mut end = 25.min(v.len());
|
||||
while end > 0 && !v.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!("{} {}", goal.verb, &v[..end])
|
||||
}
|
||||
});
|
||||
rendered.or_else(|| goal.fallback.map(|f| f.to_string()))
|
||||
}
|
||||
|
||||
/// 组渲染口径:Always = 基础组逐组渲染,缺键空值占位(消费方按固定串断言,形态不可漂移);
|
||||
/// Present = 补参口径,只渲染 args 中实际出现的键,键未出现视同未换参,不渲染空占位。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum GroupPresence {
|
||||
Always,
|
||||
Present,
|
||||
}
|
||||
|
||||
/// 组渲染:每组产出 k=v 片段,组间逗号连接。Str 组标签恒为首键(与旧实现
|
||||
/// pair("pattern", ...) 一致,取值来自回退键时标签不变),取组内首个字符串值键的值,
|
||||
/// 全非字符串落末键。Json 组全键参与归一化:数值/布尔键换值即换操作,只取 keys[0] 会把
|
||||
/// 同组其他键换值的不同调用压成同一签名(G2 熔断以签名为重复判据,压缩即误判重复),
|
||||
/// 排序后拼接保证与键序无关的确定性。
|
||||
fn render_groups(
|
||||
groups: &'static [(&'static [&'static str], SigValKind)],
|
||||
args: &Value,
|
||||
presence: GroupPresence,
|
||||
) -> String {
|
||||
groups
|
||||
.iter()
|
||||
.filter_map(|(keys, kind)| match kind {
|
||||
SigValKind::Json => {
|
||||
let mut parts: Vec<String> = keys
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|k| presence == GroupPresence::Always || args.get(*k).is_some())
|
||||
.map(|k| match args.get(k) {
|
||||
Some(v) => format!("{}={}", k, v.to_string()),
|
||||
None => format!("{}=", k),
|
||||
})
|
||||
.collect();
|
||||
parts.sort_unstable();
|
||||
if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parts.join("|"))
|
||||
}
|
||||
}
|
||||
SigValKind::Str => {
|
||||
if presence == GroupPresence::Present {
|
||||
// 补参组恒单键:键未出现整组跳过,值非字符串视同缺失
|
||||
return args
|
||||
.get(keys[0])
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| format!("{}={}", keys[0], s));
|
||||
}
|
||||
let key = keys
|
||||
.iter()
|
||||
.find(|k| args.get(**k).and_then(|v| v.as_str()).is_some())
|
||||
.unwrap_or(&keys[keys.len() - 1]);
|
||||
Some(format!(
|
||||
"{}={}",
|
||||
keys[0],
|
||||
args.get(*key).and_then(|v| v.as_str()).unwrap_or("")
|
||||
))
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
}
|
||||
|
||||
/// 签名体:基础组按 Always 口径渲染。
|
||||
pub fn sig_body(sig: SigMeta, args: &Value) -> String {
|
||||
render_groups(sig.groups, args, GroupPresence::Always)
|
||||
}
|
||||
|
||||
/// 签名补参表:注册点 SigMeta 组未覆盖、但「换值即换操作」的键,按工具名追加进签名。
|
||||
///
|
||||
/// 为什么在 df-ai 签名层:G2 熔断以签名为重复判据,签名过粗会把合法换参误判成
|
||||
/// 「同一操作反复」(同工具换参是不同操作)。注册点 SigMeta 声明主参数组,换参敏感
|
||||
/// 但未覆盖的键由本表统一补参;若日后注册点把这些键并入 groups,须同步从本表移除
|
||||
/// (双源会重复渲染同键,不改变同异判定但产生冗余片段)。
|
||||
///
|
||||
/// 渲染走 Present 口径(键出现在 args 才进签名):补参键缺省 = 未换参,既有签名逐字
|
||||
/// 不变。纯格式/开关类键(-n/-C/context_chars/timeout_secs)不补,变体不构成意图变化,
|
||||
/// 补进签名只会稀释重复判据。
|
||||
static SIG_EXTRA_GROUPS: &[(&str, &'static [(&'static [&'static str], SigValKind)])] = &[
|
||||
// read_file:同 path 换 search 是换词定位,非重复读
|
||||
("read_file", &[(&["search"], SigValKind::Str)]),
|
||||
// search_files:换 recursive 换搜索范围,换 offset/limit 是翻页续搜
|
||||
// (schema 无 -i/output_mode,大小写不敏感是内建语义非参数)
|
||||
(
|
||||
"search_files",
|
||||
&[
|
||||
(&["recursive"], SigValKind::Json),
|
||||
(&["offset"], SigValKind::Json),
|
||||
(&["limit"], SigValKind::Json),
|
||||
],
|
||||
),
|
||||
// grep:换 -i 是敏感/不敏感两种匹配,换 output_mode 是行内容/文件名/计数三种形态,
|
||||
// 换 max_results 是扩容重取(schema 无 offset/limit,数量参数实为 max_results)
|
||||
(
|
||||
"grep",
|
||||
&[
|
||||
(&["-i"], SigValKind::Json),
|
||||
(&["output_mode"], SigValKind::Str),
|
||||
(&["max_results"], SigValKind::Json),
|
||||
],
|
||||
),
|
||||
// run_command:同命令不同 working_dir 是在不同目录执行
|
||||
("run_command", &[(&["working_dir"], SigValKind::Str)]),
|
||||
];
|
||||
|
||||
/// 按工具名取补参组(未列名返回 None)
|
||||
fn sig_extra_groups(name: &str) -> Option<&'static [(&'static [&'static str], SigValKind)]> {
|
||||
SIG_EXTRA_GROUPS
|
||||
.iter()
|
||||
.find(|(n, _)| *n == name)
|
||||
.map(|(_, g)| *g)
|
||||
}
|
||||
|
||||
/// 工具调用签名:有 SigMeta 元数据按组渲染并追加补参组,未注册回退「工具名 + 全参 JSON」
|
||||
/// 保守兜底(全参已含补参键,无需追加)。
|
||||
///
|
||||
/// G2 判定语义:同工具同参连续 = 同签名,计重复;同工具换参 = 补参键进签名,签名不同
|
||||
/// 不累计(正常探索)。
|
||||
pub fn tool_signature(name: &str, args: &Value) -> String {
|
||||
match sig_of(name) {
|
||||
Some(sig) => {
|
||||
let mut body = render_groups(sig.groups, args, GroupPresence::Always);
|
||||
let extra = sig_extra_groups(name)
|
||||
.map(|g| render_groups(g, args, GroupPresence::Present))
|
||||
.unwrap_or_default();
|
||||
if !extra.is_empty() {
|
||||
body.push(',');
|
||||
body.push_str(&extra);
|
||||
}
|
||||
format!("{}:{}", name, body)
|
||||
}
|
||||
None => format!("{}:name={},args={}", name, name, args.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 工具处理器类型 — 异步函数,接收 JSON 参数,返回 JSON 结果
|
||||
pub type AiToolHandler =
|
||||
Box<dyn Fn(Value) -> Pin<Box<dyn Future<Output = anyhow::Result<Value>> + Send>> + Send + Sync>;
|
||||
@@ -65,7 +384,7 @@ impl AiToolRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// 注册一个工具
|
||||
/// 注册一个工具(无元数据,ToolMeta::default)
|
||||
pub fn register(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
@@ -73,19 +392,47 @@ impl AiToolRegistry {
|
||||
parameters: Value,
|
||||
risk_level: RiskLevel,
|
||||
handler: AiToolHandler,
|
||||
) {
|
||||
self.register_meta(name, description, parameters, risk_level, ToolMeta::default(), handler);
|
||||
}
|
||||
|
||||
/// 注册一个工具并写入元数据。
|
||||
///
|
||||
/// 元数据在同一调用点写入两处,无双源漂移:
|
||||
/// - 注册表条目 AiTool.meta(持有 registry 实例的消费方查询)
|
||||
/// - 进程级 GLOBAL_TOOL_META 表(path_auth/cache/agentic helpers 等无
|
||||
/// registry 句柄的自由函数按工具名查询)
|
||||
pub fn register_meta(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
description: impl Into<String>,
|
||||
parameters: Value,
|
||||
risk_level: RiskLevel,
|
||||
meta: ToolMeta,
|
||||
handler: AiToolHandler,
|
||||
) {
|
||||
let name_str = name.into();
|
||||
let definition = ToolDefinition::function(&name_str, description, parameters);
|
||||
GLOBAL_TOOL_META
|
||||
.write()
|
||||
.expect("全局工具元数据表锁")
|
||||
.insert(name_str.clone(), meta);
|
||||
self.tools.insert(
|
||||
name_str,
|
||||
AiTool {
|
||||
definition,
|
||||
risk_level,
|
||||
meta,
|
||||
handler,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 查询工具元数据条目(持有 registry 实例的消费方用)
|
||||
pub fn meta(&self, name: &str) -> Option<ToolMeta> {
|
||||
self.tools.get(name).map(|t| t.meta)
|
||||
}
|
||||
|
||||
/// 获取所有工具定义(发送给 LLM 的 tools 参数)
|
||||
pub fn tool_definitions(&self) -> Vec<ToolDefinition> {
|
||||
self.tools.values().map(|t| t.definition.clone()).collect()
|
||||
@@ -96,6 +443,19 @@ impl AiToolRegistry {
|
||||
self.tools.get(name)
|
||||
}
|
||||
|
||||
/// 执行指定工具 — handler 是唯一执行路径(schema+risk+实现同源,消除双轨)
|
||||
pub async fn execute(
|
||||
&self,
|
||||
name: &str,
|
||||
args: serde_json::Value,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let tool = self
|
||||
.tools
|
||||
.get(name)
|
||||
.ok_or_else(|| anyhow::anyhow!("未知工具: {}", name))?;
|
||||
(tool.handler)(args).await
|
||||
}
|
||||
|
||||
/// 获取所有已注册工具名称
|
||||
pub fn tool_names(&self) -> Vec<String> {
|
||||
self.tools.keys().cloned().collect()
|
||||
@@ -118,27 +478,6 @@ impl Default for AiToolRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具执行结果
|
||||
// ============================================================
|
||||
|
||||
/// 工具执行结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolExecutionResult {
|
||||
/// 工具调用 ID
|
||||
pub tool_call_id: String,
|
||||
/// 工具名称
|
||||
pub tool_name: String,
|
||||
/// 执行参数
|
||||
pub arguments: Value,
|
||||
/// 执行结果
|
||||
pub result: Value,
|
||||
/// 是否需要人工批准
|
||||
pub approval_required: bool,
|
||||
/// 风险级别
|
||||
pub risk_level: RiskLevel,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 辅助: 构建 JSON Schema 参数
|
||||
// ============================================================
|
||||
@@ -164,3 +503,263 @@ pub fn object_schema(properties: Vec<(&str, &str, bool)>) -> Value {
|
||||
"required": required,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 构造一个空参恒返 Ok 的 handler(注册测试用·不实际执行)
|
||||
fn dummy_handler() -> AiToolHandler {
|
||||
Box::new(|_args: Value| {
|
||||
Box::pin(async { Ok(serde_json::json!({ "ok": true })) })
|
||||
as Pin<Box<dyn Future<Output = anyhow::Result<Value>> + Send>>
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn risk_level_serde_lowercase() {
|
||||
// serde rename_all="lowercase": Low→"low" / Medium→"medium" / High→"high"
|
||||
assert_eq!(serde_json::to_string(&RiskLevel::Low).unwrap(), "\"low\"");
|
||||
assert_eq!(serde_json::to_string(&RiskLevel::Medium).unwrap(), "\"medium\"");
|
||||
assert_eq!(serde_json::to_string(&RiskLevel::High).unwrap(), "\"high\"");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<RiskLevel>("\"high\"").unwrap(),
|
||||
RiskLevel::High
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_and_get_tool() {
|
||||
let mut reg = AiToolRegistry::new();
|
||||
assert!(reg.is_empty());
|
||||
reg.register(
|
||||
"list_tasks",
|
||||
"列出任务",
|
||||
object_schema(vec![]),
|
||||
RiskLevel::Low,
|
||||
dummy_handler(),
|
||||
);
|
||||
assert!(!reg.is_empty());
|
||||
assert_eq!(reg.len(), 1);
|
||||
assert_eq!(reg.get("list_tasks").unwrap().risk_level, RiskLevel::Low);
|
||||
assert!(reg.get("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_same_name_overwrites() {
|
||||
// 同名二次注册覆盖(HashMap insert 语义)·非新增·后注册的 risk_level 胜出
|
||||
let mut reg = AiToolRegistry::new();
|
||||
reg.register("t", "v1", object_schema(vec![]), RiskLevel::Low, dummy_handler());
|
||||
reg.register("t", "v2", object_schema(vec![]), RiskLevel::High, dummy_handler());
|
||||
assert_eq!(reg.len(), 1);
|
||||
assert_eq!(reg.get("t").unwrap().risk_level, RiskLevel::High);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_definitions_and_names() {
|
||||
let mut reg = AiToolRegistry::new();
|
||||
reg.register("a", "desc a", object_schema(vec![]), RiskLevel::Low, dummy_handler());
|
||||
reg.register("b", "desc b", object_schema(vec![]), RiskLevel::Medium, dummy_handler());
|
||||
assert_eq!(reg.tool_definitions().len(), 2);
|
||||
let names = reg.tool_names();
|
||||
assert_eq!(names.len(), 2);
|
||||
assert!(names.contains(&"a".to_string()));
|
||||
assert!(names.contains(&"b".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_is_empty() {
|
||||
let reg = AiToolRegistry::default();
|
||||
assert!(reg.is_empty());
|
||||
assert_eq!(reg.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_schema_collects_required() {
|
||||
let schema = object_schema(vec![("name", "string", true), ("age", "integer", false)]);
|
||||
assert_eq!(schema["type"], "object");
|
||||
assert_eq!(schema["properties"]["name"]["type"], "string");
|
||||
let required = schema["required"].as_array().unwrap();
|
||||
assert_eq!(required.len(), 1);
|
||||
assert_eq!(required[0], "name");
|
||||
}
|
||||
|
||||
/// register_meta:条目与全局表同步可查,未注册名走保守 default。
|
||||
#[test]
|
||||
fn register_meta_stores_entry_and_global() {
|
||||
let mut reg = AiToolRegistry::new();
|
||||
let meta = ToolMeta {
|
||||
path_args: &["from", "to"],
|
||||
readonly: true,
|
||||
entity_action: Some(("task", "update")),
|
||||
..Default::default()
|
||||
};
|
||||
reg.register_meta("meta_probe", "测试", object_schema(vec![]), RiskLevel::Low, meta, dummy_handler());
|
||||
assert_eq!(reg.meta("meta_probe"), Some(meta));
|
||||
assert_eq!(tool_meta("meta_probe"), Some(meta));
|
||||
assert_eq!(path_arg_keys("meta_probe"), &["from", "to"]);
|
||||
assert!(is_readonly("meta_probe"));
|
||||
assert_eq!(entity_action("meta_probe"), Some(("task", "update")));
|
||||
// 未注册名保守 default:无路径键/非只读/无联动
|
||||
assert_eq!(path_arg_keys("never_registered"), &[] as &[&str]);
|
||||
assert!(!is_readonly("never_registered"));
|
||||
assert_eq!(entity_action("never_registered"), None);
|
||||
// 老签名 register 等价 default 元数据
|
||||
reg.register("plain_probe", "测试", object_schema(vec![]), RiskLevel::Low, dummy_handler());
|
||||
assert_eq!(reg.meta("plain_probe"), Some(ToolMeta::default()));
|
||||
}
|
||||
|
||||
/// goal_text 三种取值风格 + 键回退 + 兜底文案。
|
||||
#[test]
|
||||
fn goal_text_styles_and_fallback() {
|
||||
use serde_json::json;
|
||||
let file_name = GoalMeta { verb: "查看", keys: &["path"], style: GoalStyle::FileName, fallback: None };
|
||||
assert_eq!(goal_text(file_name, Some(&json!({"path": "a/b/c.rs"}))), Some("查看 c.rs".to_string()));
|
||||
let raw = GoalMeta { verb: "浏览", keys: &["path"], style: GoalStyle::Raw, fallback: None };
|
||||
assert_eq!(goal_text(raw, Some(&json!({"path": "x/y"}))), Some("浏览 x/y".to_string()));
|
||||
// Truncate:ASCII 超 25 字节截 25;多字节字符回退到字符边界不 panic
|
||||
let trunc = GoalMeta { verb: "执行", keys: &["command"], style: GoalStyle::Truncate, fallback: Some("执行命令") };
|
||||
let long = json!({"command": "a".repeat(40)});
|
||||
assert_eq!(goal_text(trunc, Some(&long)), Some(format!("执行 {}", "a".repeat(25))));
|
||||
let multibyte = json!({"command": "构建数据库连接池"});
|
||||
let out = goal_text(trunc, Some(&multibyte)).expect("多字节应正常产出");
|
||||
assert!(out.starts_with("执行 构建数据库"));
|
||||
// 键缺失走兜底;无兜底返 None
|
||||
assert_eq!(goal_text(trunc, Some(&json!({}))), Some("执行命令".to_string()));
|
||||
assert_eq!(goal_text(file_name, Some(&json!({}))), None);
|
||||
assert_eq!(goal_text(file_name, None), None);
|
||||
// 多键回退序:首键缺失取次键
|
||||
let multi = GoalMeta { verb: "搜索", keys: &["pattern", "query"], style: GoalStyle::Raw, fallback: Some("搜索文件") };
|
||||
assert_eq!(goal_text(multi, Some(&json!({"query": "todo"}))), Some("搜索 todo".to_string()));
|
||||
}
|
||||
|
||||
/// sig_body:组内字符串键回退 + Json 数值键形态 + 缺省空串。
|
||||
#[test]
|
||||
fn sig_body_groups_and_kinds() {
|
||||
use serde_json::json;
|
||||
let grep_sig = SigMeta { groups: &[(&["pattern", "query"], SigValKind::Str), (&["path", "glob"], SigValKind::Str)] };
|
||||
assert_eq!(sig_body(grep_sig, &json!({"pattern": "MAX", "path": "src"})), "pattern=MAX,path=src");
|
||||
assert_eq!(sig_body(grep_sig, &json!({"query": "TODO", "glob": "*.rs"})), "pattern=TODO,path=*.rs");
|
||||
assert_eq!(sig_body(grep_sig, &json!({})), "pattern=,path=");
|
||||
let read_sig = SigMeta { groups: &[(&["path"], SigValKind::Str), (&["offset"], SigValKind::Json), (&["limit"], SigValKind::Json)] };
|
||||
assert_eq!(sig_body(read_sig, &json!({"path": "a", "offset": 50})), "path=a,offset=50,limit=");
|
||||
// 未注册工具签名回退全参 JSON
|
||||
let sig = tool_signature("custom_tool", &json!({"x": 1}));
|
||||
assert!(sig.starts_with("custom_tool:name=custom_tool,args="));
|
||||
}
|
||||
|
||||
/// Json 组全键参与:同组多键排序拼接(与 args 键序无关),任一键换值即换片段。
|
||||
#[test]
|
||||
fn sig_body_json_group_includes_all_keys() {
|
||||
use serde_json::json;
|
||||
let page = SigMeta { groups: &[(&["offset", "limit"], SigValKind::Json)] };
|
||||
// 排序拼接:limit < offset 字典序,与 args 内键书写顺序无关
|
||||
assert_eq!(sig_body(page, &json!({"offset": 50, "limit": 100})), "limit=100|offset=50");
|
||||
assert_eq!(sig_body(page, &json!({"limit": 100, "offset": 50})), "limit=100|offset=50");
|
||||
// 任一键换值 → 不同片段
|
||||
assert_ne!(
|
||||
sig_body(page, &json!({"offset": 50, "limit": 100})),
|
||||
sig_body(page, &json!({"offset": 60, "limit": 100}))
|
||||
);
|
||||
// 全缺失逐键空占位(既有缺省口径);布尔键按 JSON 形态
|
||||
assert_eq!(sig_body(page, &json!({})), "limit=|offset=");
|
||||
let flag = SigMeta { groups: &[(&["-i"], SigValKind::Json)] };
|
||||
assert_eq!(sig_body(flag, &json!({"-i": true})), "-i=true");
|
||||
}
|
||||
|
||||
/// 注册与生产同形态的探针工具。df-ai 单测进程 GLOBAL_TOOL_META 无生产注册,
|
||||
/// 不种入则 tool_signature 走未注册兜底,测不到「注册点组 + 补参」合流分支。
|
||||
/// 进程级表跨测试共享,各处注册值恒相同,幂等无顺序依赖。
|
||||
fn seed_signature_probe_tools() {
|
||||
let read_sig = SigMeta {
|
||||
groups: &[
|
||||
(&["path"], SigValKind::Str),
|
||||
(&["offset"], SigValKind::Json),
|
||||
(&["limit"], SigValKind::Json),
|
||||
],
|
||||
};
|
||||
let search_sig = SigMeta {
|
||||
groups: &[
|
||||
(&["pattern", "query"], SigValKind::Str),
|
||||
(&["path", "glob"], SigValKind::Str),
|
||||
],
|
||||
};
|
||||
let cmd_sig = SigMeta { groups: &[(&["command"], SigValKind::Str)] };
|
||||
let mut reg = AiToolRegistry::new();
|
||||
reg.register_meta("read_file", "读取", object_schema(vec![]), RiskLevel::Low,
|
||||
ToolMeta { sig: Some(read_sig), ..Default::default() }, dummy_handler());
|
||||
reg.register_meta("search_files", "搜索文件", object_schema(vec![]), RiskLevel::Low,
|
||||
ToolMeta { sig: Some(search_sig), ..Default::default() }, dummy_handler());
|
||||
reg.register_meta("grep", "跨文件内容搜索", object_schema(vec![]), RiskLevel::Low,
|
||||
ToolMeta { sig: Some(search_sig), ..Default::default() }, dummy_handler());
|
||||
reg.register_meta("run_command", "执行命令", object_schema(vec![]), RiskLevel::High,
|
||||
ToolMeta { sig: Some(cmd_sig), ..Default::default() }, dummy_handler());
|
||||
}
|
||||
|
||||
/// 补参:合法换参 → 签名不同(G2 目标语义:换参不共享签名,不再误判重复);
|
||||
/// 同参 → 签名相同(重复判据正例仍成立)。
|
||||
#[test]
|
||||
fn tool_signature_extra_params_distinguish_calls() {
|
||||
use serde_json::json;
|
||||
seed_signature_probe_tools();
|
||||
|
||||
// read_file 同 path 换 search:不同签名
|
||||
let s1 = tool_signature("read_file", &json!({"path": "a.rs", "search": "fn sig"}));
|
||||
let s2 = tool_signature("read_file", &json!({"path": "a.rs", "search": "struct cfg"}));
|
||||
assert_ne!(s1, s2, "read_file 换 search 应不同签名: {} vs {}", s1, s2);
|
||||
assert!(s1.contains("search=fn sig"), "read_file 签名应含 search: {}", s1);
|
||||
|
||||
// search_files 只差 offset(翻页续搜):不同签名
|
||||
let s1 = tool_signature("search_files", &json!({"pattern": "todo", "path": "src", "offset": 0}));
|
||||
let s2 = tool_signature("search_files", &json!({"pattern": "todo", "path": "src", "offset": 50}));
|
||||
assert_ne!(s1, s2, "search_files 换 offset 应不同签名: {} vs {}", s1, s2);
|
||||
|
||||
// grep 只差 -i / output_mode / max_results(schema 无 offset/limit,数量参数实为
|
||||
// max_results):换匹配方式/输出形态/扩容重取都是不同操作
|
||||
let base = tool_signature("grep", &json!({"pattern": "err", "path": "src"}));
|
||||
let ci = tool_signature("grep", &json!({"pattern": "err", "path": "src", "-i": true}));
|
||||
let mode = tool_signature("grep", &json!({"pattern": "err", "path": "src", "output_mode": "files_with_matches"}));
|
||||
let capped = tool_signature("grep", &json!({"pattern": "err", "path": "src", "max_results": 200}));
|
||||
assert_ne!(base, ci, "grep 换 -i 应不同签名: {} vs {}", base, ci);
|
||||
assert_ne!(base, mode, "grep 换 output_mode 应不同签名: {} vs {}", base, mode);
|
||||
assert_ne!(base, capped, "grep 换 max_results 应不同签名: {} vs {}", base, capped);
|
||||
|
||||
// run_command 同命令换 working_dir:不同签名
|
||||
let s1 = tool_signature("run_command", &json!({"command": "cargo test"}));
|
||||
let s2 = tool_signature("run_command", &json!({"command": "cargo test", "working_dir": "crates/df-ai"}));
|
||||
assert_ne!(s1, s2, "run_command 换 working_dir 应不同签名: {} vs {}", s1, s2);
|
||||
|
||||
// 同工具同参:签名相同(同签名连续才算 G2 重复)
|
||||
assert_eq!(
|
||||
tool_signature("grep", &json!({"pattern": "err", "path": "src"})),
|
||||
base
|
||||
);
|
||||
}
|
||||
|
||||
/// 补参键缺席时签名与既有格式逐字一致(向后兼容:消费方按固定串断言,空补参不占位)。
|
||||
#[test]
|
||||
fn tool_signature_without_extra_keys_unchanged() {
|
||||
use serde_json::json;
|
||||
seed_signature_probe_tools();
|
||||
assert_eq!(
|
||||
tool_signature("read_file", &json!({"path": "big.log", "offset": 50, "limit": 100})),
|
||||
"read_file:path=big.log,offset=50,limit=100"
|
||||
);
|
||||
assert_eq!(
|
||||
tool_signature("read_file", &json!({})),
|
||||
"read_file:path=,offset=,limit="
|
||||
);
|
||||
assert_eq!(
|
||||
tool_signature("grep", &json!({"pattern": "MAX", "path": "src", "case_sensitive": true})),
|
||||
"grep:pattern=MAX,path=src"
|
||||
);
|
||||
assert_eq!(
|
||||
tool_signature("search_files", &json!({"query": "TODO", "glob": "**/*.rs"})),
|
||||
"search_files:pattern=TODO,path=**/*.rs"
|
||||
);
|
||||
assert_eq!(
|
||||
tool_signature("run_command", &json!({"command": "ls -la", "timeout": 5000})),
|
||||
"run_command:command=ls -la"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
//! 声明式工具注册基础设施(tool_registry 拆分第一步)
|
||||
//!
|
||||
//! 背景:`tool_registry.rs` 4224 行,每加一个工具手写一段
|
||||
//! `registry.register(name, desc, object_schema(...), RiskLevel::X, { ... Box::new ... })`,
|
||||
//! 5 段重复样板 + 闭包捕获 + 缩进极易出错。本模块提供声明式宏 `declare_tool!`,
|
||||
//! 把工具定义收敛为「名字 / 描述 / schema / 风险 / handler 块」五要素一行式声明。
|
||||
//!
|
||||
//! 设计取舍(三选一权衡):
|
||||
//! - A. proc-macro `#[ai_tool]` 属性宏:需新建 proc-macro crate + syn/quote 重依赖,违反
|
||||
//! 「不引新依赖」。否决。
|
||||
//! - B. `inventory` crate 自动收集:新外部依赖 + 拉入 linkme/ctor 运行期注册语义,
|
||||
//! 与现有 `register_*(&mut registry)` 命令式收集并存需双重 source-of-truth。否决。
|
||||
//! - C. **`macro_rules!` 声明式宏(本方案)**:零新依赖(纯 std `macro_rules!`),
|
||||
//! 展开为等价的 `registry.register(...)` 调用——与现有 48 个 `register` 调用**完全并存**,
|
||||
//! 旧工具零改动,新工具可选声明式。handler 块就地书写,`$db`/`$registry` 等捕获变量
|
||||
//! 原样透传,语义 1:1 等价(schema/risk/handler 同源不变)。
|
||||
//!
|
||||
//! 试点:`list_projects` 已迁至声明式(见 commands/ai/tools/list_projects.rs),验证编译过 +
|
||||
//! 行为等价 + 48 工具基线不破。其余 47 个工具后续批次渐进迁移,不在此步。
|
||||
//!
|
||||
//! 宏展开示例(输入):
|
||||
//! ```ignore
|
||||
//! declare_tool!(registry, db: Arc<Database>, "list_projects",
|
||||
//! "列出所有项目...", RiskLevel::Low,
|
||||
//! schema: object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]),
|
||||
//! args => {
|
||||
//! let repo = ProjectRepo::new(&db);
|
||||
//! let items = repo.list_active().await?;
|
||||
//! // ...
|
||||
//! Ok(json!({ "items": items, ... }))
|
||||
//! });
|
||||
//! ```
|
||||
//! 展开后等价于现有手写的:
|
||||
//! ```ignore
|
||||
//! registry.register(
|
||||
//! "list_projects", "...",
|
||||
//! object_schema(vec![...]), RiskLevel::Low,
|
||||
//! { let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||
//! let db = db.clone();
|
||||
//! Box::pin(async move { /* handler body */ })
|
||||
//! })},
|
||||
//! );
|
||||
//! ```
|
||||
|
||||
/// 声明式注册一个 AI 工具到 `$registry`。
|
||||
///
|
||||
/// 与手写 `registry.register(name, desc, schema, risk, handler)` 语义 1:1 等价,
|
||||
/// 仅消除闭包包装样板(`{ let db = db.clone(); Box::new(move |args| { let db = db.clone(); Box::pin(async move { ... }) }) }`)。
|
||||
///
|
||||
/// 形参:
|
||||
/// - `$registry`: `&mut AiToolRegistry` 注册表句柄。
|
||||
/// - `$capture: $cap_ty`: handler 需捕获的外部变量(如 `db: Arc<Database>`)。宏自动 clone 进闭包,
|
||||
/// handler body 内以 `$capture` 名访问。无捕获工具用 `_` 占位(并保证 body 不引用它)。
|
||||
/// - `$name`: 工具名 `&str`。
|
||||
/// - `$desc`: 工具描述 `&str`(发给 LLM)。
|
||||
/// - `$risk`: `RiskLevel`(如 `RiskLevel::Low`)。
|
||||
/// - `meta: $meta`(可选段): `ToolMeta` 字面量(DRY 债 #5)。带此段走 `register_meta` 落
|
||||
/// 条目 + 全局元数据表;不带则 default 元数据(全空,消费方走保守 default 分支)。
|
||||
/// - `$schema`: 参数 JSON Schema(`serde_json::Value`),常用 `object_schema(...)`。
|
||||
/// - `$handler`: 一个 **block 表达式**(花括号体),返回 `anyhow::Result<serde_json::Value>`。
|
||||
/// 体内在 `$args`(serde_json::Value)与 `$capture`(克隆后的捕获变量)上工作。
|
||||
/// 宏负责把它包进 `async move { ... }` 并 `Box::pin`——故调用方只写「同步语义的体」,
|
||||
/// 不写 `async move`/`Box::pin`/`Box::new` 三层样板。
|
||||
///
|
||||
/// 注意:此宏不替代 `register`,而是包装它——`register`/`register_meta` 仍是
|
||||
/// `AiToolRegistry` 的唯一注册入口,宏仅是语法糖。
|
||||
#[macro_export]
|
||||
macro_rules! declare_tool {
|
||||
(
|
||||
$registry:expr,
|
||||
$capture:ident : $cap_ty:ty,
|
||||
$name:expr,
|
||||
$desc:expr,
|
||||
$risk:expr,
|
||||
meta: $meta:expr,
|
||||
schema: $schema:expr,
|
||||
$args:ident => $handler:block
|
||||
) => {{
|
||||
let __cap: $cap_ty = $capture.clone();
|
||||
$registry.register_meta(
|
||||
$name, $desc, $schema, $risk, $meta,
|
||||
{
|
||||
let $capture = __cap.clone();
|
||||
::std::boxed::Box::new(move |$args: ::serde_json::Value| {
|
||||
let $capture = $capture.clone();
|
||||
::std::boxed::Box::pin(async move {
|
||||
let $capture: $cap_ty = $capture;
|
||||
$handler
|
||||
})
|
||||
as ::std::pin::Pin<::std::boxed::Box<
|
||||
dyn ::std::future::Future<Output = ::anyhow::Result<::serde_json::Value>>
|
||||
+ ::std::marker::Send,
|
||||
>>
|
||||
})
|
||||
},
|
||||
);
|
||||
}};
|
||||
(
|
||||
$registry:expr,
|
||||
$capture:ident : $cap_ty:ty,
|
||||
$name:expr,
|
||||
$desc:expr,
|
||||
$risk:expr,
|
||||
schema: $schema:expr,
|
||||
$args:ident => $handler:block
|
||||
) => {{
|
||||
let __cap: $cap_ty = $capture.clone();
|
||||
$registry.register_meta(
|
||||
$name, $desc, $schema, $risk, $crate::ai_tools::ToolMeta::default(),
|
||||
{
|
||||
let $capture = __cap.clone();
|
||||
::std::boxed::Box::new(move |$args: ::serde_json::Value| {
|
||||
let $capture = $capture.clone();
|
||||
::std::boxed::Box::pin(async move {
|
||||
let $capture: $cap_ty = $capture;
|
||||
$handler
|
||||
})
|
||||
as ::std::pin::Pin<::std::boxed::Box<
|
||||
dyn ::std::future::Future<Output = ::anyhow::Result<::serde_json::Value>>
|
||||
+ ::std::marker::Send,
|
||||
>>
|
||||
})
|
||||
},
|
||||
);
|
||||
}};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
|
||||
|
||||
/// 声明式宏注册的工具与手写 register 行为等价(name/schema/risk/执行结果一致)。
|
||||
/// 这是「现有工具零回归 + 语义等价」的最小验证(试点 list_projects 迁移的微观镜像)。
|
||||
#[tokio::test]
|
||||
async fn declare_tool_equivalent_to_register() {
|
||||
let mut reg = AiToolRegistry::new();
|
||||
|
||||
// 声明式注册(新基础设施)
|
||||
let counter: std::sync::Arc<std::sync::atomic::AtomicUsize> =
|
||||
std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
declare_tool!(
|
||||
reg,
|
||||
counter: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
"echo_decl",
|
||||
"声明式 echo 工具",
|
||||
RiskLevel::Low,
|
||||
schema: object_schema(vec![("msg", "string", true)]),
|
||||
args => {
|
||||
counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
let msg = args["msg"].as_str().unwrap_or("");
|
||||
Ok(serde_json::json!({ "echo": msg }))
|
||||
}
|
||||
);
|
||||
|
||||
// 等价断言:定义层(name 存在 / risk 正确 / schema 透传)
|
||||
assert_eq!(reg.len(), 1, "声明式宏应注册 1 个工具");
|
||||
let tool = reg.get("echo_decl").expect("echo_decl 应已注册");
|
||||
assert_eq!(tool.risk_level, RiskLevel::Low);
|
||||
assert_eq!(tool.definition.function.name, "echo_decl");
|
||||
assert_eq!(tool.definition.function.parameters["properties"]["msg"]["type"], "string");
|
||||
|
||||
// 执行等价:handler 收到 args、捕获变量可访问、返回 handler body 的 Value
|
||||
let out = reg.execute("echo_decl", serde_json::json!({ "msg": "hi" })).await.unwrap();
|
||||
assert_eq!(out["echo"], "hi");
|
||||
assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), 1, "捕获变量应在执行时递增");
|
||||
}
|
||||
|
||||
/// 无捕获工具(纯计算/常量返回)用占位 capture,handler 不引用它。
|
||||
#[tokio::test]
|
||||
async fn declare_tool_no_capture() {
|
||||
let mut reg = AiToolRegistry::new();
|
||||
let dummy: std::sync::Arc<()> = std::sync::Arc::new(()); // 无真实捕获,占位
|
||||
declare_tool!(
|
||||
reg,
|
||||
dummy: std::sync::Arc<()>,
|
||||
"const_tool",
|
||||
"无捕获常量工具",
|
||||
RiskLevel::Medium,
|
||||
schema: object_schema(vec![]),
|
||||
_args => { Ok(serde_json::json!({ "ok": true })) }
|
||||
);
|
||||
let out = reg.execute("const_tool", serde_json::json!({})).await.unwrap();
|
||||
assert_eq!(out["ok"], true);
|
||||
}
|
||||
|
||||
/// 带 meta 段的宏形态:元数据落条目 + 全局表;不带 meta 段落 default。
|
||||
#[tokio::test]
|
||||
async fn declare_tool_with_and_without_meta() {
|
||||
use crate::ai_tools::ToolMeta;
|
||||
let mut reg = AiToolRegistry::new();
|
||||
let dummy: std::sync::Arc<()> = std::sync::Arc::new(());
|
||||
declare_tool!(
|
||||
reg,
|
||||
dummy: std::sync::Arc<()>,
|
||||
"meta_variant_tool",
|
||||
"带元数据声明的工具",
|
||||
RiskLevel::Low,
|
||||
meta: ToolMeta { path_args: &["path"], readonly: true, ..Default::default() },
|
||||
schema: object_schema(vec![]),
|
||||
_args => { Ok(serde_json::json!({ "ok": true })) }
|
||||
);
|
||||
declare_tool!(
|
||||
reg,
|
||||
dummy: std::sync::Arc<()>,
|
||||
"plain_variant_tool",
|
||||
"不带元数据声明的工具",
|
||||
RiskLevel::Low,
|
||||
schema: object_schema(vec![]),
|
||||
_args => { Ok(serde_json::json!({ "ok": true })) }
|
||||
);
|
||||
let with = reg.meta("meta_variant_tool").expect("meta 变体应已注册");
|
||||
assert_eq!(with.path_args, &["path"]);
|
||||
assert!(with.readonly);
|
||||
assert_eq!(crate::ai_tools::path_arg_keys("meta_variant_tool"), &["path"]);
|
||||
let plain = reg.meta("plain_variant_tool").expect("无 meta 变体应已注册");
|
||||
assert_eq!(plain, ToolMeta::default());
|
||||
assert_eq!(crate::ai_tools::path_arg_keys("plain_variant_tool"), &[] as &[&str]);
|
||||
}
|
||||
}
|
||||
+1541
-210
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,286 @@
|
||||
//! Anthropic 协议适配 — 协议数据结构与 SSE 事件解析(纯函数)。
|
||||
//!
|
||||
//! 本模块从 `anthropic_compat.rs` 抽离,承载与 HTTP 无关的纯协议逻辑:
|
||||
//! - 请求/响应结构体(`AnthropicRequest` 等)
|
||||
//! - 协议常量(`ANTHROPIC_VERSION` / `DEFAULT_MAX_TOKENS`)
|
||||
//! - SSE 事件 → `StreamChunk` 转换纯函数(`apply_anthropic_event`)
|
||||
//!
|
||||
//! Provider struct + impl(含 HTTP 调用)仍留在 `anthropic_compat.rs`,
|
||||
//! Rust impl 块不可跨文件,故仅搬迁 impl 块外部的类型/常量/纯函数。
|
||||
//! 零行为变更(纯搬迁)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{error, warn};
|
||||
|
||||
use crate::provider::{tool_call_id_or_fallback, StreamChunk, TokenUsage, ToolCallDelta};
|
||||
|
||||
// ============================================================
|
||||
// Anthropic API 请求/响应结构体
|
||||
// ============================================================
|
||||
|
||||
/// Anthropic `system` 字段形态(受 prompt caching 开关 `ANTHROPIC_CACHE_ENABLED` 控制):
|
||||
/// - `Plain`:纯字符串。开关关时保持原形态,兼容非官方网关(GLM 订阅端点 / 任意 Messages API
|
||||
/// 代理——部分网关会拒收 cache_control 字段)。
|
||||
/// - `Cached`:blocks 数组 `[{type:text, text, cache_control:{type:ephemeral}}]`。开关开时启用
|
||||
/// Anthropic prompt caching,让 system 稳定段在支持缓存的端点上命中。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub(crate) enum SystemBlock {
|
||||
/// 纯字符串形态(兼容网关,开关关)
|
||||
Plain(String),
|
||||
/// blocks 数组形态(含 cache_control,开关开)
|
||||
Cached(Vec<serde_json::Value>),
|
||||
}
|
||||
|
||||
/// Anthropic 请求体
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct AnthropicRequest {
|
||||
pub model: String,
|
||||
pub messages: Vec<serde_json::Value>,
|
||||
pub max_tokens: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system: Option<SystemBlock>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
pub stream: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<AnthropicToolDef>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Anthropic 工具定义(input_schema 对应 OpenAI 的 parameters)
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct AnthropicToolDef {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub input_schema: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Anthropic 同步响应
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct AnthropicResponse {
|
||||
// Anthropic 响应反序列化字段,保留以对齐响应结构(响应 id),标注意图消除 dead_code warning
|
||||
#[allow(dead_code)]
|
||||
id: String,
|
||||
pub model: String,
|
||||
pub content: Vec<AnthropicContentBlock>,
|
||||
// Anthropic 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning
|
||||
#[allow(dead_code)]
|
||||
stop_reason: Option<String>,
|
||||
pub usage: AnthropicUsage,
|
||||
}
|
||||
|
||||
/// 响应 content 块(text / tool_use / thinking)
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct AnthropicContentBlock {
|
||||
#[serde(rename = "type")]
|
||||
pub block_type: String,
|
||||
#[serde(default)]
|
||||
pub text: Option<String>,
|
||||
/// thinking 块推理正文(端点侧开启扩展思考时出现;本侧请求未启用 thinking,一般缺省)
|
||||
#[serde(default)]
|
||||
pub thinking: Option<String>,
|
||||
/// tool_use 块字段
|
||||
pub id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub input: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct AnthropicUsage {
|
||||
pub input_tokens: u32,
|
||||
pub output_tokens: u32,
|
||||
/// Anthropic prompt caching 扩展:cache 写入 token(本次写入缓存,计费如输入但稍便宜)。
|
||||
/// 映射到 TokenUsage.prompt_cache_miss_tokens(全价输入语义)。
|
||||
/// 非 cache 场景无此字段 → serde default 0。
|
||||
#[serde(default)]
|
||||
pub cache_creation_input_tokens: u32,
|
||||
/// Anthropic prompt caching 扩展:cache 命中读取 token(低价)。
|
||||
/// 映射到 TokenUsage.prompt_cache_hit_tokens。
|
||||
/// 非 cache 场景无此字段 → serde default 0。
|
||||
#[serde(default)]
|
||||
pub cache_read_input_tokens: u32,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 协议常量
|
||||
// ============================================================
|
||||
|
||||
/// Anthropic 流式协议版本头
|
||||
pub(crate) const ANTHROPIC_VERSION: &str = "2023-06-01";
|
||||
/// Anthropic max_tokens 必填,缺省时的兜底值
|
||||
pub(crate) const DEFAULT_MAX_TOKENS: u32 = 4096;
|
||||
|
||||
// ============================================================
|
||||
// SSE 解析纯函数(与 HTTP 解耦,便于单测)
|
||||
// ============================================================
|
||||
|
||||
/// 将一条 Anthropic Messages SSE 事件 data 解析为 StreamChunk,并按需更新 usage 累加器。
|
||||
///
|
||||
/// 按 `type` 字段分发:
|
||||
/// - `message_start` → 用 `message.usage.input_tokens` 初始化累加器(output 置 0)。
|
||||
/// - `message_delta` → **output_tokens 是累计值(非增量)**,直接覆盖 `completion_tokens` 并重算 `total`。
|
||||
/// - `content_block_delta` (text_delta/input_json_delta/thinking_delta) → 文本/工具入参/思考增量,
|
||||
/// signature_delta 安静忽略。
|
||||
/// - `content_block_start` (tool_use) → 工具块开始,带 id+name。
|
||||
/// - `message_stop` → 返回 `finished=true` 终态 chunk,`usage` 取自累加器(`take()`)。
|
||||
/// - `error` → 返回 `finished=true` 终态空 chunk。
|
||||
/// - 其它(content_block_stop / ping 等)→ 空 chunk。
|
||||
///
|
||||
/// 等价性:content_block / message_stop / error 等事件分支与原 stream() 闭包逐字一致;
|
||||
/// usage 透传(message_stop 终态 take() 带出、message_delta 的 output_tokens 按累计值覆盖)
|
||||
/// 为本次新增能力,对应 StreamChunk 新增的 usage 字段。
|
||||
pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUsage>) -> StreamChunk {
|
||||
// 解析 data 中的 JSON,按 type 字段决定如何转 StreamChunk
|
||||
let v: serde_json::Value = match serde_json::from_str(data) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false }
|
||||
}
|
||||
};
|
||||
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
match ty {
|
||||
// 消息开始:取 input_tokens 初始化累积器(output 此时未知,置 0)。
|
||||
// anthropic prompt caching:cache_creation/read 在 message_start.usage 携带。
|
||||
"message_start" => {
|
||||
if let Some(inp) = v
|
||||
.get("message")
|
||||
.and_then(|m| m.get("usage"))
|
||||
.and_then(|u| u.get("input_tokens"))
|
||||
.and_then(|t| t.as_u64())
|
||||
{
|
||||
let u_obj = v.get("message").and_then(|m| m.get("usage"));
|
||||
let cache_read = u_obj
|
||||
.and_then(|u| u.get("cache_read_input_tokens"))
|
||||
.and_then(|t| t.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
let cache_creation = u_obj
|
||||
.and_then(|u| u.get("cache_creation_input_tokens"))
|
||||
.and_then(|t| t.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
*usage_accum = Some(TokenUsage {
|
||||
prompt_tokens: inp as u32,
|
||||
completion_tokens: 0,
|
||||
total_tokens: inp as u32,
|
||||
prompt_cache_hit_tokens: cache_read,
|
||||
prompt_cache_miss_tokens: cache_creation,
|
||||
reasoning_tokens: 0,
|
||||
});
|
||||
}
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: usage_accum.clone(), error: None, reasoning_content: None, truncated_by_max_tokens: false }
|
||||
}
|
||||
// 消息增量:output_tokens 是累计值(非增量),直接覆盖 completion + 重算 total
|
||||
"message_delta" => {
|
||||
if let Some(out) = v.get("usage").and_then(|u| u.get("output_tokens")).and_then(|t| t.as_u64()) {
|
||||
let acc = usage_accum
|
||||
.get_or_insert(TokenUsage::default());
|
||||
acc.completion_tokens = out as u32;
|
||||
acc.total_tokens = acc.prompt_tokens.saturating_add(acc.completion_tokens);
|
||||
}
|
||||
// stop_reason=max_tokens(message_delta 帧携带):输出被 max_tokens 截断。
|
||||
// 终态 finished 在后续 message_stop 帧,截断标志挂本帧,消费方跨帧 OR 累积。
|
||||
let truncated_by_max_tokens = v.get("delta").and_then(|d| d.get("stop_reason")).and_then(|s| s.as_str()) == Some("max_tokens");
|
||||
// 加固:usage 挂到本帧(而非仅 message_stop 带出),中途断连/端点不发 message_stop
|
||||
// 时仍能拿到真实 usage,对称 openai_helpers 的修复。
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: usage_accum.clone(), error: None, reasoning_content: None, truncated_by_max_tokens }
|
||||
}
|
||||
// 文本增量
|
||||
"content_block_delta" => {
|
||||
if let Some(delta) = v.get("delta") {
|
||||
// thinking 增量:推理正文走 reasoning_content 通道(与 DeepSeek 同路入库展示),
|
||||
// 不混入正文 delta。本侧请求未启用 thinking,此分支只为不丢端点侧自行产出的思考。
|
||||
if delta.get("type").and_then(|t| t.as_str()) == Some("thinking_delta") {
|
||||
let text = delta.get("thinking").and_then(|t| t.as_str()).unwrap_or("").to_string();
|
||||
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: Some(text), truncated_by_max_tokens: false };
|
||||
}
|
||||
// signature_delta:thinking 块签名字段,仅回传校验用。DevFlow 从不把 thinking 回传
|
||||
// anthropic 历史(丢弃 signature 后伪造回传必 400),签名无处消费,安静忽略。
|
||||
if delta.get("type").and_then(|t| t.as_str()) == Some("signature_delta") {
|
||||
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false };
|
||||
}
|
||||
if delta.get("type").and_then(|t| t.as_str()) == Some("text_delta") {
|
||||
let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string();
|
||||
return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false };
|
||||
}
|
||||
// 工具入参增量
|
||||
if delta.get("type").and_then(|t| t.as_str()) == Some("input_json_delta") {
|
||||
let partial = delta.get("partial_json").and_then(|t| t.as_str()).unwrap_or("").to_string();
|
||||
let idx = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32;
|
||||
return StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: false,
|
||||
tool_calls: Some(vec![ToolCallDelta {
|
||||
index: idx,
|
||||
id: None,
|
||||
function_name: None,
|
||||
function_arguments: Some(partial),
|
||||
}]),
|
||||
usage: None,
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
truncated_by_max_tokens: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false }
|
||||
}
|
||||
// 工具块开始:带 id + name
|
||||
"content_block_start" => {
|
||||
// thinking / redacted_thinking 块起点无正文要记(正文随后经 thinking_delta 流出;
|
||||
// redacted_thinking 为密文不可解析),落入末尾统一空 chunk 即安静跳过,不告警。
|
||||
if let Some(cb) = v.get("content_block") {
|
||||
if cb.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
|
||||
let idx = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32;
|
||||
let name = cb.get("name").and_then(|t| t.as_str()).map(|s| s.to_string());
|
||||
// CR-空 id:id 缺失/空时用 fallback 兜底(流式后续 input_json_delta 按 index 累加,
|
||||
// 中途无法整体跳过)。与同步路径 + OpenAI 路径共用 tool_call_id_or_fallback(DRY),
|
||||
// prefix=`gen_anthropic_stream` 区分来源。非空原样。
|
||||
let raw_id = cb.get("id").and_then(|t| t.as_str()).unwrap_or("");
|
||||
let id = if raw_id.is_empty() {
|
||||
let fallback = tool_call_id_or_fallback(raw_id, idx as usize, "gen_anthropic_stream");
|
||||
warn!(%fallback, name = ?name, "Anthropic 流式 tool_use 块 id 为空,已生成 fallback id(原样回传会触发 GLM 500)");
|
||||
Some(fallback)
|
||||
} else {
|
||||
Some(raw_id.to_string())
|
||||
};
|
||||
return StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: false,
|
||||
tool_calls: Some(vec![ToolCallDelta {
|
||||
index: idx,
|
||||
id,
|
||||
function_name: name,
|
||||
function_arguments: None,
|
||||
}]),
|
||||
usage: None,
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
truncated_by_max_tokens: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false }
|
||||
}
|
||||
// 消息结束:带出累积 usage
|
||||
"message_stop" => StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: true,
|
||||
tool_calls: None,
|
||||
usage: usage_accum.take(),
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
truncated_by_max_tokens: false,
|
||||
},
|
||||
// 错误事件:流中途出错。不走 finished 完成路径(避免残缺响应被当正常完成入库),
|
||||
// 改由 stream_llm 识别 error 非空 → 发 AiError + 丢弃残缺(与 OpenAI 路径 Err 一致)。
|
||||
"error" => {
|
||||
let msg = v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()).unwrap_or("stream error").to_string();
|
||||
error!(%msg, "Anthropic 流式错误事件");
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: Some(msg), reasoning_content: None, truncated_by_max_tokens: false }
|
||||
}
|
||||
// content_block_stop / ping 等不产出 chunk
|
||||
_ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false },
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
//! 上下文管理器 — 管理对话上下文和 token 预算
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::provider::ChatMessage;
|
||||
|
||||
/// 上下文窗口配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContextConfig {
|
||||
/// 最大 token 数
|
||||
pub max_tokens: u32,
|
||||
/// 保留的系统提示 token 数
|
||||
pub system_reserve: u32,
|
||||
}
|
||||
|
||||
impl Default for ContextConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_tokens: 128_000,
|
||||
system_reserve: 4_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 上下文管理器
|
||||
pub struct ContextManager {
|
||||
/// 消息历史
|
||||
messages: VecDeque<ChatMessage>,
|
||||
/// 配置
|
||||
config: ContextConfig,
|
||||
}
|
||||
|
||||
impl ContextManager {
|
||||
/// 创建上下文管理器
|
||||
pub fn new(config: ContextConfig) -> Self {
|
||||
Self {
|
||||
messages: VecDeque::new(),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加消息
|
||||
pub fn push(&mut self, message: ChatMessage) {
|
||||
self.messages.push_back(message);
|
||||
// TODO: 当超过 token 预算时,淘汰旧消息
|
||||
}
|
||||
|
||||
/// 获取当前消息列表
|
||||
pub fn messages(&self) -> &VecDeque<ChatMessage> {
|
||||
&self.messages
|
||||
}
|
||||
|
||||
/// 清空上下文
|
||||
pub fn clear(&mut self) {
|
||||
self.messages.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
//! 压缩可召回存档:被压缩原文 JSONL 落盘 + 召回句柄拼装。
|
||||
//!
|
||||
//! 压缩把原文从 LLM 上下文隔离(is_active 白名单过滤),摘要必有损。本模块在压缩标记时
|
||||
//! 把本次被压原文追加写入 `<data_dir>/context-archive/<conv_id>-archive.jsonl`
|
||||
//! (JSON Lines,一行一条 {role, content}),调用方在压缩摘要尾部拼召回句柄行,
|
||||
//! LLM 需要原文时经 read_file 回读。会话一文件、追加写,目录不存在则创建;
|
||||
//! 会话删除级联清理留待后续挂点。存档只落盘不进上下文,token 估算零影响;
|
||||
//! 不与 tool_offload 复用(那是 src-tauri 侧机制,跨 crate 不混用)。
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::provider::ChatMessage;
|
||||
|
||||
/// 存档目录名(data_dir 下)
|
||||
const ARCHIVE_DIR: &str = "context-archive";
|
||||
|
||||
/// 一次压缩事件的存档目标(会话维度)。调用方读到开关开(`df-ai-compress-recall`)后经
|
||||
/// [`super::ContextManager::set_compress_recall`] 注入,压缩标记时一次性消费。
|
||||
pub(super) struct RecallTarget {
|
||||
data_dir: PathBuf,
|
||||
conv_id: String,
|
||||
}
|
||||
|
||||
impl RecallTarget {
|
||||
pub(super) fn new(data_dir: &Path, conv_id: &str) -> Self {
|
||||
Self {
|
||||
data_dir: data_dir.to_path_buf(),
|
||||
conv_id: conv_id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 存档文件路径:`<data_dir>/context-archive/<conv_id>-archive.jsonl`
|
||||
fn file(&self) -> PathBuf {
|
||||
self.data_dir
|
||||
.join(ARCHIVE_DIR)
|
||||
.join(format!("{}-archive.jsonl", self.conv_id))
|
||||
}
|
||||
}
|
||||
|
||||
/// 把本次被压原文追加写入会话存档(JSONL 追加语义,不覆盖既有行)。
|
||||
/// 返回存档路径供句柄拼装;空消息/IO 失败返 None(调用方降级为无句柄,压缩本身不受影响)。
|
||||
pub(super) fn append_archive(target: &RecallTarget, msgs: &[ChatMessage]) -> Option<PathBuf> {
|
||||
if msgs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let path = target.file();
|
||||
let dir = path.parent()?;
|
||||
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||
tracing::warn!(error = %e, dir = %dir.display(), "[archive] 存档目录创建失败,本轮无召回句柄");
|
||||
return None;
|
||||
}
|
||||
let mut file = match OpenOptions::new().create(true).append(true).open(&path) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, path = %path.display(), "[archive] 存档文件打开失败,本轮无召回句柄");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
for m in msgs {
|
||||
let line = serde_json::json!({ "role": &m.role, "content": m.content });
|
||||
if let Err(e) = writeln!(file, "{line}") {
|
||||
tracing::warn!(error = %e, path = %path.display(), "[archive] 存档行写入失败,本轮无召回句柄");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(path)
|
||||
}
|
||||
|
||||
/// 召回句柄行:LLM 按此路径 read_file 回读被压原文。
|
||||
pub fn recall_line(path: &Path) -> String {
|
||||
format!("早期历史全文已存档: `{}`(read_file 可召回)", path.display())
|
||||
}
|
||||
|
||||
/// 摘要尾部拼召回句柄行(开关开且存档落盘成功时用;开关关调用方直接用原摘要)。
|
||||
pub fn with_recall_line(summary: &str, path: &Path) -> String {
|
||||
format!("{}\n{}", summary, recall_line(path))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::context::ContextManager;
|
||||
use crate::context_helpers::ContextConfig;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn mgr() -> ContextManager {
|
||||
ContextManager::new(ContextConfig {
|
||||
max_tokens: 100_000,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// 灌 2 条可压 + 1 条保护区消息,注入召回目标后压缩 [0..2),返回存档路径。
|
||||
fn compress_with_recall(tmp: &TempDir, conv_id: &str) -> PathBuf {
|
||||
let mut m = mgr();
|
||||
m.push(ChatMessage::user("早期问题"));
|
||||
m.push(ChatMessage::assistant("早期回复"));
|
||||
m.push(ChatMessage::user("近期问题"));
|
||||
m.set_compress_recall(tmp.path(), conv_id);
|
||||
let compressed = m.compress_old_messages(2);
|
||||
assert_eq!(compressed.len(), 2, "前置:应压缩 2 条");
|
||||
m.take_archive_path().expect("存档应落盘")
|
||||
}
|
||||
|
||||
/// 压缩时存档文件创建,且每行含被压原文的 role+content。
|
||||
#[test]
|
||||
fn compress_with_recall_archives_original_text() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = compress_with_recall(&tmp, "conv-a");
|
||||
assert_eq!(
|
||||
path,
|
||||
tmp.path().join("context-archive").join("conv-a-archive.jsonl")
|
||||
);
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
let mut lines = text.lines();
|
||||
let first: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
|
||||
assert_eq!(first["role"], "user");
|
||||
assert_eq!(first["content"], "早期问题");
|
||||
let second: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
|
||||
assert_eq!(second["role"], "assistant");
|
||||
assert_eq!(second["content"], "早期回复");
|
||||
assert!(lines.next().is_none(), "不应有多余行");
|
||||
}
|
||||
|
||||
/// 开关开:摘要尾部含召回句柄行(含存档路径与 read_file 指引);路径一次性消费。
|
||||
#[test]
|
||||
fn recall_enabled_summary_gets_handle_line() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = compress_with_recall(&tmp, "conv-b");
|
||||
let summary = "## 意图\n- 测试主题";
|
||||
let with_handle = with_recall_line(summary, &path);
|
||||
assert!(with_handle.starts_with(summary), "句柄拼在摘要尾部,原摘要在前");
|
||||
let handle = recall_line(&path);
|
||||
assert!(with_handle.ends_with(&handle));
|
||||
assert!(handle.contains(&path.display().to_string()));
|
||||
assert!(handle.contains("read_file"));
|
||||
}
|
||||
|
||||
/// 开关关(不注入召回目标):不落盘、无句柄路径可拼。
|
||||
#[test]
|
||||
fn recall_off_no_archive_no_handle() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut m = mgr();
|
||||
m.push(ChatMessage::user("问题"));
|
||||
m.push(ChatMessage::assistant("回复"));
|
||||
m.push(ChatMessage::user("近期"));
|
||||
let compressed = m.compress_old_messages(2);
|
||||
assert_eq!(compressed.len(), 2);
|
||||
assert!(m.take_archive_path().is_none(), "开关关不应有存档路径");
|
||||
assert!(
|
||||
!tmp.path().join("context-archive").exists(),
|
||||
"开关关不应创建存档目录"
|
||||
);
|
||||
}
|
||||
|
||||
/// 二次压缩追加不覆盖(JSONL 追加语义):两批原文都在,行数累计。
|
||||
#[test]
|
||||
fn second_compress_appends_not_overwrites() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut m = mgr();
|
||||
m.push(ChatMessage::user("第一批"));
|
||||
m.push(ChatMessage::assistant("第一批回复"));
|
||||
m.push(ChatMessage::user("第二批"));
|
||||
m.push(ChatMessage::assistant("第二批回复"));
|
||||
m.push(ChatMessage::user("近期"));
|
||||
m.set_compress_recall(tmp.path(), "conv-c");
|
||||
let _ = m.compress_old_messages(2);
|
||||
// 第二次压缩事件:调用方重新注入(开关可能已变),幂等跳过已压缩,只压新批
|
||||
m.set_compress_recall(tmp.path(), "conv-c");
|
||||
let second = m.compress_old_messages(4);
|
||||
assert_eq!(second.len(), 2, "第二次只压新激活的 2 条");
|
||||
let path = m.take_archive_path().unwrap();
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(text.lines().count(), 4, "两次压缩累计 4 行,不覆盖");
|
||||
assert!(text.contains("第一批回复"), "第一批原文仍在");
|
||||
assert!(text.contains("第二批回复"), "第二批原文已追加");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,909 @@
|
||||
//! 上下文管理器 — 管理对话上下文和 token 预算
|
||||
//!
|
||||
//! 职责:
|
||||
//! - 维护消息历史及其 token 计数缓存
|
||||
//! - 提供预算感知的消息裁剪(保护工具调用三元组)
|
||||
//! - 为 run_agentic_loop 提供受控的消息视图
|
||||
//!
|
||||
//! 裁剪策略与模型选择是正交维度:本模块只管「窗口多大、怎么裁」,
|
||||
//! 用哪个 model / 是否启用 reasoning 由调用方在 CompletionRequest 层决定。
|
||||
//!
|
||||
//! 纯函数 / 数据类型 / 常量(TokenEstimator / ContextConfig / MessageGroup /
|
||||
//! TrackedMessage / EvictionUnit / classify_group / PROTECT_COUNT /
|
||||
//! TOOL_MISSING_PREFIX)已抽至 [`crate::context_helpers`],本模块 `use` 复用,
|
||||
//! 并 `pub use` 重导出以保持 `df_ai::context::*` 历史路径对外可见(零调用方变更)。
|
||||
//!
|
||||
//! # 子模块
|
||||
//! - [`sanitize`]:畸形配对自愈(`sanitize_messages` / `drop_reverse_orphans` /
|
||||
//! `assert_placeholder_pairing` / `ensure_sequence_legal`)及其单测。
|
||||
//! 实现为模块级 `pub fn`,本模块通过 `ContextManager` 上的关联函数薄包装转发,
|
||||
//! 保持 `ContextManager::sanitize_messages(...)` 调用路径不变(零调用方变更)。
|
||||
|
||||
mod sanitize;
|
||||
pub mod archive;
|
||||
|
||||
use crate::context_helpers::{
|
||||
build_eviction_summary_text, classify_group, PLACEHOLDER_INTEGRITY_ENABLED, PROTECT_COUNT,
|
||||
TRIM_TARGET_RATIO,
|
||||
};
|
||||
// 重导出:保持 `df_ai::context::TokenEstimator` / `df_ai::context::ContextConfig` 等
|
||||
// 历史路径对外可见(agentic.rs / commands/ai/mod.rs 等调用方零变更)。
|
||||
// `pub use` 同时把类型带入本模块命名空间,供 ContextManager 结构体字段与 impl 直接引用。
|
||||
pub use crate::context_helpers::{
|
||||
EvictionUnit, ContextConfig, MessageGroup, TokenEstimator, TrackedMessage,
|
||||
};
|
||||
|
||||
use crate::provider::{ChatMessage, MessageRole, MessageStatus};
|
||||
|
||||
// ============================================================
|
||||
// 上下文管理器
|
||||
// ============================================================
|
||||
|
||||
/// 上下文管理器
|
||||
///
|
||||
/// 唯一的消息真相来源(替代原来的 `Vec<ChatMessage>`)。
|
||||
/// 裁剪仅影响发送视图(`build_for_request`),不影响持久化(`all_messages_clone`)。
|
||||
pub struct ContextManager {
|
||||
messages: Vec<TrackedMessage>,
|
||||
/// 当前历史总 token 数(不含 system prompt)
|
||||
history_tokens: u32,
|
||||
config: ContextConfig,
|
||||
estimator: TokenEstimator,
|
||||
/// 压缩重入标志(F-15 §4.3):true 表示一次 LLM 压缩正在进行中。
|
||||
/// agentic loop 顶部检测,防同一轮内多次触发压缩互相覆盖。纯内存态,不落库。
|
||||
is_compressing: bool,
|
||||
/// [P2 改进5] 主题切换检测标记。push user 消息时若发现末两条 user 消息的 topic
|
||||
/// 都非 None 且不同(双高置信),置位本字段,格式 "old|new"。agentic loop 顶部读并
|
||||
/// 消费(insert 系统标记后清空)。纯内存态,不落库。保守:任一 topic 为 None 不置位(宁可漏报)。
|
||||
pending_topic_marker: Option<String>,
|
||||
/// msg-split-phase1:已落库(ai_messages 表)的消息条数。
|
||||
///
|
||||
/// 增量写路径用:save_conversation 据此判定 `[persisted_msg_count..len)` 是"自上次 save
|
||||
/// 后新增的消息"(纯 append),走 `insert_batch`(INSERT OR IGNORE)只插新行,跳过昂贵的
|
||||
/// `replace_conversation`(DELETE 整对话 + 全量重插)。
|
||||
///
|
||||
/// 仅 append-only 路径才有效——一旦 [`needs_full_rewrite`] 被置位(DB 与内存可能脱钩:
|
||||
/// compress 改 status / replace_tool_result_content 改 content / pop_last_assistant_round
|
||||
/// 缩短 / insert_at 中段插入改 seq / clear 清空),save 必须走全量重写收敛,本字段随之重置。
|
||||
///
|
||||
/// restore_from_messages(DB 加载)后设为 len(DB 已是该真相源,append 基线对齐到当前长度)。
|
||||
/// 纯内存态,不落库(下次进程重启从 DB reload 时由 restore_from_messages 重置)。
|
||||
persisted_msg_count: usize,
|
||||
/// msg-split-phase1:全量重写需求标志。
|
||||
///
|
||||
/// `true` = 自上次 save 后发生过修改既有消息 / 缩短 / 中段插入 / 清空,内存与 DB 可能脱钩,
|
||||
/// 下次 save 必须走 `replace_conversation` 全量重写收敛(DELETE + INSERT 全部行)。
|
||||
/// save 全量重写成功后清零;append-only save(insert_batch)成功后保持 false。
|
||||
///
|
||||
/// 任何修改既有消息(非纯 push append)的入口置位:clear / pop_last_assistant_round /
|
||||
/// truncate_after_user_message / replace_tool_result_content / replace_last_active_user_content /
|
||||
/// insert_at / compress_old_messages / messages_mut(可变借用兜底,调用方可能改 status/content)。
|
||||
/// 误置位零代价(只是下次 save 多走一次全量重写,正确性不变);漏置位才会丢更新——故保守置位。
|
||||
needs_full_rewrite: bool,
|
||||
/// 压缩可召回:本次压缩事件的存档目标(调用方读到 KV 开后注入,compress_old_messages
|
||||
/// 一次性消费)。None = 本事件不存档(开关关/未注入),零行为变更。
|
||||
compress_recall: Option<archive::RecallTarget>,
|
||||
/// 最近一次压缩成功落盘的存档路径(take 语义,供调用方在压缩摘要尾部拼召回句柄;
|
||||
/// 每次 compress_old_messages 按事件重置,防跨事件残留误报)。
|
||||
archive_path: Option<std::path::PathBuf>,
|
||||
/// 缓存友好上下文总开关(跨 crate 注入:settings 在 src-tauri 侧,df-ai 不可直读,
|
||||
/// 调用方读 KV `df-ai-cache-friendly-context` 后经 [`Self::set_cache_friendly`] 注入)。
|
||||
/// 默认 false = 旧行为(贪心最小裁剪 + system 锚点原样发送),未注入路径零行为变更。
|
||||
cache_friendly: bool,
|
||||
}
|
||||
|
||||
impl ContextManager {
|
||||
pub fn new(config: ContextConfig) -> Self {
|
||||
Self {
|
||||
messages: Vec::new(),
|
||||
history_tokens: 0,
|
||||
config,
|
||||
estimator: TokenEstimator::default(),
|
||||
is_compressing: false,
|
||||
pending_topic_marker: None,
|
||||
persisted_msg_count: 0,
|
||||
needs_full_rewrite: false,
|
||||
compress_recall: None,
|
||||
archive_path: None,
|
||||
cache_friendly: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 追加消息(自动计算 token 并更新缓存)
|
||||
///
|
||||
/// 不在此处淘汰——push 可能发生在 agentic loop 中间(追加 tool_result),
|
||||
/// 此时不应裁剪正在使用的活跃消息。裁剪在 `build_for_request` 时统一处理。
|
||||
pub fn push(&mut self, message: ChatMessage) {
|
||||
let tokens = self.estimator.estimate_message(&message);
|
||||
let group = classify_group(&message);
|
||||
// 仅 active 消息计入 token 预算(F-15 §3.3):truncated / archived_segment /
|
||||
// compressed 不进 LLM 上下文,token 虚高会致 build_for_request 误判超预算
|
||||
// 触发不必要裁剪。!active 消息仍 push 到 self.messages 全量保留(持久化不受影响),
|
||||
// sanitize_messages step0(is_active 过滤)在发送视图统一剔除。
|
||||
if message.is_active() {
|
||||
self.history_tokens += tokens;
|
||||
}
|
||||
// [P2 改进5] 主题推断(仅 user 消息):IntentRecognizer 识别意图,置信 >= 0.7 且
|
||||
// 非 Unknown 则把 Intent 标签存入 TrackedMessage.topic 供主题切换检测。
|
||||
// 保守:低置信(None)不标,避免误报。topic 不参与裁剪/压缩(只供 marker 检测)。
|
||||
let topic: Option<String> = if matches!(message.role, MessageRole::User) {
|
||||
let (intent, conf) = crate::intent::IntentRecognizer::recognize(&message.content);
|
||||
if conf >= 0.7 && !matches!(intent, crate::intent::Intent::Unknown) {
|
||||
Some(intent.as_str().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// [P2 改进5] 主题切换检测:push 前(本消息即将成末条 user),若已存在一条更早 user
|
||||
// 且本消息 topic 与之都非 None 且不同 → 置位 pending_topic_marker("old|new")。
|
||||
// 双高置信(两条 topic 都非 None)才标,任一 None 不标(宁可漏报不误报)。
|
||||
if matches!(message.role, MessageRole::User) {
|
||||
if let Some(prev_topic) = self.last_user_topic() {
|
||||
if let Some(this_topic) = &topic {
|
||||
if prev_topic != *this_topic {
|
||||
self.pending_topic_marker =
|
||||
Some(format!("{}|{}", prev_topic, this_topic));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.messages.push(TrackedMessage {
|
||||
message,
|
||||
token_count: tokens,
|
||||
group,
|
||||
topic,
|
||||
});
|
||||
}
|
||||
|
||||
/// 清空所有消息
|
||||
pub fn clear(&mut self) {
|
||||
self.messages.clear();
|
||||
self.history_tokens = 0;
|
||||
self.is_compressing = false;
|
||||
self.pending_topic_marker = None;
|
||||
// msg-split-phase1:全清后 DB 必须也清(调用方 chat.rs:1149 走 delete_range),
|
||||
// 全量重写基线重置到 0。needs_full_rewrite=true 保险(若 save 先于 delete_range 触发,
|
||||
// 全量重写空列表也会清 DB)。
|
||||
self.persisted_msg_count = 0;
|
||||
self.needs_full_rewrite = true;
|
||||
self.compress_recall = None;
|
||||
self.archive_path = None;
|
||||
}
|
||||
|
||||
/// 消息数量
|
||||
pub fn len(&self) -> usize {
|
||||
self.messages.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.messages.is_empty()
|
||||
}
|
||||
|
||||
/// 当前历史占用的 token 数(不含 system prompt)
|
||||
pub fn history_tokens(&self) -> u32 {
|
||||
self.history_tokens
|
||||
}
|
||||
|
||||
/// 预算上限
|
||||
pub fn budget_limit(&self) -> u32 {
|
||||
self.config.budget_limit()
|
||||
}
|
||||
|
||||
// ── 核心:构建请求消息(受控裁剪版本)──
|
||||
|
||||
/// 构建发送给 LLM 的消息列表
|
||||
///
|
||||
/// `sys_tokens` 为调用方已估算好的 system prompt token 数。
|
||||
/// 超预算时自动裁剪旧消息(保护工具调用三元组 + 最近 PROTECT_COUNT 条),并在
|
||||
/// 被淘汰区头部插入关键词补偿摘要(role=system,纯视图易变区,不落库不改存储)。
|
||||
/// 返回 (消息列表, 是否发生了裁剪)。
|
||||
pub fn build_for_request(&self, sys_tokens: u32) -> (Vec<ChatMessage>, bool) {
|
||||
let budget = self.budget_limit();
|
||||
let available = budget.saturating_sub(sys_tokens);
|
||||
|
||||
// system prompt 自身超预算:裁剪无法缓解(仍返回保护区兜底),warn 便于诊断
|
||||
if sys_tokens > budget {
|
||||
tracing::warn!(
|
||||
"system prompt (~{} tokens) 超过上下文预算 ({}),裁剪无法缓解",
|
||||
sys_tokens, budget
|
||||
);
|
||||
}
|
||||
|
||||
// 未超预算 → 直接返回全量(仍做畸形配对自愈,防历史中毒触发 provider 500 死循环)
|
||||
if self.history_tokens <= available {
|
||||
let view = self.anchors_to_user(Self::sanitize_messages(self.all_messages_clone()));
|
||||
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
||||
return (
|
||||
Self::assert_placeholder_pairing(view, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
// 超预算 → 视图裁剪(不修改 self.messages,保证 all_messages_clone 仍返回全量)
|
||||
let protect_start = self.messages.len().saturating_sub(PROTECT_COUNT);
|
||||
let units = self.build_eviction_units(protect_start);
|
||||
|
||||
// 缓存友好开:块状裁剪(目标水位+轮边界吸附);关:贪心最小裁剪(逐字节等价旧路径)
|
||||
let (trim_end, removed) = if self.cache_friendly {
|
||||
self.block_trim_target(&units, available)
|
||||
} else {
|
||||
self.greedy_trim_target(&units, available)
|
||||
};
|
||||
|
||||
if trim_end == 0 {
|
||||
tracing::warn!(
|
||||
"history (~{} tokens) 超预算 ({}) 但无可淘汰单元(全在保护区 {} 条),发送兜底可能触发 provider 超限",
|
||||
self.history_tokens, available, PROTECT_COUNT
|
||||
);
|
||||
// 兜底全量也过 sanitize(对齐分支 1/3),防绕过序列修复直送 provider
|
||||
// 触发"首条 assistant 非法"/orphan/连续 role。原裸返 all_messages_clone 不过滤
|
||||
// truncated/中毒三元组/首条非法——是主 loop 唯一的 sanitize 漏洞(大体量 tool_result
|
||||
// 致超预算且保护区满时命中)。异常会话(开头连续 assistant/tool 无 user)经
|
||||
// ensure_sequence_legal 清空后,由协议层 ensure_leading_user 补 user 占位降级,不阻塞。
|
||||
// view-only:不改 self.messages 持久化(与分支 1/3 一致)。
|
||||
let view = self.anchors_to_user(Self::sanitize_messages(self.all_messages_clone()));
|
||||
return (
|
||||
Self::assert_placeholder_pairing(view, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
let evicted: Vec<ChatMessage> = self.messages[..trim_end]
|
||||
.iter()
|
||||
.map(|t| t.message.clone())
|
||||
.collect();
|
||||
let mut msgs: Vec<ChatMessage> = self.messages[trim_end..]
|
||||
.iter()
|
||||
.map(|t| t.message.clone())
|
||||
.collect();
|
||||
|
||||
// 淘汰补偿摘要:视图裁剪无声丢弃被淘汰内容,LLM 从此看不到早期主题,与压缩
|
||||
// 路径(有摘要补偿)行为不一致。此处把被淘汰文本提炼成一短段摘要插回发送视图。
|
||||
// **缓存友好修正(2026-10)**:原 insert(0) 头部插入会位移整条前缀字节,使
|
||||
// DeepSeek 等自动前缀缓存 provider 的稳定前缀(顶层 system + 早期 history)
|
||||
// 全部缓存失效——命中率掉到个位数。改尾部追加(push):摘要本身每轮即时再生成
|
||||
// (淘汰区单调增长致内容逐轮漂移)本就无法命中缓存,但放尾部不再拖累稳定前缀,
|
||||
// 命中率最大化。角色必须 user:role=system 会被 anthropic_compat 折叠进带
|
||||
// cache_control 的顶层 system 段,落 system 即逐轮改写缓存指纹;user 放尾部
|
||||
// 由 sanitize/协议层自愈兜底。摘要只进 messages 易变区,严禁拼进 system_prompt/
|
||||
// 任何稳定段;纯视图语义:不落库、不改 conv.messages 存储、不标记 compressed,
|
||||
// 被淘汰原文存储态不变。极短/空淘汰文本提炼为空 → 跳过(不值得插)。
|
||||
if let Some(text) = build_eviction_summary_text(&evicted) {
|
||||
msgs.push(ChatMessage::user(text));
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)",
|
||||
trim_end, removed
|
||||
);
|
||||
let view = self.anchors_to_user(Self::sanitize_messages(msgs));
|
||||
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
||||
(
|
||||
Self::assert_placeholder_pairing(view, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
/// 贪心最小裁剪:累计淘汰单元直到塞下硬预算即停(历史行为,开关关路径)。
|
||||
///
|
||||
/// 缺陷:对话每增长一条消息就重新越过预算一点,裁剪边界随之前移一个单元,
|
||||
/// 发送视图首条每轮都变,前缀缓存从第 0 字节起永远 miss。
|
||||
fn greedy_trim_target(&self, units: &[EvictionUnit], available: u32) -> (usize, u64) {
|
||||
let mut removed: u64 = 0;
|
||||
let mut trim_end = 0;
|
||||
for unit in units {
|
||||
if self.history_tokens.saturating_sub(removed as u32) <= available {
|
||||
break;
|
||||
}
|
||||
removed += unit.token_sum as u64;
|
||||
trim_end = unit.end;
|
||||
}
|
||||
(trim_end, removed)
|
||||
}
|
||||
|
||||
/// 块状裁剪:一次裁到目标水位 `TRIM_TARGET_RATIO × available`,且边界吸附轮边界。
|
||||
///
|
||||
/// 为什么块状:两次裁剪之间裁剪边界不动 → 被淘汰集不变 → 视图前缀逐字节稳定,
|
||||
/// 前缀缓存每轮命中;裁剪频度由水位差(块大小)决定而非逐条爬行。
|
||||
/// 轮边界:单元 end 处下一条未淘汰消息 role=User 即轮起点,整问整答(含工具链)
|
||||
/// 同生同死,淘汰摘要按轮覆盖语义完整,且轮起点是稳定地标。
|
||||
/// 病态回退(单轮体量 > 块,扫到保护区仍无两条件齐备的边界):取满足硬预算
|
||||
/// available 的最后单元边界(安全底线);连硬预算都不可得(保护区自身超限)则
|
||||
/// 尽力裁到末单元,对齐贪心路径。
|
||||
fn block_trim_target(&self, units: &[EvictionUnit], available: u32) -> (usize, u64) {
|
||||
let target = (available as f32 * TRIM_TARGET_RATIO) as u32;
|
||||
let mut removed: u64 = 0;
|
||||
let mut hard = (0usize, 0u64);
|
||||
let mut last = (0usize, 0u64);
|
||||
for unit in units {
|
||||
removed += unit.token_sum as u64;
|
||||
last = (unit.end, removed);
|
||||
let remaining = self.history_tokens.saturating_sub(removed as u32);
|
||||
if remaining <= available {
|
||||
hard = (unit.end, removed);
|
||||
}
|
||||
// 目标水位隐含硬预算已满足(target < available),轮吸附与水位同时判定
|
||||
if remaining <= target
|
||||
&& self
|
||||
.messages
|
||||
.get(unit.end)
|
||||
.is_some_and(|next| matches!(next.message.role, MessageRole::User))
|
||||
{
|
||||
return (unit.end, removed);
|
||||
}
|
||||
}
|
||||
if hard.0 > 0 {
|
||||
hard
|
||||
} else {
|
||||
last
|
||||
}
|
||||
}
|
||||
|
||||
/// 发送视图出口整形(sanitize 之后):总开关开时把存储态 role=System 消息
|
||||
/// (压缩摘要/话题标记/警示等锚点)映射为 role=User 发送。
|
||||
///
|
||||
/// 为什么:anthropic_compat 把存储态全部 role=System 折叠进顶层 system(带
|
||||
/// cache_control),每次锚点事件都改写顶层缓存指纹,system 断点与消息断点同时失配。
|
||||
/// 转 user 后顶层 system 回到纯 system_prompt 稳定段;连续 user 由
|
||||
/// anthropic_compat::merge_consecutive_users 合并(协议要求严格交替),user 不触发
|
||||
/// 三元组/首条规则。纯视图:存储态仍 System,压缩定位(has_compressible_messages
|
||||
/// 的 System 排除)与前端展示语义不变。调用方组装层加入的 system_prompt 不经本
|
||||
/// 函数(不在 storage),最终视图中它独占 role=System 首条。
|
||||
fn anchors_to_user(&self, view: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
if !self.cache_friendly {
|
||||
return view;
|
||||
}
|
||||
view.into_iter()
|
||||
.map(|mut m| {
|
||||
if matches!(m.role, MessageRole::System) {
|
||||
m.role = MessageRole::User;
|
||||
}
|
||||
m
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── 畸形配对自愈(转发至 [`sanitize`] 子模块,保持 ContextManager::xxx 调用路径)──
|
||||
|
||||
/// 畸形配对自愈 — 转发到 [`sanitize::sanitize_messages`]。
|
||||
///
|
||||
/// 保留为 `ContextManager` 关联函数以兼容历史调用路径(`Self::sanitize_messages` /
|
||||
/// `ContextManager::sanitize_messages`),实现见子模块文档。
|
||||
pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
sanitize::sanitize_messages(messages)
|
||||
}
|
||||
|
||||
/// 发送视图出口断言 — 转发到 [`sanitize::assert_placeholder_pairing`]。
|
||||
pub fn assert_placeholder_pairing(
|
||||
messages: Vec<ChatMessage>,
|
||||
enabled: bool,
|
||||
) -> Vec<ChatMessage> {
|
||||
sanitize::assert_placeholder_pairing(messages, enabled)
|
||||
}
|
||||
|
||||
/// 全量克隆(持久化 save_conversation / build_for_request 未裁剪分支,不受裁剪影响)
|
||||
pub fn all_messages_clone(&self) -> Vec<ChatMessage> {
|
||||
self.messages.iter().map(|t| t.message.clone()).collect()
|
||||
}
|
||||
|
||||
/// 只读最近 N 条消息(尾部切片 clone)。BE2 断路器/探索熔断检查等只关心
|
||||
/// 最近消息的读路径,用本方法避免 all_messages_clone 每轮全量 clone(长对话每轮 O(n) 深克隆浪费)。
|
||||
/// 尾部顺序保持时间正序(与 all_messages_clone 一致,调用方从尾部反向扫即可)。
|
||||
pub fn recent_messages(&self, n: usize) -> Vec<ChatMessage> {
|
||||
let skip = self.messages.len().saturating_sub(n);
|
||||
self.messages[skip..]
|
||||
.iter()
|
||||
.map(|t| t.message.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 从 Vec 恢复(兼容从 DB 加载)
|
||||
pub fn restore_from_messages(&mut self, messages: Vec<ChatMessage>) {
|
||||
// clear() 会置 needs_full_rewrite=true + persisted_msg_count=0;但本入口是"DB 刚加载",
|
||||
// 加载后的 messages 与 DB 完全一致(它们就是从 DB 来的),append 基线应对齐到当前 len,
|
||||
// 且不需要全量重写(DB 已是真相源)。故 clear 后立即覆盖这两个字段。
|
||||
let len = messages.len();
|
||||
self.clear();
|
||||
for msg in messages {
|
||||
self.push(msg);
|
||||
}
|
||||
self.persisted_msg_count = len;
|
||||
self.needs_full_rewrite = false;
|
||||
// 重放历史时 push 会照常跑主题切换检测,历史里"当时已发生"的切换会残留进 marker;
|
||||
// 留着它,加载后首轮 take_topic_marker 就拿到假话题锚点、误插一次切换标记。
|
||||
// 恢复即重置:切换检测只对 restore 之后的实时 push 生效。
|
||||
self.pending_topic_marker = None;
|
||||
}
|
||||
|
||||
/// 就地替换某条 tool_result 的内容(兼容审批 replace_tool_result)
|
||||
/// 返回 true 如果找到并替换了
|
||||
///
|
||||
/// 反向遍历:tool_result 由 append 进入历史,被替换的通常是最近的审批占位,
|
||||
/// 从尾部查找命中即停,避免对长历史做正向 O(n) 累积扫描。
|
||||
pub fn replace_tool_result_content(&mut self, tool_call_id: &str, new_content: &str) -> bool {
|
||||
let pos = self.messages.iter().rposition(|t| {
|
||||
matches!(t.message.role, MessageRole::Tool)
|
||||
&& t.message.tool_call_id.as_deref() == Some(tool_call_id)
|
||||
});
|
||||
|
||||
let Some(i) = pos else { return false };
|
||||
|
||||
// 先更新 content,再重估 token 并校正总量
|
||||
let old_tokens = self.messages[i].token_count;
|
||||
self.messages[i].message.content = new_content.to_string();
|
||||
let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
|
||||
self.messages[i].token_count = new_tokens;
|
||||
self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
|
||||
// msg-split-phase1:改既有消息 content,DB 需全量重写收敛(append-only 路径不覆盖旧行)。
|
||||
self.needs_full_rewrite = true;
|
||||
true
|
||||
}
|
||||
|
||||
/// 弹出末尾连续的 assistant 消息(含其 tool_calls 三元组尾随 tool_result)
|
||||
///
|
||||
/// 用于「重新生成」删掉最后一条 AI 回复(可能跨多轮 tool_calls + tool_results
|
||||
/// 紧随其后),保留触发它的 user 消息,以便重跑 agentic loop 再生成。
|
||||
///
|
||||
/// 语义:从末尾向前弹出,直到弹出至少一条 assistant 消息;若弹出 assistant 后紧邻的更早
|
||||
/// 消息仍是 assistant/tool(同一轮多块),继续一并弹出,确保不留半截三元组污染下轮。
|
||||
/// user 消息作为停止边界(不弹出 user),保证重生成时历史末尾是 user 消息。
|
||||
pub fn pop_last_assistant_round(&mut self) -> bool {
|
||||
if self.messages.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut popped_any = false;
|
||||
// 从尾向前:先弹掉末尾非 user 的消息(assistant / tool),直到遇到 user 或空
|
||||
while let Some(last) = self.messages.last() {
|
||||
if matches!(last.message.role, MessageRole::User) {
|
||||
break;
|
||||
}
|
||||
let removed = self.messages.pop().expect("just checked non-empty");
|
||||
self.history_tokens = self.history_tokens.saturating_sub(removed.token_count);
|
||||
if matches!(removed.message.role, MessageRole::Assistant) {
|
||||
popped_any = true;
|
||||
}
|
||||
}
|
||||
// msg-split-phase1:从末尾弹出 → len 缩短,append-only 基线(persisted_msg_count)
|
||||
// 会大于新 len。下次 save 必须全量重写(删 DB 中已不存在的尾行)。
|
||||
if popped_any {
|
||||
self.needs_full_rewrite = true;
|
||||
}
|
||||
popped_any
|
||||
}
|
||||
|
||||
/// 编辑某条 user 消息后,将其后所有消息标记为 truncated(UX-09 编辑重生成)。
|
||||
///
|
||||
/// 软删语义:保留在内存真相源 + DB(可追溯),但 sanitize_messages 过滤后不进 LLM 上下文,
|
||||
/// 前端按 is_active 过滤从视图移除。返回被标 truncated 的条数(0 表示该 user 消息已是末尾,无后续)。
|
||||
///
|
||||
/// `target_content` 为该 user 消息的预期内容(用于反向唯一定位:末条 user 消息可能内容相同,
|
||||
/// 故从尾部向前找第一条 role=User 且 content 匹配且仍 active 的消息)。
|
||||
/// 找不到返回 Err(()),调用方据此报错。
|
||||
pub fn truncate_after_user_message(&mut self, target_content: &str) -> Result<usize, ()> {
|
||||
// 反向找末条 active user 消息且 content 匹配
|
||||
let pos = self.messages.iter().rposition(|t| {
|
||||
matches!(t.message.role, MessageRole::User)
|
||||
&& t.message.content == target_content
|
||||
&& t.message.is_active()
|
||||
});
|
||||
let Some(i) = pos else { return Err(()) };
|
||||
// i 之后的全部标 truncated(已 truncated 的跳过,只统计本次新标的)
|
||||
let mut count = 0usize;
|
||||
for t in self.messages[i + 1..].iter_mut() {
|
||||
if t.message.is_active() {
|
||||
t.message.status = Some(MessageStatus::Truncated);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
// msg-split-phase1:改既有消息 status(truncated),DB 需全量重写收敛。
|
||||
if count > 0 {
|
||||
self.needs_full_rewrite = true;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// 替换末条 active user 消息的 content(UX-09 编辑重生成)。
|
||||
///
|
||||
/// 编辑语义:只能编辑最后一条 user 消息(中间编辑语义复杂,拒绝)。返回 Err(()) 表示无 active user 消息。
|
||||
/// 成功后调用方应紧接着 truncate_after_user_message(new_content) 软删其后续消息。
|
||||
pub fn replace_last_active_user_content(&mut self, new_content: &str) -> Result<(), ()> {
|
||||
let pos = self.messages.iter().rposition(|t| {
|
||||
matches!(t.message.role, MessageRole::User) && t.message.is_active()
|
||||
});
|
||||
let Some(i) = pos else { return Err(()) };
|
||||
let old_tokens = self.messages[i].token_count;
|
||||
self.messages[i].message.content = new_content.to_string();
|
||||
let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
|
||||
self.messages[i].token_count = new_tokens;
|
||||
self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
|
||||
// msg-split-phase1:改既有 user 消息 content,DB 需全量重写收敛。
|
||||
self.needs_full_rewrite = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 只读迭代(兼容 ensure_conversation_title 的 .iter().filter() 等)
|
||||
pub fn iter(&self) -> impl Iterator<Item = &ChatMessage> {
|
||||
self.messages.iter().map(|t| &t.message)
|
||||
}
|
||||
|
||||
/// 消息级溯源:取末条指定 role 消息的 id(ULID)。
|
||||
///
|
||||
/// 从尾部反向扫描(末条消息命中即停,避免全量 O(n) 正扫累积),返回最近一条
|
||||
/// `role` 匹配且 `id` 非空消息的 id。无匹配或老消息无 id → None(向前兼容:
|
||||
/// 老反序列化消息 id=None,溯源写入降级为 None,展示侧兼容 `conv:` 旧格式)。
|
||||
///
|
||||
/// 用途:
|
||||
/// - `MessageRole::Assistant`:audit/知识提炼写入时取当前 assistant 消息 id
|
||||
/// (LLM 返回带 tool_calls 的 assistant 已 push,process_tool_calls 入口取)
|
||||
/// - `MessageRole::User`:知识注入 referenced 事件溯源取触发检索的 user 消息 id
|
||||
fn last_message_id_by_role(&self, role: MessageRole) -> Option<String> {
|
||||
self.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|t| {
|
||||
std::mem::discriminant(&t.message.role) == std::mem::discriminant(&role)
|
||||
})
|
||||
.and_then(|t| t.message.id.clone())
|
||||
}
|
||||
|
||||
/// 末条 assistant 消息的 id(消息级溯源用)。
|
||||
pub fn last_assistant_message_id(&self) -> Option<String> {
|
||||
self.last_message_id_by_role(MessageRole::Assistant)
|
||||
}
|
||||
|
||||
/// 末条 user 消息的 id(消息级溯源用)。
|
||||
pub fn last_user_message_id(&self) -> Option<String> {
|
||||
self.last_message_id_by_role(MessageRole::User)
|
||||
}
|
||||
|
||||
// ── [P2 改进5] 主题切换检测(保守,双高置信才标) ──
|
||||
|
||||
/// 取末条 user 消息的 topic 标签(供 push 时主题切换检测)。
|
||||
///
|
||||
/// 从尾部反向扫描 user 消息,取最近一条 role=User 的 TrackedMessage.topic。
|
||||
/// 老消息(未接改进5 推断)topic=None → 返 None(向前兼容)。无 user 消息 → None。
|
||||
fn last_user_topic(&self) -> Option<String> {
|
||||
self.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|t| matches!(t.message.role, MessageRole::User))
|
||||
.and_then(|t| t.topic.clone())
|
||||
}
|
||||
|
||||
/// 取并消费 pending_topic_marker(供 agentic loop 顶部读 → insert 系统标记 → 清空)。
|
||||
///
|
||||
/// 返回 "old|new" 格式字符串(push 时末两条 user topic 都非 None 且不同置位)。
|
||||
/// 取出即清空(一次性消费,防同 marker 重复 insert)。无 marker → None。
|
||||
pub fn take_topic_marker(&mut self) -> Option<String> {
|
||||
self.pending_topic_marker.take()
|
||||
}
|
||||
|
||||
// ── F-15 上下文管理增强辅助方法(压缩链路核心 API)──
|
||||
//
|
||||
// 已全量接入压缩链路:
|
||||
// - agentic/mod.rs 自动压缩(trigger 判定 + LLM/关键词兜底 + 重入保护)
|
||||
// set_compressing/is_compressing(重入标志) / has_compressible_messages(触发判定)
|
||||
// messages_mut(取 active 喂 LLM) / compress_old_messages(标 compressed 扣 token)
|
||||
// insert_at(摘要/续接锚点 system 消息插入首位)
|
||||
// - commands/ai/compress.rs 与 commands/ai/commands/chat.rs 走 IPC 压缩入口
|
||||
// - build_eviction_units(下方)供会话分段与压缩定位共用同一分组逻辑。
|
||||
|
||||
/// 配置(只读视图,供 agentic.rs 计算压缩触发阈值 `config().budget_limit()`)
|
||||
pub fn config(&self) -> &ContextConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// 可变消息切片(供标记 status="compressed"/"archived_segment" + 调整 token)
|
||||
///
|
||||
/// 调用方约定:仅改 `message.status` / `message.content`,不增删条目(增删走
|
||||
/// [`push`] / [`insert_at`]),否则 `history_tokens` 会与实际脱钩。
|
||||
///
|
||||
/// msg-split-phase1:**若调用方借此句柄改了既有消息(status/content),必须紧接着调
|
||||
/// [`mark_needs_full_rewrite`]**,否则下次 save 走 append-only 增量路径会漏更新旧行,
|
||||
/// DB 与内存脱钩。仅读(过滤后 clone)不需调。本方法无法自行置位(借用冲突:返回 &mut
|
||||
/// 切片时不能再持有 &mut self 标志)。
|
||||
pub fn messages_mut(&mut self) -> &mut [TrackedMessage] {
|
||||
&mut self.messages
|
||||
}
|
||||
|
||||
/// msg-split-phase1:显式标记"下次 save 须全量重写"。
|
||||
///
|
||||
/// 供 [`messages_mut`] 的调用方在改完既有消息后调用(本 struct 无法在返回 &mut 切片时
|
||||
/// 自行置位)。也可供任何绕过本 impl 直接改 messages 的路径兜底。幂等(重复置 true 无害)。
|
||||
pub fn mark_needs_full_rewrite(&mut self) {
|
||||
self.needs_full_rewrite = true;
|
||||
}
|
||||
|
||||
/// msg-split-phase1:读取已落库消息条数(append-only 增量写路径的基线)。供 save_conversation
|
||||
/// 判定 `[persisted_msg_count..len)` 是否为"自上次 save 后新增"。
|
||||
pub fn persisted_msg_count(&self) -> usize {
|
||||
self.persisted_msg_count
|
||||
}
|
||||
|
||||
/// msg-split-phase1:推进已落库消息条数(insert_batch/replace_conversation 成功后调用)。
|
||||
/// `full_rewrite_applied`=本次写库是否走了全量重写(快照时刻 needs_full_rewrite 快照)。
|
||||
/// 仅全量重写覆盖了既有行才清 needs_full_rewrite;增量/无变化路径写库期间新置的 dirty
|
||||
/// (如压缩链 mark)与本次 append 写库无关,误清会致改动滞留内存永不落库。供 save_conversation 写库成功后调用。
|
||||
pub fn advance_persisted_count(&mut self, new_count: usize, full_rewrite_applied: bool) {
|
||||
// 防回退:若调用方误传更小值(理论上不应发生),取 max 保不丢基线(下次 save 仍能收敛)。
|
||||
self.persisted_msg_count = self.persisted_msg_count.max(new_count);
|
||||
if full_rewrite_applied {
|
||||
self.needs_full_rewrite = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// msg-split-phase1:是否需要全量重写(改过既有消息 / 缩短 / 中段插入 / 清空)。
|
||||
pub fn needs_full_rewrite(&self) -> bool {
|
||||
self.needs_full_rewrite
|
||||
}
|
||||
|
||||
/// 在给定位置插入一条消息(其余向后移),并把它计入 token 预算(active 才计)。
|
||||
///
|
||||
/// 供压缩点插入摘要 system 消息。`index` 越界则 panic(对齐 Vec::insert 语义,
|
||||
/// 调用方负责算合法 index,如 `compress_end` 已由 `compress_old_messages` 校验)。
|
||||
pub fn insert_at(&mut self, index: usize, message: ChatMessage) {
|
||||
let tokens = self.estimator.estimate_message(&message);
|
||||
let group = classify_group(&message);
|
||||
if message.is_active() {
|
||||
self.history_tokens += tokens;
|
||||
}
|
||||
self.messages.insert(index, TrackedMessage {
|
||||
message,
|
||||
token_count: tokens,
|
||||
group,
|
||||
topic: None,
|
||||
});
|
||||
// msg-split-phase1:中段插入会改变 index 及之后所有消息的 seq(append-only 基线
|
||||
// persisted_msg_count 按"末尾追加"语义计算 seq,中段插入后 seq 错位),DB 必须全量重写。
|
||||
self.needs_full_rewrite = true;
|
||||
}
|
||||
|
||||
/// 追加一条 system 锚点消息(警示文本/压缩摘要),自动避开**悬空 tool_call 头**。
|
||||
///
|
||||
/// **源头守静(909)**:OpenAI/内部协议不变式是「assistant(tool_calls) 之后必须紧随
|
||||
/// 其 tool 响应」。主 loop 的顺序是 push 头(`finalize_round_output`)→ 释放锁 →
|
||||
/// 执行工具 → 再取锁 push 结果;若在「头已入、结果未回」的窗口内从尾部追加 system
|
||||
/// (后台压缩摘要/警示/nudge/主题标记),system 就楔进 head 与其响应之间 → 上游报
|
||||
/// 400「insufficient tool messages following tool_calls message」。
|
||||
///
|
||||
/// 本出口统一处置:末条非悬空头 → 常规尾部追加(不位移前缀,保自动前缀缓存,行为与
|
||||
/// 原 `push` 一致);末条是悬空头 → 改插在该头**之前**(index = len-1),不劈三元组,
|
||||
/// 摘要/警示仍紧邻本轮内容对 LLM 可见。返回 `true` 表示走了避让插入(会置
|
||||
/// `needs_full_rewrite`,需 DB 全量重写),供调用方记日志。
|
||||
pub fn push_system_anchor(&mut self, message: ChatMessage) -> bool {
|
||||
if self.tail_is_dangling_head() {
|
||||
let idx = self.messages.len() - 1;
|
||||
self.insert_at(idx, message);
|
||||
tracing::debug!(
|
||||
idx,
|
||||
"system 锚点避让悬空 tool_call 头插入(防楔入三元组致 400)"
|
||||
);
|
||||
true
|
||||
} else {
|
||||
self.push(message);
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// 末条是否为「悬空 tool_call 头」(assistant 带 tool_calls 且其 tool 响应尚未回填)。
|
||||
/// 供 [`Self::push_system_anchor`] 判定避让位置:末条即头,说明头已入而结果未回。
|
||||
fn tail_is_dangling_head(&self) -> bool {
|
||||
self.messages
|
||||
.last()
|
||||
.is_some_and(|m| m.group == MessageGroup::ToolCallHead)
|
||||
}
|
||||
|
||||
/// 按淘汰单元分组消息范围(三元组原子性),供压缩定位/分段标记复用同一分组逻辑。
|
||||
///
|
||||
/// 返回每个单元的右开区间 end + token 总和,保证:
|
||||
/// - 工具调用三元组(Head + Tail* + 紧随的 Standalone Assistant)在同一单元
|
||||
/// - 保护区 `[protect_start, len)` 内的消息不纳入任何单元
|
||||
///
|
||||
/// 公开会话分段(`archived_segment` 按组原子标记)与压缩定位共用。
|
||||
pub fn build_eviction_units(&self, protect_start: usize) -> Vec<EvictionUnit> {
|
||||
let mut units = Vec::new();
|
||||
let mut i = 0usize;
|
||||
|
||||
while i < protect_start {
|
||||
let mut token_sum = 0u32;
|
||||
|
||||
if self.messages[i].group == MessageGroup::ToolCallHead {
|
||||
token_sum += self.messages[i].token_count;
|
||||
i += 1;
|
||||
while i < protect_start && self.messages[i].group == MessageGroup::ToolResultTail {
|
||||
token_sum += self.messages[i].token_count;
|
||||
i += 1;
|
||||
}
|
||||
if i < protect_start
|
||||
&& self.messages[i].group == MessageGroup::Standalone
|
||||
&& matches!(self.messages[i].message.role, MessageRole::Assistant)
|
||||
{
|
||||
token_sum += self.messages[i].token_count;
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
token_sum += self.messages[i].token_count;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
units.push(EvictionUnit { end: i, token_sum });
|
||||
}
|
||||
|
||||
units
|
||||
}
|
||||
|
||||
/// 保护区外是否存在可压缩消息(供 agentic loop 顶部触发判断)。
|
||||
///
|
||||
/// "可压缩"= status 为 None/active 的消息(已 compressed/archived_segment/truncated
|
||||
/// 不参与二次压缩,幂等)。`protect_start` 为保护区起点(如 `len - PROTECT_COUNT`)。
|
||||
pub fn has_compressible_messages(&self, protect_start: usize) -> bool {
|
||||
let end = protect_start.min(self.messages.len());
|
||||
// 排除 system 角色(压缩摘要 / 话题切换锚点)。这些是上下文锚点非压缩目标——
|
||||
// 若计入,压缩摘要 insert_at(0) 落在可压缩区 [0..protect_start) 且 is_active(status=None),
|
||||
// 致每轮 has_compressible 恒 true → 无限循环压缩(用户报"压缩后每轮提示已压缩并停止")。
|
||||
// compress_old_messages 不改:被调用时仍标旧 system 摘要 compressed(被新摘要替代,防堆积)。
|
||||
self.messages[..end]
|
||||
.iter()
|
||||
.any(|t| t.message.is_active() && !matches!(t.message.role, MessageRole::System))
|
||||
}
|
||||
|
||||
/// 把保护区 `[0, compress_end)` 范围内的 active 消息标记为 `status="compressed"`,
|
||||
/// 同步从 `history_tokens` 扣除其 token,返回被压缩消息的克隆(供喂 LLM 摘要)。
|
||||
///
|
||||
/// **幂等**:已 compressed(或任何 !active)的消息跳过,不会被二次压缩;`history_tokens`
|
||||
/// 也只扣首次标记的 token。返回的 Vec 仅含**本次新标记**的消息(已 compressed 的不返)。
|
||||
///
|
||||
/// **单向不可逆**:压缩后 DB 原始消息保留,但 LLM 上下文里被 is_active 白名单隔离
|
||||
/// (sanitize step0 过滤)。`compress_end` 越界自动 clamp 到 `messages.len()`,且实际
|
||||
/// 压缩边界会回退到最近的对话单元边界(三元组不劈开,见方法内注释)。
|
||||
///
|
||||
/// 返回空 Vec 表示本批次无可压缩消息(全部已 compressed 或范围空),调用方据此跳过 LLM 调用。
|
||||
pub fn compress_old_messages(&mut self, compress_end: usize) -> Vec<ChatMessage> {
|
||||
// 压缩可召回:目标一次性消费(防跨事件残留),路径按本事件重置。压缩标记后原文
|
||||
// 从 LLM 上下文隔离,此刻原文在手,追加写入会话存档供摘要句柄召回(read_file);
|
||||
// 失败仅置空路径(调用方降级为无句柄),不影响压缩本身。
|
||||
let recall = self.compress_recall.take();
|
||||
self.archive_path = None;
|
||||
// 压缩边界按对话单元(三元组)对齐:调用方传入的 end(如 len - PROTECT_COUNT)周期性
|
||||
// 落在单元中间(assistant 头与其 tool 结果之间),只压半个三元组会让保护区以孤立的
|
||||
// 头或尾开头,成为 sanitize 每请求告警的确定性供给。按全长分组取「完整单元边界中
|
||||
// 不超过 raw_end 的最大者」回退(只少压不多压,不侵占保护区);三元组要么整段压缩
|
||||
// 要么整段保留。空范围/空历史无单元,取 0。
|
||||
let raw_end = compress_end.min(self.messages.len());
|
||||
let end = self
|
||||
.build_eviction_units(self.messages.len())
|
||||
.iter()
|
||||
.map(|u| u.end)
|
||||
.take_while(|&e| e <= raw_end)
|
||||
.last()
|
||||
.unwrap_or(0);
|
||||
let mut newly_compressed = Vec::new();
|
||||
for t in self.messages[..end].iter_mut() {
|
||||
if t.message.is_active() {
|
||||
newly_compressed.push(t.message.clone());
|
||||
t.message.status = Some(MessageStatus::Compressed);
|
||||
self.history_tokens = self.history_tokens.saturating_sub(t.token_count);
|
||||
}
|
||||
}
|
||||
// msg-split-phase1:改了既有消息 status(active → compressed),DB 需全量重写收敛
|
||||
// (append-only 增量路径不覆盖旧行)。仅在确实标了新 compressed 时置位(无可压缩时
|
||||
// newly_compressed 为空,状态未变,无需全量重写)。
|
||||
if !newly_compressed.is_empty() {
|
||||
self.needs_full_rewrite = true;
|
||||
if let Some(target) = recall {
|
||||
match archive::append_archive(&target, &newly_compressed) {
|
||||
Some(p) => self.archive_path = Some(p),
|
||||
None => tracing::warn!("[ai] 压缩存档写入失败,本轮摘要无召回句柄(压缩不受影响)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
newly_compressed
|
||||
}
|
||||
|
||||
/// 注入本次压缩事件的存档目标(压缩可召回,`df-ai-compress-recall` 开时由调用方在
|
||||
/// `compress_old_messages` 之前调用;开关关不调用)。一次性消费:压缩标记时落盘,
|
||||
/// 路径经 [`Self::take_archive_path`] 取走。
|
||||
pub fn set_compress_recall(&mut self, data_dir: &std::path::Path, conv_id: &str) {
|
||||
self.compress_recall = Some(archive::RecallTarget::new(data_dir, conv_id));
|
||||
}
|
||||
|
||||
/// 取走最近一次压缩成功落盘的存档路径(Some = 本次压缩可召回,调用方据此在摘要尾部
|
||||
/// 拼句柄行)。take 语义:取后即空,防跨压缩事件残留误报。
|
||||
pub fn take_archive_path(&mut self) -> Option<std::path::PathBuf> {
|
||||
self.archive_path.take()
|
||||
}
|
||||
|
||||
/// 注入缓存友好上下文总开关(每请求由 src-tauri 调用方读 KV 后在 `build_for_request`
|
||||
/// 前调用,持续语义:注入值保持到下次注入)。true = 块状裁剪 + system 锚点 view 层
|
||||
/// 转 user;false = 两者旁路,行为与旧路径逐字节等价。
|
||||
pub fn set_cache_friendly(&mut self, enabled: bool) {
|
||||
self.cache_friendly = enabled;
|
||||
}
|
||||
|
||||
/// 压缩重入标志(读)。true 表示一次 LLM 压缩正在进行中,触发方应跳过本轮压缩。
|
||||
pub fn is_compressing(&self) -> bool {
|
||||
self.is_compressing
|
||||
}
|
||||
|
||||
/// 压缩重入标志(写)。`true`=开始压缩(进入 agentic loop 顶部前置置位),
|
||||
/// `false`=压缩结束(无论成功或降级)。调用方必须成对调用,防止永久卡死。
|
||||
pub fn set_compressing(&mut self, v: bool) {
|
||||
self.is_compressing = v;
|
||||
}
|
||||
|
||||
// build_eviction_units / classify_group 等已在上方公开或文件级定义。
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod manager_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod compress_boundary_tests {
|
||||
use super::*;
|
||||
use crate::provider::ToolCall;
|
||||
|
||||
fn mgr() -> ContextManager {
|
||||
ContextManager::new(ContextConfig {
|
||||
max_tokens: 100_000,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// 构造 4 个周期轮次,每轮 [user, assistant(tool_calls), tool_result, assistant]:
|
||||
/// user 独占一个单元,后三条(头+尾+紧随独立 assistant)合并为一个单元,
|
||||
/// 单元边界落在 4/8/12/16。
|
||||
fn periodic_rounds() -> ContextManager {
|
||||
let mut m = mgr();
|
||||
for r in 0..4 {
|
||||
m.push(ChatMessage::user(&format!("问题 {}", r)));
|
||||
m.push(ChatMessage::assistant_with_tools(
|
||||
"",
|
||||
vec![ToolCall::new(format!("c{}", r), "run_command", "{}")],
|
||||
));
|
||||
m.push(ChatMessage::tool_result(format!("c{}", r), "ok"));
|
||||
m.push(ChatMessage::assistant("小结"));
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compress_end_mid_unit_retreats_to_unit_boundary() {
|
||||
let mut m = periodic_rounds();
|
||||
// len=16,调用方口径 len - PROTECT_COUNT(6) = 10,落在第 3 轮三元组中间
|
||||
// (9 头 / 10 尾 / 11 独立)。旧行为会把头压掉却留下孤立的 tool 结果。
|
||||
// 回退到完整单元边界 9:只多压前面的 user,不劈开三元组(只少压不多压)。
|
||||
let compressed = m.compress_old_messages(10);
|
||||
assert_eq!(compressed.len(), 9, "边界回退到完整单元边界 9");
|
||||
let msgs = m.messages_mut();
|
||||
for i in 9..16 {
|
||||
assert!(msgs[i].message.is_active(), "第 {} 条应保持 active", i);
|
||||
}
|
||||
// 关键回归点:第 3 轮三元组(9 头 / 10 尾 / 11 独立)整体保持 active,头未被劈开
|
||||
assert!(msgs[9].message.is_active(), "三元组头不应被单独压缩");
|
||||
assert!(msgs[10].message.is_active(), "三元组尾不应残留 active 而头被压");
|
||||
assert!(msgs[11].message.is_active());
|
||||
assert_eq!(msgs[9].group, MessageGroup::ToolCallHead);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compress_end_on_boundary_unchanged() {
|
||||
let mut m = periodic_rounds();
|
||||
// end=8 恰为单元边界,行为与对齐前一致(整 8 条压缩)。
|
||||
let compressed = m.compress_old_messages(8);
|
||||
assert_eq!(compressed.len(), 8);
|
||||
let msgs = m.messages_mut();
|
||||
assert!(msgs[..8].iter().all(|t| !t.message.is_active()));
|
||||
assert!(msgs[8..].iter().all(|t| t.message.is_active()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compress_end_oversized_still_compresses_all() {
|
||||
let mut m = periodic_rounds();
|
||||
// 越界 clamp 到 len:全量单元分组后末单元 end=len,语义与改动前一致。
|
||||
let compressed = m.compress_old_messages(999);
|
||||
assert_eq!(compressed.len(), 16);
|
||||
assert_eq!(m.history_tokens(), 0);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1815
-17
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,640 @@
|
||||
//! Git worktree 生命周期管理 — 多 Agent 并行执行的文件隔离基础
|
||||
//!
|
||||
//! 设计依据:docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md §4.2
|
||||
//!
|
||||
//! 每个 SubTask 创建独立 worktree(git worktree add + 新分支),
|
||||
//! 写操作天然隔离。合并时用 git merge-tree 预检 + git merge 实际合并。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// worktree 管理错误
|
||||
#[derive(Debug)]
|
||||
pub enum WorktreeError {
|
||||
/// Git 命令执行失败(stdout/stderr 含错误信息)
|
||||
GitFailed(String),
|
||||
/// 工程根目录无 .git(非 Git 工程,应降级串行)
|
||||
NotGitRepo,
|
||||
/// worktree 目录已存在(可能上次未清理)
|
||||
AlreadyExists(PathBuf),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for WorktreeError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::GitFailed(msg) => write!(f, "Git 命令失败: {}", msg),
|
||||
Self::NotGitRepo => write!(f, "非 Git 工程(无 .git 目录)"),
|
||||
Self::AlreadyExists(p) => write!(f, "worktree 目录已存在: {}", p.display()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for WorktreeError {}
|
||||
|
||||
/// worktree 创建结果
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorktreeHandle {
|
||||
/// worktree 物理路径(SubTask 工具操作的 cwd)
|
||||
pub path: PathBuf,
|
||||
/// worktree 对应的分支名(merge 时用)
|
||||
pub branch: String,
|
||||
/// 所属 Plan id(清理时按 plan 批量)
|
||||
pub plan_id: String,
|
||||
/// SubTask id
|
||||
pub subtask_id: String,
|
||||
}
|
||||
|
||||
/// 在指定工程根目录下创建 SubTask 专属 worktree。
|
||||
///
|
||||
/// - `project_root`: 工程根目录(含 .git)
|
||||
/// - `plan_id`: 所属 Plan id
|
||||
/// - `subtask_id`: SubTask id
|
||||
/// - `base_branch`: fork 基点分支(plan 分支或主分支)
|
||||
///
|
||||
/// 返回 WorktreeHandle(path + branch)。
|
||||
/// worktree 路径:`{project_root}/.devflow/wt/{plan_id}/{subtask_id}`
|
||||
/// 分支名:`subtask/{plan_id}/{subtask_id}`
|
||||
pub fn create_worktree(
|
||||
project_root: &Path,
|
||||
plan_id: &str,
|
||||
subtask_id: &str,
|
||||
base_branch: &str,
|
||||
) -> Result<WorktreeHandle, WorktreeError> {
|
||||
if !project_root.join(".git").exists() {
|
||||
return Err(WorktreeError::NotGitRepo);
|
||||
}
|
||||
|
||||
let wt_path = project_root.join(".devflow/wt").join(plan_id).join(subtask_id);
|
||||
if wt_path.exists() {
|
||||
return Err(WorktreeError::AlreadyExists(wt_path));
|
||||
}
|
||||
|
||||
let branch = format!("subtask/{}/{}", plan_id, subtask_id);
|
||||
|
||||
// git worktree add <path> -b <branch> <base_branch>
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"worktree",
|
||||
"add",
|
||||
wt_path.to_str().unwrap_or(""),
|
||||
"-b",
|
||||
&branch,
|
||||
base_branch,
|
||||
])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git worktree add 执行失败: {}", e)))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(WorktreeError::GitFailed(format!(
|
||||
"git worktree add 失败: {}",
|
||||
stderr.trim()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(WorktreeHandle {
|
||||
path: wt_path,
|
||||
branch,
|
||||
plan_id: plan_id.to_string(),
|
||||
subtask_id: subtask_id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 在 worktree 内提交所有改动(git add -A + git commit)。
|
||||
///
|
||||
/// - `wt_path`: worktree 物理路径
|
||||
/// - `message`: commit 消息
|
||||
pub fn commit_worktree(wt_path: &Path, message: &str) -> Result<(), WorktreeError> {
|
||||
// git add -A
|
||||
let add = Command::new("git")
|
||||
.args(["add", "-A"])
|
||||
.current_dir(wt_path)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git add 失败: {}", e)))?;
|
||||
if !add.status.success() {
|
||||
return Err(WorktreeError::GitFailed(format!(
|
||||
"git add 失败: {}",
|
||||
String::from_utf8_lossy(&add.stderr).trim()
|
||||
)));
|
||||
}
|
||||
|
||||
// git commit -m (允许空提交,--allow-empty 防无改动时报错)
|
||||
let commit = Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", message])
|
||||
.current_dir(wt_path)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git commit 失败: {}", e)))?;
|
||||
if !commit.status.success() {
|
||||
// commit 失败可能因无 user.email 配置(测试环境),尝试自动配
|
||||
let stderr = String::from_utf8_lossy(&commit.stderr);
|
||||
if stderr.contains("user.email") || stderr.contains("user.name") {
|
||||
// 降级:设置 worktree 局部配置(--local 不污染全局 git config)后重试
|
||||
Command::new("git")
|
||||
.args(["config", "--local", "user.email", "devflow@ai.local"])
|
||||
.current_dir(wt_path)
|
||||
.output()
|
||||
.ok();
|
||||
Command::new("git")
|
||||
.args(["config", "--local", "user.name", "DevFlow Agent"])
|
||||
.current_dir(wt_path)
|
||||
.output()
|
||||
.ok();
|
||||
let retry = Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", message])
|
||||
.current_dir(wt_path)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git commit 重试失败: {}", e)))?;
|
||||
if !retry.status.success() {
|
||||
return Err(WorktreeError::GitFailed(format!(
|
||||
"git commit 重试仍失败: {}",
|
||||
String::from_utf8_lossy(&retry.stderr).trim()
|
||||
)));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
return Err(WorktreeError::GitFailed(format!("git commit 失败: {}", stderr.trim())));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 将 SubTask 分支 merge 到 plan 分支(在 plan worktree 内执行)。
|
||||
///
|
||||
/// 返回 Ok(true) 表示有改动被合并,Ok(false) 表示无改动(空分支)。
|
||||
/// 返回 Err 表示 merge 冲突。
|
||||
pub fn merge_branch(
|
||||
plan_wt_path: &Path,
|
||||
subtask_branch: &str,
|
||||
) -> Result<MergeResult, WorktreeError> {
|
||||
// 预检用旧版三参形态 merge-tree <共同祖先> <ours> <theirs>
|
||||
// (--write-tree 需 Git 2.38+,为兼容旧 git 不采用)
|
||||
// 第 1 参必须是 merge-base:此前误传 HEAD,branch2(HEAD) 相对 base 零差异,
|
||||
// trivial merge 恒判无冲突(实测真冲突场景输出为空 exit 0),预检形同虚设
|
||||
let mb = Command::new("git")
|
||||
.args(["merge-base", "HEAD", subtask_branch])
|
||||
.current_dir(plan_wt_path)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git merge-base 失败: {}", e)))?;
|
||||
|
||||
// 旧式形态冲突时 exit 仍为 0,只能看 stdout 判定;只认 <<<<<<< 标记,
|
||||
// 不查 =======:干净合并的 diff 上下文会带出文件内容,文档里的等号分隔行会误撞
|
||||
if mb.status.success() {
|
||||
let merge_base = String::from_utf8_lossy(&mb.stdout).trim().to_string();
|
||||
let precheck = Command::new("git")
|
||||
.args(["merge-tree", merge_base.as_str(), "HEAD", subtask_branch])
|
||||
.current_dir(plan_wt_path)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git merge-tree 失败: {}", e)))?;
|
||||
|
||||
let precheck_stdout = String::from_utf8_lossy(&precheck.stdout);
|
||||
if precheck_stdout.contains("<<<<<<<") {
|
||||
return Ok(MergeResult::Conflict {
|
||||
branch: subtask_branch.to_string(),
|
||||
details: precheck_stdout.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
// merge-base 失败 = 两分支无共同祖先,无法预检,交由下方实际 merge 裁决
|
||||
|
||||
// 无冲突,执行实际 merge
|
||||
let merge = Command::new("git")
|
||||
.args(["merge", "--no-edit", subtask_branch])
|
||||
.current_dir(plan_wt_path)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git merge 失败: {}", e)))?;
|
||||
|
||||
if !merge.status.success() {
|
||||
// merge 冲突(git merge 返回非0)
|
||||
let stderr = String::from_utf8_lossy(&merge.stderr);
|
||||
let stdout = String::from_utf8_lossy(&merge.stdout);
|
||||
// abort 部分合并状态(防 worktree 处于 conflicted 状态)
|
||||
let _ = Command::new("git")
|
||||
.args(["merge", "--abort"])
|
||||
.current_dir(plan_wt_path)
|
||||
.output();
|
||||
return Ok(MergeResult::Conflict {
|
||||
branch: subtask_branch.to_string(),
|
||||
details: format!("{}\n{}", stdout, stderr),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(MergeResult::Clean)
|
||||
}
|
||||
|
||||
/// merge 结果
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MergeResult {
|
||||
/// 干净合并(无冲突)
|
||||
Clean,
|
||||
/// 合并冲突(details 含 conflict 信息)
|
||||
Conflict { branch: String, details: String },
|
||||
}
|
||||
|
||||
/// 清理单个 SubTask 的 worktree(git worktree remove --force + 删分支)。
|
||||
pub fn remove_worktree(project_root: &Path, handle: &WorktreeHandle) -> Result<(), WorktreeError> {
|
||||
// git worktree remove --force <path>
|
||||
let _ = Command::new("git")
|
||||
.args([
|
||||
"worktree",
|
||||
"remove",
|
||||
"--force",
|
||||
handle.path.to_str().unwrap_or(""),
|
||||
])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
|
||||
// 删除分支(-D 强制,已 merge 的分支正常删,未 merge 的也删)
|
||||
let _ = Command::new("git")
|
||||
.args(["branch", "-D", &handle.branch])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 清理某 Plan 的所有 worktree(Plan 完成/失败/取消时批量清理)。
|
||||
pub fn remove_plan_worktrees(project_root: &Path, plan_id: &str) -> Result<(), WorktreeError> {
|
||||
let plan_wt_dir = project_root.join(".devflow/wt").join(plan_id);
|
||||
if !plan_wt_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// git worktree list 获取该 plan 下所有 worktree
|
||||
let list = Command::new("git")
|
||||
.args(["worktree", "list", "--porcelain"])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git worktree list 失败: {}", e)))?;
|
||||
|
||||
let list_str = String::from_utf8_lossy(&list.stdout);
|
||||
for line in list_str.lines() {
|
||||
if let Some(path_str) = line.strip_prefix("worktree ") {
|
||||
let path = PathBuf::from(path_str);
|
||||
// 仅清理属于本 plan 的 worktree
|
||||
if path.starts_with(&plan_wt_dir) {
|
||||
let _ = Command::new("git")
|
||||
.args(["worktree", "remove", "--force", path_str])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除该 plan 的所有子任务分支
|
||||
let branches = Command::new("git")
|
||||
.args(["branch", "--list", &format!("subtask/{}/*", plan_id)])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git branch --list 失败: {}", e)))?;
|
||||
|
||||
let branches_str = String::from_utf8_lossy(&branches.stdout);
|
||||
for line in branches_str.lines() {
|
||||
let branch = line.trim();
|
||||
if !branch.is_empty() {
|
||||
let _ = Command::new("git")
|
||||
.args(["branch", "-D", branch])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
}
|
||||
}
|
||||
|
||||
// 清理空目录
|
||||
let _ = std::fs::remove_dir_all(&plan_wt_dir);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检测工程是否为 Git 仓库(有 .git 目录)。
|
||||
pub fn is_git_repo(project_root: &Path) -> bool {
|
||||
project_root.join(".git").exists()
|
||||
}
|
||||
|
||||
/// 获取工程当前分支名(用于作为 plan worktree 的 fork 基点)。
|
||||
pub fn current_branch(project_root: &Path) -> Result<String, WorktreeError> {
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git rev-parse 失败: {}", e)))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(WorktreeError::GitFailed(
|
||||
String::from_utf8_lossy(&output.stderr).trim().to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
/// 创建 Plan worktree(Plan 级工作分支,所有 SubTask 分支 merge 到这里)。
|
||||
///
|
||||
/// 返回 (plan_worktree_path, plan_branch)。
|
||||
pub fn create_plan_worktree(
|
||||
project_root: &Path,
|
||||
plan_id: &str,
|
||||
) -> Result<(PathBuf, String), WorktreeError> {
|
||||
if !is_git_repo(project_root) {
|
||||
return Err(WorktreeError::NotGitRepo);
|
||||
}
|
||||
|
||||
let base = current_branch(project_root)?;
|
||||
let plan_branch = format!("plan/{}", plan_id);
|
||||
let plan_wt_path = project_root.join(".devflow/wt").join(plan_id);
|
||||
|
||||
if plan_wt_path.exists() {
|
||||
return Err(WorktreeError::AlreadyExists(plan_wt_path));
|
||||
}
|
||||
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"worktree",
|
||||
"add",
|
||||
plan_wt_path.to_str().unwrap_or(""),
|
||||
"-b",
|
||||
&plan_branch,
|
||||
&base,
|
||||
])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git worktree add(plan) 失败: {}", e)))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(WorktreeError::GitFailed(format!(
|
||||
"git worktree add(plan) 失败: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok((plan_wt_path, plan_branch))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 创建临时 Git 仓库 + 初始 commit
|
||||
fn setup_repo() -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path();
|
||||
|
||||
Command::new("git").args(["init"]).current_dir(path).output().unwrap();
|
||||
Command::new("git").args(["config", "user.email", "test@test.com"]).current_dir(path).output().unwrap();
|
||||
Command::new("git").args(["config", "user.name", "test"]).current_dir(path).output().unwrap();
|
||||
|
||||
std::fs::write(path.join("main.rs"), "fn main() {}\n").unwrap();
|
||||
Command::new("git").args(["add", "."]).current_dir(path).output().unwrap();
|
||||
Command::new("git").args(["commit", "-m", "init"]).current_dir(path).output().unwrap();
|
||||
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_01_create_and_isolate() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
let base = current_branch(root).unwrap();
|
||||
|
||||
let handle = create_worktree(root, "plan-test", "A", &base).unwrap();
|
||||
assert!(handle.path.exists(), "worktree 目录应存在");
|
||||
assert_eq!(handle.branch, "subtask/plan-test/A");
|
||||
|
||||
// 在 worktree 内写文件
|
||||
std::fs::write(handle.path.join("new_a.rs"), "// A 的改动").unwrap();
|
||||
// 主目录不应有此文件
|
||||
assert!(!root.join("new_a.rs").exists(), "主目录不应有 A 的改动");
|
||||
|
||||
// 清理
|
||||
remove_worktree(root, &handle).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_02_parallel_isolation() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
|
||||
let plan_wt = create_plan_worktree(root, "plan-iso").unwrap();
|
||||
let plan_branch = plan_wt.1;
|
||||
|
||||
let a = create_worktree(root, "plan-iso", "A", &plan_branch).unwrap();
|
||||
let b = create_worktree(root, "plan-iso", "B", &plan_branch).unwrap();
|
||||
|
||||
// A 写 new_a.rs
|
||||
std::fs::write(a.path.join("new_a.rs"), "// A").unwrap();
|
||||
// B 写 new_b.rs
|
||||
std::fs::write(b.path.join("new_b.rs"), "// B").unwrap();
|
||||
|
||||
// A 的 worktree 不应有 B 的文件
|
||||
assert!(!a.path.join("new_b.rs").exists(), "A 不应看到 B 的改动");
|
||||
// B 的 worktree 不应有 A 的文件
|
||||
assert!(!b.path.join("new_a.rs").exists(), "B 不应看到 A 的改动");
|
||||
|
||||
remove_plan_worktrees(root, "plan-iso").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_03_commit_in_worktree() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
|
||||
let plan_wt = create_plan_worktree(root, "plan-commit").unwrap();
|
||||
let a = create_worktree(root, "plan-commit", "A", &plan_wt.1).unwrap();
|
||||
|
||||
// 写文件 + commit
|
||||
std::fs::write(a.path.join("feature.rs"), "pub fn feature() {}").unwrap();
|
||||
commit_worktree(&a.path, "Add feature").unwrap();
|
||||
|
||||
// 验证分支有新 commit
|
||||
let log = Command::new("git")
|
||||
.args(["log", "--oneline", "-1"])
|
||||
.current_dir(&a.path)
|
||||
.output()
|
||||
.unwrap();
|
||||
let log_str = String::from_utf8_lossy(&log.stdout);
|
||||
assert!(log_str.contains("Add feature"), "commit 消息应在 log 中");
|
||||
|
||||
remove_plan_worktrees(root, "plan-commit").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_04_merge_no_conflict() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
|
||||
let plan_wt = create_plan_worktree(root, "plan-merge").unwrap();
|
||||
let plan_wt_path = plan_wt.0.clone();
|
||||
let plan_branch = plan_wt.1;
|
||||
|
||||
// A 改 file_a.rs, B 改 file_b.rs(不同文件,无冲突)
|
||||
let a = create_worktree(root, "plan-merge", "A", &plan_branch).unwrap();
|
||||
std::fs::write(a.path.join("file_a.rs"), "// A").unwrap();
|
||||
commit_worktree(&a.path, "A changes").unwrap();
|
||||
|
||||
let b = create_worktree(root, "plan-merge", "B", &plan_branch).unwrap();
|
||||
std::fs::write(b.path.join("file_b.rs"), "// B").unwrap();
|
||||
commit_worktree(&b.path, "B changes").unwrap();
|
||||
|
||||
// merge A → plan
|
||||
let result_a = merge_branch(&plan_wt_path, &a.branch).unwrap();
|
||||
assert!(matches!(result_a, MergeResult::Clean), "A merge 应无冲突");
|
||||
|
||||
// merge B → plan
|
||||
let result_b = merge_branch(&plan_wt_path, &b.branch).unwrap();
|
||||
assert!(matches!(result_b, MergeResult::Clean), "B merge 应无冲突");
|
||||
|
||||
// plan worktree 应同时有 file_a.rs 和 file_b.rs
|
||||
assert!(plan_wt_path.join("file_a.rs").exists(), "plan 应含 A 的改动");
|
||||
assert!(plan_wt_path.join("file_b.rs").exists(), "plan 应含 B 的改动");
|
||||
|
||||
remove_plan_worktrees(root, "plan-merge").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_05_merge_conflict_same_file() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
|
||||
// 写一个共享文件
|
||||
std::fs::write(root.join("shared.rs"), "line1\nline2\n").unwrap();
|
||||
Command::new("git").args(["add", "-A"]).current_dir(root).output().unwrap();
|
||||
Command::new("git").args(["commit", "-m", "add shared"]).current_dir(root).output().unwrap();
|
||||
|
||||
let plan_wt = create_plan_worktree(root, "plan-conf").unwrap();
|
||||
let plan_wt_path = plan_wt.0.clone();
|
||||
let plan_branch = plan_wt.1;
|
||||
|
||||
// A 和 B 改同一文件的同一行
|
||||
let a = create_worktree(root, "plan-conf", "A", &plan_branch).unwrap();
|
||||
std::fs::write(a.path.join("shared.rs"), "A_version\n").unwrap();
|
||||
commit_worktree(&a.path, "A change shared").unwrap();
|
||||
|
||||
let b = create_worktree(root, "plan-conf", "B", &plan_branch).unwrap();
|
||||
std::fs::write(b.path.join("shared.rs"), "B_version\n").unwrap();
|
||||
commit_worktree(&b.path, "B change shared").unwrap();
|
||||
|
||||
// merge A → 无冲突
|
||||
let result_a = merge_branch(&plan_wt_path, &a.branch).unwrap();
|
||||
assert!(matches!(result_a, MergeResult::Clean), "首个 merge 应无冲突");
|
||||
|
||||
// merge B → 冲突
|
||||
let result_b = merge_branch(&plan_wt_path, &b.branch).unwrap();
|
||||
assert!(
|
||||
matches!(result_b, MergeResult::Conflict { .. }),
|
||||
"同文件同行改应产生冲突"
|
||||
);
|
||||
|
||||
remove_plan_worktrees(root, "plan-conf").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_06_non_git_returns_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// 不 init git
|
||||
let result = create_plan_worktree(dir.path(), "plan-ng");
|
||||
assert!(matches!(result, Err(WorktreeError::NotGitRepo)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_07_cleanup_removes_worktree() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
|
||||
let plan_wt = create_plan_worktree(root, "plan-clean").unwrap();
|
||||
let a = create_worktree(root, "plan-clean", "A", &plan_wt.1).unwrap();
|
||||
assert!(a.path.exists());
|
||||
|
||||
remove_plan_worktrees(root, "plan-clean").unwrap();
|
||||
|
||||
// worktree 目录应被清理
|
||||
assert!(!a.path.exists(), "worktree 目录应已清理");
|
||||
// plan wt 目录也应被清理
|
||||
assert!(!plan_wt.0.exists(), "plan worktree 目录应已清理");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_08_precheck_hits_conflict_before_real_merge() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
|
||||
std::fs::write(root.join("shared.rs"), "line1\nline2\n").unwrap();
|
||||
Command::new("git").args(["add", "-A"]).current_dir(root).output().unwrap();
|
||||
Command::new("git").args(["commit", "-m", "add shared"]).current_dir(root).output().unwrap();
|
||||
|
||||
let plan_wt = create_plan_worktree(root, "plan-prechk").unwrap();
|
||||
let plan_wt_path = plan_wt.0.clone();
|
||||
let plan_branch = plan_wt.1;
|
||||
|
||||
// A 和 B 改同一文件同一行
|
||||
let a = create_worktree(root, "plan-prechk", "A", &plan_branch).unwrap();
|
||||
std::fs::write(a.path.join("shared.rs"), "A_version\n").unwrap();
|
||||
commit_worktree(&a.path, "A change shared").unwrap();
|
||||
|
||||
let b = create_worktree(root, "plan-prechk", "B", &plan_branch).unwrap();
|
||||
std::fs::write(b.path.join("shared.rs"), "B_version\n").unwrap();
|
||||
commit_worktree(&b.path, "B change shared").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
merge_branch(&plan_wt_path, &a.branch).unwrap(),
|
||||
MergeResult::Clean
|
||||
));
|
||||
|
||||
// 冲突必须由 merge-tree 预检命中(details 含标记),而非实际 merge 失败兜底
|
||||
// (兜底 details 来自 merge stderr,只有 CONFLICT 文案无标记)
|
||||
match merge_branch(&plan_wt_path, &b.branch).unwrap() {
|
||||
MergeResult::Conflict { details, .. } => {
|
||||
assert!(
|
||||
details.contains("<<<<<<<"),
|
||||
"冲突详情应含 merge-tree 预检输出的标记,而非 merge 兜底文案: {}",
|
||||
details
|
||||
);
|
||||
}
|
||||
_ => panic!("同文件同行改应冲突"),
|
||||
}
|
||||
|
||||
// 预检命中即返回,不应留下合并中状态
|
||||
let merging = Command::new("git")
|
||||
.args(["rev-parse", "-q", "--verify", "MERGE_HEAD"])
|
||||
.current_dir(&plan_wt_path)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!merging.status.success(), "预检命中不应留下 MERGE_HEAD");
|
||||
|
||||
remove_plan_worktrees(root, "plan-prechk").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_09_clean_merge_with_eq_marker_content() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
|
||||
// 文件内容含等号分隔行(md 标题下划线常见),A/B 隔行修改,实际可干净合并
|
||||
std::fs::write(root.join("doc.md"), "t0\n=======\nl3\nl4\nl5\n").unwrap();
|
||||
Command::new("git").args(["add", "-A"]).current_dir(root).output().unwrap();
|
||||
Command::new("git").args(["commit", "-m", "add doc"]).current_dir(root).output().unwrap();
|
||||
|
||||
let plan_wt = create_plan_worktree(root, "plan-eqmark").unwrap();
|
||||
let plan_wt_path = plan_wt.0.clone();
|
||||
let plan_branch = plan_wt.1;
|
||||
|
||||
let a = create_worktree(root, "plan-eqmark", "A", &plan_branch).unwrap();
|
||||
std::fs::write(a.path.join("doc.md"), "A0\n=======\nl3\nl4\nl5\n").unwrap();
|
||||
commit_worktree(&a.path, "A chg line1").unwrap();
|
||||
|
||||
let b = create_worktree(root, "plan-eqmark", "B", &plan_branch).unwrap();
|
||||
std::fs::write(b.path.join("doc.md"), "t0\n=======\nl3\nl4\nB5\n").unwrap();
|
||||
commit_worktree(&b.path, "B chg line5").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
merge_branch(&plan_wt_path, &a.branch).unwrap(),
|
||||
MergeResult::Clean
|
||||
));
|
||||
|
||||
// B 隔行修改应 Clean:预检不得因 diff 上下文中的等号行误判冲突
|
||||
assert!(matches!(
|
||||
merge_branch(&plan_wt_path, &b.branch).unwrap(),
|
||||
MergeResult::Clean
|
||||
));
|
||||
|
||||
remove_plan_worktrees(root, "plan-eqmark").unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! 本地图片引用展开(#958 图片入站引用化)
|
||||
//!
|
||||
//! 消息持久化只存引用(ContentPart::Image.url = `file://<绝对路径>`,base64 空),
|
||||
//! 发送请求前在此统一展开为 base64 内嵌——存储面瘦 referencing(一张 5MB 图不再以
|
||||
//! 6.7MB base64 字符串进 SQLite 随历史全量重放),转换面按需读盘(SSD 毫秒级,
|
||||
//! 对比 base64 内嵌的每请求全量携带,磁盘读便宜得多)。
|
||||
//!
|
||||
//! 展开点是 provider 转换层的入口:主会话/子代理/标题/压缩等全部请求路径在此
|
||||
//! 单点覆盖,调用方(飞书入站/桌面粘贴图)只管落盘产引用,不关心 provider 差异。
|
||||
//!
|
||||
//! 失败语义:引用文件丢失/不可读时降级为 Text 片「[图片文件缺失: path]」——
|
||||
//! 诚实告知模型图不可用,不让请求 400(空 base64 会被两个协议端点拒绝)。
|
||||
|
||||
use crate::provider::ContentPart;
|
||||
use base64::Engine as _;
|
||||
|
||||
/// `file://` 引用前缀。内部引用标识(两端自洽即可,不进端点):落盘方拼
|
||||
/// `file://<原生绝对路径>`,展开方 strip 后按原生路径读(Windows 盘符路径直接可用)。
|
||||
pub const LOCAL_FILE_SCHEME: &str = "file://";
|
||||
|
||||
/// 判定 Image 片是否为本地引用(base64 空 + url 带本地 scheme)
|
||||
fn is_local_ref(url: Option<&str>, base64: Option<&str>) -> bool {
|
||||
base64.is_none() && url.is_some_and(|u| u.starts_with(LOCAL_FILE_SCHEME))
|
||||
}
|
||||
|
||||
/// 把消息内的本地图片引用展开为 base64 内嵌片;非本地片原样透传。
|
||||
/// 文件缺失/不可读时该 Image 片降级为 Text 片(请求不炸,模型得到可读的缺失说明)。
|
||||
pub fn inline_local_image_parts(parts: Vec<ContentPart>) -> Vec<ContentPart> {
|
||||
parts.into_iter().map(inline_local_image_part).collect()
|
||||
}
|
||||
|
||||
/// 单片版(provider 转换臂逐片 map 用)
|
||||
pub fn inline_local_image_part(p: ContentPart) -> ContentPart {
|
||||
match p {
|
||||
ContentPart::Image { url, base64, media_type, alt }
|
||||
if is_local_ref(url.as_deref(), base64.as_deref()) =>
|
||||
{
|
||||
let path = url.unwrap();
|
||||
match std::fs::read(&path[LOCAL_FILE_SCHEME.len()..]) {
|
||||
Ok(bytes) => ContentPart::Image {
|
||||
url: None,
|
||||
base64: Some(base64::engine::general_purpose::STANDARD.encode(bytes)),
|
||||
media_type,
|
||||
alt,
|
||||
},
|
||||
Err(_) => ContentPart::Text {
|
||||
text: format!("[图片文件缺失: {}]", &path[LOCAL_FILE_SCHEME.len()..]),
|
||||
},
|
||||
}
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn local_ref_expands_to_base64() {
|
||||
let dir = std::env::temp_dir().join("df-ai-image-ref-test");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("expands.png");
|
||||
std::fs::write(&path, b"\x89PNG fake-bytes").unwrap();
|
||||
|
||||
let out = inline_local_image_parts(vec![ContentPart::Image {
|
||||
url: Some(format!("file://{}", path.display())),
|
||||
base64: None,
|
||||
media_type: Some("image/png".into()),
|
||||
alt: None,
|
||||
}]);
|
||||
match &out[0] {
|
||||
ContentPart::Image { url, base64, media_type, .. } => {
|
||||
assert!(url.is_none());
|
||||
let b = base64.as_deref().expect("base64 应已填充");
|
||||
assert_eq!(b, base64::engine::general_purpose::STANDARD.encode(b"\x89PNG fake-bytes"));
|
||||
assert_eq!(media_type.as_deref(), Some("image/png"));
|
||||
}
|
||||
other => panic!("应保持 Image 片, 实际 {other:?}"),
|
||||
}
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_degrades_to_text_not_empty_base64() {
|
||||
let out = inline_local_image_parts(vec![ContentPart::Image {
|
||||
url: Some("file:///nonexistent/df-ai-no-such.png".into()),
|
||||
base64: None,
|
||||
media_type: Some("image/png".into()),
|
||||
alt: None,
|
||||
}]);
|
||||
match &out[0] {
|
||||
ContentPart::Text { text } => {
|
||||
assert!(text.contains("[图片文件缺失"), "应降级为缺失说明, 实际 {text}");
|
||||
assert!(text.contains("df-ai-no-such.png"));
|
||||
}
|
||||
other => panic!("缺失文件应降级 Text 片, 实际 {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_local_parts_pass_through() {
|
||||
let passthrough = vec![
|
||||
ContentPart::text("hello"),
|
||||
ContentPart::image_base64("image/png", "aGVsbG8="),
|
||||
ContentPart::image_url("https://x/a.png"),
|
||||
// base64 非空的 file:// 不是引用(优先内嵌),原样透传
|
||||
ContentPart::Image {
|
||||
url: Some("file:///x.png".into()),
|
||||
base64: Some("aGVsbG8=".into()),
|
||||
media_type: Some("image/png".into()),
|
||||
alt: None,
|
||||
},
|
||||
];
|
||||
let out = inline_local_image_parts(passthrough);
|
||||
assert_eq!(out.len(), 4);
|
||||
assert!(matches!(&out[0], ContentPart::Text { .. }));
|
||||
assert!(matches!(&out[1], ContentPart::Image { base64: Some(_), .. }));
|
||||
assert!(matches!(&out[2], ContentPart::Image { url: Some(u), .. } if u.starts_with("https://")));
|
||||
assert!(matches!(&out[3], ContentPart::Image { base64: Some(_), .. }));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+164
-2
@@ -1,10 +1,172 @@
|
||||
//! df-ai: AI 编排 — LLM Provider、模型路由、Agent 协调、上下文管理、流式处理、工具注册
|
||||
//! df-ai: AI 编排 — LLM Provider、Agent 协调、上下文管理、流式处理、工具注册
|
||||
|
||||
pub mod ai_tools;
|
||||
// 声明式工具注册宏 `declare_tool!`(tool_registry 拆分第一步基础设施)。
|
||||
// macro_rules + #[macro_export]:零新依赖,展开为等价 `register` 调用,与现有 48 工具并存。
|
||||
// 试点:list_projects 已迁至声明式(commands/ai/tools/list_projects.rs),验证可行+零回归。
|
||||
#[macro_use]
|
||||
pub mod ai_tools_decl;
|
||||
pub mod anthropic_compat;
|
||||
pub mod anthropic_helpers;
|
||||
pub mod context;
|
||||
pub mod context_helpers;
|
||||
pub mod memory_adapter;
|
||||
pub(crate) mod net_probe;
|
||||
// 多 Agent 协作调度中心(Phase 1 规则驱动, Phase 2 LLM 扩展)。
|
||||
pub mod coordinator;
|
||||
// 会话意图识别层(纯函数,不接入 agentic loop)。依据 docs/02-架构设计/构想审查/
|
||||
// 意图识别层论证-2026-06-19.md。提供 recognize / tool_subset_for / suggested_model_tier。
|
||||
// 待 Phase B+C 完成 + 模型模态管理落地后再接入 agentic loop。
|
||||
pub mod git_worktree;
|
||||
pub mod intent;
|
||||
pub mod model_fetch;
|
||||
// model_fetch 的纯逻辑子模块(URL 拼接 / 噪音过滤 / 响应反序列化),
|
||||
// crate 内复用,private mod — 外部经 model_fetch 间接访问,无直接路径需求。
|
||||
mod model_fetch_helpers;
|
||||
pub mod model_probe;
|
||||
// model_probe 的纯逻辑子模块(预设表加载 / 启发式推断 / 词素判定),
|
||||
// crate 内复用,private mod — 外部经 model_probe 间接访问,无直接路径需求。
|
||||
mod model_probe_helpers;
|
||||
// 单对话并行多轮 Phase 0a · 0.8 plan_hint 纯函数(扩 intent::tool_subset_for 编排语义)。
|
||||
// 依据 docs/02-架构设计/单对话并行多轮-Phase0落地路线图-2026-06-20.md 第 0.8 节。
|
||||
// 纯函数(零 IO 零状态),Phase 1 接入主 loop,LLM 调度留 Phase 2。
|
||||
pub mod plan_hint;
|
||||
pub mod openai_compat;
|
||||
pub mod openai_helpers;
|
||||
// 本地图片引用展开(#958 图片入站引用化):消息存 file:// 引用,发送前单点展开 base64
|
||||
pub mod image_ref;
|
||||
// Phase 0 (B 路线): Plan-driven 规划骨架(维度 0.8 Plan/SubTask 结构 + 0.7 规划校验/Kahn 分层)。
|
||||
// 纯函数模块(零 IO 零状态)。依据 docs/02-架构设计/单对话并行多轮-{设计,Phase0落地路线图}-2026-06-20.md。
|
||||
// Phase 1(plan_hint 接入主 loop)/Phase 2(planning)/Phase 3(并行 execution) 接入后续。
|
||||
pub mod planner;
|
||||
// Phase 2: Plan DAG 分层执行器(骨架,PLAN_EXECUTION_ENABLED 门控默认关)。
|
||||
// 按 Plan::to_layers 层间串行/层内并行调度子任务。
|
||||
// 依据 docs/02-架构设计/单对话并行多轮-设计-2026-06-20.md。
|
||||
pub mod persona;
|
||||
pub mod plan_executor;
|
||||
pub mod provider;
|
||||
pub mod router;
|
||||
pub mod stream;
|
||||
// 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用,
|
||||
// stream_recv/agentic 流前重试需复用 backoff_delay(jitter)+is_status_retryable(Fatal 分类)
|
||||
// 避免重写退避/分类逻辑。改 pub mod 后对外仅暴露纯函数 + 常量。
|
||||
pub mod retry;
|
||||
pub mod sse_parser;
|
||||
pub mod namespace_store;
|
||||
// P1 工具结果瘦身(上下文根治专项 2026-10-06 设计 §二):超阈值工具结果正文落盘 + 句柄。
|
||||
// 与 src-tauri 审计侧 D2a(tool_result_offload) 同构;base 未注入时降级为纯摘要(零行为变化)。
|
||||
pub mod tool_offload;
|
||||
|
||||
use provider::LlmProvider;
|
||||
use reqwest::Client;
|
||||
|
||||
// df-ai-core 直接暴露,供需要直接引用 trait crate 的下游(可选)。
|
||||
pub use df_ai_core;
|
||||
|
||||
/// 构建 Provider 共用的 reqwest Client(OpenAI/Anthropic Provider::new DRY)。
|
||||
///
|
||||
/// connect_timeout 30s 防连接阶段静默 hang(网络静默断);不设总 timeout——reqwest 的
|
||||
/// `.timeout()` 会限制整个响应 body 时长,流式长生成任务会被误砍。读取阶段中途静默由上层
|
||||
/// stream_llm 的 idle timeout 兜底。builder 失败回退 `Client::new()` 保可用(warn 日志)。
|
||||
pub(crate) fn build_provider_client() -> Client {
|
||||
Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
// 连接池收敛:idle 连接 15s 弃用 + 每主机最多存 2 条。reqwest 默认 idle 90s 且
|
||||
// 无上限,服务端/前置 keepalive 更短时复用陈旧连接首包即被 RST(ConnectionReset
|
||||
// 10054),呈现「隔几十秒一发就失败」的间歇形态;15s 保守低于常见网关 60s keepalive。
|
||||
.pool_idle_timeout(std::time::Duration::from_secs(15))
|
||||
.pool_max_idle_per_host(2)
|
||||
.build()
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!("reqwest builder 失败,回退默认 client: {}", e);
|
||||
Client::new()
|
||||
})
|
||||
}
|
||||
|
||||
/// 流式 send 阶段 reqwest 错误的谓词快照。
|
||||
///
|
||||
/// reqwest::Error 无公开构造器,抽谓词结构体过纯函数,单测才能锁定分类
|
||||
/// (send 阶段 ConnectionReset → 可重试类词)。
|
||||
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct SendErrPreds {
|
||||
pub is_timeout: bool,
|
||||
pub is_connect: bool,
|
||||
pub is_decode: bool,
|
||||
pub is_body: bool,
|
||||
pub is_request: bool,
|
||||
pub is_builder: bool,
|
||||
}
|
||||
|
||||
impl SendErrPreds {
|
||||
pub(crate) fn of(e: &reqwest::Error) -> Self {
|
||||
Self {
|
||||
is_timeout: e.is_timeout(),
|
||||
is_connect: e.is_connect(),
|
||||
is_decode: e.is_decode(),
|
||||
is_body: e.is_body(),
|
||||
is_request: e.is_request(),
|
||||
is_builder: e.is_builder(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 流式 send 阶段错误分类,返回类词(置于 bail 串首位供下游按串分类)。
|
||||
///
|
||||
/// reqwest 0.12 的 Display 只给 "error sending request for url",不含 source 关键词,
|
||||
/// 纯 Display 串分类必得 unknown → Fatal 零重试。此处用谓词确定性给类词:
|
||||
/// timeout/connect/decode/body 各归本类;is_request(非 builder)覆盖陈旧池化连接
|
||||
/// 首包 RST(ConnectionReset 10054)等传输层发送错,归 connect(与 retry 的
|
||||
/// is_reqwest_error_retryable 对 is_request 可重试口径一致);builder 构造错归
|
||||
/// request(不可重试)。类词须落在 stream_recv classify_status_or_class 传输词表内,
|
||||
/// 改任一侧需同步。
|
||||
pub(crate) fn send_error_class(p: SendErrPreds) -> &'static str {
|
||||
if p.is_timeout {
|
||||
"timeout"
|
||||
} else if p.is_connect {
|
||||
"connect"
|
||||
} else if p.is_decode {
|
||||
"decode"
|
||||
} else if p.is_body {
|
||||
"body"
|
||||
} else if p.is_request && !p.is_builder {
|
||||
"connect"
|
||||
} else {
|
||||
"request"
|
||||
}
|
||||
}
|
||||
|
||||
/// 按 provider_type 构建 LLM Provider 实例(统一选择逻辑,消除调用方重复 match)
|
||||
///
|
||||
/// `anthropic` 协议走 AnthropicCompatProvider(GLM 订阅端点 / Claude 官方),
|
||||
/// 其余(openai / glm / deepseek 等 OpenAI 兼容)走 OpenAICompatProvider。
|
||||
/// 调用方(AI Chat 的 run_agentic_loop、df-nodes 的 AiNode)统一引用此工厂,
|
||||
/// 新增 provider 只改这一处。
|
||||
///
|
||||
/// 未知 provider_type 无法走显式报错(工厂签名不动,调用方横跨 src-tauri/df-nodes),
|
||||
/// 兜底 OpenAI 兼容协议但 warn 留痕,不再静默——协议错配的请求失败可溯源到配置项。
|
||||
/// anthropic_family 口径与 prompt.rs cache_split / chat.rs needs_url_prefetch 对齐。
|
||||
pub fn build_provider(
|
||||
provider_type: &str,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
model: &str,
|
||||
) -> Box<dyn LlmProvider> {
|
||||
match provider_type {
|
||||
"anthropic" | "anthropic_compat" => Box::new(
|
||||
anthropic_compat::AnthropicCompatProvider::new(base_url, api_key, model),
|
||||
),
|
||||
"openai_compat" | "openai" | "glm" | "deepseek" => {
|
||||
Box::new(openai_compat::OpenAICompatProvider::new(
|
||||
base_url, api_key, model,
|
||||
))
|
||||
}
|
||||
unknown => {
|
||||
tracing::warn!(
|
||||
"未知 provider_type {:?},按 OpenAI 兼容协议兜底;请检查 ai_providers 配置的 provider_type",
|
||||
unknown
|
||||
);
|
||||
Box::new(openai_compat::OpenAICompatProvider::new(
|
||||
base_url, api_key, model,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//! MemoryAdapter trait — 上下文记忆层抽象
|
||||
//!
|
||||
//! 将 ContextManager 的核心接口提取为 trait,支持多种记忆后端:
|
||||
//! - InMemoryAdapter(当前 ContextManager 的包装,默认实现)
|
||||
//! - VectorMemoryAdapter(未来:接入向量检索,支持语义记忆)
|
||||
//!
|
||||
//! 使用方式:`Box<dyn MemoryAdapter>` 替代 `ContextManager`。
|
||||
|
||||
use crate::context::ContextManager;
|
||||
use crate::context_helpers::{ContextConfig, TrackedMessage};
|
||||
use crate::provider::ChatMessage;
|
||||
|
||||
/// 上下文记忆层抽象接口
|
||||
///
|
||||
/// 覆盖 agentic loop / IPC 最常用的 15 个方法。
|
||||
/// 保留 `ContextManager` 作为默认实现(`InMemoryAdapter`),行为零变化。
|
||||
pub trait MemoryAdapter: Send + Sync {
|
||||
// ── 写入 ──
|
||||
fn push(&mut self, msg: ChatMessage);
|
||||
fn clear(&mut self);
|
||||
fn insert_at(&mut self, index: usize, message: ChatMessage);
|
||||
fn replace_tool_result_content(&mut self, tool_call_id: &str, new_content: &str) -> bool;
|
||||
fn pop_last_assistant_round(&mut self) -> bool;
|
||||
fn truncate_after_user_message(&mut self, target_content: &str) -> Result<usize, ()>;
|
||||
fn replace_last_active_user_content(&mut self, new_content: &str) -> Result<(), ()>;
|
||||
|
||||
// ── 读取 ──
|
||||
fn build_for_request(&self, sys_tokens: u32) -> (Vec<ChatMessage>, bool);
|
||||
fn all_messages_clone(&self) -> Vec<ChatMessage>;
|
||||
fn len(&self) -> usize;
|
||||
fn is_empty(&self) -> bool;
|
||||
fn history_tokens(&self) -> u32;
|
||||
fn budget_limit(&self) -> u32;
|
||||
fn config(&self) -> &ContextConfig;
|
||||
|
||||
// ── 压缩 ──
|
||||
fn compress_old_messages(&mut self, end: usize) -> Vec<ChatMessage>;
|
||||
fn has_compressible_messages(&self, protect_start: usize) -> bool;
|
||||
fn is_compressing(&self) -> bool;
|
||||
fn set_compressing(&mut self, v: bool);
|
||||
|
||||
// ── 生命周期 ──
|
||||
fn restore_from_messages(&mut self, messages: Vec<ChatMessage>);
|
||||
fn take_topic_marker(&mut self) -> Option<String>;
|
||||
fn messages_mut(&mut self) -> &mut [TrackedMessage];
|
||||
fn iter(&self) -> Box<dyn Iterator<Item = &ChatMessage> + '_>;
|
||||
}
|
||||
|
||||
/// ContextManager 作为默认 MemoryAdapter 实现
|
||||
impl MemoryAdapter for ContextManager {
|
||||
fn push(&mut self, msg: ChatMessage) { self.push(msg); }
|
||||
fn clear(&mut self) { self.clear(); }
|
||||
fn insert_at(&mut self, index: usize, message: ChatMessage) { self.insert_at(index, message); }
|
||||
fn replace_tool_result_content(&mut self, tool_call_id: &str, new_content: &str) -> bool {
|
||||
self.replace_tool_result_content(tool_call_id, new_content)
|
||||
}
|
||||
fn pop_last_assistant_round(&mut self) -> bool { self.pop_last_assistant_round() }
|
||||
fn truncate_after_user_message(&mut self, target_content: &str) -> Result<usize, ()> {
|
||||
self.truncate_after_user_message(target_content)
|
||||
}
|
||||
fn replace_last_active_user_content(&mut self, new_content: &str) -> Result<(), ()> {
|
||||
self.replace_last_active_user_content(new_content)
|
||||
}
|
||||
|
||||
fn build_for_request(&self, sys_tokens: u32) -> (Vec<ChatMessage>, bool) {
|
||||
self.build_for_request(sys_tokens)
|
||||
}
|
||||
fn all_messages_clone(&self) -> Vec<ChatMessage> { self.all_messages_clone() }
|
||||
fn len(&self) -> usize { self.len() }
|
||||
fn is_empty(&self) -> bool { self.is_empty() }
|
||||
fn history_tokens(&self) -> u32 { self.history_tokens() }
|
||||
fn budget_limit(&self) -> u32 { self.budget_limit() }
|
||||
fn config(&self) -> &ContextConfig { self.config() }
|
||||
|
||||
fn compress_old_messages(&mut self, end: usize) -> Vec<ChatMessage> { self.compress_old_messages(end) }
|
||||
fn has_compressible_messages(&self, protect_start: usize) -> bool { self.has_compressible_messages(protect_start) }
|
||||
fn is_compressing(&self) -> bool { self.is_compressing() }
|
||||
fn set_compressing(&mut self, v: bool) { self.set_compressing(v); }
|
||||
|
||||
fn restore_from_messages(&mut self, messages: Vec<ChatMessage>) { self.restore_from_messages(messages); }
|
||||
fn take_topic_marker(&mut self) -> Option<String> { self.take_topic_marker() }
|
||||
fn messages_mut(&mut self) -> &mut [TrackedMessage] { self.messages_mut() }
|
||||
fn iter(&self) -> Box<dyn Iterator<Item = &ChatMessage> + '_> {
|
||||
Box::new(self.iter())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
//! 厂商模型列表拉取。
|
||||
//!
|
||||
//! 按 `provider_type` 分派拉取厂商模型列表,过滤非 chat 模型,返回模型名 Vec。
|
||||
//! `fetch_and_probe` 在拉取基础上对每个模型名调 `model_probe::probe` 探测出完整 `ModelConfig`。
|
||||
//!
|
||||
//! 协议分派:
|
||||
//! - `openai_compat` → `GET {base_url}/v1/models`(Bearer 鉴权)
|
||||
//! - `anthropic_compat` → `GET {base_url}/v1/models`(x-api-key + anthropic-version 鉴权)
|
||||
//! - 其他 → `UnsupportedProvider` 错误
|
||||
//!
|
||||
//! 本模块仅含网络 IO 入口;URL 拼接 / 噪音过滤 / 响应反序列化等纯逻辑见
|
||||
//! [`model_fetch_helpers`]。
|
||||
//!
|
||||
//! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §5
|
||||
//! (设计 §5.1 端点表 + §5.3 URL 智能拼接 + §5.4 噪音过滤)。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use df_ai_core::model::ModelConfig;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::model_fetch_helpers::{build_models_url, filter_chat_models, ModelsList};
|
||||
use crate::model_probe::probe;
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 公共入口
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// HTTP 请求超时(秒)。模型列表接口响应小、无需长超时。
|
||||
const FETCH_TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
/// 拉取厂商模型列表,返回已过滤噪音的模型名 Vec。
|
||||
///
|
||||
/// 协议分派(见模块文档)。错误信息含 `provider_type` + HTTP 状态/原因,便于前端友好提示。
|
||||
///
|
||||
/// 不真实调测:本函数本身是网络 IO,单测只覆盖 URL 拼接 / 噪音过滤等纯逻辑;
|
||||
/// 集成验证留给阶段 5 的 IPC `ai_fetch_models`(用户在 Settings 点「测试连接」触发)。
|
||||
pub async fn fetch_model_names(
|
||||
provider_type: &str,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
) -> Result<Vec<String>> {
|
||||
match provider_type {
|
||||
"openai_compat" => fetch_openai_compat(base_url, api_key).await,
|
||||
"anthropic_compat" => fetch_anthropic_compat(base_url, api_key).await,
|
||||
other => Err(anyhow!(
|
||||
"拉取模型列表失败:不支持的 provider_type={other}(仅支持 openai_compat / anthropic_compat)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 拉取模型列表 + 对每个模型名调 `model_probe::probe` 探测出完整 `ModelConfig`。
|
||||
///
|
||||
/// 探测为纯 CPU(无网络),不会因单模型探测失败中断整体 — 探测对任意输入都有兜底(见 model_probe)。
|
||||
pub async fn fetch_and_probe(
|
||||
provider_type: &str,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
) -> Result<Vec<ModelConfig>> {
|
||||
let names = fetch_model_names(provider_type, base_url, api_key).await?;
|
||||
Ok(names.into_iter().map(|n| probe(&n)).collect())
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 协议分派实现
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// OpenAI 兼容(GLM / DeepSeek / Kimi / 通义 / 中转站):`GET /v1/models`,Bearer 鉴权。
|
||||
async fn fetch_openai_compat(base_url: &str, api_key: &str) -> Result<Vec<String>> {
|
||||
let url = build_models_url(base_url);
|
||||
let client = build_client()?;
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(api_key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| map_network_error("openai_compat", &url, e))?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
return Err(map_status_error("openai_compat", status, &url));
|
||||
}
|
||||
|
||||
// OpenAI 响应:`{data:[{id, owned_by, ...}]}`。中转站通常同构。
|
||||
// 不用 resp.json():reqwest::Error::Decode 的 Display 吞 serde 详情(只给 "error decoding
|
||||
// response body"),SenseNova 等厂商解析失败时无法定位根因。改 text() + serde_json::from_str,
|
||||
// 解析失败时 serde_json::Error 含具体 field/type/position;再叠加宽松 Value fallback 兜底。
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| anyhow!("openai_compat 读取响应体失败({url}):{e}"))?;
|
||||
|
||||
Ok(filter_chat_models(parse_models_compat("openai_compat", &url, &body)?))
|
||||
}
|
||||
|
||||
/// Anthropic 兼容(Claude 官方 / GLM 订阅端点):`GET /v1/models`,x-api-key + anthropic-version 鉴权。
|
||||
async fn fetch_anthropic_compat(base_url: &str, api_key: &str) -> Result<Vec<String>> {
|
||||
let url = build_models_url(base_url);
|
||||
let client = build_client()?;
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("x-api-key", api_key)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| map_network_error("anthropic_compat", &url, e))?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
return Err(map_status_error("anthropic_compat", status, &url));
|
||||
}
|
||||
|
||||
// Anthropic 响应:`{data:[{id, display_name, type, ...}]}`(has_more 分页字段忽略)。
|
||||
// 兼容兜底:`{models:[{name, ...}]}`(Ollama 风格,理论 anthropic_compat 不会命中,
|
||||
// 但中转站行为不可控,用 `#[serde(alias)]` 零成本兜底 — 见 issues)。
|
||||
// 与 openai_compat 同:text() + 严格 serde + Value 宽松 fallback,见 parse_models_compat。
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| anyhow!("anthropic_compat 读取响应体失败({url}):{e}"))?;
|
||||
|
||||
Ok(filter_chat_models(parse_models_compat("anthropic_compat", &url, &body)?))
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 响应解析(text → 严格 serde → Value 宽松 fallback)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 响应体诊断片段最大字符数。完整 body 可能巨大,日志只取前缀定位结构。
|
||||
const BODY_DIAGNOSTIC_CHARS: usize = 200;
|
||||
|
||||
/// 解析厂商 `/v1/models` 响应体,返回模型 id 列表(过滤前)。
|
||||
///
|
||||
/// 三层解析(诊断优先,兜底保成功):
|
||||
/// 1. **严格**:`serde_json::from_str::<ModelsList>` — 标准结构命中,错误信息含具体
|
||||
/// field/type/position(serde_json::Error Display 自带 line/column,不丢 detail)。
|
||||
/// 2. **宽松 fallback**:`serde_json::Value` 解析 → 取 `data` / `models` 任一数组 →
|
||||
/// 遍历项取 `id` / `name` 字符串。容错厂商额外字段、类型变体(如 id 漏成 number)。
|
||||
/// 3. **诊断错误**:严格 + 宽松都失败时,返回含 HTTP 标识 + serde detail + body 前缀
|
||||
/// 的友好错误,而非 reqwest 默认 "error decoding response body"。
|
||||
///
|
||||
/// 注:fallback 只取 id/name(模型名),丢弃 ModelEntry 上的其他字段 — 厂商变体下
|
||||
/// 我们关心的就是模型名,ModelsList 本身也只消费 id/name,语义对齐。
|
||||
fn parse_models_compat(provider_type: &str, url: &str, body: &str) -> Result<Vec<String>> {
|
||||
// 1) 严格解析(标准结构,serde 错误 detail 完整)。
|
||||
match serde_json::from_str::<ModelsList>(body) {
|
||||
Ok(list) => return Ok(list.into_ids()),
|
||||
Err(strict_err) => {
|
||||
// 2) 宽松 Value fallback — 不依赖 ModelsList 结构,容错厂商变体。
|
||||
if let Some(ids) = parse_ids_loose(body) {
|
||||
return Ok(ids);
|
||||
}
|
||||
// 3) 双双失败:叠 HTTP 标识 + serde detail + body 前缀诊断。
|
||||
return Err(anyhow!(
|
||||
"{provider_type} 响应解析失败({url}):{strict_err} | body 前缀:{}",
|
||||
body_preview(body)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 用 `serde_json::Value` 宽松提取模型 id/name。失败(非 JSON / 无 data / 无 id)返回 None。
|
||||
///
|
||||
/// 取数组字段优先级:`data`(OpenAI/Anthropic)→ `models`(Ollama 风格 alias)。
|
||||
/// 项里取 `id` → 兜底 `name`,只接受字符串值(number/bool 等跳过)。
|
||||
fn parse_ids_loose(body: &str) -> Option<Vec<String>> {
|
||||
let val: Value = serde_json::from_str(body).ok()?;
|
||||
let obj = val.as_object()?;
|
||||
// 任一存在即取;data 优先(标准结构)。
|
||||
let arr = obj.get("data").or_else(|| obj.get("models"))?;
|
||||
let arr = arr.as_array()?;
|
||||
let mut ids = Vec::with_capacity(arr.len());
|
||||
for item in arr {
|
||||
let id = item
|
||||
.get("id")
|
||||
.or_else(|| item.get("name"))
|
||||
.and_then(|v| v.as_str());
|
||||
if let Some(id) = id {
|
||||
ids.push(id.to_string());
|
||||
}
|
||||
}
|
||||
Some(ids)
|
||||
}
|
||||
|
||||
/// body 前缀诊断(截断 + 控制字符占位,避免换行/制表符污染日志单行)。
|
||||
fn body_preview(body: &str) -> String {
|
||||
let prefix: String = body.chars().take(BODY_DIAGNOSTIC_CHARS).collect();
|
||||
if prefix.chars().all(|c| c.is_control()) && !prefix.is_empty() {
|
||||
// 整段控制字符(二进制?)→ 给长度提示而非乱码。
|
||||
return format!("<非文本 body,长度 {}>", body.len());
|
||||
}
|
||||
let truncated = body.chars().count() > BODY_DIAGNOSTIC_CHARS;
|
||||
// 把控制字符(换行/制表等)压成空格,保持日志单行可读。
|
||||
let cleaned: String = prefix
|
||||
.chars()
|
||||
.map(|c| if c.is_control() { ' ' } else { c })
|
||||
.collect();
|
||||
if truncated {
|
||||
format!("{cleaned}…")
|
||||
} else {
|
||||
cleaned
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// HTTP 客户端 + 错误映射
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
fn build_client() -> Result<reqwest::Client> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
|
||||
.build()
|
||||
.map_err(|e| anyhow!("构建 HTTP 客户端失败:{e}"))
|
||||
}
|
||||
|
||||
/// 网络错误(连接拒绝 / DNS / 超时)→ 友好提示,标注 provider_type + url 便于排查。
|
||||
fn map_network_error(provider_type: &str, url: &str, e: reqwest::Error) -> anyhow::Error {
|
||||
if e.is_timeout() {
|
||||
anyhow!("{provider_type} 拉取模型列表超时({url},>{FETCH_TIMEOUT_SECS}s)— 检查 base_url 可达性")
|
||||
} else {
|
||||
anyhow!("{provider_type} 拉取模型列表网络错误({url}):{e}")
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP 状态错误 → 按 401/403/404/5xx 分类友好提示(对齐设计 §5.5 失败降级)。
|
||||
fn map_status_error(provider_type: &str, status: reqwest::StatusCode, url: &str) -> anyhow::Error {
|
||||
if status.as_u16() == 401 || status.as_u16() == 403 {
|
||||
anyhow!("{provider_type} 鉴权失败({status}, {url})— 检查 api_key 是否正确 / 是否有该接口权限")
|
||||
} else if status.as_u16() == 404 {
|
||||
anyhow!("{provider_type} 端点不存在(404, {url})— 该厂商可能不支持模型列表拉取,请手动输入模型名")
|
||||
} else if status.is_server_error() {
|
||||
anyhow!("{provider_type} 厂商服务异常({status}, {url})— 稍后重试或手动输入模型名")
|
||||
} else {
|
||||
anyhow!("{provider_type} 拉取模型列表失败(HTTP {status}, {url})")
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 测试(协议分派:不支持类型直接报错,不触网)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── fetch_model_names 协议分派:不支持类型直接报错(不触网) ──
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_unsupported_provider_errors_without_network() {
|
||||
// ollama / glm 等非 openai_compat / anthropic_compat 类型 → 直接错误,不发请求
|
||||
let err = fetch_model_names("ollama", "http://localhost:11434", "k")
|
||||
.await
|
||||
.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("ollama"), "err={msg}");
|
||||
assert!(msg.contains("provider_type"), "err={msg}");
|
||||
}
|
||||
|
||||
// ── parse_models_compat:严格 / fallback / 诊断三层 ──
|
||||
|
||||
#[test]
|
||||
fn parse_strict_openai_format() {
|
||||
// 标准 OpenAI 结构 → 严格解析命中,不进 fallback
|
||||
let body = r#"{"data":[{"id":"gpt-4o","owned_by":"openai"},{"id":"gpt-4o-mini"}]}"#;
|
||||
let ids = parse_models_compat("openai_compat", "http://x/v1/models", body).unwrap();
|
||||
assert_eq!(ids, vec!["gpt-4o", "gpt-4o-mini"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_loose_fallback_on_unknown_field_type_variant() {
|
||||
// 厂商变体:data 项里多了非标准字段、且某项漏 id → 严格可能仍过(serde default),
|
||||
// 此用例构造严格失败 + 宽松应成功:id 字段为 number(非字符串)致 ModelEntry serde 失败。
|
||||
// 宽松 fallback 应:跳过 number id,保留 string id。
|
||||
let body = r#"{"data":[{"id":12345},{"id":"glm-4-flash"}]}"#;
|
||||
// 严格 ModelsList 的 id: Option<String>,number 12345 无法反序列化为 String → 失败
|
||||
let ids = parse_models_compat("openai_compat", "http://x/v1/models", body).unwrap();
|
||||
assert_eq!(ids, vec!["glm-4-flash"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_loose_fallback_via_models_alias() {
|
||||
// 严格解析缺 data 字段时进 fallback,走 models alias 取 name
|
||||
let body = r#"{"models":[{"name":"llama3:8b"},{"name":"qwen2:7b"}]}"#;
|
||||
let ids = parse_models_compat("openai_compat", "http://x/v1/models", body).unwrap();
|
||||
assert_eq!(ids, vec!["llama3:8b", "qwen2:7b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_loose_fallback_tolerates_extra_top_level_fields() {
|
||||
// 宽松 fallback 应容错顶层额外字段、非 id 项(只关心 data[].id/name)
|
||||
let body = r#"{"object":"list","data":[{"id":"deepseek-chat","object":"model"},{"id":"deepseek-coder"}],"supported_ids":["x"]}"#;
|
||||
let ids = parse_models_compat("openai_compat", "http://x/v1/models", body).unwrap();
|
||||
assert_eq!(ids, vec!["deepseek-chat", "deepseek-coder"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_diagnostic_error_has_serde_detail_and_body_prefix() {
|
||||
// 完全无法解析(非 JSON)→ 严格 + 宽松双失败 → 错误含 serde detail + body 前缀 + HTTP 标识
|
||||
let body = "this is not json at all {{{";
|
||||
let err = parse_models_compat("openai_compat", "http://x/v1/models", body).unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
// provider_type 标识
|
||||
assert!(msg.contains("openai_compat"), "err={msg}");
|
||||
// url 便于定位
|
||||
assert!(msg.contains("http://x/v1/models"), "err={msg}");
|
||||
// serde detail(serde_json 错误含 line/column 或 expected 字样)
|
||||
assert!(
|
||||
msg.contains("line") || msg.contains("column") || msg.contains("expected"),
|
||||
"err={msg}"
|
||||
);
|
||||
// body 前缀诊断片段
|
||||
assert!(msg.contains("this is not json"), "err={msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_diagnostic_truncates_long_body() {
|
||||
// 超长 body → 前缀截断(… 标记),不整段灌进错误信息
|
||||
let long_id = "a".repeat(500);
|
||||
let body = format!(r#"{{"garbage":"{long_id}""#); // 缺尾 → 非 JSON
|
||||
let err = parse_models_compat("openai_compat", "http://x/v1/models", &body).unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("…"), "长 body 应截断(err={})\n{}", msg.len(), msg);
|
||||
// 诊断片段不应超过 BODY_DIAGNOSTIC_CHARS + 容差
|
||||
assert!(
|
||||
msg.len() < long_id.len(),
|
||||
"错误信息不应含完整 500 字符 body"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_diagnostic_empty_body() {
|
||||
// 空 body → 双失败,错误信息不 panic、含 provider 标识
|
||||
let err = parse_models_compat("openai_compat", "http://x/v1/models", "").unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("openai_compat"), "err={msg}");
|
||||
assert!(msg.contains("解析失败"), "err={msg}");
|
||||
}
|
||||
|
||||
// ── parse_ids_loose:边界 ──
|
||||
|
||||
#[test]
|
||||
fn parse_ids_loose_returns_none_on_non_json() {
|
||||
assert!(parse_ids_loose("not json").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ids_loose_returns_none_on_missing_data_field() {
|
||||
// 合法 JSON 但无 data/models → None(parse_models_compat 会进而报诊断错误)
|
||||
assert!(parse_ids_loose(r#"{"foo":"bar"}"#).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ids_loose_data_not_array_returns_none() {
|
||||
// data 存在但非数组 → None
|
||||
assert!(parse_ids_loose(r#"{"data":"not-an-array"}"#).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ids_loose_skips_non_string_id() {
|
||||
// id 为 number/null/object → 跳过,只留字符串 id
|
||||
let body = r#"{"data":[{"id":1},{"id":null},{"id":"keep-me"},{"name":"named"}]}"#;
|
||||
let ids = parse_ids_loose(body).unwrap();
|
||||
assert_eq!(ids, vec!["keep-me", "named"]);
|
||||
}
|
||||
|
||||
// ── body_preview:控制字符 + 截断 ──
|
||||
|
||||
#[test]
|
||||
fn body_preview_replaces_control_chars_with_space() {
|
||||
// 换行/制表压成空格,保持日志单行
|
||||
let preview = body_preview("line1\nline2\tcol");
|
||||
assert!(!preview.contains('\n'), "preview={preview}");
|
||||
assert!(!preview.contains('\t'), "preview={preview}");
|
||||
assert!(preview.contains("line1"), "preview={preview}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_preview_truncates_with_ellipsis() {
|
||||
let body = "abcdefghij".repeat(100); // 1000 chars
|
||||
let preview = body_preview(&body);
|
||||
assert!(preview.ends_with('…'), "preview should end with ellipsis");
|
||||
// 不应含完整 body
|
||||
assert!(preview.len() < body.len());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
//! 厂商模型列表拉取 — 纯逻辑(无 IO)。
|
||||
//!
|
||||
//! 从 `model_fetch.rs` 抽离的纯函数 / 类型:URL 拼接、噪音过滤、响应反序列化。
|
||||
//! 这些逻辑无网络依赖,可独立单测;主文件 `model_fetch.rs` 保留有 IO 的拉取入口。
|
||||
//!
|
||||
//! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §5.3 / §5.4。
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// URL 智能拼接
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 拼接模型列表端点 URL。
|
||||
///
|
||||
/// 规则(设计 §5.3,base_url 先去尾 `/`):
|
||||
/// - 以 `/chat/completions` 结尾 → 替换为 `/models`(用户把对话端点当 base_url 填了)
|
||||
/// - 以 `/v1` / `/v4` 结尾 → 直接补 `/models`(避免 `/v1/v1/models`)
|
||||
/// - 以 `/models` 结尾 → 原样用(用户已填完整端点)
|
||||
/// - 以 `/api` 结尾 → 补 `/models`(Ollama 风格 base,anthropic_compat 兜底用)
|
||||
/// - 其余 → 补 `/v1/models`(最常见:用户填 `https://open.bigmodel.cn/api/paas/v4`)
|
||||
pub fn build_models_url(base_url: &str) -> String {
|
||||
let url = base_url.trim_end_matches('/');
|
||||
if url.ends_with("/chat/completions") {
|
||||
url.replace("/chat/completions", "/models")
|
||||
} else if url.ends_with("/v1") || url.ends_with("/v4") {
|
||||
format!("{url}/models")
|
||||
} else if url.ends_with("/models") {
|
||||
url.to_string()
|
||||
} else if url.ends_with("/api") {
|
||||
format!("{url}/models")
|
||||
} else {
|
||||
format!("{url}/v1/models")
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 噪音过滤(剔除非 chat 模型)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 判断是否为非 chat 模型(应从列表中剔除)。
|
||||
///
|
||||
/// 规则(设计 §5.4 + 合理扩展,见 issues):
|
||||
/// - 图片生成:dall-e / midjourney / stable-diffusion / imagen / sensenova-u1 / infographic / image-generation
|
||||
/// - 语音:tts / whisper / audio / speech / voice(语音合成/识别)
|
||||
/// - 实时:realtime(OpenAI Realtime API 语音对话,非 chat completions)
|
||||
/// - 转写:transcribe / transcription
|
||||
/// - 审查:moderation(内容审查模型)
|
||||
/// - 搜索:davinci-search / search(旧 GPT 搜索变种)
|
||||
///
|
||||
/// embedding 设计 §5.4 注释「保留(知识库需要)」— 此处遵循设计保留 embedding,
|
||||
/// 不在 `is_non_chat_model` 剔除(知识库 embedding 路由用得着)。
|
||||
///
|
||||
/// 注:`sensenova-u1` 用厂商前缀限定而非裸 `u1`,避免误伤未来含 `u1` 词素的 chat 模型
|
||||
/// (SenseNova U1 系列走 `/v1/images/generations` 端点,非 chat completions)。
|
||||
pub fn is_non_chat_model(id: &str) -> bool {
|
||||
let id = id.to_lowercase();
|
||||
// 图片生成
|
||||
id.contains("dall-e")
|
||||
|| id.contains("midjourney")
|
||||
|| id.contains("stable-diffusion")
|
||||
|| id.contains("imagen")
|
||||
// SenseNova U1 系列(图像生成,如 sensenova-u1-fast,走 /v1/images/generations 非 chat)
|
||||
|| id.contains("sensenova-u1")
|
||||
// 通用图像生成命名(infographic 海报生成 / image-generation 直白命名)
|
||||
|| id.contains("infographic")
|
||||
|| id.contains("image-generation")
|
||||
// 语音(tts 合成 / whisper 识别 / 通用 audio / speech / voice)
|
||||
|| id.contains("tts")
|
||||
|| id.contains("whisper")
|
||||
|| id.contains("audio")
|
||||
|| id.contains("speech")
|
||||
|| id.contains("voice")
|
||||
// 实时语音对话(OpenAI Realtime API,走独立协议非 chat completions)
|
||||
|| id.contains("realtime")
|
||||
// 转写
|
||||
|| id.contains("transcribe")
|
||||
|| id.contains("transcription")
|
||||
// 内容审查
|
||||
|| id.contains("moderation")
|
||||
// 旧 GPT 搜索变种(davinci-search 覆盖旧 GPT 搜索;裸 -search 误伤合法 search-augmented chat
|
||||
// 模型如 gpt-4o-search-preview/qwen-search/glm-4-search,已移除)
|
||||
|| id.contains("davinci-search")
|
||||
}
|
||||
|
||||
/// 过滤噪音:剔除非 chat 模型 + 去重(中转站可能返回重复) + 去空名。
|
||||
pub fn filter_chat_models(ids: Vec<String>) -> Vec<String> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut out = Vec::with_capacity(ids.len());
|
||||
for id in ids {
|
||||
let trimmed = id.trim();
|
||||
if trimmed.is_empty() || is_non_chat_model(trimmed) {
|
||||
continue;
|
||||
}
|
||||
if seen.insert(trimmed.to_string()) {
|
||||
out.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 响应反序列化
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 厂商 `/v1/models` 响应统一反序列化。
|
||||
///
|
||||
/// 兼容三种结构(用 `#[serde(alias)]`):
|
||||
/// - OpenAI / Anthropic:`{data:[{id, ...}]}`
|
||||
/// - Ollama:`{models:[{name, ...}]}`(anthropic_compat 走中转站时兜底)
|
||||
/// - 单字段别名:`id` / `name` 都认
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ModelsList {
|
||||
#[serde(default)]
|
||||
data: Vec<ModelEntry>,
|
||||
#[serde(default, alias = "models")]
|
||||
data_alt: Vec<ModelEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ModelEntry {
|
||||
#[serde(default, alias = "name")]
|
||||
id: Option<String>,
|
||||
}
|
||||
|
||||
impl ModelsList {
|
||||
/// 合并 `data` 与 `models`(alias)字段,提取所有模型 id/name,丢弃 None。
|
||||
pub(crate) fn into_ids(self) -> Vec<String> {
|
||||
self.data
|
||||
.into_iter()
|
||||
.chain(self.data_alt)
|
||||
.filter_map(|e| e.id)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 测试(纯逻辑/字符串处理,不真实调 API)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── is_non_chat_model:search-augmented chat 模型不应误剔(裸 -search 已移除) ──
|
||||
#[test]
|
||||
fn is_non_chat_model_keeps_search_augmented_chat() {
|
||||
// search-augmented chat 模型(对话端点可调)不应被 -search 子串误剔
|
||||
assert!(!is_non_chat_model("gpt-4o-search-preview"));
|
||||
assert!(!is_non_chat_model("qwen-search"));
|
||||
assert!(!is_non_chat_model("glm-4-search-preview"));
|
||||
// 旧 GPT 搜索变种仍剔(davinci-search 覆盖)
|
||||
assert!(is_non_chat_model("text-davinci-search-001"));
|
||||
}
|
||||
|
||||
// ── build_models_url:尾 / 处理 ──
|
||||
|
||||
#[test]
|
||||
fn build_url_strips_trailing_slash() {
|
||||
// 用户填 `https://api.x.com/v1/`(尾 /)→ 去 / 后命中 /v1 分支 → 补 /models
|
||||
assert_eq!(
|
||||
build_models_url("https://api.x.com/v1/"),
|
||||
"https://api.x.com/v1/models"
|
||||
);
|
||||
// 多个尾 /
|
||||
assert_eq!(
|
||||
build_models_url("https://api.x.com/v4//"),
|
||||
"https://api.x.com/v4/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_url_v1_suffix_appends_models() {
|
||||
assert_eq!(
|
||||
build_models_url("https://api.openai.com/v1"),
|
||||
"https://api.openai.com/v1/models"
|
||||
);
|
||||
// 不应产生 /v1/v1/models
|
||||
assert!(!build_models_url("https://api.openai.com/v1").contains("/v1/v1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_url_v4_suffix_appends_models() {
|
||||
// GLM:`https://open.bigmodel.cn/api/paas/v4`
|
||||
assert_eq!(
|
||||
build_models_url("https://open.bigmodel.cn/api/paas/v4"),
|
||||
"https://open.bigmodel.cn/api/paas/v4/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_url_bare_base_appends_v1_models() {
|
||||
// 用户只填根:`https://api.deepseek.com` → 补 /v1/models
|
||||
assert_eq!(
|
||||
build_models_url("https://api.deepseek.com"),
|
||||
"https://api.deepseek.com/v1/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_url_chat_completions_replaced() {
|
||||
// 用户误把对话端点当 base_url
|
||||
assert_eq!(
|
||||
build_models_url("https://api.x.com/v1/chat/completions"),
|
||||
"https://api.x.com/v1/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_url_already_models_kept() {
|
||||
// 用户已填完整端点 → 原样用
|
||||
assert_eq!(
|
||||
build_models_url("https://api.x.com/v1/models"),
|
||||
"https://api.x.com/v1/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_url_api_suffix_ollama_style() {
|
||||
// Ollama 风格 base(anthropic_compat 兜底路径)
|
||||
assert_eq!(
|
||||
build_models_url("http://localhost:11434/api"),
|
||||
"http://localhost:11434/api/models"
|
||||
);
|
||||
}
|
||||
|
||||
// ── is_non_chat_model:噪音过滤规则 ──
|
||||
|
||||
#[test]
|
||||
fn non_chat_image_generation_filtered() {
|
||||
assert!(is_non_chat_model("dall-e-3"));
|
||||
assert!(is_non_chat_model("dall-e-2"));
|
||||
assert!(is_non_chat_model("midjourney-v6"));
|
||||
assert!(is_non_chat_model("stable-diffusion-xl"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_chat_sensenova_u1_image_filtered() {
|
||||
// SenseNova U1 系列是图像生成模型(走 /v1/images/generations,非 chat completions),
|
||||
// /v1/models 会返回但不应留为 chat 模型,否则用户选它对话会失败。
|
||||
assert!(is_non_chat_model("sensenova-u1-fast"));
|
||||
assert!(is_non_chat_model("SenseNova-U1-Fast")); // 大小写无关
|
||||
assert!(is_non_chat_model("sensenova-u1-pro"));
|
||||
// 通用图像生成命名也剔
|
||||
assert!(is_non_chat_model("infographic-v1"));
|
||||
assert!(is_non_chat_model("image-generation-latest"));
|
||||
// 对照组:同厂商的 chat 模型 sensenova-6.7-flash-lite 不应被误剔
|
||||
// (它是原生多模态 chat,见 model_probe.rs 预设表)
|
||||
assert!(
|
||||
!is_non_chat_model("sensenova-6.7-flash-lite"),
|
||||
"sensenova-6.7-flash-lite 是 chat 模型,不应被当图像生成剔除"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_chat_speech_filtered() {
|
||||
assert!(is_non_chat_model("tts-1"));
|
||||
assert!(is_non_chat_model("tts-1-hd"));
|
||||
assert!(is_non_chat_model("whisper-1"));
|
||||
assert!(is_non_chat_model("audio-transcribe"));
|
||||
assert!(is_non_chat_model("speech-synth"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_chat_realtime_filtered() {
|
||||
assert!(is_non_chat_model("gpt-4o-realtime"));
|
||||
assert!(is_non_chat_model("gpt-4o-mini-realtime-preview"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_chat_moderation_filtered() {
|
||||
assert!(is_non_chat_model("text-moderation-stable"));
|
||||
assert!(is_non_chat_model("omni-moderation-latest"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_chat_search_variants_filtered() {
|
||||
assert!(is_non_chat_model("text-davinci-search-001"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_models_kept() {
|
||||
// 主流 chat 模型不应被误剔
|
||||
for ok in [
|
||||
"glm-4",
|
||||
"glm-4-flash",
|
||||
"glm-4v",
|
||||
"glm-4-air",
|
||||
"glm-4-plus",
|
||||
"deepseek-chat",
|
||||
"deepseek-coder",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
"claude-3-haiku-20240307",
|
||||
"qwen-plus",
|
||||
"kimi-k2",
|
||||
] {
|
||||
assert!(!is_non_chat_model(ok), "{ok} 不应被当噪音剔除");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedding_kept_per_design() {
|
||||
// 设计 §5.4 明确「embedding 保留(知识库需要)」
|
||||
for emb in ["embedding-3", "text-embedding-3-large", "bge-m3"] {
|
||||
assert!(!is_non_chat_model(emb), "{emb} 按 §5.4 应保留");
|
||||
}
|
||||
}
|
||||
|
||||
// ── filter_chat_models:过滤 + 去重 + 去空 ──
|
||||
|
||||
#[test]
|
||||
fn filter_removes_noise_and_keeps_chat() {
|
||||
let raw = vec![
|
||||
"glm-4-flash".to_string(),
|
||||
"dall-e-3".to_string(), // 噪音
|
||||
"tts-1".to_string(), // 噪音
|
||||
"whisper-1".to_string(), // 噪音
|
||||
"text-moderation-stable".to_string(), // 噪音
|
||||
"glm-4v".to_string(),
|
||||
"gpt-4o".to_string(),
|
||||
];
|
||||
let got = filter_chat_models(raw);
|
||||
assert_eq!(got, vec!["glm-4-flash", "glm-4v", "gpt-4o"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_dedupes() {
|
||||
// 中转站可能返回重复模型名
|
||||
let raw = vec![
|
||||
"glm-4".to_string(),
|
||||
"glm-4".to_string(),
|
||||
"glm-4-flash".to_string(),
|
||||
];
|
||||
let got = filter_chat_models(raw);
|
||||
assert_eq!(got, vec!["glm-4", "glm-4-flash"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_drops_empty_and_whitespace_only() {
|
||||
let raw = vec![
|
||||
"".to_string(),
|
||||
" ".to_string(),
|
||||
"glm-4".to_string(),
|
||||
" glm-4v ".to_string(), // 带空格 → trim 后保留
|
||||
];
|
||||
let got = filter_chat_models(raw);
|
||||
assert_eq!(got, vec!["glm-4", "glm-4v"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_empty_input() {
|
||||
assert!(filter_chat_models(vec![]).is_empty());
|
||||
}
|
||||
|
||||
// ── ModelsList 反序列化:兼容 OpenAI / Anthropic / Ollama 三种结构 ──
|
||||
|
||||
#[test]
|
||||
fn parse_openai_format() {
|
||||
// OpenAI:`{data:[{id, ...}]}`
|
||||
let json = r#"{
|
||||
"data": [
|
||||
{"id": "gpt-4o", "owned_by": "openai"},
|
||||
{"id": "gpt-4o-mini", "owned_by": "openai"}
|
||||
]
|
||||
}"#;
|
||||
let m: ModelsList = serde_json::from_str(json).unwrap();
|
||||
let ids = m.into_ids();
|
||||
assert_eq!(ids, vec!["gpt-4o", "gpt-4o-mini"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_anthropic_format() {
|
||||
// Anthropic:`{data:[{id, display_name, type}], has_more:false}`
|
||||
let json = r#"{
|
||||
"data": [
|
||||
{"id": "claude-3-5-sonnet-20241022", "display_name": "Claude 3.5 Sonnet", "type": "model"},
|
||||
{"id": "claude-3-haiku-20240307", "display_name": "Claude 3 Haiku", "type": "model"}
|
||||
],
|
||||
"has_more": false,
|
||||
"first_id": "claude-3-5-sonnet-20241022",
|
||||
"last_id": "claude-3-haiku-20240307"
|
||||
}"#;
|
||||
let m: ModelsList = serde_json::from_str(json).unwrap();
|
||||
let ids = m.into_ids();
|
||||
assert_eq!(ids, vec!["claude-3-5-sonnet-20241022", "claude-3-haiku-20240307"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ollama_format_via_alias() {
|
||||
// Ollama:`{models:[{name, size}]}` — 走 anthropic_compat 中转站兜底
|
||||
let json = r#"{
|
||||
"models": [
|
||||
{"name": "llama3:8b", "size": 4661214673},
|
||||
{"name": "qwen2:7b", "size": 4436122400}
|
||||
]
|
||||
}"#;
|
||||
let m: ModelsList = serde_json::from_str(json).unwrap();
|
||||
let ids = m.into_ids();
|
||||
assert_eq!(ids, vec!["llama3:8b", "qwen2:7b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_missing_data_field_yields_empty() {
|
||||
// 厂商返回 `{}` 或缺 data 字段 → 空列表,不 panic
|
||||
let json = r#"{}"#;
|
||||
let m: ModelsList = serde_json::from_str(json).unwrap();
|
||||
assert!(m.into_ids().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_entry_missing_id_skipped() {
|
||||
// data 数组项缺 id → 该项跳过
|
||||
let json = r#"{
|
||||
"data": [
|
||||
{"id": "glm-4"},
|
||||
{"owned_by": "x"},
|
||||
{"id": "glm-4v"}
|
||||
]
|
||||
}"#;
|
||||
let m: ModelsList = serde_json::from_str(json).unwrap();
|
||||
let ids = m.into_ids();
|
||||
assert_eq!(ids, vec!["glm-4", "glm-4v"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
//! 模型探测器。
|
||||
//!
|
||||
//! 给定模型名,产出完整 `ModelConfig`(4 维度 + 路由控制 + 探测来源标注)。
|
||||
//!
|
||||
//! 多源探测,高优先源命中即返(短路):
|
||||
//! 1. 内置预设表精确匹配(name 完全相等) → `ProbeSource::PresetTable`
|
||||
//! 2. 内置预设表前缀匹配(前缀 + 分隔符边界) → `ProbeSource::PresetTable`
|
||||
//! 3. 模型名启发式推断(命名模式) → `ProbeSource::Heuristic`
|
||||
//! 4. 默认值兜底(`ModelConfig::with_defaults`) → `ProbeSource::Default`
|
||||
//!
|
||||
//! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §4
|
||||
//! (设计文档 §4.1 写的是"启发式先行 + 预设表合并";本实施按阶段 2 任务规格
|
||||
//! 收敛为"预设优先 > 启发式"链 — 预设表精确数据可信度高于命名猜测,优先短路)。
|
||||
|
||||
use df_ai_core::model::{ModelConfig, ProbeSource};
|
||||
|
||||
// 纯逻辑子模块(预设表加载 / 启发式推断 / 词素判定)抽离至此 —
|
||||
// private mod,crate 内复用,外部经 model_probe::probe 间接访问(对齐 model_fetch_helpers)。
|
||||
use crate::model_probe_helpers::{heuristic_infer, presets};
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 公共入口
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 探测单个模型,返回完整 `ModelConfig`(已填充 `probe_source`)。
|
||||
///
|
||||
/// 多源探测顺序(高优先源命中即返):
|
||||
/// 1. 预设表精确匹配(`model_id` 完全相等,大小写敏感)
|
||||
/// 2. 预设表前缀匹配(`model_id` 是入参前缀且后缀以分隔符开始,大小写不敏感)
|
||||
/// 3. 启发式推断(模型名命名模式)
|
||||
/// 4. 默认值兜底
|
||||
///
|
||||
/// 返回的 `ModelConfig.probe_source` 标注实际命中来源。
|
||||
pub fn probe(model_id: &str) -> ModelConfig {
|
||||
// 1. 预设表精确匹配
|
||||
if let Some(mut hit) = presets().iter().find(|m| m.model_id == model_id).cloned() {
|
||||
hit.probe_source = Some(ProbeSource::PresetTable);
|
||||
return hit;
|
||||
}
|
||||
|
||||
// 2. 预设表前缀匹配(入参名以预设名开头 + 后缀以分隔符开始,大小写不敏感)
|
||||
// 收紧自原「双向子串包含」:子串匹配会把 glm-4.6v 误配给 glm-4v(继承其 vision),
|
||||
// gpt-4o-mini 误配给 gpt-4o 等「张冠李戴」。前缀匹配保留合理继承
|
||||
// (glm-4v-flash → glm-4v),同时要求后缀以分隔符(-/./_)或结尾开始,
|
||||
// 防止 glm-4v2/glm-4vx 命中 glm-4v。
|
||||
let needle = model_id.to_lowercase();
|
||||
let fuzzy = presets()
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
let cand = m.model_id.to_lowercase();
|
||||
if cand.is_empty() || cand.len() > needle.len() {
|
||||
return false;
|
||||
}
|
||||
if !needle.starts_with(&cand) {
|
||||
return false;
|
||||
}
|
||||
// 前缀后须为分隔符或字符串结束,避免部分单词命中(glm-4v2 不算 glm-4v)
|
||||
match needle[cand.len()..].chars().next() {
|
||||
None => true, // 完全相等(精确匹配已覆盖,这里兜底)
|
||||
Some(c) => matches!(c, '-' | '.' | '_'),
|
||||
}
|
||||
})
|
||||
.max_by_key(|m| m.model_id.len());
|
||||
|
||||
if let Some(mut hit) = fuzzy.cloned() {
|
||||
// 模糊命中:保留预设维度,但用入参的实际模型名覆盖 model_id(避免回写错名)
|
||||
hit.model_id = model_id.to_string();
|
||||
hit.probe_source = Some(ProbeSource::PresetTable);
|
||||
return hit;
|
||||
}
|
||||
|
||||
// 3. 启发式推断
|
||||
let mut heuristic = heuristic_infer(model_id);
|
||||
heuristic.probe_source = Some(ProbeSource::Heuristic);
|
||||
heuristic
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 测试
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use df_ai_core::model::{Capability, CostTier, IntelligenceTier, Modality, ProbeSource};
|
||||
|
||||
// ── 预设表加载 ──
|
||||
|
||||
#[test]
|
||||
fn presets_loaded_and_nonempty() {
|
||||
let p = presets();
|
||||
assert!(!p.is_empty(), "预设表应非空");
|
||||
// 确保每个预设都有 model_id
|
||||
assert!(p.iter().all(|m| !m.model_id.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_contain_expected_models() {
|
||||
let p = presets();
|
||||
let ids: Vec<&str> = p.iter().map(|m| m.model_id.as_str()).collect();
|
||||
assert!(ids.contains(&"glm-4"), "glm-4 应在预设表: {ids:?}");
|
||||
assert!(ids.contains(&"glm-4-flash"));
|
||||
assert!(ids.contains(&"glm-4v"));
|
||||
assert!(ids.contains(&"gpt-4o"));
|
||||
assert!(ids.contains(&"claude-3-5-sonnet-20241022"));
|
||||
}
|
||||
|
||||
// ── 预设精确匹配 ──
|
||||
|
||||
#[test]
|
||||
fn probe_preset_exact_match_glm4() {
|
||||
let m = probe("glm-4");
|
||||
assert_eq!(m.model_id, "glm-4");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
assert_eq!(m.modalities, vec![Modality::Text]);
|
||||
// 预设表不写死 cost_tier/intelligence,由 serde default 兜底中性值
|
||||
assert_eq!(m.intelligence, IntelligenceTier::Standard);
|
||||
assert_eq!(m.cost_tier, CostTier::Medium);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_preset_exact_match_glm4v_has_vision() {
|
||||
let m = probe("glm-4v");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
assert!(m.modalities.contains(&Modality::Vision));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_preset_exact_match_deepseek_coder_has_codegen() {
|
||||
let m = probe("deepseek-coder");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
assert!(m.capabilities.contains(&Capability::CodeGen));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_preset_exact_match_embedding_no_text_modality() {
|
||||
let m = probe("embedding-3");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
assert!(m.capabilities.contains(&Capability::Embedding));
|
||||
assert!(
|
||||
!m.modalities.contains(&Modality::Text),
|
||||
"embedding 模型不应有 Text 模态"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 预设表:已知多模态模型(命名无 vision 词素,靠预设显式标 vision) ──
|
||||
// SenseNova flash-lite 原生多模态(商汤文档:支持图像输入理解),
|
||||
// 但模型名不含 vision/vl/-v 词素,启发式 has_vision_token 无法识别 →
|
||||
// 必须靠预设表显式标 vision,否则 modalities 漏 vision 致路由误判。
|
||||
#[test]
|
||||
fn probe_preset_sensenova_flash_lite_has_vision() {
|
||||
let m = probe("sensenova-6.7-flash-lite");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
assert!(
|
||||
m.modalities.contains(&Modality::Vision),
|
||||
"sensenova-6.7-flash-lite 应有 Vision(原生多模态): {:?}",
|
||||
m.modalities
|
||||
);
|
||||
assert!(m.modalities.contains(&Modality::Text));
|
||||
// context_window 应为预设的 256K
|
||||
assert_eq!(m.context_window, 262144);
|
||||
// P1 温度:商汤 sensenova 仅接受 temperature=1.0,透传其它值(标题 0.3/压缩 0.2)
|
||||
// → 上游 HTTP 400(实测 9 次)。预设表须标 temperature_supported=false,
|
||||
// 协议转换层据此省略该字段。
|
||||
assert!(
|
||||
!m.temperature_supported,
|
||||
"sensenova 应标 temperature_supported=false(仅接受 1.0)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 温度支持判定:仅 sensenova 系列为 false,其余模型默认 true(零回归)。
|
||||
#[test]
|
||||
fn probe_temperature_supported_defaults_true_except_sensenova() {
|
||||
assert!(!probe("sensenova-6.7-flash-lite").temperature_supported);
|
||||
// 启发式路径(非预设表):sensenova 前缀仍判 false
|
||||
assert!(!probe("sensenova-unknown-model").temperature_supported);
|
||||
// 常规模型保持 true(原样透传)
|
||||
assert!(probe("deepseek-v4-flash").temperature_supported);
|
||||
assert!(probe("glm-4v").temperature_supported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_preset_deepseek_v4_flash_text_only() {
|
||||
// SenseNova 上的 deepseek-v4-flash:纯文本对话 + 1M 上下文,无 vision
|
||||
let m = probe("deepseek-v4-flash");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
assert_eq!(m.modalities, vec![Modality::Text]);
|
||||
assert_eq!(m.context_window, 1_048_576);
|
||||
}
|
||||
|
||||
// ── 预设前缀匹配 ──
|
||||
|
||||
#[test]
|
||||
fn probe_preset_fuzzy_match_glm4v_variant() {
|
||||
// "glm-4v-x" 不在预设表精确命中,但 "glm-4v" 是其前缀(后缀 "-x" 以分隔符开始) → 前缀命中
|
||||
let m = probe("glm-4v-x");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
assert_eq!(m.model_id, "glm-4v-x", "前缀命中后 model_id 应用入参名");
|
||||
assert!(
|
||||
m.modalities.contains(&Modality::Vision),
|
||||
"应继承 glm-4v 的 vision 模态"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_preset_prefix_match_glm4v_flash() {
|
||||
// 合理继承:glm-4v-flash 以 glm-4v 为前缀 + 分隔符 -,命中并继承 vision
|
||||
let m = probe("glm-4v-flash");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
assert!(m.modalities.contains(&Modality::Vision));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_preset_prefix_match_gpt4o_mini() {
|
||||
// 合理继承:gpt-4o-mini 以 gpt-4o 为前缀 + 分隔符 -
|
||||
let m = probe("gpt-4o-mini");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
assert_eq!(m.model_id, "gpt-4o-mini");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_preset_prefix_match_case_insensitive() {
|
||||
// 前缀匹配大小写不敏感(精确匹配大小写敏感,前缀兜底)
|
||||
let m = probe("GLM-4V-FLASH");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
assert!(m.modalities.contains(&Modality::Vision));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_preset_prefix_reject_non_prefix_variant() {
|
||||
// 张冠李戴防线:glm-4.6v 不以 glm-4v 为前缀(字符顺序不同),不继承 glm-4v 的 vision。
|
||||
// 但它以 glm-4 为前缀(+ 分隔符 .)→ 命中 glm-4 预设(纯文本),合理。
|
||||
let m = probe("glm-4.6v");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
assert_eq!(
|
||||
m.model_id, "glm-4.6v",
|
||||
"前缀命中后 model_id 应用入参名"
|
||||
);
|
||||
assert!(
|
||||
!m.modalities.contains(&Modality::Vision),
|
||||
"glm-4.6v 不应继承 glm-4v 的 vision(只继承 glm-4 纯文本): {:?}",
|
||||
m.modalities
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_preset_prefix_reject_no_separator() {
|
||||
// 分隔符边界:glm-4v2 前缀后是数字(非 -/./_),不算 glm-4v 的合法变体
|
||||
let m = probe("glm-4v2");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||
}
|
||||
|
||||
// ── 启发式:Vision ──
|
||||
|
||||
#[test]
|
||||
fn heuristic_vision_from_v_suffix() {
|
||||
// glm-4v 不走启发式(精确命中预设);用未知名验证启发式
|
||||
let m = probe("custom-model-v");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||
assert!(
|
||||
m.modalities.contains(&Modality::Vision),
|
||||
"v 后缀应推断 Vision"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_vision_from_vl_token() {
|
||||
let m = probe("qwen-vl-unknown");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||
assert!(m.modalities.contains(&Modality::Vision));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_vision_from_vision_word() {
|
||||
let m = probe("some-vision-7b");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||
assert!(m.modalities.contains(&Modality::Vision));
|
||||
}
|
||||
|
||||
// ── 启发式:档位中性(flash/mini 不再猜 Lite) ──
|
||||
|
||||
#[test]
|
||||
fn heuristic_flash_keeps_neutral_tier() {
|
||||
let m = probe("unknown-flash");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||
// cost/intel 一律中性,不靠名字猜
|
||||
assert_eq!(m.intelligence, IntelligenceTier::Standard);
|
||||
assert_eq!(m.cost_tier, CostTier::Medium);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_mini_keeps_neutral_tier() {
|
||||
let m = probe("test-mini-model");
|
||||
assert_eq!(m.intelligence, IntelligenceTier::Standard);
|
||||
assert_eq!(m.cost_tier, CostTier::Medium);
|
||||
}
|
||||
|
||||
// ── 启发式:CodeGen ──
|
||||
|
||||
#[test]
|
||||
fn heuristic_codegen_from_code() {
|
||||
let m = probe("acme-code-7b");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||
assert!(m.capabilities.contains(&Capability::CodeGen));
|
||||
// 对话模型仍应保留 ToolUse
|
||||
assert!(m.capabilities.contains(&Capability::ToolUse));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_codegen_from_coder() {
|
||||
let m = probe("acme-coder");
|
||||
assert!(m.capabilities.contains(&Capability::CodeGen));
|
||||
}
|
||||
|
||||
// ── 启发式:Embedding(特殊路径:无 Text 模态) ──
|
||||
|
||||
#[test]
|
||||
fn heuristic_embedding_no_text_modality() {
|
||||
let m = probe("acme-embedding-v3");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||
assert!(m.capabilities.contains(&Capability::Embedding));
|
||||
assert!(!m.modalities.contains(&Modality::Text));
|
||||
assert!(!m.capabilities.contains(&Capability::ToolUse));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_embedding_from_text_embedding_prefix() {
|
||||
let m = probe("text-embedding-3-large");
|
||||
assert!(m.capabilities.contains(&Capability::Embedding));
|
||||
assert!(m.modalities.is_empty());
|
||||
}
|
||||
|
||||
// ── 启发式:档位中性(pro/opus/4o 不再猜 Plus/Ultra/High) ──
|
||||
|
||||
#[test]
|
||||
fn heuristic_pro_keeps_neutral_tier() {
|
||||
let m = probe("acme-pro");
|
||||
assert_eq!(m.intelligence, IntelligenceTier::Standard);
|
||||
assert_eq!(m.cost_tier, CostTier::Medium);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_opus_keeps_neutral_tier() {
|
||||
let m = probe("acme-opus");
|
||||
assert_eq!(m.intelligence, IntelligenceTier::Standard);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_4o_keeps_neutral_cost() {
|
||||
let m = probe("acme-4o");
|
||||
assert_eq!(m.cost_tier, CostTier::Medium);
|
||||
}
|
||||
|
||||
// ── 启发式:版本号后缀不误判 Vision ──
|
||||
|
||||
#[test]
|
||||
fn heuristic_version_suffix_no_vision() {
|
||||
// "-v" 后接数字属版本号(deepseek-v3 为纯文本模型),不得误判 Vision
|
||||
for name in ["deepseek-v3", "acme-chat-v2", "acme-v12"] {
|
||||
let m = probe(name);
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||
assert!(
|
||||
!m.modalities.contains(&Modality::Vision),
|
||||
"{name} 版本号后缀不应误判 Vision: {:?}",
|
||||
m.modalities
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_v_morpheme_tail_still_vision() {
|
||||
// v 词素尾仍判视觉:独立 v 段(qwen-v-plus 形态)与段尾 v(glm-4v 形态,假名避开预设表)
|
||||
for name in ["acme-chat-v", "acme-4v-plus"] {
|
||||
let m = probe(name);
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||
assert!(
|
||||
m.modalities.contains(&Modality::Vision),
|
||||
"{name} 段尾 v 词素应判 Vision: {:?}",
|
||||
m.modalities
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 启发式:默认(Standard/Medium/Text/ToolUse) ──
|
||||
|
||||
#[test]
|
||||
fn heuristic_default_for_unknown_name() {
|
||||
let m = probe("acme-unknown-model");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||
assert_eq!(m.modalities, vec![Modality::Text]);
|
||||
assert_eq!(m.capabilities, vec![Capability::ToolUse]);
|
||||
assert_eq!(m.intelligence, IntelligenceTier::Standard);
|
||||
assert_eq!(m.cost_tier, CostTier::Medium);
|
||||
}
|
||||
|
||||
// ── 优先级链:预设精确 > 启发式 ──
|
||||
|
||||
#[test]
|
||||
fn probe_preset_beats_heuristic() {
|
||||
// "glm-4-flash" 精确命中预设表;预设/启发式档位都中性,
|
||||
// 此处仅校验 source 标注为 PresetTable
|
||||
let m = probe("glm-4-flash");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_priority_chain_returns_config() {
|
||||
// 任何名字都应返回完整 ModelConfig,不 panic
|
||||
for name in ["glm-4", "unknown-xyz", "qwen-vl", "acme-embedding", "gpt-5"] {
|
||||
let m = probe(name);
|
||||
assert_eq!(m.model_id, name);
|
||||
assert!(
|
||||
m.probe_source.is_some(),
|
||||
"probe_source 应被填充: {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! 模型探测器 — 纯逻辑子模块。
|
||||
//!
|
||||
//! 从 `model_probe.rs` 抽离的纯函数实现(预设表加载 / 启发式推断 / 词素判定)。
|
||||
//! 无 IO、无外部状态,crate 内经 `model_probe::probe` 间接复用 —
|
||||
//! 对齐 `model_fetch_helpers` 拆分先例(private mod,外部零直接路径需求)。
|
||||
//!
|
||||
//! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §4
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use df_ai_core::model::{
|
||||
Capability, CostTier, IntelligenceTier, Modality, ModelConfig,
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 预设表:编译期嵌入(include_str! 相对 crate 根),零运行时文件依赖
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 预设表原始 JSON(编译期从 `crates/df-ai/presets/models.json` 嵌入)。
|
||||
pub(super) const PRESETS_JSON: &str = include_str!("../presets/models.json");
|
||||
|
||||
/// 解析后的预设表(进程内单例,首次访问惰性解析一次)。
|
||||
pub(super) fn presets() -> &'static [ModelConfig] {
|
||||
static PRESETS: OnceLock<Vec<ModelConfig>> = OnceLock::new();
|
||||
PRESETS.get_or_init(|| {
|
||||
// include_str! 内容由仓库控制,解析失败属编译期/仓库错误,panic 合理。
|
||||
serde_json::from_str::<Vec<ModelConfig>>(PRESETS_JSON)
|
||||
.expect("presets/models.json 解析失败 — 检查 JSON 格式与 ModelConfig serde 映射")
|
||||
})
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 启发式推断(模型名命名模式 → 4 维度)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 模型名启发式推断。仅推断功能性维度(modalities/capabilities),
|
||||
/// cost_tier / intelligence 一律返中性默认(Medium / Standard)。
|
||||
///
|
||||
/// 取舍:模型名关键词猜档位(flash→Lite、4o→High、pro→Plus)无依据,
|
||||
/// 厂商定价/智力与命名无关,瞎填会污染路由器过滤(intelligence >= min / cost_tier <= max)。
|
||||
/// 改中性默认,真实档位由用户手填或更高阶探测源(如厂商 API/定价表)提供。
|
||||
///
|
||||
/// 规则(任务规格,设计文档 §4.4 模糊匹配规则对齐):
|
||||
/// - `embed`/`embedding`/`e-`(前缀) → Embedding 模型:capabilities=[Embedding],modalities=[](去 Text)
|
||||
/// - `v`/`vision`/`vl`(词素) → modalities 加 Vision
|
||||
/// - `code`/`coder` → capabilities 加 CodeGen
|
||||
/// - cost_tier / intelligence → 中性默认 Medium / Standard(不靠名字猜)
|
||||
pub(super) fn heuristic_infer(model_id: &str) -> ModelConfig {
|
||||
let name = model_id.to_lowercase();
|
||||
let mut modalities: Vec<Modality> = Vec::new();
|
||||
let mut capabilities: Vec<Capability> = Vec::new();
|
||||
// 中性默认:不靠模型名猜档位
|
||||
let cost_tier = CostTier::Medium;
|
||||
let intelligence = IntelligenceTier::Standard;
|
||||
|
||||
// —— Embedding 优先判定(改变模态集合,且通常独占) ——
|
||||
// 规则:含 embed / embedding / 以 "e-" / "embedding-" 起首
|
||||
let is_embedding = name.contains("embed")
|
||||
|| name.contains("embedding")
|
||||
|| name.starts_with("e-")
|
||||
|| name.starts_with("embedding-")
|
||||
|| name.starts_with("text-embedding");
|
||||
|
||||
if is_embedding {
|
||||
capabilities.push(Capability::Embedding);
|
||||
// embedding 模型不走对话模态,modalities 留空(设计 §4.4 embedding-3 例:modalities=[])
|
||||
return ModelConfig {
|
||||
model_id: model_id.to_string(),
|
||||
enabled: true,
|
||||
label: None,
|
||||
modalities,
|
||||
capabilities,
|
||||
cost_tier,
|
||||
intelligence,
|
||||
weight: 50,
|
||||
context_window: 8192,
|
||||
temperature_supported: temperature_supported_by_name(&name),
|
||||
probe_source: None,
|
||||
};
|
||||
}
|
||||
|
||||
// —— 对话模型默认 Text 模态 ——
|
||||
modalities.push(Modality::Text);
|
||||
|
||||
// —— Vision 词素:含 "v"(独立词素:glm-4v / qwen-vl)、"vision"、"vl" ——
|
||||
// 注:裸 "v" 子串误伤大(如 "review"),故仅在边界词素命中:
|
||||
// 以 "v" 结尾、含 "-v" / "v-" 分隔、含 "vl" / "vision"
|
||||
if has_vision_token(&name) {
|
||||
modalities.push(Modality::Vision);
|
||||
}
|
||||
|
||||
// —— CodeGen:含 code / coder ——
|
||||
if name.contains("code") || name.contains("coder") {
|
||||
capabilities.push(Capability::CodeGen);
|
||||
}
|
||||
|
||||
// —— 对话模型默认 ToolUse(主流支持 function calling) ——
|
||||
capabilities.push(Capability::ToolUse);
|
||||
|
||||
ModelConfig {
|
||||
model_id: model_id.to_string(),
|
||||
enabled: true,
|
||||
label: None,
|
||||
modalities,
|
||||
capabilities,
|
||||
cost_tier,
|
||||
intelligence,
|
||||
weight: 50,
|
||||
context_window: 8192,
|
||||
temperature_supported: temperature_supported_by_name(&name),
|
||||
probe_source: None,
|
||||
}
|
||||
}
|
||||
|
||||
// —— 词素判定助手(命名约定式启发式,集中维护便于增删) ——
|
||||
|
||||
/// Vision 词素:`vision` / `vl` 子串、`v` 词素尾(任一分隔段以 v 结尾:glm-4v / qwen-v-plus)。
|
||||
/// 裸 "v" 必须是词素尾:`contains("-v")` 会把版本号误判成视觉(deepseek-v3 纯文本,
|
||||
/// "-v" 后接数字属版本号而非视觉词素),按段尾判定后 v3/v2 不命中、4v/v 段仍命中。
|
||||
pub(super) fn has_vision_token(name: &str) -> bool {
|
||||
name.contains("vision")
|
||||
|| name.contains("vl")
|
||||
|| name.split(['-', '.', '_']).any(|seg| seg.ends_with('v'))
|
||||
}
|
||||
|
||||
/// 温度参数支持判定(命名约定式):商汤 sensenova 系列仅接受 temperature=1.0,
|
||||
/// 透传其它温度值(标题 0.3/压缩 0.2 等)→ 上游 HTTP 400(实测 9 次)。
|
||||
/// 返回 false 时协议转换层省略 temperature 字段(交上游默认),避免无效往返;
|
||||
/// 其余模型默认 true(原样透传,零回归)。
|
||||
pub(super) fn temperature_supported_by_name(name: &str) -> bool {
|
||||
!name.starts_with("sensenova")
|
||||
}
|
||||
@@ -0,0 +1,648 @@
|
||||
//! 工具命名空间存储 — 大工具结果不进主消息队列
|
||||
//!
|
||||
//! 当工具执行结果超过阈值时,存入 `NamespaceStore`,主队列只保留轻量引用路径。
|
||||
//! 运行时为内存 HashMap(LRU 淘汰),持久化时由调用方展开引用写 DB。
|
||||
//!
|
||||
//! 存储格式: "namespace://tool_name/args_hash"
|
||||
//! 例: namespace://read_file/a1b2c3d4
|
||||
//!
|
||||
//! ## 淘汰治理(2026-10-06,设计文档 namespace淘汰治理-设计-2026-10-04.md,拍板 2/2)
|
||||
//! - **pin 机制**:关键产出(子代理报告等)标记 pinned,LRU 永不淘汰;pin 总字节超
|
||||
//! 池容量 50% 时最旧 pin 降级可淘(拍板 C1,防 pin 滥用撑死池)。
|
||||
//! - **EvictionLog**:淘汰留档(50 条上限),展开点 read_only()=None 时查 Log 给富提示
|
||||
//! (原工具名+内容前 80 字+淘汰时刻),替代裸 EVICTED_PLACEHOLDER。
|
||||
//! - **owner 软隔离**:条目带归属(调用方 conv_id),淘汰顺序=无主最旧→他主未 pin→
|
||||
//! 当前主未 pin;软隔离只影响淘汰顺序,owner 传错退回现状行为,无正确性风险。
|
||||
//! - **零 schema 变更**:纯内存态增强。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
|
||||
/// 引用路径前缀
|
||||
pub const NAMESPACE_REF_PREFIX: &str = "namespace://";
|
||||
|
||||
/// namespace 条目被 LRU 淘汰(或跨会话残留引用)后,展开点 read_only()=None 时的提示文案。
|
||||
///
|
||||
/// 旧实现 None 分支保留 "namespace://" 字面 URI,LLM 收到无意义串且无告警。
|
||||
/// 现统一替换为此文案:既告知用户结果已淘汰,又提示可重新调用工具取回。
|
||||
/// 治理后:EvictionLog 命中时展开点改用富提示(见 `eviction_rich_notice`),未命中原样。
|
||||
pub const EVICTED_PLACEHOLDER: &str = "[该工具结果已超出内存上限被淘汰,如需请重新调用对应工具]";
|
||||
|
||||
/// namespace 字节阈值(> 2048 bytes 触发)
|
||||
pub const NAMESPACE_BYTE_THRESHOLD: usize = 2048;
|
||||
|
||||
/// namespace 行数阈值(> 50 行 触发)
|
||||
pub const NAMESPACE_LINE_THRESHOLD: usize = 50;
|
||||
|
||||
/// 始终进 namespace 的工具名(其输出几乎总是大结果)
|
||||
pub const ALWAYS_LARGE_TOOLS: &[&str] = &["read_file", "list_directory", "grep", "diff_files"];
|
||||
|
||||
/// EvictionLog 容量上限(条)。单条 ~200B,50 条可忽略。
|
||||
pub const EVICTION_LOG_CAP: usize = 50;
|
||||
|
||||
/// pin 总字节占池容量比例上限。超过后最旧 pin 降级可淘(拍板 C1)。
|
||||
pub const PIN_RATIO_CAP: f64 = 0.5;
|
||||
|
||||
/// 判定工具结果是否应进入 namespace
|
||||
pub fn should_use_namespace(content: &str, tool_name: &str) -> bool {
|
||||
if ALWAYS_LARGE_TOOLS.contains(&tool_name) {
|
||||
return true;
|
||||
}
|
||||
content.len() > NAMESPACE_BYTE_THRESHOLD
|
||||
|| content.lines().count() > NAMESPACE_LINE_THRESHOLD
|
||||
}
|
||||
|
||||
/// 判定 tool_result 内容是否为 namespace 引用路径
|
||||
pub fn is_namespace_ref(content: &str) -> bool {
|
||||
content.starts_with(NAMESPACE_REF_PREFIX)
|
||||
}
|
||||
|
||||
/// 从引用路径中提取内部 key
|
||||
pub fn parse_namespace_key(full_path: &str) -> Option<&str> {
|
||||
full_path.strip_prefix(NAMESPACE_REF_PREFIX)
|
||||
// 939①: 先剥 ?len= 长度尾段(估算入账用),否则尾段并入 key 致全线查空
|
||||
.and_then(|s| s.split("?len=").next())
|
||||
.and_then(|s| s.split('/').nth(1))
|
||||
}
|
||||
|
||||
/// 从引用路径提取原文字节数(939①)。带 `?len=N` 尾段的引用返回 Some(N);
|
||||
/// 旧格式/放弃入库的无尾段引用返回 None(调用方按字面字符保守估算)。
|
||||
pub fn parse_ref_content_len(full_path: &str) -> Option<usize> {
|
||||
full_path.rsplit("?len=")
|
||||
.next()
|
||||
.and_then(|s| s.parse::<usize>().ok())
|
||||
.filter(|_| full_path.contains("?len="))
|
||||
}
|
||||
|
||||
/// 从引用路径提取工具名段(懒回填重建条目元数据用)。
|
||||
pub fn parse_ref_tool_name(full_path: &str) -> Option<&str> {
|
||||
full_path.strip_prefix(NAMESPACE_REF_PREFIX)?.split('/').next()
|
||||
}
|
||||
|
||||
/// namespace 条目
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NamespaceEntry {
|
||||
pub key: String,
|
||||
pub tool_name: String,
|
||||
pub content: String,
|
||||
pub created_at: Instant,
|
||||
pub access_count: u64,
|
||||
/// pin 标记:LRU 永不淘汰(pin 总量超 50% 容量时最旧 pin 降级,拍板 C1)
|
||||
pub pinned: bool,
|
||||
/// 归属(conv_id / 子代理 id),软隔离淘汰顺序用;None=无主
|
||||
pub owner: Option<String>,
|
||||
/// 预生成的淘汰提示料(工具名+参数摘要,store 时固化),淘汰留档用
|
||||
pub evict_hint: String,
|
||||
}
|
||||
|
||||
/// 淘汰留档记录(EvictionLog 元素)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EvictionRecord {
|
||||
pub key: String,
|
||||
pub tool_name: String,
|
||||
/// 内容前 80 字符(hint),供富提示识别原文
|
||||
pub hint: String,
|
||||
pub evicted_at: Instant,
|
||||
pub owner: Option<String>,
|
||||
}
|
||||
|
||||
/// namespace 存储(内存 HashMap + LRU 淘汰 + pin/EvictionLog/owner 治理)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NamespaceStore {
|
||||
entries: HashMap<String, NamespaceEntry>,
|
||||
max_bytes: usize,
|
||||
current_bytes: usize,
|
||||
/// 淘汰留档(VecDeque 语义,容量 EVICTION_LOG_CAP)
|
||||
eviction_log: std::collections::VecDeque<EvictionRecord>,
|
||||
}
|
||||
|
||||
impl NamespaceStore {
|
||||
/// 创建 namespace 存储
|
||||
pub fn new(max_bytes: usize) -> Self {
|
||||
Self {
|
||||
entries: HashMap::new(),
|
||||
max_bytes,
|
||||
current_bytes: 0,
|
||||
eviction_log: std::collections::VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 默认大小:10MB
|
||||
pub fn default() -> Self {
|
||||
Self::new(10 * 1024 * 1024)
|
||||
}
|
||||
|
||||
/// 存入 namespace。返回引用路径字符串。(兼容入口,owner=None 无主)
|
||||
///
|
||||
/// 超限行为分两档:常规超限 LRU 淘汰旧条目腾位(既有语义);单条本身超过
|
||||
/// max_bytes 时淘汰清空也放不下,拒绝入库只返回引用路径——展开点对
|
||||
/// read_only()=None 统一落 EVICTED_PLACEHOLDER,与 LRU 淘汰后的不可解析引用同语义。
|
||||
pub fn store(&mut self, tool_name: &str, content: &str) -> String {
|
||||
self.store_with_owner(tool_name, content, None)
|
||||
}
|
||||
|
||||
/// 存入(带归属)。owner 软隔离:淘汰优先淘无主/他主,当前主最后。
|
||||
pub fn store_with_owner(&mut self, tool_name: &str, content: &str, owner: Option<&str>) -> String {
|
||||
self.store_inner(tool_name, content, owner, false)
|
||||
}
|
||||
|
||||
/// 存入并 pin(关键产出:子代理报告等)。pin 条目 LRU 永不淘汰,
|
||||
/// pin 总字节超池 50% 时最旧 pin 降级可淘(在 evict_if_needed 内兜底)。
|
||||
pub fn store_pinned(&mut self, tool_name: &str, content: &str, owner: Option<&str>) -> String {
|
||||
self.store_inner(tool_name, content, owner, true)
|
||||
}
|
||||
|
||||
fn store_inner(&mut self, tool_name: &str, content: &str, owner: Option<&str>, pinned: bool) -> String {
|
||||
let key = simple_hash(content);
|
||||
// 939① 估算入账:ref 尾段携带原文字节数,TokenEstimator 解析即按真实体量入账
|
||||
// (旧口径 ref 仅 ~30 字符被记 ~15 token,预算/裁剪/压缩三重门对其失效)。
|
||||
// parse_namespace_key 取 nth(1) 不受尾段影响,展开/淘汰提示零改动。
|
||||
// 放弃入库(超容量/淘汰后仍放不下)返回**不带长度**的 path:原文不在池,展开点
|
||||
// 落占位提示,预算按轻量字符计——已淘汰内容不应继续虚占预算。
|
||||
let path_with_len = format!("{}{}/{}?len={}", NAMESPACE_REF_PREFIX, tool_name, key, content.len());
|
||||
let path = format!("{}{}/{}", NAMESPACE_REF_PREFIX, tool_name, key);
|
||||
|
||||
// 已存在 → 刷新 access_count,不重复占用空间;若本次要求 pin,升级既有条目
|
||||
if self.entries.contains_key(&key) {
|
||||
if let Some(entry) = self.entries.get_mut(&key) {
|
||||
entry.access_count += 1;
|
||||
if pinned && !entry.pinned {
|
||||
entry.pinned = true;
|
||||
}
|
||||
}
|
||||
return path_with_len;
|
||||
}
|
||||
|
||||
let new_bytes = content.len();
|
||||
|
||||
// 单条超过总容量:硬入库会把 current_bytes 永久顶破上限(evict 清空后仍无条件
|
||||
// insert 的旧路径),拒绝入库保住 current_bytes <= max_bytes 不变式
|
||||
if new_bytes > self.max_bytes {
|
||||
return path;
|
||||
}
|
||||
|
||||
// pin 比例上限(拍板 C1):pinned 入库前无条件校验,把 pin 总量压回 50% 内
|
||||
// (最旧 pin 先降级),不只在池溢出时——防 pin 静默累积到失控
|
||||
if pinned {
|
||||
let cap = (self.max_bytes as f64 * PIN_RATIO_CAP) as usize;
|
||||
while self.pinned_bytes() + new_bytes > cap {
|
||||
if !self.demote_oldest_pin() { break; }
|
||||
}
|
||||
}
|
||||
|
||||
// 检查容量,超限淘汰
|
||||
self.evict_if_needed(new_bytes);
|
||||
|
||||
// 淘汰后仍放不下(全 pin 撑满):放弃入库(返回引用路径,展开点落占位),
|
||||
// 不降级 pin——pin 保护是强语义,普通 store 不得侵蚀 pin
|
||||
if self.current_bytes + new_bytes > self.max_bytes && !pinned {
|
||||
return path;
|
||||
}
|
||||
|
||||
let entry = NamespaceEntry {
|
||||
key: key.clone(),
|
||||
tool_name: tool_name.to_string(),
|
||||
content: content.to_string(),
|
||||
created_at: Instant::now(),
|
||||
access_count: 1,
|
||||
pinned,
|
||||
owner: owner.map(str::to_string),
|
||||
evict_hint: content.chars().take(80).collect(),
|
||||
};
|
||||
|
||||
self.current_bytes += new_bytes;
|
||||
self.entries.insert(key, entry);
|
||||
path_with_len
|
||||
}
|
||||
|
||||
/// pin/取消 pin(事后)。pin 总量超 50% 容量时本调用拒绝并返回 false(防失控,拍板 C1)。
|
||||
pub fn pin(&mut self, full_path: &str) -> bool {
|
||||
let Some(key) = parse_namespace_key(full_path) else { return false };
|
||||
if !self.entries.contains_key(key) {
|
||||
return false;
|
||||
}
|
||||
if self.pinned_bytes() + self.entries.get(key).map(|e| e.content.len()).unwrap_or(0)
|
||||
> (self.max_bytes as f64 * PIN_RATIO_CAP) as usize
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(entry) = self.entries.get_mut(key) {
|
||||
entry.pinned = true;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn unpin(&mut self, full_path: &str) {
|
||||
if let Some(key) = parse_namespace_key(full_path) {
|
||||
if let Some(entry) = self.entries.get_mut(key) {
|
||||
entry.pinned = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// pin 条目总字节
|
||||
pub fn pinned_bytes(&self) -> usize {
|
||||
self.entries.values().filter(|e| e.pinned).map(|e| e.content.len()).sum()
|
||||
}
|
||||
|
||||
/// 降级最旧 pin。返回是否有降级发生(全 pin 已降无可降返回 false)。
|
||||
fn demote_oldest_pin(&mut self) -> bool {
|
||||
let oldest_pin = self.entries.iter()
|
||||
.filter(|(_, e)| e.pinned)
|
||||
.min_by_key(|(_, e)| e.created_at)
|
||||
.map(|(k, _)| k.clone());
|
||||
if let Some(key) = oldest_pin {
|
||||
if let Some(entry) = self.entries.get_mut(&key) {
|
||||
entry.pinned = false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 读取完整内容
|
||||
pub fn read(&mut self, full_path: &str) -> Option<&str> {
|
||||
let key = parse_namespace_key(full_path)?;
|
||||
let entry = self.entries.get_mut(key)?;
|
||||
entry.access_count += 1;
|
||||
Some(entry.content.as_str())
|
||||
}
|
||||
|
||||
/// 读取(只读,不改 access_count)
|
||||
pub fn read_only(&self, full_path: &str) -> Option<&str> {
|
||||
let key = parse_namespace_key(full_path)?;
|
||||
self.entries.get(key).map(|e| e.content.as_str())
|
||||
}
|
||||
|
||||
/// 展开失败的富提示:查 EvictionLog,命中返回「原为 X 前 80 字…于淘汰,可重新调用」,
|
||||
/// 未命中(跨会话残留/未知 key)返回 None,展开点落回 EVICTED_PLACEHOLDER。
|
||||
pub fn eviction_rich_notice(&self, full_path: &str) -> Option<String> {
|
||||
let key = parse_namespace_key(full_path)?;
|
||||
let rec = self.eviction_log.iter().find(|r| r.key == key)?;
|
||||
Some(format!(
|
||||
"[结果已淘汰] 原为 {}(\"{}\"…) 因内存容量淘汰。可重新调用 {} 取回原文。",
|
||||
rec.tool_name, rec.hint, rec.tool_name,
|
||||
))
|
||||
}
|
||||
|
||||
/// 跨重启懒回填:store 清空后旧引用首次展开未命中时,从会话消息原文找回并重建条目。
|
||||
/// DB 落库约定存展开后原文,引用 key 即入库时的原文内容哈希(SipHash 确定性,跨进程
|
||||
/// 一致),哈希精确匹配即可靠对应单条原文,无需按序重建;原文不在消息里的引用(真淘汰)
|
||||
/// 不进返回值,调用方维持淘汰占位语义。
|
||||
/// 入库走 store 既有容量语义(超容量拒绝只影响后续轮次命中,本轮视图仍用返回的原文)。
|
||||
/// 返回 引用路径 → 原文。
|
||||
pub fn backfill_from_messages(
|
||||
&mut self,
|
||||
missed_refs: &[String],
|
||||
message_contents: &[String],
|
||||
) -> HashMap<String, String> {
|
||||
let mut recovered: HashMap<String, String> = HashMap::new();
|
||||
if missed_refs.is_empty() {
|
||||
return recovered;
|
||||
}
|
||||
let wanted: HashMap<&str, &String> = missed_refs.iter()
|
||||
.filter_map(|p| parse_namespace_key(p).map(|k| (k, p)))
|
||||
.collect();
|
||||
for content in message_contents {
|
||||
let key = simple_hash(content);
|
||||
if let Some(path) = wanted.get(key.as_str()) {
|
||||
// 同一引用可能在多条历史消息出现,找回一次即可
|
||||
if recovered.contains_key(path.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(tool) = parse_ref_tool_name(path) {
|
||||
self.store(tool, content);
|
||||
}
|
||||
recovered.insert((*path).clone(), content.clone());
|
||||
}
|
||||
}
|
||||
recovered
|
||||
}
|
||||
|
||||
/// 检查 key 是否存在
|
||||
pub fn contains(&self, full_path: &str) -> bool {
|
||||
parse_namespace_key(full_path)
|
||||
.and_then(|k| self.entries.get(k))
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// 条目数
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// LRU 淘汰:owner 软隔离三段顺序(设计 §3.4):
|
||||
/// ①无主且未 pin 的最旧 → ②他主(非最后入参 owner)未 pin 的最旧 → ③当前主未 pin 最旧。
|
||||
/// pin 永不淘(除非 store_pinned 路径 demote 降级)。needed 所需空间,cur_owner=当前
|
||||
/// 存入方归属(淘汰保护对象,None=无主视角)。
|
||||
fn evict_if_needed_with_owner(&mut self, needed: usize, cur_owner: Option<&str>) {
|
||||
while self.current_bytes + needed > self.max_bytes && !self.entries.is_empty() {
|
||||
// 三段淘汰顺序:无主最旧 → 他主最旧 → 当前主最旧(各段均跳过 pinned)
|
||||
let oldest_key = self.entries.iter()
|
||||
.filter(|(_, e)| !e.pinned && e.owner.is_none())
|
||||
.min_by_key(|(_, e)| e.created_at)
|
||||
.map(|(k, _)| k.clone())
|
||||
.or_else(|| {
|
||||
self.entries.iter()
|
||||
.filter(|(_, e)| {
|
||||
!e.pinned && match (&e.owner, cur_owner) {
|
||||
(Some(o), Some(c)) => o != c,
|
||||
(Some(_), None) => true, // 有主 vs 无主调用:有主条目算"他主"
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
.min_by_key(|(_, e)| e.created_at)
|
||||
.map(|(k, _)| k.clone())
|
||||
})
|
||||
.or_else(|| {
|
||||
self.entries.iter()
|
||||
.filter(|(_, e)| !e.pinned)
|
||||
.min_by_key(|(_, e)| e.created_at)
|
||||
.map(|(k, _)| k.clone())
|
||||
});
|
||||
|
||||
match oldest_key {
|
||||
Some(key) => {
|
||||
if let Some(entry) = self.entries.remove(&key) {
|
||||
self.current_bytes = self.current_bytes.saturating_sub(entry.content.len());
|
||||
// EvictionLog 留档(50 条上限,超出弹最旧)
|
||||
self.eviction_log.push_back(EvictionRecord {
|
||||
key: entry.key,
|
||||
tool_name: entry.tool_name,
|
||||
hint: entry.evict_hint,
|
||||
evicted_at: Instant::now(),
|
||||
owner: entry.owner,
|
||||
});
|
||||
while self.eviction_log.len() > EVICTION_LOG_CAP {
|
||||
self.eviction_log.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
None => break, // 全部 pinned(且非 pin 腾位路径):放不下就放不下
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 兼容入口(无主视角)
|
||||
fn evict_if_needed(&mut self, needed: usize) {
|
||||
self.evict_if_needed_with_owner(needed, None);
|
||||
}
|
||||
|
||||
/// 清空
|
||||
pub fn clear(&mut self) {
|
||||
self.entries.clear();
|
||||
self.current_bytes = 0;
|
||||
self.eviction_log.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// 内容 → 引用 key 段(确定性,不含 `/` 等会破坏 `parse_namespace_key` 的字符)。
|
||||
///
|
||||
/// 历史 bug:旧实现用 `"{len}_{前16字符}"`,前 16 字符原样进 key;内容前缀含 `/`(目录列表、
|
||||
/// 带路径的 grep 结果、文件头是路径)时,`namespace://tool/{key}` 被 `parse_namespace_key`
|
||||
/// 的 `split('/').nth(1)` 从首个 `/` 截断 → read/read_only 拿回 None → 原文彻底丢失。
|
||||
/// 现改用 SipHash u64 → 16 位 hex:确定性 + 极低碰撞 + 不含特殊字符。
|
||||
fn simple_hash(content: &str) -> String {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = DefaultHasher::new();
|
||||
content.hash(&mut hasher);
|
||||
format!("{:016x}", hasher.finish())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_use_namespace_small_content() {
|
||||
assert!(!should_use_namespace("short", "custom_tool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_use_namespace_large_bytes() {
|
||||
let large = "x".repeat(3000);
|
||||
assert!(should_use_namespace(&large, "custom_tool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_use_namespace_always_large_tool() {
|
||||
assert!(should_use_namespace("short", "read_file"));
|
||||
assert!(should_use_namespace("short", "list_directory"));
|
||||
assert!(should_use_namespace("short", "diff_files"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_use_namespace_many_lines() {
|
||||
let many = (0..60).map(|i| format!("line {}", i)).collect::<Vec<_>>().join("\n");
|
||||
assert!(should_use_namespace(&many, "custom_tool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_namespace_ref_detects_prefix() {
|
||||
assert!(is_namespace_ref("namespace://read_file/abc"));
|
||||
assert!(!is_namespace_ref("file content here"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_namespace_key_extracts_key() {
|
||||
assert_eq!(parse_namespace_key("namespace://read_file/abc_123"), Some("abc_123"));
|
||||
assert_eq!(parse_namespace_key("namespace://grep/xyz"), Some("xyz"));
|
||||
assert_eq!(parse_namespace_key("no_prefix"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_and_read() {
|
||||
let mut ns = NamespaceStore::new(100_000);
|
||||
let path = ns.store("read_file", "hello world content");
|
||||
assert!(is_namespace_ref(&path));
|
||||
assert!(path.contains("read_file"));
|
||||
assert_eq!(ns.read(&path), Some("hello world content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_duplicate_key_does_not_double_count() {
|
||||
let mut ns = NamespaceStore::new(100_000);
|
||||
let content = "test content for duplicate check";
|
||||
let p1 = ns.store("tool_a", content);
|
||||
let p2 = ns.store("tool_b", content);
|
||||
assert_eq!(ns.len(), 1, "相同内容应去重");
|
||||
// read(&mut self) 返回 Option<&str> 借用 ns,两次调用须各自转 owned 避免双重可变借用
|
||||
let r1 = ns.read(&p1).map(str::to_owned);
|
||||
let r2 = ns.read(&p2).map(str::to_owned);
|
||||
assert_eq!(r1, r2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_content_with_slash_in_prefix_reads_back() {
|
||||
// 回归:旧 simple_hash 把内容前 16 字符原样进 key,前缀含 '/' 时(目录列表/带路径 grep
|
||||
// 结果/文件头是路径)namespace://tool/{key} 被 parse_namespace_key 的 split('/') 截断
|
||||
// → read None,原文丢失。hex hash 修复后 store→read 正确取回。
|
||||
let mut ns = NamespaceStore::new(100_000);
|
||||
let content = "foo/bar/baz/qux\nline2\nline3";
|
||||
let path = ns.store("list_directory", content);
|
||||
assert!(is_namespace_ref(&path));
|
||||
assert_eq!(ns.read(&path).map(str::to_owned), Some(content.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_oldest_removed_when_over_limit() {
|
||||
let mut ns = NamespaceStore::new(100); // 极小容量
|
||||
let small = "a"; // 1 byte
|
||||
ns.store("t1", small);
|
||||
assert_eq!(ns.len(), 1);
|
||||
|
||||
// 填充到超限
|
||||
let big = "x".repeat(200);
|
||||
ns.store("t2", &big);
|
||||
// t1 可能被淘汰
|
||||
if ns.current_bytes > 100 {
|
||||
assert!(ns.len() < 2, "超限时应已淘汰旧条目");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_oversized_single_entry_rejected_cap_intact() {
|
||||
// 回归:单条超过 max_bytes 时旧路径淘汰清空后仍无条件入库,current_bytes
|
||||
// 永久突破上限。现拒绝入库,仅返回引用路径,展开点落 EVICTED_PLACEHOLDER。
|
||||
let mut ns = NamespaceStore::new(100);
|
||||
let path = ns.store("t1", &"x".repeat(200));
|
||||
assert_eq!(ns.len(), 0, "超容量单条应拒绝入库");
|
||||
assert_eq!(ns.current_bytes, 0, "字节账目不得突破上限");
|
||||
assert!(is_namespace_ref(&path), "返回值仍是引用路径");
|
||||
assert_eq!(ns.read_only(&path), None, "不可解析 → 既有淘汰占位语义");
|
||||
|
||||
// 拒绝后不污染状态,常规条目仍可正常入
|
||||
let p2 = ns.store("t2", "ok");
|
||||
assert_eq!(ns.read(&p2).map(str::to_owned), Some("ok".to_string()));
|
||||
assert_eq!(ns.current_bytes, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_resets_all() {
|
||||
let mut ns = NamespaceStore::new(100_000);
|
||||
ns.store("t1", "content");
|
||||
assert!(!ns.is_empty());
|
||||
ns.clear();
|
||||
assert!(ns.is_empty());
|
||||
assert_eq!(ns.current_bytes, 0);
|
||||
}
|
||||
|
||||
// ── 淘汰治理新增 3 组(pin / EvictionLog / owner 隔离) ──
|
||||
|
||||
#[test]
|
||||
fn pin_protects_from_eviction() {
|
||||
let mut ns = NamespaceStore::new(100);
|
||||
let pinned_path = ns.store_pinned("report", &"p".repeat(50), Some("conv-a"));
|
||||
assert!(ns.contains(&pinned_path));
|
||||
// 塞入未 pin 大内容逼淘汰:pin 条目须存活
|
||||
let _ = ns.store("t2", &"x".repeat(80));
|
||||
assert!(ns.contains(&pinned_path), "pinned 条目不得被 LRU 淘汰");
|
||||
assert_eq!(ns.read_only(&pinned_path).map(str::to_owned), Some("p".repeat(50)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_log_rich_notice() {
|
||||
let mut ns = NamespaceStore::new(50);
|
||||
let victim = ns.store("read_file", &"v".repeat(40));
|
||||
let victim_path = victim.clone();
|
||||
// 挤出 victim
|
||||
let _ = ns.store("t2", &"y".repeat(45));
|
||||
assert_eq!(ns.read_only(&victim_path), None, "victim 应已被淘汰");
|
||||
// 富提示:含工具名+内容前 80 字+重新调用指引
|
||||
let notice = ns.eviction_rich_notice(&victim_path).expect("Log 应命中");
|
||||
assert!(notice.contains("read_file"), "富提示应含工具名: {notice}");
|
||||
assert!(notice.contains("vvvv"), "富提示应含内容 hint: {notice}");
|
||||
// 未命中(未知 key)→ None,展开点落 EVICTED_PLACEHOLDER
|
||||
assert_eq!(ns.eviction_rich_notice("namespace://read_file/0000000000000000"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_soft_isolation_evicts_other_owner_first() {
|
||||
let mut ns = NamespaceStore::new(100);
|
||||
// conv-a 存 30B,conv-b 存 30B(时间序:a 旧于 b)
|
||||
let _ = ns.store_with_owner("ta", &"a".repeat(30), Some("conv-a"));
|
||||
let path_b = ns.store_with_owner("tb", &"b".repeat(30), Some("conv-b"));
|
||||
// conv-b 再存 50B 逼淘汰:软隔离应先淘无主(无)→ 他主(conv-a)而非自己
|
||||
let _ = ns.store_with_owner("tc", &"c".repeat(50), Some("conv-b"));
|
||||
assert!(!ns.contains("namespace://ta/x"), "ta 的 key 查询仅辅助");
|
||||
assert!(ns.entries.values().all(|e| e.owner.as_deref() != Some("conv-a")),
|
||||
"他主(conv-a)条目应先被淘汰");
|
||||
assert!(ns.contains(&path_b), "当前主(conv-b)条目应存活");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pin_ratio_cap_demotes_oldest() {
|
||||
// 池 100B,50% 上限=50B:pin 60B 单条(store_pinned 自身允许,超限后
|
||||
// 下一次 pin 腾位路径应降级它)
|
||||
let mut ns = NamespaceStore::new(100);
|
||||
let p_old = ns.store_pinned("old", &"o".repeat(60), None);
|
||||
// 再 pin 30B:总 pin 90B > 50B 上限,store_pinned 内 demote_oldest_pin 应降级 old
|
||||
let _ = ns.store_pinned("new", &"n".repeat(30), None);
|
||||
// 挤压逼淘汰:降级后的 old 可被淘,受保护的 new 存活
|
||||
let _ = ns.store("t3", &"z".repeat(80));
|
||||
assert!(ns.contains(&p_old) == false || ns.pinned_bytes() <= 50,
|
||||
"降级机制应使 pin 总量可被压缩");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ref_carries_content_len_for_budget() {
|
||||
// 939①: 成功入库的 ref 带 ?len= 原文字节数,parse 往返一致;
|
||||
// parse_namespace_key 不受尾段影响(展开点零改动)
|
||||
let mut ns = NamespaceStore::new(100_000);
|
||||
let content = "x".repeat(5000);
|
||||
let path = ns.store("read_file", &content);
|
||||
assert!(path.contains(&format!("?len={}", content.len())), "ref 应带 len: {path}");
|
||||
assert_eq!(parse_ref_content_len(&path), Some(content.len()));
|
||||
assert_eq!(parse_namespace_key(&path), parse_namespace_key(&format!("namespace://read_file/{}", crate::namespace_store::simple_hash(&content))));
|
||||
// 旧格式/无尾段 → None(调用方保守按字面计)
|
||||
assert_eq!(parse_ref_content_len("namespace://read_file/abc"), None);
|
||||
}
|
||||
|
||||
// ── 跨重启懒回填(跨会话召回设计 缺口3) ──
|
||||
|
||||
#[test]
|
||||
fn backfill_recovers_original_by_content_hash() {
|
||||
// 引用路径取自热 store(模拟重启前生成),冷 store(重启后)从消息原文找回
|
||||
let mut hot = NamespaceStore::new(1 << 20);
|
||||
let original = "目录列表内容\n".repeat(100);
|
||||
let path = hot.store("list_directory", &original);
|
||||
let mut cold = NamespaceStore::default();
|
||||
let recovered = cold.backfill_from_messages(
|
||||
&[path.clone()],
|
||||
&[original.clone(), "无关消息".to_string()],
|
||||
);
|
||||
assert_eq!(recovered.get(&path).map(String::as_str), Some(original.as_str()));
|
||||
assert_eq!(cold.read_only(&path).map(str::to_owned), Some(original), "回填后条目应可读");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backfill_without_original_stays_empty() {
|
||||
let mut hot = NamespaceStore::new(1 << 20);
|
||||
let path = hot.store("read_file", &"真原文".repeat(500));
|
||||
let mut cold = NamespaceStore::default();
|
||||
let recovered = cold.backfill_from_messages(
|
||||
&[path.clone()],
|
||||
&["无关A".to_string(), "无关B".to_string()],
|
||||
);
|
||||
assert!(recovered.is_empty(), "无对应原文不应误填");
|
||||
assert_eq!(cold.read_only(&path), None);
|
||||
assert!(cold.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backfill_duplicate_refs_recover_once() {
|
||||
// 同一引用出现在多条历史消息:找回一次,store 只建一条
|
||||
let mut hot = NamespaceStore::new(1 << 20);
|
||||
let original = "z".repeat(3000);
|
||||
let path = hot.store("grep", &original);
|
||||
let mut cold = NamespaceStore::default();
|
||||
let recovered = cold.backfill_from_messages(&[path.clone(), path.clone()], &[original]);
|
||||
assert_eq!(recovered.len(), 1);
|
||||
assert_eq!(cold.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! 本机连通性基准面探测(弱网重试的感知依据)。
|
||||
//!
|
||||
//! 场景: 隧道/电梯等移动网络波动可达分钟级, 固定次数重试必然失败。send 失败时探
|
||||
//! 基准面区分「本机断网」与「单点故障」: 前者进长等待(自适应间隔探测, 恢复即重试),
|
||||
//! 后者短退避即可。纯 IP TCP 与系统 DNS 解析双测, 任一通过即判在线
|
||||
//! (DNS 单挂时 IP 直连上游仍可能可用, 双测互为盲区互补)。
|
||||
//!
|
||||
//! 演进路线(P2, 需求触发再上): 系统网络事件订阅双通道——事件源治接口级变化
|
||||
//! (WiFi 断开/切蜂窝)零延迟, 本探针治链路级不通(隧道半断/DNS 故障, 系统事件
|
||||
//! 不触发的盲区)。跨平台无统一事件 crate(Windows=INetworkListManager COM,
|
||||
//! macOS=NWPathMonitor, Linux=netlink), 届时以 NetEventSource trait 抽象,
|
||||
//! 按 devflow 实际部署面(Windows 桌面+Linux 云端)逐平台实现; Linux 服务端
|
||||
//! 网络稳定, 纯探针已足够。
|
||||
|
||||
use std::net::ToSocketAddrs;
|
||||
use std::time::Duration;
|
||||
|
||||
/// 探测超时(单次解析或建连)。弱网下慢响应不代表断网, 2s 内完成才计可用。
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// send 弱网重试总窗口(隧道/电梯级分钟级波动覆盖)。
|
||||
pub(crate) const SEND_RETRY_WINDOW: Duration = Duration::from_secs(600);
|
||||
|
||||
/// 脱网确认后的探针间隔表(秒): 首段 5s 快探保即时性(网络抖动常秒级恢复),
|
||||
/// 确认持续脱网后间隔拉长到 30s 封顶——蜂窝 radio 休眠省电, 分钟级隧道场景无感差异。
|
||||
pub(crate) const OFFLINE_PROBE_SCHEDULE_SECS: &[u64] = &[5, 5, 5, 5, 10, 15, 30];
|
||||
|
||||
/// 按脱网轮次取探针间隔(越界取表尾封顶值)。
|
||||
pub(crate) fn offline_probe_wait(poll_count: usize) -> Duration {
|
||||
let idx = poll_count.saturating_sub(1).min(OFFLINE_PROBE_SCHEDULE_SECS.len() - 1);
|
||||
Duration::from_secs(OFFLINE_PROBE_SCHEDULE_SECS[idx])
|
||||
}
|
||||
|
||||
/// IP 直连探针目标(公共 DNS 服务商的 443, 不依赖 DNS)。
|
||||
const PROBE_IPS: &[&str] = &["223.5.5.5:443", "119.29.29.29:443"];
|
||||
|
||||
/// DNS 面探针目标(公共域, 走系统 resolver)。
|
||||
const PROBE_DNS_HOST: &str = "www.aliyun.com:443";
|
||||
|
||||
async fn tcp_alive(addr: &str) -> bool {
|
||||
let target = match addr.to_socket_addrs() {
|
||||
Ok(mut it) => match it.next() {
|
||||
Some(a) => a,
|
||||
None => return false,
|
||||
},
|
||||
Err(_) => return false,
|
||||
};
|
||||
tokio::time::timeout(PROBE_TIMEOUT, tokio::net::TcpStream::connect(target))
|
||||
.await
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// 本机网络基准面是否可用。任一探针通过即在线。
|
||||
pub(crate) async fn baseline_alive() -> bool {
|
||||
for ip in PROBE_IPS {
|
||||
if tcp_alive(ip).await {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
matches!(
|
||||
tokio::time::timeout(PROBE_TIMEOUT, tokio::net::lookup_host(PROBE_DNS_HOST)).await,
|
||||
Ok(Ok(_))
|
||||
)
|
||||
}
|
||||
+1436
-239
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,542 @@
|
||||
//! OpenAI 兼容协议适配 — 协议数据结构与 SSE 事件解析(纯函数)。
|
||||
//!
|
||||
//! 本模块从 `openai_compat.rs` 抽离,承载与 HTTP 无关的纯协议逻辑:
|
||||
//! - 请求/响应结构体(`OpenAiRequest` / `OpenAiResponse` / `OpenAiStreamChunk` 等)
|
||||
//! - SSE 事件 data → `StreamChunk` 转换纯函数(`apply_openai_sse`)
|
||||
//!
|
||||
//! Provider struct + impl(含 HTTP 调用 / `convert_request` / `chat_url`)仍留在
|
||||
//! `openai_compat.rs`,Rust impl 块不可跨文件,故仅搬迁 impl 块外部的类型/纯函数。
|
||||
//! 零行为变更(纯搬迁)。结构对齐 `anthropic_helpers.rs`。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, error};
|
||||
|
||||
use crate::provider::{tool_call_id_or_fallback, StreamChunk, TokenUsage, ToolCallDelta};
|
||||
|
||||
// ============================================================
|
||||
// OpenAI API 请求/响应结构体
|
||||
// ============================================================
|
||||
|
||||
/// OpenAI 兼容请求体
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct OpenAiRequest {
|
||||
pub model: String,
|
||||
pub messages: Vec<OpenAiMessage>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_tokens: Option<u32>,
|
||||
pub stream: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<serde_json::Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<serde_json::Value>,
|
||||
/// 流式时请求末 chunk 携带 usage(OpenAI 官方 + DeepSeek/GLM 兼容)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stream_options: Option<serde_json::Value>,
|
||||
/// DeepSeek thinking 模式
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
/// OpenAI 消息格式
|
||||
///
|
||||
/// `content` 为 `serde_json::Value`:纯文本消息走字符串简写(与老端点兼容),
|
||||
/// 含图消息走 `[{type:"text",text},{type:"image_url",image_url:{url}}]` 数组。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct OpenAiMessage {
|
||||
pub role: String,
|
||||
pub content: serde_json::Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<serde_json::Value>>,
|
||||
/// DeepSeek thinking 模式推理内容
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
/// OpenAI 同步响应
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiResponse {
|
||||
pub choices: Vec<OpenAiChoice>,
|
||||
pub model: String,
|
||||
pub usage: Option<OpenAiUsage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct OpenAiChoice {
|
||||
pub(crate) message: OpenAiMessageResp,
|
||||
/// 调用终止原因。`length` = 输出被 max_tokens 截断(不完整),由 [`FinishReason`]
|
||||
/// 的 Drop 侧效应打 warn 留痕 — 此前该字段无人读、截断不可见,截断的 JSON 会被
|
||||
/// 下游当解析失败整批丢弃而无任何日志线索。
|
||||
#[serde(default)]
|
||||
pub finish_reason: FinishReason,
|
||||
}
|
||||
|
||||
/// finish_reason 载体(透明 NewType)。
|
||||
///
|
||||
/// 消费方只取 message,本字段值随 choice 整体 drop 时触发截断告警;对外公开
|
||||
/// (pub 模块下的 pub 结构属库 API 面)同时免去字段 dead_code 豁免。
|
||||
/// serde 透明,线上 JSON 形态与 Option<String> 一致。
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct FinishReason(Option<String>);
|
||||
|
||||
impl Drop for FinishReason {
|
||||
fn drop(&mut self) {
|
||||
if self.0.as_deref() == Some("length") {
|
||||
tracing::warn!("[OpenAI] 同步响应因 max_tokens 截断(finish_reason=length),输出不完整");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiMessageResp {
|
||||
pub content: Option<String>,
|
||||
pub tool_calls: Option<Vec<OpenAiToolCallResp>>,
|
||||
/// DeepSeek thinking 模式响应中的推理内容
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiToolCallResp {
|
||||
/// 缺省容错:个别兼容端点同步响应省略 id → 缺省空串,由 parse_tool_calls 的
|
||||
/// tool_call_id_or_fallback 生成唯一 fallback。此前必填,单条 tool_call 缺 id 令
|
||||
/// 整个响应 serde 失败 → complete 报"响应解析失败"整批丢弃(流式路径早已容错)。
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
// 反序列化 #[serde(rename="type")] 字段,保留以对齐 OpenAI 响应结构,标注意图消除 dead_code warning
|
||||
#[allow(dead_code)]
|
||||
#[serde(rename = "type", default)]
|
||||
pub call_type: String,
|
||||
pub function: OpenAiFunctionResp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiFunctionResp {
|
||||
pub name: String,
|
||||
/// 缺省容错:arguments 缺失 → 空串(对齐 anthropic 同步 tool_use input 缺失 args="" 口径),
|
||||
/// 防单条缺字段令整个响应解析失败;name 保持必填(无名 tool_call 无法派发,无安全缺省)。
|
||||
#[serde(default)]
|
||||
pub arguments: String,
|
||||
}
|
||||
|
||||
/// OpenAI 标准嵌套缓存口径形态(usage.prompt_tokens_details):
|
||||
/// GLM/Qwen/OpenAI 官方在嵌套结构报缓存命中,DeepSeek 用扁平 prompt_cache_hit_tokens。
|
||||
/// cached_tokens 用 Option 容错给空 details 对象的网关。
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub(crate) struct PromptTokensDetails {
|
||||
#[serde(default)]
|
||||
pub cached_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
/// usage 反序列化中间载体:与 OpenAiUsage 字段同构,经 `From` 做缓存口径归一后产出
|
||||
/// OpenAiUsage(见 impl From)。流式 SSE 与非流式 JSON 两条解析路径因此都自动获得
|
||||
/// 嵌套 cached_tokens 回填,消费方一律读归一后的扁平字段。
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiUsageRaw {
|
||||
/// 三个计数缺省 0:部分兼容网关 usage 只报部分字段(如仅 total_tokens),必填会让
|
||||
/// 整帧 serde 失败走空 chunk 分支 — 同帧 content 一并蒸发、usage 归零无痕。
|
||||
#[serde(default)]
|
||||
pub prompt_tokens: u32,
|
||||
#[serde(default)]
|
||||
pub completion_tokens: u32,
|
||||
#[serde(default)]
|
||||
pub total_tokens: u32,
|
||||
/// DeepSeek 扩展:缓存命中 token(低价,deepseek-chat/reasoner)。无此字段 → default 0。
|
||||
#[serde(default)]
|
||||
pub prompt_cache_hit_tokens: u32,
|
||||
/// DeepSeek 扩展:未命中 token(全价真实输入,prompt_cache_miss_tokens)。无此字段 → default 0。
|
||||
#[serde(default)]
|
||||
pub prompt_cache_miss_tokens: u32,
|
||||
/// DeepSeek-reasoner / OpenAI o1 扩展:思考 token(隐藏输出,reasoning_tokens)。
|
||||
/// 非 reasoning 模型无此字段 → default 0。
|
||||
#[serde(default)]
|
||||
pub reasoning_tokens: u32,
|
||||
/// OpenAI 标准嵌套缓存口径(GLM/Qwen/OpenAI 官方)。
|
||||
#[serde(default)]
|
||||
pub prompt_tokens_details: Option<PromptTokensDetails>,
|
||||
}
|
||||
|
||||
/// OpenAI usage(缓存口径已归一)
|
||||
///
|
||||
/// 归一发生在反序列化时(`#[serde(from)]`):扁平 hit 有值优先(DeepSeek 自报 hit+miss);
|
||||
/// 扁平 hit 未报而嵌套 cached_tokens > 0 时回填 hit,miss 推断为 prompt - hit(嵌套口径
|
||||
/// 缺全价分项);两种口径都没有则保持 0。消费方(apply_openai_sse / openai_compat 非流式)
|
||||
/// 直接读扁平字段,无需感知 provider 差异。
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(from = "OpenAiUsageRaw")]
|
||||
pub(crate) struct OpenAiUsage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
pub total_tokens: u32,
|
||||
/// 缓存命中 token(低价;扁平优先,缺失时回填自嵌套 cached_tokens)
|
||||
pub prompt_cache_hit_tokens: u32,
|
||||
/// 缓存未命中 token(全价真实输入;嵌套口径推断时 = prompt - hit)
|
||||
pub prompt_cache_miss_tokens: u32,
|
||||
/// 思考 token(隐藏输出)
|
||||
pub reasoning_tokens: u32,
|
||||
}
|
||||
|
||||
impl From<OpenAiUsageRaw> for OpenAiUsage {
|
||||
fn from(raw: OpenAiUsageRaw) -> Self {
|
||||
let (hit, miss) = match raw.prompt_tokens_details.and_then(|d| d.cached_tokens) {
|
||||
Some(nested) if nested > 0 && raw.prompt_cache_hit_tokens == 0 => {
|
||||
let hit = u32::try_from(nested).unwrap_or(u32::MAX);
|
||||
(hit, raw.prompt_tokens.saturating_sub(hit))
|
||||
}
|
||||
_ => (raw.prompt_cache_hit_tokens, raw.prompt_cache_miss_tokens),
|
||||
};
|
||||
Self {
|
||||
prompt_tokens: raw.prompt_tokens,
|
||||
completion_tokens: raw.completion_tokens,
|
||||
total_tokens: raw.total_tokens,
|
||||
prompt_cache_hit_tokens: hit,
|
||||
prompt_cache_miss_tokens: miss,
|
||||
reasoning_tokens: raw.reasoning_tokens,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SSE 流式响应 chunk
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiStreamChunk {
|
||||
/// 流式 choice 数组。标准 error 帧(`{"error":{..}}`)与 usage 尾帧均不带 choices 键,
|
||||
/// 缺省为空数组才能整帧解析成功;必填会让这类合法帧 serde 失败走空 chunk 分支,
|
||||
/// 流呈无声截断。
|
||||
#[serde(default)]
|
||||
pub choices: Vec<OpenAiStreamChoice>,
|
||||
/// 末 chunk(choices 为空)携带的累计 usage
|
||||
#[serde(default)]
|
||||
pub usage: Option<OpenAiUsage>,
|
||||
/// 流中途 error 事件(OpenAI 兼容协议:`{"error":{"message":..,"type":..}}`)。
|
||||
/// 部分中转站按 OpenAI 协议在流中途发 error 帧而非走 HTTP 非 200,
|
||||
/// serde default + Value 兜底:旧响应无此字段不受影响,且对 error 载荷形态不敏感。
|
||||
#[serde(default)]
|
||||
pub error: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiStreamChoice {
|
||||
/// 部分 usage 尾帧的 choice 仅带 finish_reason 无 delta 键,缺省按空 delta 处理
|
||||
#[serde(default)]
|
||||
pub delta: OpenAiStreamDelta,
|
||||
pub finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub(crate) struct OpenAiStreamDelta {
|
||||
pub content: Option<String>,
|
||||
pub tool_calls: Option<Vec<OpenAiStreamToolCall>>,
|
||||
/// DeepSeek thinking 模式流式推理内容
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiStreamToolCall {
|
||||
/// 部分端点 tool_call 增量省略 index,缺省 0(单工具调用场景语义等价)
|
||||
#[serde(default)]
|
||||
pub index: u32,
|
||||
pub id: Option<String>,
|
||||
pub function: Option<OpenAiStreamFunction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiStreamFunction {
|
||||
pub name: Option<String>,
|
||||
pub arguments: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SSE 解析纯函数(与 HTTP 解耦,便于单测)
|
||||
// ============================================================
|
||||
|
||||
/// 将一条 OpenAI 兼容 SSE 事件 data 解析为 StreamChunk,并按需更新 usage 累加器。
|
||||
///
|
||||
/// - `[DONE]` → 返回 `finished=true` 的终态 chunk,`usage` 取自累加器(`take()`)。
|
||||
/// - 普通文本/工具增量 chunk → 返回对应 `StreamChunk`,usage 字段恒为 None(usage 仅在终态带出)。
|
||||
/// - usage(`stream_options.include_usage` 时末段或 usage-only chunk 携带)→ 覆盖累加器(覆盖语义保对)。
|
||||
/// - 解析失败 → 返回空 chunk(与原内联实现一致)。
|
||||
///
|
||||
/// 等价性:delta / tool_calls / finished / 解析失败等分支与原 stream() 闭包逐字一致;
|
||||
/// usage 透传([DONE] 终态 take() 带出、usage chunk 覆盖累加器)为本次新增能力,
|
||||
/// 对应 StreamChunk 新增的 usage 字段 + 请求体新增 stream_options.include_usage。
|
||||
pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>) -> StreamChunk {
|
||||
// OpenAI 发送 "data: [DONE]" 表示流结束,带出累积 usage
|
||||
if data == "[DONE]" {
|
||||
return StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: true,
|
||||
tool_calls: None,
|
||||
usage: usage_accum.take(),
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
truncated_by_max_tokens: false,
|
||||
};
|
||||
}
|
||||
|
||||
match serde_json::from_str::<OpenAiStreamChunk>(data) {
|
||||
Ok(chunk) => {
|
||||
// 流中途 error 事件(中转站按 OpenAI 协议在流中途发 error 帧)。
|
||||
// 不走 finished 完成路径(避免残缺响应被当正常完成入库),由 stream_llm
|
||||
// 识别 error 非空 → 发 AiError + 丢弃残缺(对齐 anthropic_helpers 215-219)。
|
||||
if let Some(err_val) = chunk.error {
|
||||
let msg = err_val
|
||||
.get("message")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("stream error")
|
||||
.to_string();
|
||||
error!(%msg, raw = %err_val, "OpenAI 流式错误事件");
|
||||
return StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: false,
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
error: Some(msg),
|
||||
reasoning_content: None,
|
||||
truncated_by_max_tokens: false,
|
||||
};
|
||||
}
|
||||
// 提取 usage(带 include_usage 时末段 chunk 携带,覆盖累积)
|
||||
if let Some(u) = chunk.usage {
|
||||
// 缺省计数的留痕:两个分项均 0 基本可断定 provider 上报不完整(真实完成
|
||||
// 必有 completion 计数),非流路径下游拿到零 usage 无从察觉。
|
||||
if u.prompt_tokens == 0 && u.completion_tokens == 0 {
|
||||
tracing::warn!(
|
||||
total = u.total_tokens,
|
||||
"[OpenAI] usage 缺 prompt/completion 分项(按 0 兜底),provider 上报不完整"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
prompt = u.prompt_tokens,
|
||||
completion = u.completion_tokens,
|
||||
cache_hit = u.prompt_cache_hit_tokens,
|
||||
cache_miss = u.prompt_cache_miss_tokens,
|
||||
reasoning = u.reasoning_tokens,
|
||||
"[OpenAI] 末 chunk usage 解析(deepseek 等报 cache)"
|
||||
);
|
||||
*usage_accum = Some(TokenUsage {
|
||||
prompt_tokens: u.prompt_tokens,
|
||||
completion_tokens: u.completion_tokens,
|
||||
total_tokens: u.total_tokens,
|
||||
prompt_cache_hit_tokens: u.prompt_cache_hit_tokens,
|
||||
prompt_cache_miss_tokens: u.prompt_cache_miss_tokens,
|
||||
reasoning_tokens: u.reasoning_tokens,
|
||||
});
|
||||
}
|
||||
if let Some(choice) = chunk.choices.into_iter().next() {
|
||||
let delta_text = choice.delta.content.unwrap_or_default();
|
||||
// 终态 finish_reason:stop(正常结束)/ tool_calls(转交工具执行)/
|
||||
// length(max_tokens 截断,属正常终止非断连)/ content_filter(内容审查拦截)。
|
||||
// content_filter 的流部分 provider 不发 [DONE] 帧,不纳入终态会一直悬挂
|
||||
let finished = matches!(
|
||||
choice.finish_reason.as_deref(),
|
||||
Some("stop") | Some("tool_calls") | Some("length") | Some("content_filter")
|
||||
);
|
||||
// length = 输出被 max_tokens 截断:仍按正常终止收敛(finished=true),
|
||||
// 但截断标志随 chunk 带出,消费方据此把该轮产出标记截断,不冒充完整。
|
||||
let truncated_by_max_tokens = choice.finish_reason.as_deref() == Some("length");
|
||||
|
||||
let tool_calls = choice.delta.tool_calls.map(|tcs| {
|
||||
// 缺 index 的增量缺省 0(单工具调用语义等价);但同帧多 call 且 index
|
||||
// 冲突时缺省会互相踩(下游按 index 累积 → 参数拼错位),warn 留痕定位
|
||||
// provider 兼容缺陷,不做静默改写。
|
||||
if tcs.len() > 1 {
|
||||
let mut idxs: Vec<u32> = tcs.iter().map(|tc| tc.index).collect();
|
||||
idxs.sort_unstable();
|
||||
idxs.dedup();
|
||||
if idxs.len() < tcs.len() {
|
||||
tracing::warn!(
|
||||
calls = tcs.len(),
|
||||
unique = idxs.len(),
|
||||
"[OpenAI] 同帧多 tool_call 增量 index 冲突(缺省 0 互踩),按 index 累积将错位"
|
||||
);
|
||||
}
|
||||
}
|
||||
tcs.into_iter()
|
||||
.map(|tc| {
|
||||
// CR-空 id:流式 chunk 的 id 可能为 Some("")(SenseNova 兼容缺陷)。
|
||||
// 仅对「provider 显式给了 id 字段」的 chunk 做兜底——None(OpenAI
|
||||
// 协议:仅首 chunk 携带 id,后续 chunk 无 id)保持 None,避免
|
||||
// 覆盖首 chunk 的权威 id。Some("") → `gen_stream_{index}` fallback,
|
||||
// Some(非空) → 原样。下游 stream_recv 按 index 累积,draft.id 透传
|
||||
// 至 ToolCall.id(accumulate_tool_calls 仅 Some 覆盖,None 不动)。
|
||||
let id = tc.id.map(|raw| tool_call_id_or_fallback(&raw, tc.index as usize, "gen_stream"));
|
||||
ToolCallDelta {
|
||||
index: tc.index,
|
||||
id,
|
||||
function_name: tc.function.as_ref().and_then(|f| f.name.clone()),
|
||||
function_arguments: tc.function.and_then(|f| f.arguments),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
StreamChunk {
|
||||
delta: delta_text,
|
||||
finished,
|
||||
tool_calls,
|
||||
// 加固:usage 与 finish_reason 同帧的端点(部分兼容实现),此处已累积则挂上,
|
||||
// 使下游 stream_recv 不必等到 [DONE] 帧即可拿到真实 usage。
|
||||
usage: usage_accum.clone(),
|
||||
error: None,
|
||||
reasoning_content: choice.delta.reasoning_content,
|
||||
truncated_by_max_tokens,
|
||||
}
|
||||
} else {
|
||||
// choices 为空 = usage-only chunk,不输出文本(usage 已累积),usage 一并带出
|
||||
StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: false,
|
||||
tool_calls: None,
|
||||
usage: usage_accum.clone(),
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
truncated_by_max_tokens: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("SSE 数据解析失败: {} — data: {}", e, data);
|
||||
StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: false,
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
truncated_by_max_tokens: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// DeepSeek 扁平口径:hit/miss 各取其值,不参与嵌套回填
|
||||
#[test]
|
||||
fn usage_deepseek_flat_cache() {
|
||||
let u: OpenAiUsage = serde_json::from_str(
|
||||
r#"{"prompt_tokens":100,"completion_tokens":10,"total_tokens":110,
|
||||
"prompt_cache_hit_tokens":64,"prompt_cache_miss_tokens":36}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(u.prompt_tokens, 100);
|
||||
assert_eq!(u.prompt_cache_hit_tokens, 64);
|
||||
assert_eq!(u.prompt_cache_miss_tokens, 36);
|
||||
}
|
||||
|
||||
/// OpenAI/GLM 嵌套口径:扁平未报,hit 回填自 cached_tokens,miss 推断为 prompt - hit
|
||||
#[test]
|
||||
fn usage_nested_cached_tokens_backfill() {
|
||||
let u: OpenAiUsage = serde_json::from_str(
|
||||
r#"{"prompt_tokens":100,"completion_tokens":10,"total_tokens":110,
|
||||
"prompt_tokens_details":{"cached_tokens":64}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(u.prompt_cache_hit_tokens, 64);
|
||||
assert_eq!(u.prompt_cache_miss_tokens, 36);
|
||||
}
|
||||
|
||||
/// 两种口径都没有:hit/miss 保持 0(前端不渲染 cache 段)
|
||||
#[test]
|
||||
fn usage_no_cache_fields() {
|
||||
let u: OpenAiUsage = serde_json::from_str(
|
||||
r#"{"prompt_tokens":100,"completion_tokens":10,"total_tokens":110}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(u.prompt_cache_hit_tokens, 0);
|
||||
assert_eq!(u.prompt_cache_miss_tokens, 0);
|
||||
}
|
||||
|
||||
/// 端到端:usage-only chunk 经 apply_openai_sse 累加路径带出归一后的缓存分项
|
||||
#[test]
|
||||
fn sse_usage_chunk_nested_cache_reaches_token_usage() {
|
||||
let data = r#"{"choices":[],"usage":{"prompt_tokens":100,"completion_tokens":10,
|
||||
"total_tokens":110,"prompt_tokens_details":{"cached_tokens":64}}}"#;
|
||||
let mut accum = None;
|
||||
let chunk = apply_openai_sse(data, &mut accum);
|
||||
let usage = chunk.usage.expect("usage chunk 应携带 usage");
|
||||
assert_eq!(usage.prompt_cache_hit_tokens, 64);
|
||||
assert_eq!(usage.prompt_cache_miss_tokens, 36);
|
||||
}
|
||||
|
||||
/// 部分兼容网关 usage 只报部分字段:缺省 0 入累加器,同帧 content 不再因整帧
|
||||
/// serde 失败被吞(旧行为:必填字段缺失 → 空 chunk → 文本蒸发 + usage 归零无痕)
|
||||
#[test]
|
||||
fn sse_partial_usage_frame_parses_with_defaults() {
|
||||
let mut accum = None;
|
||||
let data = r#"{"choices":[{"delta":{"content":"hi"}}],"usage":{"total_tokens":10}}"#;
|
||||
let c = apply_openai_sse(data, &mut accum);
|
||||
assert_eq!(c.delta, "hi", "usage 缺分项不应令整帧解析失败丢 content");
|
||||
let u = accum.expect("部分 usage 应写入累加器");
|
||||
assert_eq!(u.total_tokens, 10);
|
||||
assert_eq!(u.prompt_tokens, 0, "缺省分项按 0 兜底");
|
||||
assert_eq!(u.completion_tokens, 0);
|
||||
}
|
||||
|
||||
/// 同帧多 tool_call 增量缺 index:缺省 0 触发 index 冲突路径(仅 warn 留痕,不改写
|
||||
/// 缺省策略);解析本身不失败,增量仍逐条带出。
|
||||
#[test]
|
||||
fn sse_tool_calls_missing_index_in_multi_call_frame_still_parse() {
|
||||
let mut accum = None;
|
||||
let data = r#"{"choices":[{"delta":{"tool_calls":[
|
||||
{"id":"call_a","function":{"name":"ta","arguments":"{"}},
|
||||
{"id":"call_b","function":{"name":"tb","arguments":"}"}}
|
||||
]}}]}"#;
|
||||
let c = apply_openai_sse(data, &mut accum);
|
||||
let tcs = c.tool_calls.expect("缺 index 的多 call 帧应可解析");
|
||||
assert_eq!(tcs.len(), 2);
|
||||
assert!(tcs.iter().all(|tc| tc.index == 0), "缺 index 缺省 0");
|
||||
}
|
||||
|
||||
/// content_filter 属终态:敏感内容拦截的流部分 provider 不发 [DONE] 帧,
|
||||
/// 必须靠 finish_reason 收敛,否则悬挂;未列入终态的 finish_reason 保持非终态
|
||||
#[test]
|
||||
fn finish_reason_content_filter_is_terminal() {
|
||||
let data = r#"{"choices":[{"delta":{"content":""},"finish_reason":"content_filter"}]}"#;
|
||||
let mut accum = None;
|
||||
let chunk = apply_openai_sse(data, &mut accum);
|
||||
assert!(chunk.finished, "content_filter 应收敛为终态,不再悬挂");
|
||||
|
||||
let mut accum2 = None;
|
||||
let chunk2 = apply_openai_sse(
|
||||
r#"{"choices":[{"delta":{"content":"x"},"finish_reason":null}]}"#,
|
||||
&mut accum2,
|
||||
);
|
||||
assert!(!chunk2.finished, "无终态 finish_reason 的普通 delta 不应终结");
|
||||
}
|
||||
|
||||
/// finish_reason=length:max_tokens 截断仍按正常终止收敛(finished=true),
|
||||
/// 但截断标志必须随 chunk 带出(零信号会让腰斩报告冒充完整完成);
|
||||
/// stop 等正常终态不置标志。
|
||||
#[test]
|
||||
fn finish_reason_length_carries_truncation_flag() {
|
||||
let mut accum = None;
|
||||
let chunk = apply_openai_sse(
|
||||
r#"{"choices":[{"delta":{"content":"报告写到一半"},"finish_reason":"length"}]}"#,
|
||||
&mut accum,
|
||||
);
|
||||
assert!(chunk.finished, "length 属正常终止");
|
||||
assert!(chunk.truncated_by_max_tokens, "length 必须带截断标志");
|
||||
|
||||
let mut accum2 = None;
|
||||
let chunk2 = apply_openai_sse(
|
||||
r#"{"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}]}"#,
|
||||
&mut accum2,
|
||||
);
|
||||
assert!(chunk2.finished && !chunk2.truncated_by_max_tokens, "stop 不置截断标志");
|
||||
|
||||
let mut accum3 = None;
|
||||
let chunk3 = apply_openai_sse(
|
||||
r#"{"choices":[{"delta":{"content":"x"},"finish_reason":null}]}"#,
|
||||
&mut accum3,
|
||||
);
|
||||
assert!(!chunk3.truncated_by_max_tokens, "普通 delta 不置截断标志");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
//! 人设系统(P0) — AgentPersona 结构体 + PersonaRegistry 注册表
|
||||
//!
|
||||
//! 构成 AI Native 的基础:角色划分是分工的前提。
|
||||
//! - 每名人设包含:能力标签、工具白名单、system prompt 模板
|
||||
//! - Registry 提供按场景/意图选人设、按 id 查人设能力
|
||||
//!
|
||||
//! 设计依据:docs/02-架构设计/构想审查/AI-Native方向与路线图-2026-06-29.md
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// 内置人设 ID 常量
|
||||
pub const PERSONA_CODER: &str = "coder";
|
||||
pub const PERSONA_REVIEWER: &str = "reviewer";
|
||||
pub const PERSONA_ARCHITECT: &str = "architect";
|
||||
pub const PERSONA_TESTER: &str = "tester";
|
||||
pub const PERSONA_ANALYST: &str = "analyst";
|
||||
|
||||
/// 人设能力标签枚举
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum PersonaCapability {
|
||||
/// 代码生成与修改
|
||||
CodeGeneration,
|
||||
/// Code Review
|
||||
CodeReview,
|
||||
/// 架构设计与分析
|
||||
ArchitectureDesign,
|
||||
/// 测试编写与执行
|
||||
Testing,
|
||||
/// 数据分析与搜索
|
||||
Analysis,
|
||||
/// 文件读写
|
||||
FileOperation,
|
||||
/// 命令执行
|
||||
CommandExecution,
|
||||
/// Git 操作
|
||||
GitOperation,
|
||||
/// 知识库操作
|
||||
KnowledgeOperation,
|
||||
/// 项目管理
|
||||
ProjectManagement,
|
||||
}
|
||||
|
||||
impl PersonaCapability {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::CodeGeneration => "代码生成",
|
||||
Self::CodeReview => "代码审查",
|
||||
Self::ArchitectureDesign => "架构设计",
|
||||
Self::Testing => "测试",
|
||||
Self::Analysis => "分析",
|
||||
Self::FileOperation => "文件操作",
|
||||
Self::CommandExecution => "命令执行",
|
||||
Self::GitOperation => "Git 操作",
|
||||
Self::KnowledgeOperation => "知识库",
|
||||
Self::ProjectManagement => "项目管理",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 人设定义
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentPersona {
|
||||
/// 唯一标识(如 "coder", "reviewer")
|
||||
pub id: String,
|
||||
/// 人设名称(如 "程序员", "审查员")
|
||||
pub name: String,
|
||||
/// 人设描述(LLM 理解用)
|
||||
pub description: String,
|
||||
/// 能力标签集
|
||||
pub capabilities: Vec<PersonaCapability>,
|
||||
/// 工具白名单(空=不限,非空=仅允许列出的工具)
|
||||
pub tool_whitelist: Vec<String>,
|
||||
/// system prompt 模板(注入 {context} {goals} 等占位符)
|
||||
pub system_prompt_template: String,
|
||||
/// 优先级(选人设时冲突排名,0=最高)
|
||||
pub priority: u8,
|
||||
}
|
||||
|
||||
impl AgentPersona {
|
||||
/// 检查此人设是否具备某项能力
|
||||
pub fn has_capability(&self, cap: &PersonaCapability) -> bool {
|
||||
self.capabilities.contains(cap)
|
||||
}
|
||||
|
||||
/// 检查某工具是否在此人设的 whitelist 中
|
||||
pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
|
||||
self.tool_whitelist.is_empty() || self.tool_whitelist.iter().any(|t| t == tool_name)
|
||||
}
|
||||
|
||||
/// 构建带上下文的 system prompt
|
||||
pub fn build_prompt(&self, context: &str, goals: &[String]) -> String {
|
||||
let goals_text = if goals.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("\n## 当前目标\n{}", goals.iter().enumerate().map(|(i, g)| format!("{}. {}", i + 1, g)).collect::<Vec<_>>().join("\n"))
|
||||
};
|
||||
self.system_prompt_template
|
||||
.replace("{context}", context)
|
||||
.replace("{goals}", &goals_text)
|
||||
}
|
||||
}
|
||||
|
||||
/// 人设注册表 — 管理内置 + 自定义人设
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PersonaRegistry {
|
||||
personae: HashMap<String, AgentPersona>,
|
||||
/// 默认人设 id(兜底)
|
||||
default_id: String,
|
||||
}
|
||||
|
||||
impl PersonaRegistry {
|
||||
/// 构造默认注册表(含 5 内置人设)
|
||||
pub fn new() -> Self {
|
||||
let mut reg = Self {
|
||||
personae: HashMap::new(),
|
||||
default_id: PERSONA_CODER.to_string(),
|
||||
};
|
||||
reg.register(Self::builtin_coder());
|
||||
reg.register(Self::builtin_reviewer());
|
||||
reg.register(Self::builtin_architect());
|
||||
reg.register(Self::builtin_tester());
|
||||
reg.register(Self::builtin_analyst());
|
||||
reg
|
||||
}
|
||||
|
||||
// ── 5 内置人设 ──
|
||||
|
||||
fn builtin_coder() -> AgentPersona {
|
||||
AgentPersona {
|
||||
id: PERSONA_CODER.to_string(),
|
||||
name: "程序员".to_string(),
|
||||
description: "负责代码生成、修改、重构。专注于实现功能、修复 bug、优化性能,不关注大范围架构变更".to_string(),
|
||||
capabilities: vec![
|
||||
PersonaCapability::CodeGeneration,
|
||||
PersonaCapability::FileOperation,
|
||||
PersonaCapability::CommandExecution,
|
||||
],
|
||||
tool_whitelist: vec![], // 不限(全部工具可用)
|
||||
system_prompt_template: "你是 DevFlow 的**程序员**(Coder)。\n\n你的职责是编写、修改和重构代码。专注于具体实现,不擅自改变整体架构。\n\n{goals}\n\n{context}".to_string(),
|
||||
priority: 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_reviewer() -> AgentPersona {
|
||||
AgentPersona {
|
||||
id: PERSONA_REVIEWER.to_string(),
|
||||
name: "审查员".to_string(),
|
||||
description: "负责 Code Review、质量审计、安全检查。审查代码正确性、性能、安全性,返回审查意见不直接改代码".to_string(),
|
||||
capabilities: vec![
|
||||
PersonaCapability::CodeReview,
|
||||
PersonaCapability::Analysis,
|
||||
],
|
||||
tool_whitelist: vec![
|
||||
"read_file".into(), "search_files".into(), "list_directory".into(),
|
||||
"git_diff".into(), "git_log".into(), "git_status".into(),
|
||||
],
|
||||
system_prompt_template: "你是 DevFlow 的**审查员**(Reviewer)。\n\n你的职责是审查代码质量、安全性和性能。**你不直接修改代码**,只输出审查意见和修改建议。\n\n{goals}\n\n{context}".to_string(),
|
||||
priority: 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_architect() -> AgentPersona {
|
||||
AgentPersona {
|
||||
id: PERSONA_ARCHITECT.to_string(),
|
||||
name: "架构师".to_string(),
|
||||
description: "负责架构设计、技术选型、模块划分。分析整体结构,制定技术方案,不关注具体代码实现".to_string(),
|
||||
capabilities: vec![
|
||||
PersonaCapability::ArchitectureDesign,
|
||||
PersonaCapability::Analysis,
|
||||
],
|
||||
tool_whitelist: vec![
|
||||
"read_file".into(), "search_files".into(), "list_directory".into(),
|
||||
"write_file".into(), // 写架构文档
|
||||
],
|
||||
system_prompt_template: "你是 DevFlow 的**架构师**(Architect)。\n\n你的职责是架构设计、技术选型和模块划分。分析系统整体结构,制定技术方案。**不编写业务代码**,输出架构文档和设计方案。\n\n{goals}\n\n{context}".to_string(),
|
||||
priority: 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_tester() -> AgentPersona {
|
||||
AgentPersona {
|
||||
id: PERSONA_TESTER.to_string(),
|
||||
name: "测试员".to_string(),
|
||||
description: "负责编写单元测试、集成测试、执行测试。验证代码正确性,不修改生产代码".to_string(),
|
||||
capabilities: vec![
|
||||
PersonaCapability::Testing,
|
||||
PersonaCapability::CommandExecution,
|
||||
PersonaCapability::FileOperation,
|
||||
],
|
||||
tool_whitelist: vec![], // 不限
|
||||
system_prompt_template: "你是 DevFlow 的**测试员**(Tester)。\n\n你的职责是编写测试、执行测试、报告测试结果。**不修改生产代码**,只修改测试代码。\n\n{goals}\n\n{context}".to_string(),
|
||||
priority: 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_analyst() -> AgentPersona {
|
||||
AgentPersona {
|
||||
id: PERSONA_ANALYST.to_string(),
|
||||
name: "分析师".to_string(),
|
||||
description: "负责数据分析、日志排查、性能分析。搜索信息、分析数据,输出分析报告".to_string(),
|
||||
capabilities: vec![
|
||||
PersonaCapability::Analysis,
|
||||
PersonaCapability::CommandExecution,
|
||||
PersonaCapability::KnowledgeOperation,
|
||||
],
|
||||
tool_whitelist: vec![
|
||||
"read_file".into(), "search_files".into(), "grep_search".into(),
|
||||
"run_command".into(), "git_log".into(),
|
||||
"knowledge_search".into(), "knowledge_inject".into(),
|
||||
],
|
||||
system_prompt_template: "你是 DevFlow 的**分析师**(Analyst)。\n\n你的职责是分析数据、排查问题、搜索信息。输出分析报告和结论。**不修改代码**。\n\n{goals}\n\n{context}".to_string(),
|
||||
priority: 4,
|
||||
}
|
||||
}
|
||||
|
||||
// ── 注册表操作 ──
|
||||
|
||||
/// 注册/覆盖人设
|
||||
pub fn register(&mut self, persona: AgentPersona) {
|
||||
self.personae.insert(persona.id.clone(), persona);
|
||||
}
|
||||
|
||||
/// 按 id 查人设
|
||||
pub fn get(&self, id: &str) -> Option<&AgentPersona> {
|
||||
self.personae.get(id)
|
||||
}
|
||||
|
||||
/// 取默认人设
|
||||
pub fn default(&self) -> &AgentPersona {
|
||||
self.personae.get(&self.default_id).expect("默认人设必存在")
|
||||
}
|
||||
|
||||
/// 设置默认人设 id
|
||||
pub fn set_default(&mut self, id: &str) {
|
||||
if self.personae.contains_key(id) {
|
||||
self.default_id = id.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// 按意图推荐人设(启发式规则)
|
||||
pub fn recommend_for_intent(&self, intent: &str) -> &AgentPersona {
|
||||
let lower = intent.to_lowercase();
|
||||
if lower.contains("review") || lower.contains("审查") || lower.contains("审计") {
|
||||
self.get(PERSONA_REVIEWER).unwrap_or_else(|| self.default())
|
||||
} else if lower.contains("架构") || lower.contains("设计") || lower.contains("architect") {
|
||||
self.get(PERSONA_ARCHITECT).unwrap_or_else(|| self.default())
|
||||
} else if lower.contains("测试") || lower.contains("test") {
|
||||
self.get(PERSONA_TESTER).unwrap_or_else(|| self.default())
|
||||
} else if lower.contains("分析") || lower.contains("分析") || lower.contains("排查") || lower.contains("search") {
|
||||
self.get(PERSONA_ANALYST).unwrap_or_else(|| self.default())
|
||||
} else {
|
||||
// 默认走 coder(代码生成是最常见场景)
|
||||
self.default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取全部人设列表
|
||||
pub fn all(&self) -> Vec<&AgentPersona> {
|
||||
let mut list: Vec<_> = self.personae.values().collect();
|
||||
list.sort_by_key(|p| p.priority);
|
||||
list
|
||||
}
|
||||
|
||||
/// 人设数量
|
||||
pub fn len(&self) -> usize {
|
||||
self.personae.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.personae.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PersonaRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn registry_has_5_builtin() {
|
||||
let reg = PersonaRegistry::new();
|
||||
assert_eq!(reg.len(), 5);
|
||||
assert!(reg.get(PERSONA_CODER).is_some());
|
||||
assert!(reg.get(PERSONA_REVIEWER).is_some());
|
||||
assert!(reg.get(PERSONA_ARCHITECT).is_some());
|
||||
assert!(reg.get(PERSONA_TESTER).is_some());
|
||||
assert!(reg.get(PERSONA_ANALYST).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_is_coder() {
|
||||
let reg = PersonaRegistry::new();
|
||||
assert_eq!(reg.default().id, PERSONA_CODER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommend_reviewer() {
|
||||
let reg = PersonaRegistry::new();
|
||||
let p = reg.recommend_for_intent("review the code changes");
|
||||
assert_eq!(p.id, PERSONA_REVIEWER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommend_architect() {
|
||||
let reg = PersonaRegistry::new();
|
||||
let p = reg.recommend_for_intent("设计新的模块架构");
|
||||
assert_eq!(p.id, PERSONA_ARCHITECT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommend_tester() {
|
||||
let reg = PersonaRegistry::new();
|
||||
let p = reg.recommend_for_intent("write tests for this module");
|
||||
assert_eq!(p.id, PERSONA_TESTER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommend_default_to_coder() {
|
||||
let reg = PersonaRegistry::new();
|
||||
let p = reg.recommend_for_intent("implement a new feature");
|
||||
assert_eq!(p.id, PERSONA_CODER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_whitelist_filters() {
|
||||
let reviewer = AgentPersona {
|
||||
id: "test".into(),
|
||||
name: "test".into(),
|
||||
description: "".into(),
|
||||
capabilities: vec![],
|
||||
tool_whitelist: vec!["read_file".into(), "search_files".into()],
|
||||
system_prompt_template: "".into(),
|
||||
priority: 5,
|
||||
};
|
||||
assert!(reviewer.is_tool_allowed("read_file"));
|
||||
assert!(!reviewer.is_tool_allowed("write_file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_whitelist_allows_all() {
|
||||
let coder = AgentPersona {
|
||||
id: "test".into(),
|
||||
name: "test".into(),
|
||||
description: "".into(),
|
||||
capabilities: vec![],
|
||||
tool_whitelist: vec![],
|
||||
system_prompt_template: "".into(),
|
||||
priority: 5,
|
||||
};
|
||||
assert!(coder.is_tool_allowed("run_command"));
|
||||
assert!(coder.is_tool_allowed("write_file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_prompt_replaces_placeholders() {
|
||||
let reg = PersonaRegistry::new();
|
||||
let coder = reg.get(PERSONA_CODER).unwrap();
|
||||
let prompt = coder.build_prompt("some context", &["goal1".into()]);
|
||||
assert!(prompt.contains("程序员"));
|
||||
assert!(prompt.contains("goal1"));
|
||||
assert!(prompt.contains("some context"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_custom_persona() {
|
||||
let mut reg = PersonaRegistry::new();
|
||||
let custom = AgentPersona {
|
||||
id: "custom".into(),
|
||||
name: "自定义".into(),
|
||||
description: "test".into(),
|
||||
capabilities: vec![],
|
||||
tool_whitelist: vec![],
|
||||
system_prompt_template: "You are custom".into(),
|
||||
priority: 0,
|
||||
};
|
||||
reg.register(custom);
|
||||
assert_eq!(reg.len(), 6);
|
||||
assert!(reg.get("custom").is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Plan 执行器(Phase 2 · DAG 分层调度)
|
||||
//!
|
||||
//! 按 Plan::to_layers 输出的层级顺序执行子任务:
|
||||
//! - 层间串行:上层全部完成才进下一层(deps 保证)
|
||||
//! - 层内并行:同层 SubTask 用 tokio::JoinSet 并发执行(设计目标;
|
||||
//! 当前为串行循环模拟,见 execute 内 TODO,本执行器未接生产链路)
|
||||
//!
|
||||
//! **当前状态**:骨架实现,PLAN_EXECUTION_ENABLED 门控(默认关)。
|
||||
//! 未接入 agentic loop 主路径,翻 true 启用后仍需与 run_agentic_loop 对接。
|
||||
//!
|
||||
//! 设计依据:`docs/02-架构设计/单对话并行多轮-设计-2026-06-20.md`
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::planner::{Plan, SubTask};
|
||||
|
||||
/// Plan 执行总开关。false = 不启用(走单链 ReAct 旧行为)。
|
||||
///
|
||||
/// 运行时原子门控,支持热切换(通过 IPC 或前端设置开关)。
|
||||
/// 默认关。现状:PlanExecutor::execute 零生产调用点(仅测试),本执行器未接入主链;
|
||||
/// agentic loop 读本开关走的是 coordinator::dispatch(Phase 1 串行桩),与本文件无关。
|
||||
/// JoinSet 真并行留 Phase 3 对接时接通(见 execute 内 TODO)。
|
||||
/// 与 PLANNING_ENABLED 解耦:plan_hint 独立使能,编排可见性始终可开。
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
pub static PLAN_EXECUTION_ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// 设置 Plan 执行开关。
|
||||
pub fn set_plan_execution(enabled: bool) {
|
||||
PLAN_EXECUTION_ENABLED.store(enabled, Ordering::SeqCst);
|
||||
tracing::info!(enabled, "[PLAN-EXEC] 执行开关已更新");
|
||||
}
|
||||
|
||||
/// 读取 Plan 执行开关。
|
||||
pub fn plan_execution_enabled() -> bool {
|
||||
PLAN_EXECUTION_ENABLED.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// 单个 SubTask 的执行结果。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SubTaskResult {
|
||||
/// 对应 SubTask id
|
||||
pub id: String,
|
||||
/// 执行是否成功
|
||||
pub success: bool,
|
||||
/// 执行产出(工具结果摘要 / LLM 回复)
|
||||
pub output: String,
|
||||
}
|
||||
|
||||
/// Plan 执行器:按 DAG 层级调度子任务。
|
||||
///
|
||||
/// 泛型参数 `F` 为子任务执行函数:
|
||||
/// - 输入:`&SubTask`(子任务定义)
|
||||
/// - 输入:`&str`(父任务结果摘要,供子任务上下文参考)
|
||||
/// - 输出:`Future<Output = anyhow::Result<String>>`(子任务产出)
|
||||
///
|
||||
/// 调用方(Phase 3 对接 agentic loop 时)传入真实的执行闭包,
|
||||
/// 每个 SubTask 独立跑一轮 agentic loop,共享父上下文快照。
|
||||
pub struct PlanExecutor;
|
||||
|
||||
impl PlanExecutor {
|
||||
/// 按 Plan DAG 分层执行。
|
||||
///
|
||||
/// - 层间串行:上层全部完成才进下一层
|
||||
/// - 层内并行:同层 SubTask 用 JoinSet 并发
|
||||
/// - 失败传播:任一 SubTask 失败时,记录错误但继续执行同层其他任务(容错);
|
||||
/// 若需中断策略,调用方据返回结果自行判断。
|
||||
///
|
||||
/// 返回每个 SubTask 的执行结果(按 Plan 原始顺序排列)。
|
||||
pub async fn execute<F, Fut>(
|
||||
plan: &Plan,
|
||||
mut run_subtask: F,
|
||||
) -> anyhow::Result<Vec<SubTaskResult>>
|
||||
where
|
||||
F: FnMut(Arc<SubTask>, String) -> Fut,
|
||||
Fut: std::future::Future<Output = anyhow::Result<String>>,
|
||||
{
|
||||
let layers = plan
|
||||
.to_layers()
|
||||
.map_err(|e| anyhow::anyhow!("Plan 环依赖: {:?}", e.cycle))?;
|
||||
|
||||
let mut results: Vec<SubTaskResult> = Vec::new();
|
||||
let mut parent_summary = String::new();
|
||||
|
||||
for (layer_idx, layer) in layers.iter().enumerate() {
|
||||
tracing::info!(
|
||||
layer_idx,
|
||||
task_count = layer.len(),
|
||||
"[plan-exec] 执行第 {} 层, {} 个子任务",
|
||||
layer_idx,
|
||||
layer.len()
|
||||
);
|
||||
|
||||
let summary_clone = parent_summary.clone();
|
||||
|
||||
for task in layer {
|
||||
let task = Arc::new(task.clone());
|
||||
let summary = summary_clone.clone();
|
||||
// TODO(Phase 3 真并行): 当前为串行循环模拟并行(假并行),仅验证分层逻辑。
|
||||
// run_subtask 是 FnMut,不能直接 move 进 JoinSet(多任务并发调用);
|
||||
// 接真并行的改法:run_subtask 换 Arc<Fn> 或 channel 模式,
|
||||
// 本层全部 spawn 进 JoinSet 统一收集后再汇总 parent_summary,替换此串行 await。
|
||||
// 现状无用户可见影响:本执行器未接生产链路(execute 零调用点)。
|
||||
let result = run_subtask(task.clone(), summary).await;
|
||||
let sr = match result {
|
||||
Ok(output) => SubTaskResult {
|
||||
id: task.id.clone(),
|
||||
success: true,
|
||||
output,
|
||||
},
|
||||
Err(e) => SubTaskResult {
|
||||
id: task.id.clone(),
|
||||
success: false,
|
||||
output: e.to_string(),
|
||||
},
|
||||
};
|
||||
results.push(sr);
|
||||
}
|
||||
|
||||
// 汇总本层结果作为下层 parent_summary
|
||||
let layer_summary: Vec<String> = results
|
||||
.iter()
|
||||
.filter(|r| layer.iter().any(|t| t.id == r.id))
|
||||
.map(|r| format!("[{}] {}", r.id, r.output))
|
||||
.collect();
|
||||
parent_summary = layer_summary.join("\n");
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::planner::{Plan, SubTask};
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_simple_linear_plan() {
|
||||
// a → b → c (三层,各一个任务)
|
||||
let plan = Plan::from_tasks(vec![
|
||||
SubTask::new("a", "task-a"),
|
||||
SubTask::new("b", "task-b").with_deps(vec!["a".to_string()]),
|
||||
SubTask::new("c", "task-c").with_deps(vec!["b".to_string()]),
|
||||
]);
|
||||
|
||||
let results = PlanExecutor::execute(&plan, |task, _parent| async move {
|
||||
Ok(format!("done:{}", task.id))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(results.len(), 3);
|
||||
assert!(results.iter().all(|r| r.success));
|
||||
assert_eq!(results[0].id, "a");
|
||||
assert_eq!(results[1].id, "b");
|
||||
assert_eq!(results[2].id, "c");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_parallel_layer() {
|
||||
// a → {b, c} → d (三层,第二层两个并行)
|
||||
let plan = Plan::from_tasks(vec![
|
||||
SubTask::new("a", "task-a"),
|
||||
SubTask::new("b", "task-b").with_deps(vec!["a".to_string()]),
|
||||
SubTask::new("c", "task-c").with_deps(vec!["a".to_string()]),
|
||||
SubTask::new("d", "task-d").with_deps(vec!["b".to_string(), "c".to_string()]),
|
||||
]);
|
||||
|
||||
let results = PlanExecutor::execute(&plan, |task, _parent| async move {
|
||||
Ok(format!("done:{}", task.id))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(results.len(), 4);
|
||||
assert!(results.iter().all(|r| r.success));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_with_failure_continues() {
|
||||
// a(fail) → b: a 失败,b 仍执行(容错策略)
|
||||
let plan = Plan::from_tasks(vec![
|
||||
SubTask::new("a", "task-a"),
|
||||
SubTask::new("b", "task-b").with_deps(vec!["a".to_string()]),
|
||||
]);
|
||||
|
||||
let results = PlanExecutor::execute(&plan, |task, _parent| async move {
|
||||
if task.id == "a" {
|
||||
anyhow::bail!("task-a failed")
|
||||
}
|
||||
Ok("ok".to_string())
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(results.len(), 2);
|
||||
assert!(!results[0].success); // a 失败
|
||||
assert!(results[1].success); // b 仍执行
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
//! 单对话并行多轮 — Phase 0a · 0.8 plan_hint 纯函数
|
||||
//!
|
||||
//! 依据:`docs/02-架构设计/单对话并行多轮-Phase0落地路线图-2026-06-20.md` 第 0.8 节。
|
||||
//! 上游设计:`单对话并行多轮-设计-2026-06-20.md`。
|
||||
//!
|
||||
//! ## 定位
|
||||
//! Phase 0a 的**纯函数地基**:基于用户意图(`crate::intent::Intent`)/对话上下文关键词,
|
||||
//! 产出一份「编排提示」(`PlanHint`)——告诉主 loop「这批工具调用可以并行」/「这些有
|
||||
//! 顺序依赖」。它**扩 `intent::tool_subset_for` 语义**:后者只返扁平 tool 子集,本模块
|
||||
//! 在其之上叠加「并行组 / 顺序依赖」的编排关系。
|
||||
//!
|
||||
//! ## 纯函数边界(零 IO 零状态)
|
||||
//! - 无 `.await` / 无 db / 无 provider / 无全局状态。
|
||||
//! - 输入:意图标签 + 上下文关键词(纯字符串切片)。
|
||||
//! - 输出:`PlanHint`(数据结构 + 启发式算法产物)。
|
||||
//! - **不调 LLM**:LLM 调度(plan_hint 落 prompt 让模型产 Plan)留 Phase 2;本模块是
|
||||
//! Phase 1 主 loop 接入前的「确定性降级路径」——主 loop 拿到 PlanHint 直接做编排,
|
||||
//! 无需等 Phase 2 LLM 介入也能跑通串行/并行调度。
|
||||
//!
|
||||
//! ## 与 0.7 planner.rs 的关系
|
||||
//! 本 agent 独立先做(避免等 agent A),用**自有轻量 `PlanHint` 结构**。
|
||||
//! - `PlanHint` 是「提示」层(启发式,可错,主 loop 可忽略);
|
||||
//! - `Plan`(`planner.rs`,agent A 出)是「计划」层(LLM/用户确认后的硬编排)。
|
||||
//! Phase 1 接入主 loop 时,两者字段(group/dep)对齐,主 loop 优先消费 `Plan`,无 `Plan`
|
||||
//! 时退 `PlanHint`。结构对齐工作放 Phase 1,本批互不阻塞。
|
||||
//!
|
||||
//! ## feature flag
|
||||
//! `PLAN_HINT_ENABLED` 常量占位。关时单链 ReAct=旧行为(主 loop 不读 PlanHint)。
|
||||
//! Phase 1 接入时主 loop 据 `PLAN_HINT_ENABLED` 决定是否走 plan_hint 分支。
|
||||
|
||||
// ---- feature flag 占位 ------------------------------------------------------
|
||||
|
||||
/// plan_hint 总开关。Phase 0a 先占位为 `true`(纯函数无副作用可单测)。
|
||||
/// Phase 1 接入主 loop 时,关闭则主 loop 不读 PlanHint,退单链 ReAct(旧行为)。
|
||||
/// 真正的运行时开关(配置驱动)留 Phase 1,本批用编译期常量便于单测固定行为。
|
||||
pub const PLAN_HINT_ENABLED: bool = true;
|
||||
|
||||
// ---- 数据结构 --------------------------------------------------------------
|
||||
|
||||
/// 并行组:一组可同时调度的工具名(组内无顺序约束,可并行执行)。
|
||||
///
|
||||
/// 工具名对齐 `intent::tool_subset_for` 返回的工具名常量(read_file/write_file/...,
|
||||
/// 见 `intent::ToolDomain::tools`)。空组无意义(构造时应保证非空,但 validate 兜底)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ParallelGroup {
|
||||
/// 组内工具名列表(均为 `intent` crate 内声明的注册名)。
|
||||
pub tools: Vec<&'static str>,
|
||||
}
|
||||
|
||||
impl ParallelGroup {
|
||||
/// 构造一个并行组。空 tools 允许构造(validate 阶段标记非法),调用方应保证非空。
|
||||
pub fn new(tools: Vec<&'static str>) -> Self {
|
||||
Self { tools }
|
||||
}
|
||||
|
||||
/// 组内工具数。
|
||||
pub fn len(&self) -> usize {
|
||||
self.tools.len()
|
||||
}
|
||||
|
||||
/// 是否为空组。
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.tools.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 顺序依赖边:`from` 必须先于 `to` 完成(A → B)。
|
||||
///
|
||||
/// 用于表达「读后写」「先查后改」等强顺序约束。`from`/`to` 是工具名
|
||||
/// (或更细的工具调用标签,Phase 2 LLM 产 Plan 时可携带调用 id;本批仅工具名粒度)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SequentialDep {
|
||||
/// 前置工具(先执行)。
|
||||
pub from: &'static str,
|
||||
/// 后置工具(待 from 完成后执行)。
|
||||
pub to: &'static str,
|
||||
}
|
||||
|
||||
impl SequentialDep {
|
||||
/// 构造一条顺序依赖边。
|
||||
pub fn new(from: &'static str, to: &'static str) -> Self {
|
||||
Self { from, to }
|
||||
}
|
||||
}
|
||||
|
||||
/// 编排提示(轻量 PlanHint)。
|
||||
///
|
||||
/// - `groups`:可并行执行的分组(每组内工具无序,可同时调度)。
|
||||
/// - `sequence`:跨组的顺序依赖边(DAG 边)。
|
||||
///
|
||||
/// **合法 PlanHint 约束**(与 0.7 Kahn 拓扑对齐):
|
||||
/// 1. `groups` 内各组 `tools` 非空;
|
||||
/// 2. `sequence` 不含自环(`from != to`);
|
||||
/// 3. `sequence` 无重复边(去重);
|
||||
/// 4. `sequence` 构成的有向图**无环**(DAG)——`validate` 用 Kahn 判定。
|
||||
///
|
||||
/// validate 失败时主 loop 应**丢弃** PlanHint 退单链(不能据此调度,否则死锁/无限循环)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct PlanHint {
|
||||
/// 并行组列表。空 `Vec` 表示「无并行提示」(单链或全顺序)。
|
||||
pub groups: Vec<ParallelGroup>,
|
||||
/// 顺序依赖边列表。空 `Vec` 表示「无跨组顺序约束」(组间全并行)。
|
||||
pub sequence: Vec<SequentialDep>,
|
||||
}
|
||||
|
||||
impl PlanHint {
|
||||
/// 空提示(无并行、无顺序)。主 loop 见到此值应退旧行为。
|
||||
pub fn empty() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 是否为空提示(无任何编排信息)。
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.groups.is_empty() && self.sequence.is_empty()
|
||||
}
|
||||
|
||||
/// 工具总数(所有并行组工具去重后的并集大小)。
|
||||
pub fn tool_count(&self) -> usize {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for g in &self.groups {
|
||||
for t in &g.tools {
|
||||
seen.insert(*t);
|
||||
}
|
||||
}
|
||||
seen.len()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 校验(Kahn 拓扑判 DAG 无环,对齐 0.7) --------------------------------
|
||||
|
||||
/// PlanHint 合法性校验结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum HintValidity {
|
||||
/// 合法,可交付主 loop 调度。
|
||||
Ok,
|
||||
/// 非法(附原因)。主 loop 应丢弃 PlanHint 退单链。
|
||||
Invalid(&'static str),
|
||||
}
|
||||
|
||||
/// 校验 PlanHint 合法性(纯函数)。
|
||||
///
|
||||
/// 检查四项(见 `PlanHint` 文档约束):
|
||||
/// 1. 组内非空;
|
||||
/// 2. 无自环(`from != to`);
|
||||
/// 3. 无重复边;
|
||||
/// 4. **Kahn 拓扑判无环**(与 0.7 `Plan::validate` 同算法,这里在 PlanHint 层独立实现,
|
||||
/// 避免依赖 agent A 的 planner.rs;Phase 1 接入时若 planner 已稳定可抽公共函数)。
|
||||
///
|
||||
/// 返回 `Invalid(reason)` 时 reason 是静态原因标签(便于日志/断言)。
|
||||
pub fn validate(hint: &PlanHint) -> HintValidity {
|
||||
// 1. 组内非空
|
||||
for g in &hint.groups {
|
||||
if g.tools.is_empty() {
|
||||
return HintValidity::Invalid("empty_parallel_group");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 无自环
|
||||
for d in &hint.sequence {
|
||||
if d.from == d.to {
|
||||
return HintValidity::Invalid("self_loop_dep");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 无重复边(同 from→to 不重复)
|
||||
let mut seen_edges = std::collections::HashSet::new();
|
||||
for d in &hint.sequence {
|
||||
if !seen_edges.insert((d.from, d.to)) {
|
||||
return HintValidity::Invalid("duplicate_dep_edge");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Kahn 拓扑判无环
|
||||
if has_cycle(&hint.sequence) {
|
||||
return HintValidity::Invalid("cyclic_dep");
|
||||
}
|
||||
|
||||
HintValidity::Ok
|
||||
}
|
||||
|
||||
/// Kahn 算法判有向边集合是否含环(纯函数)。
|
||||
///
|
||||
/// 输入是 `SequentialDep` 边列表,节点是工具名(`&'static str`)。
|
||||
/// 返回 `true` = 有环(非法),`false` = DAG(合法)。
|
||||
///
|
||||
/// 实现:统计入度 → 入度 0 的节点入队 → BFS 出队并削减邻居入度 →
|
||||
/// 最终已出队节点数 < 总节点数 → 存在环。
|
||||
fn has_cycle(edges: &[SequentialDep]) -> bool {
|
||||
if edges.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 收集所有节点(去重)。
|
||||
let mut nodes: std::collections::HashSet<&'static str> = std::collections::HashSet::new();
|
||||
for d in edges {
|
||||
nodes.insert(d.from);
|
||||
nodes.insert(d.to);
|
||||
}
|
||||
let n = nodes.len();
|
||||
if n == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 节点 → 索引(便于用 Vec 计数)。
|
||||
let mut idx: std::collections::HashMap<&'static str, usize> =
|
||||
std::collections::HashMap::with_capacity(n);
|
||||
for (i, name) in nodes.iter().enumerate() {
|
||||
idx.insert(*name, i);
|
||||
}
|
||||
|
||||
// 入度表 + 邻接表。
|
||||
let mut indeg = vec![0usize; n];
|
||||
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
|
||||
for d in edges {
|
||||
let u = idx[&d.from];
|
||||
let v = idx[&d.to];
|
||||
adj[u].push(v);
|
||||
indeg[v] += 1;
|
||||
}
|
||||
|
||||
// BFS from 入度 0 节点。
|
||||
let mut queue: std::collections::VecDeque<usize> = std::collections::VecDeque::new();
|
||||
for (i, &d) in indeg.iter().enumerate() {
|
||||
if d == 0 {
|
||||
queue.push_back(i);
|
||||
}
|
||||
}
|
||||
|
||||
let mut visited = 0usize;
|
||||
while let Some(u) = queue.pop_front() {
|
||||
visited += 1;
|
||||
for &v in &adj[u] {
|
||||
indeg[v] -= 1;
|
||||
if indeg[v] == 0 {
|
||||
queue.push_back(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 访问到的节点数 < 总节点数 → 存在环(有节点入度永远不为 0)。
|
||||
visited < n
|
||||
}
|
||||
|
||||
// ---- 启发式产 PlanHint(纯函数,无 LLM) -----------------------------------
|
||||
|
||||
/// 输入:意图标签(来自 `intent::recognize`)+ 上下文关键词(原始用户消息)。
|
||||
///
|
||||
/// 关键词检测用 `str::contains`(中英文都支持,与 `intent::recognize` 一致风格)。
|
||||
/// 输出基于**确定性启发式规则**(非 LLM),规则覆盖 devflow 常见编排场景:
|
||||
///
|
||||
/// | 场景关键词 | 产出 |
|
||||
/// |---|---|
|
||||
/// | 读多个 + 写后改 | [读组 并行] → write_file/patch_file |
|
||||
/// | grep/find 后改 | search_files → write_file/patch_file |
|
||||
/// | http + 写文件 | http_request → write_file(先取再存) |
|
||||
/// | 仅读多文件 | 全并行单组 |
|
||||
/// | 单工具 / 无编排信号 | 空 PlanHint(主 loop 退单链) |
|
||||
///
|
||||
/// **重要**:这是降级路径(Phase 2 前)。LLM 产 Plan 的质量一定高于本启发式,
|
||||
/// 但本函数保证 Phase 1 主 loop 接入后**无 LLM 也能跑通基本编排**(读后写/grep 后改)。
|
||||
///
|
||||
/// `intent_label` 仅为日志/未来扩展预留(当前规则用关键词驱动,intent_label 不参与判定,
|
||||
/// 保留参数对齐 Phase 1 主 loop 调用签名:`plan_hint(&intent, &message)`)。
|
||||
pub fn plan_hint(intent_label: &str, context_keywords: &str) -> PlanHint {
|
||||
// 开关关闭:直接返空(主 loop 退单链)。Phase 1 接入时主 loop 据常量短路,这里也兜底。
|
||||
if !PLAN_HINT_ENABLED {
|
||||
return PlanHint::empty();
|
||||
}
|
||||
|
||||
let _ = intent_label; // 预留:当前规则纯关键词驱动,不读 intent。
|
||||
|
||||
let ctx = context_keywords;
|
||||
let lower = ctx.to_lowercase();
|
||||
|
||||
// ---- 规则 1:grep/搜索 后改 → search_files → write_file/patch_file ----
|
||||
let is_search = lower.contains("grep")
|
||||
|| lower.contains("搜索")
|
||||
|| lower.contains("查找")
|
||||
|| lower.contains("find ");
|
||||
let is_modify = lower.contains("修改")
|
||||
|| lower.contains("改")
|
||||
|| lower.contains("write")
|
||||
|| lower.contains("patch")
|
||||
|| lower.contains("写入")
|
||||
|| lower.contains("重命名")
|
||||
|| lower.contains("rename");
|
||||
if is_search && is_modify {
|
||||
return PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["search_files"]),
|
||||
ParallelGroup::new(vec!["write_file", "patch_file", "rename_file"]),
|
||||
],
|
||||
sequence: vec![SequentialDep::new("search_files", "write_file")],
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 规则 2:http 取数 + 写文件 → http_request → write_file ----
|
||||
let is_http = lower.contains("http")
|
||||
|| lower.contains("api")
|
||||
|| lower.contains("请求")
|
||||
|| lower.contains("fetch");
|
||||
if is_http && (lower.contains("保存") || lower.contains("写入") || lower.contains("write")) {
|
||||
return PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["http_request"]),
|
||||
ParallelGroup::new(vec!["write_file"]),
|
||||
],
|
||||
sequence: vec![SequentialDep::new("http_request", "write_file")],
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 规则 3:读多文件(无写) → 全并行单组 ----
|
||||
let read_count = [
|
||||
("read_file", lower.matches("read").count() + lower.matches("读取").count()),
|
||||
("list_directory", lower.matches("目录").count() + lower.matches("directory").count()),
|
||||
]
|
||||
.iter()
|
||||
.filter(|(_, c)| *c >= 1)
|
||||
.count();
|
||||
if read_count >= 2 && !is_modify {
|
||||
// 多种读工具信号 → 单并行组(可同时调度)
|
||||
let mut tools: Vec<&'static str> = Vec::new();
|
||||
if lower.contains("read") || lower.contains("读取") {
|
||||
tools.push("read_file");
|
||||
}
|
||||
if lower.contains("目录") || lower.contains("directory") {
|
||||
tools.push("list_directory");
|
||||
}
|
||||
if tools.len() >= 2 {
|
||||
return PlanHint {
|
||||
groups: vec![ParallelGroup::new(tools)],
|
||||
sequence: vec![],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 规则 4:读后写 → read_file → write_file/patch_file ----
|
||||
let is_read = lower.contains("read") || lower.contains("读取");
|
||||
if is_read && is_modify {
|
||||
return PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["read_file"]),
|
||||
ParallelGroup::new(vec!["write_file", "patch_file"]),
|
||||
],
|
||||
sequence: vec![SequentialDep::new("read_file", "write_file")],
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 兜底:无编排信号 → 空 PlanHint(主 loop 退单链) ----
|
||||
PlanHint::empty()
|
||||
}
|
||||
|
||||
// ---- 单测 -------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// --- feature flag 占位 ---
|
||||
|
||||
#[test]
|
||||
fn plan_hint_enabled_is_true_in_phase0a() {
|
||||
// Phase 0a 占位为 true,纯函数可单测。Phase 1 接入后改配置驱动。
|
||||
assert!(PLAN_HINT_ENABLED, "Phase 0a 占位应为 true");
|
||||
}
|
||||
|
||||
// --- 数据结构基本 ---
|
||||
|
||||
#[test]
|
||||
fn parallel_group_new_and_len() {
|
||||
let g = ParallelGroup::new(vec!["read_file", "write_file"]);
|
||||
assert_eq!(g.len(), 2);
|
||||
assert!(!g.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_group_empty_construct_allowed_but_invalid() {
|
||||
// 空 tools 允许构造(调用方失误),validate 阶段标记非法
|
||||
let g = ParallelGroup::new(vec![]);
|
||||
assert!(g.is_empty());
|
||||
assert_eq!(g.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequential_dep_new() {
|
||||
let d = SequentialDep::new("read_file", "write_file");
|
||||
assert_eq!(d.from, "read_file");
|
||||
assert_eq!(d.to, "write_file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_hint_empty_is_empty() {
|
||||
let h = PlanHint::empty();
|
||||
assert!(h.is_empty());
|
||||
assert_eq!(h.tool_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_hint_tool_count_dedup_across_groups() {
|
||||
// 两组含同名工具 → tool_count 去重
|
||||
let h = PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["read_file", "write_file"]),
|
||||
ParallelGroup::new(vec!["read_file", "patch_file"]),
|
||||
],
|
||||
sequence: vec![],
|
||||
};
|
||||
// read_file/write_file/patch_file = 3(去重)
|
||||
assert_eq!(h.tool_count(), 3);
|
||||
}
|
||||
|
||||
// --- plan_hint 基本编排提示 ---
|
||||
|
||||
#[test]
|
||||
fn hint_read_then_modify_yields_sequence() {
|
||||
// 规则 4:读后写 → read_file → write_file
|
||||
let h = plan_hint("code", "先 read 这个文件再修改它");
|
||||
assert!(!h.is_empty(), "读后写应产非空 hint");
|
||||
assert_eq!(h.groups.len(), 2, "应分两组(读组/改组)");
|
||||
assert!(h.sequence.iter().any(|d| d.from == "read_file" && d.to == "write_file"),
|
||||
"应含 read_file→write_file 依赖边");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_grep_then_modify_yields_search_first() {
|
||||
// 规则 1:grep 后改 → search_files → write_file
|
||||
let h = plan_hint("search", "grep 一下再修改 write_file");
|
||||
assert!(!h.is_empty());
|
||||
// 首组应是 search_files
|
||||
assert!(h.groups[0].tools.contains(&"search_files"));
|
||||
assert!(h.sequence.iter().any(|d| d.from == "search_files"),
|
||||
"search_files 应作为顺序依赖起点");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_http_then_save_yields_http_first() {
|
||||
// 规则 2:http + 保存 → http_request → write_file
|
||||
let h = plan_hint("http", "请求这个 api 并保存写入到文件");
|
||||
assert!(!h.is_empty());
|
||||
assert!(h.sequence.iter().any(|d| d.from == "http_request" && d.to == "write_file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_multi_read_only_yields_single_parallel_group() {
|
||||
// 规则 3:读多文件(无写)→ 单并行组
|
||||
let h = plan_hint("file", "read 文件,再看一下 directory 目录");
|
||||
assert!(!h.is_empty());
|
||||
assert_eq!(h.groups.len(), 1, "应单并行组");
|
||||
let tools = &h.groups[0].tools;
|
||||
assert!(tools.contains(&"read_file"), "组内应含 read_file");
|
||||
assert!(tools.contains(&"list_directory"), "组内应含 list_directory");
|
||||
assert!(h.sequence.is_empty(), "全并行无顺序依赖");
|
||||
}
|
||||
|
||||
// --- 并行组识别 ---
|
||||
|
||||
#[test]
|
||||
fn parallel_group_tools_are_registry_names() {
|
||||
// 对抗:产出的工具名必须在 intent ToolDomain 注册表内(防笔误)。
|
||||
// 注:intent::ToolDomain::tools() 是 private 方法(同模块 intent.rs 内单测可用,
|
||||
// 跨模块不可访问)。本测用从 intent.rs:246-284 抄录的 registry 名常量做校验,
|
||||
// 不依赖私有方法访问(保持 plan_hint.rs 独立可单测)。若 registry 名漂移,
|
||||
// intent.rs 的 tool_domain_names_match_registry 测会先抓到,本测同步对齐。
|
||||
let registry: std::collections::HashSet<&str> = [
|
||||
// Data domain
|
||||
"list_projects", "create_project", "update_project", "delete_project",
|
||||
"bind_directory", "list_tasks", "create_task", "update_task", "advance_task",
|
||||
"delete_task", "list_ideas", "create_idea", "run_workflow", "list_trash",
|
||||
"restore_project", "purge_project", "get_project_count", "get_task_count",
|
||||
// File domain
|
||||
"read_file", "write_file", "patch_file", "list_directory", "search_files",
|
||||
"file_info", "append_file", "delete_file", "rename_file",
|
||||
// Exec domain
|
||||
"run_command",
|
||||
// Http domain
|
||||
"http_request",
|
||||
]
|
||||
.iter()
|
||||
.copied()
|
||||
.collect();
|
||||
let h = plan_hint("file", "read 再读取目录 directory");
|
||||
if !h.is_empty() {
|
||||
for g in &h.groups {
|
||||
for t in &g.tools {
|
||||
assert!(registry.contains(*t), "工具名 {} 不在 registry", t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_groups_tools_non_empty_when_non_empty_hint() {
|
||||
// 非空 hint 的每组都应非空(由 plan_hint 产出保证,validate 兜底)
|
||||
let h = plan_hint("code", "read 后 write_file 修改");
|
||||
if !h.is_empty() {
|
||||
for g in &h.groups {
|
||||
assert!(!g.tools.is_empty(), "产出组的 tools 不应空");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 顺序依赖 ---
|
||||
|
||||
#[test]
|
||||
fn hint_sequence_edges_from_less_than_to() {
|
||||
// 顺序依赖边 from/to 都是合法工具名(from 先于 to)
|
||||
let h = plan_hint("code", "read 后修改 write_file");
|
||||
for d in &h.sequence {
|
||||
assert_ne!(d.from, d.to, "依赖边不应自环");
|
||||
assert!(!d.from.is_empty());
|
||||
assert!(!d.to.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_no_duplicate_sequence_edge() {
|
||||
// 产出的依赖边不应重复
|
||||
let h = plan_hint("code", "read 后 write_file 修改");
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for d in &h.sequence {
|
||||
assert!(seen.insert((d.from, d.to)), "重复依赖边: {}→{}", d.from, d.to);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 空 hint ---
|
||||
|
||||
#[test]
|
||||
fn hint_empty_for_plain_chat() {
|
||||
// 无编排信号 → 空 PlanHint
|
||||
let h = plan_hint("chat", "你好谢谢");
|
||||
assert!(h.is_empty(), "闲聊应产空 hint, 退单链");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_empty_for_unknown_intent_no_signal() {
|
||||
let h = plan_hint("unknown", "今天的天气不错");
|
||||
assert!(h.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_empty_when_flag_off() {
|
||||
// 开关关闭 → 恒空(主 loop 退单链)。即使有强编排信号也忽略。
|
||||
// 注:PLAN_HINT_ENABLED 是编译期常量,本测在 true 时验证 plan_hint 内部
|
||||
// 短路逻辑(若常量改 false,此测会失败提醒对齐)。
|
||||
// 用单工具避免命中多工具规则。
|
||||
let h = plan_hint("file", "read");
|
||||
// 单 read(不命中规则 3 需 ≥2 读工具,不命中规则 4 需 modify)→ 空
|
||||
assert!(h.is_empty(), "单工具无编排信号应空 hint");
|
||||
}
|
||||
|
||||
// --- validate 合法性 ---
|
||||
|
||||
#[test]
|
||||
fn validate_empty_hint_is_ok() {
|
||||
let h = PlanHint::empty();
|
||||
assert_eq!(validate(&h), HintValidity::Ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_normal_dag_is_ok() {
|
||||
// read_file → write_file(单边 DAG)合法
|
||||
let h = PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["read_file"]),
|
||||
ParallelGroup::new(vec!["write_file"]),
|
||||
],
|
||||
sequence: vec![SequentialDep::new("read_file", "write_file")],
|
||||
};
|
||||
assert_eq!(validate(&h), HintValidity::Ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_empty_parallel_group_is_invalid() {
|
||||
let h = PlanHint {
|
||||
groups: vec![ParallelGroup::new(vec![])],
|
||||
sequence: vec![],
|
||||
};
|
||||
assert_eq!(validate(&h), HintValidity::Invalid("empty_parallel_group"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_self_loop_dep_is_invalid() {
|
||||
let h = PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["read_file"]),
|
||||
ParallelGroup::new(vec!["write_file"]),
|
||||
],
|
||||
sequence: vec![SequentialDep::new("read_file", "read_file")],
|
||||
};
|
||||
assert_eq!(validate(&h), HintValidity::Invalid("self_loop_dep"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_duplicate_edge_is_invalid() {
|
||||
let h = PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["read_file"]),
|
||||
ParallelGroup::new(vec!["write_file"]),
|
||||
],
|
||||
sequence: vec![
|
||||
SequentialDep::new("read_file", "write_file"),
|
||||
SequentialDep::new("read_file", "write_file"),
|
||||
],
|
||||
};
|
||||
assert_eq!(validate(&h), HintValidity::Invalid("duplicate_dep_edge"));
|
||||
}
|
||||
|
||||
// --- Kahn 环检测 ---
|
||||
|
||||
#[test]
|
||||
fn has_cycle_empty_edges_false() {
|
||||
assert!(!has_cycle(&[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_cycle_single_edge_no_cycle() {
|
||||
let edges = vec![SequentialDep::new("a", "b")];
|
||||
assert!(!has_cycle(&edges));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_cycle_two_node_cycle_detected() {
|
||||
// a→b→a 环
|
||||
let edges = vec![
|
||||
SequentialDep::new("a", "b"),
|
||||
SequentialDep::new("b", "a"),
|
||||
];
|
||||
assert!(has_cycle(&edges), "a↔b 环应被检测");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_cycle_three_node_cycle_detected() {
|
||||
// a→b→c→a 环
|
||||
let edges = vec![
|
||||
SequentialDep::new("a", "b"),
|
||||
SequentialDep::new("b", "c"),
|
||||
SequentialDep::new("c", "a"),
|
||||
];
|
||||
assert!(has_cycle(&edges));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_cycle_dag_with_branch_no_cycle() {
|
||||
// a→b, a→c, b→d, c→d(菱形 DAG,无环)
|
||||
let edges = vec![
|
||||
SequentialDep::new("a", "b"),
|
||||
SequentialDep::new("a", "c"),
|
||||
SequentialDep::new("b", "d"),
|
||||
SequentialDep::new("c", "d"),
|
||||
];
|
||||
assert!(!has_cycle(&edges));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_cyclic_dep_is_invalid() {
|
||||
let h = PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["read_file"]),
|
||||
ParallelGroup::new(vec!["write_file"]),
|
||||
],
|
||||
sequence: vec![
|
||||
SequentialDep::new("read_file", "write_file"),
|
||||
SequentialDep::new("write_file", "read_file"),
|
||||
],
|
||||
};
|
||||
assert_eq!(validate(&h), HintValidity::Invalid("cyclic_dep"));
|
||||
}
|
||||
|
||||
// --- 对抗/边界 ---
|
||||
|
||||
#[test]
|
||||
fn validate_check_order_self_loop_before_cycle() {
|
||||
// 自环也构成环,但 validate 应先报 self_loop_dep(更具体的错误)
|
||||
let h = PlanHint {
|
||||
groups: vec![ParallelGroup::new(vec!["a"])],
|
||||
sequence: vec![SequentialDep::new("a", "a")],
|
||||
};
|
||||
assert_eq!(validate(&h), HintValidity::Invalid("self_loop_dep"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_intent_label_ignored_in_phase0a() {
|
||||
// 当前规则纯关键词驱动,intent_label 不参与判定(预留 Phase 1)
|
||||
// 同关键词不同 intent_label → 同产出
|
||||
let h1 = plan_hint("code", "read 后修改 write_file");
|
||||
let h2 = plan_hint("debug", "read 后修改 write_file");
|
||||
assert_eq!(h1, h2, "Phase 0a 不读 intent_label, 产出应一致");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_single_tool_no_signal_is_empty() {
|
||||
// 单 read(无写无多读)→ 不命中任何规则 → 空
|
||||
let h = plan_hint("file", "读取文件");
|
||||
assert!(h.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_ignores_nodes_not_in_groups() {
|
||||
// validate 不检查 sequence 边引用的工具是否在 groups 内(Phase 0a 宽松,
|
||||
// 主 loop 自行对齐;Phase 1 接入 planner.rs 后由 Plan::validate 收口)
|
||||
let h = PlanHint {
|
||||
groups: vec![ParallelGroup::new(vec!["read_file"])],
|
||||
sequence: vec![SequentialDep::new("read_file", "write_file")],
|
||||
};
|
||||
// write_file 不在任何 group,但边合法且 DAG,validate 仍 Ok(宽松)
|
||||
assert_eq!(validate(&h), HintValidity::Ok);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+13
-205
@@ -1,207 +1,15 @@
|
||||
//! LLM Provider trait — 统一的 LLM 调用抽象
|
||||
//! LLM Provider trait + 数据结构 re-export
|
||||
//!
|
||||
//! 支持 OpenAI 兼容 API(覆盖 OpenAI / GLM / DeepSeek / Claude 兼容模式),
|
||||
//! 含 function calling / tool use 能力。
|
||||
//! trait 与数据结构定义已下沉到 df-ai-core crate(零 HTTP 依赖,轻消费方
|
||||
//! 如 df-ideas 可直接依赖 trait 不引入 reqwest/eventsource-stream)。
|
||||
//! 本文件保留为 df-ai 的 re-export 入口,使 `df_ai::provider::*` 路径不变
|
||||
//! —— df-ai 内部模块(openai_compat / anthropic_compat / context / ai_tools)
|
||||
//! 与外部消费方(df-nodes / src-tauri)的 `use df_ai::provider::LlmProvider`
|
||||
//! 等引用全部透明继续可用(编译期验证)。
|
||||
//!
|
||||
//! 留在 df-ai 的部分:
|
||||
//! - `OpenAICompatProvider` / `AnthropicCompatProvider`(HTTP impl,见 openai_compat.rs / anthropic_compat.rs)
|
||||
//! - `build_provider()` 工厂(见 lib.rs,按协议选 impl)
|
||||
//! - `ContextManager` / `AiToolRegistry` / `StreamCollector`(业务逻辑)
|
||||
|
||||
use std::pin::Pin;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ============================================================
|
||||
// 核心数据结构
|
||||
// ============================================================
|
||||
|
||||
/// LLM 调用请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CompletionRequest {
|
||||
/// 模型名称
|
||||
pub model: String,
|
||||
/// 提示消息列表
|
||||
pub messages: Vec<ChatMessage>,
|
||||
/// 温度(0.0 ~ 2.0)
|
||||
pub temperature: Option<f32>,
|
||||
/// 最大生成 token 数
|
||||
pub max_tokens: Option<u32>,
|
||||
/// 是否流式输出
|
||||
pub stream: bool,
|
||||
/// 可调用的工具定义
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<ToolDefinition>>,
|
||||
/// 工具调用策略: "auto" | "none" | {"type":"function","name":"xxx"}
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 聊天消息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
pub role: MessageRole,
|
||||
pub content: String,
|
||||
/// 工具调用 ID(role=Tool 时必填)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
/// AI 发起的工具调用列表(role=Assistant 时可能有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
pub fn system(content: impl Into<String>) -> Self {
|
||||
Self { role: MessageRole::System, content: content.into(), tool_call_id: None, tool_calls: None }
|
||||
}
|
||||
pub fn user(content: impl Into<String>) -> Self {
|
||||
Self { role: MessageRole::User, content: content.into(), tool_call_id: None, tool_calls: None }
|
||||
}
|
||||
pub fn assistant(content: impl Into<String>) -> Self {
|
||||
Self { role: MessageRole::Assistant, content: content.into(), tool_call_id: None, tool_calls: None }
|
||||
}
|
||||
pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
|
||||
Self { role: MessageRole::Assistant, content: content.into(), tool_call_id: None, tool_calls: Some(tool_calls) }
|
||||
}
|
||||
pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self {
|
||||
Self { role: MessageRole::Tool, content: content.into(), tool_call_id: Some(call_id.into()), tool_calls: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// 消息角色
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MessageRole {
|
||||
System,
|
||||
User,
|
||||
Assistant,
|
||||
Tool,
|
||||
}
|
||||
|
||||
/// 工具定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolDefinition {
|
||||
#[serde(rename = "type")]
|
||||
pub tool_type: String,
|
||||
pub function: ToolFunction,
|
||||
}
|
||||
|
||||
impl ToolDefinition {
|
||||
pub fn function(name: impl Into<String>, description: impl Into<String>, parameters: serde_json::Value) -> Self {
|
||||
Self {
|
||||
tool_type: "function".into(),
|
||||
function: ToolFunction { name: name.into(), description: description.into(), parameters },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 函数定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolFunction {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
/// 工具调用(AI 发起)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCall {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub call_type: String,
|
||||
pub function: ToolCallFunction,
|
||||
}
|
||||
|
||||
impl ToolCall {
|
||||
pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
call_type: "function".into(),
|
||||
function: ToolCallFunction { name: name.into(), arguments: arguments.into() },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 工具调用函数部分
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallFunction {
|
||||
pub name: String,
|
||||
pub arguments: String,
|
||||
}
|
||||
|
||||
/// LLM 调用响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CompletionResponse {
|
||||
/// 生成的文本
|
||||
pub text: String,
|
||||
/// 使用的模型
|
||||
pub model: String,
|
||||
/// 消耗的 token 数
|
||||
pub usage: TokenUsage,
|
||||
/// AI 发起的工具调用(如有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
}
|
||||
|
||||
/// Token 用量
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TokenUsage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
pub total_tokens: u32,
|
||||
}
|
||||
|
||||
/// Provider 支持的特性标志
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ProviderFeatures {
|
||||
pub streaming: bool,
|
||||
pub function_calling: bool,
|
||||
pub vision: bool,
|
||||
}
|
||||
|
||||
/// 流式输出的 chunk
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StreamChunk {
|
||||
/// 增量文本
|
||||
pub delta: String,
|
||||
/// 是否结束
|
||||
pub finished: bool,
|
||||
/// 工具调用增量(如有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCallDelta>>,
|
||||
}
|
||||
|
||||
/// 工具调用增量(流式中的片段)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallDelta {
|
||||
/// 索引
|
||||
pub index: u32,
|
||||
/// 工具调用 ID(仅第一个 chunk 有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
/// 函数名片段
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub function_name: Option<String>,
|
||||
/// 函数参数片段
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub function_arguments: Option<String>,
|
||||
}
|
||||
|
||||
/// 异步流类型别名
|
||||
pub type StreamResult = Pin<Box<dyn Stream<Item = anyhow::Result<StreamChunk>> + Send>>;
|
||||
|
||||
/// LLM Provider trait
|
||||
#[async_trait]
|
||||
pub trait LlmProvider: Send + Sync {
|
||||
/// 同步调用
|
||||
async fn complete(&self, request: CompletionRequest) -> anyhow::Result<CompletionResponse>;
|
||||
|
||||
/// 流式调用(返回异步流)
|
||||
async fn stream(
|
||||
&self,
|
||||
request: CompletionRequest,
|
||||
) -> anyhow::Result<StreamResult>;
|
||||
|
||||
/// Provider 名称
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// 支持的特性
|
||||
fn supported_features(&self) -> ProviderFeatures;
|
||||
}
|
||||
pub use df_ai_core::provider::*;
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
//! complete() 指数退避重试 — 仅非流式同步路径,不动 stream()
|
||||
//!
|
||||
//! 背景: complete() 是 LLM 一次性请求,网络波动/瞬态 5xx/429 会让整轮对话静默失败。
|
||||
//! 各调用方(title.rs/knowledge_inject.rs/project.rs/ai_node.rs)虽已有人工兜底降级,
|
||||
//! 但重试能在不侵入上层的前提下吸收瞬态错误,对低频后台调用零风险。
|
||||
//!
|
||||
//! 策略:
|
||||
//! - 最多 3 次尝试(初次 + 2 次重试),指数退避 1s → 2s → 4s + ±50% jitter
|
||||
//! - 总挂钟预算 30s(含 sleep + 各次请求耗时),超预算直接放弃重试返回最后错误
|
||||
//! - 仅对可重试错误重试: timeout / connect / 5xx / 429; 不重试 4xx(非429) / 参数错 / 鉴权错
|
||||
//! - 不引入 reqwest-retry / tower: complete 路径简单,手写 loop 零依赖、可调试、不与 SSE/双层 Semaphore 耦合
|
||||
//!
|
||||
//! 流式 stream() 路径不进入此模块(流式长生成不能整体 timeout,重试会丢增量)。
|
||||
//!
|
||||
//! 重试期间不释放 Semaphore permit(已在调用方持有),对并发池有挤占 —— 但 complete 调用低频可接受。
|
||||
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
|
||||
use rand::Rng;
|
||||
use tracing::warn;
|
||||
|
||||
/// 最多尝试次数(含初次)。3 次 = 初次 + 2 次重试。
|
||||
///
|
||||
/// 配置化 TODO: 未来接入 per-provider config(`AiProviderRecord.config` JSON)或全局开关
|
||||
/// (`useSetting('df-ai-max-retries')`)时改为读取配置。当前低频后台调用,常量足够。
|
||||
pub const MAX_COMPLETE_ATTEMPTS: u32 = 3;
|
||||
|
||||
/// 退避基准(秒): 第 n 次重试前 sleep `BASE_BACKOFF_SECS * 2^(n-1)`。
|
||||
/// 即 1s → 2s → 4s。
|
||||
const BASE_BACKOFF_SECS: u64 = 1;
|
||||
|
||||
/// 整体重试挂钟预算(含 sleep 与各次请求耗时)。超过即放弃,返回最后错误。
|
||||
/// 与单请求 60s timeout 配合: 最坏 3 次 × 60s = 180s,30s 预算会在此之前主动止损。
|
||||
const MAX_TOTAL_BUDGET: Duration = Duration::from_secs(30);
|
||||
|
||||
/// 单次退避基准上限(毫秒),与 [`MAX_TOTAL_BUDGET`] 同口径: 超预算的退避没有意义,
|
||||
/// attempt 走深时不再指数膨胀。与总预算的一致性由测试 `backoff_cap_matches_budget` 守住。
|
||||
/// (同构先例: df-tunnel 的 backoff_delay 也以 RECONNECT_MAX 封顶。)
|
||||
const MAX_BACKOFF_MS: u64 = 30_000;
|
||||
|
||||
/// jitter 上限(相对 base 的 ±比例)。避免重试风暴对齐。
|
||||
/// CR-XX: 范围扩到 ±50%(由 gen_range(-0.5..0.5) × JITTER_RATIO=1.0 合成)。
|
||||
const JITTER_RATIO: f64 = 1.0;
|
||||
|
||||
/// 一次尝试的分类结果 —— 在 anyhow 不透明化前决定是否值得重试。
|
||||
///
|
||||
/// 暴露给 agentic.rs 流前重试复用(Init 失败分类 Fatal/Retryable 决定是否重试)。
|
||||
#[derive(Debug)]
|
||||
pub enum AttemptOutcome<T> {
|
||||
/// 成功,携带结果。
|
||||
Ok(T),
|
||||
/// 可重试错误(timeout / connect / 5xx / 429)。携带人类可读错误描述。
|
||||
Retryable(String),
|
||||
/// 不可重试错误(4xx 非 429 / 鉴权 / 参数错 / 解析错)。携带描述,直接放弃。
|
||||
Fatal(String),
|
||||
}
|
||||
|
||||
/// 判定 reqwest::Error 是否可重试(仅 timeout / connect 类)。
|
||||
///
|
||||
/// body 解析错(reqwest::Error::is_decode)与请求构造错(is_builder)属 Fatal,不重试。
|
||||
pub fn is_reqwest_error_retryable(e: &reqwest::Error) -> bool {
|
||||
e.is_timeout() || e.is_connect() || (e.is_request() && !e.is_builder())
|
||||
}
|
||||
|
||||
/// 判定 HTTP 状态码是否可重试: 5xx 与 429 重试,其余(含 4xx)不重试。
|
||||
///
|
||||
/// 暴露给 agentic.rs 流前重试复用(4xx Fatal 立即放弃,5xx/429 重试)。
|
||||
pub fn is_status_retryable(status: u16) -> bool {
|
||||
status == 429 || (500..600).contains(&status)
|
||||
}
|
||||
|
||||
/// 退避倍率 `2^(attempt-1)`,位移溢出安全。
|
||||
///
|
||||
/// 替代裸 `1u64 << (attempt - 1)`: attempt > 64 时位移越界(debug panic / release 环绕)、
|
||||
/// attempt = 0 时下溢 panic。现 attempt = 0 视作 1(倍率 1),attempt - 1 ≥ 64 时
|
||||
/// checked_shl 返回 None,倍率封顶 u64::MAX,最终时长由 [`MAX_BACKOFF_MS`] 兜底。
|
||||
/// 全输入无 panic,序列单调不减。
|
||||
fn backoff_multiplier(attempt: u32) -> u64 {
|
||||
match 1u64.checked_shl(attempt.saturating_sub(1)) {
|
||||
Some(m) => m,
|
||||
None => u64::MAX,
|
||||
}
|
||||
}
|
||||
|
||||
/// 退避基准毫秒(未加 jitter),`BASE_BACKOFF_SECS * 2^(attempt-1) * 1000`,封顶 [`MAX_BACKOFF_MS`]。
|
||||
///
|
||||
/// 乘法全程 saturating: 倍率封顶 u64::MAX 后 `* 1000` 也会二次溢出,不能裸乘。
|
||||
/// 单一事实源——backoff_delay 与单测的 base 断言都走这里,不再各写一份公式。
|
||||
fn base_backoff_ms(attempt: u32) -> u64 {
|
||||
BASE_BACKOFF_SECS
|
||||
.saturating_mul(backoff_multiplier(attempt))
|
||||
.saturating_mul(1000)
|
||||
.min(MAX_BACKOFF_MS)
|
||||
}
|
||||
|
||||
/// 指数退避 + jitter: 返回第 `attempt`(1-based)次重试前应 sleep 的时长。
|
||||
/// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s,各 ±50% jitter。
|
||||
///
|
||||
/// jitter 用 `rand::thread_rng().gen_range(-0.5..0.5)` 生成 ±50% 比例,避免多客户端同步重试风暴。
|
||||
/// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。
|
||||
/// 全输入安全: attempt 任意 u32(含 0 / >64)不 panic,结果 ≤ [`MAX_BACKOFF_MS`]。
|
||||
///
|
||||
/// 暴露 pub 供 src-tauri/agentic.rs 流前重试复用
|
||||
/// ("复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。
|
||||
pub fn backoff_delay(attempt: u32) -> Duration {
|
||||
let base_ms = base_backoff_ms(attempt) as f64;
|
||||
// ±50% jitter,相对 base 时长的浮动比例
|
||||
let jitter_ratio = rand::thread_rng().gen_range(-0.5..0.5); // [-0.5, 0.5)
|
||||
let factor = 1.0 + jitter_ratio * JITTER_RATIO;
|
||||
let ms = (base_ms * factor).clamp(0.0, MAX_BACKOFF_MS as f64) as u64;
|
||||
Duration::from_millis(ms)
|
||||
}
|
||||
|
||||
/// 对一个返回 `AttemptOutcome` 的异步操作做指数退避重试。
|
||||
///
|
||||
/// `attempt_fn` 每次被调用执行一次尝试并返回分类结果。
|
||||
/// 达到 `MAX_COMPLETE_ATTEMPTS` 或超出 `MAX_TOTAL_BUDGET` 或遇 Fatal 即终止。
|
||||
/// 终止时若仍失败,返回最后一次的 Retryable/Fatal 描述作为 `anyhow::Error`。
|
||||
///
|
||||
/// 调用方约定: `attempt_fn` 内部应保留完整请求重发能力(每次重建 RequestBuilder),
|
||||
/// 因为 reqwest::RequestBuilder 一次 `.send()` 后不可复用。
|
||||
///
|
||||
/// 仍 pub(crate)(complete() 内部用),流前重试 stream_recv/agentic 不走此函数
|
||||
/// (流式 request 不可整体 retry_with_backoff 包裹,需在 agentic loop 内手写循环复用
|
||||
/// backoff_delay + is_status_retryable)。
|
||||
pub(crate) async fn retry_with_backoff<T, F, Fut>(
|
||||
label: &str,
|
||||
attempt_fn: F,
|
||||
) -> anyhow::Result<T>
|
||||
where
|
||||
F: Fn(u32) -> Fut,
|
||||
Fut: Future<Output = AttemptOutcome<T>>,
|
||||
{
|
||||
let deadline = tokio::time::Instant::now() + MAX_TOTAL_BUDGET;
|
||||
let mut last_err = String::from("未尝试");
|
||||
|
||||
for attempt in 1..=MAX_COMPLETE_ATTEMPTS {
|
||||
match attempt_fn(attempt).await {
|
||||
AttemptOutcome::Ok(v) => return Ok(v),
|
||||
AttemptOutcome::Fatal(msg) => {
|
||||
// 不可重试: 立即放弃,不浪费预算
|
||||
return Err(anyhow::anyhow!("{}", msg));
|
||||
}
|
||||
AttemptOutcome::Retryable(msg) => {
|
||||
last_err = msg;
|
||||
let is_last = attempt == MAX_COMPLETE_ATTEMPTS;
|
||||
if is_last {
|
||||
warn!(
|
||||
label = label,
|
||||
attempt = attempt,
|
||||
"重试已达上限({}次),放弃", MAX_COMPLETE_ATTEMPTS
|
||||
);
|
||||
break;
|
||||
}
|
||||
let now = tokio::time::Instant::now();
|
||||
if now >= deadline {
|
||||
warn!(
|
||||
label = label,
|
||||
attempt = attempt,
|
||||
"重试预算({:?})已耗尽,放弃", MAX_TOTAL_BUDGET
|
||||
);
|
||||
break;
|
||||
}
|
||||
let delay = backoff_delay(attempt).min(deadline.saturating_duration_since(now));
|
||||
warn!(
|
||||
label = label,
|
||||
attempt = attempt,
|
||||
delay_ms = delay.as_millis() as u64,
|
||||
err = %last_err,
|
||||
"可重试错误,退避后重试"
|
||||
);
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!("{} 重试耗尽: {}", label, last_err))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单测(纯逻辑,不发 HTTP)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn backoff_is_monotonic_within_jitter() {
|
||||
// base 序列: 1, 2, 4 秒; jitter ±50%(JITTER_RATIO=1.0) 后仍在 [0.5x, 1.5x] 范围
|
||||
for attempt in 1..=3 {
|
||||
let d = backoff_delay(attempt);
|
||||
let base_ms = base_backoff_ms(attempt);
|
||||
let lo = (base_ms as f64 * (1.0 - JITTER_RATIO / 2.0)) as u64;
|
||||
let hi = (base_ms as f64 * (1.0 + JITTER_RATIO / 2.0)) as u64;
|
||||
assert!(
|
||||
d.as_millis() as u64 >= lo && d.as_millis() as u64 <= hi,
|
||||
"attempt={} delay={:?} 应在 [{},{}]ms",
|
||||
attempt,
|
||||
d,
|
||||
lo,
|
||||
hi
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 倍率溢出安全: attempt=0 不下溢,≥65 封顶 u64::MAX,序列单调不减
|
||||
#[test]
|
||||
fn backoff_multiplier_extremes_safe() {
|
||||
assert_eq!(backoff_multiplier(0), 1, "attempt=0 视作 1,不下溢 panic");
|
||||
assert_eq!(backoff_multiplier(1), 1);
|
||||
assert_eq!(backoff_multiplier(2), 2);
|
||||
assert_eq!(backoff_multiplier(3), 4);
|
||||
assert_eq!(backoff_multiplier(63), 1u64 << 62);
|
||||
assert_eq!(backoff_multiplier(64), 1u64 << 63, "u64 可表达的最大位移");
|
||||
assert_eq!(backoff_multiplier(65), u64::MAX, "checked_shl 越界封顶");
|
||||
assert_eq!(backoff_multiplier(100), u64::MAX);
|
||||
assert_eq!(backoff_multiplier(u32::MAX), u64::MAX);
|
||||
let seq: Vec<u64> = [0u32, 1, 2, 3, 10, 63, 64, 65, 100, u32::MAX]
|
||||
.iter()
|
||||
.map(|&a| backoff_multiplier(a))
|
||||
.collect();
|
||||
assert!(seq.windows(2).all(|w| w[0] <= w[1]), "倍率应单调不减: {:?}", seq);
|
||||
}
|
||||
|
||||
/// 基准毫秒单调不减、封顶值正确(2^5×1s=32s 已超 30s 预算,从 attempt=6 起封顶)
|
||||
#[test]
|
||||
fn base_backoff_ms_monotonic_and_capped() {
|
||||
let mut prev = 0u64;
|
||||
for attempt in [0u32, 1, 2, 3, 4, 5, 6, 10, 63, 64, 65, 100, u32::MAX] {
|
||||
let b = base_backoff_ms(attempt);
|
||||
assert!(b >= prev, "attempt={} base_ms={} 应 ≥ 前值 {}", attempt, b, prev);
|
||||
assert!(b <= MAX_BACKOFF_MS, "attempt={} base_ms={} 应 ≤ {}", attempt, b, MAX_BACKOFF_MS);
|
||||
prev = b;
|
||||
}
|
||||
assert_eq!(base_backoff_ms(1), 1000);
|
||||
assert_eq!(base_backoff_ms(2), 2000);
|
||||
assert_eq!(base_backoff_ms(3), 4000);
|
||||
assert_eq!(base_backoff_ms(5), 16_000, "未触及封顶");
|
||||
assert_eq!(base_backoff_ms(6), MAX_BACKOFF_MS, "32s 超预算封顶 30s");
|
||||
assert_eq!(base_backoff_ms(u32::MAX), MAX_BACKOFF_MS);
|
||||
}
|
||||
|
||||
/// 极端 attempt 下 backoff_delay 不 panic(修复: 1u64<<(attempt-1) 曾 debug panic/release 环绕)
|
||||
#[test]
|
||||
fn backoff_delay_extreme_attempts_no_panic_and_capped() {
|
||||
for attempt in [0u32, 1, 10, 63, 64, 65, 100, u32::MAX] {
|
||||
let ms = backoff_delay(attempt).as_millis() as u64;
|
||||
assert!(
|
||||
ms <= MAX_BACKOFF_MS,
|
||||
"attempt={} 实际 {}ms 应 ≤ {}ms(含 jitter 后仍不超)",
|
||||
attempt,
|
||||
ms,
|
||||
MAX_BACKOFF_MS
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 封顶常量与总预算同口径(改预算时两处同步)
|
||||
#[test]
|
||||
fn backoff_cap_matches_budget() {
|
||||
assert_eq!(MAX_BACKOFF_MS, MAX_TOTAL_BUDGET.as_millis() as u64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_classification() {
|
||||
assert!(is_status_retryable(429));
|
||||
assert!(is_status_retryable(500));
|
||||
assert!(is_status_retryable(503));
|
||||
assert!(!is_status_retryable(400));
|
||||
assert!(!is_status_retryable(401));
|
||||
assert!(!is_status_retryable(403));
|
||||
assert!(!is_status_retryable(404));
|
||||
}
|
||||
|
||||
/// 初次成功 → 不重试
|
||||
#[tokio::test]
|
||||
async fn first_attempt_ok() {
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let calls_clone = calls.clone();
|
||||
let r = retry_with_backoff("test", move |_| {
|
||||
let c = calls_clone.clone();
|
||||
async move {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
AttemptOutcome::Ok(42u32)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r, 42);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
/// 第二次成功 → 恰好 2 次调用
|
||||
#[tokio::test]
|
||||
async fn retry_once_then_ok() {
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let calls_clone = calls.clone();
|
||||
let r = retry_with_backoff("test", move |_| {
|
||||
let c = calls_clone.clone();
|
||||
async move {
|
||||
let n = c.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
if n == 1 {
|
||||
AttemptOutcome::Retryable("timeout".into())
|
||||
} else {
|
||||
AttemptOutcome::Ok(7u32)
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r, 7);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
/// 全部 Retryable → 用尽 3 次,返回错误
|
||||
#[tokio::test]
|
||||
async fn all_retryable_exhausts() {
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let calls_clone = calls.clone();
|
||||
let r = retry_with_backoff("test", move |_| {
|
||||
let c = calls_clone.clone();
|
||||
async move {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
AttemptOutcome::<u32>::Retryable("503".into())
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(r.is_err());
|
||||
assert!(r.unwrap_err().to_string().contains("重试耗尽"));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), MAX_COMPLETE_ATTEMPTS);
|
||||
}
|
||||
|
||||
/// Fatal 立即放弃,不重试
|
||||
#[tokio::test]
|
||||
async fn fatal_aborts_immediately() {
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let calls_clone = calls.clone();
|
||||
let r = retry_with_backoff("test", move |_| {
|
||||
let c = calls_clone.clone();
|
||||
async move {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
AttemptOutcome::<u32>::Fatal("401".into())
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(r.is_err());
|
||||
assert!(r.unwrap_err().to_string().contains("401"));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1, "Fatal 不应触发重试");
|
||||
}
|
||||
}
|
||||
+561
-37
@@ -1,51 +1,575 @@
|
||||
//! 模型路由 — 根据任务类型选择最优模型
|
||||
//! 模型路由器。
|
||||
//!
|
||||
//! 不接调用点(那是调用方:agentic.rs / title.rs / knowledge_inject.rs / project.rs /
|
||||
//! df-ideas / df-nodes ai_node.rs)。
|
||||
//!
|
||||
//! ModelRouter 为单元结构,select 是无状态关联函数。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
// 调用点经 `df_ai::router::{Modality, Capability, CostTier, IntelligenceTier}`
|
||||
// 直接 import 维度枚举构造 TaskRequirements,re-export 避免调用点
|
||||
// 各自从 df_ai_core::model 取(跨 crate 路径冗长)。select/select_model_id 仅借用枚举,无重定义。
|
||||
// 注:CostTier/IntelligenceTier 路由已解耦——provider /v1/models API
|
||||
// 不返回这两维度,数据无客观依据不可信,不参与硬路由;re-export 保留供未来真实判别源。
|
||||
// ModelTier 从 crate::intent re-export(同 crate,无跨 crate 路径问题),供调用点构造
|
||||
// `tier: suggested_model_tier(&intent)` 传入,router 同 weight 时按 tier tiebreak。
|
||||
pub use crate::intent::ModelTier;
|
||||
pub use df_ai_core::model::{Capability, CostTier, IntelligenceTier, Modality, ModelConfig};
|
||||
|
||||
/// 任务类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaskType {
|
||||
/// 代码生成
|
||||
CodeGeneration,
|
||||
/// 代码审查
|
||||
CodeReview,
|
||||
/// 文档生成
|
||||
Documentation,
|
||||
/// 分析推理
|
||||
Analysis,
|
||||
/// 摘要总结
|
||||
Summarization,
|
||||
/// 通用对话
|
||||
Chat,
|
||||
/// 任务对模型的需求(4 维度)。
|
||||
///
|
||||
/// 由调用点构造,描述本次调用需要什么模态/能力/上下文/档位,
|
||||
/// 交 ModelRouter::select 在候选池中选最优模型。
|
||||
///
|
||||
/// 路由已解耦:原 `min_intelligence`/`max_cost` 两字段删除。
|
||||
/// provider /v1/models API 不返回 cost_tier/intelligence,这两维度 100% 靠预设表写死 +
|
||||
/// 模型名启发式猜,数据无客观依据不可信,不应参与硬路由。枚举(CostTier/IntelligenceTier)
|
||||
/// 保留供未来出现真实判别源时再接回。
|
||||
///
|
||||
/// `tier`(子项 2 根因修复):任务建议的模型档位(由 `intent::suggested_model_tier` 派生,
|
||||
/// 或无意图场景传 None)。原 `max_by_key(weight)` 同 weight 返最后一个,顺序敏感无语义;
|
||||
/// 接 tier 后,同 weight 时优先选 `intelligence` 满足 tier 下限的候选(见 `tier_match`)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TaskRequirements {
|
||||
/// 任务所需的模态集合(全子集匹配:任务所需模态都必须在模型模态里)
|
||||
pub modalities: Vec<Modality>,
|
||||
/// 是否需要工具调用能力(needs_tool_use=true 时候选必须含 Capability::ToolUse)
|
||||
pub needs_tool_use: bool,
|
||||
/// 预估上下文大小(tokens,模型 context_window 必须 >= 此值)。
|
||||
/// 调用点应传 TokenEstimator 估值而非 0(0 = 当前空操作,窗口过滤维度失效)。
|
||||
pub estimated_context: usize,
|
||||
/// 任务建议的模型档位(意图→ModelTier,无意图场景 None)。
|
||||
/// 同 weight 候选间按 tier tiebreak(满足 tier 下限的候选胜)。
|
||||
pub tier: Option<ModelTier>,
|
||||
}
|
||||
|
||||
/// 模型路由器
|
||||
/// 模型路由器(单元结构,无状态)。
|
||||
///
|
||||
/// 根据任务类型、成本、延迟等选择最优模型
|
||||
pub struct ModelRouter {
|
||||
/// 默认模型
|
||||
default_model: String,
|
||||
/// select 为关联函数:给定需求 + 候选池,执行过滤链选最优模型。
|
||||
pub struct ModelRouter;
|
||||
|
||||
/// ModelTier → IntelligenceTier 下限映射(子项 2 tier tiebreak 用)。
|
||||
///
|
||||
/// 任务建议档位(ModelTier:F-Heavy)映射到模型智力下限(IntelligenceTier),
|
||||
/// 同 weight 候选间优先选 `model.intelligence >= 下限` 的(满足任务复杂度需求)。
|
||||
/// - `Fast` → `Lite`(轻量意图,任何模型都满足)
|
||||
/// - `Standard` → `Standard`(日常,需 Standard 及以上)
|
||||
/// - `Heavy` → `Plus`(复杂推理,需 Plus 及以上)
|
||||
fn tier_min_intelligence(tier: ModelTier) -> IntelligenceTier {
|
||||
match tier {
|
||||
ModelTier::Fast => IntelligenceTier::Lite,
|
||||
ModelTier::Standard => IntelligenceTier::Standard,
|
||||
ModelTier::Heavy => IntelligenceTier::Plus,
|
||||
}
|
||||
}
|
||||
|
||||
/// 同 weight tiebreak:候选是否满足任务建议档位的智力下限。
|
||||
///
|
||||
/// 返 `bool`(满足 = true)。调用方在 `max_by` 闭包内 `a_match.cmp(&b_match)` 把 bool 转 Ordering:
|
||||
/// a 满足而 b 不满足 → Greater(a 胜);都满足/都不满足 → Equal(max_by 并列返最后一个)。
|
||||
/// - `req.tier = None`(无意图场景,标题/扫描/压缩):恒 true(所有候选等价,保留旧行为)。
|
||||
/// - `req.tier = Some(t)`:返 `model_intel >= tier_min_intelligence(t)`。
|
||||
fn tier_match(model_intel: IntelligenceTier, req_tier: Option<ModelTier>) -> bool {
|
||||
req_tier
|
||||
.map(|t| model_intel >= tier_min_intelligence(t))
|
||||
.unwrap_or(true) // None → 视作满足(tiebreak 维度不参与,保旧行为)
|
||||
}
|
||||
|
||||
impl ModelRouter {
|
||||
/// 创建路由器
|
||||
pub fn new(default_model: &str) -> Self {
|
||||
Self {
|
||||
default_model: default_model.to_string(),
|
||||
/// 在候选池中选出最优模型(过滤链)。
|
||||
///
|
||||
/// 步骤:
|
||||
/// 1. enabled — 只选启用的
|
||||
/// 2. 模态匹配 — 任务所需模态全在模型模态里
|
||||
/// 3. 能力匹配 — needs_tool_use 时候选必须含 ToolUse
|
||||
/// 4. 窗口够大 — context_window >= estimated_context
|
||||
/// 5. max_by 选最优:**主键 weight 降序**(权重高者胜),**同 weight 时按 tier tiebreak**
|
||||
/// (满足任务建议档位 `intelligence >= tier_min` 的候选胜)。
|
||||
///
|
||||
/// tier tiebreak(子项 2 根因修复):原 `max_by_key(weight)` 同 weight 返最后一个,
|
||||
/// 顺序敏感无语义(intent suggested_model_tier 恒 None)→ 现接 `req.tier`
|
||||
/// (由 intent→ModelTier 派生),同 weight 时优先选满足档位下限的候选。
|
||||
/// `req.tier = None` 时 tiebreak 维度退化为等价(保留旧行为,标题/扫描路径无回归)。
|
||||
///
|
||||
/// 路由已解耦:原「智力达标」/「成本可控」两步删除,
|
||||
/// 原第 7 步排序的 `Reverse(cost_tier)` 同权重选便宜也已删除——排序主键 weight 主导,
|
||||
/// tiebreak 由 tier(基于 intelligence,有客观档位映射依据)替代纯 max_by_key 顺序。
|
||||
pub fn select<'a>(req: &TaskRequirements, pool: &'a [ModelConfig]) -> Option<&'a ModelConfig> {
|
||||
pool.iter()
|
||||
.filter(|m| m.enabled) // 1. 只选启用的
|
||||
.filter(|m| req.modalities.iter().all(|r| m.modalities.contains(r))) // 2. 模态匹配
|
||||
.filter(|m| !req.needs_tool_use || m.capabilities.contains(&Capability::ToolUse)) // 3. 能力匹配
|
||||
.filter(|m| m.context_window >= req.estimated_context) // 4. 窗口够大
|
||||
// 5. 主键 weight 降序,同 weight 时 tier tiebreak(满足档位下限的候选胜)。
|
||||
// max_by 语义:comparator 返 a 相对 b 的 Ordering,Greater = a 胜;
|
||||
// 同 key(全 Equal)时 max_by 返最后一个(对齐原 max_by_key 并列返最后的语义)。
|
||||
.max_by(|a, b| {
|
||||
// 主键:weight,a 大则 a 胜(Greater)。
|
||||
let by_weight = a.weight.cmp(&b.weight);
|
||||
if by_weight != std::cmp::Ordering::Equal {
|
||||
return by_weight;
|
||||
}
|
||||
// tiebreak:tier 满足度。a 满足档位下限而 b 不满足 → a 胜(Greater)。
|
||||
// tier_match 返 bool,bool 比较:true > false(满足 > 不满足)。
|
||||
let a_match = tier_match(a.intelligence, req.tier);
|
||||
let b_match = tier_match(b.intelligence, req.tier);
|
||||
a_match.cmp(&b_match)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 调用点 helper — 路由选模型并直接返回 model_id(纯函数)。
|
||||
///
|
||||
/// 给定 TaskRequirements + 候选池,返回最优模型的 `model_id`。
|
||||
/// 调用点用法:`provider.model_configs`(Vec<ModelConfig>)→ `select_model_id(&req, &pool)`
|
||||
/// → `Option<String>`;None 时兜底 `provider.default_model`(行为不变,平滑过渡)。
|
||||
///
|
||||
/// 行为不变保证:
|
||||
/// - 池空(用户未通过 Settings 拉取模型)→ 返回 None → 调用点兜底 default_model
|
||||
/// - 池非空但无候选满足需求 → 返回 None → 兜底 default_model
|
||||
/// - 池非空命中 → 返回 model_id(F-01 路由目标,拉取即启用路由)
|
||||
pub fn select_model_id(req: &TaskRequirements, pool: &[ModelConfig]) -> Option<String> {
|
||||
ModelRouter::select(req, pool).map(|m| m.model_id.clone())
|
||||
}
|
||||
|
||||
/// 判断 model_id 是否为图像生成模型(文本任务不应使用)。
|
||||
/// 用于 select 返回 None 兜底 default_model 时的防御:避免标题/压缩等文本任务
|
||||
/// 误用图像模型(如 sensenova-u1-fast/dall-e)致 LLM 404 刷屏(治 default_model 配错为图像模型)。
|
||||
/// 判断:pool 有该模型 → 校验模态是否含 Text(最可靠);pool 无 → 名字启发式(已知图像模型模式)。
|
||||
pub fn is_likely_image_model(model_id: &str, pool: &[ModelConfig]) -> bool {
|
||||
if let Some(m) = pool.iter().find(|m| m.model_id == model_id) {
|
||||
return !m.modalities.contains(&Modality::Text);
|
||||
}
|
||||
let lower = model_id.to_lowercase();
|
||||
const IMAGE_PATTERNS: &[&str] = &[
|
||||
"dall-e", "gpt-image", "imagen", "flux", "stable-diffusion",
|
||||
"sensenova-u1", "u1-fast", "midjourney", "sd-", "sdxl",
|
||||
];
|
||||
IMAGE_PATTERNS.iter().any(|p| lower.contains(p))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 构造一个全维度可定制的 ModelConfig(默认全过过滤,调用方按需覆盖字段)。
|
||||
fn model(model_id: &str) -> ModelConfig {
|
||||
ModelConfig {
|
||||
model_id: model_id.into(),
|
||||
enabled: true,
|
||||
label: None,
|
||||
modalities: vec![Modality::Text],
|
||||
capabilities: vec![Capability::ToolUse],
|
||||
cost_tier: CostTier::Medium,
|
||||
intelligence: IntelligenceTier::Standard,
|
||||
weight: 50,
|
||||
context_window: 8192,
|
||||
temperature_supported: true,
|
||||
probe_source: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据任务类型路由到合适的模型
|
||||
pub fn route(&self, task_type: &TaskType) -> String {
|
||||
// TODO: 实现基于规则的模型路由
|
||||
match task_type {
|
||||
TaskType::CodeGeneration => self.default_model.clone(),
|
||||
TaskType::CodeReview => self.default_model.clone(),
|
||||
TaskType::Documentation => self.default_model.clone(),
|
||||
TaskType::Analysis => self.default_model.clone(),
|
||||
TaskType::Summarization => self.default_model.clone(),
|
||||
TaskType::Chat => self.default_model.clone(),
|
||||
/// 构造一个宽松需求(默认全过过滤,调用方按需覆盖字段)。tier=None 保留旧行为。
|
||||
fn req() -> TaskRequirements {
|
||||
TaskRequirements {
|
||||
modalities: vec![Modality::Text],
|
||||
needs_tool_use: false,
|
||||
estimated_context: 0,
|
||||
tier: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ── select_model_id helper ──
|
||||
|
||||
#[test]
|
||||
fn select_model_id_empty_pool_returns_none() {
|
||||
// 池空(用户未拉取模型)→ None,调用点兜底 default_model
|
||||
let pool: Vec<ModelConfig> = vec![];
|
||||
assert!(select_model_id(&req(), &pool).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_model_id_hit_returns_model_id() {
|
||||
// 池非空命中 → 返回 model_id 字符串(非引用)
|
||||
let pool = vec![model("glm-4-flash")];
|
||||
assert_eq!(select_model_id(&req(), &pool).as_deref(), Some("glm-4-flash"));
|
||||
}
|
||||
|
||||
// ── 步骤 1:enabled 过滤 ──
|
||||
|
||||
#[test]
|
||||
fn empty_pool_returns_none() {
|
||||
let pool: Vec<ModelConfig> = vec![];
|
||||
assert!(ModelRouter::select(&req(), &pool).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_disabled_returns_none() {
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
enabled: false,
|
||||
..model("a")
|
||||
},
|
||||
ModelConfig {
|
||||
enabled: false,
|
||||
..model("b")
|
||||
},
|
||||
];
|
||||
assert!(ModelRouter::select(&req(), &pool).is_none());
|
||||
}
|
||||
|
||||
// ── 步骤 2:模态匹配 ──
|
||||
|
||||
#[test]
|
||||
fn modality_mismatch_filtered() {
|
||||
// 任务需要 Vision,但池里模型只有 Text
|
||||
let pool = vec![model("text-only")];
|
||||
let r = TaskRequirements {
|
||||
modalities: vec![Modality::Vision],
|
||||
..req()
|
||||
};
|
||||
assert!(ModelRouter::select(&r, &pool).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modality_subset_multimodal_required_filters_text_only() {
|
||||
// 多模态子集匹配:任务需 [Text, Vision](两个模态都得支持),
|
||||
// 模型仅 [Text](缺 Vision)→ 步骤 2 `.all(|r| m.modalities.contains(r))` 不成立,滤掉。
|
||||
// 区别于 modality_mismatch_filtered(单模态 Vision):本测覆盖「任务需多模态全子集」路径。
|
||||
let pool = vec![ModelConfig {
|
||||
modalities: vec![Modality::Text],
|
||||
..model("text-only")
|
||||
}];
|
||||
let r = TaskRequirements {
|
||||
modalities: vec![Modality::Text, Modality::Vision],
|
||||
..req()
|
||||
};
|
||||
assert!(ModelRouter::select(&r, &pool).is_none());
|
||||
|
||||
// 对照:模型补全 Vision 后通过(Text+Vision ⊇ 任务需求 Text+Vision)
|
||||
let pool_ok = vec![ModelConfig {
|
||||
modalities: vec![Modality::Text, Modality::Vision],
|
||||
..model("vision-capable")
|
||||
}];
|
||||
assert_eq!(
|
||||
ModelRouter::select(&r, &pool_ok).unwrap().model_id,
|
||||
"vision-capable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_false_excluded_from_routing() {
|
||||
// enabled=false 不参与路由:混池里禁用候选 weight 更高(100),
|
||||
// 但应被步骤 1 `.filter(|m| m.enabled)` 滤掉,只选 enabled=true 的低 weight 候选。
|
||||
// 区别于 all_disabled_returns_none(全禁用返 None):本测覆盖「部分禁用」混池场景。
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
enabled: false,
|
||||
weight: 100, // 若未被 enabled 过滤,weight 100 会胜
|
||||
..model("disabled-heavy")
|
||||
},
|
||||
ModelConfig {
|
||||
enabled: true,
|
||||
weight: 30,
|
||||
..model("enabled-light")
|
||||
},
|
||||
];
|
||||
assert_eq!(
|
||||
ModelRouter::select(&req(), &pool).unwrap().model_id,
|
||||
"enabled-light"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_candidate_pool_returns_none() {
|
||||
// 空候选池 → None(与 select_model_id_empty_pool_returns_none 对应,
|
||||
// 本测覆盖 select() 关联函数本身而非 helper;语义等价但断言点不同)。
|
||||
let pool: Vec<ModelConfig> = vec![];
|
||||
assert!(ModelRouter::select(&req(), &pool).is_none());
|
||||
}
|
||||
|
||||
// ── 步骤 3:能力匹配(ToolUse) ──
|
||||
|
||||
#[test]
|
||||
fn needs_tool_use_filters_non_tool() {
|
||||
// 任务需要 ToolUse,池里模型 capabilities 无 ToolUse
|
||||
let pool = vec![ModelConfig {
|
||||
capabilities: vec![Capability::Embedding],
|
||||
..model("embed-only")
|
||||
}];
|
||||
let r = TaskRequirements {
|
||||
needs_tool_use: true,
|
||||
..req()
|
||||
};
|
||||
assert!(ModelRouter::select(&r, &pool).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_tool_use_false_allows_non_tool() {
|
||||
// 任务不需 ToolUse,池里模型无 ToolUse 仍入选
|
||||
let pool = vec![ModelConfig {
|
||||
capabilities: vec![Capability::Embedding],
|
||||
..model("embed-only")
|
||||
}];
|
||||
let r = TaskRequirements {
|
||||
needs_tool_use: false,
|
||||
..req()
|
||||
};
|
||||
assert_eq!(
|
||||
ModelRouter::select(&r, &pool).unwrap().model_id,
|
||||
"embed-only"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 步骤 4(原智力/成本过滤已解耦):窗口够大 ──
|
||||
|
||||
#[test]
|
||||
fn context_window_insufficient() {
|
||||
// 任务预估 100000,池里模型 8192
|
||||
let pool = vec![model("small-window")];
|
||||
let r = TaskRequirements {
|
||||
estimated_context: 100_000,
|
||||
..req()
|
||||
};
|
||||
assert!(ModelRouter::select(&r, &pool).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimated_context_filters_small_window_model() {
|
||||
// 子项 1 根因修复回归测:estimated_context 非零(调用点传 TokenEstimator 估值,非死代码 0)
|
||||
// → 步骤 4 窗口过滤生效。两候选:小窗口(4K)weight 90(高诱惑)+ 大窗口(128K)weight 50。
|
||||
// 任务预估 8K 上下文 → 小窗口模型被滤,只剩大窗口候选胜(即使 weight 低)。
|
||||
// 若调用点回退传 0(原 bug),两候选窗口都 >= 0,weight 90 的小窗口模型会胜(误选)。
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
weight: 90,
|
||||
context_window: 4096, // 小窗口,高 weight 诱惑
|
||||
..model("small-window-heavy")
|
||||
},
|
||||
ModelConfig {
|
||||
weight: 50,
|
||||
context_window: 131072, // 大窗口,低 weight
|
||||
..model("large-window-light")
|
||||
},
|
||||
];
|
||||
let r = TaskRequirements {
|
||||
estimated_context: 8000, // 任务预估 8K,小窗口模型装不下
|
||||
..req()
|
||||
};
|
||||
assert_eq!(
|
||||
ModelRouter::select(&r, &pool).unwrap().model_id,
|
||||
"large-window-light",
|
||||
"estimated_context 非零应滤掉小窗口候选,即使其 weight 更高"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 步骤 5:max_by(weight 主键,tier tiebreak) ──
|
||||
|
||||
#[test]
|
||||
fn single_match_returns_it() {
|
||||
let pool = vec![model("only-one")];
|
||||
assert_eq!(
|
||||
ModelRouter::select(&req(), &pool).unwrap().model_id,
|
||||
"only-one"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weight_priority_higher_wins() {
|
||||
// 两候选都满足,weight 70 胜 50
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
weight: 50,
|
||||
..model("low-weight")
|
||||
},
|
||||
ModelConfig {
|
||||
weight: 70,
|
||||
..model("high-weight")
|
||||
},
|
||||
];
|
||||
assert_eq!(
|
||||
ModelRouter::select(&req(), &pool).unwrap().model_id,
|
||||
"high-weight"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_weight_picks_first_match() {
|
||||
// 同 weight 70,tier=None(req() 默认):tiebreak 维度退等价,max_by 遇并列返最后一个
|
||||
// (rust Iterator::max_by 语义,与原 max_by_key 一致)。验证同 weight + tier=None
|
||||
// 不再按 cost 取舍,行为对齐接入 tier tiebreak 前的语义(标题/扫描路径无回归)。
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
weight: 70,
|
||||
cost_tier: CostTier::High,
|
||||
..model("expensive")
|
||||
},
|
||||
ModelConfig {
|
||||
weight: 70,
|
||||
cost_tier: CostTier::Low,
|
||||
..model("low-cost")
|
||||
},
|
||||
];
|
||||
assert_eq!(
|
||||
ModelRouter::select(&req(), &pool).unwrap().model_id,
|
||||
"low-cost"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_dimensions_match_picks_best() {
|
||||
// 3+ 候选各维度参差,验证过滤链全过 + max_by(weight, tier) 选最优。
|
||||
// (智力/成本过滤已解耦,原步骤 4/5 删除,候选 d 不再因 intelligence 滤掉)
|
||||
//
|
||||
// 候选:
|
||||
// a: weight 60 → 通过全部过滤
|
||||
// b: weight 80 → 通过 — weight 次高档(与 c 并列,但 tier=None 故 tiebreak 退等价)
|
||||
// c: weight 80 → 通过 — 同 weight 80,tier=None 时 max_by 返并列最后一个
|
||||
// d: weight 90 → 通过(intelligence 不参与过滤)— weight 最高,胜
|
||||
// e: enabled=false → 步骤 1 滤掉
|
||||
//
|
||||
// 预期:d 胜(weight 90 最高,tier tiebreak 不触发因 weight 已决出胜负)
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
weight: 60,
|
||||
cost_tier: CostTier::Medium,
|
||||
intelligence: IntelligenceTier::Standard,
|
||||
..model("a")
|
||||
},
|
||||
ModelConfig {
|
||||
weight: 80,
|
||||
cost_tier: CostTier::High,
|
||||
intelligence: IntelligenceTier::Plus,
|
||||
..model("b")
|
||||
},
|
||||
ModelConfig {
|
||||
weight: 80,
|
||||
cost_tier: CostTier::Low,
|
||||
intelligence: IntelligenceTier::Plus,
|
||||
..model("c")
|
||||
},
|
||||
ModelConfig {
|
||||
weight: 90,
|
||||
cost_tier: CostTier::Low,
|
||||
intelligence: IntelligenceTier::Lite,
|
||||
..model("d")
|
||||
},
|
||||
ModelConfig {
|
||||
enabled: false,
|
||||
weight: 100,
|
||||
..model("e")
|
||||
},
|
||||
];
|
||||
let r = TaskRequirements {
|
||||
modalities: vec![Modality::Text],
|
||||
needs_tool_use: true,
|
||||
estimated_context: 0,
|
||||
tier: None,
|
||||
};
|
||||
assert_eq!(ModelRouter::select(&r, &pool).unwrap().model_id, "d");
|
||||
}
|
||||
|
||||
// ── 步骤 5 tiebreak(子项 2):同 weight 时 tier 决胜 ──
|
||||
|
||||
#[test]
|
||||
fn tier_tiebreak_heavy_prefers_meeting_model() {
|
||||
// 子项 2 根因修复:同 weight 时,任务建议 Heavy(req.tier=Some(Heavy))→ tier_min=Plus,
|
||||
// 满足 intelligence>=Plus 的候选胜过不满足的。
|
||||
// 候选 a:Standard(不满足 Plus),候选 b:Plus(满足),同 weight 50。
|
||||
// 预期:b 胜(满足 Heavy 档位下限)。原 max_by_key 会返最后一个(顺序敏感无语义)。
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
weight: 50,
|
||||
intelligence: IntelligenceTier::Standard,
|
||||
..model("a_standard")
|
||||
},
|
||||
ModelConfig {
|
||||
weight: 50,
|
||||
intelligence: IntelligenceTier::Plus,
|
||||
..model("b_plus")
|
||||
},
|
||||
];
|
||||
let r = TaskRequirements {
|
||||
tier: Some(ModelTier::Heavy),
|
||||
..req()
|
||||
};
|
||||
assert_eq!(
|
||||
ModelRouter::select(&r, &pool).unwrap().model_id,
|
||||
"b_plus",
|
||||
"同 weight 时 Heavy 档位应优先选 Plus(满足)而非 Standard(不满足)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_tiebreak_none_preserves_max_by_key_semantics() {
|
||||
// tier=None(标题/扫描/压缩无意图场景)→ tiebreak 维度退等价,
|
||||
// max_by 同 key 返最后一个(对齐原 max_by_key 行为,无回归)。
|
||||
// 候选 a/b 同 weight 70,顺序 a 在前 b 在后 → 预期返 b(max_by 并列返最后)。
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
weight: 70,
|
||||
intelligence: IntelligenceTier::Standard,
|
||||
..model("a")
|
||||
},
|
||||
ModelConfig {
|
||||
weight: 70,
|
||||
intelligence: IntelligenceTier::Plus,
|
||||
..model("b")
|
||||
},
|
||||
];
|
||||
// tier=None 时即使 b 的 intelligence 更高也不应胜(tiebreak 不参与),保 max_by_key 语义。
|
||||
assert_eq!(ModelRouter::select(&req(), &pool).unwrap().model_id, "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_tiebreak_chat_fast_any_model_meets_lite() {
|
||||
// 任务建议 Fast → tier_min=Lite,任何模型 intelligence>=Lite(Lite 是最低档)→ 都满足。
|
||||
// 故 Fast 档位下 tiebreak 退等价(都满足),max_by 同 weight 返最后一个,行为不变。
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
weight: 50,
|
||||
intelligence: IntelligenceTier::Lite,
|
||||
..model("a_lite")
|
||||
},
|
||||
ModelConfig {
|
||||
weight: 50,
|
||||
intelligence: IntelligenceTier::Ultra,
|
||||
..model("b_ultra")
|
||||
},
|
||||
];
|
||||
let r = TaskRequirements {
|
||||
tier: Some(ModelTier::Fast),
|
||||
..req()
|
||||
};
|
||||
// 都满足 Lite 下限 → tiebreak 等价 → max_by 返最后一个 = b_ultra
|
||||
assert_eq!(ModelRouter::select(&r, &pool).unwrap().model_id, "b_ultra");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_tiebreak_weight_still_dominates() {
|
||||
// tier 不凌驾 weight:weight 高者永远胜,即使低 weight 候选满足 tier 而高 weight 不满足。
|
||||
// 候选 a:weight 90,Standard(不满足 Heavy/Plus)。
|
||||
// 候选 b:weight 50,Plus(满足 Heavy)。
|
||||
// 预期:a 胜(weight 主键优先,tiebreak 只在 weight 相同时触发)。
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
weight: 90,
|
||||
intelligence: IntelligenceTier::Standard,
|
||||
..model("a_heavy_weight")
|
||||
},
|
||||
ModelConfig {
|
||||
weight: 50,
|
||||
intelligence: IntelligenceTier::Plus,
|
||||
..model("b_meets_tier")
|
||||
},
|
||||
];
|
||||
let r = TaskRequirements {
|
||||
tier: Some(ModelTier::Heavy),
|
||||
..req()
|
||||
};
|
||||
assert_eq!(
|
||||
ModelRouter::select(&r, &pool).unwrap().model_id,
|
||||
"a_heavy_weight",
|
||||
"weight 主键应凌驾 tier tiebreak"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
//! 原生 SSE 流式解析器 — 替代 eventsource-stream 库
|
||||
//!
|
||||
//! eventsource-stream 0.2 在 Windows 上对 Deepseek 等 provider
|
||||
//! 的 SSE 响应解析时报 "Transport error: error decoding response body" 错误。
|
||||
//!
|
||||
//! 根因分析:
|
||||
//! eventsource-stream 内部对 bytes_stream 做严格的 UTF-8 + SSE 协议校验,遇到以下情况
|
||||
//! 即报错(且不可恢复):
|
||||
//! - 流中断时未完整接收 UTF-8 字符(网络抖动常见)
|
||||
//! - 缺少结束的 \n\n(连接断开常见)
|
||||
//! - 非 ASCII 字符的多字节序列跨 chunk 边界
|
||||
//!
|
||||
//! 本解析器实现:
|
||||
//! - 宽松的 UTF-8 处理(用 bytes 累积,优先 `String::from_utf8` 严格解码保留多字节完整,
|
||||
//! 失败再降级 `from_utf8_lossy` 不报错)
|
||||
//! - SSE 协议简单解析(分隔符兼容 `\n\n` / `\r\n\r\n` / `\r\r` 三种行尾归一,
|
||||
//! data: 前缀提取)
|
||||
//! - 多字节续接:事件体只在定位到完整分隔符后才解码,跨 chunk 的多字节字符在
|
||||
//! buffer 中天然拼接复原(分隔符为 ASCII,不落在多字节序列中间)
|
||||
//! - 容错:解析失败时跳过该事件继续,不中断流;buffer 设 `BUF_MAX` 上限防 OOM,
|
||||
//! 超限时按事件边界对齐保尾裁剪(丢最旧的超限事件整体),不产出半截破损事件
|
||||
//! - 返回 Vec<String>(每个元素是一个事件 data 字段拼接内容)
|
||||
|
||||
use futures::Stream;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
/// SSE 事件流的 data 字段内容
|
||||
pub type SseEvent = String;
|
||||
|
||||
/// 缓冲区字节上限(防御性):正常流会被 parse_events 持续消费,
|
||||
/// 仅畸形上游(持续不发分隔符)时触发裁剪,避免无界增长 OOM。
|
||||
const BUF_MAX: usize = 1 << 20; // 1 MiB
|
||||
|
||||
/// 原生 SSE 解析器流:包装 bytes_stream,产出 Vec<SseEvent>(一次 poll 可能产出多个事件)
|
||||
pub struct SseStream<S> {
|
||||
inner: S,
|
||||
buffer: Vec<u8>,
|
||||
/// 超限裁剪置位:无边界的超限事件已整体丢弃,但其尾部字节仍在后续 chunk 中,
|
||||
/// 到达收尾 boundary 前的数据须静默吞掉,避免残缺事件体被当完整事件产出
|
||||
skip_partial: bool,
|
||||
}
|
||||
|
||||
impl<S> SseStream<S>
|
||||
where
|
||||
S: Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Unpin,
|
||||
{
|
||||
pub fn new(inner: S) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
buffer: Vec::with_capacity(8192),
|
||||
skip_partial: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 buffer 解析完整的 SSE 事件,返回事件列表。
|
||||
///
|
||||
/// 事件分隔符兼容三种行尾归一:
|
||||
/// - `\n\n` (LF LF,规范形态)
|
||||
/// - `\r\n\r\n` (CRLF CRLF,部分中转站/反代发送)
|
||||
/// - `\r\r` (CR CR,极少见但同源)
|
||||
///
|
||||
/// 取最早出现的分隔符;事件体内部的 `\r\n` 在解码后归一为 `\n`,
|
||||
/// 确保 `lines()` / `strip_prefix` 等行级解析正常工作。
|
||||
///
|
||||
/// 多字节续接:事件体只在定位到完整分隔符后才解码,跨 chunk 的多字节
|
||||
/// 字符在 buffer 中天然拼接复原(分隔符为 ASCII,不会落在多字节序列中间)。
|
||||
/// 解码用 `String::from_utf8`(失败再降级 lossy),避免把合法多字节误判为残缺。
|
||||
fn parse_events(&mut self) -> Vec<SseEvent> {
|
||||
// 超限裁剪收尾:被整体丢弃的残缺事件,其后续字节以首个 boundary 收尾,
|
||||
// 此处静默吞掉不产出,之后的正常事件恢复正常解析
|
||||
if self.skip_partial {
|
||||
match Self::find_event_boundary(&self.buffer) {
|
||||
Some((pos, len)) => {
|
||||
self.buffer.drain(..pos + len);
|
||||
self.skip_partial = false;
|
||||
}
|
||||
None => return Vec::new(),
|
||||
}
|
||||
}
|
||||
let mut events = Vec::new();
|
||||
loop {
|
||||
// 同时识别三种分隔符,取最早出现的位置与该分隔符的字节长度
|
||||
let sep = Self::find_event_boundary(&self.buffer);
|
||||
let (sep_pos, sep_len) = match sep {
|
||||
Some(v) => v,
|
||||
None => break,
|
||||
};
|
||||
// 取出事件体 + 分隔符整体(buffer 前 sep_pos+sep_len 字节)
|
||||
let event_bytes: Vec<u8> = self.buffer.drain(..sep_pos + sep_len).collect();
|
||||
// 去掉末尾的分隔符,得到事件体
|
||||
let body_end = event_bytes.len().saturating_sub(sep_len);
|
||||
let body_bytes = &event_bytes[..body_end];
|
||||
// 解码:优先严格 UTF-8(保留多字节完整),失败再降级 lossy(不崩)
|
||||
let mut event_text = match std::str::from_utf8(body_bytes) {
|
||||
Ok(s) => s.to_owned(),
|
||||
Err(_) => String::from_utf8_lossy(body_bytes).into_owned(),
|
||||
};
|
||||
// 事件体内 CRLF / 裸 CR 归一为 LF,保证后续行级解析一致
|
||||
if event_text.contains('\r') {
|
||||
event_text = event_text.replace("\r\n", "\n").replace('\r', "\n");
|
||||
}
|
||||
let data = Self::extract_data_fields(&event_text);
|
||||
if !data.is_empty() {
|
||||
events.push(data);
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
/// 在 buffer 中查找最早的事件分隔符,返回 `(起始位置, 分隔符字节长度)`。
|
||||
/// 扫描 `\r\n\r\n` / `\n\n` / `\r\r` 三种形态,取最小起始位置(取最早分隔符)。
|
||||
fn find_event_boundary(buf: &[u8]) -> Option<(usize, usize)> {
|
||||
// candidates: (pattern, length)
|
||||
const PATTERNS: &[(&[u8], usize)] = &[
|
||||
(b"\r\n\r\n", 4),
|
||||
(b"\n\n", 2),
|
||||
(b"\r\r", 2),
|
||||
];
|
||||
let mut best: Option<(usize, usize)> = None;
|
||||
for &(pat, len) in PATTERNS {
|
||||
// windows 匹配;找到首次出现位置
|
||||
if let Some(pos) = buf.windows(pat.len()).position(|w| w == pat) {
|
||||
match best {
|
||||
None => best = Some((pos, len)),
|
||||
Some((bpos, _)) if pos < bpos => best = Some((pos, len)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// 从 SSE 事件文本中提取所有 data: 行的内容,拼接为单个字符串(多个 data 行用 \n 连接)
|
||||
fn extract_data_fields(event_text: &str) -> String {
|
||||
let mut data_parts: Vec<&str> = Vec::new();
|
||||
for line in event_text.lines() {
|
||||
if let Some(rest) = line.strip_prefix("data:") {
|
||||
let rest = rest.strip_prefix(' ').unwrap_or(rest);
|
||||
data_parts.push(rest);
|
||||
}
|
||||
// 忽略 event:/id:/retry: 等其他 SSE 字段(OpenAI/Anthropic 协议未使用)
|
||||
}
|
||||
data_parts.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Stream for SseStream<S>
|
||||
where
|
||||
S: Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Unpin,
|
||||
{
|
||||
type Item = Result<Vec<SseEvent>, String>;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
use futures::StreamExt;
|
||||
loop {
|
||||
// 先尝试从 buffer 解析完整事件
|
||||
let events = self.parse_events();
|
||||
if !events.is_empty() {
|
||||
return Poll::Ready(Some(Ok(events)));
|
||||
}
|
||||
|
||||
// buffer 不足以解析出完整事件,从 inner 读更多数据
|
||||
match self.inner.poll_next_unpin(cx) {
|
||||
Poll::Ready(Some(Ok(chunk))) => {
|
||||
self.buffer.extend_from_slice(&chunk);
|
||||
// 防御:缓冲区不应无限增长。正常情况下 parse_events 会持续消费,
|
||||
// 超限只发生在单个事件体超过 BUF_MAX 的场景(畸形上游不发分隔符,
|
||||
// 或超大单事件)。按字节盲截头部会把半截事件体留给后续解析,产出
|
||||
// 破损 JSON 且无法复原;改为对齐事件边界的保尾裁剪:
|
||||
// - 已有完整分隔符 → 最旧的一个事件(超限源头)整体丢弃,保留的
|
||||
// 数据从干净的事件起点开始,后续事件解析不受损;
|
||||
// - 全程无分隔符 → 整体丢弃并置 skip_partial,该事件的收尾字节
|
||||
// 到达时静默吞掉。
|
||||
if self.buffer.len() > BUF_MAX {
|
||||
match Self::find_event_boundary(&self.buffer) {
|
||||
Some((pos, len)) => {
|
||||
self.buffer.drain(..pos + len);
|
||||
}
|
||||
None => {
|
||||
self.buffer.clear();
|
||||
self.skip_partial = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Poll::Ready(Some(Err(e))) => {
|
||||
return Poll::Ready(Some(Err(format!("SSE 流读取错误: {}", e))));
|
||||
}
|
||||
Poll::Ready(None) => {
|
||||
// 流结束,处理 buffer 中的剩余数据(可能没有 \n\n 结束的最后一段);
|
||||
// skip_partial 置位时剩余数据是被丢弃残缺事件的尾段,不得溢出为事件
|
||||
if !self.buffer.is_empty() && !self.skip_partial {
|
||||
// 解码:优先严格 UTF-8(EOF 无下一 chunk 可拼接,
|
||||
// 残缺多字节尾部只能尽力而为,降级 lossy 不崩)
|
||||
let mut remaining = match String::from_utf8(self.buffer.clone()) {
|
||||
Ok(s) => s,
|
||||
Err(_) => String::from_utf8_lossy(&self.buffer).into_owned(),
|
||||
};
|
||||
self.buffer.clear();
|
||||
// 同主解析路径:CRLF / 裸 CR 归一为 LF
|
||||
if remaining.contains('\r') {
|
||||
remaining = remaining.replace("\r\n", "\n").replace('\r', "\n");
|
||||
}
|
||||
let data = Self::extract_data_fields(&remaining);
|
||||
if !data.is_empty() {
|
||||
return Poll::Ready(Some(Ok(vec![data])));
|
||||
}
|
||||
}
|
||||
return Poll::Ready(None);
|
||||
}
|
||||
Poll::Pending => return Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use futures::stream;
|
||||
|
||||
/// 构造只含 Ok chunk 的内存流,跑完整个 SseStream,返回拍平的事件序列
|
||||
fn run_chunks(chunks: Vec<String>) -> Vec<SseEvent> {
|
||||
use futures::StreamExt;
|
||||
let items: Vec<Result<bytes::Bytes, reqwest::Error>> = chunks
|
||||
.into_iter()
|
||||
.map(|c| Ok(bytes::Bytes::from(c)))
|
||||
.collect();
|
||||
let mut s = SseStream::new(stream::iter(items));
|
||||
futures::executor::block_on(async {
|
||||
let mut out = Vec::new();
|
||||
while let Some(item) = s.next().await {
|
||||
out.extend(item.expect("测试流不应产生错误"));
|
||||
}
|
||||
out
|
||||
})
|
||||
}
|
||||
|
||||
/// 回归:正常多事件流照常解析
|
||||
#[test]
|
||||
fn normal_events_parse() {
|
||||
let events = run_chunks(vec![
|
||||
"data: {\"a\":1}\n\n".into(),
|
||||
"data: {\"b\":2}\n\n".into(),
|
||||
]);
|
||||
assert_eq!(events, vec!["{\"a\":1}".to_string(), "{\"b\":2}".to_string()]);
|
||||
}
|
||||
|
||||
/// 超限单事件(末尾带完整分隔符)整体丢弃:不产出半截破损事件,后续正常事件照常解析。
|
||||
/// 旧按字节截头实现会把残留尾部 "data: TAIL" 行当伪事件产出
|
||||
#[test]
|
||||
fn oversize_event_with_boundary_dropped_whole() {
|
||||
let giant = format!("data: {}\ndata: TAIL\n\n", "x".repeat(BUF_MAX));
|
||||
let events = run_chunks(vec![giant, "data: {\"ok\":1}\n\n".into()]);
|
||||
assert_eq!(events, vec!["{\"ok\":1}".to_string()]);
|
||||
}
|
||||
|
||||
/// 验收:超限注入(无边界大块)丢弃后,残缺事件收尾静默吞掉,后续正常事件仍可解析。
|
||||
/// 旧按字节截头实现会把 STALE-TAIL 残段当伪事件产出
|
||||
#[test]
|
||||
fn oversize_boundaryless_blob_then_normal_events_still_parse() {
|
||||
let events = run_chunks(vec![
|
||||
"x".repeat(BUF_MAX + 100),
|
||||
"data: STALE-TAIL\n\n".into(),
|
||||
"data: {\"ok\":1}\n\n".into(),
|
||||
"data: [DONE]\n\n".into(),
|
||||
]);
|
||||
assert_eq!(events, vec!["{\"ok\":1}".to_string(), "[DONE]".to_string()]);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
//! 流式处理 — LLM 响应的流式输出管理
|
||||
|
||||
use crate::provider::StreamChunk;
|
||||
|
||||
/// 流式响应收集器
|
||||
pub struct StreamCollector {
|
||||
/// 已收集的文本
|
||||
text: String,
|
||||
/// 是否完成
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
impl StreamCollector {
|
||||
/// 创建收集器
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
text: String::new(),
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 追加一个 chunk
|
||||
pub fn push(&mut self, chunk: &StreamChunk) {
|
||||
self.text.push_str(&chunk.delta);
|
||||
if chunk.finished {
|
||||
self.finished = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取已收集的文本
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
/// 是否已完成
|
||||
pub fn is_finished(&self) -> bool {
|
||||
self.finished
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for StreamCollector {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! 上下文侧工具结果正文外置(P1 工具结果瘦身)。
|
||||
//!
|
||||
//! 依据 docs/02-架构设计/上下文根治设计-20261006.md §二「工具结果瘦身(治最大头,先行)」:
|
||||
//! 超阈值工具结果**正文落盘**,上下文只留「摘要 + 句柄路径」;句柄可被 agent 主动展开
|
||||
//! (read_file 读句柄路径)——需要时找得回,不需要时不占位。与 src-tauri 审计侧 D2a 同构。
|
||||
//!
|
||||
//! 与 namespace_store 的区别(两者互补,不冲突):
|
||||
//! - namespace_store:内存态引用化(省进程内存),发 LLM 前展开为全文 → token 零收益。
|
||||
//! - 本模块:落盘 + 句柄,被省略的中段**不进 prompt 但可读回** → 真正减 token 且不丢信息。
|
||||
//!
|
||||
//! 降级契约:base 未注入(单测/未初始化)→ 不落盘返回 None,调用方退化为纯摘要(改造前行为)。
|
||||
//! 幂等:文件名取内容稳定哈希,同内容重复压缩只落盘一次。
|
||||
//! 保留策略:与全局「历史数据默认保留」一致,本模块自产文件只增不减(无清理调用点)。
|
||||
//!
|
||||
//! 测试隔离:核心逻辑 `offload_to(base, ...)` 显式传 base(零全局态),单测只测它;
|
||||
//! `init_base` 的 OnceLock 全局态一次性不可重置,故测试**绝不调用**它(避免污染同进程
|
||||
//! 其他用例——如 extract_key_info 系列断言会被意外注入的句柄行破坏)。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// 外置根目录(进程级一次注入;src-tauri lib.rs setup 调 init_base)。
|
||||
/// 与审计侧共用 {app_data},文件名前缀 `ctx-` 区分(tool-results/ 目录共存)。
|
||||
static BASE: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// 注入外置根目录(app data dir)。幂等,首次生效。
|
||||
pub fn init_base(base: PathBuf) {
|
||||
let _ = BASE.set(base);
|
||||
}
|
||||
|
||||
/// base 是否已注入(降级判定用)。
|
||||
pub fn is_enabled() -> bool {
|
||||
BASE.get().is_some()
|
||||
}
|
||||
|
||||
/// 内容稳定哈希(非密码学用途;碰撞后果仅同名文件互相覆盖,概率可忽略)。
|
||||
fn content_id(content: &str) -> u64 {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = DefaultHasher::new();
|
||||
content.hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// 落盘工具结果正文,返回可读句柄行(供摘要末尾追加)。base 未注入 → None。
|
||||
pub fn offload_with_handle(content: &str, tool_name: &str) -> Option<String> {
|
||||
offload_to(BASE.get()?, content, tool_name)
|
||||
}
|
||||
|
||||
/// 核心实现(显式 base,零全局态,可测)。
|
||||
///
|
||||
/// 建目录失败 / 写文件失败 → None(降级:调用方仅用摘要,不丢数据)。
|
||||
pub fn offload_to(base: &Path, content: &str, tool_name: &str) -> Option<String> {
|
||||
let dir = base.join("tool-results");
|
||||
if std::fs::create_dir_all(&dir).is_err() {
|
||||
return None;
|
||||
}
|
||||
let file = dir.join(format!("ctx-{:016x}.txt", content_id(content)));
|
||||
if !file.exists() && std::fs::write(&file, content).is_err() {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"[工具 {} 原文 {} 字符已外置: {} —— 需完整内容用 read_file 读该路径]",
|
||||
tool_name,
|
||||
content.chars().count(),
|
||||
file.display()
|
||||
))
|
||||
}
|
||||
|
||||
/// 读回外置文件(查询侧/测试用)。文件缺失返回带语义错误(降级不 panic)。
|
||||
pub fn read_offloaded(path: &str) -> Result<String, String> {
|
||||
std::fs::read_to_string(path).map_err(|e| format!("外置结果读取失败: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tmp_base(tag: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!("df-ai-offload-{tag}-{}", std::process::id()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offload_to_writes_readable_file_and_is_idempotent() {
|
||||
let base = tmp_base("ok");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let payload = "large tool output ".repeat(1000);
|
||||
let handle = offload_to(&base, &payload, "grep").expect("显式 base 应落盘");
|
||||
assert!(handle.contains("已外置"), "句柄应含提示文案: {handle}");
|
||||
assert!(handle.contains("read_file"), "句柄应指引读回方式: {handle}");
|
||||
// 幂等:同内容二次调用返回同一文件名
|
||||
let handle2 = offload_to(&base, &payload, "grep").expect("二次调用仍返回句柄");
|
||||
assert_eq!(handle, handle2, "同内容应幂等复用同一文件");
|
||||
// 落盘文件可读回原文(无损)
|
||||
let path = std::fs::read_dir(base.join("tool-results"))
|
||||
.expect("目录应存在")
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.find(|p| p.extension().map(|e| e == "txt").unwrap_or(false))
|
||||
.expect("应有落盘文件");
|
||||
assert_eq!(
|
||||
read_offloaded(path.to_string_lossy().as_ref()).expect("应可读回"),
|
||||
payload
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_content_gets_different_file() {
|
||||
let base = tmp_base("diff");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let h1 = offload_to(&base, &"a".repeat(3000), "grep").unwrap();
|
||||
let h2 = offload_to(&base, &"b".repeat(3000), "grep").unwrap();
|
||||
assert_ne!(h1, h2, "不同内容应落不同文件");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! P1 工具结果瘦身 — 端到端集成验证。
|
||||
//!
|
||||
//! 独立测试二进制(独立进程):`tool_offload::init_base` 的 OnceLock 全局态不外泄,
|
||||
//! 不污染 lib 单测(反之亦然)。这是验证「超阈值 → 落盘句柄 → 可无损读回」全链的唯一安全方式。
|
||||
|
||||
use df_ai::context_helpers::{extract_key_info, READ_FILE_OFFLOAD_BYTES};
|
||||
use df_ai::tool_offload;
|
||||
|
||||
fn tmp_base(tag: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!("df-ai-ctx-offload-{tag}-{}", std::process::id()))
|
||||
}
|
||||
|
||||
/// 从句柄行里粗取落盘路径(格式:`[...已外置: <path> —— ...]`)。
|
||||
fn path_of(handle_line: &str) -> String {
|
||||
handle_line
|
||||
.split("已外置: ")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split(" ——").next())
|
||||
.expect("句柄行应含路径")
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_read_file_summarized_with_lossless_handle() {
|
||||
let base = tmp_base("large");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
tool_offload::init_base(base.clone());
|
||||
|
||||
// 造一个超阈值的 read_file 结果(代码风格多行文本)
|
||||
let body: String = (1..=800)
|
||||
.map(|i| format!("line {i}: fn sample_{i}() {{ let x = {i}; }}\n"))
|
||||
.collect();
|
||||
assert!(body.len() > READ_FILE_OFFLOAD_BYTES, "夹具须超阈值");
|
||||
|
||||
let out = extract_key_info(&body, "read_file");
|
||||
assert!(out.len() < body.len(), "超阈值 read_file 应被压缩");
|
||||
assert!(out.contains("已外置"), "应追加落盘句柄:\n{out}");
|
||||
assert!(out.contains("read_file 读该路径"), "句柄应指引读回方式");
|
||||
|
||||
// 无损性:按句柄路径读回 = 原文逐字节一致
|
||||
let line = out.lines().find(|l| l.contains("已外置")).expect("应有句柄行");
|
||||
let back = tool_offload::read_offloaded(&path_of(line)).expect("句柄路径应可读回");
|
||||
assert_eq!(back, body, "落盘原文须与输入逐字节一致(无损)");
|
||||
|
||||
// 摘要确实变小(瘦身生效)
|
||||
let ratio = out.len() as f64 / body.len() as f64;
|
||||
assert!(ratio < 0.2, "压缩比应显著(<0.2),实际 {ratio:.3}");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_read_file_still_exempt() {
|
||||
let base = tmp_base("small");
|
||||
tool_offload::init_base(base);
|
||||
let small = "line1: fn a() {}\nline2: fn b() {}\n";
|
||||
assert_eq!(
|
||||
extract_key_info(small, "read_file"),
|
||||
small,
|
||||
"阈值内 read_file 应原样返回(保 AI 定向读代码语义)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_read_file_large_result_also_gets_handle() {
|
||||
let base = tmp_base("other");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
tool_offload::init_base(base.clone());
|
||||
|
||||
// 中性行夹具:不得含 error/panic/.rs: 等错误行信号——错误行保留策略会全量保留
|
||||
// 命中行,用信号行做夹具会让「应被压缩」断言先天不成立(测的是普通大结果外置)
|
||||
let body: String = (1..=1200).map(|i| format!("match {i}: src/foo.txt:{i}\n")).collect();
|
||||
assert!(body.len() >= 8 * 1024, "夹具须 ≥8KB(句柄追加下限)");
|
||||
let out = extract_key_info(&body, "grep");
|
||||
assert!(out.len() < body.len(), "应被压缩");
|
||||
assert!(out.contains("已外置"), "≥8KB 结果应带落盘句柄");
|
||||
|
||||
let line = out.lines().find(|l| l.contains("已外置")).expect("应有句柄行");
|
||||
assert_eq!(
|
||||
tool_offload::read_offloaded(&path_of(line)).expect("应可读回"),
|
||||
body
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
//! 工作流事件定义
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::types::NodeId;
|
||||
|
||||
/// 人工审批响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HumanApprovalResponse {
|
||||
pub execution_id: String,
|
||||
pub node_id: NodeId,
|
||||
pub decision: String,
|
||||
pub comment: Option<String>,
|
||||
}
|
||||
|
||||
/// 工作流事件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum WorkflowEvent {
|
||||
/// 节点开始执行
|
||||
NodeStarted {
|
||||
node_id: NodeId,
|
||||
},
|
||||
/// 节点执行进度更新
|
||||
NodeProgress {
|
||||
node_id: NodeId,
|
||||
progress: f32,
|
||||
message: String,
|
||||
},
|
||||
/// 节点产生输出
|
||||
NodeOutput {
|
||||
node_id: NodeId,
|
||||
output: String,
|
||||
},
|
||||
/// 节点执行完成
|
||||
NodeCompleted {
|
||||
node_id: NodeId,
|
||||
duration_ms: u64,
|
||||
},
|
||||
/// 节点执行失败
|
||||
NodeFailed {
|
||||
node_id: NodeId,
|
||||
error: String,
|
||||
},
|
||||
/// 工作流暂停(等待外部输入)
|
||||
WorkflowPaused {
|
||||
reason: String,
|
||||
waiting_node: NodeId,
|
||||
},
|
||||
/// 工作流执行完成
|
||||
WorkflowCompleted {
|
||||
total_duration_ms: u64,
|
||||
},
|
||||
/// 工作流执行失败
|
||||
WorkflowFailed {
|
||||
error: String,
|
||||
failed_node: NodeId,
|
||||
},
|
||||
/// 人工审批请求
|
||||
HumanApprovalRequest {
|
||||
execution_id: String,
|
||||
node_id: NodeId,
|
||||
title: String,
|
||||
description: String,
|
||||
options: Vec<String>,
|
||||
},
|
||||
/// 人工审批响应
|
||||
HumanApprovalResponse {
|
||||
execution_id: String,
|
||||
node_id: NodeId,
|
||||
decision: String,
|
||||
comment: Option<String>,
|
||||
},
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
//! df-core: 核心类型定义,所有 crate 的基础依赖
|
||||
|
||||
pub mod error;
|
||||
pub mod events;
|
||||
pub mod types;
|
||||
@@ -1,301 +0,0 @@
|
||||
//! 核心类型定义:ID 别名、状态枚举、优先级等
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ============================================================
|
||||
// ID 类型别名
|
||||
// ============================================================
|
||||
|
||||
/// 想法 ID
|
||||
pub type IdeaId = String;
|
||||
/// 项目 ID
|
||||
pub type ProjectId = String;
|
||||
/// 任务 ID
|
||||
pub type TaskId = String;
|
||||
/// 工作流 ID
|
||||
pub type WorkflowId = String;
|
||||
/// 节点 ID
|
||||
pub type NodeId = String;
|
||||
/// 发布 ID
|
||||
pub type ReleaseId = String;
|
||||
/// 分支 ID
|
||||
pub type BranchId = String;
|
||||
/// 插件 ID
|
||||
pub type PluginId = String;
|
||||
/// 执行 ID
|
||||
pub type ExecutionId = String;
|
||||
/// 决策 ID
|
||||
pub type DecisionId = String;
|
||||
|
||||
// ============================================================
|
||||
// 状态枚举
|
||||
// ============================================================
|
||||
|
||||
/// 想法状态
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum IdeaStatus {
|
||||
/// 草稿
|
||||
Draft,
|
||||
/// 待评估
|
||||
PendingReview,
|
||||
/// 已批准
|
||||
Approved,
|
||||
/// 已拒绝
|
||||
Rejected,
|
||||
/// 已转为项目
|
||||
Promoted,
|
||||
/// 已归档
|
||||
Archived,
|
||||
}
|
||||
|
||||
impl IdeaStatus {
|
||||
/// 返回数据库存储用的小写字符串
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
IdeaStatus::Draft => "draft",
|
||||
IdeaStatus::PendingReview => "pending_review",
|
||||
IdeaStatus::Approved => "approved",
|
||||
IdeaStatus::Rejected => "rejected",
|
||||
IdeaStatus::Promoted => "promoted",
|
||||
IdeaStatus::Archived => "archived",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for IdeaStatus {
|
||||
fn default() -> Self {
|
||||
IdeaStatus::Draft
|
||||
}
|
||||
}
|
||||
|
||||
/// 项目状态
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProjectStatus {
|
||||
/// 规划中
|
||||
Planning,
|
||||
/// 开发中
|
||||
InProgress,
|
||||
/// 测试中
|
||||
Testing,
|
||||
/// 发布中
|
||||
Releasing,
|
||||
/// 已完成
|
||||
Completed,
|
||||
/// 已暂停
|
||||
Paused,
|
||||
/// 已取消
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl ProjectStatus {
|
||||
/// 返回数据库存储用的小写字符串
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ProjectStatus::Planning => "planning",
|
||||
ProjectStatus::InProgress => "in_progress",
|
||||
ProjectStatus::Testing => "testing",
|
||||
ProjectStatus::Releasing => "releasing",
|
||||
ProjectStatus::Completed => "completed",
|
||||
ProjectStatus::Paused => "paused",
|
||||
ProjectStatus::Cancelled => "cancelled",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ProjectStatus {
|
||||
fn default() -> Self {
|
||||
ProjectStatus::Planning
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务状态
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaskStatus {
|
||||
/// 待开始
|
||||
Todo,
|
||||
/// 进行中
|
||||
InProgress,
|
||||
/// 代码审查中
|
||||
InReview,
|
||||
/// 测试中
|
||||
Testing,
|
||||
/// 已完成
|
||||
Done,
|
||||
/// 已阻塞
|
||||
Blocked,
|
||||
/// 已取消
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl TaskStatus {
|
||||
/// 返回数据库存储用的小写字符串
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
TaskStatus::Todo => "todo",
|
||||
TaskStatus::InProgress => "in_progress",
|
||||
TaskStatus::InReview => "in_review",
|
||||
TaskStatus::Testing => "testing",
|
||||
TaskStatus::Done => "done",
|
||||
TaskStatus::Blocked => "blocked",
|
||||
TaskStatus::Cancelled => "cancelled",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TaskStatus {
|
||||
fn default() -> Self {
|
||||
TaskStatus::Todo
|
||||
}
|
||||
}
|
||||
|
||||
/// 工作流状态
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkflowStatus {
|
||||
/// 待执行
|
||||
Pending,
|
||||
/// 运行中
|
||||
Running,
|
||||
/// 已暂停(等待人工输入等)
|
||||
Paused,
|
||||
/// 已完成
|
||||
Completed,
|
||||
/// 已失败
|
||||
Failed,
|
||||
/// 已取消
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl WorkflowStatus {
|
||||
/// 返回数据库存储用的小写字符串
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
WorkflowStatus::Pending => "pending",
|
||||
WorkflowStatus::Running => "running",
|
||||
WorkflowStatus::Paused => "paused",
|
||||
WorkflowStatus::Completed => "completed",
|
||||
WorkflowStatus::Failed => "failed",
|
||||
WorkflowStatus::Cancelled => "cancelled",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WorkflowStatus {
|
||||
fn default() -> Self {
|
||||
WorkflowStatus::Pending
|
||||
}
|
||||
}
|
||||
|
||||
/// 节点状态
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NodeStatus {
|
||||
/// 待执行
|
||||
Pending,
|
||||
/// 运行中
|
||||
Running,
|
||||
/// 已完成
|
||||
Completed,
|
||||
/// 已失败
|
||||
Failed,
|
||||
/// 已跳过
|
||||
Skipped,
|
||||
/// 等待中(如等待人工操作)
|
||||
Waiting,
|
||||
/// 已取消
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl NodeStatus {
|
||||
/// 返回数据库存储用的小写字符串
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
NodeStatus::Pending => "pending",
|
||||
NodeStatus::Running => "running",
|
||||
NodeStatus::Completed => "completed",
|
||||
NodeStatus::Failed => "failed",
|
||||
NodeStatus::Skipped => "skipped",
|
||||
NodeStatus::Waiting => "waiting",
|
||||
NodeStatus::Cancelled => "cancelled",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NodeStatus {
|
||||
fn default() -> Self {
|
||||
NodeStatus::Pending
|
||||
}
|
||||
}
|
||||
|
||||
/// 分支状态
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BranchStatus {
|
||||
/// 活跃中
|
||||
Active,
|
||||
/// 已合并
|
||||
Merged,
|
||||
/// 已废弃
|
||||
Abandoned,
|
||||
}
|
||||
|
||||
impl BranchStatus {
|
||||
/// 返回数据库存储用的小写字符串
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
BranchStatus::Active => "active",
|
||||
BranchStatus::Merged => "merged",
|
||||
BranchStatus::Abandoned => "abandoned",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BranchStatus {
|
||||
fn default() -> Self {
|
||||
BranchStatus::Active
|
||||
}
|
||||
}
|
||||
|
||||
/// 优先级
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Priority {
|
||||
/// 低
|
||||
Low = 0,
|
||||
/// 中
|
||||
Medium = 1,
|
||||
/// 高
|
||||
High = 2,
|
||||
/// 紧急
|
||||
Critical = 3,
|
||||
}
|
||||
|
||||
impl Priority {
|
||||
/// 返回数据库存储用的小写字符串
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Priority::Low => "low",
|
||||
Priority::Medium => "medium",
|
||||
Priority::High => "high",
|
||||
Priority::Critical => "critical",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Priority {
|
||||
fn default() -> Self {
|
||||
Priority::Medium
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 辅助函数
|
||||
// ============================================================
|
||||
|
||||
/// 生成新的 UUID v4 字符串
|
||||
pub fn new_id() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
[package]
|
||||
name = "df-evolve"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
df-core = { path = "../df-core" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
@@ -1,58 +0,0 @@
|
||||
//! 进化引擎:知识沉淀的核心闭环
|
||||
//!
|
||||
//! 使用 → 沉淀 → 复用 → 改进 → 再沉淀
|
||||
|
||||
use crate::knowledge::{Knowledge, KnowledgeKind, KnowledgeStore};
|
||||
use crate::pattern::PatternExtractor;
|
||||
|
||||
/// 进化引擎
|
||||
pub struct EvolveEngine {
|
||||
extractor: PatternExtractor,
|
||||
}
|
||||
|
||||
impl EvolveEngine {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
extractor: PatternExtractor,
|
||||
}
|
||||
}
|
||||
|
||||
/// 自动扫描项目事件,提取可沉淀的知识
|
||||
///
|
||||
/// 触发时机:
|
||||
/// - 工作流节点完成后
|
||||
/// - 代码审查完成后
|
||||
/// - Bug 修复完成后
|
||||
/// - 发布完成后
|
||||
pub async fn evolve_from_events(&self, _events: &[serde_json::Value]) -> Vec<Knowledge> {
|
||||
let mut new_knowledge = Vec::new();
|
||||
|
||||
// TODO: 遍历事件,分类处理
|
||||
// 1. 审查事件 → 提取审查规则
|
||||
// 2. Bug 修复事件 → 提取诊断知识
|
||||
// 3. 发布事件 → 提取部署经验
|
||||
// 4. Prompt 事件 → 提取 Prompt 模板
|
||||
|
||||
new_knowledge
|
||||
}
|
||||
|
||||
/// 查询当前任务相关的知识(供 AI 节点使用)
|
||||
///
|
||||
/// AI 在执行任务前可以查询知识库,获取相关经验和规则
|
||||
pub fn query_relevant(
|
||||
&self,
|
||||
_context: &str,
|
||||
_kind: Option<&KnowledgeKind>,
|
||||
) -> Vec<Knowledge> {
|
||||
// TODO: 语义搜索知识库
|
||||
KnowledgeStore::search(_context, _kind, 5)
|
||||
}
|
||||
|
||||
/// 验证知识的有效性(定期执行)
|
||||
///
|
||||
/// 检查知识是否仍然适用(依赖版本是否过时、规则是否仍有意义等)
|
||||
pub async fn validate_knowledge(&self) -> Vec<String> {
|
||||
// TODO: 遍历知识库,标记过时的知识
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
//! 知识条目:经验沉淀的基本单元
|
||||
|
||||
use df_core::types::ProjectId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 知识类型
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum KnowledgeKind {
|
||||
/// 代码审查规则(如"禁止在循环中创建连接")
|
||||
ReviewRule,
|
||||
/// 有效的 Prompt 模板
|
||||
PromptTemplate,
|
||||
/// 踩坑经验
|
||||
Pitfall,
|
||||
/// 架构模式
|
||||
ArchitecturePattern,
|
||||
/// 诊断知识(Bug 根因分析)
|
||||
Diagnosis,
|
||||
/// 部署经验
|
||||
DeploymentNote,
|
||||
/// 工作流优化建议
|
||||
WorkflowOptimization,
|
||||
}
|
||||
|
||||
/// 知识条目
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Knowledge {
|
||||
pub id: String,
|
||||
pub kind: KnowledgeKind,
|
||||
/// 标题
|
||||
pub title: String,
|
||||
/// 内容
|
||||
pub content: String,
|
||||
/// 标签
|
||||
pub tags: Vec<String>,
|
||||
/// 来源项目
|
||||
pub source_project: Option<ProjectId>,
|
||||
/// 来源实体(如某次审查、某个 Bug 修复)
|
||||
pub source_ref: Option<String>,
|
||||
/// 被复用次数
|
||||
pub reuse_count: usize,
|
||||
/// 效果评分 (0-100,由用户或 AI 评估)
|
||||
pub effectiveness: Option<f32>,
|
||||
/// 是否已验证有效
|
||||
pub verified: bool,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
impl Knowledge {
|
||||
pub fn new(kind: KnowledgeKind, title: String, content: String) -> Self {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
Self {
|
||||
id: df_core::types::new_id(),
|
||||
kind,
|
||||
title,
|
||||
content,
|
||||
tags: vec![],
|
||||
source_project: None,
|
||||
source_ref: None,
|
||||
reuse_count: 0,
|
||||
effectiveness: None,
|
||||
verified: false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录一次复用
|
||||
pub fn record_reuse(&mut self) {
|
||||
self.reuse_count += 1;
|
||||
self.updated_at = chrono::Utc::now().timestamp();
|
||||
}
|
||||
}
|
||||
|
||||
/// 知识库(内存索引,持久化到 SQLite)
|
||||
pub struct KnowledgeStore;
|
||||
|
||||
impl KnowledgeStore {
|
||||
/// 搜索相关知识
|
||||
pub fn search(_query: &str, _kind: Option<&KnowledgeKind>, _limit: usize) -> Vec<Knowledge> {
|
||||
// TODO: SQLite 全文搜索或向量搜索
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// 获取最常用的知识
|
||||
pub fn top_used(_limit: usize) -> Vec<Knowledge> {
|
||||
// TODO: 按 reuse_count 降序
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// 保存知识条目
|
||||
pub fn save(_knowledge: &Knowledge) -> anyhow::Result<()> {
|
||||
// TODO: SQLite INSERT/UPDATE
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
//! 经验进化引擎:从开发过程中自动沉淀知识,持续进化复用
|
||||
//!
|
||||
//! 核心闭环:使用 → 沉淀 → 复用 → 改进 → 再沉淀
|
||||
|
||||
pub mod knowledge;
|
||||
pub mod pattern;
|
||||
pub mod prompt_template;
|
||||
pub mod review_rule;
|
||||
pub mod evolve_engine;
|
||||
|
||||
pub use evolve_engine::EvolveEngine;
|
||||
pub use knowledge::{Knowledge, KnowledgeKind, KnowledgeStore};
|
||||
@@ -1,47 +0,0 @@
|
||||
//! 模式提取器:从开发过程中自动识别可沉淀的模式
|
||||
|
||||
use crate::knowledge::{Knowledge, KnowledgeKind};
|
||||
|
||||
/// 模式提取器
|
||||
///
|
||||
/// 自动从以下场景中识别可沉淀的模式:
|
||||
/// - 代码审查 → 审查规则
|
||||
/// - Bug 修复 → 诊断知识
|
||||
/// - 发布流程 → 部署经验
|
||||
/// - Prompt 调优 → Prompt 模板
|
||||
pub struct PatternExtractor;
|
||||
|
||||
impl PatternExtractor {
|
||||
/// 从代码审查结果中提取审查规则
|
||||
///
|
||||
/// 如果同一类问题在多次审查中重复出现,自动沉淀为规则
|
||||
pub fn extract_review_rule(
|
||||
_findings: &[serde_json::Value],
|
||||
_occurrence_threshold: usize,
|
||||
) -> Option<Knowledge> {
|
||||
// TODO:
|
||||
// 1. 分析 findings 的共性
|
||||
// 2. 如果出现次数 >= threshold,生成规则
|
||||
// 3. 去重(与已有规则比较)
|
||||
None
|
||||
}
|
||||
|
||||
/// 从 Bug 修复过程中提取诊断知识
|
||||
pub fn extract_diagnosis(
|
||||
_bug_description: &str,
|
||||
_root_cause: &str,
|
||||
_fix_description: &str,
|
||||
) -> Option<Knowledge> {
|
||||
// TODO: AI 总结为可复用的诊断知识
|
||||
None
|
||||
}
|
||||
|
||||
/// 从成功的 Prompt 中提取模板
|
||||
pub fn extract_prompt_template(
|
||||
_prompt: &str,
|
||||
_result_quality: f32,
|
||||
) -> Option<Knowledge> {
|
||||
// TODO: 如果 result_quality > 0.8,提取为模板
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
//! Prompt 模板管理:AI 交互经验的沉淀与复用
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Prompt 模板
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PromptTemplate {
|
||||
pub id: String,
|
||||
/// 模板名称
|
||||
pub name: String,
|
||||
/// 模板内容(支持 {variable} 占位符)
|
||||
pub template: String,
|
||||
/// 变量说明
|
||||
pub variables: Vec<TemplateVariable>,
|
||||
/// 适用场景
|
||||
pub applicable_scenarios: Vec<String>,
|
||||
/// 效果评分
|
||||
pub avg_score: f32,
|
||||
/// 使用次数
|
||||
pub use_count: usize,
|
||||
}
|
||||
|
||||
/// 模板变量
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TemplateVariable {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub default_value: Option<String>,
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
impl PromptTemplate {
|
||||
/// 渲染模板(替换变量)
|
||||
pub fn render(&self, vars: &std::collections::HashMap<String, String>) -> String {
|
||||
let mut result = self.template.clone();
|
||||
for (key, value) in vars {
|
||||
result = result.replace(&format!("{{{}}}", key), value);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
//! 审查规则:从历史审查经验中沉淀的代码审查规则
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 审查规则
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReviewRule {
|
||||
pub id: String,
|
||||
/// 规则标题
|
||||
pub title: String,
|
||||
/// 规则描述
|
||||
pub description: String,
|
||||
/// 严重级别
|
||||
pub severity: RuleSeverity,
|
||||
/// 适用的语言/框架
|
||||
pub scope: Vec<String>,
|
||||
/// 检查方式(正则/AST/AI)
|
||||
pub check_method: CheckMethod,
|
||||
/// 发现次数(历史累计)
|
||||
pub found_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuleSeverity {
|
||||
/// 必须修复
|
||||
MustFix,
|
||||
/// 建议改进
|
||||
ShouldFix,
|
||||
/// 可选优化
|
||||
NiceToHave,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CheckMethod {
|
||||
/// 正则匹配
|
||||
Regex,
|
||||
/// AST 分析
|
||||
Ast,
|
||||
/// AI 判断
|
||||
AiAnalysis,
|
||||
/// 人工判断
|
||||
Manual,
|
||||
}
|
||||
@@ -4,10 +4,7 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
df-core = { path = "../df-core" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//! 提交冻结机械闸(39 治理) — AI 引擎派发命令的 git commit 拦截。
|
||||
//!
|
||||
//! 冻结期间(用户 🔒 令)禁止引擎/挂机会话执行 git commit:命令在 spawn 前被拒,
|
||||
//! 改动留在工作区。用户本人终端不受影响(本闸只作用于引擎 execute 入口)。
|
||||
//!
|
||||
//! 冻结标志解析(优先级:环境变量 DEVFLOW_COMMIT_FROZEN > env 内 DevFlow 标志):
|
||||
//! - env::var("DEVFLOW_COMMIT_FROZEN")="1"/"true" → 冻结
|
||||
//! - ShellRequest.env 同名键同样生效(测试/调用方定向注入)
|
||||
//! 默认不冻结(用户本人正常提交流程零影响)。
|
||||
//!
|
||||
//! 拦截范围:命令子串含 `git commit`(含 `git -C x commit`)即拒;`git commit --amend`
|
||||
//! 同拒。`git log/diff/status/add/push` 不拦(只冻结提交动作)。
|
||||
//!
|
||||
//! 提示文案给 LLM 指明出路:改留工作区,等「攒批提交通知」。
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use crate::shell::ShellRequest;
|
||||
|
||||
/// 进程级冻结标志(引擎侧写入:unattended 轮询每轮按看板 🔒 行 + KV 覆盖同步)。
|
||||
/// 用静态位而非 std::env::set_var(Rust 2024 edition set_var unsafe 且多线程 UB)。
|
||||
static FROZEN: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// 引擎侧设置/解除冻结(unattended 轮询每轮调用)。
|
||||
pub fn set_frozen(v: bool) {
|
||||
FROZEN.store(v, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// 环境变量真值判定:"1"/"true"(含大小写变体)为冻结,其余("0"/"false"/空)为不冻结。
|
||||
fn env_val_is_truthy(v: &str) -> bool {
|
||||
matches!(v, "1" | "true" | "TRUE" | "True")
|
||||
}
|
||||
|
||||
/// 是否启用冻结:静态标志(引擎同步) 或 环境变量 DEVFLOW_COMMIT_FROZEN(调用方/测试定向注入)。
|
||||
pub fn is_frozen(extra_env: Option<&std::collections::HashMap<String, String>>) -> bool {
|
||||
if FROZEN.load(Ordering::SeqCst) {
|
||||
return true;
|
||||
}
|
||||
if let Some(v) = extra_env.and_then(|m| m.get("DEVFLOW_COMMIT_FROZEN")) {
|
||||
return env_val_is_truthy(v);
|
||||
}
|
||||
std::env::var("DEVFLOW_COMMIT_FROZEN")
|
||||
.map(|v| env_val_is_truthy(&v))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 命令是否为 git commit 提交动作(token 判据,含 -C 前缀形态与复合命令)。
|
||||
pub fn is_commit_command(command: &str) -> bool {
|
||||
// 按常见命令分隔符切段( ; && || | 换行),每段独立判 git commit(防 "x; git commit" 绕过)
|
||||
command
|
||||
.split(|c: char| matches!(c, ';' | '|' | '&' | '\n'))
|
||||
.any(is_commit_segment)
|
||||
}
|
||||
|
||||
/// 单段判据:token 找 "git",跳过全局选项(-C path 等)后是否接 "commit"。
|
||||
fn is_commit_segment(segment: &str) -> bool {
|
||||
let tokens: Vec<String> = segment
|
||||
.split_whitespace()
|
||||
.map(|t| t.trim_matches(|c: char| c == '"' || c == '\'').to_lowercase())
|
||||
.collect();
|
||||
for (i, t) in tokens.iter().enumerate() {
|
||||
if t == "git" {
|
||||
let mut j = i + 1;
|
||||
while j < tokens.len() {
|
||||
match tokens[j].as_str() {
|
||||
// `-c key=val` 两 token 形态:值在下一 token,跳 2
|
||||
"-c" => j += 2,
|
||||
// `--git-dir=path`/`--work-tree=path` 单 token 合并形态:自含值,跳 1
|
||||
t if t.starts_with("--git-dir=") || t.starts_with("--work-tree=") => j += 1,
|
||||
// `--git-dir path`/`--work-tree path` 两 token 形态:跳 2
|
||||
"--git-dir" | "--work-tree" => j += 2,
|
||||
"commit" => return true,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 冻结闸检查入口:冻结在场且命令是 git commit → 返回拒绝 Err(不 spawn)。
|
||||
pub fn check(request: &ShellRequest) -> Result<(), String> {
|
||||
if !is_frozen(Some(&request.env)) {
|
||||
return Ok(());
|
||||
}
|
||||
if is_commit_command(&request.command) {
|
||||
return Err(format!(
|
||||
"[提交冻结令] 检测到 git commit 命令,当前处于提交冻结期(用户 🔒 令),已拒绝执行: \
|
||||
改动保留在工作区,等用户「攒批提交通知」后才可提交。命令: {}",
|
||||
truncate_cmd(&request.command)
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn truncate_cmd(s: &str) -> String {
|
||||
if s.chars().count() <= 120 {
|
||||
s.to_string()
|
||||
} else {
|
||||
let cut: String = s.chars().take(120).collect();
|
||||
format!("{}…", cut)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn req(cmd: &str) -> ShellRequest {
|
||||
ShellRequest {
|
||||
command: cmd.to_string(),
|
||||
working_dir: None,
|
||||
env: HashMap::new(),
|
||||
timeout_secs: None,
|
||||
shell_type: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn req_env(cmd: &str, env: &[(&str, &str)]) -> ShellRequest {
|
||||
let mut r = req(cmd);
|
||||
r.env = env
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect();
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_detection_covers_variants() {
|
||||
assert!(is_commit_command("git commit -m 'x'"));
|
||||
assert!(is_commit_command("git -C E:/repo commit -m x"));
|
||||
assert!(is_commit_command("git commit --amend"));
|
||||
assert!(is_commit_command("git commit -m y"));
|
||||
assert!(is_commit_command("git commit; git push"));
|
||||
// PowerShell / sh 包裹形态
|
||||
assert!(is_commit_command("powershell -Command git commit -m x"));
|
||||
assert!(is_commit_command("cd repo && git commit -m x"));
|
||||
assert!(is_commit_command("git add . && git commit -m x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_commit_git_not_blocked() {
|
||||
assert!(!is_commit_command("git log -1"));
|
||||
assert!(!is_commit_command("git status"));
|
||||
assert!(!is_commit_command("git diff --stat"));
|
||||
assert!(!is_commit_command("git add ."));
|
||||
assert!(!is_commit_command("git push origin main"));
|
||||
assert!(!is_commit_command("cargo test"));
|
||||
// commitment 等误命中防护:commit 后无后续 token 才算
|
||||
assert!(!is_commit_command("git log --grep commit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeze_gate_blocks_commit_when_frozen() {
|
||||
let r = req_env("git commit -m '细项批'", &[("DEVFLOW_COMMIT_FROZEN", "1")]);
|
||||
assert!(check(&r).is_err(), "冻结在场 commit 必须被拒");
|
||||
let r2 = req_env("git log -1", &[("DEVFLOW_COMMIT_FROZEN", "1")]);
|
||||
assert!(check(&r2).is_ok(), "冻结只拦 commit 不拦只读命令");
|
||||
}
|
||||
|
||||
/// 解冻态放行用例:持与 static_flag_toggle 同一把串行闸——静态位是进程级单源,
|
||||
/// 置位瞬间并行跑到本用例会误判冻结(实测互踩),凡依赖「静态位=false」断言的用例一律持闸。
|
||||
#[test]
|
||||
fn no_freeze_no_block() {
|
||||
let _gate = crate::shell::PWSH_CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let r = req("git commit -m '用户本人提交'");
|
||||
assert!(check(&r).is_ok(), "无冻结标志零影响(用户正常提交不受影响)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_false_value_no_block() {
|
||||
let _gate = crate::shell::PWSH_CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let r = req_env("git commit -m x", &[("DEVFLOW_COMMIT_FROZEN", "0")]);
|
||||
assert!(check(&r).is_ok(), "显式关闭标志不拦(解冻态)");
|
||||
}
|
||||
|
||||
/// 静态标志(引擎同步路径)切换:置位拦 commit,复位恢复。
|
||||
/// 串行闸:静态位是进程级单源,置位期间并行的 env 用例(check 走 is_frozen 静态位短路)会互踩,
|
||||
/// 持锁串行 + 进出恢复默认(false)防污染。
|
||||
#[test]
|
||||
fn static_flag_toggle() {
|
||||
// 复用 PWSH_CACHE_TEST_LOCK 串行闸(进程级互斥,专用闸后续有需要再单立)
|
||||
let _gate = crate::shell::PWSH_CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
set_frozen(true);
|
||||
assert!(check(&req("git commit -m x")).is_err(), "静态冻结位拦 commit");
|
||||
assert!(check(&req("git diff")).is_ok(), "静态冻结位不拦只读命令");
|
||||
set_frozen(false);
|
||||
assert!(check(&req("git commit -m x")).is_ok(), "复位后恢复放行");
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
//! Docker 执行器 — 在容器中运行任务
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Docker 容器执行请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DockerRequest {
|
||||
/// 镜像名称
|
||||
pub image: String,
|
||||
/// 容器内执行的命令
|
||||
pub command: Option<String>,
|
||||
/// 环境变量
|
||||
pub env: std::collections::HashMap<String, String>,
|
||||
/// 挂载卷
|
||||
pub volumes: Vec<VolumeMount>,
|
||||
/// 是否在执行后自动删除容器
|
||||
pub auto_remove: bool,
|
||||
}
|
||||
|
||||
/// 卷挂载
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VolumeMount {
|
||||
pub host_path: String,
|
||||
pub container_path: String,
|
||||
pub read_only: bool,
|
||||
}
|
||||
|
||||
/// Docker 执行结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DockerResult {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub exit_code: Option<i32>,
|
||||
}
|
||||
|
||||
/// 在 Docker 容器中执行命令
|
||||
///
|
||||
/// TODO: 实现 Docker API 调用或 CLI 包装
|
||||
pub async fn execute(_request: DockerRequest) -> anyhow::Result<DockerResult> {
|
||||
tracing::info!("Docker 执行: TODO");
|
||||
Ok(DockerResult {
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
exit_code: None,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
//! 环境感知系统 — 启动时一次性探测 OS/shell/工具版本/编码,全局缓存,注入 system_prompt。
|
||||
//!
|
||||
//! 设计目标:让 LLM 准确生成跨平台命令。LLM 训练数据 Unix 多,易生成 macOS/Linux 语法
|
||||
//! 命令(PowerShell 5 不支持 `&&`、Windows 路径分隔符 `\`、GBK 终端中文乱码等),通过
|
||||
//! 把当前平台的真实环境(操作系统/Shell/工具版本)拼进 system_prompt,LLM 即可生成与
|
||||
//! 当前平台兼容的命令,从根上治「LLM 跨平台命令幻觉」。
|
||||
//!
|
||||
//! 缓存策略:`OnceLock` 全局单例,首次 `detect()` 探测,后续调用零开销。探测本身在
|
||||
//! `spawn_blocking` 中执行(执行 `tool --version` 等阻塞 IO),避免阻塞 tokio runtime。
|
||||
//! 探测失败的字段设 None/默认值,不向上传播错误(环境感知是 best-effort 增强,不应
|
||||
//! 阻断主流程)。
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// 环境快照 — 系统环境的不可变视图。
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct EnvSnapshot {
|
||||
/// 操作系统族:"windows" / "macos" / "linux"
|
||||
pub os: String,
|
||||
/// OS 版本:"11" / "Ubuntu 22.04"(探测失败为空串)
|
||||
pub os_version: String,
|
||||
/// Shell 名:"powershell" / "pwsh" / "bash" / "zsh" / "sh"
|
||||
pub shell: String,
|
||||
/// 路径分隔符:"\\"(Windows) / "/"(Unix)
|
||||
pub path_sep: String,
|
||||
/// 终端编码:"utf-8" / "gbk"(Windows 中文常见 GBK,致 LLM 输出乱码)
|
||||
pub encoding: String,
|
||||
/// 工具版本(逐个探测,缺失为 None)
|
||||
pub tools: ToolVersions,
|
||||
}
|
||||
|
||||
/// 各开发工具的版本信息。
|
||||
#[derive(Debug, Clone, serde::Serialize, Default)]
|
||||
pub struct ToolVersions {
|
||||
pub python: Option<String>,
|
||||
pub node: Option<String>,
|
||||
pub rust: Option<String>,
|
||||
pub go: Option<String>,
|
||||
pub docker: Option<String>,
|
||||
pub git: Option<String>,
|
||||
}
|
||||
|
||||
impl EnvSnapshot {
|
||||
/// 探测环境(惰性,OnceLock 全局缓存,整个进程生命周期只探一次)。
|
||||
///
|
||||
/// 返回 `&'static EnvSnapshot` —— 引用静态存储,可安全地长存于 loop 不变量中。
|
||||
/// 探测在 `spawn_blocking` 中同步执行(`tool --version` 是阻塞 IO),不卡 runtime。
|
||||
pub async fn detect() -> &'static EnvSnapshot {
|
||||
static SNAPSHOT: OnceLock<EnvSnapshot> = OnceLock::new();
|
||||
if let Some(snap) = SNAPSHOT.get() {
|
||||
return snap;
|
||||
}
|
||||
// 首次探测:同步逻辑包到 spawn_blocking,避免阻塞 async runtime。
|
||||
// probe_version 内 std::process::Command::output() 无 timeout,
|
||||
// Windows 上 python/node 若是 Microsoft Store App Execution Alias(用户未装但开了
|
||||
// "应用执行别名"),`python --version` 触发 Store 重定向、process 不退出 → output()
|
||||
// 永久阻塞 → spawn_blocking 线程永不返回 → detect().await 永久挂 → run_agentic_loop
|
||||
// 卡在 EnvSnapshot::detect() 调用(agentic/mod.rs),后端日志断在"意图收敛工具"后,
|
||||
// 前端表现为「发消息完全无回应然后卡死」(trace.log 实测 3 次发送全卡于此)。
|
||||
// 修复: spawn_blocking 外包 5s timeout,超时返回 fallback 并 set 进 OnceLock(后续命中
|
||||
// 缓存不再 probe),run_agentic_loop 最多等 5s 后继续,不再永久卡。
|
||||
// 注: 超时后 spawn_blocking 线程仍在跑(卡在 output),但已脱离 await,不阻塞调用方;
|
||||
// 线程最终随 process 退出或进程结束回收,无泄漏累积(OnceLock 已 set,不会重复 probe)。
|
||||
let snap = match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
tokio::task::spawn_blocking(|| EnvSnapshot::do_detect()),
|
||||
).await {
|
||||
Ok(Ok(s)) => s,
|
||||
Ok(Err(join_err)) => {
|
||||
eprintln!("[env_snapshot] 探测任务异常,回退默认环境: {}", join_err);
|
||||
EnvSnapshot::fallback()
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
eprintln!(
|
||||
"[env_snapshot] 探测超时(5s),某 tool(python/node/rustc/go/docker/git)命令卡住 \
|
||||
(疑似 Windows Store App Execution Alias 触发 Store 重定向),回退默认环境"
|
||||
);
|
||||
EnvSnapshot::fallback()
|
||||
}
|
||||
};
|
||||
// 多任务竞态:均等价,以先到者为准。
|
||||
let _ = SNAPSHOT.set(snap);
|
||||
SNAPSHOT.get().expect("EnvSnapshot 已初始化")
|
||||
}
|
||||
|
||||
/// 同步探测(可能短暂阻塞,仅在 spawn_blocking 中调用)。
|
||||
fn do_detect() -> EnvSnapshot {
|
||||
EnvSnapshot {
|
||||
os: std::env::consts::OS.to_string(),
|
||||
os_version: detect_os_version(),
|
||||
shell: detect_shell(),
|
||||
path_sep: std::path::MAIN_SEPARATOR.to_string(),
|
||||
encoding: detect_encoding(),
|
||||
tools: ToolVersions {
|
||||
python: probe_version("python", &["--version"]),
|
||||
node: probe_version("node", &["--version"]),
|
||||
rust: probe_version("rustc", &["--version"]),
|
||||
go: probe_version("go", &["version"]),
|
||||
docker: probe_version("docker", &["--version"]),
|
||||
git: probe_version("git", &["--version"]),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 兜底:spawn_blocking panic/join 失败或外层 5s 超时时返回最小可用快照(全 None,字段不空)。
|
||||
/// 注:异常/超时均**冻结**为 fallback 写入 OnceLock(EX-3)——本进程后续 detect() 不再重探,
|
||||
/// 瞬时挂起(如 Store Alias)会被固化为整进程降级快照;解冻需重启进程。
|
||||
fn fallback() -> EnvSnapshot {
|
||||
EnvSnapshot {
|
||||
os: std::env::consts::OS.to_string(),
|
||||
os_version: String::new(),
|
||||
shell: if cfg!(windows) { "powershell".into() } else { "sh".into() },
|
||||
path_sep: std::path::MAIN_SEPARATOR.to_string(),
|
||||
encoding: "utf-8".into(),
|
||||
tools: ToolVersions::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成 system_prompt 注入文本(拼到 system_prompt 尾部)。
|
||||
///
|
||||
/// 末尾加「请生成本平台兼容的命令」软提示,锚定 LLM 输出平台一致性。
|
||||
pub fn to_prompt(&self) -> String {
|
||||
let mut lines: Vec<String> = vec![
|
||||
"## 系统环境".to_string(),
|
||||
format!("- 操作系统: {} {}", self.os, self.os_version).trim_end().to_string(),
|
||||
format!("- Shell: {}", self.shell),
|
||||
format!("- 路径分隔符: {}", self.path_sep),
|
||||
format!("- 终端编码: {}", self.encoding),
|
||||
];
|
||||
if let Some(v) = &self.tools.python {
|
||||
lines.push(format!("- Python: {}", v));
|
||||
}
|
||||
if let Some(v) = &self.tools.node {
|
||||
lines.push(format!("- Node: {}", v));
|
||||
}
|
||||
if let Some(v) = &self.tools.rust {
|
||||
lines.push(format!("- Rust: {}", v));
|
||||
}
|
||||
if let Some(v) = &self.tools.go {
|
||||
lines.push(format!("- Go: {}", v));
|
||||
}
|
||||
if let Some(v) = &self.tools.docker {
|
||||
lines.push(format!("- Docker: {}", v));
|
||||
}
|
||||
if let Some(v) = &self.tools.git {
|
||||
lines.push(format!("- Git: {}", v));
|
||||
}
|
||||
lines.push(String::new());
|
||||
lines.push("注意: 请生成本平台兼容的命令。".to_string());
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测 OS 版本(各平台路径不一,失败返回空串而非 None,简化 prompt 拼接)。
|
||||
fn detect_os_version() -> String {
|
||||
// Windows:读注册表 HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion (ProductName/DisplayVersion)。
|
||||
// 不依赖 winver GUI / reg.exe 输出格式,直接读注册表最稳。
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(v) = read_windows_version() {
|
||||
return v;
|
||||
}
|
||||
return String::new();
|
||||
}
|
||||
// macOS:sw_vers -productVersion 输出如 "12.5"
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Ok(out) = std::process::Command::new("sw_vers").arg("-productVersion").output() {
|
||||
if out.status.success() {
|
||||
return String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
}
|
||||
}
|
||||
return String::new();
|
||||
}
|
||||
// Linux:读 /etc/os-release 的 PRETTY_NAME 字段(系统标准位置)
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Ok(content) = std::fs::read_to_string("/etc/os-release") {
|
||||
for line in content.lines() {
|
||||
if let Some(rest) = line.strip_prefix("PRETTY_NAME=") {
|
||||
return rest.trim_matches('"').to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
return String::new();
|
||||
}
|
||||
#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn read_windows_version() -> Option<String> {
|
||||
// 用 reg.exe query 读注册表(DisplayVersion 优先,如 "22H2";回退 ProductName,如 "Windows 10 Pro")。
|
||||
// 避开 winreg crate 依赖(增加构建复杂度,且 reg.exe 在所有 Win 版本均自带)。
|
||||
let out = std::process::Command::new("reg")
|
||||
.args([
|
||||
"query",
|
||||
r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion",
|
||||
"/v",
|
||||
"DisplayVersion",
|
||||
])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.creation_flags(0x0800_0000) // CREATE_NO_WINDOW
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
// 输出形如: " DisplayVersion REG_SZ 22H2"
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(idx) = trimmed.find("REG_SZ") {
|
||||
let val = trimmed[idx + "REG_SZ".len()..].trim();
|
||||
if !val.is_empty() {
|
||||
return Some(format!("Windows {}", val));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 探测默认 shell(Windows 走 shell.rs 单源,Unix 读 SHELL 环境变量)。
|
||||
///
|
||||
/// 单源语义(2026-08 修复):Windows 分支不再独立 probe pwsh/powershell,而是:
|
||||
/// 1) 先调 `shell::probe_pwsh_blocking()` 填充模块级 `PWSH_CACHE`(与 `shell::probe_pwsh()` 异步路径
|
||||
/// 共用同一 OnceLock + 同一阻塞探测实现,杜绝两套逻辑漂移);
|
||||
/// 2) 再调 `shell::current_shell()`(同步读 `PWSH_CACHE`),映射 ShellType → prompt 字符串。
|
||||
/// 后续 `execute()` → `probe_pwsh().await` 直接命中缓存,跳过重复探测。
|
||||
/// Unix 分支保留原 SHELL 环境变量读取(与执行侧 ShellType::Sh 默认一致,无漂移风险)。
|
||||
fn detect_shell() -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// 单源填充 PWSH_CACHE(本函数运行在 EnvSnapshot::detect 的 spawn_blocking 内,
|
||||
// 通常先于首次 execute(),故常是缓存的首次写入者)。
|
||||
crate::shell::probe_pwsh_blocking();
|
||||
// 单源读取并映射为 prompt 字符串。
|
||||
match crate::shell::current_shell() {
|
||||
crate::shell::ShellType::Pwsh => "pwsh".to_string(),
|
||||
crate::shell::ShellType::PowerShell => "powershell".to_string(),
|
||||
crate::shell::ShellType::Cmd => "cmd".to_string(),
|
||||
crate::shell::ShellType::Sh => "sh".to_string(),
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// Unix:SHELL 环境变量优先,常见值 /bin/bash /bin/zsh /bin/sh。
|
||||
if let Ok(sh) = std::env::var("SHELL") {
|
||||
// 取 basename(/bin/zsh → zsh)
|
||||
let name = sh.rsplit('/').next().unwrap_or(&sh);
|
||||
if !name.is_empty() {
|
||||
return name.to_string();
|
||||
}
|
||||
}
|
||||
"sh".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测终端编码(Windows 中文常见 GBK,导致 LLM 输出 UTF-8 在终端乱码)。
|
||||
fn detect_encoding() -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// chcp 输出形如 "活动代码页: 936"(GBK)。936 → gbk,65001 → utf-8,其余按数字降级。
|
||||
if let Ok(out) = std::process::Command::new("chcp")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.creation_flags(0x0800_0000)
|
||||
.output()
|
||||
{
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
if let Some(code) = extract_codepage(&text) {
|
||||
return match code.as_str() {
|
||||
"65001" => "utf-8".to_string(),
|
||||
"936" => "gbk".to_string(),
|
||||
"950" => "big5".to_string(),
|
||||
other => format!("cp{}", other),
|
||||
};
|
||||
}
|
||||
}
|
||||
return "utf-8".to_string();
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// Unix 默认 UTF-8(LANG/LC_ALL 通常含 UTF-8)。
|
||||
if let Ok(lang) = std::env::var("LANG") {
|
||||
if lang.to_ascii_uppercase().contains("UTF-8") {
|
||||
return "utf-8".to_string();
|
||||
}
|
||||
}
|
||||
"utf-8".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn extract_codepage(text: &str) -> Option<String> {
|
||||
// 提取末尾数字("...936" / "...: 65001")。
|
||||
let mut num = String::new();
|
||||
for c in text.chars().rev() {
|
||||
if c.is_ascii_digit() {
|
||||
num.insert(0, c);
|
||||
} else if !num.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if num.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(num)
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行 `tool --version`,解析首行返回版本串。失败(非零退出/无首行)返回 None。
|
||||
///
|
||||
/// 注:单条命令本身**无独立超时**(EX-4)——若 `tool --version` 挂起(如 Windows Store Alias),
|
||||
/// 本函数会一直阻塞,由外层 [`EnvSnapshot::detect`] 的 5s spawn_blocking 超时兜底整批(届时
|
||||
/// 全部工具版本一起回退 fallback),而非逐命令超时。「不阻塞调用方」靠的是外层超时,非本函数。
|
||||
///
|
||||
/// 例:python --version 输出 "Python 3.11.5" → 返回 "3.11.5";git --version 输出
|
||||
/// "git version 2.41.0" → 返回 "2.41.0"。统一抽掉工具名前缀,只保留版本号本身。
|
||||
fn probe_version(tool: &str, args: &[&str]) -> Option<String> {
|
||||
let mut cmd = std::process::Command::new(tool);
|
||||
cmd.args(args);
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::null());
|
||||
#[cfg(windows)]
|
||||
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||
let out = cmd.output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
let first_line = text.lines().next()?;
|
||||
Some(extract_version_token(first_line))
|
||||
}
|
||||
|
||||
/// 从版本命令首行抽取版本号:取首个形如 N(.N)+ 的 token(至少一个点号)。
|
||||
/// 兼容 "Python 3.11.5" / "v18.17.0" / "git version 2.41.0.windows.1" / "go version go1.21.0 ..."。
|
||||
///
|
||||
/// 策略:把行切成空白 token,逐个匹配「数字开头 + 至少一个 `.`」的模式,取首个命中。
|
||||
/// 比 char 状态机更鲁棒(状态机遇 `2.41.0.windows.1` 这种多层嵌套点会误判)。
|
||||
fn extract_version_token(line: &str) -> String {
|
||||
for token in line.split_whitespace() {
|
||||
// 找 token 内首个数字位置,从这里开始扫 "数字段(.数字段)*" 序列。
|
||||
// 遇点要求下一字符为数字,否则在该点处截断(避免 "2.41.0.windows.1" 被吞成
|
||||
// "2.41.0.windows.1",实际应止于 "2.41.0")。
|
||||
let bytes = token.as_bytes();
|
||||
let mut i = match bytes.iter().position(|b| b.is_ascii_digit()) {
|
||||
Some(i) => i,
|
||||
None => continue,
|
||||
};
|
||||
let mut head = String::new();
|
||||
loop {
|
||||
// 收数字段
|
||||
let seg_start = i;
|
||||
while i < bytes.len() && bytes[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
head.push_str(&token[seg_start..i]);
|
||||
// 点号:仅当下一字符为数字时才续,否则收尾
|
||||
if i < bytes.len() && bytes[i] == b'.' && i + 1 < bytes.len()
|
||||
&& bytes[i + 1].is_ascii_digit()
|
||||
{
|
||||
head.push('.');
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if head.contains('.') {
|
||||
return head;
|
||||
}
|
||||
}
|
||||
// 无版本号 token 时退回整行(避免返回空串让 prompt 出现 "None")。
|
||||
line.trim().to_string()
|
||||
}
|
||||
|
||||
// Windows 下统一在文件顶部引入 CommandExt,使各 #[cfg(windows)] 块可直接调用 creation_flags。
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
use std::process::Stdio;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extract_version_python_style() {
|
||||
assert_eq!(extract_version_token("Python 3.11.5"), "3.11.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_version_node_style() {
|
||||
assert_eq!(extract_version_token("v18.17.0"), "18.17.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_version_git_style() {
|
||||
assert_eq!(extract_version_token("git version 2.41.0.windows.1"), "2.41.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_version_go_style() {
|
||||
assert_eq!(
|
||||
extract_version_token("go version go1.21.0 windows/amd64"),
|
||||
"1.21.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_version_no_match_returns_line() {
|
||||
assert_eq!(extract_version_token("no version here"), "no version here");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn extract_codepage_parsing() {
|
||||
assert_eq!(extract_codepage("活动代码页: 936"), Some("936".into()));
|
||||
assert_eq!(
|
||||
extract_codepage("Active code page: 65001"),
|
||||
Some("65001".into())
|
||||
);
|
||||
assert_eq!(extract_codepage("no digits here"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_returns_cached_static_ref() {
|
||||
// 持 shell.rs 缓存串行闸:本测试可能首次创建 SNAPSHOT,其 shell 字段取自创建时刻的
|
||||
// 缓存状态;若与写缓存的 shell.rs 测试并行,漂移测试会读到与 current_shell 不一致的快照。
|
||||
let _gate = crate::shell::PWSH_CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// 两次 detect 返回同一引用(OnceLock 全局缓存)。
|
||||
let a = EnvSnapshot::detect().await as *const _;
|
||||
let b = EnvSnapshot::detect().await as *const _;
|
||||
assert_eq!(a, b, "detect() 应返回同一静态引用");
|
||||
}
|
||||
|
||||
/// 单源不变量回归:prompt 期 shell(env_snapshot.shell)与执行期 shell(current_shell)必须一致。
|
||||
///
|
||||
/// 历史 bug:detect_shell 持独立 probe_command_success 探测,与 shell.rs::probe_pwsh 各填各的
|
||||
/// OnceLock → prompt 告诉 LLM 用 pwsh,执行却走 powershell(或反之)。根本修:二者共用
|
||||
/// shell.rs 的 probe_pwsh_blocking + PWSH_CACHE 单源。本测试锁定「无漂移」不变量。
|
||||
///
|
||||
/// 隔离:持 shell.rs 缓存串行闸,快照值与 current_shell() 须读自同一缓存状态,
|
||||
/// 防与写缓存的 shell.rs 测试并行互踩(Mutex 化后写值会被复位,闸保证读窗口内无交叉写)。
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn detect_shell_matches_shell_rs_current_shell() {
|
||||
let _gate = crate::shell::PWSH_CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// detect() 内 detect_shell → 读 PWSH_CACHE 映射,与下方 current_shell() 同源。
|
||||
let snap = EnvSnapshot::detect().await;
|
||||
let current = crate::shell::current_shell();
|
||||
// 二者必须一致(同一 PWSH_CACHE 单源读出)。
|
||||
match snap.shell.as_str() {
|
||||
"pwsh" => assert_eq!(current, crate::shell::ShellType::Pwsh),
|
||||
"powershell" => assert_eq!(current, crate::shell::ShellType::PowerShell),
|
||||
"cmd" => assert_eq!(current, crate::shell::ShellType::Cmd),
|
||||
"sh" => assert_eq!(current, crate::shell::ShellType::Sh),
|
||||
other => panic!("未知 shell 字符串: {}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_prompt_contains_os_and_shell() {
|
||||
let snap = EnvSnapshot {
|
||||
os: "test_os".into(),
|
||||
os_version: "v1".into(),
|
||||
shell: "test_shell".into(),
|
||||
path_sep: "/".into(),
|
||||
encoding: "utf-8".into(),
|
||||
tools: ToolVersions {
|
||||
python: Some("3.11".into()),
|
||||
node: None,
|
||||
rust: None,
|
||||
go: None,
|
||||
docker: None,
|
||||
git: None,
|
||||
},
|
||||
};
|
||||
let prompt = snap.to_prompt();
|
||||
assert!(prompt.contains("test_os"));
|
||||
assert!(prompt.contains("test_shell"));
|
||||
assert!(prompt.contains("Python: 3.11"));
|
||||
assert!(!prompt.contains("Node"));
|
||||
assert!(prompt.contains("请生成本平台兼容的命令"));
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
//! Git 操作 — 克隆、提交、推送、合并等
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Git 操作类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GitAction {
|
||||
Clone,
|
||||
Commit,
|
||||
Push,
|
||||
Pull,
|
||||
Merge,
|
||||
Checkout,
|
||||
CreateBranch,
|
||||
}
|
||||
|
||||
/// Git 操作请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GitRequest {
|
||||
/// 操作类型
|
||||
pub action: GitAction,
|
||||
/// 仓库路径(本地路径或远程 URL)
|
||||
pub repo: String,
|
||||
/// 分支名
|
||||
pub branch: Option<String>,
|
||||
/// 提交消息
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
/// Git 操作结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GitResult {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 执行 Git 操作
|
||||
///
|
||||
/// TODO: 实现完整的 Git 操作(可包装 git CLI 或使用 git2 crate)
|
||||
pub async fn execute(_request: GitRequest) -> anyhow::Result<GitResult> {
|
||||
tracing::info!("Git 操作: TODO");
|
||||
Ok(GitResult {
|
||||
success: true,
|
||||
message: "TODO: 未实现".to_string(),
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
//! df-execute: 执行运行时 — Shell、Docker、SSH、Git 操作
|
||||
//! df-execute: 执行运行时 — Shell + 环境感知 + 提交冻结闸
|
||||
|
||||
pub mod docker;
|
||||
pub mod git_ops;
|
||||
pub mod commit_freeze;
|
||||
pub mod env_snapshot;
|
||||
pub mod shell;
|
||||
pub mod ssh;
|
||||
|
||||
pub use commit_freeze::is_commit_command;
|
||||
pub use env_snapshot::EnvSnapshot;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user