Compare commits
622
Commits
main
...
21b906ffb0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21b906ffb0 | ||
|
|
68ba85389b | ||
|
|
0c5fa7b2ae | ||
|
|
9a3b3cff8b | ||
|
|
ae68bb0241 | ||
|
|
b4d55f412e | ||
|
|
d81635672e | ||
|
|
23b6e3fe1e | ||
|
|
d680390300 | ||
|
|
6deab2e836 | ||
|
|
811e463cea | ||
|
|
789e3af74e | ||
|
|
da1d23dd23 | ||
|
|
f01fd7db20 | ||
|
|
1107b2a717 | ||
|
|
74c9f2d8df | ||
|
|
a19d12b228 | ||
|
|
6ac214366e | ||
|
|
d27c1d4e33 | ||
|
|
b989f0bb0c | ||
|
|
2dae4693be | ||
|
|
a684328b76 | ||
|
|
987cefdf28 | ||
|
|
a4efbffe60 | ||
|
|
cc566354fa | ||
|
|
71438f666c | ||
|
|
1b4d0a552f | ||
|
|
5b8c7a3ef3 | ||
|
|
33ea7a830f | ||
|
|
ce9af42945 | ||
|
|
8e3311cea2 | ||
|
|
90f430216a | ||
|
|
f310173b4c | ||
|
|
6fe68ecfac | ||
|
|
74e171bc04 | ||
|
|
c68387a69a | ||
|
|
8325d1bb8c | ||
|
|
6d92536276 | ||
|
|
353293e807 | ||
|
|
bff57bd1ae | ||
|
|
7f63c5bec4 | ||
|
|
93c5a4c46f | ||
|
|
23dc9a3e5e | ||
|
|
77dbf9b1f7 | ||
|
|
2c56bc9704 | ||
|
|
bc1dee3f56 | ||
|
|
e5e5207c94 | ||
|
|
c46053e276 | ||
|
|
8051906afa | ||
|
|
378faf212e | ||
|
|
25aa50e532 | ||
|
|
c512609c63 | ||
|
|
aa0bf8b5a0 | ||
|
|
ef8fc66e54 | ||
|
|
f847769698 | ||
|
|
9126d653e5 | ||
|
|
c2e413ec38 | ||
|
|
20f82c9679 | ||
|
|
9fa7e65278 | ||
|
|
8e2441c183 | ||
|
|
93fc24cade | ||
|
|
454ddb0b70 | ||
|
|
57d0e8c543 | ||
|
|
e0a6bca501 | ||
|
|
4213b268fe | ||
|
|
215eedf8e9 | ||
|
|
a820d9968e | ||
|
|
2d211c2d16 | ||
|
|
2bf3b536dd | ||
|
|
b36798265a | ||
|
|
f1fbc249fd | ||
|
|
ea150ceea9 | ||
|
|
4a04ad43fe | ||
|
|
745365c7df | ||
|
|
ed5005307a | ||
|
|
301f8b4276 | ||
|
|
94da6bcd6d | ||
|
|
ea43f07bce | ||
|
|
9c9a6d26c9 | ||
|
|
ffe2518bf4 | ||
|
|
ddd3d00d09 | ||
|
|
a30f4a49ff | ||
|
|
5ee5f9126f | ||
|
|
1efe3a6a79 | ||
|
|
78e99d9f4c | ||
|
|
1420e8aaa6 | ||
|
|
4a937bd269 | ||
|
|
6475eb875d | ||
|
|
2e1c3ed3fd | ||
|
|
a150211468 | ||
|
|
9e63af9063 | ||
|
|
ede00d9928 | ||
|
|
120d926cf8 | ||
|
|
2d21aa553e | ||
|
|
c907a34510 | ||
|
|
99a644d545 | ||
|
|
7505c46b70 | ||
|
|
f75e7a9c9b | ||
|
|
29181245ce | ||
|
|
b7fb4dd1c7 | ||
|
|
180128c207 | ||
|
|
3d7af019d4 | ||
|
|
89434eadef | ||
|
|
c9144c1096 | ||
|
|
6e28b38b08 | ||
|
|
fe4c702f87 | ||
|
|
cb232e7360 | ||
|
|
6233ece1fa | ||
|
|
69a3b73777 | ||
|
|
236b202fda | ||
|
|
d6ed9cfa2b | ||
|
|
3d8c30dc99 | ||
|
|
8e6c77ad10 | ||
|
|
8e56163418 | ||
|
|
e792fe655b | ||
|
|
14e76b6480 | ||
|
|
f4adf0b526 | ||
|
|
a6cb44b8a8 | ||
|
|
84c124f5a9 | ||
|
|
1bf85a955b | ||
|
|
a3482ed06e | ||
|
|
c8053b1f36 | ||
|
|
a0abf0199a | ||
|
|
f9c5b56a2f | ||
|
|
213aff2e33 | ||
|
|
afe9afb1b5 | ||
|
|
7aaf7731dc | ||
|
|
321cc3b763 | ||
|
|
34cef95e17 | ||
|
|
a057a0f61f | ||
|
|
ba17820040 | ||
|
|
ec3f6e810a | ||
|
|
52dac4575f | ||
|
|
7ae4298871 | ||
|
|
c6f5c3fea0 | ||
|
|
f91a83ff81 | ||
|
|
4a2d276881 | ||
|
|
89715d1813 | ||
|
|
be6e5f5780 | ||
|
|
b82870f41e | ||
|
|
e1bd7c3009 | ||
|
|
8f6a75b7d7 | ||
|
|
ba1974038a | ||
|
|
20160c66d8 | ||
|
|
fa850f9599 | ||
|
|
a79fe9684e | ||
|
|
f44114301e | ||
|
|
5139945bfe | ||
|
|
85abb90067 | ||
|
|
509dc412eb | ||
|
|
9b903a89bc | ||
|
|
df8fc660c0 | ||
|
|
2b6750b821 | ||
|
|
eeb5f26668 | ||
|
|
e792baa316 | ||
|
|
91a3209c9b | ||
|
|
012e9f3ad4 | ||
|
|
e852e60af9 | ||
|
|
afd56ffd99 | ||
|
|
1e0f2c9ec8 | ||
|
|
b73a1276c6 | ||
|
|
0cbc9ff886 | ||
|
|
eaada3af5d | ||
|
|
6a545badbd | ||
|
|
5de708a560 | ||
|
|
d532eb3fcd | ||
|
|
b16ab2c5e8 | ||
|
|
42279b7124 | ||
|
|
c4ea02a887 | ||
|
|
8c437866e7 | ||
|
|
d804c4e675 | ||
|
|
3589f4b14f | ||
|
|
ef3dcb699b | ||
|
|
d64b26d499 | ||
|
|
354b08a45f | ||
|
|
c0dc435770 | ||
|
|
e5cc9c6054 | ||
|
|
2ba1baf6d0 | ||
|
|
879669cc59 | ||
|
|
89e13cf53d | ||
|
|
7d2ff74cfc | ||
|
|
a918819878 | ||
|
|
679cee04f7 | ||
|
|
f8f2e9c54c | ||
|
|
59a1ac4056 | ||
|
|
cb564fe981 | ||
|
|
6cb41a8794 | ||
|
|
15181e7063 | ||
|
|
fd13b49b24 | ||
|
|
fbbbb4ea64 | ||
|
|
be38651ba4 | ||
|
|
18381f65fd | ||
|
|
3be70f8232 | ||
|
|
46caa724f2 | ||
|
|
902b6eecfd | ||
|
|
c1fa1e9b9a | ||
|
|
367abf2c8e | ||
|
|
6066165394 | ||
|
|
3ba31b58ff | ||
|
|
79b1ec36aa | ||
|
|
c3339a5d07 | ||
|
|
f82c76df27 | ||
|
|
c4e09afd38 | ||
|
|
137b3e9eaf | ||
|
|
2a43035f5d | ||
|
|
51c1672355 | ||
|
|
d37d4a899b | ||
|
|
b1f9b11d6e | ||
|
|
b19d798a2d | ||
|
|
47e56ca13c | ||
|
|
3a01b4e236 | ||
|
|
72b95df22b | ||
|
|
5b2b385524 | ||
|
|
20c1edb36a | ||
|
|
a4789ec13e | ||
|
|
a8851dc0a2 | ||
|
|
834ef3683e | ||
|
|
042cd586af | ||
|
|
f2c12b1921 | ||
|
|
6b2b07cbef | ||
|
|
407c934a05 | ||
|
|
52f08dc343 | ||
|
|
5c2189efb0 | ||
|
|
2593765add | ||
|
|
127c32b440 | ||
|
|
910d154d35 | ||
|
|
1728addcfe | ||
|
|
94b882f4d9 | ||
|
|
b65fe05635 | ||
|
|
4adfc9a2df | ||
|
|
6c164f920b | ||
|
|
3dc1232bc2 | ||
|
|
f5cf117482 | ||
|
|
e64ea0b156 | ||
|
|
c96a7db7f1 | ||
|
|
9b01efd796 | ||
|
|
3d8822c922 | ||
|
|
8c6c796a29 | ||
|
|
7512d593c2 | ||
|
|
da3a386daf | ||
|
|
19f296a2ab | ||
|
|
5494c31474 | ||
|
|
decf59c1b4 | ||
|
|
3638ed7708 | ||
|
|
fbad02899a | ||
|
|
983bc49218 | ||
|
|
88d7dd0026 | ||
|
|
5c28ace70d | ||
|
|
20d452a51a | ||
|
|
5fb0cf5e19 | ||
|
|
852c327360 | ||
|
|
a398dc9b92 | ||
|
|
9253fef92a | ||
|
|
0e1f163ef9 | ||
|
|
74b12d87ca | ||
|
|
a1083a322f | ||
|
|
7b05b4bfa9 | ||
|
|
4ae714883d | ||
|
|
8d70f6d331 | ||
|
|
c1a98d7c01 | ||
|
|
b480b04dd0 | ||
|
|
f454273c5a | ||
|
|
093f38d986 | ||
|
|
61cf80d5ee | ||
|
|
eb1828ee7a | ||
|
|
fe90f64a56 | ||
|
|
7afef8d037 | ||
|
|
58cba0b8d2 | ||
|
|
1ac02bd77e | ||
|
|
8890c30bb5 | ||
|
|
71fefe5fc3 | ||
|
|
38b4527949 | ||
|
|
3972ea4007 | ||
|
|
fa33c79e11 | ||
|
|
63e5b70e3d | ||
|
|
b8fa53e43c | ||
|
|
b99268d019 | ||
|
|
0dfad675b8 | ||
|
|
63236c9b00 | ||
|
|
ed5bd92e1b | ||
|
|
09ee561aa7 | ||
|
|
95f97f5df3 | ||
|
|
09fba77f87 | ||
|
|
b0fbb8bc0d | ||
|
|
b9e042d07d | ||
|
|
adef36538f | ||
|
|
8c6466cb00 | ||
|
|
0ce498754a | ||
|
|
af8591b78a | ||
|
|
7e7f53bb41 | ||
|
|
6e7ec56f9a | ||
|
|
afef75f640 | ||
|
|
a35bcb8aa9 | ||
|
|
e3ae0129c9 | ||
|
|
5a9f161fe3 | ||
|
|
89434e4f99 | ||
|
|
6e0323b446 | ||
|
|
e9afca9b29 | ||
|
|
deb0a3cb97 | ||
|
|
ec292081b2 | ||
|
|
32634feefa | ||
|
|
4bf1f7cac6 | ||
|
|
074ffc4ebf | ||
|
|
91e8d7a029 | ||
|
|
355f81d04a | ||
|
|
fe1ff33115 | ||
|
|
770a83c3fb | ||
|
|
bc0047b449 | ||
|
|
28b663231e | ||
|
|
5847705e35 | ||
|
|
08b3a01752 | ||
|
|
05d34853f0 | ||
|
|
c7ecc3a249 | ||
|
|
f9430dee81 | ||
|
|
5969bba3bd | ||
|
|
3732c538cc | ||
|
|
2aa3233f84 | ||
|
|
6dc1f218d5 | ||
|
|
b74de9fd86 | ||
|
|
0de68bc960 | ||
|
|
0624d9ee36 | ||
|
|
66a96d2f93 | ||
|
|
38a403afca | ||
|
|
e4e18ce2a2 | ||
|
|
0519bb3c2e | ||
|
|
de1fa83b79 | ||
|
|
3f79159fbd | ||
|
|
2c100fe3d9 | ||
|
|
49d12d9a64 | ||
|
|
52ee8e53aa | ||
|
|
37fb3cd265 | ||
|
|
a51aadeef4 | ||
|
|
cdc370dc82 | ||
|
|
2092094d0a | ||
|
|
17c9fb7545 | ||
|
|
f42ea1e41c | ||
|
|
22e8398110 | ||
|
|
a828edacc5 | ||
|
|
e8eccd2bb2 | ||
|
|
04a8ee0727 | ||
|
|
61cc32c44b | ||
|
|
29aa60d0eb | ||
|
|
fbbfcc95ee | ||
|
|
31086ed1ab | ||
|
|
1ac126fc9e | ||
|
|
025dd1796b | ||
|
|
79589b5c9f | ||
|
|
2720d77331 | ||
|
|
4ea68d17f3 | ||
|
|
4c3c98d5d4 | ||
|
|
153a9d6211 | ||
|
|
34c8cc4dca | ||
|
|
1a85de555d | ||
|
|
610b330025 | ||
|
|
ca2faa0f95 | ||
|
|
f28995c1e5 | ||
|
|
2dee372fb2 | ||
|
|
844b2a74c1 | ||
|
|
c7b2cd8ae0 | ||
|
|
18b2040c95 | ||
|
|
70c9fba529 | ||
|
|
53002f1e11 | ||
|
|
f347681034 | ||
|
|
57d5d22d87 | ||
|
|
f43751fd07 | ||
|
|
026b27b310 | ||
|
|
7ecb43bd5d | ||
|
|
3fc10fb86f | ||
|
|
99f222761e | ||
|
|
00f784b55f | ||
|
|
d102f75469 | ||
|
|
89c5fe8027 | ||
|
|
747b9ad414 | ||
|
|
b686d5fec8 | ||
|
|
fe5dbcddba | ||
|
|
bcf7fe5de0 | ||
|
|
d09b25071d | ||
|
|
3e4efa0e41 | ||
|
|
12031bd13c | ||
|
|
03d7050e1e | ||
|
|
73dabb9dde | ||
|
|
d8044763ac | ||
|
|
9b3d1d4b44 | ||
|
|
f46692c329 | ||
|
|
cf25c6643d | ||
|
|
5a7ee9d772 | ||
|
|
0aa387f83c | ||
|
|
fb646552b9 | ||
|
|
5dfaa75a06 | ||
|
|
9e4e6e623f | ||
|
|
8d44bba14a | ||
|
|
851c8a31ce | ||
|
|
2baa2cc7ca | ||
|
|
c515de28bf | ||
|
|
4223f5a19a | ||
|
|
d970884ff8 | ||
|
|
f5950e4bde | ||
|
|
bef398d47a | ||
|
|
b6b258498c | ||
|
|
a9dfae4d5f | ||
|
|
36bd19e997 | ||
|
|
d3b0921491 | ||
|
|
3f2c981cf8 | ||
|
|
abb137cfe2 | ||
|
|
e037fab7d0 | ||
|
|
0a597cfa42 | ||
|
|
817f1e6355 | ||
|
|
06fd2f4aaf | ||
|
|
c4fe5a0cfd | ||
|
|
64efb569c3 | ||
|
|
9f35b2663e | ||
|
|
6a2b8b9f12 | ||
|
|
2283383b9e | ||
|
|
93aff2fea5 | ||
|
|
44e7a5c2c5 | ||
|
|
7a81071ec8 | ||
|
|
3386679d45 | ||
|
|
9e7b4ebaac | ||
|
|
0a645b7212 | ||
|
|
db8465f68b | ||
|
|
2c4ade5f28 | ||
|
|
c43b2f1d5d | ||
|
|
5ec4c9814d | ||
|
|
8eaf81bd0d | ||
|
|
2d7a377d74 | ||
|
|
5a0c1e2fd1 | ||
|
|
dcb0b33954 | ||
|
|
2e044d1d19 | ||
|
|
c1bd740f7c | ||
|
|
e9df928072 | ||
|
|
eaca8dc8cc | ||
|
|
0ed7984de3 | ||
|
|
b614633432 | ||
|
|
5398486fdf | ||
|
|
65091f6497 | ||
|
|
bdd9d752e1 | ||
|
|
a054a55ce9 | ||
|
|
4b05f219b9 | ||
|
|
6489910f64 | ||
|
|
aeb9fea64c | ||
|
|
1b0b5bf99c | ||
|
|
f5ae953ddc | ||
|
|
617157262b | ||
|
|
1ebc82fffd | ||
|
|
7e39153a69 | ||
|
|
aedae2190b | ||
|
|
2d7151f979 | ||
|
|
8cde0162e6 | ||
|
|
764d592859 | ||
|
|
eab6b63eb0 | ||
|
|
1a6ea45b9e | ||
|
|
9c2a37c4ac | ||
|
|
37e98ec584 | ||
|
|
1bf60c1ae4 | ||
|
|
bcc53d68f3 | ||
|
|
48421923f6 | ||
|
|
cc3eecacac | ||
|
|
c42aae7797 | ||
|
|
ba8ff835cd | ||
|
|
4ad1e465da | ||
|
|
7455d09d05 | ||
|
|
950bea9758 | ||
|
|
33eeadfc38 | ||
|
|
6dbb4a2e18 | ||
|
|
e0a543cc37 | ||
|
|
76134ed47e | ||
|
|
adad4e1f62 | ||
|
|
dbcf3df8a3 | ||
|
|
518bd0a230 | ||
|
|
648292d035 | ||
|
|
35cb2d3942 | ||
|
|
2248ddce15 | ||
|
|
b0bd93b15c | ||
|
|
464ed2cad3 | ||
|
|
24d5924cee | ||
|
|
54f0b75646 | ||
|
|
eddb9b9891 | ||
|
|
9b207a31e8 | ||
|
|
04bfa2c7a7 | ||
|
|
5dae2b9741 | ||
|
|
77f75b0d99 | ||
|
|
4aab0f501a | ||
|
|
265b207699 | ||
|
|
a482bc2ea7 | ||
|
|
20305631c5 | ||
|
|
13c7c52860 | ||
|
|
0c93bb4497 | ||
|
|
7c662511ba | ||
|
|
6ea5affc8a | ||
|
|
111ad598be | ||
|
|
74f8eada47 | ||
|
|
2d256264ec | ||
|
|
3ed7fbbc69 | ||
|
|
efc1b115c9 | ||
|
|
16ae72f24e | ||
|
|
cb6fac587b | ||
|
|
1d9c14300d | ||
|
|
cee2d26205 | ||
|
|
3632e04733 | ||
|
|
f9650185f1 | ||
|
|
3146b515f3 | ||
|
|
a7feaeb2cb | ||
|
|
f43eb17030 | ||
|
|
4c48e94659 | ||
|
|
b3ffd51736 | ||
|
|
df70313d6d | ||
|
|
6f97fded3a | ||
|
|
05e23ea590 | ||
|
|
ff34b62079 | ||
|
|
a3f6f4f687 | ||
|
|
7f7cb8fbf7 | ||
|
|
ebeecc7a32 | ||
|
|
430fc4666b | ||
|
|
88dc12466d | ||
|
|
7831b0ea1b | ||
|
|
f9f5a78a02 | ||
|
|
0b036645bb | ||
|
|
7adaf490c7 | ||
|
|
3ce9489f8e | ||
|
|
488cf9c79c | ||
|
|
08223c06a5 | ||
|
|
b3c61aa1ff | ||
|
|
915aa8a100 | ||
|
|
d947ec41a9 | ||
|
|
5b37f5a551 | ||
|
|
fb880572a4 | ||
|
|
ef79edd96a | ||
|
|
d90ec7bd4f | ||
|
|
20f4a91d02 | ||
|
|
8b723009f9 | ||
|
|
71697ca4f8 | ||
|
|
01a4e29f84 | ||
|
|
10c0069fa7 | ||
|
|
be67d74822 | ||
|
|
b798e129c1 | ||
|
|
3da189e856 | ||
|
|
aa697c385f | ||
|
|
285e1e9911 | ||
|
|
0a936a3202 | ||
|
|
02aae2bbb4 | ||
|
|
e33b005612 | ||
|
|
011d25a53d | ||
|
|
ecd3cdf28c | ||
|
|
95e058094a | ||
|
|
b9253a4980 | ||
|
|
c647d33a00 | ||
|
|
8317f370cd | ||
|
|
d09ccc7570 | ||
|
|
51b2bdfba2 | ||
|
|
70fd915242 | ||
|
|
653c2489bf | ||
|
|
7b8cfc664a | ||
|
|
c52ddfd9c7 | ||
|
|
a21176ec0a | ||
|
|
c730b05621 | ||
|
|
34cac241c1 | ||
|
|
dd16176bb7 | ||
|
|
c1fb20bd6b | ||
|
|
1defd46d14 | ||
|
|
1d4bed415f | ||
|
|
efed5f05b1 | ||
|
|
0236010136 | ||
|
|
62c7a2aa12 | ||
|
|
4506f639dd | ||
|
|
8b310feceb | ||
|
|
4be3826af7 | ||
|
|
9c8d5a9e8d | ||
|
|
ac0c2b2c73 | ||
|
|
2104fc0c6c | ||
|
|
a0357a7bbd | ||
|
|
b8fb7feb86 | ||
|
|
26d65dd6bb | ||
|
|
9d040fde0b | ||
|
|
d80710d85a | ||
|
|
9f30cdd086 | ||
|
|
8603839711 | ||
|
|
8495a4b055 | ||
|
|
8190007865 | ||
|
|
f8196c111c | ||
|
|
f44bf26050 | ||
|
|
cc040b861d | ||
|
|
7c02d31194 | ||
|
|
9a3c77dbd3 | ||
|
|
a58ec7325d | ||
|
|
628d416dcd | ||
|
|
dee48b6766 | ||
|
|
3f9c4ca3c3 | ||
|
|
585dfa1e38 | ||
|
|
14a8eecfdb | ||
|
|
dc3671eb8d | ||
|
|
6a4887e52a | ||
|
|
5b2bcc9441 | ||
|
|
4e2e81f684 | ||
|
|
47047c903c | ||
|
|
886fcf2216 | ||
|
|
ba42ea2b45 | ||
|
|
9eaed2883f | ||
|
|
217319eabc | ||
|
|
2a23ecdbdb | ||
|
|
5faa955bce | ||
|
|
78c5f0f1b0 | ||
|
|
e8b5679cf4 | ||
|
|
8420691438 | ||
|
|
1a80ac2a00 | ||
|
|
8bfda096f7 | ||
|
|
bea29d3b73 | ||
|
|
17ef73bcbe | ||
|
|
7e3ec8da93 | ||
|
|
fbb331ed4b | ||
|
|
6811480786 | ||
|
|
4a71965686 | ||
|
|
192144563b | ||
|
|
3b3e5a0697 | ||
|
|
99806fefad | ||
|
|
81ebdb910c | ||
|
|
7295556c59 | ||
|
|
aee8db9b3f | ||
|
|
ca106f71ae | ||
|
|
750d594055 | ||
|
|
7943e290ec | ||
|
|
b69b74435a | ||
|
|
14d5d6e555 |
No files matched your search
+551
@@ -0,0 +1,551 @@
|
||||
# ============================================
|
||||
# Cyrene v2 配置文件 (.env)
|
||||
# 复制为 .env 后修改。所有配置均从环境变量注入,代码内禁硬编码。
|
||||
# 注意:.env 仅用于本地开发;生产用 systemd / env_file。
|
||||
# 敏感信息(API Key / JWT_SECRET)一律不要写进日志。
|
||||
# ============================================
|
||||
|
||||
# ---- 运行环境 ----
|
||||
ENV=development
|
||||
LOG_LEVEL=debug
|
||||
TZ=Asia/Shanghai
|
||||
|
||||
# ---- 数据目录(裁决 12.4.2/15.4.1,design/13 §2) ----
|
||||
# 两种取法都实现:默认 ./data;显式写 platform(保留字哨兵)则走平台标准数据目录
|
||||
# (Windows %APPDATA%\Cyrene、macOS ~/Library/Application Support/Cyrene、Linux ~/.local/share/cyrene)。
|
||||
DATA_DIR=./data
|
||||
|
||||
# ---- 服务器(网关,唯一对外端口) ----
|
||||
# HOST 默认 0.0.0.0,暴露面由 .env 决定(本机自托管要收紧就改 127.0.0.1)。
|
||||
HOST=0.0.0.0
|
||||
PORT=8080
|
||||
# 管理面板的安全入口路径(裁决 1.6/16.4.7,design/07 §0.2):
|
||||
# 管理面板前端挂在这个路径下,默认 admin(即 /admin);想藏起来就自定义,例如
|
||||
# ADMIN_PATH=my-secret/admin → 面板在 /my-secret/admin,猜 /admin 直接 404。
|
||||
# 它只影响面板前端路由;REST 仍是 /api/v1/admin/*(API 不藏,安全靠 JWT + 角色门),
|
||||
# 且该路径串不会出现在用户端页面里。
|
||||
ADMIN_PATH=admin
|
||||
# chat.image 的【解码后图像字节】上限(协同文档 G3),默认 5242880(5 MiB,与 POST /api/v1/upload 同口径)。
|
||||
# 图片有「上传」与「base64 内联」两条入口,两边必须给出同一个「一张图能有多大」的答案,否则内联可绕开上传限制。
|
||||
# 调大请谨慎:整段 base64 会被落库,并随每页历史(GET /sessions/{id}/messages)与每次 WS history 回放重传一遍。
|
||||
# ≤0(写 0 或负数)= 用默认值 5 MiB,不是「禁用上限」——一张图能有多大只能有一个答案。
|
||||
CHAT_IMAGE_MAX_BYTES=5242880
|
||||
|
||||
# POST /api/v1/documents 的单文件字节上限,默认 20971520(20 MiB)。
|
||||
# 它与上面的图片上限【刻意解耦】:图片上限被 vision 解码层钳住,文档上限由本项目的解析与上下文预算决定;
|
||||
# 合用一个数会让「调文档上限」静默改掉图片行为。
|
||||
# 文档读侧(GET /api/v1/documents/{name})要 Bearer owner/admin,且一律 attachment + nosniff(永不可内联渲染)。
|
||||
# ≤0(写 0 或负数)= 用默认值 20 MiB,不是「禁用上限」。
|
||||
CHAT_DOCUMENT_MAX_BYTES=20971520
|
||||
|
||||
# ---- 文档回收(GC:引用计数 + 保留期,design/24 §3.2(配额与清理);切片 S7) ----
|
||||
# 判据:**未被任何消息引用**(ref_count = 0)且最后一次被引用(从未被引用则看上传时间)
|
||||
# 早于「现在 − 保留期」。动作:原件(<DATA_DIR>/uploads/documents/<存储名>)+ 抽取正文
|
||||
# sidecar(<DATA_DIR>/extracted/<存储名>.txt)+ documents 表里的元数据行**三样一起删**。
|
||||
# 引用计数由消息落库/编辑/删除时维护(都在同一个事务里),因此**仍被引用的文件绝不删**。
|
||||
# 启动时清扫一次 + 此后按间隔定时清扫;每轮只记一条汇总(删了几件/释放多少字节,不含正文)。
|
||||
# DOCUMENT_GC_INTERVAL:定时清扫间隔(Go duration,如 30m / 1h / 6h),默认 1h。
|
||||
# DOCUMENT_RETENTION:保留期(Go duration),默认 168h(7 天)。
|
||||
# ⚠️ 任一键写 0 或负数 = **关闭 GC**(同时关闭启动期那一次清扫,日志会写明原因)。
|
||||
# `DOCUMENT_RETENTION=0` 不是「立即回收」——那种解释会让一次笔误清空未引用的附件。
|
||||
DOCUMENT_GC_INTERVAL=1h
|
||||
DOCUMENT_RETENTION=168h
|
||||
|
||||
# ---- 渠道事件留存(非消息事件 / 请求 / 未知帧;design/26 §4.4;切片 E3+E4) ----
|
||||
# 目的:让**到达过适配器的每一帧**至少留下可查(逐条落行)、可计数(按平台+类型聚合)的
|
||||
# 脱敏结构痕迹 —— 事故排查时「上游推过没有 / 是不是被我们过滤了」必须答得上来。
|
||||
# 落库口径:T2/T3 事件与「未知类型 / 解析失败」逐条落 channel_events(30 天保留期),
|
||||
# 纯遥测(心跳 / 生命周期 / 自身回声)只进 channel_event_counters(不参与 GC)。
|
||||
# 摘要一律走 adapter.SanitizeNoticeText;**消息正文 / 媒体 URL / OneBot request.flag
|
||||
# 等凭据绝不入库、绝不进日志**。
|
||||
# CHANNEL_EVENT_RETENTION:channel_events 行的保留期(Go duration),默认 720h(30 天)。
|
||||
# 它比文档保留期(168h)长:事件表的核心用途是**事后定案**,一周太短。
|
||||
# CHANNEL_EVENT_GC_INTERVAL:保留期清扫间隔(Go duration),默认 1h。
|
||||
# ⚠️ 上面任一键写 0 或负数 = **关闭清扫**(同时关闭启动期那一次,日志会写明原因);
|
||||
# `CHANNEL_EVENT_RETENTION=0` 不是「立即清空」——那会让一次笔误抹掉唯一的取证面。
|
||||
# CHANNEL_EVENT_COUNTER_FLUSH:内存计数落盘间隔(Go duration),默认 30s。
|
||||
# 计数是**近似值**(crash 会丢 ≤ 一个间隔的计数,面板须如实标注 approx)。
|
||||
# 该键不做「显式关闭」:写 0 或负数一律回落 30s(计数落盘不是破坏性动作)。
|
||||
CHANNEL_EVENT_RETENTION=720h
|
||||
CHANNEL_EVENT_GC_INTERVAL=1h
|
||||
CHANNEL_EVENT_COUNTER_FLUSH=30s
|
||||
|
||||
# ---- 链路图本地落盘(flow_graphs 表;切片 F2,design/19 §4 P3) ----
|
||||
# 每个会话**每轮收尾落一条**链路图(web / web:<uuid> / sess-backthink 三个白名单会话;
|
||||
# 隔离的群聊 / 陌生人会话绝不入库)。让「刷新 / 重启后翻历史轮」有本地数据,
|
||||
# 而不是图只活在进程内存里。
|
||||
# FLOW_GRAPH_RETENTION:flow_graphs 行的保留期(Go duration),默认 168h(7 天)。
|
||||
# 图的用途是「回看最近若干轮」,不是事后定案证据(后者是 channel_events 的 720h)。
|
||||
# FLOW_GRAPH_GC_INTERVAL:保留期清扫间隔(Go duration),默认 1h。
|
||||
# ⚠️ 任一键写 0 或负数 = **关闭清扫**(同时关闭启动期那一次,日志会写明原因)⇒
|
||||
# 落盘的轮次图将**无限增长**;`FLOW_GRAPH_RETENTION=0` 不是「立即清空」。
|
||||
FLOW_GRAPH_RETENTION=168h
|
||||
FLOW_GRAPH_GC_INTERVAL=1h
|
||||
|
||||
# ---- LLM 用量样本保留期(llm_usage 表;切片 U1,主人裁决「2.4 落」) ----
|
||||
# 每次成功模型调用都会把一条**非敏感**用量样本(token 计数 / 缓存两档 / 模型名 /
|
||||
# 用途 / 耗时)异步落进 llm_usage 表,供 GET /api/v1/admin/llm/usage 与面板的
|
||||
# 用量小卡聚合。**凭据与消息正文绝不入库、绝不进日志**。
|
||||
# 写入是有界队列 + 单写者 + 批量事务:队列满就丢弃并限流 WARN,**绝不阻塞模型调用**。
|
||||
# LLM_USAGE_RETENTION:保留期(Go duration),默认 720h(30 天)。
|
||||
# 启动时清理一次 + 此后每小时清理一次(清扫间隔无 env 键:保留期才是旋钮)。
|
||||
# ⚠️ 写 0 或负数 = **关闭清理**(样本全留,日志会写明「llm usage retention disabled」)。
|
||||
# `LLM_USAGE_RETENTION=0` 不是「立即清空」——那种解释会让一次笔误把成本视图清空。
|
||||
LLM_USAGE_RETENTION=720h
|
||||
|
||||
# ---- 认证 / 管理员 ----
|
||||
# JWT_SECRET 必填,长度 >= 32;缺失或过短会在启动时 panic。
|
||||
JWT_SECRET=change-me-to-a-random-string-at-least-32-characters
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin12345
|
||||
REGISTRATION_ENABLED=true
|
||||
# 不透明 refresh token 有效期(Go duration,如 720h = 30 天);access token 固定 24h。
|
||||
AUTH_REFRESH_TTL=720h
|
||||
# refresh token 单次使用轮换默认开启(用旧换新、防重放);仅当客户端无法保存轮换后的
|
||||
# 新 token 时才设为 true 关闭轮换。
|
||||
AUTH_REFRESH_ROTATE_DISABLED=false
|
||||
|
||||
# ---- 数据库 ----
|
||||
# SQLite 为默认(单二进制、无需外部服务)。
|
||||
DB_SQLITE_PATH=./data/cyrene.db
|
||||
# PostgreSQL + pgvector 为可选增强;未配置时走 SQLite。相关向量能力在 M4 接入。
|
||||
# DB_PG_URL=postgres://cyrene:cyrene_pass@localhost:5432/cyrene_ai?sslmode=disable
|
||||
|
||||
# ---- LLM 多供应商(LLM_PROVIDER 显式声明协议:openai / anthropic,默认 openai) ----
|
||||
# ⚠️ 本节是【兜底】:管理面板「LLM 模型」页保存会写入本地 models.json(默认 <DATA_DIR>/models.json,已 gitignore),
|
||||
# 只要 models.json 存在且有效,运行中的 LLM 就优先用它的多厂商多模型组,并支持面板实时保存→热更新(无需重启)。
|
||||
# 仅当 models.json 不存在/无效时才回退到下面的 .env。
|
||||
# Provider = openai:OpenAI 兼容 /chat/completions(含 OpenAI 兼容网关,也可经它承载 Anthropic 模型,但失去原生 tool input 对象优势)
|
||||
LLM_PROVIDER=openai
|
||||
|
||||
LLM_BASE_URL=https://api.openai.com/v1
|
||||
LLM_API_KEY=sk-xxxxx
|
||||
LLM_MODEL=gpt-4o
|
||||
LLM_FALLBACK_MODEL=gpt-4o-mini
|
||||
# 最后一道回退模型(fallback 链末端)的 token 预算上限。仅当链确有回退模型、
|
||||
# 且该模型组未在 models.json 设 maxTokens、请求也未显式给 MaxTokens 时生效;
|
||||
# 0 = 关闭该兜底(改用适配器默认,如 anthropic 8192)。每模型组预算见 models.json 的 maxTokens。
|
||||
LLM_FALLBACK_MAX_TOKENS=8192
|
||||
# 推理开关的【提供商形状】声明(裁决 03.4.2 / 08.4.1):auto(默认,= 留空)/ deepseek /
|
||||
# openai / vllm / dashscope。形状决定把思考开关下发改成哪个字段(如 vllm =
|
||||
# chat_template_kwargs.enable_thinking、dashscope = 顶层 enable_thinking),必须按端点真实形状
|
||||
# 声明——发错形状会被上游拒。留空/auto = 按模型名与 base URL 推断厂商,形状有歧义时一个字段都不发。
|
||||
# ⚠️ 本键只作用于 .env 合成的模型组:存在有效 models.json 时,以该组的 reasoning.kind 为准
|
||||
# (面板可编辑,见「LLM 模型」页)。
|
||||
LLM_REASONING_KIND=
|
||||
|
||||
# ---- LLM 视觉模型(视觉/OCR 工具用的模型;与「主模型能否收图」无关) ----
|
||||
# 语义(v0.53 修正):主模型自己能不能收图,看 models.json 生效组的 multimodal 字段(面板可勾选),
|
||||
# 不是看这里;本节的模型【只供 vision / ocr 工具】调用。都不配置时工具回落用主模型(LLM_MODEL)。
|
||||
# .env 侧的等价键是下面的 LLM_MULTIMODAL——它同样只作用于 .env 合成的模型组。
|
||||
# LLM_VISION_MODEL=gpt-4o-mini
|
||||
# OCR 工具单独用另一个模型(可选):留空 = 复用 LLM_VISION_MODEL。
|
||||
# LLM_OCR_MODEL=qwen-vl-ocr
|
||||
|
||||
# 声明【主模型自身】能否直接接收图片(= models.json 组 multimodal 的 .env 等价物):
|
||||
# true = 图片直接进主模型上下文(省掉工具那一轮模型调用、不做有损文本摘要);
|
||||
# 留空 / false(默认)= 图片交给 vision / ocr 工具,由工具返回文本描述。
|
||||
# 默认 false 与引入本键之前【逐字节一致】——扁平 env 块只有这一个键能声明收图能力,
|
||||
# 而声称端点可能不具备的能力,比把图交给视觉/OCR 工具更糟(design/06 §0 首条);
|
||||
# 绝不凭 base URL / 模型名猜测端点是否收图。
|
||||
# ⚠️ 只作用于 .env 合成的模型组:存在有效 models.json 时以该组的 multimodal 为准(面板可勾选)。
|
||||
# ⚠️ 非法值(非 bool,如 maybe)= 启动即报错,不静默回退。
|
||||
# LLM_MULTIMODAL=false
|
||||
|
||||
# 声明该端点的【模型上下文窗口】(token)。窗口是上下文压缩预算的推导依据:
|
||||
# 未设置 CONTEXT_MAX_TOKENS 时 budget = min(窗口 × CONTEXT_BUDGET_RATIO, 窗口 − 输出保留)。
|
||||
# ⚠️ 本仓没有任何 provider 侧的窗口发现代码(GET /models 的自动发现另立设计),窗口只能声明:
|
||||
# 不声明就回落 131072(128 Ki)并记一条 WARN,默认模型的 1M 窗口因此吃不到。
|
||||
# 它必须与 models.json 组的 contextWindow 一致(那一处是面板一等来源,本键只给 .env 合成的组播种)。
|
||||
# 负数 = 启动即报错。
|
||||
# LLM_CONTEXT_WINDOW=1048576
|
||||
|
||||
# Provider = anthropic:Anthropic Messages /v1/messages(tool_use.input 为结构化对象,工具循环更稳)
|
||||
# ANTHROPIC_BASE_URL=https://api.anthropic.com
|
||||
# ANTHROPIC_API_KEY=sk-ant-xxxxx
|
||||
# ANTHROPIC_MODEL=claude-sonnet-4-20250514
|
||||
# ANTHROPIC_FALLBACK_MODEL=claude-haiku-3-5
|
||||
# ⚠️ 若 ANTHROPIC_BASE_URL 指向 DeepSeek(https://api.deepseek.com/anthropic),模型名须用 DeepSeek 当前名称
|
||||
# (deepseek-flash / deepseek-v4-pro):不认识的名称(如 claude-*)会被服务端【静默映射成 deepseek-flash】,
|
||||
# 配置错了不会报错。旧的 deepseek-chat / deepseek-reasoner 已于 2026-07-24 停用。
|
||||
|
||||
# ---- LLM 本地第三级(降级链末端;默认关,裁决 03.4.3) ----
|
||||
# 降级链为「主 → 备 → 本地」;本地这一级默认关闭(当前设备跑本地模型困难),链保持两级。
|
||||
# 开启需要三个条件同时满足:LLM_LOCAL_ENABLED=true + LLM_LOCAL_BASE_URL + LLM_LOCAL_MODEL;
|
||||
# 只开开关不配端点/模型会被记 WARN 并跳过(不会半配成一个只会失败的链尾步)。
|
||||
LLM_LOCAL_ENABLED=false
|
||||
# 本地端点协议:openai(llama.cpp / ollama / vLLM 兼容)或 anthropic;留空 = openai。
|
||||
# LLM_LOCAL_PROVIDER=openai
|
||||
# LLM_LOCAL_BASE_URL=http://127.0.0.1:8080/v1
|
||||
# 本地服务通常不需要 key;留空即可。
|
||||
# LLM_LOCAL_API_KEY=
|
||||
# LLM_LOCAL_MODEL=qwen3-4b-instruct
|
||||
|
||||
# ---- 记忆嵌入(可选,真语义) ----
|
||||
# 不配置时使用内置 n-gram / FTS 向量(dim=256)。配置后必须真实走语义路径。
|
||||
# EMBEDDING_API_URL 是【完整 /embeddings 端点】(不是 base)——例:Ollama / llama.cpp = http://127.0.0.1:11434/v1/embeddings
|
||||
# ⚠️ 回环地址请写具体的 IPv4 字面量 127.0.0.1,**不要写 localhost**:本机 embedding 服务
|
||||
# (llama.cpp / ollama)默认只绑 IPv4 回环,而 localhost 会先解析到 IPv6 ::1 —— 那里没有
|
||||
# 监听,轻则多一次无效拨号(实测 localhost 约 0.2s,127.0.0.1 约 0.001s),重则在「::1 被
|
||||
# 静默丢弃」的环境里等到系统连接超时,白白吃掉整条入库审查的预算。
|
||||
# (客户端已对 localhost / 0.0.0.0 / 空主机做兜底规范化,但直接写对更省事、更好排查。)
|
||||
# EMBEDDING_API_URL=http://127.0.0.1:11434/v1/embeddings
|
||||
# EMBEDDING_API_KEY=
|
||||
# EMBEDDING_MODEL=Qwen3-Embedding-0.6B # 推荐(Q8_0 ~639MB、dim=1024、ModelScope 国内可下)
|
||||
|
||||
# ---- IoT 设备提供者 ----
|
||||
# mock = 模拟智能家居设备(开发测试用);none = 禁用 IoT。
|
||||
IOT_PROVIDER=mock
|
||||
|
||||
# ---- 语音服务 ----
|
||||
TTS_PROVIDER=edge-tts
|
||||
STT_PROVIDER=dashscope
|
||||
# DASHSCOPE_API_KEY=sk-xxxxx # ⚠️ 必填才能接通阿里百炼语音;未填时走降级链 (silent)。绝不打日志。
|
||||
# --- 阿里百炼 DashScope 语音(STT + TTS 主链路)---
|
||||
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||
# VOICE_STT_MODEL=qwen-audio-3.0-asr-flash
|
||||
# VOICE_STT_LANGUAGE=zh
|
||||
# IM 入站语音转写的超时(秒,默认 20):桥接器会为这次调用举着一个入站并发槽,
|
||||
# 因此上游卡住不能拖死整条流水线。≤0 或未配置时用桥接器默认值 20s。
|
||||
# 只影响 IM 渠道的入站语音;Web 客户端的语音输入不受它约束。
|
||||
# VOICE_STT_TIMEOUT_SEC=20
|
||||
# 已实测:cosyvoice-v3.5-plus / cosyvoice-v2 走 HTTP SpeechSynthesizer 会回 418(引擎拒绝);
|
||||
# 可用组合为 cosyvoice-v3-flash + 对应 v3 音色(longxiaochun_v3 龙小淳·女助手等)。
|
||||
# 各模型音色不可混用(v2 名如 longxiaochun 用于 v3 模型会 418)。
|
||||
# VOICE_TTS_MODEL=cosyvoice-v3-flash
|
||||
# VOICE_TTS_VOICE_ID=longxiaochun_v3
|
||||
# VOICE_TTS_FORMAT=wav
|
||||
# VOICE_TTS_SAMPLE_RATE=24000
|
||||
# 情绪/语气指令(TTS instruction):只对「复刻/设计音色」生效(可任意指令,如
|
||||
# "语气开朗俏皮,像女朋友撒娇"),系统音色 longxiaochun_v3 必须用固定格式故不传。
|
||||
# 长度≤100 字符(汉字记 2)。也可在 POST /api/v1/audio/speech 用 instruction 按请求覆盖。
|
||||
# VOICE_TTS_INSTRUCTION=语气开朗俏皮,像女朋友撒娇
|
||||
# 声音复刻前缀(create_voice 的 prefix,≤10 字母/数字,空则用 cyrene)。target_model
|
||||
# 固定为 VOICE_TTS_MODEL(须与合成模型一致)。
|
||||
# VOICE_CLONE_PREFIX=cyrene
|
||||
# 声音复刻(响昔涟音色):先用参考音频(公网 URL)调 POST /api/v1/audio/voice-clone
|
||||
# 建自定义音色,再把返回的 voice_id 填到 VOICE_TTS_VOICE_ID。
|
||||
# ⚠️ 建音色必须公网可访问的参考音频 URL(create_voice 只收 input.url,不收 base64);
|
||||
# 待主人提供参考音频公网 URL 方能真建音色。
|
||||
|
||||
# ---- 网络搜索(web-search 工具) ----
|
||||
# WEB_SEARCH_PROVIDER = 选谁当搜索后端。可选:searxng(默认,本机兜底)/ zhipu(智谱
|
||||
# BigModel)/ aliyun_iqs(阿里云 IQS)/ bocha(博查)/ volcengine(火山·豆包搜索)/
|
||||
# baidu_qianfan(百度千帆)。**未配置的家不会出现在可用清单里**(fail-closed)。
|
||||
# ⚠️ 指向未登记(如本期未做的 tavily)或未配置(缺 key)的名字 ⇒ **启动期报错**,
|
||||
# 绝不静默回落到兜底;写空串 / off / none ⇒ **web-search 不注册**,模型看不见它。
|
||||
WEB_SEARCH_PROVIDER=searxng
|
||||
# WEB_SEARCH_FALLBACK = 降级链(逗号分隔,从左到右试;空 = 只用手选的那一个)。
|
||||
# 只在该家「不可用」(无 key / 鉴权失败 / 限流 / 额度 / 网络)时前进一格;结果里会
|
||||
# 如实标注**实际应答**的 provider,绝不拿兜底冒充主力。空结果**不**触发前进。
|
||||
# WEB_SEARCH_FALLBACK=zhipu,bocha,searxng
|
||||
# WEB_SEARCH_MAX_RESULTS = 单次检索返回条数上限(**播种层**的键:面板文件一旦落盘,
|
||||
# 就以 $DATA_DIR/config/plugins/web-search.json 的 maxResults 为准,改面板即生效、不用重启)。
|
||||
# 默认 10;provider 自身的条数上限(如博查 50、IQS 固定 10)优先于本键的下限。
|
||||
WEB_SEARCH_MAX_RESULTS=10
|
||||
# WEB_SEARCH_ENDPOINT = **searxng provider** 的端点(SearXNG 兼容,GET + format=json)。
|
||||
# 默认值 http://127.0.0.1:38471 是**本机兜底**:scripts/searxng/settings.yml 就是它的
|
||||
# 配套配置(docker 起法见该文件头注释),无需任何第三方 API key、开箱即用 —— 默认指向
|
||||
# 本机而不是某个云搜索服务,原因就在这。它只在 WEB_SEARCH_PROVIDER=searxng 时起作用。
|
||||
WEB_SEARCH_ENDPOINT=http://127.0.0.1:38471
|
||||
# 显式关闭:把上面这行写成空串 / off / none(大小写不敏感)⇒ **web-search 不注册**,
|
||||
# 模型根本看不见它(不给一把一调就报错的锤子)。值不是 http(s) 绝对地址时同样不注册,
|
||||
# 启动日志会有一条带键名的 WARN 说明原因(绝不静默半开)。
|
||||
# WEB_SEARCH_ENDPOINT=off
|
||||
# WEB_SEARCH_TIMEOUT = 单次搜索请求的超时(Go duration,如 20s)。
|
||||
# 不配置时**沿用 io 包既有超时(15s)** —— 这里刻意不写第二个默认值。
|
||||
# 它只影响 web-search 一次调用,不改 http / web-fetch 用的那个共享客户端。
|
||||
# ⚠️ 降级链下它是**每一次上游尝试**的预算(主家超时后备家仍有自己的份额)。
|
||||
# WEB_SEARCH_TIMEOUT=20s
|
||||
#
|
||||
# ---- 各搜索后端独立一组键(未配置的家不出现;密钥绝不进日志/响应/提示词)----
|
||||
# 智谱 BigModel(Bearer;官方定价已确证:Search-Std 0.01 元/次起)。
|
||||
# 申请 key:https://open.bigmodel.cn
|
||||
# WEB_SEARCH_ZHIPU_API_KEY=<your-zhipu-api-key>
|
||||
WEB_SEARCH_ZHIPU_BASE_URL=https://open.bigmodel.cn
|
||||
# 阿里云信息查询服务 IQS(GET + X-API-Key,免签名免 SDK;默认返回 10 条且不可调)。
|
||||
# 申请 key:https://www.aliyun.com/product/iqs
|
||||
# WEB_SEARCH_ALIYUN_IQS_API_KEY=<your-aliyun-iqs-api-key>
|
||||
WEB_SEARCH_ALIYUN_IQS_BASE_URL=https://cloud-iqs.aliyuncs.com
|
||||
# 博查 Bocha(POST + Bearer;中文优先)。默认域名取官方文档口径 api.bocha.cn
|
||||
# (官网首页样例仍用 api.bochaai.com,两域关系官方未说明,可在此键切换)。
|
||||
# 申请 key:https://open.bocha.cn
|
||||
# WEB_SEARCH_BOCHA_API_KEY=<your-bocha-api-key>
|
||||
WEB_SEARCH_BOCHA_BASE_URL=https://api.bocha.cn
|
||||
# 火山引擎·豆包搜索 SearchInfinity(Bearer;含每月 500 次免费额度,官方原文确证)。
|
||||
# 申请 key:https://console.volcengine.com/search-infinity/api-key
|
||||
# WEB_SEARCH_VOLCENGINE_API_KEY=<your-volcengine-api-key>
|
||||
WEB_SEARCH_VOLCENGINE_BASE_URL=https://open.feedcoopapi.com
|
||||
# 百度·千帆 AI 搜索(Bearer;标准版 0.036 元/次,每月免费 1500 次,官方原文确证)。
|
||||
# 申请 key:https://console.bce.baidu.com/qianfan/
|
||||
# WEB_SEARCH_BAIDU_QIANFAN_API_KEY=<your-baidu-qianfan-api-key>
|
||||
WEB_SEARCH_BAIDU_QIANFAN_BASE_URL=https://qianfan.baidubce.com
|
||||
|
||||
|
||||
# ---- 后台思考 ----
|
||||
# ⚠️ 未接线(全仓无消费者),配了不生效;是否接线待主人拍板
|
||||
# (DevDocs/audit/07-backthink-automation-iot.md §1-17)。
|
||||
ENABLE_BACKGROUND_THINKING=true
|
||||
# 自主思考心跳周期(秒)。默认 900(15 分钟一次);数值越小越频繁打扰。
|
||||
THINK_INTERVAL_SEC=900
|
||||
# 静默时段内的心跳周期(秒)。默认 3600(1 小时):当前时刻落在安静时段
|
||||
# (QUIET_START_HOUR–QUIET_END_HOUR)内时,周期性心跳改用该间隔降频,窗外仍用
|
||||
# THINK_INTERVAL_SEC;≤0 = 关闭降频(回落普通间隔,不是「半夜不思考」)。
|
||||
# ⚠️ 只作用于周期性心跳:事件驱动(安全域/急性事件)照旧立刻触发、穿透安静时段。
|
||||
THINK_QUIET_INTERVAL_SEC=3600
|
||||
THINK_SILENCE_TIMEOUT_SEC=120
|
||||
# 主动推送频率(design/12 §6):窗口内最多 PushLimit 次;推后冷却 Cooldown 秒;
|
||||
# 同一风味(问候/提醒/分享/关心)在 KindWindow 秒内最多一次;主人最近消息在
|
||||
# RecentChatCooldown 秒内则冷避不推。收敛默认值:1 次 / 60 分钟 + 推后 30 分钟冷却。
|
||||
THINK_PUSH_LIMIT=1
|
||||
THINK_PUSH_WINDOW_SEC=3600
|
||||
THINK_PUSH_COOLDOWN_SEC=1800
|
||||
THINK_RECENT_CHAT_COOLDOWN_SEC=600
|
||||
THINK_KIND_WINDOW_SEC=7200
|
||||
# 安静时段(本地时钟小时,默认 23:00-07:00;两端相等且非 0 则关闭)。
|
||||
# 同时用于 backthink 后台思考与 channelbridge 公共平台安静时段轻思考(design/12 §6 策略五)。
|
||||
QUIET_START_HOUR=23
|
||||
QUIET_END_HOUR=7
|
||||
|
||||
# ---- 记忆整理与去重(design/23;主题 N · 长期记忆维护) ----
|
||||
# **低峰定期后台任务**(不是会话内派 subagent):逐分区扫描 → 相似度**召回**候选对 →
|
||||
# 「事实集合包含关系」判定 → 规则否决 → 落 `memory_tidy_candidates` 建议队列。
|
||||
# 第一版**只产建议**:绝不自动合并/删除/改写任何一条记忆,也**不推送**、不进 chat、
|
||||
# 不碰主会话上下文(跑在专属隔离会话 sess-memorytidy,与 backthink 同一范式)。
|
||||
# 每轮汇总量(扫描/候选/否决原因分布)进日志与 `memory_tidy_runs` 表,绝不静默。
|
||||
# 关闭开关:写 false 即完全不跑(日志会写明)。
|
||||
MEMORY_TIDY_ENABLED=true
|
||||
# 整理周期(秒),默认 86400 = 每天 1 次。它是**下限**:实际在低峰窗口起点跑,
|
||||
# 因此真实间隔取「低峰窗口每天一次」与本值中较长者。
|
||||
MEMORY_TIDY_INTERVAL_SEC=86400
|
||||
# 低峰窗口(本地钟点),默认与 backthink 一致的 23:00-07:00(窗口可跨午夜;
|
||||
# 两端相等且非 0 = 关闭窗口,改为按上面周期跑)。整理只在窗口内开跑。
|
||||
MEMORY_TIDY_QUIET_START_HOUR=23
|
||||
MEMORY_TIDY_QUIET_END_HOUR=7
|
||||
# 候选召回的余弦**召回门**,默认 0.80。⚠️ 它只决定「哪些对值得看一眼」,
|
||||
# **不是**合并判据 —— 能否合并由「事实集合包含关系(A ⊆ B)+ 规则否决」决定
|
||||
# (实据:必合对 0.8330 与必不合对 0.7100 只差 0.12,单阈值双向失效)。
|
||||
MEMORY_TIDY_RECALL_THRESHOLD=0.80
|
||||
# 每分区每轮的扫描上限(默认 5000)。
|
||||
MEMORY_TIDY_SCAN_LIMIT=5000
|
||||
# 每分区每轮的**候选**上限,默认 200。⚠️ 它是**每分区**的量,不是全库总量。
|
||||
# 2026-10-06 由 20 抬到 200:首轮真跑时四个分区同时打满旧值,候选数(77)由上限
|
||||
# 而不是规则决定;命中上限时响应里的 `truncated` 会带 `<分区>(candidates)`。
|
||||
MEMORY_TIDY_CANDIDATE_LIMIT=200
|
||||
# ⚠️ 2026-10-06 起(主人红线):**`.env` 冻结为「只保证最小化功能可跑」的引导面** ——
|
||||
# **新旋钮一律不再进 `.env`**(本块此前收过的「两两比对上限」与「入库前审查」四键**已撤出**:
|
||||
# 它们有代码默认值 2000 / true / 256 / 200 / 20,真正入口是 `$DATA_DIR/config/**` + 面板,
|
||||
# 迁移切片已排队)。规矩全文见 `design/13 §2.1`;`AGENTS §10` 的 E6 已按此改写。
|
||||
|
||||
# ---- 唤醒式能力渐进(capacity 意图判定,design/14 §2.2) ----
|
||||
# 总开关(默认 false):关闭时 loop 向模型暴露【全部】已注册工具,行为与接线前逐字节一致;
|
||||
# 置 true 才按判定出的状态裁剪可用工具集(判定只在 loop 外围,不阻塞 Invoke)。
|
||||
CAPACITY_ENABLED=false
|
||||
# 唤醒阈值(默认 0.6):判定置信度低于它的状态强制回落到角色默认态(保守、不打扰);
|
||||
# 角色 YAML 的 awakening.minConfidence 为正数时覆盖本全局值。
|
||||
# 写 0 = 用代码内默认 0.6,不是「阈值归零」。
|
||||
CAPACITY_MIN_CONFIDENCE=0.6
|
||||
# 状态 → 允许工具名的 JSON 映射,例如
|
||||
# {"quiet":["terminal","file"],"awake":["terminal","file","web-search"],"full":null}
|
||||
# 缺失的状态用内置默认集(quiet = terminal/file/knowledge/text/transcode/capability_list;
|
||||
# awake = quiet + web-search/web-fetch/http;full 不限制);null(如 "full")= 不限制。
|
||||
# 解析失败记 WARN 并回落到内置默认。
|
||||
# ⚠️ 自己写映射时,请把你要用的新能力一并列进去:不在允许集里的工具在
|
||||
# CAPACITY_ENABLED=true 时会从模型可见工具面里消失(Invoke 仍放行,见 design/14 §4)。
|
||||
# CAPACITY_TOOL_SETS=
|
||||
# L3 = 模型辅助的意图分类:仅当 L1 启发式与 L2 上下文仍拿不准时介入一次轻量模型调用。
|
||||
# 默认关闭(纯启发式够日常,且该层要多花一次模型调用);关闭时该层从不运行,
|
||||
# L1/L2/L4 的行为与接线前逐字节一致。
|
||||
CAPACITY_L3_ENABLED=false
|
||||
# L3 判定结果的缓存 TTL(Go duration,如 5m):同一段回合文本在此窗口内复用判定,避免每轮都问模型;
|
||||
# 留空 = 5m;0 或解析不出 = 不缓存。
|
||||
CAPACITY_L3_CACHE_TTL=5m
|
||||
|
||||
# ---- 子代理(orchestration,design/05 §7) ----
|
||||
# 单个子代理整次运行的时限(秒)。默认 600 = 10 分钟(裁决 02.4.4:agent_timeout
|
||||
# 可配置);模型派发时可用 subagent_spawn 的 timeout_seconds 参数按调用覆盖。
|
||||
SUBAGENT_TIMEOUT_SEC=600
|
||||
|
||||
# ---- 护栏与上下文预算(design/04 §1.3 / §5、附录 A.3) ----
|
||||
# 上下文溢出压缩预算(token,离线估算值)。会话估算上下文超过它时,更早的轮次被摘要成
|
||||
# 一条前导摘要消息,近期尾部(含尾部工具结果)原样保留。三种取值:
|
||||
# · 未设置(默认,即把本行注释掉)= 按模型窗口推导:
|
||||
# budget = min(窗口 × CONTEXT_BUDGET_RATIO, 窗口 − 输出保留)
|
||||
# 输出保留 = max(32768, 本组 maxTokens);窗口未声明 ⇒ 回落 131072 并记一条 WARN。
|
||||
# · > 0 = 显式覆盖(原样使用)
|
||||
# · 0 / 负数 = 关闭压缩(行为与本条接线前逐字节一致)
|
||||
# ⚠️ 旧模板里的 CONTEXT_MAX_TOKENS=32768 取自「gpt-4o 128K 窗口的四分之一」,那个前提已失效
|
||||
# (默认模型窗口 1M)。若你的 .env 里还留着它,它就是【显式覆盖 32768】,会盖掉按窗口推导
|
||||
# (1M 窗口 ⇒ 699050 token)——要吃到推导,把那一行注释掉。固定低阈值会既丢上下文、又因反复
|
||||
# 压缩作废 KV 缓存前缀而反复烧钱(附录 A.2)。
|
||||
# CONTEXT_MAX_TOKENS=
|
||||
# 推导比例:预算占窗口的比例,主人 2026-10-04 裁决取 1/2–3/4 之间,默认 2/3。
|
||||
# 写法:小数(0.5)或分数(1/2)都接受;非法值 = 记一条 WARN 并回落 2/3。
|
||||
# CONTEXT_BUDGET_RATIO=2/3
|
||||
# 单份附件注入模型可见上下文的字符上限(sidecar 仍存全文,只受 8 MiB 安全上限约束)。
|
||||
# 留空 = 按推导出的预算算:推导预算的 1/8(按一个回合最多 5 个附件留余量),下限 64K 字符。
|
||||
# 它决定「每回合往上下文里放多少」,也因此决定清单行给的续读游标从哪里开始。
|
||||
# ⚠️ 它必须**不超过**下面 GUARDRAIL_MAX_INPUT_CHARS 生效的输入上限 —— 严格说是
|
||||
# 「单份注入 × 单回合附件数(最多 5)」不超过上限,否则满载附件的回合会在装配完之后
|
||||
# 被输入护栏整回合拦死(input_length_exceeded)。改了本键就顺手核对那一项。
|
||||
# DOCUMENT_MAX_INJECT_CHARS=
|
||||
# 请求侧字符上限(输入护栏跑在**组装完成的请求**上:系统提示词 + 记忆块 + 整段对话 + 工具结果)。
|
||||
# 三种取值:
|
||||
# · 未设置 / 0(默认)= 由推导出的上下文预算派生:
|
||||
# 上限 = max(262144, 推导预算 token 数 × 4)
|
||||
# 那个 4 与附件注入预算用的是**同一个换算常量**(loop.CharsPerToken = 4 字符 ≈ 1 token)。
|
||||
# 上限量的是整段装配请求,而注入预算是单份的,两边必须是同一套换算,否则**多附件**回合
|
||||
# 会被拦死(G2b 修的正是这个:旧口径按 1 字符 ≈ 1 token 兑,2 份满额注入就顶到上限):
|
||||
# 1M 窗口 ⇒ 预算 699050 token ⇒ 上限 2796200 字符;
|
||||
# 单份附件注入预算 349525 字符 ⇒ 5 份(单回合附件上限)满额注入合计 1747625 字符,
|
||||
# 关系不变量「单回合全部附件的注入预算合计 ≤ 输入上限」成立,「分享大文件」才走得通。
|
||||
# 预算关闭(CONTEXT_MAX_TOKENS=0)或预算很小时,上限落回 262144。
|
||||
# · > 0 = 显式覆盖(原样使用;压到「单份注入 × 单回合附件数」之下会让多附件回合被拦,
|
||||
# 届时启动日志里有 WARN)
|
||||
# · < 0 = 非法:记一条 WARN 并回落派生值(绝不静默生效)
|
||||
# 它只动**输入**侧;输出上限(65536 字符)防的是模型输出失控,与上下文预算无关,不开放配置。
|
||||
# 敏感信息(API key / bearer token / 密码 / 私钥)为阻断档,同样是代码内定的安全默认值。
|
||||
# (取值与关系不变量的落点在 cmd/cyrene/wire_guardrail.go)
|
||||
# GUARDRAIL_MAX_INPUT_CHARS=
|
||||
# 内容策略关键词表(逗号分隔,大小写不敏感的子串匹配)。留空 = 不挂关键词护栏
|
||||
# (项目不内置任何词表,只有你显式声明过的词才会被拦)。
|
||||
# GUARDRAIL_KEYWORDS=
|
||||
|
||||
# ---- 主人身份说明书注入范围(design/11 §0 红线) ----
|
||||
# 主人身份说明书(owner.yaml identityFile)注入范围:owner_only(默认,仅主人会话) | always(所有会话,含群聊/陌生人,仅自测) | off。
|
||||
# owner_only 按渠道层强类型 actor 元数据判定;非法值回退 owner_only 并记 WARN(绝不静默放行)。
|
||||
# 它只决定那段知识进不进该会话的上下文,不参与任何身份/权限判定(身份始终看 actor 元数据 + 白名单)。
|
||||
OWNER_IDENTITY_INJECTION=owner_only
|
||||
|
||||
# ---- 推送出口(D1,主动推送的两级投递)----
|
||||
# 主动消息在 WS/IM 投递之外,异步镜像到「主人自建统一推送服务」(主通道)。
|
||||
# 该服务不可达/未配时,日志注明「个推(Getui)回退待接入」——当前无 Getui SDK,不伪造投递。
|
||||
# 填写自建服务的 webhook 地址并置 ENABLE=true 即启用;token 只走 Authorization 头,不落日志。
|
||||
PUSH_EXPORTER_ENABLED=false
|
||||
# PUSH_EXPORTER_URL=https://push.example.com/webhook
|
||||
# PUSH_EXPORTER_TOKEN=change-me
|
||||
# PUSH_EXPORTER_TIMEOUT_SEC=5
|
||||
# 个推(Getui)回退字段:预留占位,SDK 未接入,当前不生效。
|
||||
# GETUI_APP_ID=
|
||||
# GETUI_APP_KEY=
|
||||
# GETUI_MASTER_SECRET=
|
||||
# GETUI_BASE_URL=
|
||||
|
||||
# ---- WebSocket ----
|
||||
WS_MAX_CONNECTIONS=1000
|
||||
SESSION_IDLE_TIMEOUT_MIN=30
|
||||
|
||||
# ---- MCP 服务器(可选,JSON 数组字符串;每台 server 含 name/command/args/url/enabled) ----
|
||||
# stdio 子进程示例:
|
||||
# MCP_SERVERS_JSON=[{"name":"calc","command":"mcp-calc","args":["--stdio"],"enabled":true}]
|
||||
# SSE 端点示例:
|
||||
# MCP_SERVERS_JSON=[{"name":"weather","url":"http://localhost:3001/sse","enabled":true}]
|
||||
# ⚠️ 旧键(未接线,配了不生效,勿用):`MCP_NAME` / `MCP_COMMAND` / `MCP_ARGS` / `MCP_URL` /
|
||||
# `MCP_TRANSPORT` / `MCP_ENABLED` 这类「单 server 扁平 env 模式」(含带编号的
|
||||
# `MCP_0_*`、`MCP_1_*` …)只在 internal/capability/mcp 的单测里被解析;生产装配
|
||||
# (cmd/cyrene/mcp.go)只读 `MCP_SERVERS_JSON`。`MCP_API_KEY` / `MCP_ENV` 更连那套
|
||||
# 解析都不读(结构体上的残留 tag)。要接 MCP server 请只用上面的 MCP_SERVERS_JSON。
|
||||
|
||||
# ---- 本地执行沙箱(exec/terminal 工具;默认关闭,授权放权) ----
|
||||
# 只有 Enabled=true 时 agent 才可执行命令;仍需命令白名单与根目录边界。
|
||||
# EXEC_SANDBOX_ENABLED=false
|
||||
# EXEC_SANDBOX_COMMANDS=git,ls,cat,pwd
|
||||
# EXEC_SANDBOX_ROOTS=./data
|
||||
# EXEC_SANDBOX_ENV=PATH
|
||||
|
||||
# ---- 跨域白名单 ----
|
||||
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
|
||||
# ---- KOOK(KaiHeiLa)渠道 ----
|
||||
# 单账号走环境变量;多账号走下方 CHANNELS_JSON。
|
||||
# .env 仅提供【必要连接兜底】;接收模式 / webhook 回调路由 / verify_token / encrypt_key 等
|
||||
# 业务·高级项一律在管理面板「渠道管理」配置(写入 channels.json,热更新),不落 .env。
|
||||
# CHANNEL_KOOK_TOKEN 为 KOOK 开发者中心机器人 token(敏感,勿写日志/提交)。
|
||||
# CHANNEL_KOOK_BASE_URL 可选,默认 https://www.kookapp.cn/api/v3。
|
||||
# CHANNEL_KOOK_TOKEN=
|
||||
# CHANNEL_KOOK_BASE_URL=
|
||||
|
||||
# ---- Telegram 渠道 ----
|
||||
# 同 KOOK 口径:单账号走环境变量、多账号走下方 CHANNELS_JSON,业务·高级项一律在面板配置。
|
||||
# bot_token 为空时该账号【完全不存在】(空占位符绝不占用裸平台名),所以不用的平台留空即可。
|
||||
# CHANNEL_TELEGRAM_BOT_TOKEN 为 BotFather 签发的 token(敏感,勿写日志/提交)。
|
||||
# CHANNEL_TELEGRAM_WEBHOOK_URL 可选:填公网 HTTPS 回调地址走 webhook 投递;
|
||||
# 留空则适配器改用 getUpdates 长轮询(本地开发无需公网地址)。
|
||||
# CHANNEL_TELEGRAM_BOT_TOKEN=
|
||||
# CHANNEL_TELEGRAM_WEBHOOK_URL=
|
||||
|
||||
# ---- OneBot v11(QQ)渠道 ----
|
||||
# 四端由 transport(ws|http) × mode(client|server) 组合决定(裁决 1.4 / design/08 §4):
|
||||
# ws+client Cyrene 拨号 OneBot,收发同一 WS
|
||||
# ws+server OneBot 反连 Cyrene(需 LISTEN_ADDR),收发同一 WS
|
||||
# http+client Cyrene → OneBot(纯出站,POST {API_BASE_URL}/{action})
|
||||
# http+server OneBot → Cyrene(纯入站,POST 到 LISTEN_ADDR + REPORT_PATH)
|
||||
# 该平台【无 token 门槛】:一个键都不配也会合成账号,是否真拨号由渠道桥的 configured 闸门
|
||||
# 决定(server 模式要 LISTEN_ADDR 非空,client 模式要 REMOTE_URL 非空)。
|
||||
# transport 留空或未知值一律回落 ws;mode 默认 server(即不给任何新配置时行为与改造前一致)。
|
||||
# CHANNEL_QQ_OBV11_TRANSPORT=ws
|
||||
# CHANNEL_QQ_OBV11_MODE=server
|
||||
# ⚠️ LISTEN_ADDR 落在 AGENTS §9 登记的唯一「可开监听端口」豁免(OneBot 反连):
|
||||
# 必须由配置显式给出,为空则 server 模式 Connect 直接失败、绝不开端口。
|
||||
# CHANNEL_QQ_OBV11_LISTEN_ADDR=127.0.0.1:5700
|
||||
# ws+client 的拨号端点(ws:// 或 wss://)。
|
||||
# CHANNEL_QQ_OBV11_REMOTE_URL=
|
||||
# OneBot HTTP API 基址(如 http://127.0.0.1:3000):http+client 必填;http+server 可选,
|
||||
# 给了才有出站(回消息)能力。必须显式给出,不从 REMOTE_URL 推导。
|
||||
# CHANNEL_QQ_OBV11_API_BASE_URL=
|
||||
# http+server 接收上报的路径,默认 /(缺前导 / 会自动补齐)。
|
||||
# CHANNEL_QQ_OBV11_REPORT_PATH=/
|
||||
# 可选 OneBot access_token,四端通用:出站走 Authorization: Bearer;http+server 入站侧
|
||||
# fail-closed——配了则 Bearer 或 ?access_token= 不匹配回 403,为空则放行(勿写日志/提交)。
|
||||
# CHANNEL_QQ_OBV11_ACCESS_TOKEN=
|
||||
# 机器人自己的 QQ 号(已知时填写,用于识别自消息)。
|
||||
# CHANNEL_QQ_OBV11_SELF_ID=
|
||||
|
||||
# ---- 通用 Webhook 渠道(入站专用) ----
|
||||
# 回调由网关挂在上面的唯一端口上,不新开监听。全部字段可选且都有可用默认值,
|
||||
# 空配置也是一个能接请求的端点;该平台无 token 门槛,configured 闸门对 webhook 恒放行。
|
||||
# ENDPOINT_PATH 是该适配器在网关上的路由,默认 /webhook。
|
||||
# CHANNEL_WEBHOOK_ENDPOINT_PATH=/webhook
|
||||
# CHANNEL_ID / CHANNEL_NAME 只用于向 ListChannels 报告该渠道(留空 = 不预注册渠道)。
|
||||
# CHANNEL_WEBHOOK_CHANNEL_ID=
|
||||
# CHANNEL_WEBHOOK_CHANNEL_NAME=
|
||||
# ⚠️ SECRET 非空才对请求体做 HMAC-SHA256 验签;留空 = 【关闭验签】,任何能访问该路径的请求
|
||||
# 都会被当消息收下。对公网暴露时务必填写(敏感,勿写日志/提交)。
|
||||
# CHANNEL_WEBHOOK_SECRET=
|
||||
# 承载签名的请求头名,默认 X-Webhook-Signature。
|
||||
# CHANNEL_WEBHOOK_SIGNATURE_HEADER=X-Webhook-Signature
|
||||
|
||||
# ---- 微信 iLink 渠道 ----
|
||||
# bot_token 为空时该账号【不存在】(同 Telegram),configured 闸门要求它非空才连接。
|
||||
# CHANNEL_WEIXIN_ILINK_BOT_TOKEN 是扫码登录得到的长期 bearer token:非空即跳过二维码流程
|
||||
# (敏感,勿写日志/提交)。
|
||||
# CHANNEL_WEIXIN_ILINK_BOT_TOKEN=
|
||||
# BOT_ID = 登录确认返回的 ilink_bot_id;OWNER_ID = 已绑定主人的 ilink_user_id
|
||||
# (用于把该渠道的主人身份对上:身份判定仍只看渠道层 actor 元数据,不看提示词)。
|
||||
# CHANNEL_WEIXIN_ILINK_BOT_ID=
|
||||
# CHANNEL_WEIXIN_ILINK_OWNER_ID=
|
||||
# API 根地址与媒体 CDN 根地址;留空用协议默认值:
|
||||
# https://ilinkai.weixin.qq.com 与 https://novac2c.cdn.weixin.qq.com/c2c。
|
||||
# CHANNEL_WEIXIN_ILINK_BASE_URL=
|
||||
# CHANNEL_WEIXIN_ILINK_CDN_BASE_URL=
|
||||
|
||||
# ---- IM 多账号 / 黑白名单(可选,design/16 §4.6 §5-6) ----
|
||||
# ⚠️ 本节是【兜底】:管理面板「渠道管理」页保存会写入本地 channels.json(默认 <DATA_DIR>/channels.json,已 gitignore),
|
||||
# 只要 channels.json 存在且有效,运行中的渠道 adapter 就优先用它的多平台账号配置,并支持面板实时保存→热更新(无需重启)。
|
||||
# 仅当 channels.json 不存在/无效时才回退到下面的 CHANNELS_JSON 与 CHANNEL_<NAME>_*。
|
||||
# 业务·高级项(接收模式 receive_mode / webhook 回调路由与 verify_token、encrypt_key / 黑白名单 / 限流)
|
||||
# 一律在管理面板「渠道管理」配置:写 channels.json、热更新;.env 仅作无文件时的必要连接回退(token/base_url 等)。
|
||||
# 单账号仍走上面 CHANNEL_<NAME>_* 环境变量;这里提供独立的 JSON 注册表,
|
||||
# 支持同一平台配多个接入配置(多账号),并配群聊/私聊黑白名单(代码层拦截,
|
||||
# 被拉黑/不在白名单的群或用户消息不进 LLM,且有审计)。形如:
|
||||
# CHANNELS_JSON={"telegram":[{"id":"conf0","config":{"bot_token":"..."},"denyUsers":["12345"],"denyGroups":["-100"]}]}
|
||||
# 每个 account 字段:id(平台内唯一)、config(平台特有凭据 map)、
|
||||
# allowGroups/denyGroups(群黑白名单,allowGroups 非空则成为白名单)、
|
||||
# allowUsers/denyUsers(私聊用户黑白名单)。未配置时按单账号 env 路径处理。
|
||||
# 群/总限流与总上限(可选,design/12 §6 策略三/四,公共平台防刷屏/最小化打扰):
|
||||
# groupMsgPerMin 群聊每分钟上限(0=用默认6,-1=不限,正整数=该值)
|
||||
# directMsgPerMin 非主人(陌生人)私聊每分钟每发送者上限(0=用默认6,-1=关闭,正整数=该值)
|
||||
# msgPerMin 非主人消息每分钟总上限(>0 生效,0/-1=不限)
|
||||
# maxMessages 待处理上限,接近上限时由审查模型剔除低价值消息(0=用默认50,-1=不限,正整数=该值)
|
||||
# 旧账号不配这些字段默认走「保护性默认/不限」,保持向后兼容。
|
||||
# CHANNELS_JSON=
|
||||
@@ -0,0 +1,69 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
name: Backend (build / vet / test / cross-compile)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26"
|
||||
|
||||
# `go:embed` 需要 internal/webui/dist 存在(前端先构建并拷贝)。
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Build frontend (produces internal/webui/dist)
|
||||
working-directory: frontend/web
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: go build
|
||||
run: go build ./...
|
||||
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: go test (with coverage)
|
||||
run: go test ./... -count=1 -coverprofile=coverage.out
|
||||
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage
|
||||
path: coverage.out
|
||||
|
||||
- name: Cross-compile matrix
|
||||
run: |
|
||||
set -e
|
||||
for t in "linux amd64 " "linux arm64 " "linux arm 7" "darwin amd64 " "darwin arm64 " "windows amd64 " "windows arm64 "; do
|
||||
set -- $t
|
||||
echo "== building $1/$2/$3 =="
|
||||
GOOS=$1 GOARCH=$2 GOARM=$3 go build ./...
|
||||
done
|
||||
|
||||
frontend:
|
||||
name: Frontend (tsc + vite build)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Build
|
||||
working-directory: frontend/web
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
# 开发设计文档(本地参考,不入库)
|
||||
DevDocs/
|
||||
|
||||
# 环境变量与密钥
|
||||
.env
|
||||
# 编辑器/工具生成的密钥备份(.env.bak / .env.old / .env.prod…)一律忽略,只放行模板
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.sample
|
||||
!.env.template
|
||||
models.json
|
||||
channels.json
|
||||
plugins.json
|
||||
*.local
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# 文件管理器/编辑器自动备份与临时文件(主人的远程文件管理 App 改文件时会自动生成 .bak)
|
||||
*.bak
|
||||
*.bak-*
|
||||
*.orig
|
||||
*.tmp
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# 构建产物
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Go
|
||||
# 前端
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# 系统
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# 日志
|
||||
*.log
|
||||
|
||||
# 临时文件
|
||||
tmp/
|
||||
temp/
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# 开发/运行本地产出(不入库)
|
||||
# data/ = SQLite 数据目录(DATA_DIR=./data);/cyrene = 根目录构建产物(锚定,不误伤 cmd/cyrene)
|
||||
data/
|
||||
/cyrene
|
||||
@@ -0,0 +1,86 @@
|
||||
# Cyrene v2 开发铁律(AGENTS.md)
|
||||
|
||||
> 本文件是**项目级开发铁律**,由运行环境在**每次工作会话时自动注入**。即使发生上下文压缩/丢失,以下约束仍会强制生效。**必须遵守**,违反即偏离。
|
||||
|
||||
## 1. 先读设计,再写代码
|
||||
- 动任何代码**前**:先读 `DevDocs/开发时间线.md`(当前里程碑 + §3 功能状态清单),确定这一步属于哪个里程碑/功能。
|
||||
- 具体功能实现:先读对应 `DevDocs/design/*.md`,**按其规格实现,不得自行发挥**(design/01~13 是权威设计)。
|
||||
|
||||
## 2. 按里程碑推进,不跳步、不漂移
|
||||
- 严格按 **M0 → M12** 顺序。每个功能的依赖与归属见时间线 §2/§3。
|
||||
- 不得「想到什么做什么」。**新需求/新想法:先写入设计或时间线清单,再开发**,不脱离清单。
|
||||
- 上下文压缩后:**先读 `DevDocs/工作小结/00-当前态.md`** 恢复锚点,再继续,**不得凭印象跳走**。
|
||||
|
||||
## 3. 每阶段审查并勾选
|
||||
- 阶段完成:后端 `go build ./...` + `go vet ./...` + `go test ./...` 全绿;前端 `tsc --noEmit` + `vite build` 通过。
|
||||
- 在 `DevDocs/开发时间线.md` §3 勾选状态:`✅ 已完成` / `⚠️ 未完成/阻塞`(⚠️ 必须记录原因与延后里程碑)。
|
||||
- 与 `DevDocs/design/*` 比对:不一致时以设计为准——先改设计文档或修正代码,**不允许带着偏离堆叠**。
|
||||
|
||||
## 4. 改动前确认归属
|
||||
- 修改任一文件/功能前,明确它在哪个里程碑、对应哪份 design 文档;改完同步该文档(若设计变了)。
|
||||
|
||||
## 5. 实时同步工作小结
|
||||
- 每完成一步,更新 `DevDocs/工作小结/00-当前态.md`:当前里程碑 / 进度 / 已完成+未完成 / 关键决策 / 变更日志。
|
||||
|
||||
## 6. 小步提交 + 频繁验证
|
||||
- 小粒度 commit;每步 build/test 通过再提交;提交信息按 Git 规范(feat/fix/refactor/docs/chore,scope 用模块名)。
|
||||
|
||||
## 7. 不信任记忆,信任清单
|
||||
- 关键决策、架构铁律、用户红线(如身份识别靠元数据、不靠提示词)都记录在 `DevDocs/工作小结/00-当前态.md` 的「关键决策」区;压缩后回读,不得丢失。
|
||||
|
||||
## 8. 上下文保护:可并行项交给后台子代理,主会话当监工
|
||||
- **相互独立、不依赖主会话开放状态**的开发任务(如 M0 地基各件、层 1 并行件),用 `Agent(run_in_background=true)` 派后台子代理**并行实现**。
|
||||
- 主会话**只当监工**:等子代理回报 → 验收(`go build/vet/test`)→ 更新 `DevDocs/工作小结/00-当前态.md` + 时间线 → 小步 commit → 派下一批。**不在主会话里重复子代理已做的搜索/实现**。
|
||||
- 子代理 prompt 必须**自包含**:写清目标、已知信息、精确文件路径、必须遵守的编码约束(02-development-constraints.md)、验收命令;不要让它去搜已经知道的东西。
|
||||
- **只有真正独立才并行**;有依赖的串行。避免两个子代理同时改 `go.mod/go.sum`——若都要改,指定其中一个负责或先把依赖拉好。
|
||||
- 目的:防主会话上下文超限触发压缩;压缩后靠本文件 + `DevDocs/工作小结/00-当前态.md` 恢复锚点,继续当监工。
|
||||
|
||||
## 9. 全球红线(跨里程碑,必须遵守;违反即架构偏离)
|
||||
- **单二进制、单端口 `:8080`、零跨进程 IPC**(🔴 **本条约束的是 Cyrene 框架自身,不约束扩展插件**):框架模块间同步走**进程内接口 + 依赖注入**(直接方法调用),异步走**进程内 `bus`**;**框架自身不另开监听端口、不 HTTP 互调**。
|
||||
- 🆕 **2026-10-07 主人口径补充(原「豁免」升格为一般规则)**:**扩展插件不受本条约束** —— `plugins/` 下的渠道适配器与工具、MCP server、以及插件自己拉起的本地伴随进程/服务,**可以开自己的监听端口、可以有自己的进程形态**。这**不是「例外」,是插件的正当能力**;原先只为 `qq-obv11` 登记的那条例外,**随之由「例外」变为本条一般规则的一个实例**(该渠道规格仍见 `design/08 §4`)。
|
||||
- 🔴 **插件开端口的四条护栏(主人 2026-10-07 确认采纳)**:① 端口/地址**必须由配置显式给出**,**不得默认开启、不得写死**;② **默认绑 `127.0.0.1`**,要对外暴露必须由 `.env` 显式决定(与框架暴露面同一套口径);③ **必须登记日志来源与审计**(谁开的、绑在哪);④ **端口不得成为绕过身份/权限判定的入口** —— actor 元数据 + 白名单 + 策略引擎仍是唯一判据,端口不是后门。
|
||||
- 「**零跨进程 IPC**」的原意**仍然有效**:**框架内部模块**不得为解耦而拆成多进程/多服务、不得 HTTP 硬编码 URL 互调(详见 `DevDocs/00-overview.md`)。**框架与插件之间的边界通信不在本条约束内**;但框架**不得**因此把自身模块拆出去变成插件。
|
||||
- **身份识别靠 channel 层强类型 `actor` 元数据 + 策略引擎**,**绝不靠提示词/LLM 判断**(主人/群聊/陌生人判定与权限永远由 actor 元数据 + 白名单 + policy engine 决定)。
|
||||
- **人格 = 身份**(**不限制能力**);**行为模式 = 呈现**(≠边界,可热切换、不构成能力边界);**能力全局全能**;**前端(Web)是第一公民**。
|
||||
- **IM 是镜像**:只 chat 文本,Web 不回流 IM;输出一律 chat 文本。
|
||||
- **前端(用户端 + 管理面板)统一 Miuix 设计语言**(小米 HyperOS Miuix / Miuix Console),实现须对齐六大核心(Smooth Corner/多层 Surface/弹簧动效/深色优先/毛玻璃/Liquid Glass),不混用其他 UI 语言。
|
||||
- **存储**:SQLite 默认 + PG/pgvector **可选增强**;**配置了增强必须真实走语义/向量路径**(非空壳)。
|
||||
- **工具循环上限 8 轮**;任务:后台声明 / 前台 2s 转后台 / 消息队列回输出。
|
||||
- **记忆三类作用域隔离**:用户习惯(全局共享)/ 角色日常(他角色只读)/ 项目(人人读写 + 项目索引分层);**群聊 / 陌生人:查记忆只读不写、不入主会话上下文、不泄露主人敏感信息**。
|
||||
- **敏感操作主代理代审 + 审计日志 + 前端小字**(「xxx 想要干什么,我审查后批准了」)。
|
||||
- **模型**:per-role 路由 + 接口报错自动降级(主→备→本地);多模态/流式。
|
||||
- 🔴 **后端常驻(主人 2026-10-04 追加要求)**:在**下一次构建产物就绪之前,正在运行的后端(`cyrene.exe`,`:10942`)不准关**,除非出现**恶性 Bug**(进程致命错误/必须阻断的严重故障)。推论:① **禁止**为「跑全树验收 / 重启清理 / 图省事」而停它——`go test`/`go build`/`git` 都不需要停后端;② 换二进制**只允许**在**新产物已构建好之后**一次性 `restart`(即「先 build 到位,再一次切换」,不得留空窗);③ **启动后端不许用前台阻塞式命令**(`scripts\ctrl.bat start` 会守着进程不返回)——必须确认它**脱离工具进程**,否则工具超时会**连带杀掉后端**(2026-10-04 实测:`TaskStop` 掉阻塞包装后进程存活、PID 文件已写 `757788`,属脱离式启动);④ 上下文压缩/会话中断后:**先探活**(`netstat -ano | grep 10942` + `curl /api/v1/health`)再决定,不要凭印象以为它停了。
|
||||
|
||||
## 9.5 学习成熟开源实现,禁止闭门造车(⚠️ 用户强制红线)
|
||||
- **已成熟的能力/模块,必须优先学习并复用现有开源项目的业务逻辑与交互范式**,**严禁闭门造车/按设计文档凭空推演**。对照基准(按 design 主题):
|
||||
- **agent 工具循环 / 工具分发** → Claude Code、OpenClaw、Cursor(工具 schema、循环上限、权限门、流式输出)
|
||||
- **子代理编排 / 隔离上下文 / 审计代审** → Claude Code 的 subagent、OpenAI Swarm、CrewAI
|
||||
- **MCP** → **官方 Model Context Protocol SDK**(Go/Python/TS 客户端接入、工具自动发现)
|
||||
- **WS 实时协议 / 流式 token** → OpenAI/Anthropic 官方 SDK 的流式惯例、Claude Code 的实时事件
|
||||
- **checkpoint 恢复 / 长任务持久化** → Claude Code 的 resume / Cursor 的会话恢复
|
||||
- **跨平台渠道接入(QQ/微信/Telegram)** → OneBot v11、Telegram Bot API、微信 iLink 既有客户端
|
||||
- **管理面板 / 前端交互** → Miuix Console(本项目设计基准)、以及成熟 admin 面板范式
|
||||
- **实现前**:先在该主题的 design 文档下**补一行「开源对照」**——列出参考项目 + 借鉴点(模板:`## 开源对照(实现前必读)`)。实现时逐点对齐其成熟逻辑。
|
||||
- 若某个能力**已有成熟开源实现**而项目选择了自研,必须**写明理由**(如协议差异、单二进制约束),不得默认自研。
|
||||
- 上下文压缩后:**本铁律强制生效**;每个模块补全前**先查 `DevDocs/开源对照清单.md`**(集中记录各主题的参考项目与已完成对照),未对照不得开工。
|
||||
|
||||
## 10. 代码与流程硬性规范(压缩后仍需遵守)
|
||||
- 🔴 **文件 ≤500 行、函数 ≤80 行**;**模块按职责拆成多个源文件,严禁把整个模块塞进一个超大文件**;**三次复制必须抽取**共享。
|
||||
- 🔴 禁 `interface{}`(用 `any`)、禁包级全局变量(除 `var ErrXxx`)、禁裸 `return err`、禁魔法数字;错误一律 `%w` wrap;SQL 全参数化、主键 `id`、时间戳 `created_at/updated_at`、索引 `idx_{table}_{col}`(♢ **已登记例外(主人 2026-10-04 裁决)**:表名**统一用复数**、以既有迁移为准——本条原「表名单数」**不再作为硬约束**,命名口径以 `DevDocs/design/13`/`design/16` 为准);敏感信息打日志。
|
||||
- 🔴 **日志必须标来源**:模块/子功能用 `internal/logger.WithSource`/`Scoped`(`source=module/subfeature`)标注,跨模块可追溯定位;**禁止无来源裸日志**。
|
||||
- 🔴 **客户端 API 契约先行**:REST `/api/v1` + WS `/ws` + admin `/api/v1/admin/*` 统一**版本化契约**,供跨平台客户端(Web/移动/IM bridge)接入与治理;JSON 响应 `{data}` / `{error:{code,message}}`。
|
||||
- 🔴 **用户侧 API 文档随实现更新**:每实现/变更一个 REST 端点、WS 事件、admin API,**立即同步更新 `Docs/api-client.md`**(用户侧跨平台客户端 API 文档),供后期原生客户端开发;契约变更版本化。
|
||||
- 🔴 **`.env` 冻结(主人 2026-10-06,二次收口后为最终口径)**:`.env` 只保证「**最小化功能可跑**」——**基础键已经定好了,以后不准改**(**不加、不改、不删**;模板 `.env.example` 与 `.env` 同进退)。
|
||||
① **新配置一律不进 `.env`**:调优/门槛/上限/开关类参数走 **`$DATA_DIR/config/**`(面板可改)+ 代码默认值**;
|
||||
② **既有基础键与既有 `env:` tag 保持现状**(不迁移、不改默认值、不删键);
|
||||
③ 原协同 `E6`「新 env 键必须同步 `.env.example`」**作废**;
|
||||
④ **凡文档里写「新增 env 键须同步 `.env.example`」的段落一律是过期文本**,按本条改正(口径全文见 `design/13 §2.1`)。
|
||||
- 🔴 **每次模块/功能改动「前」与「后」都推仓库**(用户红线,留恢复误操作余地):改动前若工作树有未推改动,先 `git push origin dev` 存一个干净基线;改动完成验收通过后,立即 `git commit` + `git push origin dev` 存档。**绝不攒一堆改动到阶段末一次性推**——坏操作可随时 `git reset`/`rebase` 到任一推过的 commit 回滚。每个模块/功能提交(或里程碑归档)后均 `git push origin dev` 同步远端。
|
||||
- 🔴 **压测纪律(2026-10-04 实测事故,必守)**:子代理做负载/竞态验证时**不许**用无界进程风暴占机(如 `yes` ×30、`sleep` 风暴)—— 实测一次「`yes` 没杀干净」把 12 核占满约 **15 分钟**,期间所有 `go build/test` 近乎停滞,并把工具链误判成死锁。规矩:① 负载**限核**(`GOMAXPROCS`/单包并行/`-p`)或**限时**(几分钟级),优先用「可控对抗条件 + 注入时钟 + 门控」而不是堆进程;② 压测结束**必须确认无残留进程**(`Get-Process yes` 之类计数为 0)再出证据;③ 出现「整机变慢/命令不动」先查**是不是自己刚起的压测进程**,再怀疑工具链。
|
||||
- 🔴 **仓库根产物卫生(2026-10-04 实测事故,必守)**:`go build ./cmd/cyrene/...` 这类**单 main 包**命令会把可执行文件写到**当前工作目录** ⇒ 实测**静默覆盖**了正在用的发布产物 `cyrene.exe`(且不带 `-ldflags "-s -w"`,体积 39.3 MB → 49.1 MB)。规矩:**子代理一律不许跑会在仓库根产出可执行文件的构建** —— 只跑 `go build ./...`(多包 ⇒ 丢弃产物)、`go build ./<非 main 包>/...`、`go vet`、`go test`。**换二进制只允许**「`scripts\ctrl.bat build`(唯一权威构建,带 strip)+ **一次** `restart`」,且必须在**整树验收通过之后**执行(对齐「后端常驻」红线:换产物不留空窗)。
|
||||
- 🔴 **等待子代理:不许用长 `WaitFor` 阻塞回合(主人 2026-10-04 反馈)**:主会话在 `WaitFor` 里等子代理时,**主人无法给任何补充输入** —— 等于把主人锁在门外。规矩:**派完就结束回合**(子代理的完成通知会自动唤醒主会话);确需同回合内等,**单次 `WaitFor` ≤ 60s**,且只用于「马上就要用它的结果」;等待期间若有并行工作,**先做工作再结束回合**,不要空等。
|
||||
- 🔴 **遇到开发文档冲突 / 意义不明 / 规格矛盾时**:**阻塞会话**,用 `AskUserQuestion` 询问主人,**不得自行猜测**;文档清、不冲突则自主推进。
|
||||
- 🔴 阶段验证:后端 `go build ./...` + `go vet ./...` + `go test ./...` 全绿 + windows/darwin 交叉编译;前端(M9+)`tsc --noEmit` + `vite build` 通过。
|
||||
- 🔴 **换二进制前先确认前端是否变更(2026-10-05 登记)**:`scripts\ctrl.bat build` **只跑 `go build`,不含前端**。若本次改动触及 `frontend/web/**`,必须先在 `frontend/web` 跑 `npm run build`(会把 `dist` 拷到 `internal/webui/dist`),**再** `ctrl.bat build` + **一次** `restart`;否则后端产物里带的是旧前端。反过来:**纯前端改动不需要重启后端**(运行时优先读磁盘 `frontend/web/dist`),刷新浏览器即可。
|
||||
- 🔴 **裁定必须带完成状态(2026-10-05 登记)**:凡是给主人 / 给安卓端的裁定、承诺、回执,**每一条都必须标注「已完成 / 在办 / 未开始」**;标「已完成」须附 commit 号或验收证据,且**完成后回原条目处补记「已完成 + commit」**——不得只在别处口头称完成。**禁止把「未验证」表述成「已修好」**。
|
||||
- 🔴 **提交一律逐文件 `git add <显式路径>`(2026-10-05 实测事故)**:**禁止**在「同一目录下有多路在途切片」时用 `git add -A -- <目录>` —— 实测它会**把别的子代理的在途文件一起吞进提交**(本次误把 237 的 `sessionenv.go` 提交并推了)。修复套路:`git reset --soft HEAD~1` → `git restore --staged <误入文件>` → 重新 commit → `git push --force-with-lease origin dev`(工作树内容不丢)。
|
||||
+2040
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,862 @@
|
||||
# Cyrene v2 客户端 API 文档(用户侧)
|
||||
|
||||
> **完整版见 `Docs/api-reference.md`**(后端全部 API(REST + WebSocket)超详细调用文档,字段/鉴权/状态码以代码为准)。本文档为**摘要**,契约如有出入以 `api-reference.md` 为准。
|
||||
|
||||
> **面向**: Web 前端 / 移动端原生客户端 / IM 桥接等**跨平台客户端开发者**。
|
||||
> **维护铁律**: 每实现/变更一个 API(REST 端点、WS 事件、admin API)**立即更新本文件**;契约变更必须版本化,不破坏既有客户端。
|
||||
> **状态**: gateway 已在 M8 落地核心端点;M9 前端按此开发。**`✅` = 已实现**,`🕐` = 规划。
|
||||
|
||||
---
|
||||
|
||||
## 0. 基础约定
|
||||
- **Base URL**: `http://<host>:8080`(网关唯一对外端口;生产一般经反代/HTTPS)。
|
||||
- **版本**: REST 全部挂在 `/api/v1/{resource}`;未来破坏性变更升 v2。
|
||||
- **认证**: `Authorization: Bearer <JWT>`(RBAC:`owner`/`admin`/`viewer`,权限矩阵见 §2.0);**`X-API-Key` 已接线(v0.51)**:所有已认证 API(管理 `/api/v1/admin/*` 与用户侧 `/api/v1/*`)同时接受 `X-API-Key`;命中签发表即通过,且**一律 `owner` 级**(不再按角色分档,裁决 15.4.3)。请求带该头时**只走 key 分支**——未知/已吊销/空值回 `401` 且**不回落 Bearer**(fail-closed)。**WS 握手仍只认 Bearer**。
|
||||
- **响应统一**: `{"data": ...}` / `{"error": {"code": <string>, "message": <string>}}`;状态码 200/201/204/400/401/403/404/413/415/429/500(**413/415 为 v0.59 文档端点新增**)。
|
||||
- **错误码**:业务错误带 `code` 字符串(如 `unauthorized` / `not_found`),便于客户端分支。
|
||||
- **数据目录 `DATA_DIR`(v0.55)**:默认 `./data`;**新增接受保留字 `platform`(大小写不敏感)** → 平台标准数据目录(Windows `%APPDATA%\Cyrene`、macOS `~/Library/Application Support/Cyrene`、Linux `~/.local/share/cyrene`)。**默认 `./data` 与显式路径行为不变**;**显式空值行为也不变**(仍走原兜底,不会因升级突然换库)。
|
||||
- **管理面板安全入口 `ADMIN_PATH`(v0.55,前端路由面,不是 REST 契约)**:面板**前端**路由前缀 = `/` + `ADMIN_PATH`(默认 `admin` → `/admin`;`xxx/admin` → `/xxx/admin`;**非法值回退 `/admin`**)。HTTP 行为:`/<ADMIN_PATH>` 与其深链返回**注入了 `<base href="/">` 与 `window.__CYRENE_ADMIN_ENTRY__={"prefix":"..."}` 的 index.html**(`Cache-Control: no-store`);**形如面板入口(路径里出现 `admin` 段)但前缀不符的路径 → `404`**(**不回落用户端聊天壳**);`/<ADMIN_PATH>` 若恰好命中 `dist` 真实文件则仍走文件服务器。**API 不藏**:`/api/v1/*`、`/api/v1/admin/*`、`/ws/chat`、webhook 一律原样,**无新端点、无帧变更**(安全靠 JWT + 角色门,design/07 §0.1)。**口径**:用户端 HTML **永不携带该前缀串**(后端只在命中前缀时注入)⇒ 用户端既无管理入口链接、也解析不出面板路由(design/07 §0.2)。
|
||||
|
||||
---
|
||||
|
||||
## 1. REST 端点(公开)
|
||||
|
||||
| # | 端点 | 方法 | 认证 | 状态 | 说明 |
|
||||
|---|------|------|------|:---:|------|
|
||||
| 1 | `/api/v1/auth/login` | POST | 否 | ✅ | `{username,password}` → `{data:{token,expiresAt,role,refreshToken?}}`(access 24h;**新增 `refreshToken`**——不透明随机串,仅哈希落库,`AUTH_REFRESH_TTL` 默认 720h,用于静默续期;role=owner) |
|
||||
| 1a | `/api/v1/auth/refresh` | POST | refreshToken | ✅ | **静默续期**(B2):body `{refreshToken}` → `{data:{token,expiresAt,role,refreshToken?}}`(新 access + 新 refresh;**`role` 恒返回** = 该 refresh grant 记录的角色 `owner|admin|viewer`),**默认单次使用旋转**——旧 refresh 立即作废,并发重放 fail-closed `401 invalid_grant`)。`AUTH_REFRESH_ROTATE_DISABLED=true` 关闭轮换时旧 token 保持有效、不返回新 refresh。过期/已撤销/乱串统一 `401 {error:{code:"invalid_grant"}}`;成功才审计 |
|
||||
| 1b | `/api/v1/auth/logout` | POST | refreshToken 或 JWT | ✅ | **登出**(B2):body `{refreshToken}` 撤**单个** grant(幂等)→ `{data:{revoked:<bool>}}`;带 `Authorization: Bearer <jwt>` 且无 body → 撤**该账号全部** grant(全设备登出)→ `{data:{revoked:<bool>,revokedCount:<n>}}`。**两种响应形状不同**——单撤**没有** `revokedCount`;两者都不给回 `400 bad_request`。refresh 相关操作成功才审计 |
|
||||
| 2 | `/api/v1/health` | GET | 否 | ✅ | 健康检查 `{data:{status:"ok"}}` |
|
||||
| 3 | `/api/v1/chat` | POST | JWT | ✅ | `{text,image?,deviceId?,mode?,clientMsgId?,documents?,parallel?,stream?}` → `{data:{reply}}`;**`documents?`(v0.59)**=本回合文档附件引用数组(**≤5 条**、**只收服务端存储名**、与 REST `stream:true` / WS `chat` 同形),单回合超限 `400 too_many_documents`(先于任何文件系统访问)、名非法/穿越/不存在 `404 document_not_found`,详见 §1.2;`stream:true` 时**直达会话编排器**(v0.56/G2:不再经可丢弃的 bus,成功受理后才回,见本行末尾「`stream:true` 响应面」)。`image` 为 base64 data: URI 或 http(s) URL,入视觉路由(design/06 §0;**v0.53 语义修正**)——**主模型能不能收图看生效组的 `multimodal`**(`models.json` 组键,面板可保存、热更新);`LLM_VISION_MODEL`/`LLM_OCR_MODEL` **只配视觉/OCR 工具用的模型**,**与主模型能否收图无关**(裁决 14.4.1)——**走 `.env` 合成组(无有效 `models.json`)时,「主模型能否收图」该由 `LLM_MULTIMODAL` 声明(默认 `false`)**(**v0.58**,见 §2「模型配置契约」表);主模型不收图时由视觉·OCR 工具的**文本描述**替代(图像不占上下文,只有工具结果占);**`image` 上限(v0.56,G3)**:解码后 **5 MiB**(与 `POST /api/v1/upload` 同口径;base64 字符串上限 = `ceil(n/3)*4 + 64` 字符,已含 data: URI 前缀余量),超限一律 `400 {error:{code:"image_too_large", message}}`(文案指路 `POST /api/v1/upload`);**WS `chat` 帧到不了这条判定**——WS 读上限 `wsReadLimit = 4096` 字节/帧,超限由传输层直接断连,故 WS 上发真实图片本就必须「上传 + URL」;`deviceId` 为**客户端生成的具体端标识**(design/16 §4.5,如 `web:chrome:a1b2c3`,可选),落库到消息、便于主人知道从哪个端发来;`mode` 为**行为模式**(design/01 §3 / design/16 §5.1,如 `chat`/`code`/`home`/`docs`/…,可选),随该轮次落库——IM 恒 `chat`、缺省回落 `chat`,供前端按模式渲染气泡(仅 `chat` 才解析 `<p>` 拆气泡)。`clientMsgId`(v0.56,G4)为**客户端自带的幂等键**(可选):同一会话内同一个非空值只承认第一条,重复到达回 `200 {data:{duplicate:true,messageId,sessionId}}` 且**不落库、不起回合、不发新内容帧**;省略 = 与加入该字段前逐字相同(详见 §3.13)。**会话编排(design/15)**:非流式且该会话「正忙」时**不阻塞**,返回 `202` `{data:{queued:true,queueDepth}}`(稍后按上下文续接);待处理队列满时返回 `409` `{error:{code:"chat_busy"}}`。**忙时并行(v0.65,`parallel:true`,仅显式声明时生效)**:该会话**已有在途回合**、且单会话并发额度未满(默认 **2**、硬上限 **3**)⇒ 本条**升为一条独立的并行路立即起**,同步入口回 `202` `{data:{parallel:true,turnId:"turn-…"}}`(**拿不到同步 reply**,文本照旧经 WS 流式到达;`turnId` 就是那一路的权威回合标识、与该路全部流帧同值);**空闲时 `parallel` 无效果**(照常起第 1 路);**省略 `parallel` = 与加入该字段前逐字不变**(服务端**不做**启发式自动升级);额度已满、**或真并行被 `<2` 关掉** ⇒ 落回既有排队语义 `202 {queued:true,queueDepth}` **并在 `pending_ack` 带 `reason:"parallel_limit"`**(见 §3.2)。**`stream:true` 响应面(v0.56,G2,行为变更)**:受理成功后才回——空闲已起回合 = `200 {data:{stream:true}}`、正忙已续接排队 = `202 {data:{stream:true,queued:true,queueDepth}}`、队列满 = `409 {error:{code:"chat_busy"}}`、幂等键重复 = `200 {data:{duplicate:true,messageId,sessionId}}`、**已升为独立并行路 = `200 {data:{stream:true,parallel:true,turnId:"turn-…"}}`**(v0.65;仅 `parallel:true` 且额度未满时出现);**未接 loop 改为 `500 {error:{code:"chat_unavailable"}}`**(旧文写 `200 {stream:true}`,现与同步路径同口径);仅当**未接编排器**才回退总线派发,其失败回 `503 {error:{code:"chat_dispatch_failed"}}`——**不再有「先回 200 再看有没有落地」**,故 `202`/`409` 从此**真的会出现**(修复前因提前 return 永不出现) |
|
||||
| 4 | `/api/v1/sessions` | GET | JWT | ✅ | `{data:[session...]}`。**会话持久化(design/16)**:注入 HistoryStore 后返回持久化会话元数据 `{data:[{id,type("main"|"sub"),title?,createdAt,pinned,unreadCount,lastMessage?,personaId?}]}`(**排序(D5):以代码为准 = `pinned DESC, (type='main') DESC, created_at ASC`**——`pinned=true` 最前,其次是未置顶的主会话,最后是未置顶子会话(按 `createdAt` 升序);⚠️ **置顶的次级会话会排在主会话之前**,客户端定位主会话请按 `id=="web"` / `type=="main"`,**不要假定 index 0 就是主会话**;次级 `web:<uuid>`;`pinned` 恒返回 bool;**`unreadCount`(D4)恒返回整数**——该会话中 `createdAt >` 已读游标的消息条数(0 = 无未读),与 `lastMessage` **同一条 SQL 派生**、无 N+1;`lastMessage` 为**最后一条消息预览** `{content,role(user|assistant),createdAt}`,content 折叠空白截前 60 rune,空会话省略;**`personaId`(v0.42,可选)**=该会话绑定的角色 id(design/22 §2/§4.5),**空/缺省 = 继承全局默认角色**(`owner.yaml:defaultPersona`),客户端据此渲染激活会话的角色名/头像);未注入时退化回渠道会话列表 |
|
||||
| 5 | `/api/v1/sessions` | POST | JWT(owner/admin) | ✅ | **新建次级会话** → `201 {data:{sessionId,personaId?}}`(`sessionId="web:<uuid>"`)。body **可选**(可完全省略或空 body):`{personaId?}`(v0.42)在创建时就把新会话绑到该角色(design/22 §2:渠道级只作"新会话的初始角色提供者";**会话角色只在创建时写入、之后不可变**,见 §1 行 8 / design/22 §8-1);`personaId` 为**未知角色**回 `400 bad_request`;省略/空 = 不绑(`persona_id=''`,继承全局默认);**`personaId` 非空时响应回显它**(省略时响应形状与 v0.41 完全一致);未注入 HistoryStore 回 `501 not_implemented`;注入了**不支持角色绑定**的 HistoryStore 且显式传 `personaId` 时也回 `501`(不假装写入) |
|
||||
| 6 | `/api/v1/sessions/{id}/messages` | GET | JWT(owner/admin) | ✅ | **热分页拉历史**:`?limit=&before=&platform=&after=`(A5)默认 `limit=50`(上限 200)。**`before`**=上一页最旧 `createdAt`(毫秒),按时间**倒序**;**`after`**=<`createdAt` 毫秒>,按时间**升序**返回 `created_at > after` 的消息(**正向增量**,客户端把上次看到的 `createdAt` 当游标补断线期间漏的,解决重连只回放 50 条的缺口);**`after` 与 `before` 互斥**,同传回 `400`;负数/非数字回 `400`。`platform` 为**可选**来源平台过滤(design/16 §4.4)——Web 端不传(=全量共享主会话),IM 适配器传**本平台**(如 `telegram`)只拉本渠道消息;`deviceId` 为**客户端具体端标识**;`senderName` 为**第三方发送者昵称**;**`personaId`(v0.42,可选)**=**该消息说话时的角色 id**(design/22 §7-5:逐条记录"谁说的"——会话角色在创建时固定,§8-1;assistant 消息 = 生成它的角色,user 消息 = 当回合角色;**空 = 全局默认角色**,即 v0.42 之前的全部历史消息),客户端据此按角色渲染名字/头像;**`clientMsgId`(v0.56,可选,G4)**=该消息的**客户端幂等键**(带 `clientMsgId` 发的 user 消息会回填它;空则省略),客户端据此把本地乐观气泡对齐到服务端真相;**`q`**(D2)=**会话内内容搜索**(大小写不敏感 ASCII LIKE,`%`/`_`/`\` 已转义为字面量),仍按时间**倒序**、`before`/`limit` 同分页语义;`q` 与 `after` **互斥**(同传 `400`),空 `q` 视为未传(走普通分页),搜索**严格限定在 `{id}` 会话内**(绝不跨会话,群聊/陌生人隔离会话不会外泄);非法 `limit`/`before`/`after` 回 `400`;未注入 HistoryStore 回 `501`。**(v0.63,行为变更)会话不存在 / 已删除 ⇒ `404 {"error":{"code":"session_not_found","message":"session \"<id>\" not found"}}`**——与 `PATCH /sessions/{id}`、`POST /sessions/{id}/read` 对未知会话的口径一致,客户端据此把「当前会话已经不在了」(退回主会话 + 一句提示)与「这是一条空会话」分开;**存在但确实没有消息仍回 `200 {"data":[]}`**。⚠️ **判据是会话注册表 `chat_sessions`**:隔离的 IM 会话(群聊/陌生人的 `sess-xxx`、内部 `sess-backthink`)**不在其中**,因此与 `POST /sessions/{id}/read` 拿到同一个答案(`404`)——它们的消息请走 `GET /api/v1/admin/channels/{platform}/messages`。⚠️ 请求形状(`limit`/`before`/`after` 非法、`before`+`after` 同传、`q`+`after` 同传)**仍先于** 404 判定(形状错了就是 `400`,与目标存不存在无关) |
|
||||
| 6a | `/api/v1/messages/{id}` | PATCH | JWT(owner/admin) | ✅ | **编辑单条消息**(D3):body `{content}`(非空、≤4000 rune,空/纯空白/超长回 `400`)→ **`{data:<消息DTO>}`**(`data` **直接就是**更新后的完整消息 DTO,含 `id/sessionId/role/content/.../createdAt`,**无 `message` 包一层**;**只改 `content`**,role/时间戳/平台保持不变);消息不存在回 `404 not_found`;未注入 HistoryStore 回 `501`。**成功后广播下行 `message_edited`**(§3.8,账号其它端免刷新同步)。**重发不单独设端点**——客户端用「编辑后的内容再 `POST /chat`」即可(避免重复轮次语义) |
|
||||
| 6b | `/api/v1/messages/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除单条消息**(D3)→ `{data:{deleted:<id>}}`;不存在回 `404 not_found`;未注入 HistoryStore 回 `501`。**成功后广播下行 `message_deleted`**(§3.8,账号其它端按 `id` 移除) |
|
||||
| 7 | `/api/v1/sessions/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除次级会话及其消息** → `{data:{deleted:<id>}}`;主会话不可删回 `403 forbidden`;未注入 HistoryStore 回 `501` |
|
||||
| 8 | `/api/v1/sessions/{id}` | PATCH | JWT(owner/admin) | ✅ | **会话元数据更新**(A4 改名 + D5 置顶):body `{title?,pinned?}`(**至少一个字段**;都缺回 `400 {"code":"bad_request","message":"at least one of title or pinned is required"}`——说明「缺字段」)。`title` trim、≤80 rune:**存在但为空/仅空白回 `400 "title must not be blank"`**(**不是** `title is required`——字段在、只是值非法)、超长回 `400 "title is too long (max 80 characters)"`;**空串不能清空标题**(不回落自动命名,故客户端不提供「恢复自动命名」入口);`pinned` 为 bool(置顶/取消,主会话与次级会话均可);**`personaId` 出现即 `400`**(v0.46,design/22 §8-1:**会话角色在创建时确定、之后不可变,要换角色请新开会话**)——**不是静默忽略、也不是 200 空操作**:**键出现**(任何取值:`"yuki"` / `""` / `null` / 未知 id / 空白串)一律 `400 bad_request`,错误消息点名 `personaId`,且该检查**先于**会话存在性检查(**未知会话 + `personaId` 也回 `400`**,不是 `404`;连 `title` 同传也整体 `400`、不改任何字段)。响应只回显**本次变更**的字段:`{data:{id,title?}}` / `{data:{id,pinned?}}` / 两项同改则都有(**title-only 保持 A4 原 `{id,title}` 形状**);不存在回 `404`;主会话(`id="web"`)可改名/置顶但不可删;未注入 HistoryStore 回 `501`。另:会话**首条用户消息到达时自动命名** title(取前 20 rune 折叠空白;纯空白回落 `主会话`/`次级会话`),列表不再空名 |
|
||||
| 8a | `/api/v1/sessions/{id}/read` | POST | JWT(owner/admin) | ✅ | **推进已读游标**(D4 / Android 协同 B3):body `{before?}`——`before` 为**可选**的毫秒 Unix 时间戳,**缺省(或空 body)= 现在**,即「我看到的都算已读」。→ `200 {data:{sessionId,readAt}}`(`readAt` 为推进后的游标毫秒)。**判定**:`createdAt <= readAt` 的消息算已读(列表 `unreadCount` 只数 `createdAt > readAt` 的);**语义**:**会话级单一游标、单 owner 账号**——读一处 = **该账号所有端已读**(不做 per-device 游标,与「同一账号所有浏览器都属于同一 owner」一致);**只前进不后退**(SQL `read_at = MAX(read_at, before)`),所以乱序/并发/重试请求幂等安全、绝不会把已读变回未读。`before` 为 `0`/负数/非数字、或 body 不是 JSON 对象 → `400`;未知会话(含隔离 `sess-xxx`,不在 `chat_sessions`)→ `404`;未注入 HistoryStore → `501 not_implemented`。**成功后广播下行 `session_read`**(§3.9)让同账号其它端清红点;**失败路径不广播**。**升级回填**:迁移 `0010` 把既有会话游标回填为「该会话现有最新消息时间」,升级当天不误报未读 |
|
||||
| 9 | `/api/v1/personas` | GET | JWT | ✅ | **角色目录**(任意已认证角色可读)→ `{data:[PersonaSummary...]}`,每项 `{id,name,character,isDefault,mode,memoryNamespace,avatar?,chatBackground?,mood}`。**`mood`(v0.43)** = 该角色**当前情绪状态**(角色级,取值见 `GET /meta/enums` 的 `moods`;`""` = 该角色暂无被跟踪的情绪,**不伪造默认值**)。**语义:情绪跟随角色,与会话无关**——同一角色的所有会话看到同一 `mood`;客户端登录/加载角色目录时用它**播种心情徽标**,避免刷新后短暂空白(随后以 WS `mood` 帧为准)。**不含 admin 专属的 `thinking` 旋钮**(用户侧不暴露模型调用参数)。 |
|
||||
| 10 | `/api/v1/audio/transcribe` | POST | JWT(owner) | ✅ | **语音→文本**:body `{audio:"<base64>",format?,deviceId?}` → `{data:{text, provider?}}`(走后端 STT,百炼 `qwen-audio-3.0-asr-flash`;**当结果为空文本/silent 降级时响应体带 `provider`**(如 `"silent"`,非空文本省略),客户端据此提示「没识别到声音」) |
|
||||
| 11 | `/api/v1/audio/speech` | POST | JWT(owner) | ✅ | **文本→音频**:body `{text,voice?,format?,instruction?}` → `200` 音频字节(`Content-Type: audio/<format>`,非 JSON envelope,走后端 TTS,百炼 `cosyvoice-v3-flash`,复刻音色可带 `instruction` 情绪指令;**静默降级空音频时仍 `500 tts_empty`,并附 `X-Voice-Provider: silent` 响应头**,客户端据此提示「没生成声音」) |
|
||||
| 12 | `/api/v1/audio/voice-clone` | POST | JWT(owner) | ✅ | **声音复刻建音色**:body `{url:"<公网参考音频URL>",prefix?}` → `{data:{voiceId}}`(走百炼 `voice-enrollment` create_voice) |
|
||||
| 13 | `/api/v1/upload` | POST | JWT(owner/admin) | ✅ | **图片上传**(B1):multipart/form-data,字段 `file`(raster image,5MB 上限)→ `201 {data:{url,size,mime,name}}`;`url` 为**同源访问地址**(`<host>/api/v1/files/<name>`,可直接作 `chat.image`)。校验:`MaxBytesReader` 防超大 → `DetectContentType` 嗅探(**不信客户端 Content-Type**)→ 白名单 png/jpeg/gif/webp/bmp(**拒 SVG/HTML**);文件名用 **crypto/rand 128-bit 随机名**(原始文件名丢弃),存 `<dataDir>/uploads/`(0700)。错误码:缺文件 `400 bad_request`、超限 `413 payload_too_large`、非白名单 `415 unsupported_media_type`、未授权 `401/403` |
|
||||
| 14 | `/api/v1/files/{name}` | GET | 无需(能力URL) | ✅ | **上传文件访问**(B1):从 `<dataDir>/uploads/` 读取,同名端口服务。`name` 仅接受「32 位小写 hex + 白名单扩展名」;**路径穿越全形态拒绝**(`..`/`%2f`/`%2e%2e` 等)。返回 `Content-Type`/`Content-Length`/`Cache-Control: private,max-age=86400,immutable`/`X-Content-Type-Options: nosniff`(支持 Range)。**设计取舍**:GET 不鉴权——该 URL 是**128-bit 随机能力地址**(`chat.image`/`<img>` 无法带 Bearer),POST 仍 owner/admin+审计;任何持有该 URL 者都能取文件,勿外泄到他人 |
|
||||
|
||||
| 15 | `/api/v1/meta/enums` | GET | JWT(任意角色) | ✅ | **共享枚举权威表**(B4):`{data:{platforms,moods,modes,unknown}}`,每项 `{id,label}`——`platform`/`mood`/`mode` 的**唯一事实来源**,客户端**不必硬编码**即可拿到稳定映射(来源角标 / 情绪标签不再漂移)。只读、无敏感信息,与 `GET /sessions` 同级(owner/admin/viewer 均可读;无 token 回 `401`)。详见 §1.1 |
|
||||
| 16 | `/api/v1/documents` | POST | JWT(owner/admin) | ✅ | **文档上传**(design/24 §3,**v0.59**;**v0.61 纳入 `.pdf`**):multipart/form-data,字段 `file` → `201 {data:{name,url,mime,size,sha256,deduped:false}}`;**同字节同扩展名重复上传 → `200` + 同一 `name` + `deduped:true`**(可当成功处理)。单文件上限 **`CHAT_DOCUMENT_MAX_BYTES`(默认 20 MiB,`≤0` 视为用默认)**。类型裁决 = **字节嗅探(唯一裁决)∧ 扩展名白名单(23 项,含 `.pdf`)**,自 **v0.61** 起为**成对裁决**(`.pdf` 只收嗅探为 `application/pdf` 的字节,PDF 字节也只收在 `.pdf` 名下;HTML/纯文本改名成 `.pdf` ⇒ `415`,PDF 改名成 `.txt` ⇒ `415`);`mime` 为**嗅探基类型**(`data.json` → `text/plain`,真 PDF → `application/pdf`);`url` 为**相对路径** `/api/v1/documents/<32hex>.<ext>`。错误码:`415 unsupported_media_type` / `413 document_too_large` / `400 bad_request`(缺 `file` / 空文件)。详见 §1.2 |
|
||||
| 17 | `/api/v1/documents/{name}` | GET | **JWT(owner/admin)** | ✅ | **文档读取**(**v0.59**;**v0.61 增 PDF MIME**):**必须带 Bearer**——与图片读侧「128-bit 随机名即凭据、免鉴权」**刻意相反**;`name` 仅接受「32 位小写 hex + 白名单扩展名」,**路径穿越全形态拒绝**;响应带 `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff` + `Cache-Control: private,max-age=86400,immutable`;嗅探不出放行类型时下发 `application/octet-stream`(真 PDF 按成对裁决如实下发 `application/pdf`,仍因 `attachment` + `nosniff` 永不可内联);名字非法 / 逃出目录 / 文件不存在统一回 `404 document_not_found`(不泄露差异)。详见 §1.2 |
|
||||
|
||||
> **语音 REST 源(`/api/v1/audio/*`,均 owner-only + 审计)**:原生/移动端用它上传/获取音频,**绕开 WS 4KB 帧限**。
|
||||
> - `POST /api/v1/audio/transcribe`:body `{audio:"<base64>", format?:"wav", deviceId?}` → `200 {data:{text}}`。`audio` 为 base64(或 data: URI);`format` 为编码(`wav`/`mp3`/`ogg`,缺省 `wav`);空 audio 回 `400`;未注入语音服务 `500 voice_unavailable`、识别失败 `500 stt_failed`。
|
||||
> - `POST /api/v1/audio/speech`:body `{text, voice?, format?, instruction?}` → `200` 原始音频字节(`Content-Type: audio/<format>`),**非 JSON envelope**。`voice`/`format` 目前遵循服务端 `VOICE_TTS_VOICE_ID`/`VOICE_TTS_FORMAT` 配置(后续支持按请求覆盖);`instruction` 为**可选** per-request 情绪/语气指令(如 `"语气开朗俏皮,像女朋友撒娇"`),只对**复刻/设计音色**生效——**系统音色 `longxiaochun_v3` 被服务端忽略,永不发送自由指令**(CosyVoice 系统音色需固定格式);长度 ≤100 字符(汉字记 2);未传时回退到服务端 `VOICE_TTS_INSTRUCTION`。空 text 回 `400`;未注入语音服务 `500 voice_unavailable`、合成失败 `500 tts_failed`、静默空音频 `500 tts_empty`。
|
||||
> - `POST /api/v1/audio/voice-clone`:body `{url:"<公网参考音频URL>", prefix?:"cyrene"}` → `200 {data:{voiceId}}`。`url` 为**公网可访问的参考音频 URL**(create_voice **只收 `input.url`,不收 base64**),音频要求 10~20s 连续清晰朗读、≤10MB、≥16kHz;`prefix` 为音色名前缀(**≤10 字母/数字**,缺省 `cyrene`)。返回的 `voiceId` 填到 `VOICE_TTS_VOICE_ID` 即可用该复刻音色合成(`target_model` 固定为 `VOICE_TTS_MODEL`,须与合成模型一致)。**URL 不写审计/日志(红线,绝不打日志)**。空 url 回 `400`;未注入语音服务 `500 voice_unavailable`;建音色失败 `500 voice_clone_failed`。**⚠️ 真建音色需主人提供公网参考音频 URL(当前无,断言待提供);且需北京地域 API Key(CosyVoice 声音复刻仅华北2北京地域)。**
|
||||
|
||||
### 1.1 枚举权威表(`GET /api/v1/meta/enums`,B4)
|
||||
|
||||
> **背景**:`platform` / `mood` / `mode` 此前只在文档里给示例(`web`/`telegram`/…;`calm`;`normal`),客户端只能自建映射表——来源角标与情绪标签因此会**随客户端漂移**。本端点发布**权威枚举表 + 未知值回落约定**:Web / Android / IM bridge **共用同一份**,客户端不必硬编码。
|
||||
|
||||
- **鉴权**:`Authorization: Bearer <JWT>`,**任意已认证角色可读**(`owner`/`admin`/`viewer`),与 `GET /api/v1/sessions` 同级——只读、无敏感信息。无 token / 令牌无效回 `401`。无其它错误码(纯内存投影,无存储、无 501)。
|
||||
- **响应**:`200`
|
||||
```json
|
||||
{"data":{
|
||||
"platforms":[{"id":"web","label":"网页"}, ...],
|
||||
"moods":[{"id":"calm","label":"平静"}, ...],
|
||||
"modes":[{"id":"chat","label":"对话"}, ...],
|
||||
"unknown":"display-raw"
|
||||
}}
|
||||
```
|
||||
每项固定为 `{"id":"<枚举 id>","label":"<默认中文标签>"}`。`label` 是**默认值**,自带 i18n 的客户端可覆盖显示;`id` 才是必须比对的值。
|
||||
- **`unknown`(未知值回落约定,机器可读)**:恒为 `"display-raw"`。语义见下。
|
||||
|
||||
**platform(消息来源平台,`{id:label}` 逐值)**
|
||||
|
||||
| id | label | 说明 |
|
||||
|----|-------|------|
|
||||
| `web` | 网页 | Web 主会话来源(`chat_messages.platform` 默认值) |
|
||||
| `kook` | KOOK | KOOK 适配器 |
|
||||
| `qq-obv11` | QQ | OneBot v11(QQ)适配器 |
|
||||
| `weixin-ilink` | 微信 | 微信 iLink 适配器 |
|
||||
| `telegram` | Telegram | Telegram Bot API 适配器 |
|
||||
| `webhook` | Webhook | 通用 Webhook 适配器 |
|
||||
|
||||
**mood(情绪,`{id:label}` 逐值)**
|
||||
|
||||
| id | label |
|
||||
|----|-------|
|
||||
| `calm` | 平静 |
|
||||
| `happy` | 开心 |
|
||||
| `excited` | 兴奋 |
|
||||
| `sad` | 低落 |
|
||||
| `angry` | 生气 |
|
||||
|
||||
> ⚠️ 情绪状态机**可扩展**(persona 可注册更多状态),因此上表**不是封闭集合**——未知 mood 必须按回落约定原样显示。
|
||||
|
||||
**mode(行为/呈现模式,`{id:label}` 逐值)**
|
||||
|
||||
| id | label |
|
||||
|----|-------|
|
||||
| `chat` | 对话 |
|
||||
| `code` | 代码 |
|
||||
| `home` | 家居 |
|
||||
| `work` | 工作 |
|
||||
| `docs` | 文档 |
|
||||
| `voice` | 语音 |
|
||||
| `vision` | 视觉 |
|
||||
| `agents` | 智能体 |
|
||||
| `settings` | 设置 |
|
||||
|
||||
**未知值回落约定(红线)**
|
||||
|
||||
- 客户端遇到**不在表中**的取值(`platform` / `mood` / `mode` 任一):**原样显示该原始字符串**——**不丢弃、不报错、不替换占位符、不猜测**。
|
||||
- **服务端新增枚举取值是非破坏性变更**(forward-compatible):表中新增一项不改变既有字段/端点形状,旧客户端无需升级;**不要**因为读到未知值而清空角标、抛异常或中断渲染。
|
||||
- 判定**只比对 `id`**(大小写敏感,契约 id 为小写);`label` 仅作显示默认值。
|
||||
- Web 端镜像实现在 `frontend/web/src/lib/enums.ts`(`labelFor(kind, value)` 对未知值返回原值),权威仍以本端点为准。
|
||||
- **反漂移保证(后端)**:后端有测试断言「每个内置适配器的 `Name()` 都在 `platforms` 表内」「`moods` 与 persona 情绪状态机的内置状态一致」「`modes` 与前端 `MODES` 九模式一致」——漏登记即 CI FAIL,客户端拿到的表不会滞后于代码。
|
||||
|
||||
### 1.2 文档存储与摄取契约(v0.61,design/24 §3/§4/§6 + §7 S6 第一段)
|
||||
|
||||
> **一句话**:文档 = 「内容寻址的存储 + 服务端抽取 + 清单/正文注入」三段;**读侧要 Bearer**(与图片读侧刻意相反),客户端只需要**两个端点 + `chat.documents` 一个字段**,外加一个 `documents` 数组 DTO。
|
||||
|
||||
**① 上传 `POST /api/v1/documents`(owner|admin + 审计 `document.upload`)**
|
||||
|
||||
- 请求:`multipart/form-data`,字段 `file`(单个)。**上限** `CHAT_DOCUMENT_MAX_BYTES`(默认 **20971520 = 20 MiB**;`≤0` 视为用默认,**不存在「禁用上限」的取值**);请求体额外留 1 MiB multipart 余量,**文件本身按确切上限校验两次**(部件大小 + 流式写入时多读 1 字节)。
|
||||
- 响应:
|
||||
|
||||
```json
|
||||
{"data":{"name":"a1b2…(32位hex).md","url":"/api/v1/documents/a1b2….md","mime":"text/plain","size":12345,"sha256":"<64位hex>","deduped":false}}
|
||||
```
|
||||
|
||||
首次 `201` + `deduped:false`;**同字节同扩展名重复上传 → `200` + 同一个 `name` + `deduped:true`**(落盘名 = `sha256(bytes)[:32] + ext`,**内容寻址**;客户端原始文件名只用来取扩展名,**永不参与落盘路径**)。**200 可当成功处理**——内容与首次逐字一致。
|
||||
- **`url` 是相对路径**(`/api/v1/documents/<32hex>.<ext>`),**不是**图片侧那种绝对同源 URL:读侧要 Bearer,贴进 `<img>` 也取不到;客户端真正要用的是 **`name`**(`chat.documents` 只吃它)。
|
||||
- **类型裁决(唯一裁决权 = 字节嗅探;v0.61 起为成对裁决)**:
|
||||
- **扩展名白名单(deny-by-default,23 个)**:`.txt .md .markdown .csv .tsv .json .jsonl .yaml .yml .log .ini .cfg .toml .go .ts .tsx .java .c .h .cpp .rs .sql`,**外加 `.pdf`(v0.61 新增,第 23 项)**;
|
||||
- **字节嗅探(`http.DetectContentType`)与扩展名成对判定**(代码里就一个裁决函数 `documents.ContentAllowed`):**纯文本类**(上列除 `.pdf` 外的全部文本类扩展名)嗅探基类型必须恰为 **`text/plain`**;**`.pdf`** 嗅探基类型必须恰为 **`application/pdf`**(PDF 除外说明:它是白名单里**唯一**签名与纯文本不同的格式);
|
||||
- 两者是**合取**:`.js` / `.py` 即便嗅探成文本也拒(不在白名单);改扩展名也换不了类型(**嗅探说了算**),且自 v0.61 起**双向**——**HTML/纯文本改名成 `.pdf` ⇒ `415`**(嗅探不是 PDF),**PDF 改名成 `.txt` ⇒ `415`**(PDF 字节也只收在 `.pdf` 名下);
|
||||
- **明令拒绝**:`text/html`、`image/svg+xml` 与 XML、`application/zip` 及其封装(docx/xlsx)、可执行/脚本(exe/dll/bat/cmd/ps1/js/mjs/sh/py)、判不定者;
|
||||
- **`mime` 是嗅探所得基类型**(去掉 `; charset=…`):`data.json` 的 `mime` 是 `text/plain`,**不是** `application/json`;真 PDF 的 `mime` 是 `application/pdf` —— 客户端按 `name` 的扩展名判语义,别拿 `mime` 当类型。
|
||||
- 错误码:`415 unsupported_media_type`(**扩展名白名单(23 项)与字节嗅探的成对裁决**任一不过——如 HTML 改名成 `.pdf`、或 PDF 改名成 `.txt`)/ `413 document_too_large`(文件或 multipart 体超限)/ `400 bad_request`(缺 `file` 部件、空文件、非 multipart)。**审计**:成功 `document.upload`+`ok`、失败 +`error`(成对发生,失败分支不遗漏)。
|
||||
|
||||
**② 读取 `GET /api/v1/documents/{name}`(owner|admin,无审计)**
|
||||
|
||||
- **必须带 `Authorization: Bearer <JWT>`**:无 token → `401`、角色不足 → `403`。这是与 `GET /api/v1/files/{name}`(免鉴权能力 URL)的**关键差异**——文档摄取在服务端完成,不需要被 `<img>` / provider 直接消费。
|
||||
- `name` 形态 = `32 位小写十六进制 + '.' + 白名单扩展名`;**路径穿越全形态拒绝**(`..` / `%2e%2e` / `%2f` 等)。
|
||||
- 响应头:`Content-Type` = **读时重新嗅探**的基类型(**同一对裁决**:纯文本类 → `text/plain`;**真 PDF 如实下发 `application/pdf`**;嗅不出放行类型 → `application/octet-stream`)、`Content-Disposition: attachment; filename="<name>"`、`X-Content-Type-Options: nosniff`、`Cache-Control: private, max-age=86400, immutable`(内容寻址 ⇒ 名字变则内容变);支持 Range。⚠️ **PDF 也永不内联**:`attachment` + `nosniff` 一字未变,浏览器不会把 `.pdf` 当插件文档渲染。
|
||||
- 错误:`404 document_not_found` —— **名字非法 / 路径逃逸 / 文件不存在三种情况同一响应**(不泄露「是否存在」以外的信息)。
|
||||
|
||||
**③ `chat.documents`(REST 同步 / REST `stream:true` / WS `chat` 三处同形)**
|
||||
|
||||
```json
|
||||
{"sessionId":"web","text":"帮我看看这份","documents":[{"name":"a1b2….md"},{"name":"c3d4….csv"}]}
|
||||
```
|
||||
|
||||
- **只给 `name`**(服务端存储名)。客户端多带的 `mime` / `url` / `size` **一律被服务端覆盖**(MIME 与大小取自落盘字节);**绝不接受任意 http(s) URL**——不重开 F3 那种「服务端代取 URL」的 SSRF 面。
|
||||
- **≤5 条/回合**:超出 → `400 too_many_documents`,且该判定**先于任何文件系统访问**。
|
||||
- 名非法 / 穿越 / 文件不存在 → `404 document_not_found`;**任一条不合规都让整个请求被拒**——绝不「丢掉这条附件但照样起回合」(静默丢附件正是 design/24 §4.4 禁止的「传上去了但双方都不知道」)。WS 侧经 `error` 帧带 `data.code` 下发并中止本回合。
|
||||
- WS 上行帧仍有 **4096 字节**硬上限 ⇒ 现实中文档走 REST;两处**共用同一份解析与校验**,不会分叉。
|
||||
- 附件部件挂在**同一条 user 消息**上;**纯附件回合**(无文本、无图片)同样成立,不会被判成空回合。
|
||||
|
||||
**④ DTO:`documents` 数组(历史与实时同形)**
|
||||
|
||||
`GET /sessions/{id}/messages`、`message_persisted`、`channel_persisted`、`message_edited`、握手 `history` 回放的 `historyMessage` 上是**同一个键**(同一结构体):
|
||||
|
||||
```json
|
||||
"documents":[{"name":"a1b2….md","mime":"text/plain","size":12345,"chars":8000,"truncated":false,"status":"extracted"}]
|
||||
```
|
||||
|
||||
读不到时(v0.63)多一个**可选** `reason`:
|
||||
|
||||
```json
|
||||
"documents":[{"name":"a1b2….pdf","mime":"application/pdf","size":2048,"status":"unreadable","reason":"无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)"}]
|
||||
```
|
||||
|
||||
- **≤5 条**,顺序 = 客户端发送顺序;**0 个附件时该键省略**(不是 `[]`,与引入文档之前的形状一致)。
|
||||
- `status` ∈ `extracted`(正文已进上下文)/ `listed`(只给了模型一份附件清单,正文没进上下文)/ `unreadable`(读不到正文)。**PDF(v0.61)**:**文本层 PDF ⇒ `extracted`**;**扫描件/图片型 PDF(无文本层)⇒ 诚实拒绝 `unreadable`**,原因文案含「这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)」——**绝不假装成功、也绝不返回空正文**;**加密 PDF** 与**损坏/截断 PDF** 是**可区分**的两类失败(加密的提示解除密码后重试)。
|
||||
- **`reason`(v0.63,可选,`omitempty`)**=**读不到的原因文案**,**只在 `status:"unreadable"` 时下发**(`extracted` / `listed` 时该键**缺席**,客户端按「缺失 ⇒ 回落现有通用句」实现即可)。它**逐字就是服务端注入给模型的那份原因**(同一字符串,两处同源),因此客户端**不必自己拼**「图片型 PDF,需要 OCR」这类服务端没说过的话。当前文案集合(服务端常量,逐字):
|
||||
- `无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)`(扫描件/图片型 PDF,`ErrNoTextLayer`)
|
||||
- `无法抽取正文:PDF 已加密(无法用空口令打开),请解除密码后重新上传`
|
||||
- `无法抽取正文:PDF 损坏或无法解析`
|
||||
- `无法抽取正文:本期不支持 Word/Excel/PPT 解析(无文本层)`(Office)
|
||||
- `无法抽取正文:内容不在可抽取的纯文本范围内`(非文本/二进制)
|
||||
- `无法抽取正文:文档文件不存在或已被清理` / `无法抽取正文:读取文档失败` / `无法抽取正文:文档引用形态非法` / `无法抽取正文:文档上传目录未配置`
|
||||
- `无法抽取正文:读取或解析失败`(解析器整条消息失败时的诚实兜底)
|
||||
⚠️ **只放文案**:不含落盘路径、不含凭据、不含存储名以外的内容;原始错误(可能带路径)**只进服务端日志**,绝不透出(有端到端用例用「独特临时目录名」做反证断言)。
|
||||
⚠️ **时点**:原因与 `status`/`chars`/`truncated` 同批,由**回合结束后**的回写写进 `document` 列(S3.5)——因此它**只在读取面**(`GET /sessions/{id}/messages`、握手回放)可见;`message_persisted` 帧在用户消息落库时就发出(那时还没有结论,这与 `status` 的既有行为一致)。
|
||||
- ⚠️ **`chars` / `truncated` 是「本回合注入侧」事实**:`chars` = **本回合注入**模型上下文的字符数、`truncated` = **注入**是否被截断。**它不是「文件有多少字」、也不是「文件是否被截断」**——全文长度只有 `document-read` 的 `totalChars` 能看到;「抽取期撞上 8 MiB 安全上限」只有 `extractTruncated` 能看到。
|
||||
- **不含正文**(结构上保证:正文若从历史回传,每翻一页都要背着它)。
|
||||
- ⚠️ **破坏性提示**:旧文档里出现过单数 `document`(对象)的形状,**但那个形状从未随任何已发布契约版本号发布过**(客户端无从实现)⇒ 本版**不留兼容期**——客户端按 `documents`(数组、可缺失)读取即可。
|
||||
|
||||
**⑤ 内置工具 `document-read`(模型侧工具;客户端一般只需知道它存在)**
|
||||
|
||||
- 注入被截断时,模型可见的清单行给出**续读游标**(`offset=N`),模型用 `{name, offset?, limit?}` 继续读;
|
||||
- 返回(JSON 文本):`{name, status, offset, limit, totalChars, truncated, nextOffset, extractTruncated?, text}`;
|
||||
- `limit` 默认 **8000** 字符、上限 **64K** 字符(`≤0` 按缺省、超上限**夹取**);
|
||||
- `offset` 单位是**字符(rune)**、**0 基**、起点 = sidecar 正文(**含 CSV/TSV 摘要行**)第 0 个字符;
|
||||
- `offset < 0` 或 `offset > totalChars` → 工具错误(`offset == totalChars` 合法,读到末尾的空片段);`truncated=true` 时 `nextOffset` 是续读起点;
|
||||
- **`extractTruncated=true` 专指「抽取期撞上 8 MiB sidecar 安全上限」**——此后「正文到此为止」≠「原文件到此为止」(它是与「本回合注入被截断」**完全不同的两件事**)。
|
||||
- **PDF 文本层(v0.61,切片 B1)**:**文本层 PDF 抽正文**(页内容流里的 `Tj`/`TJ`/`'`/`"` 文本算子,页与页之间以换行相接),与纯文本类走**同一条** sidecar / 注入路径;**扫描件/图片型 PDF ⇒ `unreadable`**(见上 ④,需要 OCR,当前未做);**加密**与**损坏/截断**各自成类、可区分。**安全阀**:单份最多抽 **5000 页**、sidecar 正文上限仍是 **8 MiB**(撞上即 `truncated:true`)。**⚠️ 客户端可见的一次性重算**:抽取器版本内部升到 **`documents/v3`** ⇒ **旧 sidecar 一次性失效重抽**,同一份**旧附件**的 `chars`/`truncated` 结论会在**某次回合之后**重新计算(原先被诚实拒为 `unreadable` 的旧 PDF,若是文本层 PDF 就会变成 `extracted`)——这不是附件被改写,是结论被重算。
|
||||
- **只读 sidecar、绝不重抽**:读取不会打乱 `status`/`chars`/`truncated` 的持久统计;原文件在抽取之后被删除也不影响读取。
|
||||
- **策略默认 owner-only**(`tool_policies` 无行时 fail-closed):群聊/陌生人**根本调不到**——是安全增益。
|
||||
- **失败必须可见**:名非法 / 没有抽取结果 / 抽取结论为 `unreadable` → 明确报错,绝不返回空正文假装成功。
|
||||
|
||||
**⑥ 与图片链路的边界(一张表)**
|
||||
|
||||
| 面 | 图片(B1) | 文档(v0.59) |
|
||||
|---|---|---|
|
||||
| 上传 | `POST /api/v1/upload`,字段 `file`,5 MiB | `POST /api/v1/documents`,字段 `file`,20 MiB(`CHAT_DOCUMENT_MAX_BYTES`) |
|
||||
| 读侧鉴权 | **无**(128-bit 随机名即能力 URL) | **必须 Bearer(owner\|admin)** |
|
||||
| 读侧响应 | 按扩展名回 image MIME,可内联 | `Content-Disposition: attachment` + `nosniff`,**永不内联** |
|
||||
| 响应 `url` | **绝对**同源 URL | **相对路径** |
|
||||
| 客户端字段 | `chat.image`(base64 / URL) | `chat.documents[].name`(服务端存储名) |
|
||||
| 去重 | 不适用(每次新随机名) | **内容寻址**:同字节同扩展名 → 同一 `name` |
|
||||
|
||||
> **发现的不一致(如实登记,2026-10-04 复核)**:design/24 §9.2 与 §9.13 写「白名单 **22** 项——原文的 23 项是笔误,以代码为准」,但 **B1(commit `f82c76d`)纳入 `.pdf` 后代码已是 23 项**(`internal/runtime/documents/store.go` 的 `ExtAllowed` = 22 个文本类 + `.pdf`;`internal/services/gateway/documents.go` 的名字形态校验经它判定)——**以代码的 23 项为准**;design/24 §9.2/§9.13/§9.4 第 1 条(「不引解析库」)**均已滞后于 B1 实现**(PDF 文本层已用纯 Go 解析库落地;扫描件与 Office 仍诚实拒绝),本次只改 Docs、**未改 DevDocs**。另:design/24 §6.2 的 `document_unreadable`(400) **在代码里没有对应 REST 错误码**,它只以 `document-read` 的工具错误形态存在(见本节 ⑤),客户端可在 DTO 里看到该文档的 `documents[].status = "unreadable"`。
|
||||
|
||||
> **契约版本 `v0.65`**(**忙时并行回合基础管线(I1′ 有条件放宽)+ 六条登记**,依据 design/15 §2.5(主人 2026-10-05 裁决 B)与 commit `89e13cf`,并一并登记 `679cee0`(IM 入站附件引用落库)/ `59a1ac4`(`llm_usage.session_id` 接线)/ `a918819`(IM 各渠道语音可被读到)与 `cb564fe` / `15181e7` / `7d2ff74`(纯内部修,无契约变化),2026-10-05 落实)。**一句话**:会话**正忙**时,客户端可**显式声明**把新消息作为**独立一路**立即起(单会话默认 **2** 路、硬上限 **3**、`<2` = 关闭真并行),忙态帧因此开始报「在途路数」;同版把三条过期陈述与两条 IM 链路事实拉回与代码同步。**三档标注**:**纯增量** = ① 上行 `chat.parallel?:bool`(WS `chat` 帧 + REST 三入口**同形**)、② `session_busy` / `session_idle` / `pending_drain` / `session_interrupted` 的 `data` 增 `activeTurns:int`、③ `session_interrupted.data` 增 `cancelledTurns:int`、④ `pending_ack.data` 增**可选** `reason:"parallel_limit"`、⑤ REST 两条新应答形状(同步 `202 {data:{parallel:true,turnId}}` / `stream:true` 的 `200 {data:{stream:true,parallel:true,turnId}}`)、⑥ 新 env 键 `VOICE_STT_TIMEOUT_SEC`(默认 20s);**行为变更** = ⑦ IM 入站附件引用**真的写进落库的用户消息**(`documents` 列,**同一 DTO、无新字段**)、⑧ **IM 各渠道语音进上下文**(云端 ASR 转写为文本;不成功时落一行中文诚实提示,**绝不静默/空消息**)、⑨ **`session_idle` 时点收紧**为「所有在途路收尾且 pending 空」(**仅并行时生效**,单路逐字不变);**破坏性** = 无(**省略 `parallel` / 不读新键的客户端行为逐字不变**)。**A. `chat.parallel`**——`POST /api/v1/chat` body 与 WS `chat` 帧 `data` 同形新增可选 `parallel:bool`(`omitempty`),语义 = 「把本条作为**独立一路**处理」;**只在会话已有在途回合、且并发额度未满时照准**,**空闲时无效果**(照常起第 1 路),**服务端不做任何启发式自动升级**(触发权在客户端,design/15 §2.5.1)。**B. `activeTurns` / `cancelledTurns`**——均**纯增量整数**、落在既有「状态机信号」白名单帧内(不含内容,所有已认证连接含 `viewer` 都收得到);`activeTurns` = 该会话当前**在途路数**(含正在收尾的路;单路时非 0 即 1),`cancelledTurns` = 本次真的取消了几路。**C. `pending_ack.reason`**——**只在「客户端显式声明了并行、本条却落回排队」时出现**(额度已满,**或**真并行被 `<2` 关掉——客户端问了并行而没拿到),其它情况该键**完全缺席**(绝不伪造)。**D. REST 新应答形状**——**只在 `parallel:true` 且被照准时才可能出现**;`turnId` 就是那一路的权威回合标识、与该路全部流帧同值;**未照准**(未声明 / 空闲 / 额度满)时形状与既有逐字相同。**E. `interrupt` 多路口径**——`scope:"current"` 省略 `turnId` 时取消的是**「当前全部在途路」**(不是只停一路);`scope:"all"` 取消全部在途路 + 清队列、且**不发 `session_idle`**(与今天一致,客户端靠 `session_interrupted` 收口);`interrupt.turnId`(精确停某一路)**本版未实现**。**F. `turnId` 生成方变更**——由「网关每回合内部新生成」改为「**编排器生成 → loop 复用**」;帧形状与取值分布不变(单路仍是每回合全新的 `turn-<hex>`)⇒ **对外无可见差异**,写在此处只为让「谁生成」的口径与实现一致。**G. IM 附件引用落库(`679cee0`)**——此前 IM 路径只把引用挂在**内存里的模型可见消息**上、落库那一步没写 `documents` 列 ⇒ 客户端读到的那条消息**一片空白**(安卓报的 P0);现在复用 `history.EncodeDocuments` 同一口径写该列,并在回合结束后按 id 回写抽取结论。**契约零扩张**:无新端点 / 帧 / 字段;**0 个附件 ⇒ 键省略**;形状即 Web 的**同一个结构体**。**H. IM 语音(`a918819`)**——入站语音在流水线**最前端**归一为文本:**平台自带转写优先**(微信 iLink 非空 ⇒ 不调 ASR,不重复付费),否则走云端 ASR;**降级绝不静默**——静默档位被识破(只认 `STTOutcome.Transcribed()`),鉴权失败 / 欠费 / 限流 / 超时按原因分类并做**临时熔断 + 退避**(TTL 恢复),无论是否熔断,消息面都落**一行中文诚实提示**。**新 env 键 `VOICE_STT_TIMEOUT_SEC`**(默认 **20** 秒;`≤0` 用默认):**只约束 IM 渠道入站语音的这一次转写**(流水线要为它一直举着一个入站并发槽),**Web 客户端的 `voice_input` 不受它约束**;**无新端点 / 新帧**。**I. `llm_usage.session_id` 修正(`59a1ac4`)**——v0.64 的「Line truncated
|
||||
|
||||
> **上一版 `v0.64`**(**LLM 用量落库 + 只读汇总端点 + 面板用量小卡**,依据主人 2026-10-04 夜裁决「**2.4 落**」(在「usage 落库」「只看日志」之间选前者)与 T1(commit `72b95df`,已在每次模型调用后记结构化用量日志但**只落日志**)。**一句话**:把那条日志里的**非敏感**样本落进新表 `llm_usage`(迁移 `0017`),并新增一个只读汇总端点与一张面板小卡 —— 从此看得见趋势与按用途/模型的成本,而**写入绝不阻塞模型调用**。**三档标注**:**纯增量** = 新端点 `GET /api/v1/admin/llm/usage`、新迁移 `0017_llm_usage`、新 env 键 `LLM_USAGE_RETENTION`(默认 `720h`,**≤0 = 关闭清理**)、面板 LLM 页新小卡、前端新 API `adminLlmUsage()`;**行为变更** = 无(既有端点 / 帧 / 字段形状一字未动;`model` 层新增的是**可选** sink,**不注入时行为逐字不变**——既有 model 用例零改动即全绿);**破坏性** = 无。**A. 写入(关键取舍)**:`model` 层**不直接开库**,只在既有的 `logUsage` 处多一个**可选**接口 `model.UsageSink`(`nil` = 只记日志,逐字等于引入本版之前);组合根注入 `model.UsageWriter` —— **有界队列(1024)+ 单写者 + 批量事务(128/批,2s 兜底)**,队列满**丢弃并限流 WARN(30s 一条)**,**绝不阻塞、绝不 panic、绝不无界增长**;整批落库失败只记一条带来源的 WARN。sink 挂在 `RuntimeLLM` 上(而不是每个适配器上),因此面板每次热重载 models.json 重建适配器时,落库**不会静默断一次**;**每一条链**(主/备/本地第三级/按角色跨协议)都带上它。**B. 表与索引**:`llm_usage`(`id/session_id/purpose/adapter/model/prompt_tokens/completion_tokens/cache_hit_tokens/cache_miss_tokens/reasoning_tokens/total_tokens/took_ms/created_at`)+ `idx_llm_usage_created_at`(汇总与清理的依赖)+ `idx_llm_usage_session_id`。**`session_id` 当前恒为 `NULL`**(模型层无会话概念;要填需 loop 在请求上打标,属本轮并行切片在途文件,故留列后接)。⚠️ **该状态已由 v0.65 更正**:loop 已把会话 id 播种进运行 ctx、适配器落库时带上它(见 §1.2 与 §5 v0.65 条目 I)——**该列不再恒为 `NULL`**。**C. 读取**:`GET /api/v1/admin/llm/usage?since=&until=&purpose=`(**owner|admin 读**,与同页 `/admin/llm` 同门;**不审计**——它是查询不是动作)→ `{data:{window:{since,until,purpose},totals:{calls,prompt,completion,cacheHit,cacheMiss,hitRate},byPurpose:[…],byModel:[…]}}`;缺省窗口 = **近 24h**,跨度上限 **90 天**(超过 → `400`,**不静默裁剪**);`since`/`until` 接受 **RFC3339 或 Unix 毫秒**;**`hitRate` = cacheHit/(cacheHit+cacheMiss),分母 0 ⇒ `0`**(口径已写进文档与前端展示规则:分母 0 显示 `—`,不显示 `0%`);分解按**调用次数降序、同次数按键升序**;**绝不含凭据与消息正文**。**D. 保留期**:`LLM_USAGE_RETENTION`(默认 `720h` = 30 天;**≤0 = 关闭清理**,日志写明)→ 启动期清一次 + 每小时清一次(复用文档 GC 的既有模式;清扫间隔不做成 env 键)。**E. 面板**:LLM 页新增「用量(近 24h)」小卡(`frontend/web/src/admin/modules/llm/LlmUsageCard.tsx`)——调用数 / 输入 token / 缓存命中率;取数与其它卡片同一条 `useAsyncData` 加载/错误态(**读不到显示错误,绝不显示 0**)。**F. 失败不伪装**:汇总出口未接线或查库失败 → `500 usage_unavailable`,**不回全 0 假汇总**。详见 §2「LLM 用量汇总契约(v0.64)」。
|
||||
|
||||
> **上一版 `v0.63`**(**客户端登记两条落地:`documents[]` 增可选 `reason` + `GET /sessions/{id}/messages` 对不存在的会话回 `404 session_not_found`**,依据 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的「登记(客户端会话,2026-10-05 凌晨 · 跟上 v0.61 的 `.pdf` 之后的两条)」与「登记(… · 一条 API 语义建议:**已删除的会话「看起来像空会话」**)」两条,以及后端「回执九」(2026-10-05 凌晨)的选择与承诺,2026-10-05 落实)。**三档标注**:**纯增量** = ① `documents[]` 元素增**可选** `reason`(`omitempty`,只在 `status:"unreadable"` 时下发;不读该键的老客户端行为**逐字不变**);**行为变更** = ② `GET /api/v1/sessions/{id}/messages` 对**不存在 / 已删除**的会话由 `200 {"data":[]}` 改为 **`404 {"error":{"code":"session_not_found"}}`**(**客户端需处理 404** —— 回执九已告知,客户端此前先用自建兜底);**破坏性** = 无(既有字段形状、既有成功响应一字未动)。详见 §1 行 6、§1.2 ④ 与 §5 变更记录。
|
||||
|
||||
> **上一版 `v0.62`**(**面板「重置为 env 默认」动作落地:materialize 的反方向显式动作 + 一个新的 owner-only 管理写端点**,依据任务书 `DevDocs/_dispatch/R1-面板重置为env默认.md`、design/04 §3.1(`.env` 为默认/播种层、面板文件为一等来源)与 `DevDocs/开发时间线.md` 的欠账项(「Materialize 已导出、未接端点」),2026-10-04 落实)。**一句话**:materialize 是「面板缺该键才用 `.env` 补」,本版新增的 `POST /api/v1/admin/llm/reset-from-env` 是**反方向的显式动作**——部署者要求「把面板里 `.env` 声明过的键退回 env 值」。**A. 语义(与补齐表同源)**:只覆盖 `.env` 声明了非空值的键(`provider`/`baseUrl`/`apiKey`/`model`/`contextWindow`)、**env 未声明的键一律保留面板值**(`fallback`/`multimodal`/`maxTokens`/`reasoning`/`personaModels` 永不被改)、**组集合不增减**(不复活被删的组、不删新增的组,顺序也不动)、面板值已与 env 一致的键不算「改回」、**无键可改时不写盘**(幂等);真的改了盘就**热重载**运行中的 LLM 栈(`contextWindow` 的预算推导仍只在启动时算一次)。**B. 护栏 / 响应 / 审计**:**owner-only**(与同页两条 LLM 写端点同门;破坏性写不比同族更宽),body 必须 `{"confirm":true}`(缺失 / `false` / 非法 body ⇒ `400 bad_request` 且**一个字节都不动**)→ `200 {data:{reset:[{group,keys:[…]}], skipped:[{group,reason}], path}}`;**只回键名与中文原因,凭据值(尤其 `apiKey`)绝不回显、绝不进日志**;审计动作 `llm.resetFromEnv`(`ok`/`error`)。**C. 基准与失败**:对照基准 = 请求时刻按**既有解析入口**读到的 `.env` 默认层(新增 `config.LoadLLMDefaults`,路径经 `Deps.EnvPath` 注入,**网关绝不 fopen `.env`**);`.env` 源不可读 → `500 env_defaults_unreadable`、面板配置不存在/不可解析 → `500 models_unreadable`、写盘失败 → `500 llm_persist_failed`、已写盘但热重载失败 → `500 llm_reload_failed`、LLM 栈未注入 → `500 llm_unavailable`——**都不假装成功**。**D. 面板**:LLM 页新增「重置为 env 默认」卡片(`admin/modules/llm/LlmResetFromEnv.tsx`,行上标 `owner 专属`,**中文二次确认**后才提交)。**E. 兼容性**:**纯增量**(新端点 + 新前端卡片 + 新审计动作 + 2 个新错误码 `env_defaults_unreadable` / `models_unreadable`,另复用既有 `llm_persist_failed` / `llm_reload_failed` / `llm_unavailable`),既有端点 / WS 帧 / 字段形状**一字未动**;管理侧权限矩阵因此为 12(viewer 只读)+ 10(owner/admin 读)+ **38**(owner-only)= **60** 条路由。详见 §2「重置为 env 默认契约(v0.62)」。**上一版 `v0.61`**(**PDF 文本层抽取落地:白名单第 23 项 `.pdf` + 类型裁决由「嗅探只放行 `text/plain`」改为 `(扩展名, 嗅探)` 成对裁决**,依据 design/24 §7 的 **S6 第一段**(PDF/Office 的裁决:PDF 文本层引库,扫描件与 Office 仍诚实拒绝)与 commit `f82c76d`,2026-10-04 落实)。**三档标注**:**行为变更** = ① **`.pdf` 由「一律 `415`」变为「可上传 + 可读取 + 文本层可抽取进上下文」**(把「`.pdf` 必被拒」写死的客户端必须改);② **类型裁决收紧为成对判定**(`documents.ContentAllowed`)——**方向是收紧/细化,不是放宽**:HTML/纯文本改名成 `.pdf` ⇒ `415`(嗅探不是 PDF),**PDF 字节改名成 `.txt` ⇒ `415`**(PDF 字节也只收在 `.pdf` 名下);**纯增量** = 扩展名白名单 **22 → 23 项**(唯一新增 `.pdf`),其余 22 个文本类的判定与既有响应**一字未变**。**A. 上传(`POST /api/v1/documents`,owner|admin)**——白名单加 `.pdf`;裁决 = **字节嗅探是唯一裁决权 ∧ 扩展名白名单**,自本版起**按对判定**(纯文本类必须 `text/plain`、`.pdf` 必须 `application/pdf`;落点 `internal/runtime/documents/store.go` 的 `ExtAllowed`(23 项)/ `ContentAllowed`);`mime` 仍是**嗅探基类型**(真 PDF → `application/pdf`);`415` 文案同时点明两条口径。**B. 读取(`GET /api/v1/documents/{name}`,必带 Bearer owner|admin)**——**真 PDF 如实下发 `Content-Type: application/pdf`**(读时按同一对裁决重嗅探,判不定者仍退 `application/octet-stream`),`Content-Disposition: attachment` + `X-Content-Type-Options: nosniff` **一字未变** ⇒ **PDF 永不被内联渲染**(与图片读侧刻意相反)。**C. 抽取语义(DTO `status`)**——**文本层 PDF 抽正文**(`Tj`/`TJ`/`'`/`"` 文本算子,页间以换行相接)⇒ `status:"extracted"`,与纯文本类走同一条 sidecar/注入路径;**扫描件/图片型 PDF ⇒ 诚实拒绝 `status:"unreadable"`**,原因含「这是图片型/无文本层 PDF,需要Line truncated
|
||||
|
||||
## 2. REST 端点(管理,`/api/v1/admin/*`)
|
||||
|
||||
> 仅供管理面板与授权客户端;RBAC:`owner`/`admin`/`viewer`;**写操作全程审计**。角色分档见下方「管理侧权限矩阵」(v0.40 起 `viewer` 真正可用:只读监控档,其余一律 `403`)。
|
||||
|
||||
### 2.0 管理侧权限矩阵(v0.40 起,与 `internal/services/gateway/routes.go` 逐条一致)
|
||||
|
||||
- **匿名(无 token / token 无效)**:任意 `/api/v1/admin/*` → `401 {error:{code:"unauthorized"}}`(fail-closed,全部 **61** 条管理路由无一例外 —— 12 条 viewer 档 + 11 条 owner/admin 档 + 38 条 owner-only 档)。
|
||||
- **角色分档**(`✅` 放行,`❌` 回 `403 forbidden`;owner>admin>viewer 递增):
|
||||
|
||||
| 端点组 | owner | admin | viewer |
|
||||
|------|:---:|:---:|:---:|
|
||||
| **只读监控档**(12 条 GET,见下①) | ✅ | ✅ | ✅ |
|
||||
| **owner/admin 只读档**(11 条,见下②) | ✅ | ✅ | ❌ |
|
||||
| **owner-only 写/凭据档**(38 条,见下③) | ✅ | ❌ | ❌ |
|
||||
|
||||
① **`viewer` 可读的 12 条只读监控档**(`viewerAuth` = owner/admin/viewer)——模块清单 + 运行时指标/日志/通知,全部是**非敏感投影**(无凭据、无他人账号数据、无消息正文):
|
||||
`GET /admin/personas`、`GET /admin/sessions`、`GET /admin/plugins`、`GET /admin/plugins/installed`、`GET /admin/channels`、`GET /admin/iot`、`GET /admin/automations`、`GET /admin/rag`、`GET /admin/observability/metrics`、`GET /admin/observability/trace`、`GET /admin/observability/logs`、`GET /admin/notifications`。
|
||||
|
||||
② **仅 `owner`/`admin` 的 11 条**(`viewer` → `403`;理由:含**他人数据 / 隐私正文 / 完整导出 / 审计流水 / 全局凭据面 / 成本面**):
|
||||
`GET /admin/memories`(私人记忆正文)、`GET /admin/channels/messages`、`GET /admin/channels/{platform}/messages`(完整 IM 消息流/转录)、`GET /admin/llm`(provider/模型配置面)、`GET /admin/llm/catalog`(模型目录候选面,**v0.53 新增**)、**`GET /admin/llm/usage`(LLM 用量/成本汇总面,**v0.64 新增**——含 token 成本与模型/用途分解,故与 `/admin/llm` 同门,**不放给 viewer**)**、`GET /admin/users`(账号与 API key 列表)、`GET /admin/backthink`(后台思考记录)、`GET /admin/observability/audit`(审计流水)、`GET /admin/sessions/{id}/export`(会话导出)、`POST /admin/rag/query`(知识库检索,语义上是读,但按「全局知识面」归此档)。
|
||||
|
||||
③ **仅 `owner` 的 38 条**(`admin` 与 `viewer` 均 `403`)——全部写操作,外加**凭据获取路径**(`login-qr`/`login-status`,`view`/`friends`,渠道连接/测试/配置)与**主人身份档案**(决定谁能进 owner 车道,连读都不给 admin):
|
||||
`GET /admin/channels/{platform}/login-qr`、`login-status`、`view`、`friends`;`POST /admin/channels/{platform}/friend/handle`、`message/test`;`PUT|DELETE /admin/channels/{platform}/config`;`POST /admin/channels/open|close|test`;`POST /admin/plugins/policy|install|uninstall`;`POST /admin/personas`、`PUT /admin/personas/{id}`;`GET|PUT /admin/owner-profile`(v0.51);`POST /admin/users`、`POST /admin/users/apikey`、`DELETE /admin/users/apikey/{id}`;`POST /admin/iot/{id}/control`、`POST /admin/iot/scenarios`、`POST /admin/iot/rules`;`POST /admin/automations`、`POST /admin/automations/{id}/run`、`DELETE /admin/automations/{id}`;`POST /admin/llm`、`POST /admin/llm/test`、**`POST /admin/llm/reset-from-env`(v0.62 新增,破坏性写——覆盖面板对若干键的修改,因此门与同页两条 LLM 写端点一致,**不比同族更宽**)**;`POST /admin/memories`、`POST /admin/memories/search`;`POST /admin/rag`、`POST /admin/rag/delete`;`POST /admin/system/revoke-keys|rotate-jwt|backup|restore`。
|
||||
|
||||
> **用户侧同级只读**(非 admin 前缀,同样任意已认证角色可读):`GET /api/v1/sessions`、`GET /api/v1/personas`、`GET /api/v1/meta/enums`(§1 行 4/9/15)。
|
||||
> **已知遗留**:前端管理面板**尚未**按角色隐藏/禁用分区——`viewer` 打开②③档页面会拿到 `403`,客户端需按本矩阵自行降级(后端契约已定,前端适配后置)。
|
||||
|
||||
| # | 端点 | 方法 | 状态 | 说明 |
|
||||
|---|------|------|:---:|------|
|
||||
| 1 | `/api/v1/admin/personas` | GET | ✅ | 人格列表(`viewer` 可读)→ `{data:[PersonaSummary...]}`,每项含用户侧全部字段,**另加 admin 专属 `thinking?`(v0.43)**:`{budgetTokens,effort,showToUser,mode}`——见下方「人格 `thinking` 读侧契约」 |
|
||||
| 2 | `/api/v1/admin/sessions` | GET | ✅ | 持久化会话列表(主会话 + 次级会话,design/16 §2);未注入 HistoryStore 时回退渠道会话视图。**v0.42 起每项含可选 `personaId`**(与会话级选角同源,见 §1 行 4) |
|
||||
| 3 | `/api/v1/admin/memories` | GET | ✅ | 记忆列表(手动添加/检索已实现;编辑/删除/衰减随 M10 扩展) |
|
||||
| 4 | `/api/v1/admin/plugins` | GET | ✅ | 插件/能力列表(含 MCP/渠道) |
|
||||
| 5 | `/api/v1/admin/plugins/policy` | POST | ✅ | 写工具策略(owner-only,审计);**策略落盘 SQLite `tool_policies`,重启保留**(迁移 `0012_tool_policy`)——`registry.SetPolicy` **先落库后改内存**,落盘失败则内存保持旧值并返回错误 |
|
||||
| 5a | `/api/v1/admin/plugins/installed` | GET | ✅ | **已装运行时插件列表**(owner/admin 读):`{data:[{id,name,kind,transport,endpoint?,command?,args?,apiKey?,env?,createdAt?,live,toolCount}]}`,`apiKey`/敏感 `env` 值**掩码**(如 `****1234`),`live` = 是否已连接注册,`toolCount` = 该 server 注册工具数 |
|
||||
| 6 | `/api/v1/admin/channels` | GET | ✅ | 渠道列表/健康 + 每平台已配账号(脱敏 token 掩码,design/04 §3.1);已连接渠道的账号附 **bot 身份**(`botId`/`botUsername`/`botNickname`,如 KOOK `GET /api/v3/user/me` 自动获取);适配器要求重新授权时附 **`needsRescan`** + **`lastError`**(v0.39,仅微信 iLink 返回 `errcode=-14`、会话被 iLink 侧拒绝这一种);**每账号附可选 `personaId`(v0.44)**=该账号的**渠道级角色绑定**(design/22 §2 方案 b;缺省 = 未绑定,非敏感、不掩码) |
|
||||
| 7 | `/api/v1/admin/iot` | GET | ✅ | IoT 设备列表 |
|
||||
| 8 | `/api/v1/admin/users` | GET/POST | ✅(GET) | 用户/角色/API key(GET 已实现;**key 的签发/吊销已实现**——`POST`/`DELETE /admin/users/apikey`,见下方写操作契约;`POST` 创建**用户**仍 `501`) |
|
||||
| 9 | `/api/v1/admin/llm` | GET/POST | ✅ | LLM 多厂商多模型(models.json):GET 列表(APIKey 脱敏,含 `provider` 协议与 `maxTokens` 预算);POST 保存组→落盘 `models.json`→**热更新**运行中的 LLM stack(design/04 §3.1,无需重启)。**v0.53**:组级新增 `reasoning` / `personaModels` / `personaProviders` 三键(见下方「模型配置契约(v0.53)」) |
|
||||
| 9a | `/api/v1/admin/llm/catalog` | GET | ✅ | **模型目录候选**(owner/admin 读,v0.53;`?refresh=1` 强制在线刷新)——面板「添加提供商 / 添加模型」的 baseURL / 模型清单 / 能力位来源,服务端快照 + 离线兜底;失败 `503 catalog_unavailable`。见下方「模型配置契约(v0.53)」 |
|
||||
| 9b | `/api/v1/admin/llm/reset-from-env` | POST | ✅ | **重置为 env 默认**(**owner-only 写**,v0.62,审计 `llm.resetFromEnv`):body `{"confirm":true}`(缺 = `400`)→ `200 {data:{reset:[{group,keys}], skipped:[{group,reason}], path}}`——**只覆盖 `.env` 声明过的键**、env 未声明的键原样保留、**组集合不增减**,无键可改时**不写盘**;**只回键名、绝不回显凭据值**。见下方「重置为 env 默认契约(v0.62)」 |
|
||||
| 10 | `/api/v1/admin/automations` | GET/POST | ✅(GET) | 自动化与提醒(GET 已实现;POST 创建 M10) |
|
||||
| 11 | `/api/v1/admin/backthink` | GET | ✅ | 后台思考记录 |
|
||||
| 12 | `/api/v1/admin/rag` | GET | ✅ | 知识库文档列表(分块/嵌入状态);带 `?query=` 时并入检索结果 |
|
||||
| 13 | `/api/v1/admin/rag/query` | POST | ✅ | 知识库检索(真实检索;配置向量增强走语义路径) |
|
||||
| 14 | `/api/v1/admin/rag/delete` | POST | ✅(写) | 删除知识库文档(**真实删除**:项目索引条目 + 级联章节);owner-only,`{id}` → `{id,deleted:true}`,不存在 `404` |
|
||||
| 15 | `/api/v1/admin/observability/metrics` | GET | ✅ | 网关请求指标(总数/QPS/延迟/方法·路径·状态计数/LLM·子代理计数) |
|
||||
| 16 | `/api/v1/admin/observability/trace` | GET | ✅ | 最近 trace span(环形缓冲,默认 512 条;**loop/工具/编排的真实 span**) |
|
||||
| 17 | `/api/v1/admin/observability/audit` | GET | ✅ | 审计记录(who/what/when/result,SQLite 持久化) |
|
||||
| 17b | `/api/v1/admin/observability/logs` | GET | ✅ | **后端日志 tail**(logger 环形缓冲,默认 2000 条;`?level=`/`?limit=`,见下方「可观测性契约」) |
|
||||
| 17a | `/api/v1/admin/notifications` | GET | ✅ | **统一通知 feed**(审计 + 主动推送聚合为只读 feed,design/10 §3 #16;见下方「通知聚合契约」) |
|
||||
| 18 | `/api/v1/admin/memories` | POST | ✅(写) | 手动添加记忆(owner-only,审计);body `{personaId?,content,scope?}`,无 store 回 `501` |
|
||||
| 19 | `/api/v1/admin/rag` | POST | ✅(写) | 导入知识文档(owner-only,审计);body `{name,desc?,content}`,分块后落项目索引可检索;无 store 回 `501` |
|
||||
| 20 | `/api/v1/admin/system/revoke-keys` | POST | ✅(写) | 吊销**全部** API key(owner-only,审计);返回 `{revoked:n}`——key 表清空,先前签发的所有 key 立即失效 |
|
||||
| 21 | `/api/v1/admin/system/rotate-jwt` | POST | ✅(写) | 轮换 JWT 密钥(owner-only,审计);body `{newSecret}`(≥32 位,不强校验熵)→ `{rotated:true,note,refreshTokensRevoked}`——**现有 access token 立即失效需重新登录,且**所有 refresh token 一并作废(`refreshTokensRevoked` 为删除条数);新密钥**不回显、不落日志** |
|
||||
| 22 | `/api/v1/admin/system/backup` | POST | ✅(写) | 备份数据(owner-only,审计);用 SQLite `VACUUM INTO` 快照(含已提交 WAL 内容)到 `<dataDir>/backup-<ts>.db` → `{path,size}` |
|
||||
| 23 | `/api/v1/admin/system/restore` | POST | ✅(写) | 恢复数据(owner-only,审计);body `{path}` → 校验路径在 dataDir 内→**先备份当前**→替换 DB 文件→`{restored:true,backup,note:"需重启后生效"}`(运行进程持有 DB 句柄,热替换需重启可见) |
|
||||
| 24 | `/api/v1/admin/system/storage` | GET | ✅ | **存储状态(只读,v0.60)**:owner-only、**无审计动作**;`{data:{main:{kind:"sqlite",path,fileBytes,walBytes,shmBytes,pageSize,pageCount,freelistPages,freeBytes},documents:{count,bytes},vector:{kind:"pgvector"|"none",enabled,ready}}}`——**绝不含凭据**(DSN/密码/`postgres://` 全文一个字都不回);未接活连接 / 路径未配置 / `documents` 表读不到 → `500 storage_unavailable`(**不回全 0 假快照**)。详见下方「系统设置契约」 |
|
||||
| 24a | `/api/v1/admin/llm/usage` | GET | ✅ | **LLM 用量汇总(只读,v0.64,切片 U1)**:**owner\|admin 读**、**无审计动作**;`?since=&until=&purpose=`(时间接受 RFC3339 或 Unix 毫秒;缺省窗口 = 近 24h;跨度上限 **90 天**,超过 → `400`,**不静默裁剪**)→ `{data:{window:{since,until,purpose},totals:{calls,prompt,completion,cacheHit,cacheMiss,hitRate},byPurpose:[…],byModel:[…]}}`;**`hitRate` = cacheHit/(cacheHit+cacheMiss),分母 0 ⇒ `0`**;**绝不含凭据与消息正文**;未接线 / 查库失败 → `500 usage_unavailable`(**不回全 0 假汇总**)。详见下方「LLM 用量汇总契约(v0.64)」 |
|
||||
|
||||
> **M10 前端契约补充(管理面板)**:`GET /api/v1/admin/users` · `GET /api/v1/admin/llm` · `GET /api/v1/admin/automations` · `GET /api/v1/admin/backthink` · `GET /api/v1/admin/rag` · `POST /api/v1/admin/rag/query` · `GET /api/v1/admin/observability/metrics` · `GET /api/v1/admin/observability/trace` · `GET /api/v1/admin/observability/audit` · `GET /api/v1/admin/observability/logs` · `GET /api/v1/admin/notifications` 已实现,返回真实数据(**日志 tail 见下方「可观测性契约」**;未注册 `GET /api/v1/admin/logs`)。**写操作已补齐**(见下方「管理面板写操作契约」):人格/API key/设备控制/自动化/LLM 测试/渠道连接与识别测试/记忆检索/会话导出/插件卸载**真实持久化或真实生效**;**系统设置安全与存储类**(吊销 API key / 轮换 JWT 密钥 / 备份 / 恢复)也已**真实实现**(见下方「系统设置契约」);**主人档案保存(v0.51)同样已实现**——`GET`/`PUT /api/v1/admin/owner-profile` 真实落盘 + 热替换活档案,面板的「保存」不再是假成功;仍为后置 M10:**新增用户**、**IoT 场景与规则**、**编辑环境变量/重启服务/检查更新**——后端如实返回 `501 not_implemented`,不伪造成功。(**知识库删除已实现**:`POST /admin/rag/delete` 真实删除,见下方写操作契约;**插件安装已实现**:v1 为 MCP server 热安装。)
|
||||
|
||||
**模型配置契约(v0.53)**(依据 design/04 §3/§3.1/§3.2(裁决 03.4.1 / 03.4.2 / 03.4.3 / 08.4.1)与 design/06 §0/§3(裁决 14.4.1 / 14.4.5);判定与校验以 `internal/config/runtimeconfig` + `internal/services/gateway/admin_llm*.go` 为准):
|
||||
|
||||
- **`GET /api/v1/admin/llm/catalog`(owner|admin 读,v0.53)** → `200 {data:{...}}`:
|
||||
- **请求**:`?refresh=1` = 强制在线刷新;不带时,本地快照仍在 **24h TTL** 内就直接用它(不每次打开面板都打外网)。
|
||||
- **响应 `data`**:`{source:"cache"|"models.dev", fetchedAt?, stale?, warning?, providers:[{id,name,api?,doc?,env?,npm?,models:[{id,name,attachment,reasoning,toolCall,contextLimit?,outputLimit?}]}]}`
|
||||
- `source` 说明这份候选从哪来(`models.dev` = 本次在线抓取,`cache` = 本地快照);`fetchedAt` 为快照抓取时间(RFC3339,无快照时省略);**`stale:true` + `warning` 只在「在线刷新失败、返回上次快照」时出现**(离线兜底,**绝不编造候选**)。
|
||||
- `api` = 该供应商 baseURL(目录未标注时省略,面板提示需手填);`doc` = 文档地址、`env` = 目录标注的官方 key 环境变量名(仅提示,面板不写 `.env`)、`npm` = SDK 包名。
|
||||
- 每个模型:`attachment`(接受图片/文件输入 → 面板预填 `multimodal`)、`reasoning`、`toolCall`、`contextLimit?`/`outputLimit?`(目录未标注时为 `0` 而省略;`outputLimit` 直接作 `maxTokens` 建议值)。
|
||||
- 供应商与模型都**按 id 排序**(输出确定、便于前端 diff);**没有模型清单的供应商不进列表**(避免噪声行)。
|
||||
- **失败**:**既无本地快照又抓不到** → `503 {error:{code:"catalog_unavailable", message}}`(message 给出「可改用粘贴导入」的指引);**有快照但刷新失败** → **仍 `200`**(`source:"cache"` + `stale:true` + `warning`);快照文件损坏/为空只记 WARN 并按「无快照」继续在线抓取,**不影响本次回答**。
|
||||
- **压缩**:客户端带 `Accept-Encoding: gzip` 时响应带 **`Content-Encoding: gzip`**(目录约 1MB 量级);**信封 `{data}` 形状不变**,浏览器与 Go 客户端透明解压;不带该头时返回明文并带 `Content-Length`。
|
||||
- **副作用(只有这一处)**:落盘快照 `<DATA_DIR>/llm_catalog.json`(与 `models.json` 同目录、`data/` 已 gitignore、**不含任何凭据**、TTL 24h);**不修改 `models.json`**。LLM stack 未注入时快照落到系统临时目录,**绝不往工作树写文件**。
|
||||
- **抓取只发生在本端点内**(数据源 `https://models.dev/api.json`,单次超时 20s、体积上限 64MiB):**不进运行时热路径**——模型请求链路从不读这份外网目录。
|
||||
- **审计**:成功记 `llm.catalog` / `ok`,返回过期快照记 `stale`,失败记 `error`(可用 `GET /admin/observability/audit` 查)。
|
||||
- **`models.json` 组级新键(v0.53,可热更新;`GET`/`POST /api/v1/admin/llm` 已同步这三个键的读与写)**:
|
||||
- **`reasoning: {kind}`**:`kind` ∈ `"auto"`(= 缺省)/`"deepseek"`/`"openai"`/`"vllm"`/`"dashscope"`,决定该组用哪种**提供商开关形状**下发推理指令(vLLM → `chat_template_kwargs.enable_thinking`;DashScope → 顶层 `enable_thinking`;DeepSeek → 顶层 `thinking:{type}` + `reasoning_effort`;OpenAI 系 → 只有 `reasoning_effort`;`auto` = 按模型名/baseURL 推断厂商,**形状有歧义就不下发任何字段**)。
|
||||
- **校验**:不在上列取值 → 组校验失败(`POST /admin/llm` 回 `400 bad_request`);**anthropic 组上声明 `deepseek`/`openai`/`vllm`/`dashscope` 同样拒绝**(Anthropic 用自己的 `thinking` 形状)。
|
||||
- **请求语义**:`reasoning` 键**缺失 = 保留已存形状**;给出(哪怕 `{}`)= 整块替换(`kind` 缺失/空串 = 回到「未声明」)。
|
||||
- **响应语义**:每组的 `reasoning` **恒下发**(`kind:""` = 未声明形状,不是「后端没报告」);顶层另下发 **`reasoningKinds`**(合法形状取值域,**不含空串**——`""` 是「不声明形状」而非一个形状名,面板另行给出该选项)与 **`personaProviderOptions`**(合法协议名域,即 `openai`/`anthropic`)供面板渲染下拉框——取值域由后端常量直接生成,**面板无需硬编码**。
|
||||
- **`personaModels: {personaId: modelId}`** = **角色级模型路由**(「角色」= 该回合答话的 persona,**不是**消息作者的 role);**`personaProviders: {personaId: "openai"|"anthropic"}`** = **角色级协议路由**(跨协议换 adapter,配合 `personaModels` 使用)。
|
||||
- **校验**:persona id / 值不得为空,`personaProviders` 的值必须是 `openai` 或 `anthropic`,否则组校验失败(`400`);键值首尾空白会被规整,**规整后撞键直接报错**(不静默丢一条)。
|
||||
- **回落**:未绑定 / 绑定到未知 persona → 该组默认模型;`personaProviders` 声明的协议在 `models.json` 里**没有对应组** → 记 WARN 并**跳过**(该角色仍走默认链,**不半建一条只会失败的链**)。
|
||||
- **请求语义**:两个键**缺失 = 保留已存表**;给出(含 `{}`)= **整表替换**(用整表替换而非合并,面板才能删除一条已存路由);未声明时响应中该键不出现(`omitempty`)。
|
||||
- **语义修正(重要)**:`multimodal` = **主模型自身能否收图**(**不是**「本部署有没有视觉能力」);`LLM_VISION_MODEL` / `LLM_OCR_MODEL` **只配视觉/OCR 工具用的模型**,与 `multimodal` 互不代表(裁决 14.4.1,见 §1 行 3)。
|
||||
- **`.env` 新增 10 键(v0.53 首登 9 键,v0.58 追加 `LLM_MULTIMODAL`;`.env` 是 `models.json` 的兜底,对齐 `.env.example`)**:
|
||||
|
||||
| 键 | 默认 | 语义 |
|
||||
|---|---|---|
|
||||
| `LLM_REASONING_KIND` | 空(= `auto`) | **仅供 `.env` 合成组**的推理开关形状声明(**无有效 `models.json` 时**才生效,取值同 `reasoning.kind`;有 `models.json` 时以该组的 `reasoning.kind` 为准) |
|
||||
| `LLM_OCR_MODEL` | 空 | **OCR 工具**用的模型;空 = 复用 `LLM_VISION_MODEL`,两者都空 = 复用主模型(`LLM_MODEL`) |
|
||||
| `LLM_MULTIMODAL` | `false` | **声明「主模型自身能否收图」**(`bool`,v0.58 新增)——即 `models.json` 组 `multimodal` 的 **env 等价物**。**仅供 `.env` 合成的模型组**(**无有效 `models.json` 时才生效**;有组文件时以该组的 `multimodal` 为准,与 `LLM_REASONING_KIND` 同口径)。`true` = 图片**直进主模型上下文**;**留空 / `false`(默认)= 把图交给 vision/OCR 工具,换成文本描述**(图像不占上下文,只有工具结果占)。**绝不凭 base URL / 模型名猜测**端点是否收图;**非 bool 值(如 `maybe`)启动即报错**(fail-fast,不静默回退成 `false`)。`LLM_VISION_MODEL` / `LLM_OCR_MODEL` 配的是**视觉/OCR 工具调用的模型**,与本键互不代表 |
|
||||
| `LLM_LOCAL_ENABLED` | `false` | 降级链**第三级(本地模型)**总开关;默认关,链保持「主 → 备」两级 |
|
||||
| `LLM_LOCAL_PROVIDER` | `openai` | 本地端点协议(`openai`/`anthropic`) |
|
||||
| `LLM_LOCAL_BASE_URL` | 空 | 本地端点 base URL(如 `http://127.0.0.1:8080/v1`) |
|
||||
| `LLM_LOCAL_API_KEY` | 空 | 本地端点 key(本地服务通常为空) |
|
||||
| `LLM_LOCAL_MODEL` | 空 | 本地模型名 |
|
||||
| `CAPACITY_L3_ENABLED` | `false` | capacity 意图判定的 **L3(模型辅助分类)**开关;默认关,只走 L1/L2 启发式 |
|
||||
| `CAPACITY_L3_CACHE_TTL` | `5m` | L3 意图判定结果缓存 TTL |
|
||||
|
||||
- `LLM_LOCAL_*` **三个条件缺一不可**:`LLM_LOCAL_ENABLED=true` **且** `LLM_LOCAL_BASE_URL` 非空 **且** `LLM_LOCAL_MODEL` 非空;只开开关而不配端点/模型时**记 WARN 并跳过该级**(绝不半配一个只会失败的链尾步)。
|
||||
- **内置工具新增 `image-crop`(v0.53)**:按区域裁切图片(参数 `image` + `x`/`y`/`width`/`height`,**0 基像素、区域须完整落在图内**)→ 返回 `{image:"data:image/png;base64,…", format:"image/png", width, height, region:{x,y,width,height}, source:{width,height}}`;用途是**上游可能压缩分辨率、大图细节会丢**,让模型自行裁出区域再交给 vision/OCR 工具精读(design/06 §3,裁决 14.4.5)。上限:源图 ≤50MiB、区域 ≤16MP、返回载荷 ≤8MiB(超出**报错**而非截断/降采样);源可以是本地路径(走 file 工具同一沙箱)/ http(s) URL(同 SSRF 守卫)/ base64 data URI。**策略与其它工具一致**:`tool_policies` 无行时按最严默认 **owner-only**(群聊/陌生人的调用被策略层拒,见 §2 行 5)。
|
||||
|
||||
**安全 / 上下文环境变量(v0.56)**(`.env` / 环境变量;与 `.env.example` 逐键对齐):
|
||||
|
||||
| 键 | 默认 | 语义 |
|
||||
|---|---|---|
|
||||
| `OWNER_IDENTITY_INJECTION` | `owner_only` | **主人身份说明书(`owner.yaml:identityFile`)注入范围**:`owner_only`(默认,**仅主人会话**,按**渠道层强类型 actor 元数据**判定)/ `always`(**所有会话**,含群聊与陌生人私聊,**仅本地自测/排障**——它显式开启红线例外)/ `off`(完全关闭,连主人会话也不注入)。取值**大小写不敏感、容忍首尾空白**;**非法值回退 `owner_only` 并记 WARN**(绝不静默放行、也绝不静默失效)。它只决定「那段知识进不进该会话的上下文」,**不参与任何身份/权限判定**(design/11 §0):文件内容既不参与 `IsOwner`,也不改变授权。 |
|
||||
| `CONTEXT_MAX_TOKENS` | `32768` | **上下文溢出压缩预算**(token,离线估算;默认值与 `loop.DefaultMaxContextTokens` 同源,有测试锁死两者不许漂移)。会话估算上下文超过它时,更早的轮次被摘要成一条前导摘要消息,近期尾部(含尾部工具结果)原样保留;**`0` 或负数 = 关闭压缩**(行为与接线前一致)。 |
|
||||
| `GUARDRAIL_KEYWORDS` | 空 | **内容策略关键词表**(逗号分隔,大小写不敏感的子串匹配)。**留空 = 不挂关键词护栏**(项目不内置任何词表,只有你显式声明过的词才会被拦)。敏感信息(API key / bearer token / 密码 / 私钥)走**阻断档**、请求与响应各有长度上限——这两项是代码内定的安全默认值,**不对外开放配置**(`cmd/cyrene/wire_guardrail.go`)。 |
|
||||
|
||||
**上下文窗口与预算契约(v0.59)**(design/04 §1.3 + 附录 A.3 的主人裁决,2026-10-04;判定以 `internal/config/context_budget.go` + `cmd/cyrene/wire_context.go` 为准):
|
||||
|
||||
- **`models.json` 组级新键 `contextWindow`**:正整数 = 该组模型的**上下文窗口**(token)。本仓**没有任何 provider 侧窗口发现代码**,窗口只能由人声明。`0`(省略该键)= **未声明** ⇒ 后端按 **`DefaultModelContextWindow = 131072`** 保守估算推导预算,并记**恰好一条** WARN(提示「声明窗口才能吃满模型的真实窗口」);**负数 = 组校验失败**(写端点回 `400`,载入期同样拒绝)。
|
||||
- **面板读写面**:`GET /api/v1/admin/llm` 的组 DTO **含 `contextWindow`**(`omitempty`,未声明时该键不出现——与 `maxTokens` 同形),`POST /api/v1/admin/llm` **接受可选 `contextWindow`**:**缺失 = 保留已存窗口**(面板的局部编辑绝不复位它)、**显式 `0` 与缺失同义**(都 = 未声明)、**负值 `400 bad_request` 且不落盘**。因此面板可以显示并编辑该键,不是「只能手改文件」。
|
||||
- **`.env` 4 键**(与 `.env.example` 逐键对齐):
|
||||
|
||||
| 键 | 默认 | 语义 |
|
||||
|---|---|---|
|
||||
| `LLM_CONTEXT_WINDOW` | 空(= 未声明) | **per-group 播种**:`models.json` 组 `contextWindow` 的 **env 等价物**(同一个数、同一个含义)。它作用于 `.env` 合成组与 materialize 的播种层;**负数启动报错**。 |
|
||||
| `CONTEXT_BUDGET_RATIO` | `2/3` | 推导比例,取值域 `(0, 1]`,**分数(`2/3`)与小数(`0.6667`)两种写法都接受**;**非法值记一条带原始取值的 WARN 并回落 `2/3`**(绝不「配了当没配」)。 |
|
||||
| `DOCUMENT_MAX_INJECT_CHARS` | 空(= 自动) | **单份附件注入上下文**的字符上限。未设 = 按推导出的上下文预算自动算(缺省下限 **64K 字符**);`>0` = 显式覆盖。它与「sidecar 存多少」是**两个数**:sidecar 存抽取全文(只受 **8 MiB** 安全上限约束),本键只决定每回合注入多少 ⇒ **截断发生在装配期**,清单行的续读游标因此真是「继续读的起点」。 |
|
||||
| `CONTEXT_MAX_TOKENS` | **未设置**(≠ `0`) | **三态(v0.59 语义变更)**:**未设置 ⇒ 按窗口推导**;`>0` ⇒ **显式覆盖**(原样使用);`0` / 负数 ⇒ **关闭压缩**。「未设置」与「显式 `0`」必须区分(按环境变量 / `.env` 的**存在性**判定,不靠 0),因为 `0` 已被「关闭压缩」占用。 |
|
||||
|
||||
- **推导式**:`budget = min(窗口 × 比例, 窗口 − 输出保留)`;**输出保留 = `max(32768, 该组 maxTokens)`,并封顶为 `窗口/2`**(组把 `maxTokens` 声明得比窗口还大时,不让「窗口 − 输出」退化到 0 或负数)。**1M 窗口 ⇒ `699050` tokens**(`1048576 × 2/3` 取整)。
|
||||
- ⚠️ **`CONTEXT_MAX_TOKENS=32768` 属「显式覆盖」**:`.env` 里若仍写着这个数(旧模板的默认值取自「gpt-4o 的 128K 窗口 ÷ 4」,该前提已随默认模型 1M 窗口失效),**推导不生效**——这是设计语义,**不是 bug**。要吃到推导就**删掉 / 注释掉该行**(本次 `.env.example` 已把该行改成注释形式,并在旁注写明旧值来历)。**本次不改客户端的任何行为**:它只是一个 env 键的语义说明。
|
||||
- **`.env` → 面板配置的 materialize 语义(v0.59 补登记的欠账,此前未成文)**:进程**每次启动**跑一次(`runtimeconfig.Materialize`,在模型栈构建之前):
|
||||
- **组级**:面板配置**非空** ⇒ **组集合完全由面板决定**(env 不新增组、**不复活**被面板删掉的组,也不删面板的组);面板配置**缺失 / 空文件 / 空数组** ⇒ 由 env 播种出合成组并落盘(让面板从此有一份一等来源)。
|
||||
- **键级**:**只在面板缺该键时用 env 补**;面板**已有**该键(含**显式空值 / 显式 `false` / 显式 `null`**)⇒ **面板优先,绝不覆盖**(靠「原始键是否出现」区分「从未写过」与「显式清空」——类型化解码做不到这件事)。
|
||||
- **硬约束**:「面板改一次、重启被打回」**绝不成立**(有专门用例钉住)。无改动时**一个字节都不动**(幂等)。
|
||||
- **日志**:补齐写 Info(补了哪几个键、来源 env),因面板优先而跳过的写 Debug;**只打组 id 与键名,`apiKey` 等凭据绝不进日志**。
|
||||
- **范围**:目前**仅 `models.json` 一族**(`channels.json` / `plugins.json` **未纳入**)。
|
||||
- **与面板保存路径的分工**:materialize **只在启动时**跑;面板保存走「整组写入 + 热重载」,**那里不跑补齐**——否则面板刚显式清空的键会被立刻补回。
|
||||
|
||||
**「重置为 env 默认」契约(v0.62,`POST /api/v1/admin/llm/reset-from-env`)**(任务书 R1;判定以 `internal/config/runtimeconfig/reset.go` + `internal/services/gateway/admin_write_llm_reset.go` 为准):
|
||||
|
||||
- **它是什么**:materialize(上一节)的方向是「面板缺该键 ⇒ 用 `.env` 补」;本动作是它的**反方向显式动作**——部署者明确要求「**把面板里这些键退回 `.env` 声明的默认值**」(例如面板把 `apiKey`/`baseUrl` 改坏了,一键回到 env 基线)。两者**共用同一张键表**(`runtimeconfig` 的 `groupFillSpecs`),因此「本动作能改回的键集合」与「materialize 会补齐的键集合」结构性一致。
|
||||
- **鉴权 / 审计**:**`owner` 专属**(与同页 `POST /admin/llm`、`POST /admin/llm/test` **同门**;`admin` 与 `viewer` 均 `403`)——它是破坏性写(覆盖面板对若干键的修改),**门不比同族写端点更宽**;带审计动作 **`llm.resetFromEnv`**(成功 `ok`、被拒/失败 `error`;多词动作沿用本仓既有 camelCase 约定,与 `llm.addProvider` / `persona.update` 同风格)。
|
||||
- **请求**:body **必须** `{"confirm":true}`。**缺失 / `false` / 非法 body 一律 `400 {error:{code:"bad_request"}}`**,且**一个字节都不动**——本动作用 env 值覆盖面板修改,属破坏性操作,缺护栏的默认必须是「不执行」。
|
||||
- **语义(逐条)**:
|
||||
- **只动 `.env` 声明了非空值的键**(`provider` / `baseUrl` / `apiKey` / `model` / `contextWindow`——与 materialize 的补齐表同集;`contextWindow` 只认正数,`fallback` / `multimodal` / `maxTokens` / `reasoning` / `personaModels` 在 `.env` 里没有出处,**永不被本动作改动**)。缺省协议族按**组自己的 `provider`** 选(`.env` 同时配了扁平块与 `ANTHROPIC_*` 时,两族各拿自己的值)。
|
||||
- **env 未声明的键一律保留面板值**(含面板显式清空/`false`/`null` 的键)。
|
||||
- **组集合不增减**:不复活被面板删掉的组、不删面板新增的组,**顺序也不动**(本动作只改键值)。
|
||||
- **面板值已与 env 一致的键不算「改回」**,因此不出现在 `reset` 里(`provider` 恒属这种情形:env 默认层总为两族各声明一次协议名,而面板组本来就与之相同)。
|
||||
- **没有任何键需要改时一个字节都不写**(幂等;重复调用第二次会返回 `reset:[]` + 每组一条「已一致」的 `skipped`)。
|
||||
- **真的改了盘就热重载**运行中的 LLM 供应商栈(design/04 §3.1,无需重启)——但注意 `contextWindow` 的**预算推导**仍只在进程启动时算一次(与 `POST /admin/llm` 同一口径,面板文案已写明「重启后生效」)。
|
||||
- **响应**:`200 {data:{reset:[{group,keys:[…]}], skipped:[{group,reason}], path}}`
|
||||
- `reset`:**真的被改回**的分组,`group` = 组 id、`keys` = `models.json` 的 JSON 键名数组(顺序 = 补齐表顺序)。**只回键名,值(尤其 `apiKey`)绝不回显。**
|
||||
- `skipped`:未被改动的分组 + 中文原因(`"面板值已与 `.env` 默认一致,无需重置"` / `` "`.env` 未声明该组的默认键(面板值原样保留)" ``)。两组键**恒为数组**(无记录时是 `[]`,不是 `null`)。
|
||||
- `path`:被改写(或本应改写)的 `models.json` 路径,与 `POST /admin/llm` 同源。
|
||||
- **对照基准**:**请求时刻按既有 config 解析入口读到的 `.env` 默认层**(`config.LoadLLMDefaults`;优先级与启动时 `config.Load` 逐字一致:进程环境 > `.env` > 标签默认)。⇒ 部署形态(systemd/env_file,无 `.env` 文件)下读到的是进程环境里那一份;`.env` 改了而没重启时,本动作按**文件当前内容**回退,而重启后 materialize 也用同一份——两者不冲突。
|
||||
- **错误(都绝不改文件,且都带审计 `error`)**:`.env` 源不可读 → `500 {error:{code:"env_defaults_unreadable"}}`;面板配置不存在/不可解析 → `500 {error:{code:"models_unreadable"}}`;写盘失败 → `500 {error:{code:"llm_persist_failed"}}`;已写盘但热重载失败 → `500 {error:{code:"llm_reload_failed"}}`(说明文件已改、未生效);LLM 栈未注入 → `500 {error:{code:"llm_unavailable"}}`。
|
||||
- **面板**:LLM 页的「**重置为 env 默认**」卡片(`frontend/web/src/admin/modules/llm/LlmResetFromEnv.tsx`,行上标 `owner 专属`)——点按钮先展开中文**二次确认**文案,确认后才带 `confirm:true` 提交;结果按 `reset`/`skipped` 渲染成可复核的组名 + 键名摘要。
|
||||
|
||||
**管理面板写操作契约**(`POST/PUT/DELETE /api/v1/admin/*`,均 **owner-only**,读为 owner/admin;写操作经 audit 落 `audit_logs`;成功 `{data}` / 失败 `{error:{code,message}}`):
|
||||
- `POST /admin/personas` body `{id,name,role,systemPrompt,characterRef?,modes?/mode?,memoryNamespace?,thinking?}` → `201 {data:PersonaSummary}`(**v0.43 起与读端点同形状:含 `mood` + `thinking?`**;注册进 persona registry + 持久化到 `configs/personas/<id>.yaml`);id 已存在回 `409 duplicate`。**`systemPrompt` 自 v0.51 起必填**(落的 YAML 键是 `identity.systemPrompt`):**缺失/空白 → `400 bad_request`**(消息 `"systemPrompt is required (identity.systemPrompt must not be empty)"`),**不再派生默认说明串**回落——载入期(`loader.Load`)执行同一规则,因此写出的 YAML 必能被重新载入。
|
||||
- `PUT /admin/personas/{id}` body 同上(不含 id)→ `200 {data:PersonaSummary}`(**v0.43 起与读端点同形状:含 `mood` + `thinking?`**,面板可直接用它回填);不存在回 `404`。**`systemPrompt` 与创建同一把门**:v0.51 起同样必填,缺失/空白回 `400 bad_request`(同一条消息)——**不派生默认串、也不静默沿用旧值**,请求被拒即不落盘。
|
||||
- `thinking`(可选,design/01 §2.1)`{budgetTokens?,effort?,showToUser?,mode?}`:写入 YAML 的 `thinking` 节,驱动该人格的思考预算/开关/呈现。`mode` ∈ `default|on|off`(缺省 `default`),非法 `mode` 或负 `budgetTokens` 回 **`400 bad_request`**(`invalid thinking config: …`)。**`showToUser` 是 bool 且可缺省**:**缺省 = 保持现行为(thinking 帧照常推客户端)**;显式 `false` = 只落库不回显。**`PUT` 省略整个 `thinking` 对象时保留已存的 `thinking` 节**(不因客户端不认识该字段而抹掉);显式传对象则整体替换(字段全缺省即清空)。该字段**不构成能力边界**。
|
||||
- **人格 `thinking` 读侧契约(v0.43,回读形状)**:`GET /admin/personas` 每项按**与写侧同构**的形状回读 `thinking`,供面板回填:
|
||||
- **未配置的人格:`thinking` 键完全不出现**(不是全零对象)——与写侧「省略 `thinking` 对象 = 保留已存」互为镜像,客户端可有样学样地「原样回填 / 原样省略」。
|
||||
- 已配置时 **四个键都出现**:`budgetTokens`(number,未设为 `0`)、`effort`(string,未设为 `""`)、`showToUser`、`mode`(string,未设为 `""`)。
|
||||
- **`showToUser` 是三态、且读侧如实三态**:**`null` = 未配置(保持现行为:thinking 帧照常推客户端)**;`true`/`false` = 显式选择(`false` = 只落库不回显)。**服务端不会把 `null` 拍平成 `false`**(写侧用的是 `*bool`,读侧同样保留指针语义),否则「没配过」会被面板误显示成「关」。
|
||||
- **能力提示由服务端负责,客户端不要假装**:`effort` 的**有效白名单随当前生效 provider** 而定(OpenAI 兼容系 `none/minimal/low/medium/high/xhigh/max`;DeepSeek 仅 `none/low/high/max`,别名 `minimal→low`/`medium→high`/`xhigh→high`/`ultra→max`;未知 provider 不支持)。**服务端在模型层按 provider / 形状校验,非法或该形状不支持的字段一律丢弃并记 WARN(不降档、也不静默无效)**(design/04 §3.2 / v0.53);读端点只回读**已存配置**,**不承诺**该值在上游真的生效。面板可给出提示与前端枚举校验,但**不得**把「配了 effort」呈现为「上游已启用」。
|
||||
- `POST /admin/users` → `501 not_implemented`(**无用户表**:登录仅用 `.env` 单 admin `ADMIN_USERNAME`/`ADMIN_PASSWORD`,角色为 `owner>admin>viewer` 常量;多用户存储 + 登录接入需先设计——design/11 单用户为主。不做「建了却登不进」的假用户)。
|
||||
- `POST /admin/users/apikey`(**无请求体**,v0.51 取消 role 分级)→ `201 {data:{id:<掩码>, key:<明文·仅此一次>, role:"owner"}}`;`key` **仅此响应出现一次**,任何有效 key 鉴权后即 `owner`(裁决 15.4.3:key 不做角色分档;旧的 `{"role":…}` 请求体被忽略,表里也没有角色维度)。
|
||||
- `DELETE /admin/users/apikey/{id}`(`id`=列表掩码)→ `200 {data:{id,revoked:true}}`;未命中 `404`。
|
||||
- `GET /api/v1/admin/owner-profile`(**owner-only**,**读不审计**)→ `200 {data:{id,displayName,defaultPersona,identityFile,notes,accounts:[{platform,accountId}]}}`(后三个可选字段为 `omitempty`——为空时**键不出现**,客户端按缺省处理即可);**文件不存在(全新安装)→ `200` + 空白档案**(`accounts:[]`,**非 `null`**,面板据此渲染空表单让主人首次填写);**档案损坏/读不出 → `500 {error:{code:"owner_profile_unreadable"}}`**(如实报错,不返回一份看似正常的空档案——否则「白名单是空的」与「档案读不出来」长得一模一样)。
|
||||
- `PUT /api/v1/admin/owner-profile`(**owner-only** + 审计 `owner.profile.update`)body 同形状(`accounts` 可空/省略;各字段**首尾空白会被去掉**——面板里手打的平台名/账号 id 常带空格,带进白名单就永远匹配不上)→ `200 {data:<落盘后档案>}`;`id`/`displayName` 缺失或 `accounts` 项**半空**(只给 platform 或只给 accountId)→ `400 {error:{code:"bad_request", message:"invalid owner profile: …"}}` 且**不落盘**(整条全空的占位项被跳过,不算错);写盘失败 → `500 {error:{code:"owner_profile_persist_failed"}}`。落盘 `configs/users/owner.yaml`(**0600 原子写**:临时文件 + rename)+ **热替换活档案**,新白名单**立即**参与渠道层 actor 判定、无需重启;组合根未暴露活档案时只记一条 WARN(已落盘、需重启生效),**不假装热重载成功**。它**不是**提示词/LLM 判定的输入——谁能当主人始终由这份档案的 `accounts` 白名单 + 每渠道 `adminIds` 决定(design/11 §0)。
|
||||
- `POST /admin/iot/{id}/control` body `{action,params?}` → `200 {data:{id,name,type,state,online}}`(真实下发到 IoT provider);`404` 设备不存在 / `400` 动作不支持或参数错 / `409` 设备离线。
|
||||
- `POST /admin/iot/scenarios`、`POST /admin/iot/rules` → `501 not_implemented`(**IoT 无场景/规则模型与存储**:`iot.Provider` 只有设备列表/控制/状态;通用 `automations` 只跑 cron/事件规则,不含设备状态条件 + 设备动作执行器。场景/规则 CRUD 需先新增模型 + 执行器)。
|
||||
- `POST /admin/automations` body `{name,kind:"cron|event",expression}` → `201 {data:{id,name,kind}}`(真实注册规则);无效 cron/规则回 `400 invalid_cron|invalid_rule`。
|
||||
- `POST /admin/automations/{id}/run` → `200 {data:{id,output}}`(手动触发);`404`。
|
||||
- `DELETE /admin/automations/{id}` → `200 {data:{id,deleted:true}}`;`404`。
|
||||
- `POST /admin/llm` body `{id?,provider?,baseUrl,apiKey?,model,multimodal?,fallback?,maxTokens?,contextWindow?,reasoning?,personaModels?,personaProviders?}` → `201 {data:{provider:{id,name,provider,baseUrl,model,multimodal,enabled,fallback?,maxTokens?,contextWindow?,reasoning,personaModels?,personaProviders?}, persisted:true, path}}`。**真实持久化**:读-改-写 `models.json`(本地、gitignore),保存组提升为**生效组**(index 0),并**热更新**运行中的 LLM provider(`path` 为落盘文件路径);API key 写入文件但**永不回显/不落日志**。`provider` 为空默认 `openai`;缺 `baseUrl`/`model` 回 `400`;`maxTokens` / `contextWindow` 为负回 `400`;未注入 LLM stack 回 `500 llm_unavailable`、写盘失败 `500 llm_persist_failed`、热重载失败 `500 llm_reload_failed`。`maxTokens` 缺省/`0` = 未设(回落 `LLM_FALLBACK_MAX_TOKENS` / 适配器默认);`contextWindow` 缺省/`0` = 未声明(按回落窗口 131072 推导)。⚠️ **时机**:预算推导**只在进程启动时算一次**(`cmd/cyrene/wire_context.go` 组合期)⇒ 面板改窗口后**需重启**才重算(provider 栈本身是热更新的,两者口径不同,面板文案已写明「重启后生效」)。`.env` 为兜底。**v0.53 新增三键**(`reasoning.kind` / `personaModels` / `personaProviders`,语义与校验见上方「模型配置契约(v0.53)」):三者**缺省 = 保留已存值**,给出则 `reasoning` 整块替换、两张 persona 表**整表替换**;非法 `kind` / 非法协议名 / 未知 anthropic 形状 → `400 bad_request`。**v0.59 新增 `contextWindow`**(组级模型窗口,语义见上方「上下文窗口与预算契约(v0.59)」):**缺省 = 保留已存窗口**、显式 `0` 与缺省同义、负值 `400`。
|
||||
- `POST /admin/llm/test` body `{provider?,baseUrl,model,apiKey?,prompt?}` → `200 {data:{ok,provider,model,reply,tokens}}`(**真实模型调用**);无效/失败回 `400 invalid_provider` / `500 llm_test_failed`。API key 仅本次请求内使用,不落库、不进 wire。
|
||||
- `POST /admin/llm/reset-from-env`(**v0.62,owner-only**,审计 `llm.resetFromEnv`)body `{confirm:true}`(缺 = `400 bad_request`)→ `200 {data:{reset:[{group,keys}], skipped:[{group,reason}], path}}`:把面板里 `.env` 声明过的键退回 env 值,**组集合不增减**、env 未声明的键原样保留、无键可改时不写盘,**只回键名不回显凭据值**。失败:`500 env_defaults_unreadable` / `models_unreadable` / `llm_persist_failed` / `llm_reload_failed` / `llm_unavailable`。完整契约见上方「重置为 env 默认契约(v0.62)」。
|
||||
- `POST /admin/channels/open`、`POST /admin/channels/close` body `{name}` → `200 {data:{name,connected}}`(真实 Connect/Disconnect);`404`(未注册渠道)/`409 session_rejected`(微信 iLink 侧拒绝/作废了该会话,重连无法修复,须先「扫码登录」重新绑定,v0.39)/`409 needs_login`(微信 iLink 尚无 token)/`500`(其它凭据缺失等连接失败)。
|
||||
- `POST /admin/channels/test` body `{platform?,accountId,isGroup}` → `200 {data:{role,owner,isGroup,env,verdict}}`。**结构性判定**:channel 层 actor 元数据 × `configs/users/owner.yaml` 白名单,绝不靠提示词。
|
||||
- **微信 iLink「会话被拒」契约**(v0.39,适配器级,非端点):iLink 返回 `errcode=-14` 时适配器进入**显式被拒态**——`IsConnected()` 恒 `false`;`Connect()` **不再返回成功**,而是返回「需要重新扫码」类错误(`POST /admin/channels/open` 对应 `409 session_rejected`);`HealthCheck` 同样把「被拒」与「未连接」区分开。`GET /admin/channels` 该平台行带 **`needsRescan:true`** + **`lastError`**(人类可读原因,**绝不含 token**)。**清除条件**:扫码登录拿到新 token(`login-status` 报 `confirmed`)或配置里换了新 token(保存后热重载)。被拒期间:`login-status` **不**假报 `confirmed` 而是 `failed` + 被拒原因(避免面板把已作废的凭据当成功重新落盘);`login-qr` 仍可正常出码(**不**回 `already_logged_in` 挡住重新扫码);`get_bot_qrcode` 不再携带已被拒的 token(否则服务端会以「已绑定」应答而不发新凭据)。未实现该能力的平台:`needsRescan`/`lastError` **不出现**(`omitempty`),客户端按缺省处理。
|
||||
- **事实说明(勿误读为「过期」)**:iLink 的 bot token 是**长期凭据**(实测一个月前扫码得到的 token 仍可用),`errcode=-14` 表示 **iLink 侧拒绝/作废了该会话凭据**(解绑、在别处重新登录、协议层拒绝等),**不是时间性过期**;恢复方式为**重新扫码绑定**(或配置换新 token)。当前尚未在真实日志中观测到 `-14`,此处按「被拒」语义处理以防静默变哑。
|
||||
- **KOOK 适配器面板端点**(design/17 §2,**owner-only + 审计**,仅 `kook` 平台实现;其它平台回 `501 not_implemented`,账号未找到回 `404`):这些端点均带可选 `?accountId=` 定位到具体 channels.json 账号(不传=该平台首个账号)。**token/密钥不打印、不落日志**;子项拉取失败不伪造、记入 `errors`。
|
||||
- `GET /admin/channels/{platform}/view?accountId=` → `200 {data:{platform,accountId?,friends?,userChats?,guilds?,channels?,members?,errors?}}`。聚合该账号真实数据(好友/申请/屏蔽、服务器、首个服务器频道与成员、私信会话);`errors` 为 `{子项:原因}` 记录失败的子项。
|
||||
- `GET /admin/channels/{platform}/friends?type=&accountId=` → `200 {data:{friends?,requests?,blocked?}}`。`type` ∈ `friend|request|block`(空=全部),调用 KOOK `GET /friend?type=`。
|
||||
- `POST /admin/channels/{platform}/friend/handle?accountId=` body `{id,accept}` → `200 {data:{handled:true,id,accept}}`。同意/拒绝好友申请(KOOK `POST /friend/handle-request`)。
|
||||
- `POST /admin/channels/{platform}/message/test?accountId=` body `{targetId,content}` → `200 {data:{msg_id,msg_timestamp}}`。真实向 `targetId`(频道 id)发一条 kmarkdown 测试消息(KOOK `POST /message/create`),验证 bot 可达性。
|
||||
- `GET /admin/channels` 每条账号附 **`capabilities`**(可选 `[]string`,如 kook 的 `["friends","user_chats","channels","guilds","message_test"]`),供面板按平台渲染专属功能卡片。
|
||||
- **KOOK「token 即用、其余自动获取」**(适配器契约,非端点):填 KOOK 渠道时**只必填 `token`**(bot_token)。`base_url` 留空即用官方默认 `https://www.kookapp.cn/api/v3`;`receive_mode` 空默认 `ws`(WS 模式地址由 `GET /api/v3/gateway/index?compress=0` 自动获取,已接入);`webhook_path` 空默认 `/webhook/kook`。高级值(改用 webhook、`webhook_verify_token`、`webhook_encrypt_key`,仅 webhook 模式需要)经 PUT 的 `config` JSON 传入(前端走「额外 config」,如 `{"receive_mode":"webhook","webhook_verify_token":"..."}`)。**Connect 拿到 token 后自动调用 `GET /api/v3/user/me`**(Bearer 鉴权)解析 bot 账号(id/username/nickname),存到 adapter 并随 `GET /admin/channels` 账号的 `botId`/`botUsername`/`botNickname` 显示;user/me 失败仅记日志、bot 身份为空,**不中断**连接/收发。token 只在 header / 写文件,**不打印、不进日志**。
|
||||
- **OneBot v11 四端契约(v0.55,适配器契约 + 配置面;design/08 §4 / design/13 §0,裁决 1.4 / 12.4.1)**:`qq-obv11` 适配器按 `transport`(ws|http) × `mode`(client|server) 提供**四个端**,谁向谁发数据各不相同:
|
||||
- **`ws + client`**:Cyrene 主动拨号 OneBot 的 WS 端点,收发同一个长连接;**`ws + server`**:OneBot 反连 Cyrene 的 `listen_addr`,收发同一个长连接(两端均沿用改造前语义)。
|
||||
- **`http + client`**:Cyrene 作为 HTTP 客户端调 OneBot 的 HTTP API——`POST {api_base_url}/{action}`、等响应、按 `retcode` 判成败(**纯出站**,无长连接、无监听端口);token 只走 `Authorization: Bearer`。
|
||||
- **`http + server`**:OneBot 主动 `POST` 到 Cyrene 的 `listen_addr` + `report_path`(**纯入站**);**fail-closed 鉴权**(配了 `access_token` 时 `Authorization: Bearer` 或 `?access_token=` 必须匹配,否则 `403`)、**10MiB 请求体上限**(超出 `413`)、方法非 `POST` 回 `405`,成功回 `204`。
|
||||
- **配置面**(channels.json 账号 `config` + 适配器 `Config()` 快照)新增:`transport`(`ws`|`http`,默认 `ws`,**未知值一律回落 `ws`**)、`api_base_url`、`report_path`(默认 `/`,无前导 `/` 时补上)。快照里 camelCase 为 `transport`/`apiBaseURL`/`reportPath`;`remoteURL` 快照值 = `remote_url`,**为空时回落 `api_base_url`**(语义即「出站远端地址」)。**既有键与值不变**(不给出任何新配置时行为与改造前一致:`transport=ws`、`mode=server`)。
|
||||
- **env 兜底**:`CHANNEL_QQ_OBV11_TRANSPORT`(默认 `ws`)/ `CHANNEL_QQ_OBV11_API_BASE_URL` / `CHANNEL_QQ_OBV11_REPORT_PATH`。
|
||||
- **关键取舍**:`mode` 决定**哪一半必需**(`server` 必需 `listen_addr`、`client` 必需 `api_base_url`),另一半只要字段给齐就**一并启用** ⇒ 单个适配器即可完成「收上报 + 回消息」闭环;四个端仍各自可独立选择与测试。任一端配置有误即 `Connect` 失败,**绝不留下半开的监听端口**。
|
||||
- **端口例外**:`http + server` 会用 `listen_addr`,属 `AGENTS.md` §9 已登记的**同一豁免**(design/13 §0 原文即写「WS server / HTTP server」);`listen_addr` 为空则报错、**绝不开端口**、**无默认值**。
|
||||
- **未接**:`get_group_list` 枚举(后续);`access_token` 为空时上报路径**放行**(与既有 `ws + server` 同口径,尚无 fail-closed 开关)。
|
||||
- `PUT /admin/channels/{platform}/config`(owner-only,审计)body `{id?,token?,baseURL?,remoteURL?,listenAddr?,mode?,accessToken?,selfID?,config?,allowGroups?,denyGroups?,allowUsers?,denyUsers?,groupMsgPerMin?,msgPerMin?,maxMessages?,personaId?}` → `201 {data:{persisted:true,path,platform,accountId}}`。**真实持久化 + 热更新**(design/04 §3.1,无需重启):读当前有效配置(channels.json→env 兜底)→ **按 id 打补丁**(`PatchChannel`)→ 落盘 `channels.json`(本地、gitignore,0600)→ `bridge.Reload(platform)` 热替换该平台 adapter(进行中消息用旧、新消息用新);token 写入文件但**永不在响应/日志回显**(GET 列表为掩码)。`platform` 必填;空配置回 `400`;未注入渠道配置服务回 `500 channels_unavailable`、写盘失败 `500 channel_persist_failed`、热重载失败 `500 channel_reload_failed`。`.env`(`CHANNELS_JSON` / `CHANNEL_*_TOKEN`)为兜底。
|
||||
- **`config`(v0.54 起为 patch 语义,与 `personaId` 同构)**:`config` 是**只含本次请求显式给出的键**的补丁,**不是**一整套完整配置。**取值三态**:**键省略(该键不在 `config` 里,或 `config` 整体缺席 / 为 `null`)= 保留该账号已存值**(未给出的键**绝不被抹掉**);**给出非空值 = 覆盖该键**;**显式 `null` 或 `""` = 清除该键**(适配器随后回落到自己的零值;`0` / `false` 是真实取值,**不算空**)。顶层便捷字段(`token` / `baseURL` / `remoteURL` / `listenAddr` / `mode` / `accessToken` / `selfID`)是普通 `string`,**只在非空时**并入补丁(普通 string 分不出「缺席」与「显式空串」)⇒ **要清除 `bot_token` 之类的键,必须走 `config` 里的显式 `null` / `""`**。**为什么**:面板只发送用户本次**碰过**的平台字段——若把「键不存在」当成清除,主人打开一张账号卡直接保存就会**静默抹掉**面板不渲染的凭据(**微信(iLink)**的 `bot_token`/`bot_id`/`owner_id`/`get_updates_buf`,iLink token 是长期凭据;**Telegram** 只填 `webhook_url` 会连 token 一起丢、账号被 `cleanAccounts` 整个删掉)。**仍是整值替换(不走三态)**:`allowGroups` / `denyGroups` / `allowUsers` / `denyUsers` 与 `groupMsgPerMin` / `msgPerMin` / `maxMessages`(线上是值语义)——**省略即清空**,客户端须回传**完整列表 / 非零值**。**未改**:token 要求型平台(kook / telegram)上**显式**清 `bot_token` 仍按 `cleanAccounts` 丢掉该账号(这是明确要求清除凭据,不是误删);「本次请求一个 `config` 键都没给出」在**非 `weixin-ilink`** 平台仍回 `400`(`weixin-ilink` 豁免,以便先建账号再扫码)。
|
||||
- **`personaId`(v0.44 新增,v0.45 起为三态)**:=该账号**答话时的角色 id**(design/22 §2 方案 b,取值来自 `GET /personas`)。**取值三态**:**键省略(或 `null`)= 保留该账号已存绑定**(不动);**显式 `""` = 解绑**(写盘时该账号不带 `personaId` 键);**非空 id = 绑定该角色**。**未知角色 id 回 `400 bad_request`**(不绑一个答不了话的角色;校验只针对**本次真的传了值**的情形,省略继承的已存绑定在写入时已校验过)。**为什么是三态**:面板只在主人**动过**角色下拉时才发这个键——若把「键不存在」也当成解绑,主人只改 token 就会**静默清掉**账号的角色绑定(两端语义相反即丢数据)。**写盘即生效**:保存后 `Reload(platform)` 会重建该平台 adapter,新角色在下一批入站消息上生效(无需重启)。**它不是身份/权限输入**——谁能做什么仍由 channel 层 actor 元数据 + 白名单决定(design/11 §0)。**优先级见 §4.2「角色解析优先级」**:渠道绑定**只**作用于该账号的**主人私聊**,且仅当主会话自身未绑角色;群聊/陌生人会话**强制默认角色**。
|
||||
- `DELETE /admin/channels/{platform}/config?id={accountId}`(owner-only,审计)→ `200 {data:{persisted:true,path,platform,accountId}}`。删除该平台指定账号 → 落盘 `channels.json` → `bridge.Reload(platform)` 热更新(该账号 adapter 优雅关停)。`id` 必填;未注入服务回 `500 channels_unavailable`。
|
||||
- `GET /admin/channels/{platform}/messages?limit=&before=`(owner/admin 读)→ `200 {data:[{id,sessionId,role,content,mode?,mood?,image?,platform?,deviceId?,senderName?,createdAt}]}`。**IM 适配器日志流**(design/16 §4.6 §4):该平台**所有非主人私聊/群聊消息**(隔离会话 `sess-xxx`,非主会话 `web`)以消息流显示,`senderName` 标发送者昵称;未注入 `HistoryStore` 回 `501`。
|
||||
- `POST /admin/plugins/install`(**owner-only + 审计 `plugin.install`**)body `{kind:"mcp",name,config:{transport:"stdio|http|sse",endpoint?|command?,args?,apiKey?,env?}}` → `200 {data:{installed:true,name,kind:"mcp",transport,toolCount}}`。**v1 只装 MCP server**(单二进制无法热装 Go 插件):真实 connect+discover,把发现的工具**热注册**进 capability registry(下次模型请求即注入,无需重启)→ 落盘 `plugins.json`(本地、gitignore、0600,`apiKey` 写入但**永不回显/不落日志**)→ 持有 live server 供后续刷新/卸载。**同名重复安装 = 刷新**(先卸旧再装新,失败回滚旧)。校验:`name` 必填、`kind` 非 `mcp` 回 `400`、transport 非法/`stdio` 缺 `command`/`http|sse` 缺 `endpoint` 回 `400`;**连接/发现/注册失败回 `502 plugin_install_failed` 且不落盘**(诚实,不静默装一半)。未注入 plugin manager 回 `501 not_implemented`。
|
||||
- `POST /admin/plugins/uninstall`(**owner-only + 审计 `plugin.uninstall`**)body `{name}` → `200 {data:{name,uninstalled:true,kind:"mcp"}}`(已装 MCP:`Unregister` 移除其工具 + close + 删 `plugins.json` 条目;**非 MCP/普通能力**走原 `registry.Remove`,返回 `{data:{name,uninstalled:true}}`);未命中回 `404`。
|
||||
- `POST /admin/memories` body `{personaId?,content,scope?}` → `201 {data:{id,scope,text}}`。**手动添加记忆**(owner 私聊直写);`scope` ∈ `user|persona|project`,缺省 `persona`(与「记住…」同路径);无效非空 `scope` 回 `400`;未注入 `memory.Store` 回 `501`。
|
||||
- `POST /admin/memories/search` body `{query,personaId?,topK?}` → `200 {data:{query,personaId,semantic,hits:[{id,scope,text,score}]}}`。真实检索(keyword×衰减);**语义/向量路径**需主会话注入 vector index(`semantic` 当前恒 `false`,诚实反映未接向量器)。**`personaId` 省略时(v0.42 起)回落到库里注册的默认角色**(`default` 标记的那个 character → 其 persona),**不再写死 `"cyrene"`**;若既未传 `personaId` 又没有任何默认角色 → `400 bad_request`(诚实报错,而不是拿一个不可能命中的 id 去查)。
|
||||
- `GET /admin/sessions/{id}/export` → `200 {data:{session:{platform,channelId,sessionId,actorRole,owner,isGroup,memWritable}}}`;`404`。当前导出会话身份元数据(完整消息转录由 M10 提供)。
|
||||
|
||||
**系统设置契约**(`POST /api/v1/admin/system/*`,均 **owner-only**,审计;成功 `{data}` / 失败 `{error:{code,message}}`):
|
||||
- `POST /system/revoke-keys`(无 body)→ `200 {data:{revoked:n}}`。**吊销全部 API key**:清空 key 表(先前签发的所有 key 立即失效),`revoked` 为吊销数量;未注入 API key 服务回 `500 users_unavailable`。
|
||||
- `POST /system/rotate-jwt` body `{newSecret}` → `200 {data:{rotated:true,note:"现有 token 已全部失效,需重新登录"}}`。**轮换 JWT 密钥**:`newSecret` ≥32 位,替换当前签名密钥,**先前 token 全部失效**;`newSecret` 缺失/空白回 `400`;长度 <32 回 `400`;认证服务未配置回 `500 auth_unavailable`。**新密钥永不回显、不落日志**。
|
||||
- `POST /system/backup`(无 body)→ `200 {data:{path,size}}`。**备份数据**:经 SQLite `VACUUM INTO`(含已提交 WAL 内容)快照到 `<dataDir>/backup-<ts>.db`,`path` 为备份文件绝对路径、`size` 为字节数;数据库路径未配置回 `500 backup_unavailable`、快照失败回 `500 backup_failed`。
|
||||
- `POST /system/restore` body `{path}` → `200 {data:{restored:true,backup,note:"已安排恢复,需重启服务后生效"}}`。**恢复数据**:`path` 须为 **dataDir 内**的备份文件(**路径穿越防护**——`filepath.Rel` 校验,`..` 逃逸回 `400`);文件不存在回 `404`;**先自动备份当前数据库**(`backup` 为快照路径)再替换 DB 文件;运行进程持有 DB 句柄,**替换后需重启服务生效**;数据库路径未配置回 `500 backup_unavailable`、替换失败回 `500 restore_failed`。
|
||||
|
||||
**存储状态契约(`GET /api/v1/admin/system/storage`,v0.60)** —— 上面四条是**动作**(写 + 审计),这一条是**只读查询**:owner-only、**不审计**、**只读**(只有 `SELECT`/`PRAGMA` + `os.Stat`,复用组合根注入的活连接——不新开连接池、不写、不迁移、不备份)。它回答「现在这台机器上到底存了什么」,是面板首页「存储」卡的数据源。
|
||||
|
||||
- **请求**:无 body、无 query。`Authorization: Bearer <JWT>`(role=owner)或 `X-API-Key`;**无凭据 / 非 owner → `401`**。
|
||||
- **响应 `200 {data:…}`**(三块,字段名逐字对齐 `internal/services/gateway/admin_system_storage.go`):
|
||||
|
||||
```json
|
||||
{"data":{
|
||||
"main":{"kind":"sqlite","path":"/srv/cyrene/data/cyrene.db","fileBytes":12894208,
|
||||
"walBytes":107872,"shmBytes":32768,"pageSize":4096,"pageCount":3148,
|
||||
"freelistPages":12,"freeBytes":49152},
|
||||
"documents":{"count":37,"bytes":2143567},
|
||||
"vector":{"kind":"none","enabled":false,"ready":false}
|
||||
}}
|
||||
```
|
||||
|
||||
- `main.kind` 恒为 `"sqlite"`(当前唯一的主库引擎);`main.path` = 组合根实际打开的库文件路径(与备份/恢复端点同一个来源)。
|
||||
- `main.fileBytes` = 主库文件字节数(`os.Stat`);`walBytes`/`shmBytes` = `<db>-wal` / `<db>-shm` 边车文件字节数,**合法地不存在时如实为 `0`**(已 checkpoint 或库未开 WAL)。
|
||||
- `main.pageSize`/`pageCount`/`freelistPages` 来自 `PRAGMA page_size` / `page_count` / `freelist_count`(一条 SELECT 取完);`freeBytes = pageSize × freelistPages`,即「已分配但空闲、VACUUM 可回收」的量级。
|
||||
- `documents` = `documents` 表的**行数**与 `SUM(size)`(读表,**不 walk 目录**);空表时 `bytes` 为 `0`(`COALESCE`),**不是** `null`。
|
||||
- `vector` = 可选 pgvector 增强的**运行时真相**(主人 2.4 的困惑点:面板只写「SQLite」会让人以为 PG 没生效):
|
||||
- `kind`:`"pgvector"` = 已配置 `DB_PG_URL`;`"none"` = 未配置(前端显示「未启用(未配置 `DB_PG_URL`)」)。
|
||||
- `enabled`:开关在不在(等价于 `kind != "none"`)。
|
||||
- `ready`:**活着的**向量索引确实是 PostgreSQL+pgvector 后端。配了 PG 但连接/schema 准备失败 ⇒ `enabled:true` + `ready:false`(检索已降级),客户端应显示「已配置但未就绪」,**不要**把 `enabled` 当成「生效了」。
|
||||
- 真源 = 组合根的运行时状态(`DB_PG_URL` 非空 + 活索引的具体类型),网关**不自行探测**。
|
||||
- **不含凭据(红线)**:DSN / 用户名 / 密码 / `postgres://` 全文**一个字都不回**,`vector` 段只有 `kind`/`enabled`/`ready` 三个键(有专门用例断言响应体里搜不到这些串)。
|
||||
- **错误**:
|
||||
- `401`(无凭据 / 非 owner / 凭据无效——与其它 `ownerAuth` 端点同档);
|
||||
- `500 {error:{code:"storage_unavailable",message}}`:数据库**路径**未配置、活**连接**未注入、或页级统计 / `documents` 表读不到(例如库没跑迁移)。**诚实边界**:此时**不返回**任何全 0 的假快照,客户端应显示错误而不是 `0`。
|
||||
- **只读保证**:调用前后 `documents` 行数与数据目录内的文件集合均不变(有用例钉住:既没有写、也没有顺手备份)。
|
||||
- **前端用法**:`adminSystemStorage()`(`frontend/web/src/lib/api/adminSystem.ts`)→ 卡片 `value` = `SQLite 12.3 MiB`、`hint` = WAL/页数/空闲页、详情行 = `documents N 件` 与 `PG 增强 …`;取数走与其它已接线卡片同一条加载/错误态。
|
||||
|
||||
**LLM 用量汇总契约(`GET /api/v1/admin/llm/usage`,v0.64,切片 U1)** —— 只读查询:**owner|admin 读**(与同页 `GET /admin/llm` 同门;含成本与模型/用途分解,**不是**给 `viewer` 的监控投影)、**不审计**、**只读**(只有 `SELECT`,复用组合根注入的活连接——不新开池、不写、不迁移)。它回答「这些模型调用到底花了多少」,是面板 LLM 页「用量」小卡的数据源。字段名逐字对齐 `internal/services/gateway/admin_llm_usage.go`。
|
||||
|
||||
- **请求**:`GET /api/v1/admin/llm/usage?since=&until=&purpose=`,`Authorization: Bearer <JWT>`(role=owner|admin)或 `X-API-Key`。
|
||||
- `since`/`until`:**接受两种形态** —— **RFC3339**(如 `2026-10-05T00:00:00Z`,JS 的 `new Date().toISOString()` 就是这个)或 **Unix 毫秒**(纯数字,如 `1759622400000`;与 `/sessions/{id}/messages` 的 `before`/`after` 游标同一种口径,便于客户端复用)。其它形态 → `400 bad_request`(**绝不静默当成「现在」**)。
|
||||
- **缺省窗口 = 近 24 小时**(`until` 缺省 = 现在,`since` 缺省 = `until − 24h`);`until` 必须**严格晚于** `since`,否则 `400`。
|
||||
- **窗口上限 = 90 天**:跨度超过上限回 `400 {error:{code:"bad_request"}}`(文案点名上限)——**不静默裁剪**,静默裁剪会让客户端把「我没查那么久」误读成「就那么少」。
|
||||
- `purpose`:空格截断后**精确匹配**用途标签(如 `chat` / `memory_extract` / `noise_review`);缺省/空 = **不过滤**。
|
||||
- **响应**:`200`
|
||||
|
||||
```json
|
||||
{"data":{
|
||||
"window":{"since":"2026-10-04T00:00:00Z","until":"2026-10-05T00:00:00Z","purpose":""},
|
||||
"totals":{"calls":128,"prompt":412000,"completion":38000,
|
||||
"cacheHit":356000,"cacheMiss":56000,"hitRate":0.8641},
|
||||
"byPurpose":[{"purpose":"chat","calls":120,"prompt":410000,"completion":37000,
|
||||
"cacheHit":355000,"cacheMiss":55000,"hitRate":0.8659}],
|
||||
"byModel":[{"model":"deepseek-chat","calls":128,"prompt":412000,"completion":38000,
|
||||
"cacheHit":356000,"cacheMiss":56000,"hitRate":0.8641}]
|
||||
}}
|
||||
```
|
||||
|
||||
- `window` 回显**补齐后**的时间窗(缺省值也回显,客户端不必自己猜)与用途过滤(`""` = 未过滤);时间一律 **UTC RFC3339**。
|
||||
- `totals`:整窗合计,键**恰好六个**——`calls`(成功调用次数)、`prompt`(输入侧 token)、`completion`(输出侧 token)、`cacheHit`/`cacheMiss`(输入侧两档)、`hitRate`。
|
||||
- **`hitRate` 口径(重要)**:`cacheHit / (cacheHit + cacheMiss)`,**分母为 0 时下发 `0`**(口径与 `model.Response.CacheHitRate` 同源,绝不下发 `NaN`/`Inf`/编造的 100%)。⚠️ 客户端展示时建议把「分母为 0」与「真 0% 命中」分开:前者显示 `—` + 说明「本窗口没有输入侧缓存明细」,后者才是 `0.0%`(Web 面板的用量小卡就是这么做的)。
|
||||
- `byPurpose` / `byModel`:按用途 / 按模型分解,**元素键 = 分组键 + 上面六个合计键**(分别是 `purpose` / `model`);排序 = **调用次数降序、同次数按键升序**(输出确定,客户端可安全 diff);**无数据时是 `[]` 而非 `null`**。
|
||||
- **不含凭据(红线)**:响应里既没有 `apiKey` / token,也**没有消息正文** —— 那些东西根本没进 `llm_usage` 表(写入方的样本结构里就没有这些字段)。
|
||||
- **错误**:`401`(无凭据 / 凭据无效);`403`(`viewer`;与 `/admin/llm` 同门);`400 bad_request`(`since`/`until` 形态非法、`since` 不早于 `until`、跨度超 90 天);`500 {error:{code:"usage_unavailable"}}`(汇总出口未接线,或查库失败)——**诚实边界**:此时**不返回**全 0 的假汇总,客户端应显示错误而不是 `0`。
|
||||
- **数据来源与写入口径(谁在写这张表)**:每次**成功**的模型调用都会把一条**非敏感**样本(`adapter` / `model` / `purpose` / 六个 token 计数 / `tookMs`)异步落进本机 `llm_usage` 表(迁移 `0017`)。写入是**有界队列 + 单写者 + 批量事务**:队列满即**丢弃并限流 WARN**,**绝不阻塞模型调用**;整批失败只记 WARN,不影响任何一次对话。**凭据与消息正文绝不入库、绝不进日志**。
|
||||
- ✅ **`session_id` 已接线(v0.65 更正)**:loop 在 `Run` 顶部把会话 id 播种进运行 ctx(`model.WithSessionID`),适配器落库时带上它(空串映射为 `NULL`)——**v0.64 写的「当前恒为 `NULL`」已过期**,该列从此真的有值。但**本端点仍不按会话过滤 / 分组**(只按 `purpose` / `model` 分解),**响应形状不变**;sink 未注入或会话未播种时行为逐字不变。
|
||||
- **保留期**:`LLM_USAGE_RETENTION`(默认 `720h` = 30 天;**≤0 = 关闭清理**,样本全留,启动日志会写明「llm usage retention disabled」);启动期清一次 + 此后每小时清一次。
|
||||
- **鉴权/审计档位说明**:它是**查询不是动作**,因此**无审计记录**;矩阵归类见 §2.0 的 ②(owner/admin 只读档)。
|
||||
- **前端用法**:`adminLlmUsage()`(`frontend/web/src/lib/api/admin.ts`)→ 卡片「用量(近 24h)」显示**调用数 / 输入 token / 缓存命中率**(`cacheHit + cacheMiss = 0` 时命中率显示 `—`);取数走与其它已接线卡片同一条加载/错误态(**读不到就显示错误,不显示 0**)。
|
||||
|
||||
**知识库 RAG 契约**:
|
||||
- `GET /admin/rag` → `{"data":{"documents":[{id,name,desc?,chunks,embedded}],"hits"?:[{docId,docName,chapter?,text,score,semantic}]}}`。`hits` 仅在携带 `?query=` 时出现;`embedded` 反映部署是否配置向量增强(未配置为待嵌入)。
|
||||
- `POST /admin/rag` body `{"name","desc?","content"}` → `201 {"data":{"name"}}`。**导入文档**:后端分块(空行分段,超长段按固定长度切)并落项目索引(项目+章节),立即可被关键词/行上下文检索;未注入 `RagStore` 回 `501`。语义/向量嵌入为优雅降级(知识库 Store 不直属向量索引,导入文档走 keyword/line 路径;语义检索仍对既有 project-linked 记忆生效)。
|
||||
- `POST /admin/rag/query` body `{"query":"..."}` → `{"data":{"query":"...","hits":[{docId,docName,chapter?,text,score,semantic}]}}`。检索走项目索引关键词+行上下文;配置 pgvector/向量增强时并入语义/向量命中(`semantic:true`)。
|
||||
- `POST /admin/rag/delete` body `{"id":"..."}` → `200 {"data":{"id":"...","deleted":true}}`。**真实删除**:从项目索引删除该文档条目并**级联删除其全部章节**(单事务),删除后 `GET /admin/rag` 不再出现、检索不再命中。`id` 缺失/空白回 `400`;文档不存在回 `404 not_found`;未注入 `RagStore` 回 `501`(**owner-only + 审计 `rag.delete`**)。
|
||||
|
||||
**可观测性契约**(`GET /api/v1/admin/observability/*`,均需 owner/admin;`metrics`/`trace`/`audit` 的 `?limit=N` 缺省 50、上限 500):
|
||||
- `GET /metrics` → `{"data":{"totalRequests","qps","avgLatencyMs","maxLatencyMs","perMethod{}","perPath{}","statusCounts{}","llmCalls","subagentEvents"}}`。`qps` 为近 1 秒窗口计数;`llmCalls`/`subagentEvents` 由实际观测到的 trace span 按 `kind` 累计(Tracer 未注入时为 0)。**Token/成本本端点不统计、不返回**(不伪造指标)——**token 与成本由 v0.64 新增的 `GET /admin/llm/usage` 单独回答**(见 §2「LLM 用量汇总契约(v0.64)」)。
|
||||
- `GET /trace?limit=100` → `{"data":[{id,parentId?,name,kind,status,startedAt,endedAt?,durationNs,props?,events?]}`。`kind` ∈ `llm_call|tool_call|orchestration|state_transition|subagent|memory|guardrail`;`durationNs` 为纳秒;`events` 为 `[{time,type,props?}]`。
|
||||
- `GET /audit?limit=100` → `{"data":[{id,who,what,when,result}]}`。`id` 为整数自增;`when` 为 RFC3339;来源为 SQLite `audit_logs` 表(未注入 AuditStore 时退化为进程内存)。
|
||||
- `GET /logs?level=&limit=` → `{"data":[{time,level,source,msg}]}`(**后端日志 tail**,v0.35)。
|
||||
- **来源**:logger 进程内**有界环形缓冲**(容量 2000,满则丢弃最旧),只存 logger 已产生的记录,**不额外打印、不重渲染、不落库**;控制台/文件输出完全不受影响。`source` 取自 `logger.WithSource` 的 `source=` 属性(`logger.Scoped` 的记录回退为 `module/sub`),无来源记 `""`。
|
||||
- **排序**:`time` **倒序**(最新在前)。`time` 为 **RFC3339(UTC,秒精度)**,与 `trace.startedAt` / `audit.when` 一致;`level` ∈ `debug|info|warn|error`;`msg` 为记录消息。
|
||||
- **查询参数**:`level` ∈ `debug|info|warn|error`(大小写不敏感)为**最低等级过滤**(如 `level=warn` 返回 warn + error),**空/缺省 = 全部**,未知值回 `400 invalid_level`;`limit` 缺省 `200`、上限 `1000`(小于等于 0 或非法值回落缺省)。日志量超过 `limit` 时按时间倒序截断(先按 level 过滤再取 `limit`,缓冲全量 2000 条内扫描)。
|
||||
- **诚实边界**:组合根未注入日志环形缓冲(非二进制主链路启动)时回 **`501 not_implemented`**,不返回伪造/空的成功 tail。日志内容为 logger 已产出内容,**敏感信息按既定规范本就不落日志**(本端点不新增任何打印)。
|
||||
|
||||
**通知聚合契约**(`GET /api/v1/admin/notifications`,**owner/admin 读**;design/10 §3 #16):
|
||||
|
||||
- `GET /admin/notifications?type=&before=&limit=` → `200 {"data":[{id,type,title,body?,source,createdAt}]}`,按 `createdAt` **倒序**(同毫秒按 `id` 倒序稳定)。
|
||||
- **查询参数**:`type` ∈ `system|channel|backthink|audit`(**空/缺省 = 全部**,未知值回 `400 invalid_type`);`limit` 缺省 `50`、上限 `200`;`before` 为 `createdAt`(epoch 毫秒)游标,**含游标本身**(`createdAt <= before`——两源时间戳精度不同(审计秒/纳秒、backthink 毫秒),页边界可能落在同一毫秒,**客户端按 `id` 去重**)。缺省/非法 `before` = 从最新开始。
|
||||
- **字段**:`id` = `<source>:<原生 id>`(`audit:<audit_logs.id>` / `backthink:<backthink_records.id>`),**跨页稳定、可去重**;`source` ∈ `audit|backthink`;`createdAt` 为 epoch 毫秒(无法解析的审计时间戳记 `0`,不伪造时间)。`title`/`body` 由各自源派生:审计项 `title = what`(如 `channel.open`)、`body = "<who> · <result>"`;backthink 项 `title = "主动推送(<trigger> · 已送达|未送达)"`、`body =` 结论摘要(≤200 rune,超出加 `…`)。
|
||||
- **类型归类**(审计源按操作前缀派生):`channel.*` → `channel`;`system.*` / `plugin.*` / `policy.*` → `system`;其余审计操作(persona/user/memory/rag/session/upload/audio/…)→ `audit`;backthink 主动推送 → `backthink`。
|
||||
- **数据源与诚实边界**:**只聚合现有源**(审计 `audit_logs` + backthink 记录),**不新建 notifications 表**。backthink 仅 `shouldPush=true` 的记录算通知(纯思考记录不推给主人);**未注入的源 → 该类型为空,不伪造**(无 backthink 服务即无 `backthink` 项);源报错回 `500 notifications_failed`(**不静默少给**)。本版为**只读 feed**:未读/已读游标后置(不建重表)。
|
||||
|
||||
## 3. WebSocket `/ws/chat`(实时)
|
||||
|
||||
- **握手**: `ws://<host>:8080/ws/chat?token=<JWT>` 或 `Authorization: Bearer`;未认证在握手前回 `401` JSON。
|
||||
- **下行(服务端→客户端)**: 经 bus 广播 `{type:"<event>", data:..., turnId?, seq?, sessionId?}`(`turnId`/`seq` 为流式关联字段,见 §3.1;`sessionId` 为**会话持久化(design/16)新增**,能确定会话的事件带上,供客户端把该帧路由到**它自己那个会话**的消息桶(**v0.54 起:非激活会话的帧不再被丢弃**);**v0.36 起结构化流帧 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.*` 也带 `sessionId` + `turnId`**——多会话并发流式时客户端据此把 delta 落到各帧自身 `sessionId` 的消息桶,见 §3.1)——`message.start/delta/end` / `thinking.start/delta/end` / `tool_call.start/delta/end`(以上模型流帧的 `data` 另带 `streamId`,v0.50)/ `tool_execution.start/update/end`(工具执行三事件,v0.50)/ `turn.start` / `turn.end`(回合边界帧,v0.50)(结构化流,design/04 §1.1),以及向后兼容的 `stream_chunk` / `stream_end` / `thinking`(思维链) / `mood`(情绪变化,见 §3.7) / `reminder` / `device_update` / `tool_used`(工具回显) / `subagent_event`(逐代理进度) / **`subagent_result`**(后台批次的最终结果,见 §3.10) / **`approval_notice`**(主代理代审小字,见 §3.11) / `graph_update` / **`message_persisted`**(会话持久化增量,见 §3.3)/ **`message_edited`**、**`message_deleted`**(消息编辑/删除增量,见 §3.8)/ **`session_read`**(已读游标推进,见 §3.9)/ **`observability_log`**、**`observability_trace`**、**`observability_metrics`**(可观测实时推送,见 §3.12,v0.55)。**会话编排状态(design/15,均为广播,`data.sessionId` 区分会话)**:`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(见 §3.2)、**`chat_duplicate`**(客户端幂等键重复到达的明确回执,v0.56 新增,见 §3.13);另有 **`steer_ack`**(忙时会话里排队的那条用户消息**已被在飞回合吃进同一回合**的回执,v0.57 新增,见 §3.14)——它经**同一状态通道**,但由 **hub 直接广播、不经 bus**,帧壳带 `sessionId`(`data` 内不重复);因它**携带具体消息身份**,**不属**上面这组「状态机信号」。语音回复走 `voice_output`(定向回请求连接,非广播);**队列满的定向拒绝**走 `{type:"error", data:{code:"chat_busy", …}}`(只发发送者,见 §3.2);语音回合本身走与文本相同的编排路径(见 §3.15)。
|
||||
- **下行帧的角色门(v0.48,design/11 §7.1)**: 下行帧按**类型**分两类,由后端**唯一一处**收口判定(**不在白名单里的帧默认只发 `owner` + `admin`**,fail-closed):① **状态机信号**(白名单,**不含任何内容**)→ **所有已认证连接**(含 `viewer`):`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(§3.2)、`session_read`(§3.9);② **其余全部** → **仅 `owner` + `admin`**:流式内容 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.start/update/end` / `turn.start` / `turn.end`(**v0.50 新增的这几种同属本档**——工具进度与回合边界对 `viewer` 也是内容相邻信息,fail-closed 默认即只发 owner+admin,未扩白名单)/ `stream_chunk` / `stream_end` / `thinking`(§3.1)、持久化 `message_persisted`(§3.3)/ `channel_persisted`(§3.4)/ `message_edited` / `message_deleted`(§3.8)、系统内部 `graph_update`(§3.5)/ `subagent_event` / **`subagent_result`(§3.10,v0.51 新增的这帧同属本档)** / **`approval_notice`(§3.11,v0.52 新增,同属本档)** / `task_output` / `task_done` / `tool_used` / `capacity_awaken` / `mode_switch`、私有状态 `mood`(§3.7)/ `device_update` / `reminder`、**可观测推送 `observability_log` / `observability_trace` / `observability_metrics`(§3.12,v0.55 新增,同属本档——未进 7 个状态机信号白名单,fail-closed 只发 owner+admin)**、**`chat_duplicate`(§3.13,v0.56 新增,同属本档——它虽由编排器状态通道下发,但未进那 7 个状态机信号白名单,故 fail-closed 只发 owner+admin)**、**`steer_ack`(§3.14,v0.57 新增,同属本档——它携带具体消息 id 与模型步序号,未进那 7 个状态机信号白名单,故 fail-closed 只发 owner+admin;经 hub 直接广播、不经 bus)**。
|
||||
- **上行帧与握手回放的角色门(v0.49,design/11 §7.2)**: 与上面那条下行门**成对**,收的是同一个缺口的两处残余——① **客户端→服务端(上行)JSON 帧**:只有 `owner` / `admin` 连接可以发;非 `owner`/`admin`(即 `viewer`)连接发来的**任何**上行 JSON 帧都**默认被拒**,回复是**定向**(只回发送者)的 `{type:"error", data:{code:"forbidden"}}`,**不广播、不驱动模型、不落库、不返回任何内容**。被拒帧:`chat` / `interrupt` / `subagent_ask` / `task_output_query` / `voice_input` / `mode_switch`——**未登记的新上行帧同样默认拒**,要放行必须先在 design/11 §7.2 登记白名单(实现上那把「默认拒」也必须同步改为登记表,不能直接删)。② **握手 `history` 回放**:仅 `owner` + `admin` 连接收到;`viewer` 连接建立后**不收**回放。**协议级帧不受影响**:WS ping/pong 与正常 close 照常(否则连接会掉),被拒的上行帧也不会关掉连接。
|
||||
|
||||
> **这是「谁被主动推送」的门,不是数据可见性**:`viewer` 的 12 条只读监控端点(`viewerAuth`,v0.40)**照旧**(含 `GET /personas` 的角色级 `mood`);`viewer` **仍可连** `/ws/chat`(连接鉴权不变),只是收不到第 ② 类帧。定向回请求连接的帧(`voice_output` 等)不受影响。**上一条的门只管「推给谁」;「谁能发、谁能拿回放」见上面 v0.49 那条**——`viewer` 连上后**发不出任何指令**、也**收不到 `history` 回放**。
|
||||
|
||||
> **`history` 回放事件(design/16,新增)**:WS 握手升级成功后、该连接下一帧即收到 `{type:"history", sessionId:"web", data:{messages:[{id,sessionId,role,content,mode?,mood?,image?,platform?,deviceId?,personaId?,clientMsgId?,createdAt}]}}`——回放主会话最新 50 条(`thinking` 已剥离;默认主会话,次级会话由客户端按激活会话走 `GET /sessions/{id}/messages` 拉取)。客户端据此在重连/新开连接后补全上下文。消息体含 `senderName?`(第三方昵称,design/16 §4.6,主人私聊为空)与 `personaId?`(v0.42,说话时的角色,design/22 §7-5,空 = 默认角色)。**角色门(v0.49,design/11 §7.2 A)**:这条回放**只发 `owner` + `admin` 连接**;`viewer` 连接建立后**不会**收到 `history` 帧(它若需要只读数据,走 §1 的只读 REST 端点)。
|
||||
|
||||
### 3.1 结构化流事件(`message.*` / `thinking.*` / `tool_call.*` / `tool_execution.*` / `turn.start/end`)
|
||||
- **事件壳**:`{type, sessionId?, turnId?, seq?, data}`, `data` 为**类型化结构**(非 `any`)。`seq` 为 hub 级单调递增序号,供客户端将一次突发的流式事件排序关联。**`sessionId` 为事件所属会话、`turnId` 为该轮助手回合 id(均 v0.36 起带上)**:`sessionId` 由 loop 在回合边界注入(模型适配器在 loop 之下、不感知会话,恒留零值;loop 外无会话上下文的事件该字段为空),`turnId` 由网关每个回合新生成并挂进运行上下文——同一会话同时只有一个 in-flight 回合,故**同轮内所有帧的 `turnId` 一致**。客户端据此把 `message.delta` / `thinking.delta` / `tool_call.*` / `tool_execution.*` **路由到该帧自己 `sessionId` 的会话消息桶**(**v0.54 起:非激活会话的帧不再被丢弃**——后台会话的正文继续累积、切过去即可看到完整这一轮);**只有「单槽忙态」**(`session_busy` / `session_idle` / `session_interrupted` 与 `stream_end` 的 `sessionBusy` 收口,见 §3.2)**仍按 `sessionId === activeSessionId` 判定**,以免别的会话的忙/闲帧串到屏幕上的会话。
|
||||
- **`streamId`(v0.50,模型流帧 `data` 内可选字段)**:`message.*` / `thinking.*` / `tool_call.*` 帧的 `data` 带 `streamId`(如 `{"delta":"…","index":0,"streamId":"stream-a1b2…"}`)——**同一次模型流的多帧共享同一 id**,loop 在每次模型流(一次 ChatStream 调用)开始时生成;多步工具轮的第二次模型调用取**新** id。`turnId` 标记「这轮回答」(整轮恒定),`streamId` 标记「这轮里的哪一条流式消息」;一个回合含工具调用时会有多个 `streamId`。`tool_execution.*` 与 `turn.*` 帧不属于任何模型流,**不带** `streamId`。
|
||||
- **`message.start`**(`{index, streamId?}`) / **`message.delta`**(`{delta, index, streamId?}`) / **`message.end`**(`{index, streamId?}`):正文段。`delta` 为正文 token 增量;客户端对 `message.delta` 累加到当前消息正文;`message.start/end` 划定正文段边界,**不**结束整轮(结束由 `stream_end` 负责)。
|
||||
- **`thinking.start`**(`{index, streamId?}`) / **`thinking.delta`**(`{delta, index, streamId?}`) / **`thinking.end`**(`{index, streamId?}`):思维链段(前端折叠块)。`thinking.delta` 累加增量。
|
||||
- **`tool_call.start`**(`{id, index, streamId?}`) / **`tool_call.delta`**(`{id, index, delta, streamId?}`) / **`tool_call.end`**(`{id, index, streamId?}`):工具调用中间态;`tool_call.delta` 携带参数增量(如 Anthropic `input_json_delta`)。
|
||||
- **`tool_execution.start`**(`{tool, phase:"running", id?}`):工具**进入执行**(v0.50)。`tool` 为工具名;`phase` 为细粒度执行阶段(枚举与 §3.5 流程图节点 status 同一套 `running/done/error`);`id` 为 provider 工具调用 id(可关联同轮的 `tool_call.*` 帧,provider 未给 id 时省略)。
|
||||
- **`tool_execution.update`**(`{tool, phase:"running", delta?, id?}`):工具**运行中**的进度/局部输出,一次执行可多帧(v0.50)。**现状**:wire 映射已实现并验证,但工具分发器尚无中途进度上报源,loop 目前不产出该帧(start/end 有真实生产者)——出现生产者后无需再改契约。
|
||||
- **`tool_execution.end`**(`{tool, phase:"done"|"error", id?}`):工具执行**完成或失败**(v0.50);`phase:"error"` 即失败(design/19 流程图的 tool 节点据此翻 `error` 态;错误详情不进该帧,走流程图节点日志的脱敏摘要)。
|
||||
- **`turn.start` / `turn.end`**(无 `data` 载荷):回合边界帧(v0.50),loop 在 Run 的起止发出、经 ws_bridge 下行;帧壳的 `sessionId`/`turnId`/`seq` 即边界信号,客户端用它括起「一轮回答」的完整区间(含其全部模型流与工具执行)。`turn.end` 覆盖回合所有出口(最终答案 / 工具循环安全网终止 / 预算耗尽 / 步骤出错);**回合终态信息(`stopReason`/`steps`)仍由既有 `stream_end` 携带**,两帧分工不重叠。内部自主流程(backthink / 子代理)被静音,不发出这两帧。
|
||||
- **向后兼容(v0.50 对 `tool_execution` 的替换说明)**:v0.49 及之前的单帧 `tool_execution.update {id,index,delta}`(`delta` 形如 `"start:工具名"` / `"end:error:工具名"` 的拼串)**不再由 loop 产生**——按旧拼串解析的客户端必须迁移到三事件(`tool_call.*` 帧形状不变)。`turn.start/end` 与各帧 `data` 里的 `streamId` 为纯新增键,老客户端按未知帧/未知键忽略即可。现有 Web 前端未消费这些新帧,行为不变。
|
||||
- **向后兼容**:未映射/无法确定的事件仍走 `stream_chunk` + `stream_end`(非流式回退路径)。
|
||||
- **上行(客户端→服务端)**: `mode_switch` / `chat`(跑 loop 并广播流式) / `voice_input` / `subagent_ask` / `task_output_query` / `interrupt`。
|
||||
- **`interrupt`**(上行,客户端→服务端):`{sessionId, scope?}`(**`scope` 为 v0.56/A2 新增**)→ 会话编排器中断(design/15 §3)——取消该会话当前在跑的任务(context cancel)并回 `session_interrupted`;用户说「停下/算了/换个话题」时发。**`scope` 取值域 `{"", "all", "current"}`**(**大小写敏感,只容忍首尾空白**):
|
||||
- **省略 / 空串 / `"all"`**(三者同义)= 取消**当前全部在途路** **+ 清空待处理队列**,会话立即回到空闲(**逐字不变的现状语义**,老客户端只发 `{sessionId}` 的行为完全不变);`session_interrupted.dropped` = 被清空的 pending 条数、`mode` = `idle`、`activeTurns` = `0`。
|
||||
- **`"current"`** = **只取消在途路、保留待处理队列**(多路时**每一路都取消**——省略 `turnId` 的 scope 就是「作用于每一路」):队列里的消息按编排器正常续跑规则继续执行(带上会话尾部上下文),一条都不丢。此时 `dropped` 恒为 **`0`**,`mode` 如实报告该会话当时的**忙碌通道**(`thinking`/`executing`/`subagent`,**不是 `idle`**)。用途:客户端「打断」与「追加队列」是两种模式,若打断一律连队列清空,用户选「打断」时连发的多条消息会被静默丢弃、只剩一个 `dropped` 数字。
|
||||
- **计数键(v0.65,纯增量)**:`session_interrupted.data` 增 `cancelledTurns`(本次**真的取消了几路**)与 `activeTurns`(清点后仍在途的路;`"all"` 时恒 `0`);并**不发 `session_idle`**(与今天一致,客户端靠本帧收口)。
|
||||
- **未实现(v0.65 诚实边界)**:精确停**某一路**的 `interrupt.turnId` **本版未实现**(省略 `turnId` 即作用于全部在途路,见上);`{sessionId, turnId}` 里的 `turnId` 今天**会被忽略**。
|
||||
- **其它取值** → **只回发送者** `{type:"error",data:{code:"bad_request"}}`(**定向**:不广播、**不打断任何会话**);显式报错,绝不静默按默认执行(否则客户端以为队列保住了、实际被清空,正是 A2 要修掉的那类缺陷)。
|
||||
- 命中空闲会话为 no-op(两种 scope 都是)。
|
||||
- **`chat` 帧的上行字段**(v0.56/G4 + v0.59 + v0.65):`{sessionId, text, image?, deviceId?, mode?, clientMsgId?, documents?, parallel?}`——`clientMsgId` 为**客户端自带的幂等键**(可选):同一会话内同一个非空值只承认第一条,重复到达**不落库、不起回合**,回新下行帧 **`chat_duplicate`**(§3.13);省略 = 与加入该字段之前逐字相同。`documents`(v0.59)为文档附件引用数组(§1 行 16/17 与 §1.2)。**`parallel`(v0.65,可选)** = 「把本条作为**独立一路**处理」的显式声明:**只有会话正忙、且单会话并发额度未满(默认 2 / 硬上限 3 / `<2` 关闭真并行)才照准**——照准则立即起第二条路(帧壳 `turnId` 即那一路),额度满、或真并行被关掉、或未声明则落回既有排队语义并在 `pending_ack` 带 `reason:"parallel_limit"`(见 §3.2);**空闲时无效果**;**服务端不做启发式自动升级**。**REST `POST /api/v1/chat` 这几个可选项语义完全相同**(重复回 `200 {data:{duplicate:true,messageId,sessionId}}`、照准并行回 `202 {data:{parallel:true,turnId}}`,见 §1 行 3)。
|
||||
- **`voice_input`**(上行,客户端→服务端):`{audio?, format?, text?, sessionId?, deviceId?, clientMsgId?}`。`audio` 为 base64(或 data: URI)音频,`format` 为其编码(`wav`/`mp3`/`ogg`);`text` 为已转写文本时跳过 STT;`deviceId` 为**客户端具体端标识**(design/16 §4.5,如 `voiceassist:myapp:v1`,可选),随该语音轮次落库、聊天气泡可标注来源。**`clientMsgId`(v0.59)=客户端自带的幂等键**(可选,**与 `chat` 帧同形** ⇒ 语音也有幂等):同一会话内同一个非空值只承认第一条,重复到达**不落库、不起回合、不发音频**,回既有下行帧 `chat_duplicate`(§3.13);省略 = 与加入该字段之前逐字相同。**语音回合现在走与文本回合同一条路**(进编排器 → 落库 → 流式 → 跨端同步 → 忙时排队),详见 **§3.15**。服务端走 STT→agent 聊天→TTS(design/06 §1),回复经 `voice_output` 定向回该连接。**真 STT/TTS 已接通阿里百炼**(`DASHSCOPE_API_KEY` 必填:STT=`qwen-audio-3.0-asr-flash`、TTS=`cosyvoice-v3-flash`+`longxiaochun_v3`);无凭据时后端走降级链(cloud → edge-tts/espeak(未实现)→ `silent` 空音频)。无注入语音服务时回 `error` 帧(`voice unavailable`)。
|
||||
- **`voice_output`**(下行,服务端→客户端,**定向**):`{audio, format, text, reply, sessionId, deviceId?, provider?}`。`audio` 为 base64 合成音频,`format` 为其编码,`text` 为识别文本,`reply` 为助手回复文本;**v0.59 新增 `sessionId`(恒有)与 `deviceId`(非空才有)**——多端同时说话时客户端据此区分归属。**当合成音频为空(silent 降级)或 TTS 失败时带 `provider`**(如 `"silent"`,非空音频时省略)——客户端据此提示「没生成声音」,但该帧**仍照常发出、不算业务错误**;**空正文一律走同一条降级信号,绝不拿 `stopReason` 顶替成语音内容**。**时点与投递口径见 §3.15**(回合结算时才到、定向目标 = 发起那条连接、断线不补投)。
|
||||
- **向后兼容**:未映射/无法确定的事件仍走 `stream_chunk` + `stream_end`(非流式回退路径)。
|
||||
|
||||
---
|
||||
|
||||
### 3.2 会话编排状态事件(design/15)
|
||||
|
||||
> **角色门(v0.48,design/11 §7.1)**:本节的 6 个状态信号**都在白名单内**——它们只报「会话忙/闲/排队」,**不含任何内容**,所以**所有已认证连接**(含 `viewer`)都收得到;真正的正文/推理内容走 §3.1 的流帧,那些**只发 `owner` + `admin`**。
|
||||
- **触发**:同一会话内,`chat` 到达时若「她忙」(正在跑 loop/工具循环/子代理/长回复)→ 新消息**不阻塞**、入该会话 pending;这一层的状态经以下事件推给前端,供「正在做 xx… 有新消息稍后回」轻状态展示。
|
||||
- **`session_busy`**:会话进入忙碌态(`{sessionId, mode, activeTurns}`,`mode` ∈ `idle|thinking|executing|subagent`)。**`activeTurns`(v0.65,纯增量)** = 该会话当前**在途路数**(含正在收尾的路;单路时非 0 即 1)——并行时它才是真正的路数。
|
||||
- **`pending_ack`**:忙时新消息已入队(`{sessionId, mode, queueDepth, reason?}`)——「收到,稍后回」。**`reason`(v0.65,纯增量、可选)**:**只在「客户端显式声明了并行(`parallel:true`)、本条却落回排队」时出现**,取值恒为 `"parallel_limit"`(额度已满,**或**真并行被 `<2` 关掉——客户端问了并行而没拿到);**其它情况该键完全缺席**,帧与今天逐字相同(绝不伪造)。
|
||||
- **`pending_drain`**:忙完开始消费下一待处理消息(`{sessionId, mode, queueDepth, activeTurns}`)。
|
||||
- **`session_idle`**:会话回到空闲(`{sessionId, mode, activeTurns}`)。⚠️ **时点(v0.65,行为变更,仅并行时生效)**:从「队列清空」收紧为「**所有在途路都已收尾,且 pending 为空**」——单路时这个时点与今天**逐字相同**(此刻在途路数必为 0);真并行下,只要还有任一路在飞(哪怕它正在续跑队列里的下一条)就**不报空闲**。
|
||||
- **`session_interrupted`**:用户 `interrupt` 中断(`{sessionId, mode, dropped, cancelledTurns, activeTurns}`)——语义随 `scope`(v0.56/A2):`scope:"all"`(**也是省略 / 空串**)= 取消**当前全部在途路** + pending 清空,此时 `mode = idle`、`dropped` = 被清空的 pending 条数、`activeTurns = 0`;**`scope:"current"` = 只取消在途路、pending 保留**(多路时**每一路都取消**,因为省略 `turnId` 的 scope 就是「作用于每一路」),此时 `dropped` 恒为 **`0`**、`mode` 为该会话当时的**忙碌通道**(`thinking`/`executing`/`subagent`,**非 `idle`**)。`dropped` 恒等于**真的被丢弃**的条数(绝不把「保留」报成丢弃)。**两个计数键(v0.65,纯增量)**:`cancelledTurns` = 本次**真的取消了几路**,`activeTurns` = 清点之后仍在途的路(`scope:"all"` 恒为 `0`)。**`scope:"all"` 不发 `session_idle`**(与今天一致,客户端靠本帧收口)。
|
||||
- **多路下的未实现项(v0.65,诚实边界)**:精确停某一路的 `interrupt.turnId`、编排器发出的 **`turn_begin`** 帧、`steer_ack` 的**帧壳 `turnId`** **本版均未实现**(见 §5 v0.65 条目 J 段);`graph_update` 亦**未按路分桶**(两路会同会话互相顶掉)。客户端**不要**依赖这些帧/键。
|
||||
- **`pending_full`**:单会话 pending 达上限(默认 5)拒绝入队(`{sessionId, mode, max}`)——防无限积压(design/15 §4)。**v0.59(G5)载荷增两个可选键**:`clientMsgId?`(= **被拒那条消息**的客户端幂等键;该消息未携带则**整个键缺席**,绝不伪造)与 `messageId?`(**仅当该消息此前已落库 / 已认领时**才回填)。⚠️ **服务端绝不在此处新铸 id**:队列满的拒绝发生在幂等键**认领之前**,代铸一个 id 会让客户端拿同一 `clientMsgId` 重试时被判成「重复到达」而**永远进不来**。
|
||||
- **`pending_full` 的定向伴生帧(v0.59 / G5,此前无文档登记)**:队列满时**除广播 `pending_full` 外**,服务端还会向**发送这条消息的那条连接**单独发一条定向错误帧 `{type:"error", data:{code:"chat_busy", message, sessionId, clientMsgId?, messageId?}}`——**只发发送者、不广播**(其它端按广播帧里的 `clientMsgId` 自行对齐即可)。`code` 与 REST 的 `409 chat_busy` **同码**(客户端**一个 code 覆盖两条入口**)。用途:WS 路径此前没有与 REST `409` 等价的回执,本地乐观气泡只能永远挂在「发送中…」;有了它就能精确回滚那一条。
|
||||
- **上下文续接**:忙完重开时新回合**带上一轮上下文**(`{mode, queueDepth}` 之外的续接由服务端完成),非清空重来(design/15 §2.2)。
|
||||
- **客户端侧(v0.54)**:`sessionBusy` / `pendingCount` 是**单槽全局**状态(不按会话分桶)——前端只在帧的 `sessionId === activeSessionId` 时置忙/置闲(`frontend/web/src/lib/ws/handlers/session.ts`,置忙与置闲两侧同一把尺子),与「消息按自己的 `sessionId` 入桶、非激活会话不丢弃」不同(见 §3.1)。这是 `design/16 §4.2` 已登记的**单槽债**(多会话并发时忙态徽标只反映屏幕上的会话)。
|
||||
|
||||
### 3.3 消息持久化增量事件(`message_persisted`,design/16)
|
||||
- **触发**:每一条 user / assistant 消息**落库后**由编排层广播——Web 主会话路径走 `persistUserMessages`/`persistAssistantReply`,**IM 渠道主人私聊**(design/16 §4.4)由 channelbridge 把消息**落主会话 `web`**(带 `platform` 标记)后复用**同一** `message_persisted` 广播(同一 hub 的 `BroadcastPersisted`),保证 Web 端实时镜像 IM 消息。
|
||||
- **载荷**:`{type:"message_persisted", sessionId, data:{message:{id, sessionId, role, content, mode?, mood?, image?, platform?, deviceId?, senderName?, personaId?, createdAt}}}`——`role` ∈ `user|assistant`;`id` 为**后端生成的消息 id**(客户端据此去重);`platform` 为**来源平台**(`web` 或渠道如 `telegram`/`weixin-ilink`/`qq-obv11`);`deviceId` 为**具体端/客户端标识**(design/16 §4.5,如 `web:chrome:a1b2c3` / `weixin-ilink:conf0:wx_uid`);`senderName` 为**第三方发送者昵称**(design/16 §4.6,群聊/陌生人消息带,主人私聊为空,`omitempty`);**`personaId`(v0.42,`omitempty`)=该消息说话时的角色 id**(design/22 §7-5;空 = 全局默认角色,即升级前的全部历史),客户端据此按角色渲染名字/头像;**`clientMsgId`(v0.56,`omitempty`,G4)= 客户端自带的幂等键**(空 = 该消息没有幂等键:老客户端、IM 镜像、历史行),客户端据此把本地乐观气泡对齐到服务端真相;`thinking` 已剥离(design/16 §6)。**`documents?`(v0.59 起;v0.63 补 `reason`)**:载荷里的消息与 `historyMessage` **同结构**(同一结构体),因此带附件的回合会带 `documents` 数组(`[{name,mime,size,chars?,truncated?,status?,reason?}]`,0 个附件时省略该键)。⚠️ **时点**:本帧在消息**落库那一刻**发出(用户消息早于该回合的抽取),因此帧里的 `status`/`reason` **可能还没有结论**;抽取结论由回合结束后的回写补进库里,**以读取面(`GET /sessions/{id}/messages`、握手回放)为准**(有附件的回合请按读接口渲染「读到了 / 读不到 + 原因」)。
|
||||
- **注:群聊/陌生人(非主人)消息**只在**该 IM 适配器日志界面**以消息流显示,**不**经 `message_persisted` 进 Web 主会话(design/16 §4.6 §3-4);该日志流由 `GET /api/v1/admin/channels/{platform}/messages` 拉取,落库后另经 `channel_persisted`(§3.4)实时推送。
|
||||
- **客户端处理**:把帧按 `data.message.sessionId`(缺省时用帧壳 `sessionId`)写进**该会话自己的消息桶**(**v0.54 起:非激活会话的消息不再被丢弃**,切过去即可见,见 §3.1);按 `id` upsert——本端已预建的**同 role 乐观气泡**(本地 `uid` 无后端 `id`)被**归一**(`uid` → 后端 `id`),异端无本地气泡则直接追加,两者最终收敛为**同一条 `id` 相同的消息**,「A 发消息、A 与 B 都无需刷新且各出现一次」。
|
||||
|
||||
### 3.4 适配器消息实时事件(`channel_persisted`,design/16)
|
||||
- **触发**:每一条**群聊/陌生人(非主人 / 隔离 sess-xxx)消息**落库后由 channelbridge `persistIsolated` 广播;该消息**只写自己的隔离会话**,**绝不**写主会话 `web`,`channel_persisted` 仅是事件通知,不是把内容塞进主会话/Web 上下文(design/16 §4.6 §3-4)。
|
||||
- **载荷**:`{type:"channel_persisted", sessionId, data:{message:{id, sessionId, role, content, mode?, platform?, deviceId?, adapterId?, senderName?, createdAt}}}`——与 `historyMessage` wire 一致;`adapterId` 为**产生该消息的适配器注册名**(如 `kook:3bef...`,`adapter != ''`);`sessionId` 为隔离会话 id(`sess-xxx`,**非** `web`);`role` ∈ `user|assistant`;`senderName` 为第三方昵称;**`clientMsgId`(v0.56,`omitempty`,G4)= 客户端自带的幂等键**(空 = 无幂等键,如渠道入站消息)。
|
||||
- **事件类型分流**(`BroadcastPersisted` 按 `sessionId` 分流):`sessionId == "web"` 或其 `web:<uuid>` 前缀 → **`message_persisted`**(Web 聊天实时同步);**其它**(隔离 `sess-xxx`)→ **`channel_persisted`**(适配器日志专用)。
|
||||
- **客户端处理**:Web 聊天**无** `channel_persisted` handler,天然忽略、不进会话;**IM 适配器日志页**订阅该事件,按 `adapterId === 当前适配器`(或总览 `adapterId != ''`)过滤后**仅追加**(按 `id` 去重、不整页重载),首次拉取仍由 `GET /api/v1/admin/channels/messages` + 轮询兜底。
|
||||
|
||||
### 3.5 实时全链路流程事件(`graph_update`,design/19)
|
||||
|
||||
> **角色门(v0.48,design/11 §7.1)**:`graph_update` 属「系统内部」帧 → **仅 `owner` + `admin`**(`viewer` 收不到,与本节「仅主人 Web 会话发布」及 design/19 §1「flow 可视化仅 owner/admin」一致)。REST 侧可见性不变。
|
||||
- **触发**:每次会话轮次中,loop 的每个 trace span **开始/结束时**由 `loop.FlowPublisher` 经 bus `ws.graph_update` 广播增量 **FlowGraph**。**发布范围为三类会话(v0.50 口径,裁决 01.4.5)**:`web`(Web 主会话) / `web:<uuid>`(Web 子会话,含 IM 主人私聊镜像落主会话的形态) / `sess-backthink`(后台思考的固定内部会话,design/20 §2 的「思考链路图」由此取数)——**其余隔离会话(IM 群聊/陌生人的 `sess-xxx`)与子代理轮次不发布**,防敏感信息泄向面板。管理员/主人 Web 端订阅后实时渲染「输入 → 思考(LLM) → 工具 → 输出」主干图。**tool 节点的 `status` 由细粒度 phase 驱动**(v0.50,design/04 §1.1 + design/19 §1):工具执行期间为 `running`,执行结束按成败翻 `done` / `error`——与 §3.1 `tool_execution.*` 帧的 `phase` 同一套枚举、同一处决策。
|
||||
- **载荷**:`{type:"graph_update", data:{id, nodes:[{id,label,kind?,status?,startedAt?,endedAt?,props?,log?}], edges:[{from,to}], sessionId, startedAt, endedAt}}`。
|
||||
- **子代理分支节点的 `props.persona`(v0.47,design/22 §8-2)**:`subagent_spawn` 指定了目标角色派发时,该子代理在父会话图里的**分支根节点** `props` 会多一个 `persona` 键(值 = 目标角色 id),标注它**正在扮演的角色**;**不指定角色派发时该键不出现**(与旧版逐字一致),客户端按未知键忽略即可。
|
||||
- `id` 为 `sessionId-round`(每轮一图);`sessionId` 为会话 id;`startedAt/endedAt` 为 RFC3339。
|
||||
- `kind` ∈ `input|llm|tool|subagent|memory|guardrail|orchestration|output`。
|
||||
- `status` ∈ `idle|running|done|error`(`running` = span 在跑 / `done` = 成功 / `error` = 失败)。
|
||||
- `props` 仅含白名单摘要(如 `stop_reason` / `error_kind` / `result_summary`),**工具原始参数与密钥绝不前送**;长文本截断到 ~80 字。
|
||||
- **节点 `props` 的可选键(v0.52,裁决 11.4.2 / 11.4.3)**——旁路节点(`memory` / `guardrail` / `orchestration`)的 span 现已**真实产生**,其 `props` 新增下列键。**全部为纯增量、可选**;值在**发布前已脱敏**(凭据形状 → `[redacted]`、超长截断);**帧名、事件名、顶层字段集一字未动**:
|
||||
- **tool 节点**:`args_keys: string[]`(该次调用的**全部参数名**,字典序,**值一律不出现**)、`args_summary: {[key]: string}`(**仅放行表内、且非密钥类**的键才给值——密钥类键由 `trace.IsSecretKey` 判定(凭据形状 / `*_key` / `*token*` 等),**连值都不给**;单值 ≤**80 rune**、最多 **24** 条,表外键与超出条数的键连条目都没有,客户端按 `args_keys` 逐键列表、未放行的显示「值未展示」)。
|
||||
- **guardrail 节点**:`action`(`guardrail.input` | `guardrail.output`)、`allow: bool`、`elapsed_ms`;检查本身报错时另带 `error`。
|
||||
- **memory 节点**:`action`(`recall` | `write`)、`scope`(`owner` | `restricted`)、`hits` / `bytes` / `count` / `failed`、`elapsed_ms`——**只记元数据,不含任何记忆正文**。
|
||||
- **orchestration 节点**:`subagent`(被派发的子代理 id)、`persona`(指定目标角色时才有)、`elapsed_ms`。
|
||||
- **input 节点(v0.57,裁决 13.4.5 目标态)**:`steered: bool`——本回合在**回合内边界**吃过新到达的用户消息(steer)时出现;**纯增量、可选**,老客户端忽略即可(标记只并入既有 input 节点,不新建节点、不改标签,图仍只有一条主干)。
|
||||
- `log` 为该节点累积的 span 事件 `[{time,type,props?}]`(设计 §2「节点实时日志」)。
|
||||
- **客户端处理**:`graph_update` → `setFlowGraph(parseGraph(data))`(`src/lib/wsParse.ts`)——**图按 `data.sessionId` 落进该会话的「图桶」**(v0.54,裁决 16.4.2;前端 `frontend/web/src/store/sessionCache.ts` 的 `putGraph` / `currentGraph`,桶上限 12 张、超出时丢最久未更新的一张)。管理端「全链路流程」页(`admin/modules/Flow.tsx`)与 Agents 页取**激活会话**的图(该会话还没有图时回落主会话最近一张);**Backthink 页**(`admin/modules/Backthink.tsx`)取 `sess-backthink` 的图——后台思考的图**不再顶掉**管理员看的「当前会话」图。节点可点开看参数/结果/实时日志;无图时诚实显示「暂无流程」。
|
||||
|
||||
### 3.6 主动推送事件(`proactive_message`,design/12)
|
||||
- **触发**:后台思考(backthink)自主决定「此刻主动发一条」时,经 `SendProactive` 投递给**当前所有在线 owner/admin 连接**(viewer / 未登录连接**永不**投递)。`SendProactive` 返回实际送达的连接数——**0 时调用方回退 IM 桥**(`im_bridge`),把该消息经渠道主动推送发给你(KOOK/微信等已接)。
|
||||
- **载荷**:`{type:"proactive_message", data:{platform?, channelId?, channelType?, content, contentType?, sender?, timestamp?, messageId?}}`(由 `adapter.UnifiedMessage` 脱敏投影:**不含 RawData / 媒体字节 / token**,绝不让凭据漏到前端)。
|
||||
- **客户端处理**:仅 owner/admin 收到;渲染成一条「她从某平台主动发来」的气泡(内容 + platform + sender + timestamp)。**与 `message_persisted` 的区别**:`message_persisted` 是你与她的对话回显;`proactive_message` 是**她未经你输入**主动推的消息(如 IoT 完成事件、后台思考结论),两路不同、不要混用去重。
|
||||
- **红线**:owner-only;payload 已脱敏;backthink 每次主动推送**只发一个决策平台**(决策渠道→最近活跃→旋转回退),Web 端与 IM 桥为两条投递路,客户端不要据此断言「所有平台都发了」。
|
||||
- **推送出口(D1,对客户端无新端点)**:在 WS/IM 投递之外,后端会把主动消息**异步镜像**到「推送出口」——主通道是**主人自建统一推送服务**(`PUSH_EXPORTER_URL` webhook,HTTP POST JSON `{source,title,content,platform,timestamp}`,可带 `Authorization: Bearer <PUSH_EXPORTER_TOKEN>`),回退是**个推 Getui**(字段预留、SDK 未接入)。该出口只影响**出站投递**,不改变 WS 事件契约;未配/不可达时只记日志(注明「个推回退待接入」),**不影响**上面的 WS/IM 投递。移动端接入自建推送服务即可收到主人主动消息。
|
||||
|
||||
### 3.7 情绪事件(`mood`,design/01 §2 情绪状态机 + 好感度)
|
||||
|
||||
> **角色门(v0.48,design/11 §7.1)**:`mood` 属「私有状态」帧 → **仅 `owner` + `admin`**,`viewer` 不再收到它;但角色当前情绪**仍可读**——`viewer` 照旧调 `GET /personas` 拿到该角色的 `mood` 字段(REST 只读档不变,见 §1)。
|
||||
- **触发**:每轮对话结束(loop 回合边界,每轮一次、**非**每步)把该轮用户消息的情绪信号应用到**该角色自己的**情绪状态机(按 persona 隔离),且**状态真的变化时**才广播一次 `mood`;状态不变(含「一直 calm」)**不发**,避免每轮噪声。
|
||||
- **载荷**:`{type:"mood", data:{mood, personaId, affection}}`——`mood` ∈ `calm|happy|excited|sad|angry`(情绪状态机 state id,与消息体 `mood` 同源同值);`personaId` 为该角色 id(情绪/好感度跟随角色,不跟随行为模式);`affection` 为该角色当前好感度(受角色配置 `affection.min/max` 约束)。角色级数值,**不带** `sessionId`。
|
||||
- **与消息体 `mood` 的关系**:assistant 消息体的 `mood` 是该轮结束后的**当前情绪**(每轮都写,见 §3.3),而 `mood` 事件只在**变化时**发——前者答「这条回复时她什么心情」,后者答「她的心情刚刚变了」。
|
||||
- **群聊/陌生人(`MemWritable=false`)**:情绪**只读不写**——第三方消息**不**改变角色情绪与好感度、**不**发 `mood` 事件、该轮 assistant 消息 `mood` 为空(与记忆只读同一隔离边界,design/02 §3 / design/11 §2)。
|
||||
- **状态转移与衰减**:按角色配置的转移表(缺省内置表)与 `emotion.decayRate`——有情绪信号的轮次**沿合法转移走一步**指向对应状态;无信号轮次**衰减一步**趋向 `calm`;`decayRate ≤ 0` 关闭衰减。好感度按角色 `affection.delta` 随情绪轮次增减(warm `+delta` / cold `−delta`,clamped 到 `[min,max]`)。
|
||||
- **客户端处理**:`mood` → `setMood(data.mood)`(`src/lib/ws.ts`),顶栏/侧栏显示「心情 · <mood>」;消息体内 `mood` 由聊天气泡显示(`chat.tsx`)。
|
||||
- **状态持久性**:情绪与好感度为**进程内、按角色**状态,**重启回到角色配置的 `initialState`**(design/01 未要求落库;如需跨重启保留,待主人确认后加表)。
|
||||
|
||||
### 3.8 消息编辑 / 删除实时事件(`message_edited` / `message_deleted`,D3 实时同步)
|
||||
- **触发**:`PATCH /api/v1/messages/{id}`(改 content)或 `DELETE /api/v1/messages/{id}` **成功写库后**由网关广播(与 `message_persisted` 同一条 hub 广播路径,**全连接广播 + 客户端按会话过滤**——hub 无按会话订阅;消息体的唯一真源始终在后端,这两个事件只驱动渲染同步,不携带新真源)。
|
||||
- **`message_edited`**:`{type:"message_edited", sessionId, data:{id, content, mode?, mood?, createdAt?, ...}}`——`data` 为**编辑后的完整消息 DTO**(与 §3.3 的 `message.message` 同形,至少含 `id`/`content`/`sessionId`/`createdAt`,另带 `role`/`mode?`/`mood?`/`image?`/`platform?`/`deviceId?`);`thinking` 已剥离(与 `message_persisted` 一致)。**只改 `content`**,`role`/时间戳/平台等保持原值。
|
||||
- **`message_deleted`**:`{type:"message_deleted", sessionId, data:{id}}`——只带被删除消息的 `id`(`sessionId` 为该消息所属会话,由后端在删除前读取后填入;客户端按它过滤,**不**据此删除别的会话的行)。
|
||||
- **客户端处理**:与 `message_persisted` **同一路由规则**——按帧的 `sessionId` 写进**该会话自己的消息桶**(**v0.54 起:不再按激活会话丢弃**)(`message_deleted` 的 `sessionId` 缺失时按 `id` 兜底:消息 `id` 全局唯一,逐桶搜索、只会命中它自己那条)。`message_edited` → **按 `id` 原位更新该条的 `content`**(不新增行:找不到该 `id` 即忽略,避免把别的会话的消息注入当前列表);`message_deleted` → **按 `id` 从列表移除**。两个操作都**不触发整页重拉**——这正是它相对「重拉绕行」的收益。
|
||||
- **失败不广播**:`404`(消息不存在)/ `400`(内容非法)/ `501`(未注入 HistoryStore)**不**产生任何事件,客户端不会因一次失败请求而丢掉本地消息。
|
||||
|
||||
### 3.9 已读游标事件(`session_read`,D4)
|
||||
- **触发**:`POST /api/v1/sessions/{id}/read` **成功推进游标后**由网关广播(与 `message_persisted` 同一条 hub 广播路径:**全连接广播 + 客户端按 `sessionId` 过滤**,hub 无按会话订阅)。用途是**同账号多端清红点**:一端读了,其它端不用重拉列表就把该会话的未读清零。
|
||||
- **载荷**:`{type:"session_read", sessionId, data:{readAt}}`——`readAt` 为该会话推进后的**已读游标**(毫秒 Unix 时间戳,与该会话消息的 `createdAt` 同单位)。
|
||||
- **客户端处理**:仅当 `sessionId === activeSessionId`(或该会话在自己维护的会话列表里)时处理 → 把该会话 `unreadCount` 置 0(更精确的做法是按 `readAt` 重算本地未读;本地没有更完整的数据时直接清零也不会错,因为 `readAt` 只前进)。**它只驱动渲染**:唯一真源仍是后端 `chat_sessions.read_at`,漏收该帧的端在下一次 `GET /api/v1/sessions` 自然收敛(不需要补发、不需要重连)。
|
||||
- **失败不广播**:`400`(`before` 非法)/ `404`(会话不存在)/ `501`(未注入 HistoryStore)**不**产生事件,其它端不会误清红点。
|
||||
|
||||
### 3.10 后台子代理结果事件(`subagent_result`,design/05 §9)
|
||||
|
||||
> **角色门(v0.48 下行门的默认档,design/11 §7.1)**:`subagent_result` 属**内容帧**(含子代理的结论正文)→ **仅 `owner` + `admin`**,`viewer` 收不到;它**不在**那 7 个状态机信号白名单里(要进白名单须先在 design/11 §7.1 登记「不含内容」的理由)。
|
||||
- **触发**:`subagent_spawn` 派发的批次**转后台后**(前台只等 2 秒,design/04 §8.5)批次结算时,编排层把合并结果发布到 bus 主题 `subagent.result`,经 `ws_bridge` 中继成这一帧。**在 2 秒窗口内跑完的批次不发此帧**——它的结果已作为工具结果回给了模型(与本节同一份投影)。
|
||||
- **载荷**:`{type:"subagent_result", sessionId, data:{sessionId, status:"done", summary, agents:[{id, goal, role?, mode?, status, summary?, text?, steps?}]}}`
|
||||
- **帧壳 `sessionId` = 派生该批次的父会话**(`data.sessionId` 同值)——客户端据此把结果归到那个会话的消息流,多会话并发时不串话。派发回合没有会话归属时(如循环外派发)两处都省略(该帧即成无归属帧)。
|
||||
- `data.status` 恒为 `"done"`(帧只在批次结算后发出,无论批次里有没有失败/中止的子代理——逐代理结论看 `agents[].status`);`summary` 是「子代理汇总: 共 N 个, M 成功…」的一行汇总。
|
||||
- `agents[]` 与派发工具返回给模型的结果**同形同值**:`id`(同时也是 `subagent_event` 帧与流程图泳道节点的 id)、`goal`、`role` / `mode`(绑定的角色/行为模式,未指定时省略)、`status` ∈ `done|failed|aborted`、`summary`(该子代理的执行总结)、`text`(结论正文,**截断到 4000 字**)、`steps`(循环步数)。
|
||||
- **与 `subagent_event` 的分工**:`subagent_event` 是**逐代理进度**(派发/开始/完成/失败/中止,`{id,name,status,role,mode}`,**不含结论**);`subagent_result` 是**整批的最终结果**(含每个子代理的总结与结论文本),一个批次只发一次。
|
||||
- **客户端处理(现状)**:后端已按上述形状推送;**现有 Web 前端尚未消费该帧**(无 handler,按未知帧忽略),因此这一版的可见效果是「帧到达 `owner`/`admin` 连接」,而**不是**「聊天气泡里出现一条后台结果」。要渲染成「后台任务完成」提示,按 `sessionId` 过滤后追加即可(读 `data.summary` 与 `data.agents[].summary`/`text`);**不要**把它写进本地消息列表当持久消息——该结果**不落库**,后端转录里没有对应行,重连回放不会补发。
|
||||
- **边界(诚实说明,v0.51)**:本帧只做「**会话作用域的实时推送**」——**不**在父会话的持久化转录中新增消息,也**未**经 design/15 的会话消息编排器排队/续接;所以 design/05 §9 的「结果回填主会话」目前停在**实时可见**这一层(此前该结果在 `ws_bridge` 处被静默丢弃,前台只剩进度帧)。
|
||||
|
||||
### 3.11 主代理代审小字事件(`approval_notice`,design/11 §4)
|
||||
|
||||
> **角色门**:内容帧(含角色名/动作/文案)→ 仅 `owner` + `admin`;未进 7 个状态机信号白名单(fail-closed 默认)。
|
||||
- **触发**:loop 的工具准入门判定某次调用属敏感操作(design/11 §4 清单,当前实现的三个操作类别:执行命令 / 修改文件 / 网络写方法——`GET`/`HEAD`/`OPTIONS` 与读文件、检索属非敏感)并经主代理代审后广播。非敏感调用**不**产生该帧。
|
||||
- **载荷**:`{type:"approval_notice", sessionId, data:{actor, tool, op, detail, explain, allowed, at}}`
|
||||
- `sessionId` = 该次 Run 所属会话(子代理的敏感调用归属到**派生它的父会话**,与 §3.10 同口径)。
|
||||
- `explain` = 固定文案 `「<actor> 想要干什么,我审查后批准了」` / `「<actor> 想要干什么,我审查后拒绝了」`(**由后端产出**,客户端**原样渲染**,不要自拼)。
|
||||
- `detail` = 动作摘要(工具 + 关键参数,**目标部分截断到 80 rune**);`allowed=false` 表示该敏感调用**被拦截未执行**(无代审者/代审报错也走 fail-closed 拦截,小字同样是「拒绝了」)。
|
||||
- `op` ∈ `执行命令|修改文件|网络访问`;`actor` = 想动手的角色名;`at` = RFC3339Nano。
|
||||
- **审计**:每次代审在 `audit_logs` 落一行:`who`=角色名、`what`=`approval.<工具名>`、`result`=`granted|denied`;查询走既有 `GET /api/v1/admin/observability/audit` 与 `/admin/notifications`。
|
||||
- **客户端处理**:与流式帧同一会话过滤规则(`sessionId === activeSessionId`),把 `explain` 作为聊天流里的一行小字渲染、`detail` 作副文本(现 Web 前端已按此实现,见 `frontend/web/src/components/status.tsx` 的 `ApprovalNoticeHint`)。
|
||||
- **约束**:不新增端点、不改既有帧;老客户端按未知帧忽略即可。
|
||||
|
||||
### 3.12 可观测实时推送(`observability_log` / `observability_trace` / `observability_metrics`,v0.55)
|
||||
|
||||
> **角色门**:三个帧**都不在** `frameReachesAll` 白名单 ⇒ fail-closed **只发 `owner` + `admin`**;`viewer` 收不到,继续走既有 12 条只读监控 REST 档(v0.40)。未进 7 个状态机信号白名单(要进须先在 design/11 §7.1 登记「不含内容」的理由——它们是**运行时数据面**,不属于状态信号)。
|
||||
- **取代关系**:可观测面(日志 / 指标 / trace)从「面板 1s 轮询 REST」改为 **WS 帧推送**(裁决 05.4.3 + 16.4.3-WS,design/10 §5)。**既有 REST 端点完全不变**(路径 / RBAC / `level` / `limit` 语义 / 响应形状一字未动),仍然可用,并在被读取时兼任「订阅握手」。
|
||||
- **订阅(推送开启条件)**:owner/admin 凭据**读取** `GET /api/v1/admin/observability/{logs,metrics,trace}` **任一个**即建立订阅(绑定**该次登录凭据**,键 = sub+role+iat)。该凭据的 WS 连接**全部断开**即退订;订阅表空后推送循环自行退出(**无常驻 goroutine、无周期性开销**)。**这不是轮询**:那次读取本身照常返回完整引导数据,此后吃推送。
|
||||
- **节拍**:≤1s 一拍,**无新数据不发帧**(空转时一帧都不发)。
|
||||
- **三个下行新帧**(**纯新增**,既有帧名/形状零改动;帧壳 `{type,data}`,**不带** `sessionId` / `turnId` / `seq`——它们不属任何会话):
|
||||
|
||||
| 帧名 | 载荷 `data` | 上限 | 触发 |
|
||||
|---|---|---|---|
|
||||
| `observability_log` | `{entries:[{time,level,source,msg}…]}`(**最新在前**,与 `GET /admin/observability/logs` 同形) | 200 条 | 订阅后**新增**日志;一拍最多一帧,无新条目不发 |
|
||||
| `observability_trace` | `{spans:[…]}`(**最新在前**,与 `GET /admin/observability/trace` 同形) | 100 条 | 订阅后**新完成**的 span |
|
||||
| `observability_metrics` | `AdminMetrics` **本体(扁平)**,与 REST 同形:`totalRequests,qps,avgLatencyMs,maxLatencyMs,perMethod,perPath,statusCounts,llmCalls,subagentEvents` | — | 与**上次发出的**不同才发(序列化指纹比较,键排序确定) |
|
||||
|
||||
- **初始锚点**:订阅建立时以「当前最新」为锚(日志取最新条目时间、trace 取当前写入序号、指标取当前快照指纹),因此循环**不会**把环形缓冲里的历史整段灌成巨型帧——历史由面板那次引导读取提供。
|
||||
- **单帧上限的语义**:一拍的突发超过上限时只发**最新**的那些(更旧的条目本就落在面板保留窗口之外,与 REST 尾部 `limit` 上限同口径),不丢「看得见」的数据。
|
||||
- **分级过滤改在客户端本地做**:这三个帧经 bus 广播给**所有 `owner`/`admin` 连接**(hub 无按连接订阅、也无按连接分级——订阅只决定「推送循环跑不跑」),因此 `?level=` 那种服务端过滤不适用于推送;面板按本地等级选择过滤即可。
|
||||
- **前端落点**:`frontend/web/src/lib/ws.ts` 的 `subscribeObservabilityFrames()`(帧**不进应用 store**,只有打开着面板的视图在听,其余时候安静丢弃);面板挂载时各读一次对应 REST 作为引导(logs 用 `limit=200`),推送帧**按业务键去重合并、倒序、窗口 200**(`frontend/web/src/admin/modules/observabilityStream.ts`)。
|
||||
- **向后兼容**:纯新增帧,老客户端按未知帧忽略即可;既有 REST 与其它帧/端点/鉴权一律不变。
|
||||
|
||||
### 3.13 客户端幂等回执(`chat_duplicate`,v0.56 / 协同 G4)
|
||||
|
||||
> **角色门**:`chat_duplicate` **不在** `frameReachesAll` 的 7 个状态机信号白名单里 ⇒ fail-closed **只发 `owner` + `admin`**(`viewer` 收不到)。它由编排器的状态通道(与 `session_busy` 等同一出口)下发,因此**帧壳带 `sessionId`,没有 `turnId` / `seq`**。
|
||||
|
||||
- **触发**:`chat` 上行(REST `POST /api/v1/chat` 或 WS `chat` 帧)带了**非空** `clientMsgId`,而同一会话内**已经认领过同一个值**——即弱网 / 切后台下的客户端超时重发。判重由 `(session_id, client_msg_id)` 的**部分唯一索引**原子裁决(只覆盖非空键),因此并发重发只有一个赢家,不存在「先查再插」的竞态窗口。
|
||||
- **载荷**:`{type:"chat_duplicate", sessionId, data:{sessionId, clientMsgId, messageId}}`——`messageId` = **既有那条用户消息的 id**(客户端据此把本地乐观气泡对齐到服务端真相,替代「靠文本相等」的脆弱匹配)。它是**广播给所有 `owner` / `admin` 连接**的状态信号(hub 无按会话订阅),没发过该 `clientMsgId` 的客户端按值自行忽略即可。
|
||||
- **不做什么(红线)**:重复到达**不落库、不起回合、不发任何内容 / 流式新帧**——第二次绝不产生第二条用户消息、第二个模型调用。
|
||||
- **REST 侧同义**:`POST /api/v1/chat` 的**同步**与 **`stream:true`** 两条路径重复到达都回 `200 {data:{duplicate:true,messageId,sessionId}}`(见 §1 行 3)。
|
||||
- **回填与可见性**:`clientMsgId` 随消息落库并**回填进消息 DTO** ⇒ `message_persisted`(§3.3)/ `channel_persisted`(§3.4)/ 握手 `history` 回放 / `GET /sessions/{id}/messages`(§1 行 6)都能看到(**空则省略**),客户端可在重连后从读接口收敛本地乐观消息。
|
||||
- **向后兼容**:上行 `clientMsgId` 是**可选**字段——**省略时行为与加入该字段之前逐字相同**(老客户端零影响,历史行该键为空);`chat_duplicate` 为**纯新增帧**,老客户端按未知帧忽略即可。
|
||||
- **迁移**:新增 `0013_chat_client_msg_id`(up:`ALTER TABLE chat_messages ADD COLUMN client_msg_id TEXT NOT NULL DEFAULT ''` + 部分唯一索引 `idx_chat_messages_client_msg_id ON chat_messages (session_id, client_msg_id) WHERE client_msg_id != ''`;down:**先删索引再删列**——SQLite 的 `DROP COLUMN` 在列仍被索引引用时会失败;全新库由 `history.NewStore` 幂等前滚同一列与索引)。
|
||||
|
||||
### 3.14 回合内 steer 的消费回执(`steer_ack`,v0.57 / 裁决 13.4.5 目标态)
|
||||
|
||||
> **角色门**:`steer_ack` **不在** `frameReachesAll` 的 7 个状态机信号白名单里 ⇒ fail-closed **只发 `owner` + `admin`**(`viewer` 收不到)——它**携带具体消息身份**(客户端据此定位到某一条气泡),而不只是会话忙闲。它由编排器的状态通道(与 `session_busy` 等同一出口)**经 hub 直接广播,不经 bus** 下发,因此**帧壳带 `sessionId`,没有 `turnId` / `seq`**。
|
||||
|
||||
- **触发**:一个**忙时会话**收到了新用户消息(已入该会话 pending、且**已落库**),在飞回合在**回合内边界**把这条消息**吃进同一回合**的那一刻。**没有可注入消息的边界一帧都不发**。
|
||||
- **载荷**:`{type:"steer_ack", sessionId, data:{messageId, atStep}}`——帧壳带 `sessionId`(`data` 内**不重复**该键)。
|
||||
- `messageId` = **那条用户消息的权威 id**(后端生成,与 `message_persisted` 的 `data.message.id` 同源),客户端据此把本地乐观气泡对齐到服务端真相,**不要靠文本相等**这类脆弱匹配。
|
||||
- `atStep` = **吃进它的模型步序号,从 `1` 开始(1 基)**:被注入的消息出现在**这一模型步**的可见视图里。
|
||||
- **一批多帧**:同一批次吃进 N 条消息就发 N 帧(每帧一条消息的 `messageId`),**不合并**——FIFO 保持(design/15 §2.1)。
|
||||
- **语义(客户端怎么渲染)**:该消息**已被在飞回合吃进**(已从待处理队列移除),**但尚未被正面回答** ⇒ 客户端应把对应气泡渲染为「**已读、未答**」的**灰态**,直到该回合的 `message.*` / `turn.end`(§3.1)到达;**不需要第二个确认帧**。它与「忙『稍后回』」的 pending 气泡**不是同一个态**:灰态表示**这条已经在被处理的那一轮里了**。
|
||||
- **触发时机**:只在**回合内边界**产生——loop **每一步开头**(上一次模型步与它那一批工具调用**都已结束**、下一次模型调用**尚未开始**,因此此刻没有工具在飞,注入**不可能取消**任何已发起的工具)回调转向源;**只有该边界真的匹配到 pending 里、且已落库的用户消息时才发帧**(无新消息则一帧不发,这是常态),**不是每个 step 都发**。
|
||||
- **与 `pending_ack` / `session_busy` 的关系**:后两者描述**队列深度与忙闲**(「收到,稍后回」「她正忙」);`steer_ack` 描述**队列里哪一条已被本回合吃进**(精确到 `messageId`)。客户端可用「pending 条数 − 已收 `steer_ack` 条数」表达 design/15 §5 的「队列里有 N 条、其中 M 条已被本回合吃进」(忙态本身仍是**单槽**,见 §3.2)。
|
||||
- **不发的四种情形(失败一律让路)**:未接转录存储 / 读该会话转录失败 / 该消息**尚未落库** / 本回合世代已被打断或被更新的回合取代(认领 `pending` 失败)——一律**不发帧、也不注入**,该消息**留在 pending**,照旧作为自己的回合被回答,**绝不静默消失**。**未接转向源的运行**(子代理、后台思考、未接网关的循环)**不产生该帧**。
|
||||
- **只吃本编排器排队过的消息**:候选必须在**该会话转录的最近 20 条窗口内**(每次边界重读转录取权威 `id`),且**必须在本编排器的 pending 里**——由别的写入方(如 IM 适配器自有路径)落库、本编排器从未排队的消息**保持不注入**(那条路径正在自己作答它,注入会让同一条被答两次);超出窗口的 pending 消息同样留队、由各自的回合作答(**不丢**)。
|
||||
- **兼容性**:**纯新增帧**,老客户端按未知帧忽略即可;既有帧 / 端点 / 鉴权 / REST / `.env` 一律不变。**多路下的已知缺口(v0.65 诚实边界)**:本帧的**帧壳仍不带 `turnId`**(v0.57 起如此,v0.65 的并行基础管线**未**接线它)——真并行时同一会话有两条交错的路,客户端**无法从本帧判断那条消息被「哪一路」吃进**;按 `turnId` 两级分桶是后续切片的事(见 §5 v0.65 条目 J 段)。
|
||||
- **客户端现状(诚实边界)**:**Web 前端目前尚未消费该帧**(`frontend/web/src` 内无 `steer_ack` handler),故今天的可见效果是「帧到达 `owner` / `admin` 连接」;要落成灰态,按本节口径加 handler 即可(`messageId` 定位气泡、`message.*` / `turn.end` 收灰)。
|
||||
|
||||
### 3.15 语音回合(`voice_input` / `voice_output`,v0.59:进编排 + 归属 + 降级信号)
|
||||
|
||||
> **行为变更(对语音客户端是实质变化)**:语音回合**不再「转写完直连 loop 就跑完」**——它进**会话编排器**,与文本回合**走同一条路**。因此语音问过的事**进历史**、**跨端可见**、**参与忙/排队**。帧形状的变化只有两处(§3.1 已改):`voice_input` 增可选 `clientMsgId`、`voice_output` 增 `sessionId`(恒有)/ `deviceId`(非空才有)。
|
||||
|
||||
- **同一条路(v0.59)**:一次 `voice_input` 在 STT 之后产出的用户消息 = **转写文本**,经编排器提交:
|
||||
- **落库**:用户消息(转写文本)与助手回复都进 `chat_messages` ⇒ `GET /sessions/{id}/messages` 拉得到、握手回放补得到;
|
||||
- **实时帧**:照发 `message_persisted`、流式 `message.*` / `thinking.*` / `tool_call.*` / `turn.start` / `turn.end`、状态帧等(与文本回合零差别);
|
||||
- **跨端同步**:另一端连着 WS 也能看到这一轮(按各自 `sessionId` 入桶);
|
||||
- **忙时排队**:目标会话正忙 ⇒ 该语音消息**按编排器既有规则入 pending**——`pending_ack` / `pending_drain` / `pending_full` / `session_interrupted` 的**语义与形状零变化**,只是多了一个消费者;**排队满**同样回 §3.2 的广播 `pending_full` + **定向 `error{code:"chat_busy"}`**(带 `clientMsgId?`/`messageId?`)。
|
||||
- `deviceId` 照旧落库(design/16 §4.5);`platform` 走存储默认 `web`。
|
||||
- **幂等(v0.59)**:`voice_input` 增可选 `clientMsgId`(**与 `chat` 帧同形**)。重复到达 → 编排器回既有下行帧 `chat_duplicate`(§3.13),**不产生新回合、不发音频、不落库**。客户端重连后带**同一个** `clientMsgId` 重试是安全的。
|
||||
- **`voice_output` 的时点(重要,别当成「发出去就立刻有音频回来」)**:
|
||||
- 它在**该回合结算时**才到——可能**晚于若干状态帧**(`pending_ack` → `session_busy` → … → `session_idle`),也可能由**续跑回合**(忙完消费 pending 的那一轮)或**被 steer 注入的那一回合**(§3.14)产生;
|
||||
- 忙时先收 `pending_ack`;`steer_ack` 可作「已读未答」的灰态(§3.14);
|
||||
- 因此**不能用「发完 N 秒没音频」判定失败**,也不要用到达顺序推断回合归属——用帧里的 `sessionId` / `deviceId` 判归属。
|
||||
- **定向目标与断线(诚实边界)**:
|
||||
- `voice_output` **只发「发起这条 `voice_input` 的那条 WS 连接本身」**——服务端**不按 `deviceId` 路由**(`deviceId` 只是载荷里的归属标注),也**不会**发给同账号的其它端;
|
||||
- **连接断开后不补投**:服务端的收尾回调发现该连接已关就**记一条带来源的 WARN 并丢弃**(音频是「回复已落库」之后的增强,绝不静默假成功);**助手回复本身已经落库**,所以重连后从历史 / 回放能拿到正文,只是拿不到那段音频;
|
||||
- 重连后带同一 `clientMsgId` 重发**不会**重复起回合(幂等),但**也不会**补回那次音频(音频不落库)。
|
||||
- **降级信号(与 §3.1 同一口径)**:`provider` 只在**没有真实音频**时出现(`"silent"` 或实际档位),覆盖两种情况——**空正文**(回合没产出文本,如被取消)与 **TTS 失败 / 降级为空音频**。客户端据此提示「没生成声音」,但**该帧仍照常发出**:客户端总能看到这条语音回合的终点,而不是永远等一个不会来的 `voice_output`。**绝不把 `stopReason`(`stop` / `idle`)当语音内容**。
|
||||
- **未接编排器时**(未接线的构建 / 裸 hub 单元测试)保留旧直连路径:STT → 直接跑 loop → 立即合成并回音频。两条路径共用同一个帧构造,`voice_output` 契约一致。
|
||||
- **引擎与降级链**:真 STT/TTS 走阿里百炼(STT `qwen-audio-3.0-asr-flash`、TTS `cosyvoice-v3-flash` + `longxiaochun_v3`);无凭据时 cloud → edge-tts/espeak(未实现)→ `silent` 空音频。
|
||||
- **IM 渠道入站语音(v0.65,与上面这条 Web 语音回合是两条路径)**:IM 适配器收到的语音消息在**入站流水线最前端**被归一为文本(写回 `msg.Content`),因此**丢空判据 / 最小打扰判定 / 落库 / 模型可见消息看到的是同一份文本**——语音内容从此**进上下文**(此前纯语音消息会在丢空判据处被整条丢掉)。三条口径:① **平台自带转写优先**(微信 iLink 的 `voice_item.text` 非空 ⇒ **不调 ASR**,不重复付费);② **静默档位必须被识破**(语音链末端总是成功,只看文本会把「没有可用的识别后端」当成一次成功的空转写)⇒ 只认 `STTOutcome.Transcribed()`;③ **绝不静默、绝不留空消息**——转写不成功时落**一行中文诚实提示**(讲清没转写成功 + 原因方向:未配置 / 鉴权失败 / 欠费 / 限流 / 超时 / 静默降级 / 静默音频),**不含上游错误原文与密钥**。鉴权失败 / 欠费 / 限流按原因分类做**临时熔断 + 退避**(TTL 恢复;熔断打开时不再发起上游调用,提示里带上退避剩余时间)。**超时旋钮 `VOICE_STT_TIMEOUT_SEC`**(秒,默认 **20**;`≤0` 用默认)**只约束这一次 IM 入站转写**(要一直举着一个入站并发槽),**Web 客户端的 `voice_input` 不受它约束**。**无新端点 / 新帧**;客户端看到的是普通的用户消息(正文 = 转写文本或那行提示),在 IM 适配器日志 / 主人私聊镜像里与其它消息一致。
|
||||
|
||||
## 4. 客户端接入指引(Web / 移动 / 原生 / IM bridge 通用)
|
||||
|
||||
### 4.1 一次性接入流程(三步走)
|
||||
1. **拿令牌**:`POST /api/v1/auth/login` `{username,password}` → `{data:{token,expiresAt,role,refreshToken}}`(JWT,默认 24h,`role` ∈ `owner|admin|viewer`;`refreshToken` 为 B2 不透明长期凭据,用于静默续期,未接线 refresh 存储时**省略该键**)。
|
||||
2. **声明版本(可选/advisory)**:可带 `X-API-Version: 1` + `Authorization: Bearer <token>`;WS 握手用 `ws://<host>:<port>/ws/chat?token=<token>`(或 `Authorization: Bearer`)。**`X-API-Version` 是**advisory**惯例头——服务端当前不校验、不据此协商版本,带不带行为不变**;`viewer` 只能读会话/人格元数据;读写会话消息、管理写操作需 `owner`/`admin`(**WS 上 `viewer` 也只收 7 个状态机信号帧:既收不到 `history` 回放,也发不出任何上行指令**,见 §3 顶部两把角色门,v0.48 + v0.49)。
|
||||
3. **带端标识**(可选但推荐):每条消息/请求可带 `deviceId`(见 §4.3),让她知道你从哪个端来。
|
||||
|
||||
### 4.2 会话与消息用法(聊天)
|
||||
- **列出会话**:`GET /api/v1/sessions` → `[{id,type:"main"|"sub",title?,createdAt,pinned,unreadCount,lastMessage?,personaId?}]`。**排序:置顶会话(`pinned=true`)最前,其次是未置顶的主会话 `id="web"`**(她与你的主线,最大化继承上下文),再按创建时间升序;⚠️ **置顶的次级会话可以排在主会话之前**,别把列表第一项当主会话(按 `id=="web"` / `type=="main"` 定位);次级 `web:<uuid>` 由 `POST /api/v1/sessions` 新建(临时小事/专业问题,独立上下文回溯)。**`personaId`(v0.42)=该会话绑定的角色**;空/缺省 = **继承全局默认角色**,客户端渲染激活会话的名字/头像用它(后端唯一真源是 `chat_sessions.persona_id`,localStorage 之类只作缓存)。
|
||||
- **新建时选角(v0.42,v0.46 收口)**:`POST /api/v1/sessions` body `{personaId?}` 在**新建时**就绑角色(省略/空 = 跟随全局默认角色)。**会话角色在创建时确定、之后不可变**(design/22 §8-1):**没有"会话内换角色"这回事**——`PATCH /api/v1/sessions/{id}` 一旦收到 `personaId` 键就回 `400`(见 §1 行 8),**要换角色请新开会话**。**记忆按角色隔离**:不同角色的会话各记各的,同一角色的多个会话互相可见(design/22 §3 口径 1);群聊/陌生人会话仍**只读**、且强制默认角色。
|
||||
- **角色解析优先级(v0.44,design/22 §2 裁决规则 + §7-3/§7-4)**:一个回合到底以哪个角色答话,**自上而下**取第一个命中的:
|
||||
1. **请求级覆盖** —— 仍**未实现**(design/22 §2 裁决规则 1,留待后续批次);
|
||||
2. **会话绑定** `chat_sessions.persona_id`(`POST /sessions {personaId}` 在**创建**时写入;之后不可变,design/22 §8-1)—— 非空即赢;
|
||||
3. **渠道绑定** —— 该 IM 账号的 `personaId`(`PUT /admin/channels/{platform}/config`,v0.44)。**仅**作用于该账号的**主人私聊**,且仅当该会话(=镜像目标主会话 `web`)**自身未绑角色**时;
|
||||
4. **全局默认角色** `owner.yaml:defaultPersona` —— 兜底(`''` 始终 = 当前默认角色)。
|
||||
**群聊 / 陌生人隔离会话强制默认角色**(§7-4,更保守):这两类会话**跳过** 2/3 两条渠道与主会话绑定,一律用默认角色,避免把第三方会话挪进某个角色的记忆域;记忆仍是**只读**。**Web 主会话与 IM 主人私聊共享同一角色**(§7-3):主会话绑 Y 时,同一主会话里 IM 镜像进来的主人私聊回合也以 Y 答话。
|
||||
- **模型上下文里的「谁说的」(v0.44,design/22 §7-5)**:一回合的上下文由该会话**最近 20 条**持久化消息(`chat_messages`,含 `persona_id`)+ 本回合新消息组成(此前只带新消息本身,模型看不到会话前文);**每条 assistant 历史消息按其说话角色标注**,模型收到形如 `[发言归属] 以下发言来自「昔涟」(xilian),不是你说的。` 的独立 system 标注,以及一条声明当前角色的 `[会话角色] …` 段。**正文永不被改写**:标注只走 system 消息,用户正文与 assistant 正文一字不改(design/11 §10:不给用户正文冒充系统指令的机会)。**对客户端透明**(无新字段、无新端点)。
|
||||
- **改名 / 置顶**:`PATCH /api/v1/sessions/{id}` body `{title?}`(≤80 rune,空白标题回 `400 "title must not be blank"`——**空串不能清空标题**/恢复自动命名)和/或 `{pinned?:bool}`(至少一个字段;都缺回 `400`)→ `{data:{id,title?,pinned?}}`(只回显变更字段)。**body 里出现 `personaId` 键一律 `400`**(会话角色不可变,v0.46 / design/22 §8-1)。
|
||||
- **未读 / 已读游标(D4)**:列表项的 **`unreadCount`** 直接用于会话列表红点(恒返回整数,0 = 无未读)。客户端进入会话、或滚到底看到最新消息后调用 **`POST /api/v1/sessions/{id}/read`**:body `{"before":<毫秒>}`(如「本次已渲染的最新一条的 `createdAt`」),或**省略 body** = 标记到**现在**。后端**只前进**地推进该会话**唯一**游标(单 owner 账号:读一处 = 所有端已读),并广播 `session_read`(§3.9)——同账号其它端**免刷新**清红点。列表 → 读 → 再看列表即可自洽(漏收广播的端下次拉列表自然收敛)。
|
||||
- **拉取激活会话历史**:`GET /api/v1/sessions/{id}/messages?limit=50&before=<ms>&platform=&q=`。
|
||||
- **热分页**:`limit` 默认 50(上限 200),`before` = 当前最旧一条的 `createdAt`(毫秒),向上翻页。`platform` 可选(IM 适配器传本平台只拉本渠道;Web/通用客户端不传 = 全量共享主会话)。
|
||||
- **搜索**:`q` 非空 = **会话内**内容搜索(倒序,仍支持 `before`/`limit` 分页);空 `q` = 普通分页;`q` 与 `after` 互斥。**不跨会话**。
|
||||
- 返回**按时间倒序**,需 `reverse()` 成旧→新用于渲染。
|
||||
- **编辑 / 删除单条消息**:`PATCH /api/v1/messages/{id}` `{content}`(≤4000 rune)→ `{data:<消息DTO>}`(裸 DTO,无 `message` 包裹);`DELETE /api/v1/messages/{id}` → `{data:{deleted:<id>}}`。不存在回 `404`。**重发**用「编辑后内容再 `POST /chat`」即可(无独立重发端点)。两者成功后都会广播(`message_edited` / `message_deleted`,§3.8),同账号其它端**免刷新**同步。
|
||||
- **实时增量**:连接 WS 后,消息落库会广播 `message_persisted`(§3.3)——**用其中的 `id` 去重**,按 `sessionId` 写进该会话自己的桶(**v0.54 起非激活会话的帧不再丢弃**);新开/重连连接首帧即 `history` 回放(主会话最近 50 条)。**消息被编辑/删除**则分别广播 `message_edited` / `message_deleted`(§3.8)——同一 `sessionId` 路由规则,前者按 `id` 原位改 `content`、后者按 `id` 移除,都不需要重拉整页。
|
||||
- **发消息**:`POST /api/v1/chat` `{text, deviceId?, image?, mode?, clientMsgId?, documents?, parallel?, stream?}`。`stream:true` **直达会话编排器**(v0.56/G2):受理成功后才回 `200 {stream:true}`(**已升为独立并行路** = `200 {stream:true,parallel:true,turnId}`,v0.65);会话正忙已续接排队 `202 {stream:true,queued:true,queueDepth}`、队列满 `409 chat_busy`、**未接 loop `500 chat_unavailable`**、仅未接编排器时的兜底派发失败 `503 chat_dispatch_failed`。非流式返回完整 `reply`(会话正忙时 `202 {queued:true,queueDepth}`、**已升为独立并行路** `202 {parallel:true,turnId}`(v0.65)、排队满 `409 chat_busy`,见 §3.2)。**忙时并行(v0.65)**:带 **`parallel:true`** 且会话已有在途回合、单会话额度未满 ⇒ 本条**独立一路立即起**(默认 2 路 / 硬上限 3 / `<2` 关闭真并行),REST 拿不到同步 reply、文本照旧经 WS 到达;**省略 = 逐字不变**,服务端**不做**启发式升级。`image` 解码后上限 **5 MiB**(超限 `400 image_too_large`,文案指路 `POST /api/v1/upload`);**WS `chat` 帧受 4096 字节读上限约束**(超限断连),故 WS 上发大图必须走「上传 → URL」。**文档附件(v0.59)**:先 `POST /api/v1/documents`(multipart `file`,上限 20 MiB)拿到 `name`,再在 `documents:[{"name":…}]` 里引用(**≤5 条、只给存储名**;超限 `400 too_many_documents`、名非法/不存在 `404 document_not_found`,契约见 §1.2)。`text`/`image`/`documents` **至少一项非空**(纯附件回合成立)。
|
||||
- **幂等发送(v0.56/G4)**:给自己生成的每条消息带一个**非空** `clientMsgId`(如本地乐观气泡的本地 id),服务端在同一会话内只承认第一条:重复(超时重发 / 切后台重发)**不再落库、不再起回合**——WS 回 `chat_duplicate`(§3.13)、REST 回 `200 {data:{duplicate:true,messageId,sessionId}}`;该键也会**回填进消息 DTO**,供重连后对齐本地气泡。**省略它 = 老行为逐字不变**。
|
||||
|
||||
### 4.3 deviceId 与 platform 规范(design/16 §4.4-4.5)
|
||||
- **`platform`**(来源平台):`web` / 渠道如 `telegram`、`weixin-ilink`、`qq-obv11`。Web 端恒 `web`;IM 渠道消息带各自平台。**同一平台多个端 → 相同 platform、不同 deviceId。**
|
||||
- **`deviceId`**(具体端,客户端自己生成):Web 每个浏览器 `localStorage` 持久化 `web:<browser>:<随机短id>`(如 `web:chrome:a1b2c3`);IM 按 `<im平台名>:<本地配置唯一id>:<用户平台唯一uid>`;某 APP 可自定可读前缀。发送时带上,服务端落库消息,聊天气泡可标注来源。
|
||||
- **`senderName`**(第三方昵称):只有群聊/陌生人(非主人)消息带,主人私聊恒空(`omitempty`)。**IM 适配器_log**:该平台非主人私聊在 `GET /api/v1/admin/channels/{platform}/messages` 以消息流显示。
|
||||
|
||||
### 4.4 常见错误码 / 边界
|
||||
- `401` 未认证/令牌失效 → 重新登录。`403` 权限不足(viewer 读管理写/主会话删除)。
|
||||
- `404` 资源不存在(消息/会话 id 未找到)或端点未实现;`501 not_implemented` 后端明确未提供(如知识库删除、新增用户)。**会话相关(v0.63)**:`GET /sessions/{id}/messages` 对**不存在/已删除**的会话回 `404 {"error":{"code":"session_not_found"}}`(此前是 `200 {"data":[]}`,客户端因此分不清「空会话」与「会话已不在」);`PATCH /sessions/{id}` 的未知会话仍是 `404 not_found`(**码不同**:前者点明是会话,后者是通用资源),`POST /sessions/{id}/read` 与 `PATCH /messages/{id}` 沿用各自既有码。
|
||||
- `400` 请求体/参数非法(`limit`/`before` 越界、空 content)。`409 chat_busy` 排队满(**REST 响应体与 WS 定向 `error` 帧同码**,见 §3.2)。
|
||||
- **文档相关(v0.59 起;v0.61 更新白名单与裁决口径)**:`415 unsupported_media_type`(**扩展名白名单(23 项,含 `.pdf`)与字节嗅探的成对裁决**任一不过——HTML/纯文本改名成 `.pdf`、或 PDF 改名成 `.txt` 都走它)、`413 document_too_large`(超 `CHAT_DOCUMENT_MAX_BYTES`,默认 20 MiB)、`404 document_not_found`(文档名非法/穿越/不存在;`chat.documents` 里引用不存在的名也是它)、`400 too_many_documents`(单回合 >5 条)。⚠️ **诚实说明**:design/24 §6.2 列的 `document_unreadable`(400) 目前**没有对应的 REST 错误码**——它只以 `document-read` 工具的错误形态存在(模型侧可见;客户端在 DTO 里看到的是该文档的 `documents[].status = "unreadable"`,**扫描件/图片型 PDF 就是它**,v0.61)。
|
||||
- **红线**:身份判定永远来自后端 channel 层 actor 元数据 + 白名单,**客户端不要用提示词/文本判身份**;消息体的 **`thinking` 字段**在**实时帧**(`message_persisted` / `channel_persisted` / `message_edited`)与**握手回放**中**恒为空**(**有意的隐私与带宽默认**,不是漏发);实时思维链仍由 §3.1 的 `thinking.*` 流帧按 owner/admin 档下发;**历史消息的思维链**只有 `GET /sessions/{id}/messages`(owner/admin)能拿到,客户端不应依赖其它入口。
|
||||
|
||||
### 4.5 离线 / 缓存建议(原生端)
|
||||
- PWA / 原生端缓存 `sessions`/`personas` 列表 + 最近一屏消息;重连先本地渲染再拉增量。
|
||||
- 消息**真源在后端**,本地只做渲染缓存;用 `message_persisted.id` 去重,不要用本地自增 id。
|
||||
- 语音:上行 `voice_input`(base64 音频或已转写 text,**v0.59 起可带 `clientMsgId` 幂等**),下行 `voice_output`(合成音频 base64 + text + **`sessionId`/`deviceId?`**,**定向回发起那条连接、回合结算时才到**,空正文/TTS 失败带 `provider` 降级信号);**语音回合与文本回合走同一条路**(落库 / 跨端 / 忙时排队),见 §3.15。
|
||||
|
||||
## 5. 变更记录
|
||||
- `v0.65`(**忙时并行回合基础管线(I1′ 有条件放宽)+ 六条登记**,依据 design/15 §2.5(主人 2026-10-05 裁决 B,commit `89e13cf`),并一并登记 `679cee0`(IM 入站附件引用落库,安卓报的 P0)/ `59a1ac4`(`llm_usage.session_id` 真正落库)/ `a918819`(IM 各渠道语音可被读到)与 `cb564fe`(memory 空 query 不触发语义回退,纯内部)/ `15181e7`(Anthropic 并行工具结果合并为同一条 user 消息,修真机 400,纯内部)/ `7d2ff74`(telegram 语音内联回归测试,无契约),2026-10-05 落实)。**三档标注**:**纯增量** = ① 上行 `chat.parallel?:bool`(WS `chat` 帧 + REST 三入口**同形**)、② `session_busy` / `session_idle` / `pending_drain` / `session_interrupted` 的 `data` 增 `activeTurns:int`(均在既有白名单帧内)、③ `session_interrupted.data` 增 `cancelledTurns:int`、④ `pending_ack.data` 增**可选** `reason:"parallel_limit"`、⑤ REST 两条新应答形状(同步 `202 {data:{parallel:true,turnId}}` / `stream:true` 的 `200 {data:{stream:true,parallel:true,turnId}}`)、⑥ 新 env 键 `VOICE_STT_TIMEOUT_SEC`(默认 20s);**行为变更** = ⑦ IM 入站附件引用**真的写进落库的用户消息**(`documents` 列;**同一 DTO、无新字段**)、⑧ IM 各渠道语音进上下文(转写不成功落一行中文诚实提示,**绝不静默/空消息**)、⑨ **`session_idle` 时点收紧**为「所有在途路收尾且 pending 空」(**仅并行时生效**,单路逐字不变);**破坏性** = 无(**省略 `parallel` / 不读新键的客户端行为逐字不变**;`session_id` 有值属既有列,端点响应形状不变)。**A. `chat.parallel`(纯增量,省略 = 逐字不变)**——`POST /api/v1/chat` body 与 WS `chat` 帧 `data` 同形新增可选 `parallel:bool`(`omitempty`),语义 = 「把本条作为**独立一路**处理」;**只在会话已有在途回合、且单会话并发额度未满时照准**(额度:默认 **2**、硬上限 **3**、`<2` = 关闭真并行);**空闲时无效果**(照常起第 1 路);**服务端不做任何启发式自动升级**(触发权在客户端,design/15 §2.5.1:启发式会让「同一条消息被如何作答」随负载漂移,破坏「恰好一次」的直觉)。真并行路**立即起**(不是「排到队首再起」),因此 REST 同步入口拿不到同步 reply,文本照旧经 WS 流式到达。**B. 忙态帧的 `activeTurns` / `cancelledTurns`(纯增量,均在既有白名单帧内)**——`session_busy` / `session_idle` / `pending_drain` / `session_interrupted` 的 `data` 各增整数 `activeTurns`(该会话当前**在途路数**,含正在收尾的路;单路时非 0 即 1);`session_interrupted.data` 另增 `cancelledTurns`(本次真的取消了几路)。四个帧都**不含任何内容**,因此仍属「状态机信号」白名单、所有已认证连接(含 `viewer`)都收得到。**C. `pending_ack.reason`(纯增量,可选,绝不伪造)**——`pending_ack.data` 增可选 `reason:"parallel_limit"`,**只在「客户端显式声明了并行、本条却落回排队」时出现**(额度已满,**或**真并行被 `<2` 关掉——客户端问了并行而没拿到);其它情况该键**完全缺席**,帧与今天逐字相同。**D. REST 两条新应答形状(纯增量,只在 `parallel:true` 且被照准时才可能出现)**——① 同步入口(`stream:false`)照准并行 ⇒ `202 {"data":{"parallel":true,"turnId":"turn-…"}}`(**不是 `queued`**:它不排队、是立即起第二条路);② `stream:true` 照准并行 ⇒ `200 {"data":{"stream":true,"parallel":true,"turnId":"turn-…"}}`。`turnId` 就是那一路的**权威回合标识**、与该路全部流帧的 `turnId` 同值。**未照准**(未声明 / 空闲 / 额度满)时形状与既有逐字相同(`202 queued` / `200 {stream:true}`)。**E. `interrupt` 的多路口径(口径确认,无新字段)**——`scope:"current"` 省略 `turnId` 时取消的是**「当前全部在途路」**(不是只停一路,因为省略 `turnId` 的 scope 就是「作用于每一路」);`scope:"all"` 取消全部在途路 + 清队列、且**不发 `session_idle`**(与今天一致,客户端靠 `session_interrupted` 收口);`interrupt.turnId`(精确停某一路)**本版未实现**。**F. `turnId` 的生成方变更(对外无可见差异)**——`turnId` 由「网关每回合内部新生成(`runTurn` 的 `newTurnID()`)」改为「**编排器生成 → loop 复用**」(`loop.WithTurnID`;未挂载时如子代理 / backthink / 未接编排器仍新铸)。帧形状与取值分布不变(单路时仍是每回合全新的 `turn-<hex>`),因此**对客户端无可见差异**;写在此处只为让「谁生成」的口径与实现一致(这也是后续 `turn_begin` 帧与流帧必须同值的**硬前提**—Line truncated
|
||||
- `v0.64`(**LLM 用量落库 + 只读汇总端点 + 面板用量小卡**,依据主人 2026-10-04 夜裁决「**2.4 落**」与 T1(`72b95df`:每次模型调用已记结构化用量日志、但**只落日志**),2026-10-04 落实)。**三档标注**:**纯增量** = 新端点 `GET /api/v1/admin/llm/usage`、新迁移 `0017_llm_usage`、新 env 键 `LLM_USAGE_RETENTION`(默认 `720h`;`≤0` = 关闭清理)、面板 LLM 页新小卡、前端新 API `adminLlmUsage()`;**行为变更** = 无;**破坏性** = 无。**A. 为什么落库**:日志能看单次、看不了趋势,也做不出按用途/模型/会话的成本视图,面板更无数据源 —— 主人要的是「这些调用到底花了多少」。**B. 写入绝不阻塞调用(本版最关键的一处设计)**:`model` 层**不直接开库**,只在既有 `logUsage` 处增加**可选**接口 `model.UsageSink`(**`nil` ⇒ 仅日志、行为逐字不变**,因此既有 model 用例**零改动**即全绿);组合根注入 `model.UsageWriter` = **有界队列(1024)+ 单写者 + 批量事务(128 行/批、2s 兜底冲刷)**:队列满 ⇒ **丢弃 + 限流 WARN(30s 一条,带累计丢弃数)**,**绝不阻塞、绝不 panic、绝不无界增长**;整批失败只记一条带来源 WARN(不静默);停机时先 drain 写完再退。sink 挂在 `RuntimeLLM`(热重载时稳定)而非适配器上,因此面板每次保存 models.json 都不会把落库静默断掉;**每一条链**(主/备/本地第三级/按角色跨协议)都带上它。**C. 表**:`llm_usage`(`id/session_id/purpose/adapter/model/prompt_tokens/completion_tokens/cache_hit_tokens/cache_miss_tokens/reasoning_tokens/total_tokens/took_ms/created_at`)+ `idx_llm_usage_created_at` + `idx_llm_usage_session_id`。✅ **`session_id` 已接线(v0.65 更正)**:模型层「不直接开库」的取舍不变,但 loop 现在在 `Run` 顶部把会话 id 播种进 ctx(`model.WithSessionID`),适配器 `record(ctx,…)` 带上它、空串映射 `NULL` ⇒ **该列从此真的有值**(v0.64 写的「恒为 `NULL`」**已过期**)。**本端点仍不按 `session_id` 过滤 / 分组**,响应形状不变;sink 未注入或会话未播种时行为逐字不变。**D. 读取**:`GET /api/v1/admin/llm/usage?since=&until=&purpose=`(**owner|admin 读**,与同页 `/admin/llm` 同门;**不审计** —— 查询不是动作)→ `{data:{window:{since,until,purpose},totals:{calls,prompt,completion,cacheHit,cacheMiss,hitRate},byPurpose:[…],byModel:[…]}}`;**缺省窗口 = 近 24h**;`since`/`until` 接受 **RFC3339 或 Unix 毫秒**;**跨度上限 90 天,超过 ⇒ `400`(不静默裁剪)**;**`hitRate` = cacheHit/(cacheHit+cacheMiss),分母为 0 ⇒ `0`**(口径已写进 §2 契约与前端展示规则:**分母 0 显示 `—`,不显示 `0%`**);分解**按调用次数降序、同次数按键升序**;**绝不含凭据与消息正文**(表里根本没有这些列)。**E. 保留期**:`LLM_USAGE_RETENTION` 默认 `720h`(30 天),**`≤0` = 关闭清理**(启动日志写明「llm usage retention disabled」);**启动期清一次 + 每小时清一次**(复用 `cmd/cyrene/wire_document_gc.go` 的既有模式;清扫间隔**不做成 env 键** —— 保留期才是部署者的旋钮)。**F. 面板**:LLM 页「用量(近 24h)」小卡 = **调用数 / 输入 token / 缓存命中率**,取数走既有 `useAsyncData` 加载/错误态(**读不到显示错误,绝不显示 0**)。**G. 失败不伪装**:汇总出口未接线或查库失败 ⇒ `500 {error:{code:"usage_unavailable"}}`,**不回全 0 假汇总**。**H. 可观测性端点的口径不变**:`GET /admin/observability/metrics` 仍只报 `llmCalls`/`subagentEvents`(它不统计 token/成本),token 与成本由本版端点单独回答。**实现落点**:`internal/runtime/model/{usage.go,usage_store.go,usage_query.go,openai.go,anthropic.go,factory.go,runtime.go}`、`internal/db/migrations/0017_llm_usage.{up,down}.sql`、`cmd/cyrene/{wire_usage.go,wire_runtime.go,wire_frontend.go,wire_services.go,main.go}`、`internal/services/gateway/{admin_llm_usage.go,routes.go,deps.go,contracts.go,server.go}`、`frontend/web/src/admin/modules/llm/LlmUsageCard.tsx`、`.env.example`。**验证**:sink 为 nil 逐字不变、队列满不阻塞(有超时判定)、批量写入→读回(含 hitRate 公式)、时间窗/用途过滤与超限 400、保留期清理(过期删 / 未过期留 / ≤0 不清理)、鉴权(无凭据 401 / viewer 403 / admin 放行)、不回显凭据(键集恰好六个);**两条变异**:① `RecordUsage` 改阻塞发送 ⇒ 「队列满不阻塞」红;② `hitRate` 分母写成 `cacheMiss` ⇒ 命中率断言红(还原后均复绿)。
|
||||
- `v0.63`(**客户端登记的两条落地:`documents[]` 增可选 `reason`(读不到的原因)+ `GET /sessions/{id}/messages` 对不存在/已删除的会话回 `404 session_not_found`**,依据 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的两条登记(2026-10-05 凌晨)与后端「回执九」的接受与选择,2026-10-05 落实)。**三档标注**:**纯增量** = ① `documents[]` 元素增可选 `reason`(`omitempty`,**只在 `status:"unreadable"` 时下发**;不读该键的客户端行为逐字不变);**行为变更** = ② `messages` 对**不存在/已删除**的会话由 `200 {"data":[]}` 改为 **`404 {"error":{"code":"session_not_found"}}`**(**客户端需按 404 处理**:退回主会话 + 一句提示;回执九已提前告知);**破坏性** = 无。**A. `reason`(§1.2 ④)**——数据来源 = 服务端**已有的那份抽取失败原因文案**(就是注入模型清单里那条,`internal/runtime/documents/sidecar.go` 的 `reason*` 常量);装配期在给出 `status` 的同时给出 `Reason`(`internal/runtime/loop/document.go` 的 `documentReason`),**同一个字符串两处用**:既进模型可见上下文,也作为客户端可见的 `reason` —— 服务端因此不必为客户端另编一句,两处口径也不可能分叉。**只在「读不到」时下发**(`extracted`/`listed` 为空 ⇒ 键缺席;客户端按「缺失 ⇒ 回落通用句」实现)。**文案集合(逐字)**:`无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)` / `…PDF 已加密(无法用空口令打开),请解除密码后重新上传` / `…PDF 损坏或无法解析` / `…本期不支持 Word/Excel/PPT 解析(无文本层)` / `…内容不在可抽取的纯文本范围内` / `…文档文件不存在或已被清理` / `…读取文档失败` / `…文档引用形态非法` / `…文档上传目录未配置` / 解析器整条消息失败时的诚实兜底 `无法抽取正文:读取或解析失败`。**只放文案**:不含落盘路径、不含凭据、不含存储名以外的内容;原始错误(可能带路径)**只进日志** —— 端到端用例用一个**独特的临时目录名**做反证断言(响应体里搜不到它,且 reason 不含 `uploads`、`\documents` 等路径片段)。**抵达面**:它随 `history.DocumentRef`(**与落库同一个结构体**)由 S3.5 的回合后回写写进 `document` 列(`mergeDocumentStatus` 把 `reason` 与 `status`/`chars`/`truncated` **同批**搬运、并计入「有无变化」判据),因此**所有读取面**(`GET /sessions/{id}/messages`、握手回放)都有它;⚠️ `message_persisted` 帧在用户消息落库时就发出,那时抽取还没发生(**不带** `status`/`reason`,与既有 `status` 行为一致 —— 客户端以读接口为准)。**B. `404 session_not_found`(§1 行 6)**——现象:此前对**已删除/凭空编造**的会话都回 `200 {"data":[]}`,客户端分不清「空会话」与「会话已不在」(主人的真实遭遇:正看着的临时会话被清理,界面上只剩一片空白)。**理由(与 API 自身一致)**:`PATCH /sessions/{id}`、`POST /sessions/{id}/read` 对未知会话本来就是 `404`,让 `messages` 单独例外会让每个新客户端都踩一次。**实现**:判定**复用既有存储面**(`history.Store.SessionPersona` —— 既有存储里唯一一个「按 id 读**单个**会话 + 未知 id 报 `history.ErrSessionNotFound`」的方法;`ListSessions` 是全量列表扫描,在热分页端点上代价不成比例),**不新写 SQL、不新增存储方法**,落点 `internal/services/gateway/history_session_guard.go`(新文件)+ `handleListMessages` 一行收口。**存在但没有消息仍是 `200 {"data":[]}`**(空会话绝不能被答成 404);参数校验(`limit`/`before`/`after`/`q`)**先于**存在性判定(形状错了就是 `400`,与目标存不存在无关);存储**没有该视图**(测试替身 / 未接库的窄存储)视为**无从判定** ⇒ 保持旧的空集合语义(判不出来绝不等于「不存在」)。**C. 会话注册表口径(重要,随本版更正 §3.4 旧文)**:`chat_sessions` 是会话注册表;**隔离的 IM 会话(群聊/陌生人的 `sess-xxx`、内部 `sess-backthink`)不在其中** ⇒ 它们在本端点与 `POST /sessions/{id}/read` 拿到**同一个答案**(`404`),这是「与 API 自身一致」的必然推论;它们的消息仍可由 `GET /api/v1/admin/channels/{platform}/messages` 读出(`Docs/api-reference.md` §3.4 那句「IM 隔离会话 `sess-xxx` 亦可用」**随本版更正**)。**D. 错误码风格**:新码 **`session_not_found`**(点明是**会话**,与 `chat` 的 `document_not_found` 同风格),与 `PATCH /sessions/{id}` 的既有通用码 `not_found` **不同码** —— 本版**不动** PATCH 的既有Line truncated
|
||||
- `v0.62`(**面板「重置为 env 默认」动作落地:materialize 的反方向显式动作 + 一个新的 owner-only 管理写端点**,依据任务书 `DevDocs/_dispatch/R1-面板重置为env默认.md` 与 design/04 §3.1(`.env` 为默认/播种层、面板文件为一等来源)和 `DevDocs/开发时间线.md` 的欠账项(「Materialize 已导出、未接端点」),2026-10-04 落实)。**三档标注**:**纯增量** = 新端点 `POST /api/v1/admin/llm/reset-from-env`、面板 LLM 页的新卡片、审计动作 `llm.resetFromEnv`、2 个新错误码(`env_defaults_unreadable` / `models_unreadable`,另复用既有 `llm_persist_failed` / `llm_reload_failed` / `llm_unavailable`);**行为变更** = 无(既有端点 / 帧 / 字段一字未动);**破坏性** = 无(新端点的新用途;它本身的破坏性由请求体 `confirm:true` 强制)。**A. 新端点**(**owner-only**——与同页 `POST /admin/llm`、`POST /admin/llm/test` **同门**,破坏性写不比同族更宽;带审计 `llm.resetFromEnv`)body **必须** `{"confirm":true}`(缺失 / `false` / 非法 body ⇒ `400 bad_request` 且**一个字节都不动**),→ `200 {data:{reset:[{group,keys:[…]}], skipped:[{group,reason}], path}}`。**B. 语义(与 materialize 共用同一张键表 `runtimeconfig.groupFillSpecs`,方向相反)**:① **只覆盖 `.env` 声明过非空值的键**(`provider` / `baseUrl` / `apiKey` / `model` / `contextWindow`;协议族按**组自己的 `provider`** 选,`.env` 同时配扁平块与 `ANTHROPIC_*` 时两族各取自己的值);② **env 未声明的键一律保留面板值**(`fallback` / `multimodal` / `maxTokens` / `reasoning` / `personaModels` 在 `.env` 里没有出处 ⇒ 永不被本动作改动);③ **组集合不增减**(不复活被面板删掉的组、不删面板新增的组,**顺序也不动**);④ **面板值已与 env 一致的键不算「改回」**,不出现在 `reset` 里(`provider` 恒属此种情形);⑤ **无键可改时不写盘**(幂等,重复调用第二次回 `reset:[]` + 每组一条「已一致」的 `skipped`);⑥ **真的改了盘就热重载**运行中的 LLM 栈(无需重启;`contextWindow` 的**预算推导**仍只在启动时算一次,与 `POST /admin/llm` 同口径)。**C. 响应与凭据纪律**:`reset[].keys` 只有 **`models.json` 的 JSON 键名**、`skipped[].reason` 是中文原因,**凭据值(尤其 `apiKey`)绝不回显、绝不进日志**(`reset` / `skipped` 恒为数组,无记录时是 `[]` 而非 `null`;`path` 与 `POST /admin/llm` 同源)。**D. 对照基准**:**请求时刻按既有解析入口读到的 `.env` 默认层**(新增 `config.LoadLLMDefaults`,复用 `parseDotEnv` + 同一份 `env:"…"` 解码器 ⇒ 优先级与启动期 `config.Load` 逐字一致:进程环境 > `.env` > 标签默认);路径由组合根经 `Deps.EnvPath` 注入(与 `main.go` 的 `config.MustLoad(envPath)` 同一条),**网关自己绝不 fopen `.env`**。**E. 失败不伪装**(都**不改文件**且带审计 `error`):`.env` 源不可读 → `500 env_defaults_unreadable`;面板配置不存在/不可解析 → `500 models_unreadable`;写盘失败 → `500 llm_persist_failed`;已写盘但热重载失败 → `500 llm_reload_failed`(说明文件已改、未生效);LLM 栈未注入 → `500 llm_unavailable`。**F. 面板**:`frontend/web` 的 LLM 页新增「重置为 env 默认」卡片(`admin/modules/llm/LlmResetFromEnv.tsx`,标 `owner 专属`)——点按钮先展开**中文二次确认**文案,确认后才带 `confirm:true` 提交;结果按 `reset`/`skipped` 渲染成可复核的组名 + 键名摘要,失败原样显示后端错误文案。**G. 实现落点**:`internal/config/llm_defaults.go`(env 默认层读取)+ `internal/config/runtimeconfig/reset.go`(`ResetFromEnv` 语义,新文件)+ `internal/services/gateway/admin_write_llm_reset.go`(端点,新文件)+ `routes.go`(注册为 `ownerAuth`)+ `deps.go`/`server.go`(`Deps.EnvPath`)+ `cmd/cyrene/wire_frontend.go`(组合根接线)+ 前端 `lib/types/adminWrite.ts` / `lib/api/admin.ts` / `admin/modules/llm/LlmResetFromEnv.tsx` / `admin/modules/Llm.tsx`。**H. 未做 / 诚实边界**:**不支持「只重置某一组」**(一次调用对全部组生效;`skipped` 会如实报出没被改动的组与原因);**不做「重置 channels.json / plugins.json」**(materialize 本身也只覆盖 `models.json` 一族);**不做「重置后自动重启」**(provider 栈本身热更新,窗口推导需重启由面板文案写明)。
|
||||
- `v0.61`(**PDF 文本层抽取落地:白名单第 23 项 `.pdf` + 类型裁决由「嗅探只放行 `text/plain`」改为 `(扩展名, 嗅探)` 成对裁决**,依据 design/24 §7 的 **S6 第一段**(PDF/Office 的裁决:PDF 文本层引库,扫描件与 Office 仍诚实拒绝)与 commit `f82c76d`,2026-10-04 落实)。**三档标注**:**行为变更** = ① **`.pdf` 由「一律 `415`」变为「可上传 + 可读取 + 文本层可抽取进上下文」**(把「`.pdf` 必被拒」写死的客户端必须改);② **类型裁决收紧为成对判定**(`documents.ContentAllowed`)——**方向是收紧/细化,不是放宽**:HTML/纯文本改名成 `.pdf` ⇒ `415`(嗅探不是 PDF),**PDF 字节改名成 `.txt` ⇒ `415`**(PDF 字节也只收在 `.pdf` 名下);**纯增量** = 扩展名白名单 **22 → 23 项**(唯一新增 `.pdf`),其余 22 个文本类的判定与既有响应**一字未变**。**A. 上传(`POST /api/v1/documents`,owner|admin)**——白名单加 `.pdf`;裁决 = **字节嗅探是唯一裁决权 ∧ 扩展名白名单**,自本版起**按对判定**(纯文本类必须 `text/plain`、`.pdf` 必须 `application/pdf`;落点 `internal/runtime/documents/store.go` 的 `ExtAllowed`(23 项)/ `ContentAllowed`,网关名字形态校验经它判定);`mime` 仍是**嗅探基类型**(真 PDF → `application/pdf`);`415` 文案同时点明两条口径(「只接受白名单内的文档(如 .txt/.md/.csv/.json,或内容确为 PDF 的 `.pdf`),且内容必须与扩展名相符」)。**B. 读取(`GET /api/v1/documents/{name}`,必带 Bearer owner|admin)**——**真 PDF 如实下发 `Content-Type: application/pdf`**(读时按同一对裁决重嗅探,判不定者仍退 `application/octet-stream`),`Content-Disposition: attachment` + `X-Content-Type-Options: nosniff` **一字未变** ⇒ **PDF 永不被内联渲染**(与图片读侧刻意相反);`Cache-Control: private, max-age=86400, immutable` 与 Range 支持不变。**C. 抽取语义(DTO `status`)**——**文本层 PDF 抽正文**(页内容流里的 `Tj`/`TJ`/`'`/`"` 文本算子,页间以换行相接)⇒ `status:"extracted"`,与纯文本类走**同一条** sidecar / 注入路径;**扫描件/图片型 PDF(无文本层)⇒ 诚实拒绝 `status:"unreadable"`**,原因文案含「这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)」,**绝不假装成功、也绝不返回空正文**(`ErrNoTextLayer`);**加密 PDF** 与**损坏/截断 PDF** 是**可区分**的两类失败(`ErrPDFEncrypted` / `ErrPDFDamaged`,加密的提示解除密码后重试);安全阀 = 单份最多 **5000 页** + sidecar 正文 **≤8 MiB**(撞上即 `truncated`)。**D. 客户端可见的一次性重算**——抽取器版本内部升到 **`documents/v3`**(`internal/runtime/documents/extract.go`)⇒ **旧 sidecar 一次性失效重抽**:同一份**旧附件**的 `chars`/`truncated` 结论会在**某次回合之后**重新计算(旧 PDF 若是文本层 PDF,将从 `unreadable` 变为 `extracted`)——不是附件被改写,是结论被重算。**E. 未做(诚实边界)**——**不做 OCR / 扫描件栅格化**(纯 Go 无可靠实现,会撞「单二进制 + 交叉编译」「零跨进程 IPC」两条红线)、**不做 PDF 写/生成**(只读)、**不做 `.docx`/`.xlsx` 等 Office**(仍诚实拒绝);**无新端点、无新 WS 帧、无鉴权变更、无 `.env` 新增键**。**实现落点**:`internal/runtime/documents/extract_pdf.go`(文本层抽取 + 加密/损坏分类)+ `internal/runtime/documents/{extract.go,store.go,sidecar.go}`(版本号 / 白名单与成对裁决 / unreadable 原因文案)+ 依赖 `github.com/ledongthuc/pdf`(纯 Go、无 cgo)。
|
||||
- `v0.60`(**管理侧只读「存储状态」端点 + 面板存储卡接真数据**,依据主人 2026-10-04 实测反馈(面板首页「存储」卡一直是占位「SQLite」,看不出 PG 增强有没有生效)与 design/10 §3 模块 14(系统设置)/ design/03 §3(pgvector 可选增强),2026-10-04 落实)。**一句话**:**纯新增一个只读端点**,既有端点 / WS 帧 / 字段形状**一字未动**;面板那张假卡换成真数据。**A. 新端点** `GET /api/v1/admin/system/storage`(**owner-only**、**只读**、**无审计动作**,与四条 `POST /system/*` 动作端点同门不同档)→ `200 {data:{main,documents,vector}}`:`main` = `{kind:"sqlite",path,fileBytes,walBytes,shmBytes,pageSize,pageCount,freelistPages,freeBytes}`(`path` 与备份/恢复端点同源;WAL/SHM 边车**合法地不存在**时如实为 `0`;`freeBytes = pageSize × freelistPages`)、`documents` = `{count,bytes}`(读 `documents` 表,**不 walk 目录**,空表 `bytes` 为 `0` 而非 `null`)、`vector` = `{kind:"pgvector"|"none",enabled,ready}`。**B. `vector` 的语义(主人 2.4 的困惑点)**:`kind` 说「配没配 `DB_PG_URL`」,`enabled` 说「开关在不在」(等价于 `kind != "none"`),`ready` 说「**活着的**向量索引真的是 pgvector 后端吗」—— 配了但连接/schema 准备失败 ⇒ `enabled:true` + `ready:false`,客户端必须显示「已配置但未就绪(检索已降级)」,**不要把 `enabled` 当成生效**。客户端显示建议:`none` → 「未启用(未配置 `DB_PG_URL`)」、`enabled && ready` → 「启用(`pgvector` · ready)」、`enabled && !ready` → 未就绪。**C. 两条纪律**:① **不含凭据**——DSN / 用户名 / 密码 / `postgres://` 全文**一个字都不回**(`vector` 段只有 `kind`/`enabled`/`ready` 三个键;有用例断言响应体里搜不到这些串);② **只读**——只有 `SELECT`/`PRAGMA` + `os.Stat`,且复用组合根注入的**活连接**(不新开连接池、不写、不迁移、不备份;调用前后 `documents` 行数与数据目录内文件集合均不变,有用例钉住)。**D. 真源(`vector`)**:组合根 `cmd/cyrene/wire_frontend.go` 的 `app.vectorState()` —— `Configured` 取 `DB_PG_URL` 非空、`Live` 取活索引的具体类型(`*vector.PGVectorIndex`);网关**只读这份注入值**,不读环境变量、不新开 PG 连接、不自行探测(否则面板与运行时会给出两个答案)。**E. 失败不伪装**:未接活连接 / 数据库路径未配置 / 读不到页级统计或 `documents` 表(例如库没跑迁移)⇒ `500 {error:{code:"storage_unavailable",message}}`,**绝不回一个全 0 的假快照**;无凭据 → `401`。**F. 面板**:`frontend/web` 的存储卡(`admin/modules/dashboard/overviewMetrics.ts`)改为真数据 —— `value` = `SQLite 12.3 MiB`(`main.fileBytes` 的人类可读量级)、`hint` = `WAL … · N 页(空闲 M 页 / …)`、新增两行详情 = `documents N 件 · …` 与 `PG 增强 …`;取数走与其它已接线卡片**同一条** `useAsyncData` 加载/错误态(数据没到就显示「端点报错:…」,**不回退成 0、不假装成功**)。**G. 实现落点**:`internal/services/gateway/admin_system_storage.go`(新文件)+ `routes.go`(注册)+ `deps.go`/`server.go`(注入 `Deps.Database` / `Deps.Vector`)+ `cmd/cyrene/wire_frontend.go`(组合根真源)+ 前端 `lib/types/adminWrite.ts` / `lib/api/adminSystem.ts` / `admin/modules/dashboard/{useDashboardExtras,overviewMetrics,MiniMetric}`。**H. 兼容性**:**纯新增**——新端点为纯增量;前端 `MiniMetricSpec` 新增的 `extra?: string[]` 是**可选**补充详情行,不传时卡片渲染与旧版**逐字等价**(其它卡片一字未改)。**未做 / 诚实边界**:`main` 只报 SQLite(当前唯一主库引擎);`vector` 只报 `kind`/`enabled`/`ready`(**刻意不回 host**,把泄漏面压到最小);不报表级明细、不报备份文件列表(`documents` 之外的占用仍要下钻到备份/文件系统看)。
|
||||
- `v0.59`(**文档进对话 + 上下文预算按窗口推导 + 语音回合进编排 + `thinking` 可达性口径**,依据 design/24 §3/§4/§6(切片 S1–S5)、design/04 §1.3 + 附录 A.3(主人裁决 2026-10-04)与 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的 G5 / C1 / C3;commit `a8851dc` / `a4789ec` / `20c1edb` / `5b2b385` / `72b95df` / `3a01b4e`,2026-10-04 落实)。**三档标注**:**纯增量** = 两个 documents 端点、`chat.documents` 可选字段、4 个文档错误码、`models.json` 组键 `contextWindow`、4 个 `.env` 键、`document-read` 工具、`pending_full` 的 `clientMsgId?`/`messageId?`、定向 `error{code:"chat_busy"}` 帧、`voice_input.clientMsgId`、`voice_output.sessionId`/`deviceId`;**行为变更** = ① 语音回合改走编排器(落库 / 流式 / 跨端 / 忙时排队)② `CONTEXT_MAX_TOKENS` **未设置时改为按窗口推导**(`.env` 里写着 `32768` 仍是显式覆盖)③ 队列满时新增**定向回执**(此前 WS 无等价物);**破坏性** = ① `historyMessage` 的 `document`(单对象)→ **`documents`(数组,≤5)** ——**但单数形状从未随任何已发布版本号发布过**,故**无兼容期** ② `chars` / `truncated` 的语义由「抽取侧」改为 **「本回合注入侧」**(全文长度只有 `document-read` 的 `totalChars` 能看到)。**A. 文档(新增 §1.2 + §1 行 16/17)**——`POST /api/v1/documents`(multipart `file`,上限 `CHAT_DOCUMENT_MAX_BYTES` 默认 20 MiB)→ `201 {name,url,mime,size,sha256,deduped:false}`;**同字节同扩展名重复上传 → `200` + 同一 `name` + `deduped:true`**;`GET /api/v1/documents/{name}` **必须带 Bearer(owner|admin)**(与图片读侧的免鉴权能力 URL 刻意相反)+ `Content-Disposition: attachment` + `nosniff`;**字节嗅探是唯一类型裁决**(**当时**放行基类型只有 `text/plain`;**v0.61 起**改为 `(扩展名, 嗅探)` 成对裁决、`.pdf` 走 `application/pdf`),**扩展名白名单 22 项**(⚠️ 历史值;**v0.61 起为 23 项**,纳入 `.pdf`)deny-by-default,`mime` 是嗅探基类型(`data.json` → `text/plain`)、`url` 是**相对路径**;错误码 `unsupported_media_type`(415) / `document_too_large`(413) / `document_not_found`(404) / `too_many_documents`(400);`chat` 请求(REST 同步 / REST `stream:true` / WS **三处同形**)增可选 `documents:[{name}]`(**≤5、只收服务端存储名、不收 URL**,先于任何 FS 访问判条数,客户端多带的 `mime/url/size` 被服务端覆盖);DTO 四处同源(`GET /sessions/{id}/messages` / `message_persisted` / `message_edited` / 握手回放)改 `documents` 数组,元素 `{name,mime,size,chars,truncated,status}`、`status ∈ extracted|listed|unreadable`、无附件时省略该键;内置工具 `document-read`(`{name,offset?,limit?}` → `totalChars`/`truncated`/`nextOffset`/`text`/`extractTruncated?`,`limit` 默认 8000、上限 64K、`offset` 为 0 基 rune,策略默认 owner-only)。**B. 配置(§2「上下文窗口与预算契约(v0.59)」)**——`models.json` 组级新增 `contextWindow`(0/省略 = 未声明 ⇒ 131072 保守估算 + **恰好一条 WARN**;负数 = 校验失败);`.env` 新增 `LLM_CONTEXT_WINDOW` / `CONTEXT_BUDGET_RATIO`(默认 `2/3`)/ `DOCUMENT_MAX_INJECT_CHARS`,并**变更 `CONTEXT_MAX_TOKENS` 语义**(未设置 ⇒ 按窗口推导;`>0` 覆盖;`0`/负 = 关闭压缩)——推导 `budget = min(窗口 × 比例, 窗口 − 输出保留)`,输出保留 = `max(32768, 该组 maxTokens)` 封顶 `窗口/2`,**1M 窗口 ⇒ 699050 tokens**;另**补登记 `.env` → 面板的 materialize 语义**(每次启动一次;面板非空 ⇒ 组集合由面板决定、env 不复活被删的组;键级只在面板缺该键时补,面板的**显式空值/`false`/`null` 绝不覆盖**;「面板改一次、重启被打回」为硬约束;凭据不进日志;范围仅 `models.json` 一族)。**C. 语音(新增 §3.15)**——语音轮次**现在走与文本同一条路径**(落库 = 转写文本 + 助手回复、发 `message_persisted` 与流式帧、跨端同步、忙时按既有规则排队,`pending_ack`/`pending_drain`/`session_busy` 语义零变化);`deviceId` 照旧落库、`platform` 走存储默认 `web`;`voice_input` 增可选 `clientMsgId`(重复到达回 `chat_duplicate`,不产生新回合、不发音频);`voice_output` 增 `sessionId`(恒有)/ `deviceId`(非空才有),**空正文与 TTS 失败/降级都走既有 `provider` 降级信号**(不再把 `stopReason` 当语音内容);**时点** = 回合结算时才到(可能晚于若干状态帧、也可能由续跑或被 steer 注入的那一回合产生);**定向目标 = 发起 `voice_input` 的那条连接本身**(不按 `deviceId` 路由)、**断线不补投**(带来源 WARN),重连带同一 `clientMsgId` 重试安全Line truncated
|
||||
- `v0.58`(**纯新增 env 键:`LLM_MULTIMODAL`——让 env-only 部署也能声明「主模型自身能收图」**,依据裁决 14.4.1 / design/06 §0(commit `4adfc9a`),2026-10-04 落实)。**新增 1 个 `.env` 键**:`LLM_MULTIMODAL`(`bool`,**默认 `false`**)= `models.json` 组 `multimodal` 的 **env 等价物**,**只在没有有效 `models.json`(走 `.env` 合成组)时生效**(有组文件时以该组的 `multimodal` 为准,与 `LLM_REASONING_KIND` 同口径);`true` = 图片**直进主模型上下文**;**留空 / `false` = 把图交给 vision/OCR 工具换成文本描述**;**非 bool 值(如 `maybe`)启动即报错**(fail-fast,不静默回退)。**兼容性**:默认 `false` ⇒ **老部署行为逐字不变**(引入本键之前,env-only 部署没有任何键能声明该能力,图片恒走 vision 工具);**`LLM_MULTIMODAL=true` 后图片走主模型直收,属部署者显式选择**。**无端点 / 帧 / 字段形状变更**(纯新增 env 键,见 §2「模型配置契约」表与 §1 行 3)。
|
||||
- `v0.57`(**13.4.5 目标态 steer 落地登记:新下行帧 `steer_ack`(回合内转向的消费回执)**,依据 design/15 §2.0 / §2.1 / §2.4 / §3.x / §5(**13.4.5 目标态规格**,commit `3d8822c`),2026-10-04 落实;**本版是已实现并已推库行为的登记,只有一处纯新增帧 + 一处链路图纯增量键,无其它契约变更**)。**A. 新下行帧 `steer_ack`(见 §3.14)**——`{type:"steer_ack", sessionId, data:{messageId, atStep}}`:忙时会话里新到的用户消息**被在飞回合吃进同一回合**时下发,`messageId` 是那条用户消息的**权威 id**(与 `message_persisted` 的 `data.message.id` 同源)、`atStep` 是吃进它的**模型步序号(1 基)**;同一批次吃进 N 条就发 N 帧、**不合并**。语义=「**已被本回合吃进(已从 pending 移除)、尚未被正面回答**」⇒ 客户端应把对应气泡渲染为「**已读、未答**」的**灰态**,直到该回合的 `message.*` / `turn.end` 到达,**不需要第二个确认帧**(与「忙『稍后回』」的 pending 气泡不是同一个态)。**角色门**:它**携带具体消息身份**,因此**不登记**进 `frameReachesAll` 的 7 个状态机信号白名单,落 fail-closed 默认档——**只发 `owner` + `admin`**(`viewer` 收不到);经编排器状态通道**由 hub 直接广播、不经 bus**;帧壳带 `sessionId`、**不带** `turnId`/`seq`。**触发时机**:只在**回合内边界**(loop 每一步开头:上一次模型步与它那一批工具调用都已结束、下一次模型调用尚未开始,此刻没有工具在飞)产生,且**只有该边界真的匹配到 pending 里、且已落库的消息时才发**——**不是每个 step 都发**(无可注入消息的边界一帧都不发)。**B. 转向的三条不变量(design/15 §2.0)**——**I1(同会话在途回合数恒为 1)**:转向只注入、不取消,**绝不新开第二个回合**(loop 侧结构性地没有任何「起新回合」的分支,注入只是把消息并进**当前回合**下一次模型调用的视图);**I2(消息恰好被处理一次)**:注入=**已消费**——被注入的消息立刻从该会话 pending **移除**(按 pending 序号认领),绝不被第二个回合再答一遍;去重键=后端 `message.id`,候选必须**同时**满足「在该会话转录里」与「在本编排器的 pending 里」(**不用时间戳**:Windows `time.Now()` 步长约 0.44ms,连发常同毫秒并列,任何毫秒基线都会漏注入或重复注入);**I3(转向不取消已发起的工具)**:注入点只在回合内边界,此刻没有任何工具在飞,正在执行的工具**永远跑完**(与 Cline PR #13958「只打断在飞的模型请求、工具与整轮继续」同口径)。**C. 注入的范围(重要口径)**——注入**只影响本轮视图**(每一步边界重新并入),**不写回运行窗口** ⇒ 既不进该回合的 **checkpoint 快照**、也不进**后台记忆提取的输入面**;被注入的消息**本身早在到达时已落库**(pending 路径即时持久化),注入**不新增、也不改写任何行**(它与 `message_persisted` 是两回事:后者在该消息落库时发,前者在该消息被吃进时发)。最新性=**每个边界重读该会话转录**(`history.Recent`),不靠回合开始那一次播种(回合开始前已有的消息由 `startTurnMessages` 播种,**绝不重复注入**)。**D. 失败一律让路(诚实边界)**——未接转录存储 / 读转录失败 / 该消息尚未落库 / 本回合世代已被打断或被更新的回合取代(认领 `pending` 失败)时,**不发 `steer_ack` 也不注入**:该消息**留在 pending**、照旧作为自己的回合被回答,**绝不静默消失**(转向是增益,任何一处失败都不该让已经在跑的回合失败)。**未做**:**没有「取回 / 撤回 pending 消息」的上行帧**(design/15 §5 提到的队列取回暂无协议面),也没有「已撤回」类下行帧;`steer_ack` 只报「**吃进**」,**不报「回答」**(回答由既有的 `message.*` / `turn.end` 表达)。**E. 一处随实现落地的链路图纯增量键**——注入时给该轮图的 **input 节点**打 `props.steered:true`(`loop.FlowPublisher.MarkInputSteered`),让流程图如实表达「这一轮的输入在回合中途被追加过」;图仍**只有一条主干**(**不是**被画成第二轮,与 I1 一致),标记只并入既有 input 节点、**不新建节点、不改标签**。**F. 实现落点**——loop 侧 `internal/runtime/loop/steer.go`(`SteerRequest` / `SteerSource` / `WithSteerSource` / `steerAtBoundary`)+ `run.go` 的边界调用点 + `flow.go` 的 `MarkInputSteered`;网关侧 `internal/services/gateway/session_steer.go`(每回合一份的 `steerTurn` 游标 / `match` / `consumePending`)+ `session_orchestratLine truncated
|
||||
- `v0.56`(**契约债一次补齐(协同项 A2 / G1 / G2 / G3 / G4 / F3):`interrupt` 支持 `scope` + REST `stream:true` 不再静默失败 + `chat.image` 上限 + `clientMsgId` 幂等 + 视觉链路 URL 取回事实 + 既有错误陈述修正**,依据 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的 A2 / G1 / G2 / G3 / G4 / F3(commit `7512d59` 等),2026-10-04 落实)。**A. `interrupt` 支持 `scope`(A2,纯新增可选字段)**——上行 `interrupt` 由 `{sessionId}` 扩为 `{sessionId, scope?}`,取值域 `{"", "all", "current"}`(省略 / 空串 / `"all"` **三者同义**,= 现状语义:取消在跑回合 + **清空**待处理队列;老客户端只发 `{sessionId}` 的行为**逐字不变**)。**新增 `"current"` = 只取消在跑回合、保留待处理队列**,队列消息按编排器正常续跑规则继续执行(带上会话尾部上下文)——修的是「客户端『打断』与『追加队列』两种模式被后端当成一种」:打断一律连队列清空时,用户选「打断」后连发的多条消息会被**静默丢弃**,只剩一个 `dropped` 数字。**其余取值 → 定向回** `{type:"error",data:{code:"bad_request"}}`(只回发送者,**不广播、不打断任何会话**),显式报错而绝不静默按默认执行。**下行 `session_interrupted` 帧形状零变化**(`{sessionId, mode, dropped}`),语义改为「如实上报」:`dropped` 恒等于**真的被丢弃**的待处理条数(`scope:"current"` 时为 `0`),且该档 `mode` 为会话当时的**忙碌通道**(`thinking|executing|subagent`,**不是 `idle`**)。实现落点 `internal/services/gateway/session_interrupt.go`(`InterruptScope` / `InterruptScope()`,从编排器主文件拆出以守住 ≤500 行)与 `ws_hub_turn.go` 的 `interruptRequest.Scope` + `parseInterruptScope`(**大小写敏感**、只容忍首尾空白)。**B. REST `stream:true` 不再静默失败(G2,行为变更,客户端必读)**——见 §1 行 3 与 §4.2:该分支改为与 WS `chat` 帧**同一条直达编排器的路径**(design/15 §2.1「会话消息不得被 drop」)。**响应面**:受理成功后才回 `200 {data:{stream:true}}`;正忙已续接 `202 {data:{stream:true,queued:true,queueDepth}}`;队列满 `409 {error:{code:"chat_busy"}}`;幂等键重复 `200 {data:{duplicate:true,messageId,sessionId}}`;**未接 loop 由 `200 {stream:true}` 改为 `500 {error:{code:"chat_unavailable"}}`**(与同步路径同口径);仅**未接编排器**时回退总线派发,其失败回 `503 {error:{code:"chat_dispatch_failed"}}`(修复前 `Publish` 的错误被吞掉、且传的是**请求作用域** ctx)。**`202` / `409` 从此真的会出现**(修复前因「先 return 200」永不出现)。派发用的回合上下文与请求作用域脱钩(`context.WithoutCancel` + 30 分钟上限),回合不会在客户端刚收到 200 时被连带取消。**兼容性**:`stream:false` 与既有响应形状**一字未动**;**依赖「任何情况都拿到 200」的客户端必须按上表分支**。**C. `chat.image` 上限(G3,纯新增校验)**——`POST /api/v1/chat` 的 `image` 新增硬上限:**解码后 5 MiB**(与 `POST /api/v1/upload` 同口径;base64 字符串上限 = `ceil(n/3)*4 + 64` 字符,覆盖 data: URI 前缀余量),超限 → `400 {error:{code:"image_too_large", message}}`(文案指路 `POST /api/v1/upload`),在**任何解码 / 落库 / 入模型之前**拦下(超大 base64 因此不进历史、也不会被重连回放重传;校验按**字符串长度**做——入口层唯一零分配的一步,先拦再接)。**事实(诚实边界)**:**WS `chat` 帧到不了这条 400**——WS 读上限 `wsReadLimit = 4096` 字节/帧,超限由传输层直接断连 ⇒ **WS 上发真实图片本就必须「上传 + URL」**(图片有「上传」与「内联」两条入口,两边给出同一个「一张图能有多大」的答案)。**D. `clientMsgId` 幂等(G4,纯新增可选字段 + 纯新增帧)**——见 §3.13:上行新增**可选** `clientMsgId`(REST `chat` 体与 WS `chat` 帧都支持),同一会话内同一非空值只承认第一条;重复到达 REST 回 `200 {data:{duplicate:true,messageId,sessionId}}`、WS 回**新下行帧 `chat_duplicate`**(`{type:"chat_duplicate", sessionId, data:{sessionId, clientMsgId, messageId}}`,**未进 7 个状态机信号白名单 ⇒ fail-closed 只发 `owner` + `admin`**),且**不落库、不起回合、不发任何内容 / 流式帧**。判重由 `(session_id, client_msg_id)` 的**部分唯一索引**(`WHERE client_msg_id != ''`,空键刻意排除在外——否则同一会话第二条空键消息就会撞唯一约束)**原子裁决**,并发重发只有一个赢家;非空键的写入走 `ON CONFLICT DO NOTHING`。`clientMsgId` 另**回填进消息 DTO** ⇒ `message_persisted` / `channel_persisted` / 握手 `hisLine truncated
|
||||
- `v0.55`(**阶段 7 落地项登记:路径式安全入口 + 数据目录 `platform` + OneBot v11 四端 + 可观测实时推送**,依据裁决 1.6 / 16.4.7 / 12.4.2(commit `a398dc9`)、1.4 / 12.4.1(commit `9253fef`)、05.4.3 + 16.4.3-WS(commit `0e1f163`),以及 13.4.5 面内三条(commit `74b12d8`)、MCP 官方 SDK(commit `a1083a3`),2026-10-04 落实;**本版全部是已实现并已推库行为的登记,无新代码**)。**A. 安全入口(`ADMIN_PATH`,裁决 1.6 / 16.4.7,**前端路由面——不是 REST 契约**)**——① **新增 env `ADMIN_PATH`**(默认 `admin`):面板前端路由前缀 = `/` + 值(`admin` → `/admin`;`xxx/admin` → `/xxx/admin`);**非法值回退 `/admin`**——容忍首尾斜杠与空白,拒绝空段、`.`/`..` 段与含 `?`/`#` 的段(宁可挂回默认,也不挂到不可预测的路径上)。② **HTTP 行为**:`/<ADMIN_PATH>` 与其深链(`/<前缀>/<section>`)返回**注入了 `<base href="/">` 与 `window.__CYRENE_ADMIN_ENTRY__={"prefix":"..."}` 的 index.html**(`Cache-Control: no-store`;`<base href="/">` 必在资源引用之前,否则多段前缀/深链下 Vite 的 `./assets/...` 会解析到错误目录);**形如面板入口但前缀不符的路径 → `404`**(判定 = 路径里出现 `admin` 段;既不回落用户端聊天壳、也不回显正确前缀);`/<ADMIN_PATH>` 若**恰好命中 `dist` 真实文件**则仍走文件服务器(面板入口页绝不顶掉 SPA 资源)。③ **API 不藏**:`/api/v1/*`、`/api/v1/admin/*`、`/ws/chat`、webhook 一律原样——**无新端点、无帧变更**,安全靠 JWT + 角色门(design/07 §0.1)。④ **口径**:**用户端 HTML 永不携带该前缀串**(后端只在命中前缀时注入)⇒ 用户端既无管理入口链接、也解析不出面板路由(design/07 §0.2)。前端路由随之改**路径式**(`frontend/web/src/admin/router.ts` 只读注入、**无硬编兜底**、移除 hash 入口;模块切换走 history)。**B. 数据目录(同 commit,裁决 12.4.2)**——`DATA_DIR` 新增可接受值 **`platform`**(大小写不敏感)→ 平台标准数据目录(Windows `%APPDATA%\Cyrene`、macOS `~/Library/Application Support/Cyrene`、Linux `~/.local/share/cyrene`);**默认 `./data` 与显式路径行为不变**;**显式空值行为不变**(仍走组合根原兜底,避免正在运行的实例突然换库)。**C. OneBot v11 四端(`9253fef`,裁决 1.4 / 12.4.1)**——① **配置面**(channels.json 账号 `config` + 适配器 `Config()` 快照)新增:`transport`(`ws`|`http`,默认 `ws`;**未知值一律回落 `ws`**,绝不静默改走 HTTP)、`api_base_url`(OneBot HTTP API 基址,必须显式给出——**不从 `remoteURL` 推导**,v1 那种「剥掉最后一段路径」的推导是有损的)、`report_path`(默认 `/`,无前导 `/` 时补齐);快照里 camelCase 为 `transport` / `apiBaseURL` / `reportPath`;`remoteURL` 快照值 = `remote_url`、为空时回落 `api_base_url`(语义即「**出站远端地址**」)。**既有键与值不变**——不给出任何新配置时行为与改造前逐字节一致(`transport=ws`、`mode=server`)。② **env 兜底**:`CHANNEL_QQ_OBV11_TRANSPORT`(默认 `ws`)/ `CHANNEL_QQ_OBV11_API_BASE_URL` / `CHANNEL_QQ_OBV11_REPORT_PATH`。③ **四端语义(谁向谁发数据)**:`ws+client` Cyrene 拨号 OneBot、收发同一 WS;`ws+server` OneBot 反连 Cyrene、收发同一 WS;`http+client` Cyrene→OneBot(`POST {api_base_url}/{action}`、等响应、按 `retcode` 判成败,**纯出站**、无长连接、token 只走 `Authorization: Bearer`);`http+server` OneBot→Cyrene(POST 到 `listen_addr` + `report_path`,**纯入站**;**fail-closed 鉴权**——配了 `access_token` 时 `Authorization: Bearer` 或 `?access_token=` 不匹配回 `403`,`access_token` 为空则放行,与既有 `ws+server` 同口径)、**10MiB 请求体上限**(超出 `413`)、方法非 `POST` 回 `405`、成功回 `204`。④ **关键取舍**:`mode` 决定**哪一半必需**(server 必需 `listen_addr`、client 必需 `api_base_url`),另一半只要给齐字段就**一并启用** ⇒ **单个适配器即可完成「收上报 + 回消息」闭环**,四端仍各自可独立选择与测试;任一端配置有误即 `Connect` 失败(先做纯配置校验并构造出站客户端,再绑监听端口),**绝不留下半开的端口**。⑤ **端口例外**:`http+server` 会用 `listen_addr`,属 `AGENTS.md` §9 已登记的**同一豁免**(design/13 §0 原文即写「WS server / HTTP server」);`listen_addr` 为空则报错、**绝不开端口**(无默认值)。⑥ **未接**:`get_group_list` 枚举(后续)。**D. 可观测实时推送(`0e1f163`,裁决 05.4.3 + 16.4.3-WS)——新增 §3.12**:日志 / 指标 / trace 从「面板 1s 轮询 REST」改为 **WS Line truncated
|
||||
|
||||
- `v0.54`(**契约债一次补齐:渠道配置写路径改 patch 语义(修 P0 级凭据静默丢失) + 阶段 6 前端批登记 + 三处过时陈述修正**,依据 design/04 §3.1、design/22 §2 方案 b 与裁决 16.4.1 / 16.4.2 / 16.4.3 / 16.4.4 / 16.4.5 / 16.4.6;commit `4ae7148` / `f454273` / `b480b04` / `c1a98d7` / `8d70f6d`,2026-10-04 落实)。**A. 渠道配置写路径由「整条替换」改为「按 id 打补丁」(P0 级数据安全,破坏性语义变更)**——① **语义变更**:`PUT /api/v1/admin/channels/{platform}/config` 的 `config` 从「整条替换该账号配置」改为「**按 id 打补丁**」(`runtimeconfig.PatchChannel`,**与 `personaId` 同构的三态**):**键省略(该键不在 `config` 里,或 `config` 整体缺席 / 为 `null`)= 保留该账号已存值**、**给出非空值 = 覆盖该键**、**显式 `null` 或 `""` = 清除该键**(适配器回落零值;`0` / `false` 是真实取值,**不算空**)。**破坏性提示**:按旧文实现、依赖「省略某 `config` 键 = 删掉它」的客户端,**必须改为显式传 `null` / `""`** 才能清除该键。② **修掉的数据安全问题**:面板只发送用户本次**碰过**的平台字段,后端却整条替换 ⇒ ⓐ 保存**微信(iLink)**账号卡会抹掉 `bot_token` / `bot_id` / `owner_id` / `get_updates_buf`(**iLink token 是长期凭据**,被抹掉即掉线且不可恢复);ⓑ **Telegram** 只填 `webhook_url` 而不重填 token ⇒ `bot_token` 变空 ⇒ `cleanAccounts` **丢掉整个账号**。两条在修复前都是「面板保存成功、盘上凭据没了」的静默丢失。③ **保留清除能力**:显式 `null` / `""` 仍能真正删除该键,未触碰的兄弟键不受影响。④ **未改**:token 要求型平台(kook / telegram)上**显式**清 `bot_token` 仍按 `cleanAccounts` 丢掉该账号(明确要求清除凭据,不是误删);「本次请求一个 `config` 键都没给出」在**非 `weixin-ilink`** 平台仍回 `400`(`weixin-ilink` 豁免以便先建账号再扫码)。⑤ **无新端点 / 新字段 / 响应形状变更**:请求体与 `201 {data:{persisted,path,platform,accountId}}` 一字未动,只有 `config` 的解释规则变了;`allowGroups` / `denyGroups` / `allowUsers` / `denyUsers` 与 `groupMsgPerMin` / `msgPerMin` / `maxMessages` **仍是整值替换**(省略 = 清空,客户端须回传完整列表 / 非零值)。⑥ **实现落点**:`internal/config/runtimeconfig/channels.go` 新增 `PatchChannel` / `mergeChannelConfig` / `isEmptyConfigValue`;`internal/services/gateway/admin_write_channel_config.go` 的 `adminSaveChannelConfig` 改调 `PatchChannel`(`channelConfigFromRequest` 返回**补丁**而非完整配置)。**验证**:`go test ./internal/services/gateway/ -run TestAdminSaveChannelConfigKeeps -v`(微信 iLink 凭据保留 / 空补丁三形态 / Telegram 账号存活 / 显式清除 / 无基线新建)+ `go test ./internal/config/... -run TestPatchChannel -v`;**变异验证 3 条**——把写路径改回**整条替换**(旧 upsert 语义)后,「省略键保留」「Telegram 账号存活」「空补丁不改凭据」三类断言**精确复现** `bot_token` / `get_updates_buf` 被抹掉与账号被 `cleanAccounts` 删掉的旧缺陷 ⇒ 变异必红。**B. 阶段 6 前端批(纯前端行为,无 API 变化)**——① **16.4.1 静默 / 活动态与预约切换**(`f454273`,帧缺口补 `b480b04`):输入模式切换的唯一入口 `frontend/web/src/components/modeSwitch.ts`——**她活动**(`sessionBusy || 屏上 streaming || sending`)时用户切换只登记「**已预约**」(不改当前模式、不上行、不清除既有预约),**她静默**时立即生效并上行 `mode_switch`;提前切 → **下次静默态落地**(徽章「已预约 · X(她静默后生效)」);预约状态**纯前端**、不进后端契约。② **16.4.5 会话级消息缓存**(`b480b04`):**帧按自己的 `sessionId` 落进该会话的消息桶**(新增 `frontend/web/src/store/sessionCache.ts` 纯函数 + `useAppStore.sessionMessages`),**切会话「存回 / 取出」不再清空**、非激活会话的帧**不再被丢弃**;**只有「单槽忙态」**(`session_busy` / `session_idle` / `session_interrupted` / `stream_end` 的 `sessionBusy` 收口)仍按激活会话判定(见 §3.1 / §3.2)。③ **16.4.2 导图口径**(`b480b04`):`graph_update` 按 `data.sessionId` 入**图桶**(上限 12 张)——管理端 Flow / Agents 页取**激活会话**的图、**Backthink 页取 `sess-backthink`** 的图,后台思考图不再顶掉「当前会话」图(见 §3.5)。④ **16.4.3 / 16.4.6 虚拟滚动与专属页只读卡**(`c1a98d7`):管理端长列表超过阈值走**窗口化**(`AdminTable` / 新增可复用 `VirtualList` / 适配器消息日志,DOM 行数与数据量解耦);专属页**只读展示该平台Line truncated
|
||||
- `v0.53`(**阶段 5 落地:模型目录端点 + per-persona 路由 / 本地三级 / capacity L3 / 视觉·OCR 独立模型 / `image-crop` 工具 / 按 provider 形状下发推理开关**,依据 design/04 §3/§3.1/§3.2(裁决 03.4.1 / 03.4.2 / 03.4.3 / 08.4.1)、design/06 §0/§3(裁决 14.4.1 / 14.4.5)、design/14 §2.2/§3/§4(裁决 14.4.2 / 14.4.3 / 14.4.4),2026-10-04 落实):① **新增管理只读端点 `GET /api/v1/admin/llm/catalog`(owner|admin)**——面板「添加提供商 / 添加模型」的候选目录(baseURL / 模型 id / 能力位 / 输出上限),数据源 `https://models.dev/api.json`;`?refresh=1` 强制在线刷新,否则 24h TTL 内的本地快照直接复用。`200 {data:{source:"cache"|"models.dev", fetchedAt?, stale?, warning?, providers:[{id,name,api?,doc?,env?,npm?,models:[{id,name,attachment,reasoning,toolCall,contextLimit?,outputLimit?}]}]}}`;**既无快照又抓不到** → `503 {error:{code:"catalog_unavailable"}}`;**有快照但刷新失败** → 仍 `200` + `stale:true` + `warning`(离线兜底,不编造候选);带 `Accept-Encoding: gzip` 时响应带 `Content-Encoding: gzip`(信封形状不变)。**副作用只有一处**:落盘快照 `<DATA_DIR>/llm_catalog.json`(**无凭据**、`data/` 已 gitignore、TTL 24h),**不改 `models.json`**;**抓取只发生在本端点内,不进运行时热路径**。审计动作 `llm.catalog`(`ok`/`stale`/`error`)。详见 §2「模型配置契约(v0.53)」。② **`models.json` 组级新增三键**(可热更新,`GET`/`POST /api/v1/admin/llm` 已同步读写;**未声明时该键不出现**):`reasoning: {kind}` 声明该组的推理开关**形状**;`personaModels: {personaId: modelId}` 与 `personaProviders: {personaId: "openai"|"anthropic"}` 是**角色级模型/协议路由**(「角色」= 该回合答话的 persona,不是消息作者 role)。**校验在 `Group.Validate`**:非法 `kind`(只认 `auto`/`deepseek`/`openai`/`vllm`/`dashscope`)、anthropic 组上声明 openai 系形状、`personaProviders` 的非法协议名、空 persona id/空值 → **写端点回 `400 bad_request` 且不落盘**;persona 表键值会 trim,**trim 后撞键直接报错**(不静默丢条目)。**请求语义**:三键缺省 = 保留已存值(`reasoning` 整块给出即整块替换,persona 表给出即**整表替换**——否则面板无法删除一条已存路由);**响应语义**:每组的 `reasoning` **恒下发**(`kind:""` = 未声明形状,不是「后端没报告」),`personaModels`/`personaProviders` 为空则省略;顶层新增 **`reasoningKinds`** 与 **`personaProviderOptions`** 两个取值域数组(由后端常量直接生成,面板无需硬编码枚举)。**回落**:未绑定/未知 persona → 该组默认模型;`personaProviders` 声明的协议在 `models.json` 里没有对应组 → 记 WARN 并跳过(该角色仍走默认链,**不半建一条只会失败的链**)。③ **语义修正(重要,此前文档写反了)**:**`multimodal` = 主模型自身能否收图**(不是「本部署有没有视觉能力」);`LLM_VISION_MODEL` / `LLM_OCR_MODEL` **只配视觉/OCR 工具用的模型**(OCR 空则复用视觉模型,两者都空则复用主模型),与 `multimodal` **互不代表**(裁决 14.4.1)。④ **`.env` 新增 9 键**:`LLM_REASONING_KIND`(默认空 = `auto`;**只在无有效 `models.json`(走 env 合成组)时生效**,有 `models.json` 时以该组 `reasoning.kind` 为准)、`LLM_OCR_MODEL`、`LLM_LOCAL_ENABLED`(默认 `false`)/`LLM_LOCAL_PROVIDER`(默认 `openai`)/`LLM_LOCAL_BASE_URL`/`LLM_LOCAL_API_KEY`/`LLM_LOCAL_MODEL`(本地第三级:`Enabled` + `BaseURL` + `Model` 三者齐备才成为链尾一步,只开开关则 WARN 并跳过)、`CAPACITY_L3_ENABLED`(默认 `false`)/`CAPACITY_L3_CACHE_TTL`(默认 `5m`)。⑤ **内置工具新增 `image-crop`**:按区域裁切图片(`image` + `x`/`y`/`width`/`height`,0 基像素、区域须完整落在图内)→ PNG data URI + 裁切尺寸 + 源图尺寸;供**大图被上游压缩后分区精读**(design/06 §3)。上限:源图 ≤50MiB、区域 ≤16MP、载荷 ≤8MiB(超出**报错**,不截断);源可为本地路径(file 工具同一沙箱)/ http(s) URL(同 SSRF 守卫)/ base64 data URI。**策略默认 owner-only**(`tool_policies` 无行时 fail-closed)。⑥ **推理开关按 provider 形状下发(替换 v0.41 的「只 WARN 不发」)**——形状由 `reasoning.kind` 声明(未声明 = `auto`:按模型名/baseURL 推断厂商,**形状有歧义就不下发**),字段名映射表:
|
||||
|
||||
| 形状 `kind` | 线上字段 | 说明 |
|
||||
|---|---|---|
|
||||
| `deepseek` | 顶层 `thinking:{type:"enabled"\|"disabled"}` + `reasoning_effort` | 思考**默认开**,「不传」≠「关」:要关必须显式 `disabled` 或 `effort:"none"`;`none` 胜过 `enable:true`(关思考优先);`budget_tokens` 在该协议上无对应字段,记 WARN 丢弃 |
|
||||
| `openai` | 仅 `reasoning_effort` | 该系没有布尔思考开关;`thinking.enable` 记 WARN 丢弃(官方关推理路径 = `effort:"none"`) |
|
||||
| `vllm` | 顶层 `chat_template_kwargs.enable_thinking` | 只有布尔开关,无 `reasoning_effort`;**`effort:"none"` 一律翻译成显式关思考**(不得降成任何强度档) |
|
||||
| `dashscope` | 顶层 `enable_thinking` | 同上;**只在显式声明该形状时下发**,绝不从模型名/BaseURL 推断 |
|
||||
| `auto`/未声明 | 无 | `deepseek`/`openai` 可推断时按其形状下发;**形状有歧义(如 Qwen 可能由 vLLM 或 DashScope 承载)或厂商未知 → 一个字段都不发**,逐项 WARN 并给出配置指引 |
|
||||
|
||||
- `reasoning_effort` 校验:按**厂商白名单**(OpenAI 系 `none/minimal/low/medium/high/xhigh/max`;DeepSeek `none/low/high/max`)——非法值**丢弃并记 WARN,不再「降级到最近一档」**(降档会反转调用方意图);DeepSeek 的别名 `minimal→low`/`medium→high`/`xhigh→high`/`ultra→max` 归一化后发送并记一条 WARN。Anthropic 组继续走自己的 `thinking:{type,budget_tokens}`(`reasoning.kind` 在其上被拒)。
|
||||
- 同批另含两条**对客户端无端点/帧/字段变更**的实现:**persona `awakening` 档位真生效**(`configs/personas/*.yaml` 的 `awakening` 块归一化为 `quiet|awake|full` 并进 capacity 决策;非法档位/越界阈值载入期报错、块缺席 = 无覆盖继续走全局默认,design/14 §3)、**工具越出本轮允许集的软判并回**(越界调用**仍然执行**——能力≠边界;结果里带 `not_allowed` 提示回灌模型,并记审计 `tool.reject.not_allowed`,design/14 §4)。`capacity_awaken` / `approval_notice` 等既有帧形状**一字未动**。
|
||||
⑦ **向后兼容**:新端点、新组键、新 `.env` 键、新工具都是**纯增量**;既有端点 / WS 帧 / 字段形状**无破坏性变更**(`GET /admin/llm` 只多出 `reasoning`/`personaModels`/`personaProviders`/`reasoningKinds`/`personaProviderOptions` 这些新键,消费旧键的客户端不受影响)。**未做(诚实边界)**:DashScope 形状的 `auto` 推断(需显式声明 `reasoning.kind=dashscope`)、Qwen 的 `/think` 前导消息软开关(与 design 口径冲突,本批未实现)。⑧ **实现落点**:目录 `internal/services/gateway/admin_llm_catalog{,_cache,_source}.go` + 路由 `routes.go`;组键与读侧 `internal/config/runtimeconfig/models.go`(`ReasoningOptions`/`PersonaModels`/`PersonaProviders`/`Validate`)、写读端点 `internal/services/gateway/admin_llm_provider.go` + `admin_write_llm_channels.go` + `admin.go`/`admin_query.go`;形状下发 `internal/runtime/model/reasoning_shape.go`(+ `reasoning.go` 的厂商白名单/别名、`openai_encode.go` 的线上字段);per-persona 路由 `internal/runtime/model/{router,persona_stack,runtime}.go`;本地第三级 `internal/runtime/model/factory.go`(`localChainStep`)+ `internal/config/config.go`;capacity L3 `internal/runtime/capacity/{capacity,classifier,l3}.go`;awakening `internal/persona/loader/awakening.go`;软判 `internal/runtime/tool/allowset.go` + `internal/runtime/loop/tool_policy.go`;工具 `plugins/tools/io/crop.go`。对应提交:`63e5b70`(目录端点)、`fa33c79`(per-persona 路由 / 本地三级 / L3 / OCR 独立模型 / image-crop)、`38b4527`(工具软判)、`71fefe5`(persona awakening)、`8890c30`(按形状下发推理开关 + per-persona 提供商链)。
|
||||
- `v0.52`(**阶段 4 三组落地:工具策略落盘 + 主代理代审小字 + 流程图节点参数摘要**,依据 design/11 §3/§4/§8(裁决 1.3 / 10.4.2 / 13.4.3)与 design/19 §1/§1.1(裁决 11.4.2 / 11.4.3 / 13.4.1),2026-10-04 落实):① **工具策略落盘**:`allowInGroup` / `allowInStranger` 的载体改为 SQLite 表 **`tool_policies`**(迁移 `0012_tool_policy`,唯一索引 `idx_tool_policies_name` 承载 upsert 冲突目标),`POST /api/v1/admin/plugins/policy` 从此**重启保留**——不再是「面板保存成功、一重启就复位」的假持久化(见 §2 行 5)。**写入路径先落库、后改内存**(`registry.SetPolicy` → 落盘成功才改内存并记入快照):**落盘失败则内存保持旧值并返回错误**,不会出现「面板说成功、内存已改、库里没有」的分叉;启动时 `AttachPolicyStore` 立即 Load 全量策略套到**已注册**能力上,此后 `Register` 进来的能力(MCP 热装、插件刷新)同样套上落盘值;**没有行的能力按默认(两布尔均 false = owner-only)处理**(fail-closed)。策略存储不可用/加载失败时**保持最严默认并记 WARN**,不静默降级成内存态、也不假装读出了策略。**对客户端无端点、无字段形状变更**。② **新增下行帧 `approval_notice`**(§3.11,design/11 §4):loop 的工具准入门(`ToolLoopHooks.CallAdmit`)判定某次调用属敏感操作(执行命令 / 修改文件 / 网络**写**方法)→ 经主代理代审后广播 `{type:"approval_notice", sessionId, data:{actor, tool, op, detail, explain, allowed, at}}`;`explain` 是**后端产出的固定文案**「<actor> 想要干什么,我审查后批准了」/「…我审查后拒绝了」(客户端**原样渲染**,不要自拼),`detail` 为动作摘要(工具 + 关键参数,目标部分截断到 80 rune),`allowed=false` 表示该敏感调用**被拦截未执行**(无代审者 / 代审报错同样 fail-closed 拦截,小字同为「拒绝了」)。**非敏感调用不产生该帧**;**子代理的敏感调用归属到派生它的父会话**(与 §3.10 同口径),小字因此落在主人正在看的那个会话里,而不是一个没人看的子代理 id 下。**审计**:每次代审在 `audit_logs` 落一行(批准与拒绝**各一行**)——`who`=角色名、`what`=`approval.<工具名>`、`result`=`granted|denied`,查询走既有 `GET /api/v1/admin/observability/audit` 与 `GET /api/v1/admin/notifications`。**角色门**:内容帧 → **仅 `owner` + `admin`**(fail-closed 默认,未扩那 7 个状态机信号白名单)。**前端已渲染**(`frontend/web/src/lib/ws/handlers/approval.ts` + `frontend/web/src/components/status.tsx` 的 `ApprovalNoticeHint`):按同一会话过滤规则把 `explain` 作为聊天流里的一行小字、`detail` 作副文本。**向后兼容**:纯新增帧,不改既有帧 / 端点 / 鉴权,老客户端按未知帧忽略即可。③ **`graph_update` 节点 `props` 新增四类可选键**(裁决 11.4.2 / 11.4.3,见 §3.5):**旁路节点的 span 首次真进图**——`memory.recall` / `memory.write` / `guardrail.input` / `guardrail.output` / `orchestration.spawn` 现在都由**真实生产者**产生,`kind` 因此会实际出现 `memory` / `guardrail` / `orchestration`;tool 节点另带 `args_keys`(该次调用的**全部参数名**)与 `args_summary`(**仅放行表内、且非密钥类**的键才给值,**密钥类键连值都不给**,单值 ≤**80 rune**、最多 **24** 条);旁路节点只带元数据(`action` / `scope` / `hits` / `bytes` / `count` / `failed` / `allow` / `subagent` / `elapsed_ms`),**不含记忆正文、不含提示词、不含密钥**。值在**发布前已脱敏**(凭据形状 → `[redacted]`、长文本截断),前端对未放行的键显示「值未展示」。**帧名、事件名、顶层字段集一字未动**——以上只是节点 `props` 内的**纯增量可选键**。④ **本版未做 / 诚实边界**:**代审是「记录并批准」**——「敏感与否」与「是否放行」仍由上层策略层判定,`orchestration/audit.Approver` 只做校验、记录、叙述并返回结果模型,**不构成新的权限边界**(身份/权限判定始终来自 channel 层 `actor` 元数据 + 白名单 + 策略引擎);**`agent.RunStep`(静音 / 非流式内部流程,如 backthink)的输出侧护栏仍同步内联**(其裁决是 `StepResult` 的一部分、不落在主会话关键路径上),只有**主会话流式关键路径**的输出侧护栏改为**后台异步**(`loop.checkOutputAsync`,裁决 13.4.1:真并发、不阻塞回合,拦截仍写审计留痕;**输入侧前置门依旧同步拦下**)。⑤ **实现落点**:策略落盘 `internal/capability/registry/policy_store.go` + `registry.go` 的 `SetPolicy`、迁移 `internal/db/migrations/0012_tool_poLine truncated
|
||||
- `v0.51`(**后台子代理结果登 WS 帧 + 回填父会话**:新增下行帧 `subagent_result`;依据 design/05 §9「子代理完成 → 结果回填主会话并提示用户」,裁决 02.4.3,2026-10-04 落实):① **问题**:`subagent_spawn` 的批次前台只等 2 秒(design/04 §8.5),转后台后结算时编排层已把合并结果发布到 bus 主题 `subagent.result`,但 gateway 的中继**不认这个 subject**(既非 `ws.*`,也不是已登记的 4 段广播形态 `cyrene.agent.*.event`)⇒ 结果**永远到不了前台**,前端唯一可见的是逐代理进度帧 `subagent_event`(「跑起来了/跑完了」,但**拿不到结论**)。② **新增帧 `subagent_result`**:`{type:"subagent_result", sessionId, data:{sessionId, status:"done", summary, agents:[{id, goal, role?, mode?, status, summary?, text?, steps?}]}}`——`data` 与派发工具返回给模型的结果**同形同值**(同一处投影、同一处 4000 字截断,`text` 即子代理的结论正文);**帧壳 `sessionId` = 派生该批次的父会话**,客户端据此把结果归到**那个会话**的实时流(多会话并发不串话),而不是当成一张全局帧。详见 §3.10。③ **回填边界(诚实说明)**:本版把「结果可见」做到了**会话作用域的实时帧**——结果现在真的到达父会话的 WS 流(此前被中继静默丢弃)。**未做**:不落库(父会话的持久化转录里**没有**新增一条消息,重连/回放不会补发它),也**未**经 design/15 的会话消息编排器排队续接;**现有 Web 前端也没有该帧的 handler**(按未知帧忽略,故今天的可见效果是「帧到达 owner/admin 连接」而非「聊天气泡里出现结果」)。REST/历史接口**逐条不变**:`GET /sessions/{id}/messages` 不会多出这条结果。④ **角色门(fail-closed)**:新帧属**内容帧** → **只发 `owner` + `admin`**,`viewer` 收不到;**未**扩那 7 个状态机信号白名单(design/11 §7.1,白名单要进须先在设计登记「不含内容」的理由)。⑤ **向后兼容**:**纯新增帧**——老客户端按未知帧忽略即可;不改任何既有帧的类型/字段、不改端点、不改鉴权;`owner`/`admin` 只是多收一帧。⑥ **实现落点**:生产者 `internal/runtime/orchestration/spawntool.go`(`SubagentResultEvent` 载荷带上派发回合的父会话);中继 `internal/services/gateway/ws_bridge.go`(`bridgePatterns` 订阅该 subject + `normalizeEvent` 专用成形:帧壳 `sessionId` 取自载荷父会话;载荷形状不符预期时退回通用映射,两路都只发 `owner`+`admin`)。⑦ **验证**:进程内两端各一条——编排侧断言后台批次发布的载荷**类型/键集/父会话/逐代理结果**;gateway 侧**真 bus Publish → 真 `/ws/chat` 连接**(真 JWT × `owner`/`admin`/`viewer`)断言 `owner` 与 `admin` 各收到**恰好 1 帧** `subagent_result`(帧壳 `sessionId` = 父会话,`data` 的汇总与逐代理字段逐项对得上)+ 1 帧哨兵状态信号,而 `viewer` **只**收到哨兵(收不到结果帧)。**变异验证**:① 把 `orchestration.SubagentResultSubject` 从 `bridgePatterns` 去掉 → 「owner 收到 `subagent_result`」必须 FAIL;② 把 `subagent_result` 加进 `frameReachesAll` → 「`viewer` 收不到」必须 FAIL(两条均已跑,还原后复绿)。⑧ **同版另含管理面(D 组,2026-10-04 落实,与上列同属 v0.51 一版)**:**① `X-API-Key` 真正接线**(裁决 15.4.2)——`Authorize` 之上叠加 key 分支,管理/用户侧已认证端点同时接受该头,签发出去的 key **真的能用**;**且 key 一律超管、取消 role 分级**(裁决 15.4.3,主人原话「key 始终给超级管理员权限」):签发端点**不再读 `role` 请求体**、响应恒 `role:"owner"`、任何有效 key 鉴权后即 `owner`。**fail-closed**:请求带该头时**只走 key 分支**,未知/已吊销/空值(或 key 表未注入)一律 `401`,**不回落 Bearer**;不带该头时 Bearer 路径**一字不变**;**WS 握手仍只认 Bearer**。**破坏性边界**:此前文档写的 `{"role":"admin|viewer"}` 请求体自本版起**被忽略**——若曾有客户端指望签一把只读 key,它拿到的其实是 owner key(这正是裁决要的效果:取消分级,不给「以为只读、实则超管」的错觉)。**② 新增主人档案读写 `GET`/`PUT /api/v1/admin/owner-profile`**(裁决 10.4.5,**owner-only**——这份档案决定谁能进 owner 车道,故连读都不给 `admin`;写带审计 `owner.profile.update`,读不审计;全新安装 `GET` 回 `200` + 空白档案而非 `404`):消灭前端**假保存**——面板的「保存」从此真的落盘 `configs/users/owner.yaml`(0600 原子写)并**热替换活档案**,渠道层 actor 判定立即生效、无需重启;校验失败(`idLine truncated
|
||||
- `v0.50`(**事件批:流式帧的结构化补齐**——`tool_execution` 三事件 + `streamId` + `turn.start/end` + `graph_update` 三范围口径;依据 design/04 §1.1(裁决 01.4.1 / 01.4.2 / 01.4.3 / 03.4.4)与裁决 01.4.5(design/20 §2),2026-10-04 落实):① **`tool_execution` 拆三事件(破坏性替换)**:旧单帧 `tool_execution.update {id,index,delta}`(`delta` 为 `"start:工具名"` / `"end:error:工具名"` 拼串)**不再由 loop 产生**,替换为 `tool_execution.start`(`{tool, phase:"running", id?}`,进入执行)/ `tool_execution.update`(`{tool, phase:"running", delta?, id?}`,运行中进度,可多帧)/ `tool_execution.end`(`{tool, phase:"done"|"error", id?}`,完成或失败)——**phase 与工具名是结构化字段,不再塞进 `delta`**;`id` 复用 provider 工具调用 id(可关联 `tool_call.*` 帧,无 id 时省略该键)。**迁移**:按旧 `delta` 拼串解析的客户端必须改读 `phase`/`tool` 字段;`tool_call.*` 帧形状不变。**诚实边界**:start/end 由 loop 在每次工具执行的首尾真实发出;`update` 的 wire 映射已实现并有单测锁定,但工具分发器尚无中途进度上报源,**loop 暂不产出 update 帧**(未来接入进度上报无需再改契约)。② **`streamId`(新增可选字段,位于模型流帧的 `data` 内)**:`message.*` / `thinking.*` / `tool_call.*` 帧的 `data` 带 `streamId`——loop 在**每次模型流**(一次 ChatStream 调用)开始时生成,同流多帧共享,多步工具轮的下一个模型调用取新 id;与帧壳 `turnId`(整轮恒定)分工:`turnId` = 哪轮回答,`streamId` = 这轮里的哪条流式消息。`tool_execution.*` / `turn.*` 帧不带。③ **`turn.start` / `turn.end`(新帧,无 `data` 载荷)**:loop 在 Run 起止发出、经 ws_bridge 下行(`model.StreamEvent` 新 kind → hub `broadcastStream` 映射);帧壳 `sessionId`/`turnId`/`seq` 即边界信号;`turn.end` 覆盖所有出口(最终答案 / 工具循环安全网终止 / 预算耗尽 / 步骤出错);回合终态仍由 `stream_end` 携带(分工不重叠);被静音的内部流程(backthink / 子代理)不发出。④ **`graph_update` 三范围口径**(01.4.5,随实现事实补记):发布范围为 `web` / `web:<uuid>` / `sess-backthink` 三类(此前文档只写「web 及其子会话」,漏了 backthink 思考链路图这一实现事实;实现侧 `cmd/cyrene/wire_runtime.go` 的 allow 谓词本就如此,本批零代码变更);并补记 **tool 节点 `status` 由 phase 驱动**(与 §3.1 `tool_execution.*` 的 `phase` 同枚举同决策:`trace.FlowStatusForPhase` 消费 tool span 上的 phase,缺省回落 span 终态)。⑤ **角色门**:本批新帧(`tool_execution.start/end`、`turn.start/end`)全部落 v0.48 下行门的「仅 `owner` + `admin`」档(fail-closed 默认,白名单未扩);`viewer` 行为不变。⑥ **向后兼容总结**:新增帧与新增键(`streamId`、`data.tool`、`data.phase`)可被老客户端安全忽略;唯一破坏点是旧 `tool_execution.update` 单帧消失(见 ①);**现有 Web 前端未消费 `tool_execution.*` / `turn.*` / `streamId`,本批对它零影响**。⑦ **验证**:loop 包断言三事件顺序/phase/工具名/id/Delta 为空、同流共享 streamId 跨流相异、turn 边界成对且覆盖错误出口、静音 run 零事件、flow 图 tool 节点 running→done/error 与 phase 一致;gateway 包真 WS 帧端到端(真 loop + 真 dispatcher + bus → relay → broadcastStream):按 seq 排序断言完整帧序 `turn.start → message.delta → tool_execution.start → tool_execution.end → message.delta → turn.end`(+`stream_end`)、`streamId` 每流独立、`tool_execution` 帧无 `delta`/`streamId` 键、turn 帧无载荷;帧门矩阵补 5 行(三事件 + turn 两帧 × 三角色)。**变异验证**:去掉 loop 的 `ev.StreamID = streamID` 透传 → 「streamId 每流独立/前缀」断言 FAIL(已跑,还原后复绿)。
|
||||
- `v0.49`(**WS 握手回放 + 上行帧的角色门**:把 design/11 §7.1 下行门落地时查出的**同一类缺口的另两处**一并收口——**对 `viewer` 继续收紧**,**`owner` / `admin` 行为不变**,**无端点、无字段形状变更**;依据 design/11 §7.2,2026-10-03 落实):① **问题**:本质与 §7.1 同一个——`viewer` 是**只读监控档**,却能通过 WS 拿到内容、甚至发起写入。(a) 握手 `history` 回放**对任意角色照发**(回放主会话最近 50 条**消息正文**,`thinking` 已剥离但正文本身即主人的对话),`viewer` 一连上就把对话拿走了;(b) `route` 的**上行分发完全没有角色检查**,`viewer` 发 `chat` 能**真的驱动模型并把消息落库**(只读档拿到写权限),`subagent_ask` / `task_output_query` 的应答是**定向帧**(`enqueueJSON`)因此**绕过 §7.1 的下行门**,把子代理上下文 / 任务输出直接回给 `viewer`。② **规则(fail-closed,默认拒)**:**A** 握手 `history` 回放**仅 `owner` + `admin`**(`ownerAdminClaims`,与主动推送同一个判据);**B** 非 `owner`/`admin` 连接发来的**任何**上行 JSON 帧一律拒绝——**定向**(只回发送者)回 `{type:"error",data:{code:"forbidden"}}`,**不广播、不驱动模型、不落库、不返回任何内容**;被拒帧:`chat` / `interrupt` / `subagent_ask` / `task_output_query` / `voice_input` / `mode_switch`(**默认拒**,未登记的一律拒)。**协议级帧不受影响**:WS ping/pong 与正常 close 照常(否则连接会掉),被拒帧也不关连接。③ **实现**:A 收口在**唯一**发出回放的 `hub.replayHistory` 顶部(**不放调用点**,没有第二个调用者能绕过);B 收口在 `wsConn.route` 的**第一条语句**,且写成「**默认拒**」而**不是被拒类型黑名单**——新加的上行帧类型自动被拒,漏一处也不会放行。④ **合口径(与 §7.1 一起读)**:`viewer` = **可以连接、可以读 12 条只读监控 REST(v0.40)、可以收 7 个状态机信号帧(v0.48)**;**除此以外 WS 上拿不到任何内容,也发不出任何指令**。⑤ **未来放宽的要求**:将来若真需要 `viewer` 上行(如手机端以 viewer 凭据做只读订阅之外的交互),**必须先在 design/11 §7.2 给它开口并登记白名单**;代码里那把「默认拒」必须**改造成登记表**,**不得直接删掉**。⑥ **测试与变异验证**:真 WS 连接 + 真 JWT(复用 v0.48 的测试装置)。A:`viewer` 连接**收不到** `history` 回放,`owner` / `admin` **收得到**(且回放里 `thinking` 仍为空);B:`viewer` 依次发 `chat` / `interrupt` / `subagent_ask` / `task_output_query` / `voice_input` / `mode_switch`,**每次都收到定向 `forbidden` error**,且其后**无任何内容帧**(对 `subagent_ask` / `task_output_query` 尤其断言「不得收到定向的 `subagent_event` / `task_output`」),全程**计数 runner = 0 且落库 = 0**;另一条 `viewer` 连接作为观察者**不收**这些 error(证明是**定向**而非广播);回归:`owner` 发同一个 `chat` 仍然**真的驱动 runner(= 1)**并把 user + assistant 两条**落库**;协议级:被拒后 ping/pong 往返与正常 close(服务端注销连接)**均正常**。**变异验证**:① 去掉 A 的门 → `viewer` 立刻收到 `history`(A 断言 FAIL,已跑);② 去掉 B 的门 → `viewer` 的 `chat` 驱动 runner **1 次**并**落库 2 条**、`subagent_ask` 直接回 `subagent_event`(B 断言 FAIL,已跑)。⑦ **影响面**:`owner` / `admin` **逐字不变**;`/ws/chat` **连接鉴权不变**(`viewer` 仍可连);无新端点、无字段形状变更;今天**没有 `viewer` 客户端在跑**(`/auth/login` 仍写死签发 `RoleOwner`,除测试外无 viewer 凭据),故线上实际影响为 0。
|
||||
- `v0.48`(**WS 下行帧的角色门**:`/ws/chat` 的下行帧改为**按帧类型分档**——**对 `viewer` 是收紧**(此前它连上就能收到角色回复正文与推理内容,现在收不到),**`owner` / `admin` 行为不变**,**无端点、无字段形状变更**;依据 design/11 §7.1,主人 2026-10-03 拍板「按建议收紧」):① **问题**:hub 的下行是**全连接广播**,而 `/ws/chat` 只验 JWT(任意角色可连),于是 `viewer` 连接会收到 `message.*` / `thinking.*` / `tool_call.*`(**含角色回复正文与推理内容**)、`message_persisted` / `message_edited` / `message_deleted`、`graph_update`、`mood`、`subagent_event` / `task_output` / `device_update` / `reminder` 等——与 design/19 §1/§3「flow 可视化仅 owner/admin」直接冲突,也与 `GET /sessions/{id}/messages` 的 owner/admin 档位不一致(**同一批内容从 WS 旁路漏出**)。② **规则(fail-closed,默认拒)**:**只有白名单里的 7 个「状态机信号」**(`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full` / `session_read`,**均不含任何内容**)发给**所有已认证连接**(含 `viewer`);**其余全部**(`stream_chunk` / `stream_end` / `thinking`、`message_persisted` / `message_edited` / `message_deleted` / `channel_persisted`、`graph_update` / `subagent_event` / `subagent.result` / `task_output` / `task_done` / `tool_used` / `capacity_awaken` / `mode_switch`、`mood` / `device_update` / `reminder`)**只发 `owner` + `admin`**;**未登记的新帧类型默认只发 owner/admin**(要进白名单须先在 design/11 §7.1 登记理由「不含内容」)。③ **实现**:收口为**唯一一处**「按帧类型分类后投递」的入口(`internal/services/gateway/ws_frame_scope.go` + `hub.broadcast`),**不给每个调用点加开关**(那正是「漏一处即泄漏」的写法)——流式 `message.*` / `thinking.*` / `tool_call.*`(`broadcastStream`)、`stream_chunk` / `stream_end`(`runTurn` / `streamChat`)、`message_persisted` / `message_edited` / `message_deleted`、`mood` / `capacity_awaken`、编排状态信号(`StateSink`)、`graph_update` / `subagent_event` / `device_update` / `reminder`(`relay` 归一化)**全部经同一入口**,不存在第二条投递路径。④ **语义澄清(不是数据从此不可见)**:这条门管「**谁该被主动推送**」——`viewer` 的 **12 条只读监控端点**(v0.40 `viewerAuth`,含 `GET /personas` 的角色级 `mood`)**逐条照旧**;`/ws/chat` 的**连接鉴权不变**(`viewer` 仍可连,只是收不到上列帧);**定向回请求连接的帧**(`voice_output` / `error` / `subagent_ask`、`task_output_query` 的应答)不进这道门、行为不变。⑤ **测试与变异验证**:真 WS 连接 + 真角色 token 的**逐帧表驱动**断言(design/11 §7.1 表里**每一个**帧类型 × 三种角色:`owner` / `admin` 两类都收得到,`viewer` 只收白名单)+ 帧类型 × 角色的直接矩阵 + 「白名单精确等于 7 个状态信号」与「未登记类型默认拒」的锁定;变异验证:把 `graph_update` 改回全广播 → 「viewer 收不到 `graph_update`」必须 FAIL(已跑,见提交说明),把 `session_idle` 改走受限路径 → 「viewer 收得到 `session_idle`」必须 FAIL(已跑)。⑥ **已知残留(本次未动,留待主人裁决)**:`/ws/chat` 的**上行**仍未按角色授权(`viewer` 仍可发 `chat` / `interrupt` / `subagent_ask` / `task_output_query` / `voice_input`,其应答是**定向**帧、不经过本门),且握手时的 `history` 回放仍对任意角色照发(`thinking` 已剥离)——二者都在 design/11 §7.1 本次门的作用域之外。**(本残留已由 `v0.49` 收口:上行帧默认拒、握手回放仅 `owner`/`admin`,见上一条变更记录与 §3 顶部第二把角色门。)**
|
||||
- `v0.47`(**`graph_update` 子代理泳道根节点新增可选 `props.persona`**,对客户端**纯增量**;实现依据 design/22 §8-2「跨角色派发 = 把其他角色以子代理形式派出去」):① **`subagent_spawn` 指定目标角色派发时**,该子代理在父会话图里的**分支根节点** `props` 多一个 `persona` 键(值 = 目标角色 id),供流程图显示"这个子代理是以谁的身份在替班",而不只是子代理 id。② **不指定角色派发时该键不出现**:帧形状与 v0.46 逐字一致,老客户端忽略未知键即可。③ **无新端点、无字段改名、无鉴权变更**;`subagent_event` 帧**未新增字段**(其 `role` 会等于目标角色——既有字段的取值语义变化,非形状变更)。④ **`subagent_spawn` 的 `persona` 参数不在本文档契约内**:那是给**模型**用的工具 schema(工具层以「进程能否按该角色运行」为判据,未知角色整批拒绝且不产生任何子代理),不是给客户端的 HTTP/WS 契约。⑤ **验证**:进程内 e2e 断言「子代理实际收到的 system prompt = 目标角色 instructions」+「其记忆写入落在**目标角色**域」+「未知角色整批拒绝」+「**省略 `persona` 时结果 JSON 键集与旧版完全相同**」;变异验证(把目标角色换回父/默认角色、关掉角色校验)对应断言均 FAIL。
|
||||
- `v0.46`(**会话角色不可变:`PATCH /sessions/{id}` 拒绝 `personaId`**,对客户端**是破坏性变更**——按 v0.42–v0.45 旧文实现、依赖「PATCH 换角色」的客户端必须改为**新建会话**选角;design/22 §8-1 主人拍板定稿):① **`PATCH /api/v1/sessions/{id}` 的 `personaId` 改为「**出现即 `400`**」**:只要请求体里**有 `personaId` 键**(任何取值:`"yuki"` / `""` / `null` / 未知 id / 空白串)就回 `400 bad_request`,**错误消息点名 `personaId`** 并说明「会话角色在创建时确定、之后不可变,要换角色请新开会话」。**实现取「明确拒绝」而非静默忽略或 200 空操作**:静默忽略会让客户端以为绑定已改。② **顺序保证**:该检查在解析完 body 后**立即**执行、**先于**会话存在性检查 —— **未知会话 + `personaId` 回 `400`(不是 `404`)**;`title` + `personaId` 同传也整体 `400`,**其余字段一个都不落库**。③ **不变的部分**:`title` / `pinned` 的语义、边界(≤80 rune 等)与响应形状**逐字不变**;`POST /api/v1/sessions {personaId}`(**创建时**绑定、未知角色 `400`、非空回显)**完全不变**;`GET /sessions` 与消息 DTO 的只读 `personaId` 不变。④ **移除的路径**:PATCH 上再也没有 `{personaId}` 成功响应、也没有「不支持角色绑定的 HistoryStore → `501`」这条路(网关的 `Server.setSessionPersona` 及其中间层随之删除;`history.Store.SetSessionPersona` 作为存储能力保留)。⑤ **前端同步**:用户端**移除**「会话行 → 切换角色」菜单与面板「挂角色」下拉,**保留**「新建会话时选角色」;管理面板「角色」列改为**只读**展示创建时绑定的角色。⑥ **测试与变异验证**:新增断言覆盖「键出现即 400(六种取值)+ 绑定未变 + 未知会话仍 400 + `title`/`pinned` 不变」,并把「静默忽略」与「改回写绑定」两种反向实现各跑一次(两处断言均必须 FAIL,见提交说明)。
|
||||
- `v0.45`(**渠道 `personaId` 三态修订:修「省略 = 解绑」会静默清空绑定**,对客户端**无字段/端点形状变更**,但**语义收紧**——按 v0.44 旧文实现的客户端若靠「省略 `personaId`」来解绑,必须改为**显式传 `""`**;design/22 §2 方案 b):① **`PUT /api/v1/admin/channels/{platform}/config` 的 `personaId` 改为三态**:**键省略(或 JSON `null`)= 保留该账号已存绑定**(不动);**显式 `""` = 解绑**(写盘时该账号不带 `personaId` 键);**非空 id = 绑定该角色**;**未知角色 id 仍 `400 bad_request`**(且**只校验本次真的传了值**的情形——省略时继承的已存绑定在当初写入时已校验)。② **修的问题(数据安全)**:管理面板只在主人**动过**角色下拉时才发这个键(前端 `channelPersona.ts` / `channelPersonaRequest`),而 v0.44 后端把「键不存在」也当成解绑 ——**主人打开一个已绑角色的账号、不动下拉、只改 token 并保存 → 该账号的角色绑定被静默清空**(无提示、无审计可见的理由),两端语义相反。现在后端的「省略」与前端一致 = 保留。③ **实现**:请求 DTO 的该字段由 `string` 变 `*string`(指针区分「省略」与「显式空串」),应用逻辑在既有「读当前配置(channels.json)→ 写入该平台账号 → 落盘 → `bridge.Reload(platform)`」路径内按三态取值(**写入路径自 v0.54 起改为按 id 打补丁 `PatchChannel`**)(省略时从**同平台同 id** 的已存账号继承,故保存一个账号不会动到同平台其它账号的绑定);**无新端点、无新字段、无响应形状变更**。④ **`GET /api/v1/admin/channels` 每账号的 `personaId` 回显不变**(v0.44 新增,非敏感、不掩码),面板据此回填角色下拉。⑤ **测试**:省略 → 绑定保留、显式 `""` → 清除、非空 id → 绑定、未知 id → `400`(含「省略时改其它字段后读回确认」与「同平台多账号互不影响」),并把「省略 = 解绑」作为变异反向验证(该断言必须失败)。
|
||||
- `v0.44`(**渠道级选角 + 「谁说的」进模型上下文**,对客户端**新增两个可选字段**,向后兼容;design/22 批次 2 后端 = §2 裁决规则 + §4.4 + §7-3/§7-4/§7-5):① **`PUT /api/v1/admin/channels/{platform}/config` body 新增可选 `personaId`**(渠道级绑定,design/22 §2 方案 b):=该账号答话时的角色 id;**未知角色 `400 bad_request`**,~~**省略/`""` = 解绑**(写盘时该账号不带 `personaId` 键)~~(**本条的「省略」一项已由 v0.45 修订为「保留已存绑定」;只有显式 `""` 才解绑**,见上);沿用既有「读当前配置 → 写入该平台账号 → 落盘 `channels.json`(0600) → `bridge.Reload(platform)` 热更新」路径(**写入路径自 v0.54 起改为按 id 打补丁 `PatchChannel`**),保存即生效(下一批入站消息),**无新端点**。② **`GET /api/v1/admin/channels` 每账号新增可选 `personaId`**(非敏感,不掩码),供面板回填。③ **角色解析优先级明确化(对客户端语义澄清,无形状变更)**:**会话绑定 > 渠道绑定 > 全局默认角色**;渠道绑定**只**作用于该账号的**主人私聊**且仅当主会话自身未绑角色(§7-3:IM 主人私聊镜像随主会话角色);**群聊/陌生人隔离会话强制默认角色**(§7-4,更保守,仍只读)。④ **模型上下文新增角色归属标注(§7-5 的剩下一半)**:一回合上下文改为该会话**最近 20 条**持久化消息 + 本回合新消息(此前只带新消息本身),每条 assistant 历史消息按其说话角色出具 `[发言归属] 以下发言来自「<名字>」(<id>),不是你说的。` 独立 system 标注 + 一条 `[会话角色] …` 当前角色声明;**消息正文一字不改**(标注只走 system 消息,design/11 §10),**对客户端透明**(无新字段)。前端无需改动即可继续工作;面板可选用 ① 让每个 IM 账号挂角色。⑤ **向后兼容**:纯新增可选字段;未配置 `personaId` 的账号与不读新字段的客户端行为不变(渠道绑定缺省 = 全局默认角色,与 v0.43 完全一致)。
|
||||
- `v0.43`(**人格读侧合并批:admin 回读 `thinking` + 用户侧角色级 `mood`**,对客户端**新增两个可选字段**,向后兼容;design/01 §2/§2.1、design/04 §3.2):① **`GET /api/v1/admin/personas` 每项新增可选 `thinking`**(`{budgetTokens,effort,showToUser,mode}`),与写侧同构、供面板回填编辑。**未配置的人格不带 `thinking` 键**(不是全零对象),与 `PUT` 的「省略 `thinking` = 保留已存」互为镜像。② **`showToUser` 三态原样回读**:**`null` = 未配置 → 保持现行为(thinking 帧照常推客户端)**,`true`/`false` = 显式开关;**服务端不把 `null` 拍平成 `false`**(否则面板会把「没配过」误显示为「关」,v0.41 的语义会被读侧丢掉)。③ **`GET /api/v1/personas` 每项新增 `mood`(角色级)**:= 该角色**当前情绪状态**(取值同 `GET /meta/enums` 的 `moods`;`""` = 暂无被跟踪情绪,不伪造)。**情绪跟随角色、与会话无关**——同一角色的所有会话看到同一 `mood`;客户端登录/加载角色目录时用它**播种心情徽标**(按角色 id 分桶),消除「刷新后徽标短暂为空」,随后仍以 WS `mood` 帧为准。**用户侧不暴露 `thinking`**(模型调用旋钮属管理侧)。④ **`effort` 的能力提示归服务端**:读端点只回读**已存配置**,**不承诺**该值在当前 provider 上生效——白名单校验/降级/WARN 仍在模型层(design/04 §3.2),客户端不得把「配了」呈现为「已启用」。⑤ **向后兼容**:纯新增字段;不读新字段的客户端行为逐字节不变(旧面板仍可编辑并保留已存 `thinking`)。
|
||||
- `v0.42`(**多角色并存 + 按会话选角**,对客户端**新增可选字段/可选请求字段**,向后兼容;design/22 批次 1 = 后端会话级绑定):① **`chat_sessions` 新增 `persona_id TEXT NOT NULL DEFAULT ''`、`chat_messages` 新增 `persona_id TEXT NOT NULL DEFAULT ''`**(同一迁移 **0011**,down 可逆,**不回填**)。`''` **= 继承全局默认角色**(`owner.yaml:defaultPersona`),所以**既有一切行升级后行为逐字节不变**,且默认角色改名后这些行跟着变(design/22 §4.7/§7-6)。② **`GET /api/v1/sessions` 每项新增可选 `personaId`**(=会话绑定角色);**消息 DTO(REST `GET /sessions/{id}/messages`、WS `history` 回放、`message_persisted`/`message_edited` 广播)新增可选 `personaId`**(=**说话时的角色**,design/22 §7-5)。③ **`POST /api/v1/sessions` body 新增可选 `{personaId}`**(创建即绑定;未知角色 `400`;响应仅在非空时回显 `personaId`,故**省略时响应形状与 v0.41 完全一致**);~~**`PATCH /api/v1/sessions/{id}` body 新增可选 `{personaId}`**(重新绑定,**显式 `""` 解绑**,未知角色 `400`;只回显变更字段)~~(**本条的「PATCH 重新绑定/解绑」已由 v0.46 作废**:会话角色在创建时确定、之后不可变,PATCH 一旦收到 `personaId` 即回 `400`,见 §1 行 8 / §8-1)。④ **回合语义**:角色决定**系统提示词 / 记忆命名空间 / 情绪与好感度 / 思考预算**四者**同源**解析(design/22 §4.2);~~换角色**保留上下文**、**不动已存消息**,**下一回合**起生效~~(**已由 v0.46 作废**:会话内不再换角色——角色在创建时定死,`chat_messages.persona_id` 仍逐条记录"谁说的");**记忆口径 = 保持 persona 隔离**(design/22 §3):不同角色的会话互不浮现,同角色的多个会话互相可见。⑤ **请求级覆盖(每次发送临时指定角色)本批未实现**(design/22 §2 裁决规则 1 仍待批次 2/后续);**渠道级绑定(IM 账号/群默认角色)本批未实现**(design/22 §4.4 批次 2),故 IM 路径仍按默认角色解析。⑥ **契约内新增内部读接口**:网关经窄接口类型断言读取 `history.Store.SessionPersona/SetSessionPersona/NewSubSessionFor`(**v0.46 起**请求路径不再调用 `SetSessionPersona`——PATCH 的 persona 写路径已整体移除;「注入的 HistoryStore **不支持**角色绑定」只在 `POST /sessions` **显式传** `personaId` 时回 `501`,而非假成功)。
|
||||
- `v0.41`(**人格 `thinking` 配置 + 推理旋钮按 provider 映射**,对客户端**新增一个可选请求字段**,向后兼容):① `POST /admin/personas` / `PUT /admin/personas/{id}` body 新增可选 **`thinking`** 对象 `{budgetTokens?,effort?,showToUser?,mode?}`(design/01 §2.1),原样写入并持久化到 `configs/personas/<id>.yaml` 的 `thinking` 节;非法 `mode`/负预算回 **`400 bad_request`**;**`PUT` 省略该对象时保留已存 `thinking`**(旧面板/脚本不会抹掉它);该字段只描述**呈现/资源**,不构成能力边界。② **`showToUser` 语义**:**缺省 = 照常把 `thinking.*` 流帧推给客户端**(现行为),显式 `false` = 仅落库/剥离语义不变、**不推帧**(展示与落库解耦)。③ **服务端推理映射**(对客户端透明,仅影响上游请求):Anthropic 系在 persona 配置 `budgetTokens`/`mode: on` 时发 `thinking:{type:"enabled",budget_tokens:N}`,并**保证 `budget_tokens + 正文留量 ≤ max_tokens`**(不足则抬高 `max_tokens` 并记 WARN,绝不把正文吃空);OpenAI 兼容系按**识别到的厂商白名单**校验 `reasoning_effort`(OpenAI 系 `none…max`、DeepSeek 仅 `low/high/max`),非法值~~**降级到最近一档并记 WARN**~~(**已由 v0.53 修订为「丢弃并记 WARN、不降档」**——降档会反转调用方意图,`none` = 关思考,绝不翻成任何强度档);~~**未知 provider/未识别模型、以及 Qwen 的 `enable_thinking` 硬开关(协议无统一 wire 形状)** 一律记 **WARN(含 provider/字段/取值)并保持请求不变**,绝不静默无效~~(**本条已由 v0.53 作废**:开关形状改由组键 `reasoning.kind` 显式声明并**真下发**,**只有形状未知/有歧义时才 WARN 且一个字段都不发**,见 v0.53 变更记录)。详见 design/04 §3.2。
|
||||
- `v0.40`(**`viewer` 角色真正接线:只读监控档**,对客户端**收紧/放宽若干 admin 读的鉴权**,无端点/字段形状变更):此前 `viewer` 只是常量(`internal/auth` 的 `RoleViewer` 注释写着「只读 dashboard/logs/monitoring」)而**从未接线**——管理侧 24 条 GET 全走 owner/admin,`viewer` 一条都读不到(**全 403**)。本版新增第四道门 `viewerAuth = owner+admin+viewer`,并把 **12 条只读监控 GET** 从 owner/admin 放宽到 owner/admin/**viewer**:`GET /admin/personas`、`/admin/sessions`、`/admin/plugins`、`/admin/plugins/installed`、`/admin/channels`、`/admin/iot`、`/admin/automations`、`/admin/rag`、`/admin/observability/metrics`、`/admin/observability/trace`、`/admin/observability/logs`、`/admin/notifications`。**明确不放给 viewer**(含凭据获取路径、他人数据、完整消息/导出明文、审计流水,或 `admin` 也不给):`GET /admin/memories`、`/admin/llm`、`/admin/users`、`/admin/backthink`、`/admin/observability/audit`、`/admin/channels/messages`、`/admin/channels/{platform}/messages`、`/admin/sessions/{id}/export`、`POST /admin/rag/query`(这 9 条 owner/admin)以及其余 **35 条 owner-only**(全部写操作 + `login-qr`/`login-status`/`view`/`friends`/渠道连接与配置/`plugins/*`/`users/*`/`system/*`)。**匿名**:任意 admin 路由仍 `401`(fail-closed)。**向后兼容**:`owner`/`admin` 语义不变(放行集合是超集),仅 `viewer` 的可见面从「全 403」变为 12 条只读监控;不依赖 `viewer` 的客户端行为不变。完整分档见 §2.0「管理侧权限矩阵」。
|
||||
- `v0.39`(**微信 iLink 会话被拒可见化 + 不再假 OK**,对客户端**新增两个可选字段、一处错误码**,向后兼容):① **`GET /api/v1/admin/channels` 每平台行新增可选 `needsRescan`(bool,`omitempty`)+ `lastError`(string,`omitempty`)**——仅当适配器**真的**要求重新授权时出现(今天只有微信 iLink 返回 `errcode=-14` 这一种),取值来自适配器新增的**可选能力接口** `RescanRequired() (bool, string)`,网关**经类型断言**读取(主 `PlatformAdapter` 契约**不变**;未实现该能力的平台字段**缺省不出现**)。用途:把「需要重新扫码」与「主人自己关掉的未连接」区分开——后者仅 `connected:false`。② **`POST /admin/channels/open`** 对「iLink 已拒绝该会话」回 **`409 session_rejected`**(message 明确要求用「扫码登录」重新绑定),**不再**吞掉错误回 200;`409 needs_login`(尚无 token,v0.8 起)不变。③ **适配器行为**:iLink `errcode=-14` 进入**显式被拒态**——`IsConnected()` 恒 `false`;`Connect()` 返回被拒错误(**不再**对被拒会话报成功);`HealthCheck` 区分「会话被拒」与「未连接」;并以 **`ERROR`** 级日志写明「本次会话已被 iLink 拒绝,需要重新扫码绑定」(只带 `bot_id` 等非敏感标识,**token 永不入日志**)。被拒态在读循环与发送两条路径**都会**置位,并仅在**扫码登录拿到新 token** 或**配置换了新 token**(保存 + 热重载)时清除。④ 扫码登录侧:被拒期间 `login-status` 报 **`failed` + 被拒原因**(**不再**假报 `confirmed`,否则面板会把已作废凭据当成功重新落盘);`login-qr` 仍可正常出码(**不**回 `already_logged_in`,否则重新扫码这条路被堵死);`get_bot_qrcode` 不再携带已被拒的 token(否则服务端以「已绑定」应答、**不**发新凭据)。**向后兼容**:只新增可选字段与一处更诚实的错误码;不读新字段、不处理 409 的客户端行为不变(此时面板显示「未连接」,仍可用「扫码登录」恢复)。**语义澄清(本次仅措辞,不改契约形状、不另起版本号——v0.39 尚未被任何客户端采用)**:iLink 的 bot token 是**长期凭据**(实测一个月前扫码得到的 token 仍可用),`errcode=-14` 表示 **iLink 侧拒绝/作废了该会话凭据**,**不是时间性过期**。详见 §2 行 6 与「微信 iLink『会话被拒』契约」。
|
||||
- `v0.38`(**B4 客户端共享枚举权威表 + 未知值回落约定**,对客户端**新增一个只读 REST 端点**):新增 **`GET /api/v1/meta/enums`**(**任意已认证角色可读**,与 `GET /sessions` 同级;无 token `401`)→ `200 {data:{platforms,moods,modes,unknown}}`,每项 `{id,label}`:① **`platforms`**(6)= `web`/`kook`/`qq-obv11`/`weixin-ilink`/`telegram`/`webhook`——即各内置适配器 `Name()` + Web 主会话来源 `web`;② **`moods`**(5)= `calm`/`happy`/`excited`/`sad`/`angry`,与 persona 情绪状态机内置状态一致(该状态机**可扩展**,故非封闭集合);③ **`modes`**(9)= `chat`/`code`/`home`/`work`/`docs`/`voice`/`vision`/`agents`/`settings`(design/07 §1);④ **`unknown`** 恒 `"display-raw"` = **未知值回落约定机器可读**:客户端遇不在表中的取值**原样显示原始字符串**(**不丢弃、不报错、不占位符**),**服务端新增取值视为非破坏性变更**(forward-compatible)。**单一事实来源在 Go 侧**(`internal/contract/enums`),并有**反漂移测试**断言适配器 `Name()` / 情绪内置状态 / 前端九模式与三张表一致,漏登记即 CI FAIL。Web 侧镜像 `frontend/web/src/lib/enums.ts`(`labelFor` 未知值返回原值),顶栏/侧栏/消息气泡的「心情」标签已改用它渲染。**向后兼容**:仅新增端点,既有契约形状不变。详见 §1 行 15 与 §1.1。
|
||||
- `v0.37`(**D4 未读状态 / 已读游标**,对客户端**新增一个语义字段、一个 REST 端点、一个下行事件**):① `chat_sessions` 新增 **`read_at INTEGER NOT NULL DEFAULT 0`**(迁移 **0010**,down 可逆;**毫秒 Unix 时间戳**,与 `chat_messages.created_at` **同单位**)。② **语义**:**会话级单一已读游标 + 单 owner 账号**——同一账号所有端共用一个游标,**读一处 = 全端已读**(不做 per-device/per-user 游标,与「同一账号所有浏览器都属于同一 owner」一致);`createdAt <= readAt` 的消息算已读,未读数 = `createdAt > readAt` 的条数。③ **`GET /api/v1/sessions` 每项新增恒返回的 `unreadCount`**(整数,0 = 无未读):与 `lastMessage` **同一条 SQL 派生**(关联子查询 `COUNT(*) WHERE session_id = s.id AND created_at > s.read_at`,走已有 `idx_chat_msgs_session_created`,**无 N+1、不新增索引**)。④ **新增 `POST /api/v1/sessions/{id}/read`**(owner/admin,与 `PATCH /sessions/{id}` 同鉴权):body `{before?}`(可选毫秒;缺省= 现在)→ `200 {data:{sessionId,readAt}}`;`before` 为 0/负数/非数字/body 非对象 → `400`,未知会话(含隔离 `sess-xxx`)→ `404`,未注入 HistoryStore → `501`。**游标只前进不后退**(`read_at = MAX(read_at, before)`),乱序/并发/重试请求幂等安全。⑤ **新增下行事件 `session_read`**:`{type:"session_read", sessionId, data:{readAt}}`,全连接广播 + 客户端按 `sessionId` 过滤,用于同账号其它端清红点;失败路径不广播(§3.9)。⑥ **升级回填**:0010 把既有会话游标回填为「该会话现有最新消息时间」,**升级当天不误报未读**(既有历史全部视为已读)。⑦ `HistoryStore`(网关消费侧契约)新增 `MarkSessionRead(ctx, sessionID, before)`;`SessionMeta` 新增 `UnreadCount`。**向后兼容**:仅新增字段/端点/事件,既有契约形状不变;不消费 `unreadCount`/`session_read` 的客户端行为不变。详见 §1 行 4/8a 与 §3.9。
|
||||
- `v0.36`(**A1 流式帧附加会话 / 回合标识**,对客户端**新增两个可选字段、无端点变更**):结构化流帧 `message.start/delta/end`、`thinking.start/delta/end`、`tool_call.start/delta/end`、`tool_execution.update` 现在带 `sessionId` + `turnId`(**向后兼容**:只新增帧字段,`data` 结构与既有事件形状不变)。`sessionId` 由 loop 在**回合边界**从运行上下文注入(模型适配器在 loop 之下、天然不感知会话,保持零值;loop 外无会话上下文时该字段为空);`turnId` 由网关**每回合**新生成(`newTurnID`)并经 `loop.WithTurnID` 挂进运行上下文,`runTurn` 是编排层与 legacy 直连路径**唯一**的回合收口点,两路都覆盖——同一会话同时只有一个 in-flight 回合,故同轮内所有帧的 `turnId` 一致。此前这几帧属于 design/16 §4.2 标注的「若知道就带上」缺口(v0.10 起记录):**多会话并发流式时前端会把别的会话的 delta 接进当前会话气泡**,现在客户端按 `sessionId === activeSessionId` 过滤即可路由到激活会话。`stream_chunk` / `stream_end` 的 `sessionId`/`turnId` 行为不变。详见 §3.1。
|
||||
- `v0.35`(**可观测性收尾:后端日志 tail 端点**,对客户端**新增一个 admin 只读端点**):`GET /api/v1/admin/observability/logs?level=&limit=`(owner/admin)→ `200 {data:[{time,level,source,msg}]}`,**最新在前**。数据源为 logger 新增的**有界环形缓冲**(`internal/logger`,容量 2000,满则丢最旧、并发安全、不阻塞、不改动既有控制台/文件输出),组合根在构建 logger 时挂上、在网关就绪后经 `SetLogBuffer` 注入;缓冲未注入回 `501 not_implemented`(不伪造空 tail)。`time` 为 **RFC3339 UTC(秒精度)**,与 `trace.startedAt`/`audit.when` 一致;`level ∈ debug|info|warn|error`(大小写不敏感)为**最低等级过滤**,空 = 全部,未知值 `400 invalid_level`;`limit` 缺省 `200`、上限 `1000`。`source` 取 `logger.WithSource` 的 `source=`(`Scoped` 记录回落 `module/sub`),无来源为空串。详见 §2「可观测性契约」。
|
||||
- `v0.34`(**D3 收尾:消息编辑 / 删除的 WS 实时广播**,对客户端**新增两个下行事件、无 REST 契约变更**):`PATCH /api/v1/messages/{id}` 成功后广播 **`message_edited`**(`{type, sessionId, data:<更新后的完整消息DTO>}`,`thinking` 已剥离)、`DELETE /api/v1/messages/{id}` 成功后广播 **`message_deleted`**(`{type, sessionId, data:{id}}`);两者复用 `message_persisted` 的同一 hub 广播路径(**全连接广播 + 客户端按 `sessionId` 过滤**,hub 无按会话订阅),客户端**按 `id` 原位改 `content` / 按 `id` 移除**,不再需要「改/删后重拉」的绕行。`message_deleted` 的 `sessionId` 由后端在删除前读取消息所属会话填入(`HistoryStore` 新增 `GetMessage`;读不到时客户端按全局唯一的 `id` 兜底)。`404`/`400`/`501` 等失败路径**不广播**。Web 端已消费(`lib/ws.ts` + `store/useAppStore` 新增 `updateMessageById`/`removeMessageById`)。详见 §3.8。
|
||||
- `v0.33`(**A2 记忆异步提取 / A4 可观测性 Tracing 接线 / A6 通知页后端**):① **A6 新增 `GET /api/v1/admin/notifications`**(owner/admin 读,**全新端点**):把**现有源**(审计 `audit_logs` + backthink 主动推送记录)聚合为**统一只读通知 feed**,`type`/`before`/`limit` 过滤与游标见 §2「通知聚合契约」;**不新建表**,未注入的源该类型为空、不伪造;未读游标后置。② **A4 trace 端点由恒空变真实数据**(**无契约变更**,`GET /admin/observability/trace` 与 `metrics` 的 `llmCalls`/`subagentEvents` 从此反映 loop/工具/编排的真实 span):此前 `TraceSink` 从未接线、环形缓冲无人喂;现在组合根把 loop 产生的 span 扇出到网关观测缓冲。响应字段/形状不变。③ **A2 LLM 异步记忆提取接线**(**对客户端无变更**):loop 回合结束后改由 LLM 提取器**后台异步**抽取记忆(不阻塞回复、失败仅记日志),**群聊/陌生人仍只读不写**;无可用模型凭据时回落确定性 `ManualExtractor`(`记住 X` 路径不变)。
|
||||
- `v0.32`(**A1 情绪系统接线:情绪/好感度真跑起来**):后端把既有的 `persona/emotion` 状态机接进 loop 回合边界——每轮结束按该轮用户消息的情绪信号移动**该角色自己**的情绪状态机(沿配置转移表走一步)并调整好感度(按角色 `affection.delta`);无信号轮次衰减一步趋向 `calm`。① **消息 `mood` 不再恒空**:assistant 消息落库带该轮情绪,`message_persisted` / `history` 回放 / `GET /sessions/{id}/messages` 的 `mood` 为真实值(user 消息不带)。② **新增下行 `mood` 事件**(`{data:{mood,personaId,affection}}`,**仅情绪变化时**广播,见 §3.7),前端顶栏「心情 · x」与消息气泡接真数据。③ **隔离**:群聊/陌生人(`MemWritable=false`)情绪只读不写——不改情绪/好感度、不发事件、该轮 `mood` 空。④ **持久性**:情绪/好感度为进程内按角色状态,重启回 `initialState`(未落库;design/01 未要求)。**触发词表与「每轮一步」为 design/01 未规定处的最小实现,待主人确认**。**契约新增(向后兼容):`mood` 事件 + 消息 `mood` 变真实值。**
|
||||
- `v0.31`(**Android 协同批次 ③b:B5 剩余 501 + D1 推送出口钩子**):① **B5-知识库删除由 501 改为真实实现**:`POST /api/v1/admin/rag/delete` body `{"id"}` → `200 {data:{id,deleted:true}}`——从项目索引删除该文档条目并**级联删除全部章节**(单事务);不存在回 `404 not_found`,`id` 空回 `400`,未注入 store 回 `501`;路由由 admin 收紧为 **owner-only**(与其它 admin 写一致)+ 审计 `rag.delete`。② **B5-新增用户 / IoT 场景 / IoT 规则仍诚实 501**:无用户表(登录仅 `.env` 单 admin)、IoT 无场景/规则模型与存储(`iot.Provider` 只有设备列表/控制/状态),错误消息明确缺什么,**不伪造 store/假成功**。③ **D1 推送出口钩子**:主动消息在 WS/IM 投递之外异步镜像到外部推送出口,**自建统一推送服务优先 → 个推(Getui)兜底**(个推 SDK 未接入,仅预留 `GETUI_*` 字段,不可达时日志注明待接入);`.env` 新增 `PUSH_EXPORTER_ENABLED/URL/TOKEN/TIMEOUT_SEC`,token 只走 `Authorization` 头、不落日志;**对客户端无新端点**(详见 §3.6)。
|
||||
- `v0.30`(**Android 协同批次 ③a:D2 消息搜索 / D3 消息编辑删除 / D5 会话置顶**):① **D2**:`GET /api/v1/sessions/{id}/messages` 新增可选 **`q`**——**会话内**内容搜索(大小写不敏感 ASCII LIKE,`%`/`_`/`\` 转义为字面量),仍倒序 + `before`/`limit` 游标;`q` 与 `after` 互斥(同传 `400`),空 `q` 走普通分页;**严格限定单会话,不新增跨会话搜索**(避免群聊/陌生人隔离会话外泄,红线)。`HistoryStore` 新增 `Search`。② **D3**:新增 `PATCH /api/v1/messages/{id}`(body `{content}`,非空 ≤4000 rune → `{data:{message}}`,只改 content)与 `DELETE /api/v1/messages/{id}`(→ `{data:{deleted:id}}`);不存在回 `404 not_found`;`HistoryStore` 新增 `UpdateMessage`/`DeleteMessage` + `ErrMessageNotFound`。**不新增重发端点**(客户端用编辑后的 content 再发)。③ **D5**:`chat_sessions` 新增 `pinned INTEGER NOT NULL DEFAULT 0`(迁移 **0009**,down 可逆);`GET /sessions` 排序改为 **`pinned DESC` → 主会话优先 → `createdAt ASC`**,会话 DTO 新增恒返回的 **`pinned`** bool;`PATCH /sessions/{id}` 扩展为 body `{title?,pinned?}`(至少一个字段,只回显变更字段,title-only 保持 A4 形状);`HistoryStore` 新增 `SetPinned`。全部为 owner/admin 写、读消息 owner/admin;未注入 HistoryStore 回 `501`。
|
||||
- `v0.29`(**/admin/llm 读-改-写合并**):`POST /api/v1/admin/llm` 由「全量替换」改为**部分编辑合并**——请求体字段用指针区分「未给」与「显式零值」;`maxTokens` / `apiKey` / `provider` / `baseUrl` / `model` / `fallback` / `multimodal` **省略即保留原值**(不再把省略的 `maxTokens` 重置为 0),新组仍要求 `baseUrl`+`model`、非法 provider / 负 maxTokens 仍 `400`。面板 LLM 页新增每组 `maxTokens` 展示/编辑 + 新增/编辑表单;面板插件页新增「已装插件列表」+「安装 MCP 插件」表单(kind/name/transport/endpoint|command+args/apiKey/env),api.ts 请求体带 `config`(修掉旧 body 400)。`runtimeconfig/models.go` 新增 `Find`。
|
||||
- `v0.28`(**MCP 插件热安装/卸载**):`POST /api/v1/admin/plugins/install`(**由 501 改为真实实现**,owner-only + 审计)body `{kind:"mcp",name,config:{transport,endpoint?|command?,args?,apiKey?,env?}}` → `200 {data:{installed,name,kind,transport,toolCount}}`——真实连接 MCP server、发现并**热注册工具**进 capability registry(下一轮模型请求即注入),配置落盘 `plugins.json`(gitignore、0600);连接失败回 `502` 且**不落盘**;同名重复 = 刷新。`POST /api/v1/admin/plugins/uninstall` 扩展:MCP 插件走 `Unregister`(移除工具 + close + 删配置),普通能力保持原 `registry.Remove`。新增 `GET /api/v1/admin/plugins/installed`(owner/admin 读)列出已装插件(`apiKey`/敏感 `env` 掩码 + `live`/`toolCount`)。`mcp.MCPConfig` 新增 `APIKey`(http/sse 以 `Authorization: Bearer` 发送)与 `Env`(stdio 子进程环境);`plugins.json` 于启动时 `Restore` 重新安装。**契约版本 `v0.28`**。
|
||||
- `v0.27`(**LLM token 预算可配置化**):`models.json` 模型组新增可选 **`maxTokens`**(`int`,缺省/`0` = 未设),作为该组主/备链的 token 预算;`GET /api/v1/admin/llm` 的 `AdminLlmProvider` 与 `POST /api/v1/admin/llm` body/回显同步该字段(`omitempty`,未设为 0 时不出现)。`.env` 新增 **`LLM_FALLBACK_MAX_TOKENS`**(`int`,默认 `8192`,`0` = 关闭兜底),只作用于**确有回退模型**时链末端的回退步。**优先级**:显式 `Request.MaxTokens`(审查/判定类小预算)> 生效组 `maxTokens` > `LLM_FALLBACK_MAX_TOKENS`(仅链末端、且组未设时)> 适配器默认(anthropic 8192)。单步链(无 fallback)不套用 env 兜底。`maxTokens` 为负回 `400 bad_request`。**契约版本 `v0.27`**。
|
||||
- `v0.26`(**A5 消息正向增量**):`GET /api/v1/sessions/{id}/messages` 新增 **`after`** 查询参数(`<createdAt 毫秒>`,升序返回 `created_at > after` 的消息,默认 `limit=50`/上限 200)——解决「WS 重连只回放主会话最近 50 条、断线超 50 条永久丢」的缺口;`before` 现行为不变;**`after` 与 `before` 互斥**(同传 `400`);负数/非数字 `400`。`HistoryStore` 新增 `ListAfter`(走 `idx_chat_msgs_session_created`,`rowid` 兜底同毫秒并列)。**游标契约**:客户端用「本页最后一条 `createdAt`」作下一页 `after`;同毫秒消息 > limit 时纯 `createdAt` 游标会跨页重读(超罕见,客户端按 `id` 去重兜底,与现有绕行一致)。
|
||||
- `v0.25`(**B2 refresh token**):`POST /api/v1/auth/refresh`(**新增**,`{refreshToken}` → 新 access + 新 refresh,默认单次使用旋转,并发重放 fail-closed `invalid_grant`)+ `POST /api/v1/auth/logout`(**新增**,撤单个或全设备)。`POST /api/v1/auth/login` 在 `{token,expiresAt,role}` 之外**新增 `refreshToken`**(不透明 256-bit 随机串,**仅 SHA-256 哈希落库**,`AUTH_REFRESH_TTL` 默认 `720h`,`AUTH_REFRESH_ROTATE_DISABLED` 关旋转)。新表 `refresh_tokens`(迁移 0008,id/user_id/role/token_hash UNIQUE/expires_at/revoked_at;含过期/轮换原子事务);`rotate-jwt` 成功即删除全部 refresh(返回 `refreshTokensRevoked`),`revoke-keys` 不动 refresh。**密码/secret 记录见 `internal/auth`;refresh 全文绝不落日志/审计。**
|
||||
- `v0.24`(**B1 图片上传端点**):`POST /api/v1/upload`(**新增**,multipart `file`,owner/admin+审计 → `201 {data:{url,size,mime,name}}`)+ `GET /api/v1/files/{name}`(**新增**,同端口静态服务)。安全:`MaxBytesReader`(5MB)、`DetectContentType` 嗅探(不信客户端 Content-Type)、白名单 raster image(拒 SVG/HTML)、`crypto/rand` 128-bit 随机名(丢弃原始文件名)、非可执行上传目录(0700)、`nosniff`;路径穿越全形态拒绝(含编码 `%2f`/`%2e%2e`)。错误码 `400/413/415`(`internal/errors` 增 `ErrPayloadTooLarge`/`ErrUnsupportedMediaType`)。**设计取舍**:GET 不鉴权(能力 URL,`chat.image`/`<img>` 无法带 Bearer),POST 仍 owner/admin。`url` 为绝对地址(`X-Forwarded-Proto`/TLS + `r.Host`,因 `ParseImageRef` 只认 `http(s)://`)。`Deps.DataDir` 由 wire 注入(`resolveDataDir`)。
|
||||
- `v0.23`(**C2 语音 silent 降级信号**):三处统一用 **`provider`** 只在「结果为空」时出现(非空/成功时才省略)——`POST /api/v1/audio/transcribe` 空文本响应体带 `{text:"", provider:"silent"}`;WS `voice_output` 空音频 payload 带 `provider`;`POST /api/v1/audio/speech` 空音频仍 `500 tts_empty` 并加 `X-Voice-Provider: silent` 响应头(CORS `Access-Control-Expose-Headers` 已放开)。`voice.Chain/Service` 新增 `STTWithProvider/TTSWithProvider`(返回真实档位 cloud/local/edge-tts/espeak/silent),网关用可选接口 `voiceProviderReporter` 透传,自定义实现回退 `silent`。**含 `X-Voice-Provider`(`apperr` 包装、`providerSignal`);`DASHSCOPE_API_KEY` 无/降级时信号为 `silent`。**(`go test -race` 在本机受内核 TSAN VMA 限制失败,非本代码问题。)
|
||||
- `v0.22`(**B3 会话列表 lastMessage 预览**):`GET /api/v1/sessions` 与 `/api/v1/admin/sessions` 每个会话新增可选 **`lastMessage`** `{content,role,createdAt}`(content 折叠空白截前 60 rune + `…`;空会话省略),**单条 SQL**(LEFT JOIN 取每会话 `ORDER BY created_at DESC,rowid DESC LIMIT 1`,无 N+1,走 `idx_chat_msgs_session_created` 覆盖索引)。`unreadCount` 需已读游标设计,后置。
|
||||
- `v0.21`(**A4 会话 title**):`PATCH /api/v1/sessions/{id}`(**新增**,body `{title}` ≤80 rune → `{data:{id,title}}`;空/超长 400、不存在 404、主会话可改名);会话**首条用户消息到达时自动命名**(`history.append` 前 `ensureTitle`:折叠空白取前 20 rune,纯空白回落 `主会话`/`次级会话`),仅首次命名、不覆盖。`HistoryStore` 新增 `RenameSession`;隔离会话(`sess-xxx`)不在 `chat_sessions` 有行、不自动命名(其本不在 `/sessions` 列表)。**边界**:前端 `createSubSession(title?)` 的 POST body 含 `title` 但后端暂忽略(会被首条消息自动命名覆盖),未处理。
|
||||
- `v0.20`(**A3 消息 mode 落库**):`POST /api/v1/chat` 与 `ws.chat` 上行新增**可选 `mode`**(行为模式,design/01 §3 / design/16 §5.1)。后端把每条持久化消息的 `Mode` **真实写入**(IM 路径恒 `chat`;Web 路径经 ctx 透传本轮 `mode`,缺省回落 `chat`;恢复/续接路径沿用发起轮次的 `mode`),消息 DTO、`message_persisted`、`channel_persisted` 的 `mode` 不再恒空,供前端按模式渲染气泡(**仅 `chat` 解析 `<p>` 拆气泡**,其它模式原样整条)。
|
||||
- `v0.19`(**百炼声音复刻 create_voice + TTS instruction 情绪驱动,主 TTS 用昔涟复刻音色**):`POST /api/v1/audio/voice-clone`(**新增**,owner-only + 审计,body `{url:"<公网参考音频URL>", prefix?}` → `{data:{voiceId}}`)——走后端 `voice.DashscopeCloner`(百炼 `voice-enrollment` create_voice,`POST {base}/services/audio/tts/customization`,`input{action:create_voice, target_model:VOICE_TTS_MODEL, prefix, url}`,**只收公网 URL 不收 base64**,返回 `output.voice_id`;**URL 不打日志不写审计**)。`POST /api/v1/audio/speech` 新增**可选 `instruction`**(per-request 情绪/语气指令,如 `"语气开朗俏皮,像女朋友撒娇"`),经 ctx 透传给 TTS adapter;**只对复刻/设计音色生效**(`cosyvoice-v3-flash` 复刻音色可任意指令;系统音色 `longxiaochun_v3` 需固定格式,服务端 `canInstruct` 判定后**永不发送**)。config 新增 `VOICE_TTS_INSTRUCTION`(默认 `""`)、`VOICE_CLONE_PREFIX`(默认 `cyrene`,≤10 字母/数字);`voice.Chain`/`voice.Service` 新增 `CloneVoice`,`VoiceCloner` 接口(cloud rung 提供;未配 cloud 回 `voice_unavailable`)。**⚠️ 真建昔涟音色仍待主人提供公网参考音频 URL(当前无)**,且 CosyVoice 声音复刻仅华北2北京地域(确认 key 地域);TTS instruction 用现有复刻音色或先仅单测请求体,避免改系统音色行为。
|
||||
- `v0.18`(**后端语音接通阿里百炼 STT + TTS 主链路**):`POST /api/v1/audio/transcribe`(**新增**,owner + 审计,body `{audio,format?,deviceId?}` → `{data:{text}}`,走 STT);`POST /api/v1/audio/speech`(**新增**,owner + 审计,body `{text,voice?,format?}` → 音频字节 `audio/<format>`,走 TTS)——**REST 语音源**,原生/移动端经它绕开 WS 4KB 帧限上传/获取音频;WS `voice_input` 上行新增 **`deviceId`**(随语音轮次落库,design/16 §4.5)。后端 `internal/runtime/multimodal/voice` 的 cloud rung 由空 endpoint stub 改为**真实百炼 adapter**:STT `qwen-audio-3.0-asr-flash`(base64 原始字节→文本)、TTS `cosyvoice-v3-flash`(按 `voice_id` 合成,取非流式返回的 24h 音频 URL 下载字节)。**实测**(`DASHSCOPE_API_KEY` 实跑):TTS「你好,我是昔涟」→ 106124 字节 wav;把该 wav 喂给 STT → 文本「你好,我是西联。」(TTS 发音把「昔涟」读得近似「西联」,STT 识别正确);语音链 (cloud→…→silent) 返回真实字节/文本。**已实测修正**:`cosyvoice-v3.5-plus`/`longxiaochun`(v2 音色)走 HTTP `SpeechSynthesizer` 会回 `418`(引擎拒绝),改用 **`cosyvoice-v3-flash` + `longxiaochun_v3`**(v3 音色,各模型音色不可混用)。**声音复刻(响昔涟音色)仍待**:需参考音频(公网 URL)在百炼建自定义音色后把 `voice_id` 填入 `VOICE_TTS_VOICE_ID`(本项目不做 create_voice)。config 新增 `DASHSCOPE_BASE_URL`/`VOICE_STT_\*`/`VOICE_TTS_\*`(`VOICE_TTS_SAMPLE_RATE` 默认 24000)。
|
||||
- `v0.17`(系统设置安全与存储:吊销全部 API key / 轮换 JWT 密钥 / 备份 / 恢复,design/11 §4 + design/10 §3 模块14):新增 `POST /api/v1/admin/system/{revoke-keys,rotate-jwt,backup,restore}`(均 **owner-only** + 审计)。`revoke-keys` 走 `auth.APIKey.RevokeAll()`(清空 key 表,返回 `{revoked:n}`);`rotate-jwt` 走 `auth.JWTService.RotateSecret`(≥32 位,**新密钥不回显/不落日志**,现有 token 立即失效);`backup` 用 SQLite `VACUUM INTO` 快照到 `<dataDir>/backup-<ts>.db`(含已提交 WAL 内容)返回 `{path,size}`;`restore` 先校验路径在 dataDir 内(**`filepath.Rel` 防路径穿越**,逃逸回 `400`)→ **先自动备份当前 DB** → 替换 DB 文件 → 返回 `{restored:true,backup,note:"需重启后生效"}`(运行进程持有 DB 句柄,热替换需重启可见)。gateway 新增 `Deps.DatabasePath`(composition root 传入实际打开的 SQLite 路径)。`编辑环境变量 / 重启服务 / 检查更新` 仍为后置,未实现、不伪造。
|
||||
- `v0.17`(KOOK 适配器面板 + 每适配器专属页,design/17 §2):`GET /api/v1/admin/channels/{platform}/view`、`GET /api/v1/admin/channels/{platform}/friends?type=`、`POST /api/v1/admin/channels/{platform}/friend/handle`、`POST /api/v1/admin/channels/{platform}/message/test`(**新增**,均 owner-only + 审计,带可选 `?accountId=`,仅 `kook` 实现,其它平台 `501`);`GET /api/v1/admin/channels` 每条账号附 `capabilities`(面板按平台渲染专属卡片)。KOOK 面板:好友/申请、服务器/频道/成员、私信会话、发消息测试——经适配器 REST(`Authorization: Bot <token>`)拉真实数据,token 不打印不落日志,子项失败记入 `errors` 不伪造。
|
||||
- `v0.16`(运行时配置体系:渠道适配器配置落盘 + 热更新,design/04 §3.1):`PUT /api/v1/admin/channels/{platform}/config`(**新增**,owner-only + 审计)body `{id?,token?,baseURL?,remoteURL?,listenAddr?,mode?,accessToken?,selfID?,config?,allowGroups?,denyGroups?,allowUsers?,denyUsers?,groupMsgPerMin?,msgPerMin?,maxMessages?}` → `201 {persisted:true,path,platform,accountId}`——读当前有效配置 → 写入该平台账号(**自 v0.54 起改为按 id 打补丁 `PatchChannel`**)→ 落盘 `channels.json`(本地、gitignore、0600)→ `bridge.Reload(platform)` **热替换运行中 adapter**(进行中消息用旧、新消息用新),无需重启;token 写入文件但**不回显/不落日志**。`DELETE /api/v1/admin/channels/{platform}/config?id=`(**新增**)删除账号并热更新。`GET /api/v1/admin/channels` 每条渠道附 `accounts`(每平台已配账号,token 脱敏掩码)。`.env`(`CHANNELS_JSON`/`CHANNEL_*_TOKEN`)退为兜底。
|
||||
- `v0.15`(运行时配置体系:LLM 多厂商多模型独立组落盘 + 热更新,design/04 §3.1):`GET /api/v1/admin/llm` 返回 **models.json 的全部组**(`provider` 协议字段新增,index 0 = 生效组;APIKey 仍脱敏不回显);`POST /api/v1/admin/llm` 由「临时记录」改为**真实持久化 + 热更新**(body `{id?,provider,baseUrl,apiKey?,model,multimodal?,fallback?}` → `201 {provider,persisted:true,path}`,读-改-写 `models.json`、保存组提升为生效组并热重载运行中的 LLM stack,无需重启)。`.env` 退为兜底(无 `models.json` 时按 `LLM_PROVIDER`/`LLM_*`/`ANTHROPIC_*` 合成单组)。
|
||||
- `v0.14`(IM 多会话隔离 + 第三方身份 + 黑白名单,design/16 §4.6):**`chat_messages` 增 `sender_name` 列**(迁移 0006,默认 `''`);消息 DTO(REST 消息、WS `history` 回放、WS `message_persisted`)均带 **`senderName`**(第三方发送者昵称,主人私聊为空 `omitempty`)。**第三方身份边界**:IM 群聊/陌生人会话在上下文构建时对第三方消息注入 `[第三方 · <昵称>]` 前缀(**不改源消息正文**,落库保留原文)+ 前置系统提示词"每个 IM 的非管理员账户 = 第三方人员,和主人无关"——身份判定基于 channel 层 actor 元数据 + 白名单(`MemWritable=false` 即群聊/陌生人),**绝不靠 LLM 猜**。**会话隔离**:群聊/陌生人消息**落独立隔离会话**(`sess-xxx`,带 `platform`+`senderName`,**不**并入主会话 `web`、**不**走 `message_persisted` 广播),供 **`GET /api/v1/admin/channels/{platform}/messages`**(owner/admin,management 面板渠道日志区)以消息流显示;**前端主会话**只显示各平台管理员(owner)私聊;群聊/陌生人记忆只读不写主会话(`memory_gating` 已按 `MemWritable` 门控)。**多账号**:`CHANNELS_JSON` 支持同一平台多个接入配置(每个 account 独立 session/账号,见 §2 渠道契约)。**黑白名单**:每平台每账号可配 `allowGroups/denyGroups/allowUsers/denyUsers`,channelbridge **入站即拦截**(黑名单/不在白名单 → 丢弃,`audit` 可查,**不进 LLM**);白名单按 `platform+account`(群 id / 用户 uid)。
|
||||
- `v0.13`(每条消息带 deviceID/client 标识,design/16 §4.5):**`chat_messages` 增 `device_id` 列**(迁移 0005,默认 `''`);`POST /api/v1/chat` 请求体新增**可选 `deviceId`**(客户端生成的端标识,如 `web:chrome:a1b2c3`);消息 DTO(REST 消息、WS `history` 回放、WS `message_persisted`)均带 **`deviceId`**。`platform`(平台)与 `deviceId`(具体端)互补:同一平台多个端 → 相同 `platform`、不同 `deviceId`。**Web 端**由 `lib/client.ts` 用 `localStorage` 持久化每个浏览器一个的 `web:<browser>:<随机短id>`,发送时自动带上,聊天气泡默认小字标注来源(仅当 `deviceId` 存在才显示);**IM 端**按 `<im平台名>:<本地配置唯一id>:<用户平台唯一uid>` 构造(当前 `UnifiedMessage` 尚无 deviceId 概念,channelbridge 以 `<platform>:<channelID>:<senderID>` 占位,`<本地配置唯一id>` 待适配器提供后替换);Web 端聊天不回流 IM。
|
||||
- `v0.12`(IM 渠道并入主会话 + 平台标记,design/16 §4.4):**`chat_messages` 增 `platform` 列**(迁移 0004,默认 `'web'`);`GET /api/v1/sessions/{id}/messages` 支持可选 **`?platform=`** 过滤(Web 端不传=全量共享主会话;IM 适配器传本平台只拉本渠道);消息 DTO(REST 与 WS `message_persisted`)均带 `platform`。**IM 渠道主人私聊**(`!IsGroup && Owner`)经 channelbridge 落**主会话 `web`**(`platform=<渠道>`)并广播 `message_persisted` → Web 端免刷新镜像;**群聊/陌生人**保持独立隔离上下文、不落主会话。Web 端聊天(`platform=web`)不回流 IM。
|
||||
- `v0.11`(跨端实时同步,design/16 §4.2):WS 下行新增 **`message_persisted`** 事件——每条 user/assistant 消息落库后广播 `{type, sessionId, data:{message:{id,sessionId,role,content,mode?,mood?,image?,createdAt}}}`(`id` 为后端生成,`thinking` 已剥离)。客户端按 `sessionId === activeSessionId` 过滤 + 按 `id` upsert(含本端乐观气泡 `uid`→后端 `id` 归一)去重,实现「A 发消息、A 与 B 都无需刷新且各出现一次」。后端 `history.Store` 新增 `AppendFull` 供编排层取含 `id` 的落库消息。
|
||||
- `v0.10`(会话消息持久化 — WS 契约,design/16,后端-2):WS 下行帧新增可选 `sessionId` 字段(能确定会话的事件带上);新增 **`history` 回放事件**——WS 握手成功即回放主会话最新 50 条(`{type:"history", sessionId:"web", data:{messages:[...]}}`,`thinking` 已剥离)。`stream_end`/`stream_chunk`/会话编排状态帧(`session_busy` 等)在能确定 session 时带 `sessionId`;`message.delta` 等结构化流帧当时因 `model.StreamEvent` 无 session 上下文**不带**(即"若知道就带上"边界)——**该缺口已在 v0.36 补齐**(结构化流帧现在带 `sessionId` + `turnId`,见 §3.1)。
|
||||
- `v0.9`(会话消息持久化 + 主/次级会话,design/16,后端-1):新增 `POST /api/v1/sessions`(新建次级会话)、`GET /api/v1/sessions/{id}/messages?limit=&before=`(游标热分页拉历史,时间倒序)、`DELETE /api/v1/sessions/{id}`(删次级会话及其消息);`GET /api/v1/sessions` 在注入 `HistoryStore` 后改返回持久化会话元数据数组(主 + 次级,`type` 区分),未注入时保持原渠道会话列表向后兼容。读消息/写会话为 owner/admin(用户侧私有历史,thinking 不达 viewer);会话元数据列表保持任意认证角色可读。
|
||||
- `v0.8`(管理面板写操作 CRUD):补齐管理面板**写端点**(见 §2「管理面板写操作契约」,均 owner-only + 审计)——`POST/PUT /admin/personas`(真实注册 + 持久化 `configs/personas/<id>.yaml`)、`POST /admin/users/apikey` + `DELETE /admin/users/apikey/{id}`(真实签发/撤销)、`POST /admin/iot/{id}/control`(真实设备控制)、`POST /admin/automations` + `/run` + `DELETE`(真实注册/触发/移除规则)、`POST /admin/llm`(持久化 `models.json` + 热更新,v0.15 起)+ `/admin/llm/test`(真实模型调用)、`POST /admin/channels/{open,close,test}`(真实连接/断开 + 结构性身份识别)、`POST /admin/plugins/uninstall`(真实 registry 移除)、`POST /admin/memories/search`(真实检索)、`GET /admin/sessions/{id}/export`。无存储的写(新增用户 / IoT 场景·规则 / 插件安装)如实返回 `501 not_implemented`。
|
||||
- `v0.7`(会话消息编排 design/15):`POST /api/v1/chat` 非流式且会话「正忙」时改回 `202 {data:{queued:true,queueDepth}}`(原同步 `{data:{reply}}` 仍为空闲时返回);pending 满回 `409 chat_busy`。WS 上行新增 `interrupt`(`{sessionId}`,中断当前任务+清 pending);WS 下行新增会话编排状态事件 `session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(见 §3.2)。既有 `stream_chunk`/`stream_end` 与结构化流事件不变,契约向后兼容。
|
||||
- `v0.6`(可观测性):新增 `GET /api/v1/admin/observability/{metrics,trace,audit}`(owner/admin)——metrics 返回网关实时请求计数/QPS/延迟/方法·路径·状态分布 + LLM/子代理 span 计数;trace 返回 gateway 环形缓冲里的最近 span(经 TraceSink 对接 runtime tracer,映射为 `AdminTraceSpan`);audit 返回持久化审计(SQLite `audit_logs`,未注入时退化为内存)。`llmCalls`/`subagentEvents` 由实际观测 span 累计;Token/成本后端不统计、不返回(不伪造指标)。
|
||||
- `v0.5`(批次B·视觉真推理 + 语音端):`POST /api/v1/chat` 增 `image` 字段(base64 data: URI 或 URL),入 loop 视觉路由(vision/OCR 工具或主模型多模态,design/06 §0「先看图再对话」);WS 上行 `voice_input` 接 STT→聊天→TTS 管道,下行新增 `voice_output`(定向回请求连接,含合成音频 + 识别文本 + 回复文本)。
|
||||
- `v0.4`(结构化流):WS 下行新增 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.update` 结构化流事件(event shell 补 `turnId`/`seq`,`data` 类型化),对齐 OpenClaw AssistantMessageEvent;保留 `stream_chunk`/`stream_end` 向后兼容。
|
||||
- `v0.3`(M10):管理面板 `/admin` 落地——按 §2 契约封装 admin 只读端点(personas/sessions/memories/plugins/channels/iot)+ owner-only `plugins/policy` 写;其余 M10 端点以契约路径封装、后端未实现时前端显式"待实现"壳。(**注**:面板**前端**入口自 v0.55 起为**路径式安全入口**,前缀由 `ADMIN_PATH` 决定、默认即 `/admin`;本节其余关于面板的叙述都不含前端路由前缀语义。)
|
||||
- `v0.2`(M8):gateway 落地——登录/health/chat/sessions/personas + admin 只读端点 + `plugins/policy` 写 + WS `/ws/chat`(JWT + bus 广播)。
|
||||
- `v0.1`(契约先行):骨架 + 端点登记表。
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,283 @@
|
||||
# Cyrene_v2
|
||||
# Cyrene v2 — 昔涟
|
||||
|
||||
喵
|
||||
> 自托管的「人格化通用智能体运行时」。一个 Go 单二进制,默认以「全能小女友」的人格陪伴你;在聊天/开发/家庭/工作等模式间无感切换;如 OpenClaw 般接入聊天平台(**KOOK / 微信 iLink 已接通**,QQ OneBot v11 链路已打通但暂不连,Telegram 待凭据);各人格独立记忆与情绪、共享同一套能力。
|
||||
|
||||
**状态**:M0–M12 里程碑主干已落地,现处于「后 M12 增量切片」阶段。每一项能力以本文的三档清单(已实现 / 需凭据·部分 / 未实现)为准,**不作整体完成宣称**;真实缺口见「已知缺口与路线图」。 | **分支**:`dev` | 设计文档见 `DevDocs/00-overview.md`
|
||||
|
||||
---
|
||||
|
||||
## 当前能力(按真实状态分三档,不虚标)
|
||||
|
||||
> 状态以**代码 + 真机实测**为准。标注「需凭据」的项依赖外部凭据 / endpoint 才能达到完整形态;「未实现」一律如实写出,不当「已修」。
|
||||
|
||||
### ✅ 已实现
|
||||
|
||||
- **单二进制、进程内协作** — **框架**的所有服务同进程打包、**框架**仅网关一个对外端口(默认 `:8080`);框架模块间走进程内接口 + 事件总线,**零跨进程 IPC**。🔴 **2026-10-07 口径补充:该红线只约束框架自身,不约束扩展插件** —— `plugins/` 下的渠道适配器与工具、MCP server、以及插件自己拉起的本地伴随服务**可以开自己的监听端口、可以有自己的进程形态**(原「`qq-obv11` 反连 server 模式」那条例外随之升格为一般规则的实例)。插件端口仍须:**由配置显式给出**、**默认绑 `127.0.0.1`**、**登记日志与审计**、**不得绕过身份判定**
|
||||
- **人格化陪伴** — 多人格(`configs/personas/*.yaml`)/ 情绪状态机 / 好感度;**人格 = 身份**(不限制能力)、per-persona 记忆隔离、能力全局共享
|
||||
- **通用 Agent 运行时** — 多供应商 LLM(per-role 路由 + 降级:主 → 备 → 本地,本地级默认关)、工具调用(**8 轮上限**)、流式输出、子代理编排(生命周期 / 结果合并 / 并行上限 / 超时 / 防失控)、会话持久化 + 主·次级会话、上下文压缩(预算按模型窗口推导)
|
||||
- **记忆系统** — **三类作用域隔离**(用户习惯全局共享 / 角色日常他角色只读 / 项目人人读写 + 项目索引分层)、相关性 + 最近度检索、衰减、溯源、RAG;**LLM 异步提取**(每轮落定后后台提取,手工提取兜底);**定期记忆整理**(低峰后台任务,只产候选建议、绝不自动改写);**入库前审查**(D3:新记忆落库前先判定 merge / modify / create / skip,软合并可 `revert` 回滚)
|
||||
- **durable execution / checkpoint** — 每步落盘快照,**崩溃后可从最新 checkpoint 续跑**(`loadCheckpoint` → `Step+1`;仅「崩溃遗留的在途 run」允许恢复,已落定退出一律重开)
|
||||
- **IM 渠道插件(内置 5 个,随二进制分发)** — **KOOK** ✅、**微信 iLink** ✅(扫码绑定 + 绑定确认时**自动把主人登记进 owner 白名单**,热生效)、**QQ OneBot v11**(正/反 WS × HTTP,四端组合齐;判主 / 收发 / 语音转写均**真机验证过**,但主人 2026-10-05 因风控决定**暂不连**,代码保留)、**Telegram**(长轮询 / webhook,需 Bot token)、**通用 Webhook**(入站,已挂网关)
|
||||
- **多渠道主会话同步** — 主人私聊(Web / KOOK / 微信 / QQ)汇聚到主会话,Web 端免刷新镜像;群聊 / 陌生人走独立隔离会话,不写记忆、不入主会话上下文
|
||||
- **后台思考与主动推送** — 自主思考心跳 + 事件触发(安全域 / 急性事件可穿透安静时段)+ 静默时段降频(`THINK_QUIET_INTERVAL_SEC`)+ 主动推送限频;**自排思考 `request_think`**(她可在一次思考里给自己排下一轮,有窗口次数上限)
|
||||
- **技能(Skill)** — 目录式 YAML(`configs/skills/<id>/skill.yaml`,内含示例 `daily-brief`);**渐进披露**(清单只给 description,命中才展开正文)+ **工具面收缩**(`allowed-tools` 是能力上限,只收不放);**`Runner.Run` 多步执行可用**(步骤拓扑 / 环检测 / 并行上限 / guardrail 强制点)
|
||||
- **插件声明的管理页面** — `GET /api/v1/admin/panels`(按角色过滤的接入点快照)+ `POST /api/v1/admin/plugins/{id}/actions/{action}`(**真执行**已登记动作,写动作走 owner/admin + 审计);插件生命周期变化推 WS 帧 `panels_changed`
|
||||
- **网络搜索** — 原生 **6 家 provider**(SearXNG 本机兜底 / 智谱 / 阿里 IQS / 博查 / 火山·豆包 / 百度千帆)+ 降级链 + **配置面板热重载**(改配置即生效、无需重启;会让 web-search 无可用后端的改动会被回滚并报 `409`)
|
||||
- **文件 / 文档进对话** — 上传 → 抽取正文 sidecar → 按预算注入上下文;**含 PDF 摄取**;引用计数 + 保留期 GC(仍被引用的文件绝不删)
|
||||
- **语音** — 云端 **STT / TTS 真实接通**(阿里云百炼 DashScope:`qwen-audio-3.0-asr-flash` / `cosyvoice-v3-flash`),含**声音复刻**入口;IM 入站语音自动转写;**语音落库 + 回放**(消息 DTO `voice` + `GET /api/v1/media/{name}`,支持 Range);不可播格式(QQ AMR)**转码成 wav** 后再落副本
|
||||
- **浏览器工具** — 自研**出站 CDP**(不发 `Runtime.enable`,减少被识别面);**有头 Chrome + 持久化 profile**(过风控口径);动作 `open` / `screenshot` / `info`(owner-only);**截图落盘后自动进本轮上下文**;插件声明页(B4)已落地
|
||||
- **MCP 客户端** — 接入官方 MCP SDK(Go);stdio 子进程 / SSE / Streamable HTTP;工具**自动发现并注册**进能力面
|
||||
- **实时全链路流程图(含时间轴)** — `graph_update` 实时帧 + 落盘 `flow_graphs` 多轮历史 + `GET /api/v1/admin/flow/graph`(最近一轮 / 指定 `turnId` / `lanes[]`)+ `GET /api/v1/admin/flow/timeline`(按时间正序的多轮时间轴,支持 `limit` / `before` 分页 + 保留期信号);**真并行每路各发自己的帧**(不再互相覆盖);Flow 页提供「最近一轮 / 时间轴」两种视图
|
||||
- **超级详尽后台管理面板**(`/admin`,路径可配)— **18 个静态模块**(仪表盘 / 人格 / 会话 / 记忆 / 知识库 / 插件 / 渠道 / 渠道事件 / IoT / LLM / 自动化 / Agent 监控 / 全链路流程 / 后台思考 / 通知 / 可观测性 / 用户与权限 / 系统设置)+ **插件声明的动态页**,统一 RBAC(`owner` > `admin` > `viewer`)+ 审计
|
||||
- **系统设置(4 个可写操作)** — 吊销全部 API key / 轮换 JWT / 备份 / 恢复(owner-only + 审计 + 前端确认弹窗)
|
||||
- **语境感知 UI** — 用户端是**一个共享页面**:单页消息流,每条消息带自己的 mode,气泡按 mode 渲染(`chat` / `code` / `home` / `work` / `docs` / `voice` / `vision` / `agents` / `settings` 共 9 种);统一 **Miuix 设计语言** + PWA
|
||||
- **多端契约** — 用户侧 REST `/api/v1` + 实时 WS `/ws/chat` + 管理 `/api/v1/admin/*`,**版本化契约 v0.81**(权威文档 `Docs/api-client.md`)
|
||||
- **多平台部署** — Linux / Windows / macOS / ARM / Termux;**SQLite 默认**(纯 Go、单二进制);PG + pgvector **可选增强**(配置后真走语义 / 向量路径)
|
||||
|
||||
### ⚠️ 部分实现 / 需凭据
|
||||
|
||||
- **视觉** — 分层路由(主模型 → 工具化视觉 / OCR)与**真推理入口已落地**;真实视觉 / OCR 模型服务**需配 `LLM_VISION_MODEL`(或 `LLM_OCR_MODEL`)及凭据**。无凭据时降级为主模型直接看图 / 视觉·OCR 工具文本描述
|
||||
- **语音的真实双向通话(Web 端)** — 上行链路与 STT/TTS 已通;`voice` 模式的「实时通话」交互仍未接(前端标注 deferred)。`edge-tts` / `espeak` 仍是**未实现占位**(无凭据时走降级链到 `silent`)
|
||||
- **声音复刻** — 代码路径已实现,但仍**需主人提供公网可访问的参考音频 URL** 方能真建自定义音色
|
||||
- **网络搜索的 5 家云端 provider** — 除本机 SearXNG 兜底外,智谱 / 阿里 IQS / 博查 / 火山 / 百度千帆**均需自配 API key,且尚未真机实测**(无凭据);中文查询在本机兜底上的效果有限
|
||||
- **RAG 语义 / 向量检索** — 默认走内置 n-gram / FTS 向量(dim=256,离线可用);配置 `EMBEDDING_API_URL` + PG/pgvector 后走**真语义**路径
|
||||
- **微信 iLink 绑定依赖微信侧资格** — 扫码协议已对齐官方并打通;仍需微信账号侧具备 iLink bot 绑定资格(账号环境依赖)
|
||||
- **QQ OneBot v11** — 代码链路与真机验证都成立,但按主人决定**当前不连**(风控原因);接回只需配置并在面板启用
|
||||
- **主动推送的设备兜底(Getui)** — 自建推送服务的 webhook 出口已实现(`PUSH_EXPORTER_*`);个推(Getui)**SDK 未接入**,仅预留字段
|
||||
|
||||
### ❌ 未实现(当前不提供)
|
||||
|
||||
- **`Runner.Run` 多步工作流未接进 loop** — 技能进入 loop 的目前只有「清单 / 命中展开正文 / 工具面收缩」;多步工作流执行器(`Runner.Run` / `Runner.RunSkill`)本身可用且有单测,但**模型还不能在对话里直接触发一套多步技能**
|
||||
- **浏览器 MCP 委派未落地** — `browser` 工具只提供 `open` / `screenshot` / `info`;`click` / `type` / `wait` / `extract` 仅在工具描述里指路,实际需经 MCP 浏览器服务补齐(未接)
|
||||
- **搜索降级档 B5 未做**、浏览器容器 docker 配方(CJK 字体)**未落盘**、浏览器会话**未挂进程关闭链**
|
||||
- **系统设置**:编辑环境变量 / 重启服务 / 检查更新 — 后端未实现,不提供
|
||||
- **自排思考 C2 的写路径(面板可调)** — 自排的**成本可见面(读侧:`?trigger=` 筛选)已落地**;把后台思考调优参数做成**面板可保存**(`SaveBackgroundTuning` + admin 端点)的部分仍在做
|
||||
- **全链路 P2 剩余** — token / 耗时统计、移动端优化(并行路 / 泳道 / 节点实时日志本身已落地)
|
||||
- **群聊类能力** — 冷避、@ 窗口期、戳一戳回应等**全部依赖有群聊的平台**;因 QQ 暂不连、微信 iLink 只支持绑定微信号私聊,当前暂无活体群聊对象(队列保留,不删)
|
||||
- **IM 路由主动消息的行 id 回填(design/19 §6.10-2)** — 需扩 `SendProactive` 返回值并改全部 mock,本期不做
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 0. 前置
|
||||
- **Go 1.26**、**Node 22**(前端构建用)、**Docker**(可选:PG + pgvector;不用 PG 可仅用 SQLite)。
|
||||
|
||||
### 1. 构建
|
||||
|
||||
```bash
|
||||
# 前端(必做一次:go:embed 需要 dist;构建脚本会同步到 internal/webui/dist)
|
||||
cd frontend/web && npm ci && npm run build && cd ../..
|
||||
|
||||
# 后端二进制(Windows)
|
||||
scripts\ctrl.bat build
|
||||
# Linux / macOS(无 build 子命令,直接 go build)
|
||||
go build -ldflags="-s -w" -trimpath -o cyrene ./cmd/cyrene/
|
||||
```
|
||||
|
||||
> `scripts\ctrl.bat build` 是 Windows 上的权威构建(带 `-ldflags="-s -w" -trimpath`),产物写到仓库根 `cyrene.exe`。
|
||||
> **换二进制**:先 `build` 到位,再**一次** `restart`(不留空窗)。
|
||||
> **纯前端改动不需要重启后端**:运行时**优先读磁盘 `frontend/web/dist`**(存在即覆盖内嵌 dist),刷新浏览器即可。
|
||||
|
||||
### 2. 启停(本地服务控制)
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
scripts\ctrl.bat start # 启动(无参 = start;已在跑则跳过)
|
||||
scripts\ctrl.bat stop # 停止(先优雅、后强杀)
|
||||
scripts\ctrl.bat restart # 停止 + 启动
|
||||
scripts\ctrl.bat status # 端口 + PID + /api/v1/health
|
||||
scripts\ctrl.bat build # 只构建二进制
|
||||
|
||||
# Linux / macOS(等价,无 build)
|
||||
bash scripts/ctrl.sh start|stop|restart|status
|
||||
```
|
||||
|
||||
> 端口取 `.env` 的 `PORT`,缺省回落 `10080`。日志 `%USERPROFILE%\cy.log`(每次启动前把上一份归档为 `cy.log.<时间戳>.bak`,保留最近 20 份),PID 文件 `%USERPROFILE%\.cyrene.pid`。
|
||||
> 启动默认**分离进程**(`Start-Process -WindowStyle Hidden`):从 Git Bash 调 `restart` 时请把输出重定向到文件(`> /tmp/cy-restart.log 2>&1 < /dev/null`),否则 bash 会等管道 EOF 而「不返回」;**判定成功要看「新 PID + health ok + 启动日志」,不是看命令退没退出**。
|
||||
|
||||
### 3. 配置 `.env`(复制 `.env.example`)
|
||||
|
||||
```bash
|
||||
# 认证(JWT_SECRET 必填 ≥32,过短启动即 panic)
|
||||
JWT_SECRET=<≥32 字符随机串>
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=<你的密码>
|
||||
|
||||
# LLM(LLM_PROVIDER 显式声明协议:openai / anthropic,默认 openai)
|
||||
LLM_PROVIDER=anthropic
|
||||
ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic
|
||||
ANTHROPIC_API_KEY=<key>
|
||||
ANTHROPIC_MODEL=deepseek-flash
|
||||
# 或 OpenAI 兼容:LLM_PROVIDER=openai + LLM_BASE_URL / LLM_API_KEY / LLM_MODEL
|
||||
|
||||
# 数据库(可选增强:PG + pgvector 向量检索;不配则用 SQLite)
|
||||
DB_PG_URL=postgres://cyrene:cyrene_pass@localhost:5432/cyrene_ai?sslmode=disable
|
||||
|
||||
# 记忆嵌入(真语义;EMBEDDING_API_URL 必须是【完整 /v1/embeddings 端点】,不是 base)
|
||||
EMBEDDING_API_URL=http://localhost:11434/v1/embeddings
|
||||
EMBEDDING_MODEL=Qwen3-Embedding-0.6B
|
||||
```
|
||||
|
||||
### 4. 可选依赖
|
||||
|
||||
```bash
|
||||
# PG + pgvector(可选增强:向量索引走这里)
|
||||
docker compose -f docker-compose.dev.yml up -d pg
|
||||
|
||||
# 本地 embedding(可选:真语义;llama.cpp + Qwen3 GGUF,CPU/GPU)
|
||||
scripts\embed.bat start|stop|status # Linux / macOS:见 embed.bat 内注释自行起 llama-server
|
||||
|
||||
# 本机 SearXNG 兜底搜索(可选;配置见 scripts/searxng/settings.yml)
|
||||
```
|
||||
|
||||
> `scripts/dev-up.sh` 是 Linux / macOS 上的**一键全流程**(PG → llama.cpp embedding → 前端 build → cyrene),约定 llama.cpp 放 `~/apps/llama.cpp/`、GGUF 放 `~/.models/`、二进制 `~/apps/cyrene/cyrene`;已运行的服务自动跳过。
|
||||
|
||||
### 5. 访问
|
||||
|
||||
- 用户端 SPA:`http://localhost:8080`(按 `.env` 的 `PORT`)
|
||||
- 管理面板:`http://localhost:8080/<ADMIN_PATH>`(`ADMIN_PATH` 默认 `admin`;REST/admin API 仍是 `/api/v1/admin/*`,安全靠 JWT + 角色门)
|
||||
|
||||
### 6. 服务端口 / 路径速查
|
||||
|
||||
| 服务 | 地址 / 路径 | 说明 |
|
||||
|------|------------|------|
|
||||
| Cyrene 网关 | `http://localhost:8080`(`.env:PORT`) | SPA + REST/WS + 面板 |
|
||||
| PG + pgvector | `localhost:5432`(docker `cyrene-pg`) | 可选;向量索引在 `cyrene_ai` |
|
||||
| llama.cpp embedding | `http://localhost:11434/v1/embeddings` | 可选;Qwen3 |
|
||||
|
||||
---
|
||||
|
||||
## 配置
|
||||
|
||||
### `.env` 已冻结(最小化可跑,不加 / 不改 / 不删)
|
||||
|
||||
`.env` 只保证「**最小化功能可跑**」的引导面,**不是配置面**。主人 2026-10-06 定红线:
|
||||
|
||||
- **基础键已经定好,以后不准改**:不加、不改、不删;模板 `.env.example` 与 `.env` 同进退。
|
||||
- **新配置一律不进 `.env`**:调优 / 门槛 / 上限 / 开关类参数走 **`$DATA_DIR/config/**`**(管理面板可改、热重载)+ 代码内默认值。
|
||||
- 既有基础键与既有 `env:` tag **保持现状**(不迁移、不改默认值、不删键)。
|
||||
|
||||
> 想要调优项请走面板(如「LLM 模型」页写 `models.json`、「渠道管理」页写 `channels.json`、插件配置写 `$DATA_DIR/config/plugins/<plugin-id>.json`),或直接改 `$DATA_DIR/config/**` 下的文件。
|
||||
> 口径全文见 `DevDocs/design/13 §2.1`;`AGENTS.md §10` 亦载明。
|
||||
|
||||
### 数据目录 `DATA_DIR`
|
||||
|
||||
默认 `./data`;显式写 `platform`(保留字哨兵)则走平台标准数据目录(Windows `%APPDATA%\Cyrene`、macOS `~/Library/Application Support/Cyrene`、Linux `~/.local/share/cyrene`)。
|
||||
|
||||
---
|
||||
|
||||
## Embedding 模型推荐
|
||||
|
||||
> 用于长记忆 / 知识库的**语义向量检索**(默认不配 = 内置 n-gram 向量,dim=256,离线但质量一般)。
|
||||
> 配了 `EMBEDDING_API_URL` / `EMBEDDING_MODEL` 后走**真语义**。维度 = 模型 dim,与向量索引一致;**切换模型会改维度,需清空旧向量数据**。
|
||||
|
||||
| 模型 | dim | 大小 | 推理内存 | 质量 | 建议 |
|
||||
|------|-----|------|---------|------|------|
|
||||
| **Qwen3-Embedding-0.6B** | 1024 | Q8_0 **639MB** / f16 1.2GB | ~0.7–1GB | ★★★★★(多语言、2025 新、MTEB 榜前列) | ⭐ **首选**(ModelScope 国内可下 + GGUF) |
|
||||
| **bge-m3** | 1024 | ~2.2GB(fp16) / ~1.2GB(Q4) | ~2–3GB | ★★★★★(跨语言标杆) | 内存 / GPU 充裕时可用 |
|
||||
| **bge-small-zh-v1.5** | 512 | ~30–100MB | ~几十MB | ★★★★ | 极轻量备选 |
|
||||
| nomic-embed-text | 768 | ~270MB | ~400–600MB | ★★★☆ | 可 |
|
||||
| mxbai-embed-large | 768 | ~350–670MB | ~0.8–1GB | ★★★★ | 可 |
|
||||
|
||||
> **首选说明**:**Qwen3-Embedding-0.6B**(阿里通义开源,覆盖 100+ 语言)——**有 GGUF**、**ModelScope(魔搭)国内直连可下**(HuggingFace 在国内已不可访问);`Q8_0` 仅 **639MB**,内存紧张(如 ~3GB 可用、无独立显存的 UMA GPU)也能跑,质量≈f16。
|
||||
>
|
||||
> **llama.cpp 能替代 Ollama 吗?** 能,且更省内存。`llama-server`(服务模式)暴露 **OpenAI 兼容 `/v1/embeddings`**:
|
||||
> ```bash
|
||||
> ./llama-server -m Qwen3-Embedding-0.6B-Q8_0.gguf --embeddings -ngl 99 --host 127.0.0.1 --port 11434
|
||||
> # 然后 EMBEDDING_API_URL=http://localhost:11434/v1/embeddings 照用(dim=1024;切换前清一次向量表)
|
||||
> ```
|
||||
> - **Ollama**:模型管理方便(`pull`)、开箱即用,但常驻偏重。
|
||||
> - **llama.cpp**:单文件更轻、可离线自制 GGUF;但模型要自己下 / 转(无 registry)。**只认 GGUF**,`model.safetensors` 不能直接喂。
|
||||
|
||||
### 注意事项
|
||||
|
||||
- **勿用 `/tmp` 放二进制 / 模型**(tmpfs 在内存里);一律 `~/apps/`、`~/.models/`。
|
||||
- `EMBEDDING_API_URL` **必须是完整 `/v1/embeddings`**(不是 base),否则 404。
|
||||
- **切换 embedding 模型会改维度**(Qwen3=1024,n-gram=256)→ 换模型前清一次向量表:`docker exec cyrene-pg psql -U cyrene -d cyrene_ai -c "TRUNCATE vec_items;"`。
|
||||
- LLM 用 anthropic 协议时填 `ANTHROPIC_*`;用 openai 兼容时填 `LLM_*`。`LLM_PROVIDER` 二选一。
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
Cyrene_v2/
|
||||
├── cmd/cyrene/ # 唯一入口(wire_*.go 组合根:把各子系统接线 + go:embed 前端)
|
||||
├── internal/ # 进程内模块(不对外)
|
||||
│ ├── config/ # .env 加载 + $DATA_DIR/config/** 可变源(面板一等来源)
|
||||
│ ├── logger/ platform/ db/ bus/ errors/ auth/ user/ # 地基
|
||||
│ ├── runtime/ # 核心运行时:model / agent / loop / tool / guardrail / trace /
|
||||
│ │ # persist(checkpoint) / memory / embedding / multimodal /
|
||||
│ │ # orchestration / exec / task / flowgraph / documents / voice / capacity
|
||||
│ ├── persona/ capability/ channel/ iot/ urgency/ contract/
|
||||
│ ├── services/ # gateway(REST/WS/admin) / automations / backthink / approvalreview / channelbridge
|
||||
│ └── webui/ # 内嵌前端 dist(go:embed)
|
||||
├── plugins/ # 一等插件:tools(basic/io/browser) + channels(kook/qq_obv11/telegram/webhook/weixin_ilink)
|
||||
├── pkg/plugins/sdk/ # 插件 SDK(第三方接入)
|
||||
├── configs/ # 配置模板:personas/ + users/(主人档案)+ skills/(示例技能)
|
||||
├── frontend/web/ # React SPA(单页消息流 + mode 渲染器)+ 管理面板(Miuix);构建产物 dist/ 供磁盘热覆盖
|
||||
├── Docs/ # 用户文档(进 git):api-client.md(客户端 API 契约)+ archive/
|
||||
├── DevDocs/ # 开发文档(**不进 git**,本地锚点:设计 / 时间线 / 工作小结 / 审计)
|
||||
├── data/ # 运行时数据(DATA_DIR;SQLite / uploads / 面板写入的 *.json)
|
||||
├── scripts/ # ctrl.bat / ctrl.sh / embed.bat / dev-up.sh / searxng/
|
||||
└── services/ # 预留(外部服务扩展位)
|
||||
```
|
||||
|
||||
> `Docs/`(大写 D)**进 git**;`DevDocs/` **被 gitignore,不进 git**。`*.exe`、`dist/`、`data/`、`.env` 等亦不入库(见 `.gitignore`)。
|
||||
|
||||
---
|
||||
|
||||
## 已知缺口与路线图
|
||||
|
||||
> **不美化**:以下是**当前真做不了 / 没做完**的事。
|
||||
|
||||
**核心运行时**
|
||||
1. 🔴 **`Runner.Run` 多步工作流未接进 loop** — 技能的多步执行器可用且有单测,但**模型还不能在对话里触发一整套多步技能**(现只到「清单 / 命中展开正文 / 工具面收缩」)。
|
||||
2. **自排思考 C2 的写路径未做** — 自排的成本可见读侧(`?trigger=` 筛选)已落地,面板可保存的后台思考调优(`SaveBackgroundTuning` + admin 端点)在做。
|
||||
3. **全链路 P2 剩余** — token / 耗时统计、移动端优化。
|
||||
|
||||
**浏览器**
|
||||
4. 🔴 **MCP 委派未落地** — `click` / `type` / `wait` / `extract` 尚未接通(只在工具描述里指路)。
|
||||
5. **搜索降级档 B5 未做**;容器 docker 配方(CJK 字体)未落盘;浏览器会话未挂进程关闭链。
|
||||
|
||||
**渠道**
|
||||
6. **QQ OneBot v11 暂不连**(主人 2026-10-05 因风控决定);**Telegram 需 Bot token**。群聊类能力(冷避 / @ 窗口 / 戳一戳)因此暂无活体平台可验。
|
||||
7. **搜索 5 家云端 provider 未真机实测**(无 key);微信 iLink 依赖微信侧绑定资格;声音复刻需公网参考音频 URL。
|
||||
8. **视觉 / OCR 真模型需凭据**;`edge-tts` / `espeak` 仍是占位。
|
||||
|
||||
**治理 / 其他**
|
||||
9. **系统设置**:编辑环境变量 / 重启服务 / 检查更新 — 后端未实现。
|
||||
10. **Getui(个推)兜底未接入**(SDK 未接);待主人自建推送服务。
|
||||
11. **部分真机端到端未验**(如实登记):时间轴真机面板联调、搜索 provider 热重载真机端到端、AMR 落副本生产端到端、`graph_update` 真并行真机端到端、`design/19 §6.8` 的 Q1–Q4。
|
||||
12. **Android 客户端**另会话推进(跨仓协同事项见 `DevDocs`,不入本仓库)。
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层 | 技术 |
|
||||
|----|------|
|
||||
| 后端 | Go 1.26(单二进制运行时)、进程内事件总线(API 刻意对齐 NATS,便于日后换出进程 transport)、`slog`(日志带 `source` 来源标注) |
|
||||
| 前端 | React 19、TypeScript、Vite 6、Tailwind 4、Zustand 5、PWA(Miuix 设计语言) |
|
||||
| 数据库 | **SQLite 默认**(纯 Go、单二进制);PG + pgvector 可选增强(配置后真生效) |
|
||||
| 多模态 | vision(分层路由:主 / 视觉 / OCR;真模型需凭据,无凭据降级主模型)、voice(DashScope STT/TTS 真实接通 + 降级链;无凭据降级 `silent`) |
|
||||
| LLM | 多供应商(OpenAI 兼容 / Anthropic / 本地 / 网关)、per-role 路由 + 降级(主 → 备 → 本地) |
|
||||
| 插件 / 协议 | 进程内插件 SDK(`pkg/plugins/sdk`)、官方 MCP SDK(stdio / SSE / Streamable HTTP) |
|
||||
|
||||
---
|
||||
|
||||
## 文档
|
||||
|
||||
| 文档 | 位置 | 说明 |
|
||||
|------|------|------|
|
||||
| 架构总览 / 重构蓝图 | `DevDocs/00-overview.md` | 产品定位、架构、渠道插件、后台面板、阶段计划(**本地,不进 git**) |
|
||||
| 开发约束 | `DevDocs/02-development-constraints.md` | 编码规范 + 性能要求(**本地**) |
|
||||
| **客户端 API** | `Docs/api-client.md` | 跨平台客户端 API 契约(REST / WS / admin,版本化,当前 **v0.81**) |
|
||||
| 开发铁律 | `AGENTS.md` | 每会话自动注入;含全球红线(单二进制 / 身份识别 / 前端 Miuix 等) |
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0 © 2026 Aska
|
||||
Whitespace-only changes.
@@ -0,0 +1,309 @@
|
||||
package main
|
||||
|
||||
// 本文件从运行时可变配置构建四个内置 IM 渠道适配器。有效来源是 channels.json
|
||||
// (存在且合法时优先,design/04 §3.1);否则回退到 env 渠道块(CHANNELS_JSON
|
||||
// 多账号 + 单账号 CHANNEL_<NAME>_* 变量),后者是最终兜底,使裸装环境在没有任何
|
||||
// 配置文件时也能启动。
|
||||
//
|
||||
// 每个账号各自成为一个适配器实例。单账号 env 适配器以裸平台名命名(如 "kook");
|
||||
// 多账号条目命名为 "kook:confID",并包在 namedAdapter 里,通过 Config() 暴露其
|
||||
// allow/deny 名单,供渠道桥的黑/白名单闸门使用(design/16 §4.6 §5-6)。
|
||||
// channelFactory 让桥在面板保存新配置时热替换某平台的适配器(Bridge.Reload),
|
||||
// 因此 token/地址/速率变更无需重启即可生效。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/kook"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/qq_obv11"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/telegram"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/webhook"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink"
|
||||
)
|
||||
|
||||
// buildChannelAdapters 从有效账号配置(channels.json → env 兜底)构建全部渠道
|
||||
// 适配器,并返回构建成功的子集。适配器构建从不发起网络连接,真正的连接发生在渠道桥
|
||||
// 里。f 持有路径/兜底/logger,使启动路径与热重载工厂读取同一来源。
|
||||
func buildChannelAdapters(f *channelFactory) []adapter.PlatformAdapter {
|
||||
accounts, warn := runtimeconfig.LoadChannels(f.path, f.fallback)
|
||||
if warn != nil {
|
||||
f.log.Warn("channels.json load fell back to env", "error", warn)
|
||||
}
|
||||
out := make([]adapter.PlatformAdapter, 0)
|
||||
for platform, accts := range accounts {
|
||||
out = append(out, buildPlatformAdapters(platform, accts, f.log)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// channelFactory 是接入渠道桥的 channelbridge.PlatformFactory。它从**当前**有效
|
||||
// 配置(文件 → env)重建某平台的适配器,因此面板保存后调用 Bridge.Reload(platform)
|
||||
// 即可让新配置生效,无需重启。
|
||||
type channelFactory struct {
|
||||
path string
|
||||
fallback map[string][]config.ChannelAccount
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// Build 返回某平台按其有效账号配置构建出的适配器。
|
||||
func (f *channelFactory) Build(platform string) ([]adapter.PlatformAdapter, error) {
|
||||
accounts, warn := runtimeconfig.LoadChannels(f.path, f.fallback)
|
||||
if warn != nil {
|
||||
f.log.Warn("channels.json reload fell back to env", "error", warn)
|
||||
}
|
||||
return buildPlatformAdapters(platform, accounts[platform], f.log), nil
|
||||
}
|
||||
|
||||
// Persona 返回某平台某个渠道账号所绑定的角色(design/22 §2 方案 b)。它读取与
|
||||
// Build 相同的有效配置 —— channels.json 优先、env 兜底 —— 因此「面板保存 + 桥
|
||||
// 热重载」始终是唯一的更新路径;它每次适配器连接调用一次,而不是每条消息调用一次。
|
||||
// 平台/账号未知,或账号未配 personaId 时返回 ""(无渠道级绑定:改用会话绑定或全局
|
||||
// 默认)。
|
||||
func (f *channelFactory) Persona(platform, accountID string) string {
|
||||
accounts, warn := runtimeconfig.LoadChannels(f.path, f.fallback)
|
||||
if warn != nil {
|
||||
f.log.Warn("channels.json persona lookup fell back to env", "error", warn)
|
||||
}
|
||||
for _, acct := range accounts[platform] {
|
||||
if strings.TrimSpace(acct.ID) == strings.TrimSpace(accountID) {
|
||||
return strings.TrimSpace(acct.PersonaID)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// buildPlatformAdapters 把某平台的全部账号各构建成一个适配器实例。某个账号构建
|
||||
// 失败只记一条 WARN 并跳过(绝不致命),与桥的「没有凭据 → 告警并继续」约定一致。
|
||||
func buildPlatformAdapters(platform string, accts []config.ChannelAccount, log *slog.Logger) []adapter.PlatformAdapter {
|
||||
out := make([]adapter.PlatformAdapter, 0, len(accts))
|
||||
for _, acct := range accts {
|
||||
built, berr := buildAccountAdapter(platform, acct, log)
|
||||
if berr != nil {
|
||||
log.Warn("channel account build skipped", "platform", platform, "account", acct.ID, "error", berr)
|
||||
continue
|
||||
}
|
||||
out = append(out, built)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// channelAccountsFromEnv 合成 env 兜底:单账号 CHANNEL_<NAME>_* 适配器,加上
|
||||
// CHANNELS_JSON 里的多账号条目。它是 channels.json 缺失或非法时传给
|
||||
// runtimeconfig.LoadChannels 的 .env 兜底。.env 只承载必要的连接兜底
|
||||
// (token/base_url/...);业务与高级的按账号字段(receive_mode、webhook
|
||||
// path/verify_token/encrypt_key、allow/deny 名单、速率限制)在面板的渠道管理里
|
||||
// 配置,落在 channels.json,绝不出现在 .env。
|
||||
//
|
||||
// 需要 token 的平台(kook/telegram/weixin-ilink)只有在其 token 环境变量已设置时
|
||||
// 才合成;token 未设置就完全不存在该账号,因此空 token 的占位符绝不会占用裸平台名。
|
||||
// qq-obv11 与 webhook 始终存在(它们不需要 token;由桥的 configured() 闸门决定是否
|
||||
// 拨号),所以渠道列表保持稳定。
|
||||
func channelAccountsFromEnv() map[string][]config.ChannelAccount {
|
||||
out := map[string][]config.ChannelAccount{}
|
||||
// KOOK:只放必要的连接字段。receive_mode / webhook_path /
|
||||
// webhook_verify_token / webhook_encrypt_key 属于业务·高级字段,经由面板写入
|
||||
// channels.json —— .env 不再对它们做兜底。
|
||||
if tok := os.Getenv("CHANNEL_KOOK_TOKEN"); tok != "" {
|
||||
out["kook"] = append(out["kook"], config.ChannelAccount{Config: map[string]any{
|
||||
"bot_token": tok,
|
||||
"base_url": os.Getenv("CHANNEL_KOOK_BASE_URL"),
|
||||
}})
|
||||
}
|
||||
// Telegram:token(必要)+ 可选的 webhook_url(连接)。业务调优写入
|
||||
// channels.json。
|
||||
if tok := os.Getenv("CHANNEL_TELEGRAM_BOT_TOKEN"); tok != "" {
|
||||
out["telegram"] = append(out["telegram"], config.ChannelAccount{Config: map[string]any{
|
||||
"bot_token": tok,
|
||||
"webhook_url": os.Getenv("CHANNEL_TELEGRAM_WEBHOOK_URL"),
|
||||
}})
|
||||
}
|
||||
// qq-obv11 的四个端(裁决 1.4):transport(ws|http) × mode(client|server)。
|
||||
// 默认 transport=ws、mode=server,与改造前的行为完全一致(保守:不改变已部署
|
||||
// 现状);listen_addr 是 server 端的显式监听地址,留空即不开端口(AGENTS §9)。
|
||||
out["qq-obv11"] = append(out["qq-obv11"], config.ChannelAccount{Config: map[string]any{
|
||||
"transport": envString("CHANNEL_QQ_OBV11_TRANSPORT", "ws"),
|
||||
"mode": envString("CHANNEL_QQ_OBV11_MODE", "server"),
|
||||
"listen_addr": os.Getenv("CHANNEL_QQ_OBV11_LISTEN_ADDR"),
|
||||
"remote_url": os.Getenv("CHANNEL_QQ_OBV11_REMOTE_URL"),
|
||||
"api_base_url": os.Getenv("CHANNEL_QQ_OBV11_API_BASE_URL"),
|
||||
"report_path": os.Getenv("CHANNEL_QQ_OBV11_REPORT_PATH"),
|
||||
"access_token": os.Getenv("CHANNEL_QQ_OBV11_ACCESS_TOKEN"),
|
||||
"self_id": os.Getenv("CHANNEL_QQ_OBV11_SELF_ID"),
|
||||
}})
|
||||
out["webhook"] = append(out["webhook"], config.ChannelAccount{Config: map[string]any{
|
||||
"endpointPath": os.Getenv("CHANNEL_WEBHOOK_ENDPOINT_PATH"),
|
||||
"channelId": os.Getenv("CHANNEL_WEBHOOK_CHANNEL_ID"),
|
||||
"channelName": os.Getenv("CHANNEL_WEBHOOK_CHANNEL_NAME"),
|
||||
"secret": os.Getenv("CHANNEL_WEBHOOK_SECRET"),
|
||||
"signatureHeader": os.Getenv("CHANNEL_WEBHOOK_SIGNATURE_HEADER"),
|
||||
}})
|
||||
if tok := os.Getenv("CHANNEL_WEIXIN_ILINK_BOT_TOKEN"); tok != "" {
|
||||
out["weixin-ilink"] = append(out["weixin-ilink"], config.ChannelAccount{Config: map[string]any{
|
||||
"base_url": os.Getenv("CHANNEL_WEIXIN_ILINK_BASE_URL"),
|
||||
"cdn_base_url": os.Getenv("CHANNEL_WEIXIN_ILINK_CDN_BASE_URL"),
|
||||
"bot_token": tok,
|
||||
"bot_id": os.Getenv("CHANNEL_WEIXIN_ILINK_BOT_ID"),
|
||||
"owner_id": os.Getenv("CHANNEL_WEIXIN_ILINK_OWNER_ID"),
|
||||
}})
|
||||
}
|
||||
if raw := os.Getenv("CHANNELS_JSON"); raw != "" {
|
||||
if accounts, err := config.ParseChannelAccounts(raw); err == nil {
|
||||
for platform, accts := range accounts {
|
||||
out[platform] = append(out[platform], accts...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// namedAdapter 包装平台适配器,使多账号实例获得唯一名字(platform:confID),并
|
||||
// 通过 Config() 暴露其 allow/deny 名单 —— 渠道桥读取它来按账号执行平台的黑/白名单
|
||||
// (design/16 §4.6 §5-6)。其余方法全部委派给内层适配器。
|
||||
type namedAdapter struct {
|
||||
adapter.PlatformAdapter
|
||||
name string
|
||||
allowGroups []string
|
||||
denyGroups []string
|
||||
allowUsers []string
|
||||
denyUsers []string
|
||||
// 流控限制(design/12 §6 策略三/四)。它们始终经 Config() 暴露,使桥能构建按账号
|
||||
// 的入口闸门;桥把零值默认映射为保护性取值,把负值映射为「关闭」。
|
||||
groupMsgPerMin int
|
||||
directMsgPerMin int
|
||||
msgPerMin int
|
||||
maxMessages int
|
||||
}
|
||||
|
||||
func (n *namedAdapter) Name() string { return n.name }
|
||||
|
||||
func (n *namedAdapter) Config() map[string]any {
|
||||
cfg := n.PlatformAdapter.Config()
|
||||
out := make(map[string]any, len(cfg)+8)
|
||||
for k, v := range cfg {
|
||||
out[k] = v
|
||||
}
|
||||
out["allowGroups"] = n.allowGroups
|
||||
out["denyGroups"] = n.denyGroups
|
||||
out["allowUsers"] = n.allowUsers
|
||||
out["denyUsers"] = n.denyUsers
|
||||
out["groupMsgPerMin"] = n.groupMsgPerMin
|
||||
out["directMsgPerMin"] = n.directMsgPerMin
|
||||
out["msgPerMin"] = n.msgPerMin
|
||||
out["maxMessages"] = n.maxMessages
|
||||
return out
|
||||
}
|
||||
|
||||
// InboundHandler 在内层适配器暴露入站 HTTP 回调时转发给它;桥会对包装器做类型
|
||||
// 断言,并把该 handler 挂到 WebhookPath。内层适配器不是 webhook 时返回 nil。
|
||||
func (n *namedAdapter) InboundHandler() http.Handler {
|
||||
if inner, ok := n.PlatformAdapter.(interface{ InboundHandler() http.Handler }); ok {
|
||||
return inner.InboundHandler()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WebhookPath 转发内层适配器的 webhook 路由;内层不暴露该路由时返回 ""(桥据此
|
||||
// 跳过它)。
|
||||
func (n *namedAdapter) WebhookPath() string {
|
||||
if inner, ok := n.PlatformAdapter.(interface{ WebhookPath() string }); ok {
|
||||
return inner.WebhookPath()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// buildAccountAdapter 为某平台构建单个账号的适配器:把账号的通用 Config map 解码
|
||||
// 成该平台的强类型配置,并包装起来,使桥看到唯一的账号名及其 allow/deny 名单。
|
||||
func buildAccountAdapter(platform string, acct config.ChannelAccount, log *slog.Logger) (adapter.PlatformAdapter, error) {
|
||||
name := platform
|
||||
if acct.ID != "" {
|
||||
name = platform + ":" + acct.ID
|
||||
}
|
||||
|
||||
var inner adapter.PlatformAdapter
|
||||
switch barePlatform(platform) {
|
||||
case "telegram":
|
||||
var cfg telegram.Config
|
||||
if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("decode telegram config: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(cfg.BotToken) == "" {
|
||||
return nil, fmt.Errorf("%s: missing bot token", platform)
|
||||
}
|
||||
inner = telegram.New(cfg, telegram.WithLogger(log))
|
||||
case "weixin-ilink":
|
||||
var cfg weixin_ilink.Config
|
||||
if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("decode weixin-ilink config: %w", err)
|
||||
}
|
||||
// weixin-ilink 账号允许 bot_token 为空:这是一条待扫码登录的账号,面板通过
|
||||
// login-qr/login-status 管理端点驱动它(design/08 §4)。桥的 configured() 闸门
|
||||
// 会跳过它,直到扫码确认且 token 落库,因此这里空 token 是合法的 —— 应当构建
|
||||
// 该适配器(未连接),而不是丢弃它。
|
||||
w, err := weixin_ilink.New(cfg, nil, logger.WithSource(log, "plugins/channels/weixin_ilink"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build weixin-ilink: %w", err)
|
||||
}
|
||||
inner = w
|
||||
case "qq-obv11":
|
||||
var cfg qq_obv11.Config
|
||||
if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("decode qq-obv11 config: %w", err)
|
||||
}
|
||||
inner = qq_obv11.NewAdapter(cfg, qq_obv11.WithLogger(log))
|
||||
case "kook":
|
||||
var cfg kook.Config
|
||||
if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("decode kook config: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(cfg.BotToken) == "" {
|
||||
return nil, fmt.Errorf("%s: missing bot token", platform)
|
||||
}
|
||||
inner = kook.New(cfg, kook.WithLogger(log))
|
||||
case "webhook":
|
||||
var cfg webhook.Config
|
||||
if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("decode webhook config: %w", err)
|
||||
}
|
||||
inner = webhook.New(webhook.WithConfig(cfg), webhook.WithLogger(logger.WithSource(log, "plugins/channels/webhook")))
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown platform %q", platform)
|
||||
}
|
||||
return &namedAdapter{
|
||||
PlatformAdapter: inner,
|
||||
name: name,
|
||||
allowGroups: acct.AllowGroups,
|
||||
denyGroups: acct.DenyGroups,
|
||||
allowUsers: acct.AllowUsers,
|
||||
denyUsers: acct.DenyUsers,
|
||||
groupMsgPerMin: acct.GroupMsgPerMin,
|
||||
directMsgPerMin: acct.DirectMsgPerMin,
|
||||
msgPerMin: acct.MsgPerMin,
|
||||
maxMessages: acct.MaxMessages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// barePlatform 返回可能带后缀的 "platform:confID" 的平台名。
|
||||
func barePlatform(name string) string {
|
||||
for i := 0; i < len(name); i++ {
|
||||
if name[i] == ':' {
|
||||
return name[:i]
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// envString 返回 key 的值;环境变量未设置或为空白时返回 fallback。
|
||||
func envString(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
// 覆盖裁决 1.4 在组合根侧的落点:qq-obv11 的四个端所需配置项由 .env 兜底进适配器配置,
|
||||
// 且默认值保持现状(transport=ws、mode=server),不改变已部署行为。
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestChannelAccountsFromEnv_QQObv11FourEnds 验证 env 兜底把 transport / mode /
|
||||
// listen_addr / remote_url / api_base_url / report_path 带进账号配置,并且默认口径不变。
|
||||
func TestChannelAccountsFromEnv_QQObv11FourEnds(t *testing.T) {
|
||||
for _, k := range []string{
|
||||
"CHANNEL_QQ_OBV11_TRANSPORT", "CHANNEL_QQ_OBV11_MODE", "CHANNEL_QQ_OBV11_LISTEN_ADDR",
|
||||
"CHANNEL_QQ_OBV11_REMOTE_URL", "CHANNEL_QQ_OBV11_API_BASE_URL", "CHANNEL_QQ_OBV11_REPORT_PATH",
|
||||
} {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
acct := qqObv11EnvAccount(t)
|
||||
if acct["transport"] != "ws" || acct["mode"] != "server" {
|
||||
t.Fatalf("defaults = transport:%v mode:%v, want ws/server", acct["transport"], acct["mode"])
|
||||
}
|
||||
|
||||
t.Setenv("CHANNEL_QQ_OBV11_TRANSPORT", "http")
|
||||
t.Setenv("CHANNEL_QQ_OBV11_MODE", "client")
|
||||
t.Setenv("CHANNEL_QQ_OBV11_API_BASE_URL", "http://127.0.0.1:3000")
|
||||
t.Setenv("CHANNEL_QQ_OBV11_REPORT_PATH", "/onebot/report")
|
||||
|
||||
list := channelAccountsFromEnv()["qq-obv11"]
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("qq-obv11 env accounts = %d, want 1", len(list))
|
||||
}
|
||||
acct = list[0].Config
|
||||
if acct["transport"] != "http" || acct["mode"] != "client" {
|
||||
t.Errorf("transport/mode = %v/%v, want http/client", acct["transport"], acct["mode"])
|
||||
}
|
||||
if acct["api_base_url"] != "http://127.0.0.1:3000" || acct["report_path"] != "/onebot/report" {
|
||||
t.Errorf("api_base_url/report_path = %v/%v, want the env values", acct["api_base_url"], acct["report_path"])
|
||||
}
|
||||
|
||||
// 端到端一次:env 配置经 DecodeConfig 进适配器后,四个端的开关仍然在。
|
||||
built, err := buildAccountAdapter("qq-obv11", list[0], testLog())
|
||||
if err != nil {
|
||||
t.Fatalf("buildAccountAdapter error: %v", err)
|
||||
}
|
||||
cfg := built.Config()
|
||||
if cfg["transport"] != "http" || cfg["mode"] != "client" {
|
||||
t.Errorf("adapter transport/mode = %v/%v, want http/client", cfg["transport"], cfg["mode"])
|
||||
}
|
||||
if cfg["apiBaseURL"] != "http://127.0.0.1:3000" || cfg["reportPath"] != "/onebot/report" {
|
||||
t.Errorf("adapter apiBaseURL/reportPath = %v/%v, want the env values", cfg["apiBaseURL"], cfg["reportPath"])
|
||||
}
|
||||
}
|
||||
|
||||
// qqObv11EnvAccount 取出 env 兜底里合成的 qq-obv11 账号配置。
|
||||
func qqObv11EnvAccount(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
list := channelAccountsFromEnv()["qq-obv11"]
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("qq-obv11 env accounts = %d, want 1", len(list))
|
||||
}
|
||||
return list[0].Config
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package main
|
||||
|
||||
// 钉住组合根侧的接线契约:buildAccountAdapter 构造 qq-obv11 适配器时必须把 channelFactory
|
||||
// 手上的 logger 传进去(与 kook/telegram/webhook 同口径,见 cmd/cyrene/channels.go)。缺陷
|
||||
// 形态:漏传 ⇒ 适配器自造默认 INFO 兜底 ⇒ 入站帧取证(DEBUG)全部静默丢弃,主人要查的
|
||||
// 「群友 @ 没回 / 戳一戳没反应」在日志里一片空白,且与「上游压根没上报」无法区分。
|
||||
//
|
||||
// 断言走**真实入站路径**:真开一次 http+server 端、真 POST 一条 OneBot 上报,再看注入的
|
||||
// 捕获器里有没有 `msg="inbound frame"`。所以只要接线一断,这条测试必红。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/qq_obv11"
|
||||
)
|
||||
|
||||
// logSyncBuf 是并发安全的日志捕获目标:上报由 http.Server 自己的 goroutine 处理,断言在
|
||||
// 测试 goroutine 上读。
|
||||
type logSyncBuf struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *logSyncBuf) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *logSyncBuf) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
// qqObv11WiringFrame 是一条最普通的群消息事件(与 plugins/channels/qq_obv11 的取证测试
|
||||
// 同口径):走到 handlePayload 就必然留一行入站帧。
|
||||
const qqObv11WiringFrame = `{"post_type":"message","message_type":"group","sub_type":"normal",
|
||||
"self_id":10001,"user_id":20002,"group_id":1070898847,
|
||||
"raw_message":"hello","message":[{"type":"text","data":{"text":"hello"}}]}`
|
||||
|
||||
// TestBuildAccountAdapter_QQObv11WiresLoggerIntoInboundForensics 断言注入的 logger 真的
|
||||
// 到了适配器手里。捕获器级别设为 DEBUG:取证日志本身是 DEBUG 级,用默认 INFO 的捕获器
|
||||
// 「丢了」与「没丢」都看不见,测不出这条接线。
|
||||
func TestBuildAccountAdapter_QQObv11WiresLoggerIntoInboundForensics(t *testing.T) {
|
||||
buf := &logSyncBuf{}
|
||||
log := slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
|
||||
acct := config.ChannelAccount{Config: map[string]any{
|
||||
"transport": "http",
|
||||
"mode": "server",
|
||||
"listen_addr": "127.0.0.1:0",
|
||||
"report_path": "/onebot/report",
|
||||
}}
|
||||
built, err := buildAccountAdapter("qq-obv11", acct, log)
|
||||
if err != nil {
|
||||
t.Fatalf("buildAccountAdapter: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
if cerr := built.Connect(ctx); cerr != nil {
|
||||
t.Fatalf("Connect: %v", cerr)
|
||||
}
|
||||
t.Cleanup(func() { _ = built.Disconnect(context.Background()) })
|
||||
|
||||
post := "http://" + qqObv11ListenAddr(t, built) + "/onebot/report"
|
||||
resp, perr := http.Post(post, "application/json", strings.NewReader(qqObv11WiringFrame))
|
||||
if perr != nil {
|
||||
t.Fatalf("POST %s: %v", post, perr)
|
||||
}
|
||||
if cerr := resp.Body.Close(); cerr != nil {
|
||||
t.Fatalf("close response body: %v", cerr)
|
||||
}
|
||||
|
||||
if got := buf.String(); !strings.Contains(got, `msg="inbound frame"`) {
|
||||
t.Fatalf("注入的 logger 没收到入站帧日志——组合根没把 logger 接进 qq-obv11 适配器?\n捕获到的日志:\n%s", got)
|
||||
}
|
||||
if got := buf.String(); !strings.Contains(got, "source=plugins/channels/qq_obv11") {
|
||||
t.Errorf("入站帧日志缺来源标注:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// qqObv11ListenAddr 取出适配器实际绑定的监听地址(listen_addr 用 :0 让内核挑端口)。
|
||||
func qqObv11ListenAddr(t *testing.T, built adapter.PlatformAdapter) string {
|
||||
t.Helper()
|
||||
named, ok := built.(*namedAdapter)
|
||||
if !ok {
|
||||
t.Fatalf("适配器类型 = %T, want *namedAdapter", built)
|
||||
}
|
||||
inner, ok := named.PlatformAdapter.(*qq_obv11.Adapter)
|
||||
if !ok {
|
||||
t.Fatalf("内层适配器类型 = %T, want *qq_obv11.Adapter", named.PlatformAdapter)
|
||||
}
|
||||
addr := inner.Addr()
|
||||
if addr == "" {
|
||||
t.Fatal("适配器没绑定监听地址:http+server 端没起来")
|
||||
}
|
||||
return addr
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
func testLog() *slog.Logger {
|
||||
return logger.New(logger.Options{Out: io.Discard})
|
||||
}
|
||||
|
||||
// testFactory builds a channelFactory pointing at a temp (absent) channels.json,
|
||||
// seeded by the env fallback, so the test reads the env the same way boot does.
|
||||
func testFactory(t *testing.T) *channelFactory {
|
||||
t.Helper()
|
||||
return &channelFactory{
|
||||
path: filepath.Join(t.TempDir(), "channels.json"),
|
||||
fallback: channelAccountsFromEnv(),
|
||||
log: testLog(),
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildChannelAdapters_IncludesKook(t *testing.T) {
|
||||
t.Setenv("CHANNEL_KOOK_TOKEN", "kook-secret")
|
||||
adapters := buildChannelAdapters(testFactory(t))
|
||||
if !hasAdapter(adapters, "kook") {
|
||||
t.Fatalf("expected a kook adapter, got %v", names(adapters))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildChannelAdapters_KookMultiAccount(t *testing.T) {
|
||||
t.Setenv("CHANNELS_JSON", `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret"},"allowUsers":["u-1"],"denyGroups":["g-9"]}]}`)
|
||||
adapters := buildChannelAdapters(testFactory(t))
|
||||
if !hasAdapter(adapters, "kook:conf0") {
|
||||
t.Fatalf("expected a kook:conf0 adapter, got %v", names(adapters))
|
||||
}
|
||||
// The named adapter must expose its allow/deny lists via Config().
|
||||
for _, a := range adapters {
|
||||
if a.Name() == "kook:conf0" {
|
||||
cfg := a.Config()
|
||||
if got, _ := cfg["allowUsers"].([]string); len(got) != 1 || got[0] != "u-1" {
|
||||
t.Errorf("allowUsers = %v, want [u-1]", cfg["allowUsers"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildChannelAdapters_SkipsEmptyTokenKookFromFile verifies a stale
|
||||
// channels.json with both a bare empty-token kook and a real token account builds
|
||||
// only the token account, so the bare "kook" never registers (and never wins a
|
||||
// bare-name open).
|
||||
func TestBuildChannelAdapters_SkipsEmptyTokenKookFromFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "channels.json")
|
||||
raw := `{"kook":[
|
||||
{"id":"","config":{"base_url":"","bot_token":""}},
|
||||
{"id":"3fc8","config":{"bot_token":"real-token"}}
|
||||
]}`
|
||||
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
f := &channelFactory{path: path, fallback: channelAccountsFromEnv(), log: testLog()}
|
||||
adapters := buildChannelAdapters(f)
|
||||
if hasAdapter(adapters, "kook") {
|
||||
t.Fatalf("bare empty-token kook should not be built, got %v", names(adapters))
|
||||
}
|
||||
if !hasAdapter(adapters, "kook:3fc8") {
|
||||
t.Fatalf("expected kook:3fc8, got %v", names(adapters))
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelAccountsFromEnv_NoEmptyTokenPlaceholders verifies the env fallback
|
||||
// does not synthesize an empty-token kook/telegram/weixin account (only a real
|
||||
// token produces the account), so a bare placeholder is never registered.
|
||||
func TestChannelAccountsFromEnv_NoEmptyTokenPlaceholders(t *testing.T) {
|
||||
for _, k := range []string{"CHANNEL_KOOK_TOKEN", "CHANNEL_TELEGRAM_BOT_TOKEN", "CHANNEL_WEIXIN_ILINK_BOT_TOKEN", "CHANNELS_JSON"} {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
env := channelAccountsFromEnv()
|
||||
for _, platform := range []string{"kook", "telegram", "weixin-ilink"} {
|
||||
if _, ok := env[platform]; ok {
|
||||
t.Fatalf("empty-token %s should not be synthesized", platform)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelAccountsFromEnv_KookPresentWhenTokenSet verifies a set token yields
|
||||
// the account.
|
||||
func TestChannelAccountsFromEnv_KookPresentWhenTokenSet(t *testing.T) {
|
||||
t.Setenv("CHANNEL_KOOK_TOKEN", "kook-secret")
|
||||
env := channelAccountsFromEnv()
|
||||
list, ok := env["kook"]
|
||||
if !ok || len(list) != 1 {
|
||||
t.Fatalf("expected a synthesized kook account, got %+v", env["kook"])
|
||||
}
|
||||
if list[0].Config["bot_token"] != "kook-secret" {
|
||||
t.Fatalf("bot_token = %v, want kook-secret", list[0].Config["bot_token"])
|
||||
}
|
||||
}
|
||||
|
||||
func hasAdapter(adapters []adapter.PlatformAdapter, name string) bool {
|
||||
for _, a := range adapters {
|
||||
if a.Name() == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestChannelFactoryBuildFromFile exercises the reload factory against a real
|
||||
// channels.json file: the factory reads the file (file beats env) and builds a
|
||||
// named adapter carrying the account's token and black-or-white lists, so a panel
|
||||
// save → Reload path uses exactly this builder.
|
||||
func TestChannelFactoryBuildFromFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "channels.json")
|
||||
if err := os.WriteFile(path, []byte(`{"kook":[{"id":"conf0","config":{"bot_token":"bt"},"denyGroups":["g-1"],"allowUsers":["u-1"],"groupMsgPerMin":3,"directMsgPerMin":-1}]}`), 0o600); err != nil {
|
||||
t.Fatalf("write channels.json: %v", err)
|
||||
}
|
||||
f := &channelFactory{path: path, fallback: channelAccountsFromEnv(), log: testLog()}
|
||||
adpts, err := f.Build("kook")
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
if !hasAdapter(adpts, "kook:conf0") {
|
||||
t.Fatalf("kook:conf0 not built, got %v", names(adpts))
|
||||
}
|
||||
for _, a := range adpts {
|
||||
if a.Name() != "kook:conf0" {
|
||||
continue
|
||||
}
|
||||
cfg := a.Config()
|
||||
if cfg["bot_token"] != "bt" {
|
||||
t.Errorf("bot_token = %v, want bt", cfg["bot_token"])
|
||||
}
|
||||
if got, _ := cfg["denyGroups"].([]string); len(got) != 1 || got[0] != "g-1" {
|
||||
t.Errorf("denyGroups = %v, want [g-1]", cfg["denyGroups"])
|
||||
}
|
||||
if got, _ := cfg["allowUsers"].([]string); len(got) != 1 || got[0] != "u-1" {
|
||||
t.Errorf("allowUsers = %v, want [u-1]", cfg["allowUsers"])
|
||||
}
|
||||
if got, _ := cfg["groupMsgPerMin"].(int); got != 3 {
|
||||
t.Errorf("groupMsgPerMin = %v, want 3", cfg["groupMsgPerMin"])
|
||||
}
|
||||
// directMsgPerMin 同一条链:channels.json 的账号顶层字段 → namedAdapter.Config() →
|
||||
// 桥的 gateOptions。断桥这一环就等于「面板/文件配了却没人读」(负值必须原样透出)。
|
||||
if got, _ := cfg["directMsgPerMin"].(int); got != -1 {
|
||||
t.Errorf("directMsgPerMin = %v, want -1", cfg["directMsgPerMin"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNamedAdapterForwardsWebhook verifies the production wrapper forwards a
|
||||
// kook webhook-mode adapter's InboundHandler/WebhookPath to the channel bridge,
|
||||
// so bridge.WebhookHandlers() mounts the KOOK callback route on the gateway. The
|
||||
// webhook business fields (receive_mode/webhook_path) are sourced from
|
||||
// channels.json — .env only carries the necessary connection fallback.
|
||||
func TestNamedAdapterForwardsWebhook(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "channels.json")
|
||||
raw := `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret","receive_mode":"webhook","webhook_path":"/webhook/kook"}}]}`
|
||||
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
|
||||
t.Fatalf("write channels.json: %v", err)
|
||||
}
|
||||
f := &channelFactory{path: path, fallback: channelAccountsFromEnv(), log: testLog()}
|
||||
adpts, err := f.Build("kook")
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
var found *namedAdapter
|
||||
for _, a := range adpts {
|
||||
if nwa, ok := a.(*namedAdapter); ok && barePlatform(nwa.Name()) == "kook" {
|
||||
found = nwa
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("kook named adapter not found, got %v", names(adpts))
|
||||
}
|
||||
if p := found.WebhookPath(); p != "/webhook/kook" {
|
||||
t.Errorf("WebhookPath = %q, want /webhook/kook", p)
|
||||
}
|
||||
if found.InboundHandler() == nil {
|
||||
t.Error("InboundHandler() = nil, want a mounted handler")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNamedAdapterNoWebhookInWSMode verifies a ws-mode adapter (all non-webhook
|
||||
// platforms) does not surface a webhook handler through the wrapper.
|
||||
func TestNamedAdapterNoWebhookInWSMode(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "channels.json")
|
||||
raw := `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret","receive_mode":"ws"}}]}`
|
||||
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
|
||||
t.Fatalf("write channels.json: %v", err)
|
||||
}
|
||||
f := &channelFactory{path: path, fallback: channelAccountsFromEnv(), log: testLog()}
|
||||
adpts, err := f.Build("kook")
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
for _, a := range adpts {
|
||||
if barePlatform(a.Name()) != "kook" {
|
||||
continue
|
||||
}
|
||||
if nwa, ok := a.(*namedAdapter); ok {
|
||||
if p := nwa.WebhookPath(); p != "" {
|
||||
t.Errorf("ws-mode kook WebhookPath = %q, want empty", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func names(adapters []adapter.PlatformAdapter) []string {
|
||||
out := make([]string, 0, len(adapters))
|
||||
for _, a := range adapters {
|
||||
out = append(out, a.Name())
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
)
|
||||
|
||||
// 本文件把 persona 情绪状态机(design/01 §2:情绪状态机 + 好感度曲线跟随
|
||||
// persona/角色,绝不跟随行为模式)接到 agent 循环的轮次边界上。循环保持与情绪无关:
|
||||
// 它上报每个已结算轮次,并把返回的心情标签带到 Result.Mood,由持久化层写到助手消息上
|
||||
// (design/16 §3.3)。
|
||||
//
|
||||
// 本里程碑(A1)端到端接通的范围:
|
||||
// - 每个已结算的主人轮次推进所绑定 persona 的情绪状态机与好感度(内存态,按 persona
|
||||
// 为键,由 registry 的 emotion Manager 持有);
|
||||
// - 由此得到的心情随助手消息进入 chat_messages 与 `message_persisted` 帧,而每次
|
||||
// *变化* 都作为 `mood` 帧广播。
|
||||
//
|
||||
// 未接通(design/01 未作规定):心情尚未反注入 system 提示,因此不会左右回复文本;
|
||||
// 情绪状态也不跨重启持久化(design/01 两者都不要求;启动时状态重置为 persona 配置的
|
||||
// initialState)。
|
||||
|
||||
// moodEmitter 是面向 gateway 的 `mood` 广播出口。*gateway.Server 实现它
|
||||
// (BroadcastMood);这里持有接口是为了让组合根能用假实现做单元测试,与
|
||||
// capacityEmitter 一致。
|
||||
type moodEmitter interface {
|
||||
BroadcastMood(personaID, mood string, affection int)
|
||||
}
|
||||
|
||||
// buildEmotionObserver 装配 persona 情绪观察者:registry 的 emotion Manager 提供
|
||||
// 每个 persona 的状态机与好感度,registry 提供 persona 配置的好感度步长
|
||||
// (好感度曲线),每次变化都经 publishMood 推送到 Web UI。
|
||||
func (a *app) buildEmotionObserver() *emotion.Observer {
|
||||
return emotion.NewObserver(a.personas.Emotions(), a.personas.AffectionStep, a.publishMood)
|
||||
}
|
||||
|
||||
// onTurnEnd 是循环的轮次边界钩子(design/01 §2):已结算的轮次被施加到循环所绑定的
|
||||
// persona 上,并把该轮之后的心情返回给助手消息。persona id 缺失时退回默认 persona,
|
||||
// 与循环的绑定方式一致。
|
||||
//
|
||||
// 两道闸门确保只有真实对话轮次才触发:
|
||||
// - 只有面向用户的对话车道(Web 主会话及其子会话;IM 主人私聊走主会话,
|
||||
// design/16 §4.4)会推动心情。自主与委派的循环调用 —— 后台思考(sess-backthink)、
|
||||
// 子代理工作、隔离的 IM 会话 —— 不是主人对话的轮次,因此不得改写她的心情;
|
||||
// - 受限(群聊/陌生人)对话是只读的:观察者拒绝写入,因此第三方永远无法左右她的
|
||||
// 心情或好感度(design/02 §3、design/11 §2)。
|
||||
func (a *app) onTurnEnd(end loop.TurnEnd) string {
|
||||
if a.emotion == nil || !isConversationLane(end.SessionID) {
|
||||
return ""
|
||||
}
|
||||
personaID := end.PersonaID
|
||||
if personaID == "" {
|
||||
personaID = a.defaultPersonaID()
|
||||
}
|
||||
return string(a.emotion.Observe(personaID, end.UserText, !end.Env.Restricted()))
|
||||
}
|
||||
|
||||
// isConversationLane 报告某会话是否属于主人的实时对话:固定的主会话 "web" 及其
|
||||
// "web:<uuid>" 子会话(design/16 §2.1 —— IM 主人私聊被路由进主会话)。
|
||||
func isConversationLane(sessionID string) bool {
|
||||
return sessionID == history.MainSessionID || strings.HasPrefix(sessionID, history.MainSessionID+":")
|
||||
}
|
||||
|
||||
// publishMood 把一次情绪变化作为 `mood` 帧广播(design/07 §3、design/01 §2),
|
||||
// 使 Web 的心情徽标无需刷新即可跟随 persona。它只在心情确实变化时调用,因此该帧
|
||||
// 是新闻而不是每轮噪声。出口在调用时读取,因为 gateway 构建在循环之后;它还不存在
|
||||
// 时该变化只记日志并丢弃。
|
||||
func (a *app) publishMood(update emotion.Update) {
|
||||
log := logger.WithSource(a.log, "cmd/cyrene")
|
||||
if a.moodSink == nil {
|
||||
log.Debug("mood change dropped: gateway not ready",
|
||||
"persona", update.PersonaID, "from", string(update.Before), "to", string(update.After),
|
||||
"affection", update.Affection, "signal", update.Signal)
|
||||
return
|
||||
}
|
||||
log.Info("persona mood changed",
|
||||
"persona", update.PersonaID, "from", string(update.Before), "to", string(update.After),
|
||||
"affection", update.Affection, "signal", update.Signal)
|
||||
a.moodSink.BroadcastMood(update.PersonaID, string(update.After), update.Affection)
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
|
||||
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// fakeMoodEmitter captures the `mood` broadcasts the composition root forwards,
|
||||
// so tests can assert an emotion change actually reaches the Web UI.
|
||||
type fakeMoodEmitter struct {
|
||||
calls int
|
||||
personaID string
|
||||
mood string
|
||||
affection int
|
||||
}
|
||||
|
||||
func (f *fakeMoodEmitter) BroadcastMood(personaID, mood string, affection int) {
|
||||
f.calls++
|
||||
f.personaID = personaID
|
||||
f.mood = mood
|
||||
f.affection = affection
|
||||
}
|
||||
|
||||
// moodAdapter is a model.Adapter returning fixed text, so a test can drive a real
|
||||
// loop turn without a provider.
|
||||
type moodAdapter struct{ reply string }
|
||||
|
||||
func (m moodAdapter) Name() string { return "mood-test" }
|
||||
|
||||
func (m moodAdapter) Chat(context.Context, model.Request, model.Options) (model.Response, error) {
|
||||
return model.Response{Content: m.reply}, nil
|
||||
}
|
||||
|
||||
func (m moodAdapter) ChatStream(ctx context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) {
|
||||
return m.Chat(ctx, req, model.Options{})
|
||||
}
|
||||
|
||||
// testEmotionApp builds an app with one registered persona (emotion calm, decay
|
||||
// enabled, affection 50/700 step 10) and the emotion observer wired exactly as
|
||||
// the composition root wires it, plus a capturing mood sink.
|
||||
func testEmotionApp(t *testing.T) (*app, *fakeMoodEmitter) {
|
||||
t.Helper()
|
||||
reg := personareg.New()
|
||||
persona := &loader.Persona{
|
||||
ID: "cyrene",
|
||||
Name: "昔涟",
|
||||
Emotion: loader.EmotionParams{
|
||||
InitialState: emotion.Calm,
|
||||
DecayRate: 0.5,
|
||||
},
|
||||
Affection: loader.AffectionParams{Initial: 50, Min: 0, Max: 700, Delta: 10},
|
||||
}
|
||||
if err := reg.RegisterPersona(persona); err != nil {
|
||||
t.Fatalf("RegisterPersona: %v", err)
|
||||
}
|
||||
if err := reg.RegisterCharacter(&personareg.Character{ID: "cyrene", Name: "昔涟", Default: true, PersonaID: "cyrene"}); err != nil {
|
||||
t.Fatalf("RegisterCharacter: %v", err)
|
||||
}
|
||||
|
||||
sink := &fakeMoodEmitter{}
|
||||
a := &app{log: testLog(), personas: reg, moodSink: sink}
|
||||
a.emotion = a.buildEmotionObserver()
|
||||
return a, sink
|
||||
}
|
||||
|
||||
// testMoodLoop builds a real loop whose turn boundary is the app's hook.
|
||||
func testMoodLoop(t *testing.T, a *app, reply string) *loop.Loop {
|
||||
t.Helper()
|
||||
ag, err := agent.New("你是昔涟。", nil, nil, agent.WithModel(moodAdapter{reply: reply}, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("agent.New: %v", err)
|
||||
}
|
||||
return loop.New(loop.Options{Agent: ag, PersonaID: "cyrene", OnTurnEnd: a.onTurnEnd})
|
||||
}
|
||||
|
||||
// TestOnTurnEndDrivesEmotionThroughLoop is the A1 end-to-end lock: a real loop
|
||||
// turn is applied to the bound persona's emotion machine, the mood lands on
|
||||
// Result.Mood (which the persistence layer writes onto the assistant message),
|
||||
// affection steps by the persona's configured magnitude, and the change is
|
||||
// broadcast once as a `mood` frame.
|
||||
func TestOnTurnEndDrivesEmotionThroughLoop(t *testing.T) {
|
||||
a, sink := testEmotionApp(t)
|
||||
l := testMoodLoop(t, a, "人家也开心♪")
|
||||
|
||||
res, err := l.Run(loop.WithSessionEnv(context.Background(), loop.OwnerEnv()), "web",
|
||||
[]model.Message{model.TextMessage(model.RoleUser, "今天谢谢你呀,好开心")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.Mood != string(emotion.Happy) {
|
||||
t.Fatalf("Result.Mood = %q, want happy", res.Mood)
|
||||
}
|
||||
machine, affection, ok := a.personas.Emotions().Get("cyrene")
|
||||
if !ok {
|
||||
t.Fatal("persona cyrene not registered in the emotion manager")
|
||||
}
|
||||
if machine.Current() != emotion.Happy {
|
||||
t.Fatalf("machine state = %s, want happy", machine.Current())
|
||||
}
|
||||
if affection.Value() != 60 {
|
||||
t.Fatalf("affection = %d, want 60 (50 + the persona's step 10)", affection.Value())
|
||||
}
|
||||
if sink.calls != 1 || sink.mood != string(emotion.Happy) || sink.personaID != "cyrene" || sink.affection != 60 {
|
||||
t.Fatalf("mood broadcast = %+v, want one happy/cyrene/60 frame", sink)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnTurnEndEvolvesAndDecaysAcrossTurns locks how the emotion walks over a
|
||||
// conversation: each emotional turn moves one legal edge toward the state the cue
|
||||
// aims at (so a strong warm cue reaches excited on the second turn, not the
|
||||
// first), a cue-free turn lets it decay one step toward calm (design/01 §2
|
||||
// decayRate), and each real move is broadcast once while an unchanged mood
|
||||
// broadcasts nothing.
|
||||
func TestOnTurnEndEvolvesAndDecaysAcrossTurns(t *testing.T) {
|
||||
a, sink := testEmotionApp(t)
|
||||
l := testMoodLoop(t, a, "嗯")
|
||||
ctx := loop.WithSessionEnv(context.Background(), loop.OwnerEnv())
|
||||
|
||||
res, err := l.Run(ctx, "web", []model.Message{model.TextMessage(model.RoleUser, "我太开心了,最喜欢你了")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.Mood != string(emotion.Happy) {
|
||||
t.Fatalf("first strong warm turn mood = %q, want the first legal step happy", res.Mood)
|
||||
}
|
||||
// A second strong warm turn escalates to excited.
|
||||
res, err = l.Run(ctx, "web", []model.Message{model.TextMessage(model.RoleUser, "最喜欢你了")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.Mood != string(emotion.Excited) {
|
||||
t.Fatalf("second strong warm turn mood = %q, want excited", res.Mood)
|
||||
}
|
||||
if sink.calls != 2 || sink.mood != string(emotion.Excited) {
|
||||
t.Fatalf("broadcasts = %d/%s, want 2 with excited", sink.calls, sink.mood)
|
||||
}
|
||||
// Same mood again: no change, so no third frame.
|
||||
res, err = l.Run(ctx, "web", []model.Message{model.TextMessage(model.RoleUser, "还是最喜欢你")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.Mood != string(emotion.Excited) {
|
||||
t.Fatalf("third warm turn mood = %q, want excited unchanged", res.Mood)
|
||||
}
|
||||
if sink.calls != 2 {
|
||||
t.Fatalf("broadcasts = %d, want 2 (unchanged mood is not news)", sink.calls)
|
||||
}
|
||||
// A cue-free turn carries no signal, so the emotion decays excited → happy.
|
||||
res, err = l.Run(ctx, "web", []model.Message{model.TextMessage(model.RoleUser, "帮我看下这个报错")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.Mood != string(emotion.Happy) {
|
||||
t.Fatalf("neutral turn mood = %q, want decayed happy", res.Mood)
|
||||
}
|
||||
if sink.calls != 3 || sink.mood != string(emotion.Happy) {
|
||||
t.Fatalf("broadcasts = %d/%s, want 3 with happy", sink.calls, sink.mood)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnTurnEndRestrictedConversationIsReadOnly locks the isolation boundary at
|
||||
// the wiring level: a group/stranger turn never moves the persona's mood or
|
||||
// affection and never broadcasts, matching the read-only memory lane (design/02
|
||||
// §3, design/11 §2).
|
||||
func TestOnTurnEndRestrictedConversationIsReadOnly(t *testing.T) {
|
||||
a, sink := testEmotionApp(t)
|
||||
l := testMoodLoop(t, a, "你好呀")
|
||||
restricted := loop.SessionEnv{MemWritable: false}
|
||||
ctx := loop.WithSessionEnv(context.Background(), restricted)
|
||||
|
||||
res, err := l.Run(ctx, history.MainSessionID, []model.Message{model.TextMessage(model.RoleUser, "我好喜欢你")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.Mood != "" {
|
||||
t.Fatalf("Result.Mood = %q, want empty for a read-only conversation", res.Mood)
|
||||
}
|
||||
machine, affection, _ := a.personas.Emotions().Get("cyrene")
|
||||
if machine.Current() != emotion.Calm || affection.Value() != 50 {
|
||||
t.Fatalf("restricted turn moved the persona to %s/%d, want calm/50", machine.Current(), affection.Value())
|
||||
}
|
||||
if sink.calls != 0 {
|
||||
t.Fatalf("broadcasts = %d, want 0 for a restricted conversation", sink.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnTurnEndFallsBackToDefaultPersona locks the persona binding: a loop bound
|
||||
// without an explicit persona id advances the library's default persona instead
|
||||
// of silently tracking nothing.
|
||||
func TestOnTurnEndFallsBackToDefaultPersona(t *testing.T) {
|
||||
a, sink := testEmotionApp(t)
|
||||
|
||||
if got := a.onTurnEnd(loop.TurnEnd{SessionID: "web", UserText: "谢谢你", Env: loop.OwnerEnv()}); got != string(emotion.Happy) {
|
||||
t.Fatalf("mood = %q, want happy on the default persona", got)
|
||||
}
|
||||
if sink.personaID != "cyrene" {
|
||||
t.Fatalf("broadcast persona = %q, want cyrene", sink.personaID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnTurnEndSkipsNonConversationLanes locks the lane gate: only the owner's
|
||||
// conversation (main session and its sub-sessions) moves the persona's mood.
|
||||
// Autonomous / delegated loop calls — background thinking, sub-agent work,
|
||||
// isolated IM sessions — are not conversation turns and must leave her state
|
||||
// alone.
|
||||
func TestOnTurnEndSkipsNonConversationLanes(t *testing.T) {
|
||||
a, sink := testEmotionApp(t)
|
||||
|
||||
for _, sessionID := range []string{"sess-backthink", "sess-group-abc", "task-42", ""} {
|
||||
if got := a.onTurnEnd(loop.TurnEnd{SessionID: sessionID, UserText: "我好喜欢你", Env: loop.OwnerEnv()}); got != "" {
|
||||
t.Fatalf("mood for session %q = %q, want empty", sessionID, got)
|
||||
}
|
||||
}
|
||||
machine, affection, _ := a.personas.Emotions().Get("cyrene")
|
||||
if machine.Current() != emotion.Calm || affection.Value() != 50 {
|
||||
t.Fatalf("non-conversation lane moved the persona to %s/%d, want calm/50", machine.Current(), affection.Value())
|
||||
}
|
||||
if sink.calls != 0 {
|
||||
t.Fatalf("broadcasts = %d, want 0", sink.calls)
|
||||
}
|
||||
// The main session and its sub-sessions are the conversation lane.
|
||||
for _, sessionID := range []string{history.MainSessionID, history.MainSessionID + ":abc"} {
|
||||
if got := a.onTurnEnd(loop.TurnEnd{SessionID: sessionID, UserText: "谢谢你", Env: loop.OwnerEnv()}); got != string(emotion.Happy) {
|
||||
t.Fatalf("mood for session %q = %q, want happy", sessionID, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnTurnEndNilDependenciesNoPanic locks the degraded boots: no observer and
|
||||
// no gateway sink must not panic (the gateway is built after the loop).
|
||||
func TestOnTurnEndNilDependenciesNoPanic(t *testing.T) {
|
||||
empty := &app{log: testLog()}
|
||||
if got := empty.onTurnEnd(loop.TurnEnd{UserText: "我爱你", Env: loop.OwnerEnv()}); got != "" {
|
||||
t.Fatalf("mood without an observer = %q, want empty", got)
|
||||
}
|
||||
empty.publishMood(emotion.Update{PersonaID: "cyrene", Before: emotion.Calm, After: emotion.Happy})
|
||||
|
||||
a, _ := testEmotionApp(t)
|
||||
a.moodSink = nil
|
||||
a.publishMood(emotion.Update{PersonaID: "cyrene", Before: emotion.Calm, After: emotion.Happy})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
// Wiring-level anti-drift test for the shared client enum table (design/07 §6).
|
||||
// The enums package test pins the platform table to the adapters' Name() values;
|
||||
// this one pins it to the composition root's factory — every platform the table
|
||||
// publishes must be one buildAccountAdapter actually knows how to build, so a
|
||||
// phantom entry (a platform listed for clients but never wired up) fails here.
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/contract/enums"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
)
|
||||
|
||||
// TestChannelFactoryBuildsEnumeratedPlatforms builds every adapter the published
|
||||
// platform table lists (other than the Web main-session source, which is not an
|
||||
// adapter) through the real factory and asserts the built adapter's Name()
|
||||
// resolves back to that platform. Adapter construction never dials out, so the
|
||||
// minimal configs below are enough.
|
||||
func TestChannelFactoryBuildsEnumeratedPlatforms(t *testing.T) {
|
||||
t.Parallel()
|
||||
log := logger.New(logger.Options{Out: io.Discard})
|
||||
minimalConfig := map[string]map[string]any{
|
||||
"kook": {"bot_token": "test"},
|
||||
"telegram": {"bot_token": "test"},
|
||||
}
|
||||
|
||||
for _, p := range enums.Platforms() {
|
||||
if p.ID == history.PlatformWeb {
|
||||
continue
|
||||
}
|
||||
acct := config.ChannelAccount{Config: minimalConfig[p.ID]}
|
||||
built, err := buildAccountAdapter(p.ID, acct, log)
|
||||
if err != nil {
|
||||
t.Errorf("platform %q is published to clients but the factory cannot build it: %v", p.ID, err)
|
||||
continue
|
||||
}
|
||||
if got := barePlatform(built.Name()); got != p.ID {
|
||||
t.Errorf("buildAccountAdapter(%q).Name() = %q, want %q", p.ID, got, p.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// memoryOwnerRoleID 是组合根写入提取出的记忆时所用的角色 id;主人是唯一会写入
|
||||
// 私有记忆的 actor(群聊/陌生人会话根本不提取,design/11 §2)。
|
||||
const memoryOwnerRoleID = "owner"
|
||||
|
||||
// buildMemoryExtractor 选择循环在每轮落定后运行的记忆提取器(design/03 §1
|
||||
// LLM 异步自动提取):
|
||||
//
|
||||
// - provider 栈存在可用的活动组时用 LLM 提取器,由模型去噪并去重真正值得记的内容;
|
||||
// - 否则用确定性的 ManualExtractor,使无 key 的部署仍能记录主人显式的「记住 X」
|
||||
// 指令,而不是每轮都让模型调用失败。
|
||||
//
|
||||
// 该提取器刻意不持有任何人设:一个进程现在会以多个角色作答(design/22 §4.2),
|
||||
// 若在此固化某个人设,就会把所有提取出的记忆都归到那一个角色下。循环在持久化时
|
||||
// 用产生该轮的角色逐条打标(loop.persistMemories)。
|
||||
//
|
||||
// 循环会把 LLM 提取器分离到后台 goroutine(ExtractAsync);见
|
||||
// internal/runtime/loop/memory.go。
|
||||
func (a *app) buildMemoryExtractor() memory.Extractor {
|
||||
if llmGroupUsable(a.llm) {
|
||||
a.log.Info("memory extractor: llm", "model", a.llm.Model(), "provider", a.llm.Provider())
|
||||
return memory.NewLLMExtractor(a.llm, "", memoryOwnerRoleID)
|
||||
}
|
||||
a.log.Warn("memory extractor: manual (no usable llm model group)")
|
||||
return memory.NewManualExtractor("", memoryOwnerRoleID)
|
||||
}
|
||||
|
||||
// llmGroupUsable 报告运行时栈的活动模型组是否真的可调用:模型与 base URL 是必需
|
||||
// 项,且要么配置了 API key,要么端点是本地/内网地址 —— 后者无 key 调用属正常情况
|
||||
// (自托管的 OpenAI 兼容服务)。
|
||||
//
|
||||
// 它刻意只做廉价的静态检查,绝不发探测请求:启动不该花掉一次模型调用。看起来可用
|
||||
// 但调用时才失败的组,只会记一条提取错误日志(提取失败从不致命)。
|
||||
func llmGroupUsable(llm *model.RuntimeLLM) bool {
|
||||
if llm == nil {
|
||||
return false
|
||||
}
|
||||
group := llm.Group()
|
||||
if strings.TrimSpace(group.Model) == "" || strings.TrimSpace(group.BaseURL) == "" {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(group.APIKey) != "" {
|
||||
return true
|
||||
}
|
||||
return keylessEndpoint(group.BaseURL)
|
||||
}
|
||||
|
||||
// keylessEndpoint 报告 baseURL 是否指向回环或内网主机 —— 这类地址上缺少 API key
|
||||
// 是预期情况,而非配置错误。
|
||||
func keylessEndpoint(baseURL string) bool {
|
||||
parsed, err := url.Parse(strings.TrimSpace(baseURL))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
return ip.IsLoopback() || ip.IsPrivate()
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
modelpkg "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// testRuntimeLLM builds a provider stack from env alone (the pointed-at
|
||||
// models.json does not exist), so a test controls the active group through the
|
||||
// flat LLM config exactly as a keyless .env boot would.
|
||||
func testRuntimeLLM(t *testing.T, env config.LLMConfig) *modelpkg.RuntimeLLM {
|
||||
t.Helper()
|
||||
return modelpkg.NewRuntimeLLM(filepath.Join(t.TempDir(), "models.json"), env, testLog())
|
||||
}
|
||||
|
||||
// TestBuildMemoryExtractor_LLMWhenCredentials proves a configured provider group
|
||||
// selects the LLM extractor (design/03 §1). The extractor is role-agnostic
|
||||
// (design/22 §4.2): it is built without a persona and the loop stamps the turn's
|
||||
// role onto each extracted item, so multi-role extraction cannot be filed under
|
||||
// the default role.
|
||||
func TestBuildMemoryExtractor_LLMWhenCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := &app{
|
||||
log: testLog(),
|
||||
llm: testRuntimeLLM(t, config.LLMConfig{
|
||||
BaseURL: "https://api.example.com/v1",
|
||||
APIKey: "sk-test",
|
||||
Model: "gpt-4o-mini",
|
||||
}),
|
||||
}
|
||||
if _, ok := a.buildMemoryExtractor().(*memory.LLMExtractor); !ok {
|
||||
t.Fatalf("extractor = %T, want *memory.LLMExtractor (credentials configured)", a.buildMemoryExtractor())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildMemoryExtractor_ManualWhenNoCredentials proves the keyless fallback: a
|
||||
// boot without an API key (and without a local endpoint) keeps the deterministic
|
||||
// ManualExtractor instead of firing a doomed model call on every turn.
|
||||
func TestBuildMemoryExtractor_ManualWhenNoCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := &app{log: testLog(), llm: testRuntimeLLM(t, config.LLMConfig{})}
|
||||
if _, ok := a.buildMemoryExtractor().(*memory.ManualExtractor); !ok {
|
||||
t.Fatalf("extractor = %T, want *memory.ManualExtractor (no usable llm group)", a.buildMemoryExtractor())
|
||||
}
|
||||
|
||||
bare := &app{log: testLog()}
|
||||
if _, ok := bare.buildMemoryExtractor().(*memory.ManualExtractor); !ok {
|
||||
t.Fatalf("extractor = %T, want *memory.ManualExtractor (nil provider stack)", bare.buildMemoryExtractor())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLLMGroupUsable_KeylessLocalEndpoint locks the ollama-style case: a local
|
||||
// OpenAI-compatible server needs no API key, so extraction stays LLM-backed.
|
||||
func TestLLMGroupUsable_KeylessLocalEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
env config.LLMConfig
|
||||
ready bool
|
||||
}{
|
||||
{name: "api key", env: config.LLMConfig{BaseURL: "https://api.example.com/v1", APIKey: "sk", Model: "m"}, ready: true},
|
||||
{name: "loopback keyless", env: config.LLMConfig{BaseURL: "http://127.0.0.1:11434/v1", Model: "llama3"}, ready: true},
|
||||
{name: "localhost keyless", env: config.LLMConfig{BaseURL: "http://localhost:1234/v1", Model: "m"}, ready: true},
|
||||
{name: "private keyless", env: config.LLMConfig{BaseURL: "http://192.168.1.20:11434/v1", Model: "m"}, ready: true},
|
||||
{name: "remote keyless", env: config.LLMConfig{BaseURL: "https://api.example.com/v1", Model: "m"}, ready: false},
|
||||
{name: "no model", env: config.LLMConfig{BaseURL: "https://api.example.com/v1", APIKey: "sk"}, ready: false},
|
||||
{name: "no base url", env: config.LLMConfig{APIKey: "sk", Model: "m"}, ready: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := llmGroupUsable(testRuntimeLLM(t, tc.env)); got != tc.ready {
|
||||
t.Fatalf("llmGroupUsable = %v, want %v", got, tc.ready)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// cyrene 命令是 Cyrene v2 的唯一二进制入口:它把全部子系统(配置、日志、数据库、
|
||||
// bus、人格、模型适配器、工具分发器、记忆栈、agent 循环、编排、执行、IoT、渠道
|
||||
// 会话与适配器)组装进同一个进程,在单端口(:8080)上同时提供网关与内嵌前端,
|
||||
// 并在收到终止信号时优雅退出。
|
||||
//
|
||||
// 构建:`go build -ldflags="-s -w" -trimpath -o cyrene ./cmd/cyrene/`。
|
||||
// 前端必须先构建,SPA 才能被内嵌:`cd frontend/web && npm run build`。
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
// 内嵌 IANA 时区数据库,让配置的 TZ(config.TZ,默认 "Asia/Shanghai")
|
||||
// 在任何平台都能解析:Windows 不自带系统 tzdata,Go 也刻意不捆绑,缺了它
|
||||
// 循环的时间注入在 Windows 上会静默失效(internal/runtime/loop/time.go 会记
|
||||
// "timezone load failed")。代价约 450 KB,换来二进制自包含。
|
||||
_ "time/tzdata"
|
||||
|
||||
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/platform"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/webui"
|
||||
)
|
||||
|
||||
// envPath 是启动时合并的 .env 文件(仅开发用;生产走 systemd/env_file)。
|
||||
// .env 不存在不算错误。
|
||||
const envPath = ".env"
|
||||
|
||||
// shutdownTimeout 是优雅退场的时长上界。
|
||||
const shutdownTimeout = 15 * time.Second
|
||||
|
||||
// externalDistCandidates 是前端覆盖目录的候选清单:按序检查,都不存在才回落到
|
||||
// 内嵌 dist;命中的第一个生效。路径相对进程工作目录。
|
||||
func externalDistCandidates() []string {
|
||||
return []string{
|
||||
filepath.Join("frontend", "web", "dist"),
|
||||
filepath.Join("frontend", "dist"),
|
||||
}
|
||||
}
|
||||
|
||||
// resolveFrontendFS 返回用于伺服 SPA 的文件系统:存在外部 dist 目录时用它
|
||||
// (部署期热覆盖),否则用内嵌 dist。
|
||||
func resolveFrontendFS(log *slog.Logger) fs.FS {
|
||||
for _, dir := range externalDistCandidates() {
|
||||
if info, err := os.Stat(dir); err == nil && info.IsDir() {
|
||||
log.Info("serving external frontend dist", "dir", dir)
|
||||
return os.DirFS(dir)
|
||||
}
|
||||
}
|
||||
log.Info("serving embedded frontend dist")
|
||||
return webui.Assets()
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "cyrene:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// run 启动应用、拉起全部服务与网关,然后阻塞等待终止信号或网关失败。
|
||||
func run() error {
|
||||
cfg := config.MustLoad(envPath)
|
||||
format := logger.FormatText
|
||||
if cfg.Env == "production" {
|
||||
format = logger.FormatJSON
|
||||
}
|
||||
logRing := logger.NewRingBuffer(logger.DefaultRingCapacity)
|
||||
log := logger.New(logger.Options{Level: cfg.LogLevel, Format: string(format), Ring: logRing})
|
||||
log = logger.WithSource(log, "main")
|
||||
|
||||
ctx, stop := platform.SignalContext()
|
||||
defer stop()
|
||||
|
||||
a, err := newApp(ctx, cfg, log)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 可观测性日志追尾:logger 的环形缓冲在上面(网关之前)就建好了,
|
||||
// 现在网关已存在,把它交给网关。
|
||||
wireGatewayLogBuffer(a, logRing)
|
||||
|
||||
if err := a.automation.Start(ctx); err != nil {
|
||||
return fmt.Errorf("start automations: %w", err)
|
||||
}
|
||||
a.backthink.Start(ctx)
|
||||
// 记忆整理(design/23 §4):低峰定期后台任务。它不做启动即跑 —— 只在
|
||||
// 安静时段起点开跑,因此进程重启不会触发一次全库扫描;配置关闭时只记
|
||||
// 一条日志并立即返回。
|
||||
if a.memoryTidy != nil {
|
||||
a.memoryTidy.Start(ctx)
|
||||
}
|
||||
// 入库前审查(design/23 §15.1 D3):有界后台队列,在 store 的写入路径上
|
||||
// 接住每条新记忆。启动即就绪(它不做「启动即跑」,只等写入侧投递);
|
||||
// 配置关闭时组合根根本没建它(nil),写入路径逐字与改动前一致。
|
||||
if a.memoryReview != nil {
|
||||
a.memoryReview.Start(ctx)
|
||||
}
|
||||
// 文档回收(切片 S7):启动期清扫一次 + 此后按 DOCUMENT_GC_INTERVAL 定时清扫。
|
||||
// 它不等这一次跑完就去拉网关(见 wire_document_gc.go 的取舍 1)。
|
||||
a.startDocumentGC(ctx)
|
||||
// 渠道事件留存维护(切片 E3+E4):保留期清扫一次 + 此后定时清扫,内存计数
|
||||
// 按 CHANNEL_EVENT_COUNTER_FLUSH 定时落盘(退出前最后一次落盘在循环里)。
|
||||
a.startChannelEvents(ctx)
|
||||
// 链路图留存维护(切片 F2):保留期清扫一次 + 此后按 FLOW_GRAPH_GC_INTERVAL 定时清扫。
|
||||
// 没有它,落盘的轮次图会**无限增长**(design/19 §4 P3 的 GC 口径)。
|
||||
a.startFlowGraphs(ctx)
|
||||
// LLM 用量落库与保留期清理(切片 U1):单写者 + 有界队列的异步写入器,
|
||||
// 加上按 LLM_USAGE_RETENTION 的过期样本清理。
|
||||
a.startUsage(ctx)
|
||||
a.log.Info("cyrene subsystems started",
|
||||
"personas", a.personas.CountPersonas(),
|
||||
"tools", len(a.caps.List(capreg.KindTool)),
|
||||
"channels", len(a.caps.List(capreg.KindChannel)))
|
||||
a.log.Info("cyrene ready", "data_dir", resolveDataDir(cfg))
|
||||
|
||||
errCh, err := a.gw.Start(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("start gateway: %w", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
a.log.Info("cyrene signal received, shutting down")
|
||||
case gwErr := <-errCh:
|
||||
if gwErr != nil {
|
||||
shutCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||
defer cancel()
|
||||
a.close(shutCtx)
|
||||
return fmt.Errorf("gateway failed: %w", gwErr)
|
||||
}
|
||||
}
|
||||
|
||||
shutCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||
defer cancel()
|
||||
a.close(shutCtx)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/mcp"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// mcpConnectTimeout 约束每个 MCP server 的连接+发现握手,使挂死的 server 降级为
|
||||
// 一条告警,而不是阻塞启动。
|
||||
const mcpConnectTimeout = 15 * time.Second
|
||||
|
||||
// wireMCP 读取 cfg.MCP.ServersJSON,连接每个已启用的 server,把发现到的工具注册进
|
||||
// 能力注册表(KindTool,使 agent 可调用它们),并暴露一个 KindMCP 能力,让管理面板
|
||||
// 的插件列表能枚举该 server。连接失败或未配置的 server 降级为一条告警,绝不阻塞
|
||||
// 启动。
|
||||
func (a *app) wireMCP() {
|
||||
log := logger.WithSource(a.log, "cmd/cyrene")
|
||||
servers, err := a.mcpServers()
|
||||
if err != nil {
|
||||
log.Warn("mcp config parse skipped", "error", err)
|
||||
return
|
||||
}
|
||||
if len(servers) == 0 {
|
||||
log.Debug("no mcp servers configured")
|
||||
return
|
||||
}
|
||||
for _, srv := range servers {
|
||||
if !srv.Enabled {
|
||||
continue
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), mcpConnectTimeout)
|
||||
err := a.registerMCPServer(ctx, srv)
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Warn("mcp server skipped", "name", srv.Name, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mcpServers 从 cfg.MCP.ServersJSON 解析出已配置的 MCP server。
|
||||
func (a *app) mcpServers() ([]config.MCPServer, error) {
|
||||
raw := strings.TrimSpace(a.cfg.MCP.ServersJSON)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var servers []config.MCPServer
|
||||
if err := json.Unmarshal([]byte(raw), &servers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
// registerMCPServer 连接单个 server,注册其工具,并以 "mcp:<name>" 发布一个
|
||||
// KindMCP 能力。它返回第一个错误以便调用方分类;注册中途失败时会清理已加入的内容。
|
||||
func (a *app) registerMCPServer(ctx context.Context, srv config.MCPServer) error {
|
||||
log := logger.WithSource(a.log, "cmd/cyrene")
|
||||
reg := mcp.NewRegistry(a.caps, mcp.MCPConfig{
|
||||
Name: srv.Name,
|
||||
Command: srv.Command,
|
||||
Args: srv.Args,
|
||||
URL: srv.URL,
|
||||
}, mcp.WithLogger(a.log))
|
||||
if err := reg.Register(ctx); err != nil {
|
||||
_ = reg.Unregister(context.Background())
|
||||
return err
|
||||
}
|
||||
if err := a.caps.Register(registry.Capability{
|
||||
Name: "mcp:" + srv.Name,
|
||||
Kind: registry.KindMCP,
|
||||
Description: "MCP server " + srv.Name,
|
||||
Value: reg,
|
||||
}); err != nil {
|
||||
log.Warn("mcp capability register skipped", "name", srv.Name, "error", err)
|
||||
}
|
||||
a.mcpRegs = append(a.mcpRegs, reg)
|
||||
log.Info("mcp server registered", "name", srv.Name)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// materializeModels 在进程启动时跑一次「`.env` 默认层 → models.json 补齐」。
|
||||
//
|
||||
// 语义(主人裁决,规则见 runtimeconfig.Materialize):`.env` 只是基础入口/播种层,
|
||||
// models.json(面板专用配置文件)才是一等来源。每次启动把 `.env` 的 LLM 块补进面板
|
||||
// 配置里**缺失**的键,面板已有的键(含显式空值)绝不覆盖,组集合完全由面板决定
|
||||
// (面板删掉的组不会因 `.env` 仍声明它而复活);面板配置缺失/为空时才由 `.env`
|
||||
// 播种出合成组(既有行为)。幂等,重复启动不再改动文件。
|
||||
//
|
||||
// 失败一律只记日志、**不阻断启动**:模型栈的既有兜底(LoadModels 回退 `.env`)仍然
|
||||
// 兜住,配置文件的一次写失败不该让服务起不来。
|
||||
func materializeModels(path string, env config.LLMConfig, log *slog.Logger) {
|
||||
res, err := runtimeconfig.Materialize(path, env, log)
|
||||
if err != nil {
|
||||
log.Warn("models.json 补齐失败(模型栈仍按现状运行)", "path", path, "error", err)
|
||||
return
|
||||
}
|
||||
if res.Warn != nil {
|
||||
log.Warn("models.json 补齐未完成(模型栈仍按现状运行)", "path", path, "error", res.Warn)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// 启动补齐接线(materializeModels)的契约:成功时面板专用配置文件在**模型栈构建
|
||||
// 之前**就位(NewRuntimeLLM 紧接着读它);任何失败都只记日志,**不阻断启动**。
|
||||
|
||||
func TestMaterializeModelsCreatesPanelFileAtBoot(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "models.json")
|
||||
env := config.LLMConfig{Provider: "openai", BaseURL: "https://x/v1", APIKey: "sk-x", Model: "m"}
|
||||
var buf bytes.Buffer
|
||||
materializeModels(path, env, slog.New(slog.NewJSONHandler(&buf, nil)))
|
||||
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("启动补齐应写出 models.json: %v", err)
|
||||
}
|
||||
groups, warn := runtimeconfig.LoadModels(path, env)
|
||||
if warn != nil {
|
||||
t.Fatalf("LoadModels: warn = %v", warn)
|
||||
}
|
||||
if len(groups) != 1 || groups[0].Model != "m" || groups[0].APIKey != "sk-x" {
|
||||
t.Fatalf("组 = %+v, want .env 播种的合成组", groups)
|
||||
}
|
||||
if strings.Contains(buf.String(), "sk-x") {
|
||||
t.Fatalf("启动日志泄漏了凭据: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterializeModelsNeverBlocksBoot(t *testing.T) {
|
||||
// 路径不可读/不可写时只记日志:模型栈的既有兜底(LoadModels 回退 .env)仍成立,
|
||||
// 配置文件的一次失败不该让服务起不来。
|
||||
var buf bytes.Buffer
|
||||
materializeModels(t.TempDir(), config.LLMConfig{Provider: "openai"}, slog.New(slog.NewJSONHandler(&buf, nil)))
|
||||
if !strings.Contains(buf.String(), "models.json 补齐") {
|
||||
t.Fatalf("失败时应有可诊断日志: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelsBootPathEndToEnd 复现主人的原始场景(去掉 DB/HTTP 接线,端口与 data/ 都不碰):
|
||||
// `.env` → 启动补齐 → 面板专用配置文件 → **真**模型栈(NewRuntimeLLM);面板改一次
|
||||
// (经真面板保存用的 runtimeconfig.SaveModels 写回),**再启动一次**不得被 `.env` 打回。
|
||||
func TestModelsBootPathEndToEnd(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "models.json")
|
||||
env := config.LLMConfig{Provider: "openai", BaseURL: "https://env.example/v1", APIKey: "sk-env", Model: "env-model"}
|
||||
log := slog.New(slog.NewJSONHandler(io.Discard, nil))
|
||||
|
||||
// 第一次启动:面板文件不存在 ⇒ 由 .env 播种,模型栈直接读到 env 的模型名。
|
||||
materializeModels(path, env, log)
|
||||
stack := model.NewRuntimeLLM(path, env, log)
|
||||
if got := stack.Model(); got != "env-model" {
|
||||
t.Fatalf("首次启动模型名 = %q, want env 的 env-model", got)
|
||||
}
|
||||
|
||||
// 面板把该组改成 panel-model(与面板保存端点同一条写路径),随后热重载。
|
||||
groups := stack.Groups()
|
||||
groups[0].Model = "panel-model"
|
||||
groups[0].APIKey = "sk-panel"
|
||||
if err := runtimeconfig.SaveModels(path, groups); err != nil {
|
||||
t.Fatalf("面板保存: %v", err)
|
||||
}
|
||||
|
||||
// 第二次启动:补齐只看**缺失**的键,面板的改动不得被打回 .env 值。
|
||||
materializeModels(path, env, log)
|
||||
restarted := model.NewRuntimeLLM(path, env, log)
|
||||
if got := restarted.Model(); got != "panel-model" {
|
||||
t.Fatalf("重启后模型名 = %q, want 面板改的 panel-model(被 .env 打回即失败)", got)
|
||||
}
|
||||
if got := restarted.Group().APIKey; got != "sk-panel" {
|
||||
t.Fatalf("重启后 apiKey = %q, want 面板的 sk-panel", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
|
||||
)
|
||||
|
||||
// newAppTraceWriter 构建组合根的 trace writer:JSONL stderr 记录器(每个 span 的
|
||||
// 本地结构化日志),外加为 gateway 可观测性出口预留的位置 —— 后者只能在 gateway
|
||||
// 存在之后注册(见 wireGatewayTraceSink)。
|
||||
func newAppTraceWriter() *traceFanout {
|
||||
return newTraceFanout(trace.NewJSONRecorder(os.Stderr))
|
||||
}
|
||||
|
||||
// wireGatewayTraceSink 把 gateway 的可观测性出口注册到应用的 trace 扇出上。它由
|
||||
// wireFrontend 调用 —— 那是 gateway 首次存在的时刻 —— 因为 gateway 构建在发出 span
|
||||
// 的循环之后。没有它,管理面板的 trace 环形缓冲区永远不会被喂数据,
|
||||
// GET /api/v1/admin/observability/trace 会永远返回空列表(design/19 §2)。在没有
|
||||
// tracer 或 gateway 的启动中是空操作。
|
||||
func wireGatewayTraceSink(a *app, gw *gateway.Server) {
|
||||
if a.traceWriter == nil || gw == nil {
|
||||
return
|
||||
}
|
||||
a.traceWriter.Add(gatewaySpanSink{srv: gw})
|
||||
a.log.Info("observability trace sink wired")
|
||||
}
|
||||
|
||||
// traceFanout 是组合根的 trace.Writer。它把每个已完成的 span 扇出到启动时安装的
|
||||
// 出口(JSONL stderr 记录器),以及启动流程中稍后注册的出口:gateway 的可观测性
|
||||
// 缓冲区要等 wireFrontend 构建好 gateway 才存在,而发出 span 的循环在 wireRuntime
|
||||
// 中更早就构建了。
|
||||
//
|
||||
// 它可安全并发使用 —— 循环会从多个 goroutine 结束 span(按步、按工具调用、子代理)。
|
||||
type traceFanout struct {
|
||||
mu sync.RWMutex
|
||||
writers []trace.Writer
|
||||
}
|
||||
|
||||
// newTraceFanout 在给定 writers 之上构建扇出。nil writer 会被丢弃。
|
||||
func newTraceFanout(writers ...trace.Writer) *traceFanout {
|
||||
f := &traceFanout{}
|
||||
for _, w := range writers {
|
||||
f.Add(w)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// Add 再安装一个出口。nil writer 被忽略。
|
||||
func (f *traceFanout) Add(w trace.Writer) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.writers = append(f.writers, w)
|
||||
}
|
||||
|
||||
// WriteSpan 把 rec 写给每个出口并返回第一个错误。即使失败也仍会尝试所有出口,
|
||||
// 因此单个坏掉的出口无法把 span 从其他出口那里藏起来;tracer 只记录返回的错误
|
||||
// (trace.Tracer.emit)。
|
||||
func (f *traceFanout) WriteSpan(rec trace.SpanRecord) error {
|
||||
f.mu.RLock()
|
||||
defer f.mu.RUnlock()
|
||||
var firstErr error
|
||||
for _, w := range f.writers {
|
||||
if w == nil {
|
||||
continue
|
||||
}
|
||||
if err := w.WriteSpan(rec); err != nil && firstErr == nil {
|
||||
firstErr = fmt.Errorf("trace fanout: %w", err)
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// wireGatewayLogBuffer 把组合根的日志环形缓冲区交给 gateway,使
|
||||
// GET /api/v1/admin/observability/logs 能提供最近的后端日志尾部。logger 构建在
|
||||
// gateway 之前(其记录必须从第一行日志起就被捕获),因此缓冲区在这里注入 ——
|
||||
// gateway 首次存在的时刻 —— 与 wireGatewayTraceSink 对 span 的做法一致。在没有
|
||||
// gateway 的启动中是空操作。
|
||||
func wireGatewayLogBuffer(a *app, buf *logger.RingBuffer) {
|
||||
if a == nil || a.gw == nil || buf == nil {
|
||||
return
|
||||
}
|
||||
a.gw.SetLogBuffer(buf)
|
||||
a.log.Info("observability log buffer wired", "capacity", buf.Len())
|
||||
}
|
||||
|
||||
// gatewaySpanSink 通过 *gateway.Server 的 ObserveSpan 入口把它适配成 trace.Writer,
|
||||
// 使发出的 span 落在管理面板的 trace 环形缓冲区**并且**计入 LLM/子代理指标计数器
|
||||
// (ObserveSpan 既追加到缓冲区,也观测指标;缓冲区正是
|
||||
// GET /api/v1/admin/observability/trace 所提供的内容)。
|
||||
type gatewaySpanSink struct{ srv *gateway.Server }
|
||||
|
||||
// WriteSpan 把 rec 喂给 gateway。server 缺失时是空操作。
|
||||
func (g gatewaySpanSink) WriteSpan(rec trace.SpanRecord) error {
|
||||
if g.srv == nil {
|
||||
return nil
|
||||
}
|
||||
g.srv.ObserveSpan(rec)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
|
||||
)
|
||||
|
||||
const obsTestSecret = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
// recordingWriter captures the spans a fanout hands it.
|
||||
type recordingWriter struct {
|
||||
spans []trace.SpanRecord
|
||||
}
|
||||
|
||||
func (r *recordingWriter) WriteSpan(rec trace.SpanRecord) error {
|
||||
r.spans = append(r.spans, rec)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestTraceFanout_LateSinkReceivesSpans proves the composition root can register
|
||||
// a sink after the loop's tracer is already running: spans finished before the
|
||||
// sink is added do not reach it, every span after does (the gateway is built
|
||||
// after the loop, so this ordering is what A4 depends on).
|
||||
func TestTraceFanout_LateSinkReceivesSpans(t *testing.T) {
|
||||
t.Parallel()
|
||||
early := &recordingWriter{}
|
||||
fanout := newTraceFanout(early)
|
||||
tracer := trace.New(fanout)
|
||||
|
||||
_, first := tracer.StartSpan(context.Background(), "before", trace.SpanKindStateTransition)
|
||||
first.Finish(trace.SpanStatusOK)
|
||||
|
||||
late := &recordingWriter{}
|
||||
fanout.Add(late)
|
||||
|
||||
_, second := tracer.StartSpan(context.Background(), "after", trace.SpanKindLLM)
|
||||
second.Finish(trace.SpanStatusOK)
|
||||
|
||||
if len(early.spans) != 2 {
|
||||
t.Fatalf("early sink spans = %d, want 2", len(early.spans))
|
||||
}
|
||||
if len(late.spans) != 1 || late.spans[0].ID != second.ID {
|
||||
t.Fatalf("late sink spans = %+v, want exactly the span %q", late.spans, second.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireGatewayTraceSink_Guards proves the boot helper tolerates a missing
|
||||
// tracer or gateway, so a degraded boot cannot panic on the observability path.
|
||||
func TestWireGatewayTraceSink_Guards(t *testing.T) {
|
||||
t.Parallel()
|
||||
wireGatewayTraceSink(&app{log: testLog()}, nil)
|
||||
a := &app{log: testLog(), traceWriter: newAppTraceWriter()}
|
||||
wireGatewayTraceSink(a, nil)
|
||||
if len(a.traceWriter.writers) != 1 {
|
||||
t.Fatalf("writers = %d, want 1 (the stderr recorder only)", len(a.traceWriter.writers))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGatewaySpanSink_FeedsTraceEndpointAndMetrics is the A4 acceptance: a span
|
||||
// emitted by the tracer is written through the gateway sink (registered by the
|
||||
// same helpers wire.go calls) and surfaces both in
|
||||
// GET /api/v1/admin/observability/trace (a real span, not an empty buffer) and in
|
||||
// the LLM metric counter.
|
||||
func TestGatewaySpanSink_FeedsTraceEndpointAndMetrics(t *testing.T) {
|
||||
t.Parallel()
|
||||
jwt := auth.New(config.AuthConfig{JWTSecret: obsTestSecret})
|
||||
gw, err := gateway.New(&config.Config{}, gateway.Deps{Log: testLog(), Auth: jwt})
|
||||
if err != nil {
|
||||
t.Fatalf("gateway.New: %v", err)
|
||||
}
|
||||
t.Cleanup(gw.Close)
|
||||
ts := httptest.NewServer(gw.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
// Exactly the boot path: the app's fanout is built in wireRuntime and the
|
||||
// gateway sink is registered in wireFrontend.
|
||||
a := &app{log: testLog(), traceWriter: newAppTraceWriter()}
|
||||
wireGatewayTraceSink(a, gw)
|
||||
tracer := trace.New(a.traceWriter)
|
||||
|
||||
ctx := context.Background()
|
||||
_, llmSpan := tracer.StartSpan(ctx, "llm.chat", trace.SpanKindLLM)
|
||||
llmSpan.Finish(trace.SpanStatusOK)
|
||||
_, stepSpan := tracer.StartSpan(ctx, "loop.step", trace.SpanKindStateTransition)
|
||||
_ = stepSpan.SetProp("session", "web")
|
||||
stepSpan.Finish(trace.SpanStatusOK)
|
||||
|
||||
tok, err := jwt.Sign("acct", auth.RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("sign token: %v", err)
|
||||
}
|
||||
|
||||
var traceBody struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
} `json:"data"`
|
||||
}
|
||||
status, raw := obsGet(t, ts.URL+"/api/v1/admin/observability/trace", tok)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("trace status = %d, want 200 (body=%s)", status, raw)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &traceBody); err != nil {
|
||||
t.Fatalf("decode trace: %v (body=%s)", err, raw)
|
||||
}
|
||||
seen := map[string]string{}
|
||||
for _, sp := range traceBody.Data {
|
||||
seen[sp.ID] = sp.Kind
|
||||
}
|
||||
if seen[stepSpan.ID] != string(trace.SpanKindStateTransition) {
|
||||
t.Fatalf("trace endpoint = %+v, want the loop.step span %q", traceBody.Data, stepSpan.ID)
|
||||
}
|
||||
if seen[llmSpan.ID] != string(trace.SpanKindLLM) {
|
||||
t.Fatalf("trace endpoint = %+v, want the llm span %q", traceBody.Data, llmSpan.ID)
|
||||
}
|
||||
|
||||
var metricsBody struct {
|
||||
Data struct {
|
||||
LLMCalls int64 `json:"llmCalls"`
|
||||
} `json:"data"`
|
||||
}
|
||||
status, raw = obsGet(t, ts.URL+"/api/v1/admin/observability/metrics", tok)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("metrics status = %d, want 200 (body=%s)", status, raw)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &metricsBody); err != nil {
|
||||
t.Fatalf("decode metrics: %v (body=%s)", err, raw)
|
||||
}
|
||||
if metricsBody.Data.LLMCalls != 1 {
|
||||
t.Fatalf("llmCalls = %d, want 1 (ObserveSpan counts the emitted llm span)", metricsBody.Data.LLMCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireGatewayLogBuffer_Guards proves the boot helper tolerates a missing
|
||||
// gateway or buffer, so a degraded boot cannot panic on the log-tail path.
|
||||
func TestWireGatewayLogBuffer_Guards(t *testing.T) {
|
||||
t.Parallel()
|
||||
wireGatewayLogBuffer(nil, nil)
|
||||
wireGatewayLogBuffer(&app{log: testLog()}, logger.NewRingBuffer(4))
|
||||
wireGatewayLogBuffer(&app{log: testLog()}, nil)
|
||||
|
||||
jwt := auth.New(config.AuthConfig{JWTSecret: obsTestSecret})
|
||||
gw, err := gateway.New(&config.Config{}, gateway.Deps{Log: testLog(), Auth: jwt})
|
||||
if err != nil {
|
||||
t.Fatalf("gateway.New: %v", err)
|
||||
}
|
||||
t.Cleanup(gw.Close)
|
||||
wireGatewayLogBuffer(&app{log: testLog(), gw: gw}, nil)
|
||||
}
|
||||
|
||||
// TestWireGatewayLogBuffer_FeedsLogEndpoint is the log-tail acceptance: the
|
||||
// process logger's ring buffer is handed to the gateway by the same helper run()
|
||||
// calls, and the records the app logged surface newest-first on
|
||||
// GET /api/v1/admin/observability/logs.
|
||||
func TestWireGatewayLogBuffer_FeedsLogEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
jwt := auth.New(config.AuthConfig{JWTSecret: obsTestSecret})
|
||||
gw, err := gateway.New(&config.Config{}, gateway.Deps{Log: testLog(), Auth: jwt})
|
||||
if err != nil {
|
||||
t.Fatalf("gateway.New: %v", err)
|
||||
}
|
||||
t.Cleanup(gw.Close)
|
||||
ts := httptest.NewServer(gw.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
// Exactly the boot path: run() builds the logger with the ring buffer before
|
||||
// newApp and hands it to the gateway afterwards.
|
||||
ring := logger.NewRingBuffer(8)
|
||||
log := logger.WithSource(logger.New(logger.Options{
|
||||
Level: "debug", Format: "text", Out: io.Discard, Ring: ring,
|
||||
}), "main")
|
||||
log.Info("boot ok")
|
||||
log.Warn("disk filling")
|
||||
|
||||
a := &app{log: testLog(), gw: gw}
|
||||
wireGatewayLogBuffer(a, ring)
|
||||
|
||||
tok, err := jwt.Sign("acct", auth.RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("sign token: %v", err)
|
||||
}
|
||||
var body struct {
|
||||
Data []struct {
|
||||
Time string `json:"time"`
|
||||
Level string `json:"level"`
|
||||
Source string `json:"source"`
|
||||
Msg string `json:"msg"`
|
||||
} `json:"data"`
|
||||
}
|
||||
status, raw := obsGet(t, ts.URL+"/api/v1/admin/observability/logs", tok)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("logs status = %d, want 200 (body=%s)", status, raw)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Fatalf("decode logs: %v (body=%s)", err, raw)
|
||||
}
|
||||
if len(body.Data) != 2 {
|
||||
t.Fatalf("logs = %+v, want the two records logged above", body.Data)
|
||||
}
|
||||
if body.Data[0].Msg != "disk filling" || body.Data[0].Level != "warn" || body.Data[0].Source != "main" {
|
||||
t.Fatalf("logs[0] = %+v, want the newest record (warn/disk filling/main)", body.Data[0])
|
||||
}
|
||||
if body.Data[1].Msg != "boot ok" || body.Data[1].Level != "info" {
|
||||
t.Fatalf("logs[1] = %+v, want the older info record", body.Data[1])
|
||||
}
|
||||
}
|
||||
|
||||
// obsGet performs an authenticated owner GET and returns the status plus the raw
|
||||
// body.
|
||||
func obsGet(t *testing.T, url, token string) (int, []byte) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", url, err)
|
||||
}
|
||||
return resp.StatusCode, raw
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
)
|
||||
|
||||
// 本文件构建按角色的 agent 池(design/22 §4.2 方案 A)。agent.Agent 的指令在构造
|
||||
// 之后不可变,因此「会话的角色决定 system 提示」被实现为每个角色一个 agent:它们
|
||||
// 共享同一个 dispatcher、guardrail 流水线、模型适配器与选项 —— 只有指令字符串不同。
|
||||
// 循环在每一步为当轮角色挑选池中条目(internal/runtime/loop/persona.go),因此角色
|
||||
// 切换在下一轮即生效,无需重建循环。
|
||||
//
|
||||
// design/22 §5 的要求:提示、记忆、情绪与 thinking 必须全部由同一个角色解析。
|
||||
// 池拥有提示,循环拥有另外三者(personaFor / knobsFor),因此一轮绝不会混用两个
|
||||
// 角色。
|
||||
|
||||
// personaAgentPool 返回按角色的 agent 池,以及用来跑轮次的 agent:池中有默认角色时
|
||||
// 用池里的那个,否则用调用方的兜底 agent,因此未注册任何角色的部署仍保持与多角色
|
||||
// 之前完全一致的单 agent 形态。
|
||||
func (a *app) personaAgentPool(llm *model.RuntimeLLM, dispatcher *tool.Dispatcher, guards *guardrail.Pipeline, fallback *agent.Agent) (map[string]*agent.Agent, *agent.Agent) {
|
||||
pool := a.buildPersonaAgents(llm, dispatcher, guards)
|
||||
if pooled, ok := pool[a.defaultPersonaID()]; ok {
|
||||
return pool, pooled
|
||||
}
|
||||
return pool, fallback
|
||||
}
|
||||
|
||||
// buildPersonaAgents 返回「角色 → agent」池:每个已注册角色对应的 persona 都得到
|
||||
// 一个携带该 persona system 指令的 agent(instructionsFromPersona,与默认 agent
|
||||
// 是同一个装配点 —— 见 personaInstructions),design/01 §2.2。未注册 persona 时
|
||||
// 返回空 map,循环便保持其单一默认 agent。
|
||||
//
|
||||
// 池里的指令是**静态**的,因此主人身份说明书不在其中:那段知识按会话注入
|
||||
// (loop.Options.PersonaKnowledgeFor),否则它会跟着每个角色进入群聊与
|
||||
// 陌生人私聊(design/11 §0 红线)。
|
||||
//
|
||||
// 只有通过已注册角色可达的 persona 才入池:角色才是可选单位(1 角色 ↔ 1 persona),
|
||||
// 因此没有角色的 persona 永远无法绑定到会话。绑定到未知 id 的会话于是退回默认
|
||||
// agent,而其记忆作用域仍跟随所绑定的 id —— 过期的绑定退化为默认提示,而不是让
|
||||
// 该轮失败。
|
||||
func (a *app) buildPersonaAgents(llm *model.RuntimeLLM, dispatcher *tool.Dispatcher, guards *guardrail.Pipeline) map[string]*agent.Agent {
|
||||
log := logger.WithSource(a.log, "cmd/cyrene")
|
||||
pool := make(map[string]*agent.Agent, a.personas.CountPersonas())
|
||||
for _, c := range a.personas.List() {
|
||||
if _, ok := pool[c.PersonaID]; ok {
|
||||
continue
|
||||
}
|
||||
p, ok := a.personas.Persona(c.PersonaID)
|
||||
if !ok || p == nil {
|
||||
continue
|
||||
}
|
||||
// 该角色的 agent 必须绑它自己的 persona 视图:否则 models.json 的 personaModels /
|
||||
// personaProviders(角色级模型/提供商路由)在生产里不会被消费,形同虚设。
|
||||
stack := llm.ForPersona(c.PersonaID)
|
||||
ag, err := agent.New(a.personaInstructions(p), dispatcher, guards,
|
||||
agent.WithModel(stack, stack),
|
||||
agent.WithOptions(agent.Options{MaxRounds: defaultMaxRounds, Purpose: "chat"}))
|
||||
if err != nil {
|
||||
log.Warn("persona agent skipped", "persona", c.PersonaID, "error", err)
|
||||
continue
|
||||
}
|
||||
pool[c.PersonaID] = ag
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
// personaExists 返回编排层用来校验被派发的子代理目标角色的解析器(design/22 §8-2)。
|
||||
// 某角色可派发,当且仅当池中有它的 agent,而这正是循环会用该角色指令作答的条件。
|
||||
// 其他任何情况 —— 未注册的 persona、没有角色的 persona、空池 —— 都会让派发以
|
||||
// orchestration.ErrUnknownPersona 失败,而不是悄悄按默认角色运行子代理。
|
||||
func personaExists(pool map[string]*agent.Agent) func(personaID string) bool {
|
||||
return func(personaID string) bool {
|
||||
_, ok := pool[personaID]
|
||||
return ok
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
|
||||
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// This file locks design/22 acceptance 4 at the composition-root level: with two
|
||||
// roles registered, a turn of role Y must advance ONLY Y's emotion machine and
|
||||
// affection (design/01 §2: 情绪/好感度 follow the persona), and the `mood` frame it
|
||||
// broadcasts must name Y — never the default role. The turn's role reaches the hook
|
||||
// through the loop's per-turn SessionEnv resolution (loop.TurnEnd.PersonaID), so a
|
||||
// regression to a construction-time persona shows up as the wrong machine moving.
|
||||
|
||||
// testTwoPersonaApp registers two personas (each with its own emotion machine and
|
||||
// affection curve) and wires the composition root's emotion observer exactly as
|
||||
// wire.go does.
|
||||
func testTwoPersonaApp(t *testing.T) (*app, *fakeMoodEmitter) {
|
||||
t.Helper()
|
||||
reg := personareg.New()
|
||||
for _, p := range []*loader.Persona{
|
||||
{
|
||||
ID: "xilian",
|
||||
Name: "昔涟",
|
||||
Emotion: loader.EmotionParams{InitialState: emotion.Calm, DecayRate: 0.5},
|
||||
Affection: loader.AffectionParams{Initial: 50, Min: 0, Max: 700, Delta: 10},
|
||||
},
|
||||
{
|
||||
ID: "yuki",
|
||||
Name: "由纪",
|
||||
Emotion: loader.EmotionParams{InitialState: emotion.Calm, DecayRate: 0.5},
|
||||
Affection: loader.AffectionParams{Initial: 20, Min: 0, Max: 700, Delta: 5},
|
||||
},
|
||||
} {
|
||||
if err := reg.RegisterPersona(p); err != nil {
|
||||
t.Fatalf("RegisterPersona(%s): %v", p.ID, err)
|
||||
}
|
||||
}
|
||||
if err := reg.RegisterCharacter(&personareg.Character{ID: "xilian", Name: "昔涟", Default: true, PersonaID: "xilian"}); err != nil {
|
||||
t.Fatalf("RegisterCharacter: %v", err)
|
||||
}
|
||||
if err := reg.RegisterCharacter(&personareg.Character{ID: "yuki", Name: "由纪", PersonaID: "yuki"}); err != nil {
|
||||
t.Fatalf("RegisterCharacter: %v", err)
|
||||
}
|
||||
|
||||
sink := &fakeMoodEmitter{}
|
||||
a := &app{log: testLog(), personas: reg, moodSink: sink}
|
||||
a.emotion = a.buildEmotionObserver()
|
||||
return a, sink
|
||||
}
|
||||
|
||||
// TestTurnEndMovesOnlyTheTurnPersona is acceptance 4: a Y turn moves Y's emotion and
|
||||
// affection and leaves the default role's untouched, and the mood broadcast carries
|
||||
// Y's id (so the frontend's 心情 badge is the active role's, not a global one).
|
||||
func TestTurnEndMovesOnlyTheTurnPersona(t *testing.T) {
|
||||
t.Parallel()
|
||||
a, sink := testTwoPersonaApp(t)
|
||||
ag, err := agent.New("你是由纪。", nil, nil, agent.WithModel(moodAdapter{reply: "由纪在呢~"}, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("agent.New: %v", err)
|
||||
}
|
||||
l := loop.New(loop.Options{
|
||||
Agent: ag,
|
||||
Agents: map[string]*agent.Agent{"yuki": ag},
|
||||
PersonaID: "xilian", // the configured default role is NOT the turn's role
|
||||
OnTurnEnd: a.onTurnEnd,
|
||||
})
|
||||
|
||||
env := loop.OwnerEnv()
|
||||
env.PersonaID = "yuki"
|
||||
res, err := l.Run(loop.WithSessionEnv(context.Background(), env), "web", []model.Message{model.TextMessage(model.RoleUser, "好开心呀,最喜欢你了")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.Mood == "" {
|
||||
t.Fatal("Result.Mood is empty, want Y's mood for the turn")
|
||||
}
|
||||
|
||||
yukiMachine, yukiAffection, ok := a.personas.Emotions().Get("yuki")
|
||||
if !ok {
|
||||
t.Fatal("persona yuki has no emotion machine")
|
||||
}
|
||||
if yukiMachine.Current() == emotion.Calm {
|
||||
t.Fatalf("yuki machine state = %s, want it moved by her own turn", yukiMachine.Current())
|
||||
}
|
||||
if yukiAffection.Value() != 25 {
|
||||
t.Fatalf("yuki affection = %d, want 25 (20 + her own step 5)", yukiAffection.Value())
|
||||
}
|
||||
|
||||
xilianMachine, xilianAffection, ok := a.personas.Emotions().Get("xilian")
|
||||
if !ok {
|
||||
t.Fatal("persona xilian has no emotion machine")
|
||||
}
|
||||
if xilianMachine.Current() != emotion.Calm {
|
||||
t.Fatalf("default role machine = %s, want it untouched (calm)", xilianMachine.Current())
|
||||
}
|
||||
if xilianAffection.Value() != 50 {
|
||||
t.Fatalf("default role affection = %d, want it untouched (50)", xilianAffection.Value())
|
||||
}
|
||||
|
||||
if sink.calls != 1 || sink.personaID != "yuki" {
|
||||
t.Fatalf("mood broadcast = %+v, want exactly one frame naming yuki", sink)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
)
|
||||
|
||||
// identityKnowledgeHeader 是主人身份说明书注入上下文时的段头:
|
||||
// 与【性格】【价值观】等并列,使模型能把它读成「人格知识」,而不是一条指令。
|
||||
const identityKnowledgeHeader = "【主人身份说明书】"
|
||||
|
||||
// ownerIdentityKnowledge 读取 owner.yaml 的 identityFile 指向的正文
|
||||
// (configs/users/owner_identity.md),供按会话注入。
|
||||
//
|
||||
// 它是**人格知识**,不是权限来源:该文件只描述「你在陪伴谁、你们是什么关系」,
|
||||
// 身份识别始终只看渠道层 actor 元数据 + 白名单(design/11 §0),文件内容
|
||||
// 既不参与 IsOwner,也不改变任何授权判定。文件里自己也把这条边界写在开头。
|
||||
//
|
||||
// fail-soft:未配置、文件缺失或读不动(含路径指向目录)都返回空串 —— 只记一条
|
||||
// WARN,绝不报错、绝不阻断启动或对话。这是「宁可少一段知识,也不能因为一个
|
||||
// 可选文件起步失败」的取舍。
|
||||
func (a *app) ownerIdentityKnowledge() string {
|
||||
if a.owner == nil {
|
||||
return ""
|
||||
}
|
||||
path := strings.TrimSpace(a.owner.IdentityFile)
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if a.log != nil {
|
||||
logger.WithSource(a.log, "cmd/cyrene").Warn(
|
||||
"owner identity file skipped", "path", path, "error", err)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(raw))
|
||||
}
|
||||
|
||||
// identityKnowledgeBlock 把身份说明书正文渲染成一个可独立注入的系统块;
|
||||
// knowledge 为空(未配置 / 读不出)时返回空串 = 不注入任何块。
|
||||
func identityKnowledgeBlock(knowledge string) string {
|
||||
if strings.TrimSpace(knowledge) == "" {
|
||||
return ""
|
||||
}
|
||||
return identityKnowledgeHeader + "\n" + knowledge
|
||||
}
|
||||
|
||||
// personaInstructions 装配某个角色 agent 的**静态** system 指令:人格指令
|
||||
// (instructionsFromPersona,design/01 §2.2)。默认 agent 与角色池里的每个
|
||||
// agent 都走这里,因此两个装配点不会漂移。
|
||||
//
|
||||
// 主人身份说明书**刻意不在这里**:静态指令在装配期定死,会让那段知识进入该
|
||||
// 角色的每一个会话(含群聊与陌生人私聊)并随请求发给模型供应商。它现在按
|
||||
// 会话注入 —— 见 personaKnowledgeFor 与 loop.Options.PersonaKnowledgeFor。
|
||||
func (a *app) personaInstructions(p *loader.Persona) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return instructionsFromPersona(p)
|
||||
}
|
||||
|
||||
// personaKnowledgeFor 返回循环的按会话「人格知识」解析器
|
||||
// (loop.Options.PersonaKnowledgeFor):该 turn 的会话在注入范围内时返回主人
|
||||
// 身份说明书整块(段头 + 正文),否则返回空串 —— 空串意味着那段知识
|
||||
// **完全不进**该会话的上下文,也就绝不随请求发给模型供应商。
|
||||
//
|
||||
// 默认档 owner_only:只有渠道层 actor 元数据判定的主人车道拿得到它。群聊与
|
||||
// 陌生人私聊(design/11 §0 隔离红线)返回空串。开关见 OWNER_IDENTITY_INJECTION。
|
||||
//
|
||||
// 正文在装配期读一次并捕获进闭包(不是每轮读盘):它只在启动时变一次,
|
||||
// 每轮重读一个启动期就不变的可选文件纯属浪费。
|
||||
func (a *app) personaKnowledgeFor(envFilePath string) func(loop.SessionEnv) string {
|
||||
log := sourcedWiringLog(a.log)
|
||||
mode := ownerIdentityInjectionMode(log, envFilePath)
|
||||
block := identityKnowledgeBlock(a.ownerIdentityKnowledge())
|
||||
log.Info("owner identity injection",
|
||||
"mode", mode, "env", config.OwnerIdentityInjectionEnv, "knowledge", block != "")
|
||||
return func(env loop.SessionEnv) string {
|
||||
if !injectsIdentityKnowledge(mode, env) {
|
||||
return ""
|
||||
}
|
||||
return block
|
||||
}
|
||||
}
|
||||
|
||||
// sourcedWiringLog 返回带来源标记 "cmd/cyrene" 的 logger;nil 基础 logger
|
||||
// 换成丢弃 logger(与 loop 包的 sourcedLog 同一约定),使这里的日志调用
|
||||
// 永远不会因为「没接 logger」而空指针。
|
||||
func sourcedWiringLog(base *slog.Logger) *slog.Logger {
|
||||
if base == nil {
|
||||
base = slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
return logger.WithSource(base, "cmd/cyrene")
|
||||
}
|
||||
|
||||
// ownerIdentityInjectionMode 解析注入范围开关,任何读不出/不认识的情形都
|
||||
// **明确回退**到默认的 owner_only 并记一条带原始取值的 WARN —— 回退方向永远
|
||||
// 是最安全的一侧,且绝不静默(非法取值不会被当成默认值悄悄放行)。
|
||||
func ownerIdentityInjectionMode(log *slog.Logger, envFilePath string) string {
|
||||
raw, err := config.LoadOwnerIdentityInjection(envFilePath)
|
||||
if err != nil {
|
||||
log.Warn("owner identity injection: cannot read config, falling back",
|
||||
"env", config.OwnerIdentityInjectionEnv,
|
||||
"fallback", config.OwnerIdentityOwnerOnly, "error", err)
|
||||
return config.OwnerIdentityOwnerOnly
|
||||
}
|
||||
mode, perr := config.ParseOwnerIdentityInjection(raw)
|
||||
if perr != nil {
|
||||
log.Warn("owner identity injection: unknown value, falling back",
|
||||
"env", config.OwnerIdentityInjectionEnv, "value", raw,
|
||||
"fallback", config.OwnerIdentityOwnerOnly, "error", perr)
|
||||
return config.OwnerIdentityOwnerOnly
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
// injectsIdentityKnowledge 报告该会话是否在注入范围内。
|
||||
//
|
||||
// owner_only 的判定读**强类型的渠道层 actor 元数据**(SessionEnv.ActorEnv),
|
||||
// 与 memory / 工具准入门用的是同一个车道 —— 绝不从提示词文本或会话 id 猜。
|
||||
// 注意反向也成立:身份说明书的内容永远不参与这个判定,也不参与任何授权。
|
||||
func injectsIdentityKnowledge(mode string, env loop.SessionEnv) bool {
|
||||
switch mode {
|
||||
case config.OwnerIdentityAlways:
|
||||
return true
|
||||
case config.OwnerIdentityOff:
|
||||
return false
|
||||
default: // owner_only(默认,也是未知取值的回退档)
|
||||
return env.ActorEnv == restrict.ActorOwner
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
|
||||
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||
)
|
||||
|
||||
// 本文件锁定 P0-10 的**收窄**(design/11 §0 红线):
|
||||
//
|
||||
// - owner.yaml 的 identityFile(主人身份说明书)是**人格知识**,现在按
|
||||
// **会话**注入:只有主人车道拿得到,群聊与陌生人私聊的上下文里一个字
|
||||
// 都没有(因此也不会随请求发给模型供应商);
|
||||
// - 它不再进静态 system 指令(那会让它跟着角色进入每个会话);
|
||||
// - 它绝不参与主人判定 —— 判主只看 actor 元数据 + 白名单。
|
||||
|
||||
// identityFixture 是写进临时身份说明书的一段正文,含一个「陌生人 id」,
|
||||
// 用来证明「文件里写了什么」与「谁被认成主人」毫无关系。
|
||||
const identityFixture = "# 身份说明书 · 我的「人」\n\n" +
|
||||
"- 称呼:你叫他「亲爱的」。\n" +
|
||||
"- 陌生账号 kook:stranger-999 与本文件无关。\n" +
|
||||
"- 本说明书只描述人设/关系,不授权任何权限。"
|
||||
|
||||
// identityProbeAdapter 记下每次请求,使测试能读回模型实际看到的上下文。
|
||||
type identityProbeAdapter struct{ reqs []model.Request }
|
||||
|
||||
func (a *identityProbeAdapter) Name() string { return "identity-probe" }
|
||||
|
||||
func (a *identityProbeAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) {
|
||||
a.reqs = append(a.reqs, req)
|
||||
return model.Response{Content: "好呀♪"}, nil
|
||||
}
|
||||
|
||||
func (a *identityProbeAdapter) ChatStream(_ context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) {
|
||||
a.reqs = append(a.reqs, req)
|
||||
return model.Response{Content: "好呀♪"}, nil
|
||||
}
|
||||
|
||||
// identityApp 装配一个最小组合根切片:真实默认角色档案 + 给定身份说明书的
|
||||
// owner 档案。identityFile 为空串表示「没配」。
|
||||
func identityApp(t *testing.T, identityFile string) (*app, *loader.Persona) {
|
||||
t.Helper()
|
||||
return identityAppLogged(t, identityFile, testLog())
|
||||
}
|
||||
|
||||
// identityAppLogged 同上,但用调用方给的 logger(用于断言日志不静默)。
|
||||
func identityAppLogged(t *testing.T, identityFile string, log *slog.Logger) (*app, *loader.Persona) {
|
||||
t.Helper()
|
||||
|
||||
p, err := loader.Load(filepath.Join("..", "..", "configs", "personas", "cyrene.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("loader.Load(cyrene.yaml): %v", err)
|
||||
}
|
||||
reg := personareg.New()
|
||||
if err := reg.RegisterPersona(p); err != nil {
|
||||
t.Fatalf("RegisterPersona: %v", err)
|
||||
}
|
||||
if err := reg.RegisterCharacter(&personareg.Character{
|
||||
ID: p.ID, Name: p.Name, Default: true, PersonaID: p.ID,
|
||||
}); err != nil {
|
||||
t.Fatalf("RegisterCharacter: %v", err)
|
||||
}
|
||||
|
||||
owner := &user.OwnerProfile{
|
||||
ID: "owner",
|
||||
DisplayName: "叶酱",
|
||||
DefaultPersona: p.ID,
|
||||
Accounts: []user.OwnerAccount{{Platform: "kook", AccountID: "owner-1"}},
|
||||
IdentityFile: identityFile,
|
||||
}
|
||||
a := &app{log: log, personas: reg, owner: owner, cfg: &config.Config{}}
|
||||
return a, p
|
||||
}
|
||||
|
||||
// writeIdentity 把 identityFixture 写进临时文件并返回路径。
|
||||
func writeIdentity(t *testing.T) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "owner_identity.md")
|
||||
if err := os.WriteFile(path, []byte(identityFixture), 0o600); err != nil {
|
||||
t.Fatalf("write identity file: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// absentIdentityEnvFile 返回一个不存在的 .env 路径,使测试不受仓库 .env 影响。
|
||||
func absentIdentityEnvFile(t *testing.T) string {
|
||||
t.Helper()
|
||||
return filepath.Join(t.TempDir(), "absent.env")
|
||||
}
|
||||
|
||||
// unsetInjectionEnv 真正清掉注入范围开关(不是设成空串 —— 空串会遮住 .env
|
||||
// 里的取值),并在测试结束还原。
|
||||
func unsetInjectionEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
key := config.OwnerIdentityInjectionEnv
|
||||
prev, had := os.LookupEnv(key)
|
||||
if err := os.Unsetenv(key); err != nil {
|
||||
t.Fatalf("os.Unsetenv(%s): %v", key, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if had {
|
||||
_ = os.Setenv(key, prev)
|
||||
return
|
||||
}
|
||||
_ = os.Unsetenv(key)
|
||||
})
|
||||
}
|
||||
|
||||
// envOwnerTurn / envGroupTurn / envStrangerTurn 与生产路径构造的取值一致
|
||||
// (Web/语音/主人私聊 = OwnerEnv,群聊 / 陌生人 = 受限环境)。
|
||||
func envOwnerTurn() loop.SessionEnv { return loop.OwnerEnv() }
|
||||
|
||||
func envGroupTurn() loop.SessionEnv {
|
||||
return loop.SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false}
|
||||
}
|
||||
|
||||
func envStrangerTurn() loop.SessionEnv {
|
||||
return loop.SessionEnv{ActorEnv: restrict.ActorStranger, MemWritable: false}
|
||||
}
|
||||
|
||||
// runIdentityTurn 用**生产解析器**(a.personaKnowledgeFor)驱动一次真实循环,
|
||||
// 返回模型实际收到的消息列表。
|
||||
func runIdentityTurn(t *testing.T, resolve func(loop.SessionEnv) string, env loop.SessionEnv) []model.Message {
|
||||
t.Helper()
|
||||
|
||||
adapter := &identityProbeAdapter{}
|
||||
ag, err := agent.New("你是昔涟。", nil, nil, agent.WithModel(adapter, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("agent.New: %v", err)
|
||||
}
|
||||
l := loop.New(loop.Options{Agent: ag, PersonaKnowledgeFor: resolve, MaxRounds: 8})
|
||||
if _, err := l.Run(loop.WithSessionEnv(context.Background(), env), "sess",
|
||||
[]model.Message{model.TextMessage(model.RoleUser, "亲爱的,今天想我了没?")}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if len(adapter.reqs) == 0 {
|
||||
t.Fatal("适配器没有收到任何请求")
|
||||
}
|
||||
return adapter.reqs[0].Messages
|
||||
}
|
||||
|
||||
// identityMessagesContain 报告消息里是否有任一携带给定文本。
|
||||
func identityMessagesContain(messages []model.Message, want string) bool {
|
||||
for _, m := range messages {
|
||||
for _, part := range m.Content {
|
||||
if strings.Contains(part.Text, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestPersonaInstructions_StaticPromptHasNoIdentityKnowledge 是本收窄的
|
||||
// 反证断言:静态 system 指令里**不再**有身份说明书 —— 默认 agent 与角色池
|
||||
// 里每个 agent 的指令都逐字节等于人格指令本身。静态注入正是泄漏面
|
||||
// (它会跟着角色进入群聊与陌生人私聊),因此它必须消失。
|
||||
func TestPersonaInstructions_StaticPromptHasNoIdentityKnowledge(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a, p := identityApp(t, writeIdentity(t))
|
||||
base := instructionsFromPersona(p)
|
||||
|
||||
if got := a.defaultInstructions(); got != base {
|
||||
t.Fatalf("默认静态指令被身份说明书污染:\n%q\nwant\n%q", got, base)
|
||||
}
|
||||
if pooled := a.personaInstructions(p); pooled != base {
|
||||
t.Fatalf("角色池静态指令被身份说明书污染:\n%q\nwant\n%q", pooled, base)
|
||||
}
|
||||
if strings.Contains(a.defaultInstructions(), identityFixture) {
|
||||
t.Fatal("静态指令里仍有身份说明书正文 —— 它会被带进每一个会话")
|
||||
}
|
||||
|
||||
llm := model.NewRuntimeLLM(filepath.Join(t.TempDir(), "models.json"), config.LLMConfig{}, testLog())
|
||||
for id, ag := range a.buildPersonaAgents(llm, nil, a.buildGuardrailPipeline(noWindowBudget())) {
|
||||
if ag.Instructions() != base {
|
||||
t.Fatalf("角色 %q 的静态指令与人格指令不一致(含身份说明书?):\n%q", id, ag.Instructions())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerIdentityKnowledge_MissingFileIsFailSoft 是真断言(既有回归):文件
|
||||
// 缺失、路径指向目录、未配置都只 WARN,读出来是空串 —— 此时连主人会话也
|
||||
// 什么都不注入,输出与「接线前」逐字节一致,绝不报错、绝不阻断对话。
|
||||
func TestOwnerIdentityKnowledge_MissingFileIsFailSoft(t *testing.T) {
|
||||
t.Parallel()
|
||||
unsetInjectionEnv(t)
|
||||
|
||||
missing := filepath.Join(t.TempDir(), "nope.md")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{name: "文件不存在", path: missing},
|
||||
{name: "路径是目录", path: t.TempDir()},
|
||||
{name: "没配 identityFile", path: ""},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
a, p := identityApp(t, tc.path)
|
||||
if got, want := a.defaultInstructions(), instructionsFromPersona(p); got != want {
|
||||
t.Fatalf("身份说明书不可用时静态指令变了:\n%q\nwant\n%q", got, want)
|
||||
}
|
||||
if a.ownerIdentityKnowledge() != "" {
|
||||
t.Fatalf("ownerIdentityKnowledge() = %q,want 空串", a.ownerIdentityKnowledge())
|
||||
}
|
||||
if block := identityKnowledgeBlock(a.ownerIdentityKnowledge()); block != "" {
|
||||
t.Fatalf("没有正文时不该渲染出任何块:%q", block)
|
||||
}
|
||||
resolve := a.personaKnowledgeFor(absentIdentityEnvFile(t))
|
||||
if got := resolve(envOwnerTurn()); got != "" {
|
||||
t.Fatalf("没有正文时主人会话也被注入了:%q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerIdentityInjection_ScopeIsPerSession 是本任务的验收核心:
|
||||
// 默认档(owner_only)下主人会话拿到整块身份说明书,群聊与陌生人私聊拿到
|
||||
// 空串 —— 那段知识不进它们的上下文,也就不随请求发给模型供应商。
|
||||
func TestOwnerIdentityInjection_ScopeIsPerSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
unsetInjectionEnv(t)
|
||||
|
||||
a, _ := identityApp(t, writeIdentity(t))
|
||||
resolve := a.personaKnowledgeFor(absentIdentityEnvFile(t))
|
||||
|
||||
owner := resolve(envOwnerTurn())
|
||||
if owner == "" || !strings.Contains(owner, identityFixture) {
|
||||
t.Fatalf("主人会话没有拿到身份说明书:%q", owner)
|
||||
}
|
||||
if !strings.HasPrefix(owner, identityKnowledgeHeader) {
|
||||
t.Fatalf("主人会话的知识块没有段头:%q", owner)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
env loop.SessionEnv
|
||||
}{
|
||||
{name: "群聊会话", env: envGroupTurn()},
|
||||
{name: "陌生人私聊会话", env: envStrangerTurn()},
|
||||
} {
|
||||
if got := resolve(tc.env); got != "" {
|
||||
t.Fatalf("%s 拿到了身份说明书(红线泄漏):%q", tc.name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerIdentityInjection_AlwaysAndOff 证明开关真的生效:
|
||||
// always 档下群聊也有(明确开启的红线例外),off 档下连主人会话也没有。
|
||||
func TestOwnerIdentityInjection_AlwaysAndOff(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mode string
|
||||
env loop.SessionEnv
|
||||
wantContent bool
|
||||
}{
|
||||
{name: "always-群聊", mode: config.OwnerIdentityAlways, env: envGroupTurn(), wantContent: true},
|
||||
{name: "always-陌生人", mode: config.OwnerIdentityAlways, env: envStrangerTurn(), wantContent: true},
|
||||
{name: "off-主人", mode: config.OwnerIdentityOff, env: envOwnerTurn(), wantContent: false},
|
||||
{name: "off-群聊", mode: config.OwnerIdentityOff, env: envGroupTurn(), wantContent: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
unsetInjectionEnv(t)
|
||||
t.Setenv(config.OwnerIdentityInjectionEnv, tc.mode)
|
||||
|
||||
a, _ := identityApp(t, writeIdentity(t))
|
||||
got := a.personaKnowledgeFor(absentIdentityEnvFile(t))(tc.env)
|
||||
|
||||
if tc.wantContent != strings.Contains(got, identityFixture) {
|
||||
t.Fatalf("%s:解析结果 %q,want 含正文 = %v", tc.name, got, tc.wantContent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerIdentityInjection_IllegalValueIsNotSilent 钉住「非法取值不静默」:
|
||||
// 回退到最安全的 owner_only,且日志里带上原始取值与回退档。
|
||||
func TestOwnerIdentityInjection_IllegalValueIsNotSilent(t *testing.T) {
|
||||
unsetInjectionEnv(t)
|
||||
t.Setenv(config.OwnerIdentityInjectionEnv, "everyone")
|
||||
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
a, _ := identityAppLogged(t, writeIdentity(t), log)
|
||||
resolve := a.personaKnowledgeFor(absentIdentityEnvFile(t))
|
||||
|
||||
if got := resolve(envGroupTurn()); got != "" {
|
||||
t.Fatalf("非法取值下群聊被注入了:%q", got)
|
||||
}
|
||||
if got := resolve(envOwnerTurn()); !strings.Contains(got, identityFixture) {
|
||||
t.Fatalf("非法取值应回退到 owner_only(主人会话仍应有正文):%q", got)
|
||||
}
|
||||
logged := buf.String()
|
||||
for _, want := range []string{config.OwnerIdentityInjectionEnv, "everyone", config.OwnerIdentityOwnerOnly, "WARN"} {
|
||||
if !strings.Contains(logged, want) {
|
||||
t.Fatalf("日志里没有 %q(非法取值不得静默):\n%s", want, logged)
|
||||
}
|
||||
}
|
||||
if strings.Contains(logged, identityFixture) {
|
||||
t.Fatal("日志里出现了身份说明书正文(不得把正文打进日志)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerIdentityInjection_EndToEnd 是端到端真断言:真正发给模型的上下文
|
||||
// 里,主人会话含身份正文(作为 agent 指令之后的第 2 条 system),群聊与
|
||||
// 陌生人私聊的每条消息都不含它。
|
||||
func TestOwnerIdentityInjection_EndToEnd(t *testing.T) {
|
||||
t.Parallel()
|
||||
unsetInjectionEnv(t)
|
||||
|
||||
a, _ := identityApp(t, writeIdentity(t))
|
||||
resolve := a.personaKnowledgeFor(absentIdentityEnvFile(t))
|
||||
|
||||
ownerMsgs := runIdentityTurn(t, resolve, envOwnerTurn())
|
||||
if !identityMessagesContain(ownerMsgs, identityFixture) {
|
||||
t.Fatalf("主人会话的模型上下文里没有身份说明书:%+v", ownerMsgs)
|
||||
}
|
||||
if len(ownerMsgs) < 2 || !strings.Contains(ownerMsgs[1].Content[0].Text, identityKnowledgeHeader) {
|
||||
t.Fatalf("身份知识块的位置不对(want 紧接 agent 指令之后):%+v", ownerMsgs)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
env loop.SessionEnv
|
||||
}{
|
||||
{name: "群聊会话", env: envGroupTurn()},
|
||||
{name: "陌生人私聊会话", env: envStrangerTurn()},
|
||||
} {
|
||||
msgs := runIdentityTurn(t, resolve, tc.env)
|
||||
if identityMessagesContain(msgs, identityFixture) {
|
||||
t.Fatalf("%s 的模型上下文里出现了身份说明书:%+v", tc.name, msgs)
|
||||
}
|
||||
if identityMessagesContain(msgs, identityKnowledgeHeader) {
|
||||
t.Fatalf("%s 的模型上下文里出现了身份说明书段头:%+v", tc.name, msgs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerIdentity_NeverChangesOwnerJudgement 是设计红线(design/11 §0)的真
|
||||
// 断言:说明书正文会作为人格知识进入主人上下文,但主人判定只认 actor 元数据
|
||||
// + 白名单 —— 连文件里点名写到的那个陌生账号也照样不是主人。
|
||||
func TestOwnerIdentity_NeverChangesOwnerJudgement(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a, _ := identityApp(t, writeIdentity(t))
|
||||
|
||||
// 正文确实会被注入(只是限定在主人会话),且里面写着「kook:stranger-999」。
|
||||
block := identityKnowledgeBlock(a.ownerIdentityKnowledge())
|
||||
if !strings.Contains(block, "stranger-999") {
|
||||
t.Fatalf("测试前提不成立:说明书正文没被读出来")
|
||||
}
|
||||
if !a.owner.IsOwner("kook", "owner-1") {
|
||||
t.Fatal("白名单里的账号未被认成主人")
|
||||
}
|
||||
if a.owner.IsOwner("kook", "stranger-999") {
|
||||
t.Fatal("身份说明书里出现过的账号被判成了主人 —— 身份不能被文本左右")
|
||||
}
|
||||
if a.owner.IsOwner("kook", "") {
|
||||
t.Fatal("空账号被判成了主人")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// 以下段头对应 design/01 §2.2 中此前未被消费的人格字段。它们作为独立的 system
|
||||
// 提示段追加在身份块之后,使每一段都能单独调优而不必改动身份正文。
|
||||
const (
|
||||
personalityHeader = "【性格】"
|
||||
valuesHeader = "【价值观】"
|
||||
speechStyleHeader = "【说话风格】"
|
||||
catchphraseHeader = "【口头禅】"
|
||||
backgroundHeader = "【背景】"
|
||||
)
|
||||
|
||||
// personaSection 是一个追加块:方括号段头 + 正文。
|
||||
type personaSection struct {
|
||||
header string
|
||||
body string
|
||||
}
|
||||
|
||||
// defaultPersona 返回默认角色的 persona;没有注册角色/persona 时返回 false。
|
||||
func (a *app) defaultPersona() (*loader.Persona, bool) {
|
||||
c, ok := a.personas.DefaultCharacter()
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
p, ok := a.personas.Persona(c.PersonaID)
|
||||
if !ok || p == nil {
|
||||
return nil, false
|
||||
}
|
||||
return p, true
|
||||
}
|
||||
|
||||
// identityInstructionFor 返回 persona 的身份块 —— design/01 §2.2 之前唯一注入的
|
||||
// 部分:优先 identity.systemPrompt,其次 identity.role,再退回最小兜底文本。
|
||||
func identityInstructionFor(p *loader.Persona) string {
|
||||
if p.Identity.SystemPrompt != "" {
|
||||
return p.Identity.SystemPrompt
|
||||
}
|
||||
if p.Identity.Role != "" {
|
||||
return p.Identity.Role
|
||||
}
|
||||
return fallbackInstruction
|
||||
}
|
||||
|
||||
// instructionsFromPersona 构建 persona 的 system 指令:身份块,后接此前无人消费的
|
||||
// personality/values/speechStyle/catchphrase/background 各段(design/01 §2.2)。
|
||||
// 空白字段会被跳过,因此不会产生空段;这些字段全空的 persona 产出与身份块完全一致
|
||||
// —— 与既有输出逐字节相同。
|
||||
func instructionsFromPersona(p *loader.Persona) string {
|
||||
base := identityInstructionFor(p)
|
||||
sections := personaSections(p)
|
||||
if len(sections) == 0 {
|
||||
return base
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(base)
|
||||
for _, s := range sections {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(s.header)
|
||||
b.WriteString("\n")
|
||||
b.WriteString(s.body)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// personaSections 按固定顺序渲染五个可选段(personality → values → speechStyle
|
||||
// → catchphrase → background),跳过其中为空的。
|
||||
func personaSections(p *loader.Persona) []personaSection {
|
||||
out := make([]personaSection, 0, 5)
|
||||
appendSection := func(header, body string) {
|
||||
if body = strings.TrimSpace(body); body == "" {
|
||||
return
|
||||
}
|
||||
out = append(out, personaSection{header: header, body: body})
|
||||
}
|
||||
appendSection(personalityHeader, p.Personality)
|
||||
appendSection(valuesHeader, bulletList(p.Values))
|
||||
appendSection(speechStyleHeader, p.SpeechStyle)
|
||||
appendSection(catchphraseHeader, p.Catchphrase)
|
||||
appendSection(backgroundHeader, p.Background)
|
||||
return out
|
||||
}
|
||||
|
||||
// bulletList 把字符串项渲染成 markdown 无序列表,跳过空白项;全空的列表产出 ""
|
||||
// 使对应段彻底消失。
|
||||
func bulletList(items []string) string {
|
||||
lines := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item = strings.TrimSpace(item); item != "" {
|
||||
lines = append(lines, "- "+item)
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// defaultThinkingOptions 返回循环应为默认 persona 注入的推理开关:thinking 指令、
|
||||
// reasoning_effort 取值与 thinking 广播开关。它是循环每轮所做的按角色解析的默认
|
||||
// 一半(Options.PersonaKnobs,见下方 personaKnobs),因此配置的默认值与每轮查询
|
||||
// 永远不会漂移。
|
||||
//
|
||||
// 只有当 persona 配置了会影响请求的内容(预算、effort,或显式的开关模式)时
|
||||
// thinking 指令才非 nil。showToUser 刻意不产生指令:它只是呈现开关,而一个没有
|
||||
// 开关的非 nil 指令会顺带打开供应商侧 thinking。未注册 persona 时三者均为零值,
|
||||
// 循环既有行为不受影响。
|
||||
func (a *app) defaultThinkingOptions() (*model.Thinking, string, *bool) {
|
||||
knobs, ok := a.personaKnobs(a.defaultPersonaID())
|
||||
if !ok {
|
||||
return nil, "", nil
|
||||
}
|
||||
return knobs.Thinking, knobs.ReasoningEffort, knobs.ThinkingShowToUser
|
||||
}
|
||||
|
||||
// personaKnobs 为循环解析某个角色的推理开关(loop.Options.PersonaKnobs,
|
||||
// design/22 §4.2):循环会为即将构建请求的那一轮询问其角色,因此绑定角色 Y 的
|
||||
// 会话运行时用的是 Y 的 thinking 预算 / effort / 广播开关,而不是默认角色的。
|
||||
// 从角色 id 解析,而不是把取值推入每个调用方的 SessionEnv,意味所有路径 —— Web
|
||||
// REST/WS/语音、IM、backthink、子代理 —— 都由同一个输入拿到正确的开关。
|
||||
// ok=false 表示「角色未知」,循环沿用其配置的默认值。
|
||||
func (a *app) personaKnobs(personaID string) (loop.LoopKnobs, bool) {
|
||||
if personaID == "" {
|
||||
return loop.LoopKnobs{}, false
|
||||
}
|
||||
p, ok := a.personas.Persona(personaID)
|
||||
if !ok || p == nil {
|
||||
return loop.LoopKnobs{}, false
|
||||
}
|
||||
thinking, effort := personaThinking(p)
|
||||
return loop.LoopKnobs{
|
||||
Thinking: thinking,
|
||||
ReasoningEffort: effort,
|
||||
ThinkingShowToUser: p.Thinking.ShowToUser,
|
||||
}, true
|
||||
}
|
||||
|
||||
// personaName 把角色 id 解析成其展示名,供模型上下文的说话人归属使用
|
||||
// (loop.Options.PersonaName,design/22 §7-5)。循环把发话角色写成
|
||||
// 「「昔涟」(xilian)」而不是裸 id,使模型在会话切换角色后仍能跟上谁说了什么。
|
||||
// id 为空或未知时解析为 "",循环退回只写 id —— 归属不会因为名字解析不出就消失,
|
||||
// 且这里没有任何身份或权限输入。
|
||||
func (a *app) personaName(personaID string) string {
|
||||
if personaID == "" {
|
||||
return ""
|
||||
}
|
||||
p, ok := a.personas.Persona(personaID)
|
||||
if !ok || p == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(p.Name)
|
||||
}
|
||||
|
||||
// personaThinking 把 persona 的 `thinking` 段映射到模型层开关(design/01 §2.1 /
|
||||
// design/04 §3.2)。没有任何内容影响请求时返回 (nil, "")。
|
||||
func personaThinking(p *loader.Persona) (*model.Thinking, string) {
|
||||
effort := strings.TrimSpace(p.Thinking.Effort)
|
||||
enable := p.Thinking.Enable()
|
||||
if p.Thinking.BudgetTokens == 0 && effort == "" && enable == nil {
|
||||
return nil, ""
|
||||
}
|
||||
return &model.Thinking{BudgetTokens: p.Thinking.BudgetTokens, Enable: enable}, effort
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
|
||||
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
|
||||
)
|
||||
|
||||
// boolPtr returns a pointer to v, for the tri-state switch fields.
|
||||
func boolPtr(v bool) *bool { return &v }
|
||||
|
||||
// TestInstructionsFromPersona_AllEmptyIsByteIdenticalToTheOldOutput locks the
|
||||
// backward-compatibility promise of design/01 §2.2: a persona that sets none of
|
||||
// the five previously-inert fields produces exactly what the old code produced
|
||||
// (the identity block alone, in its own precedence order).
|
||||
func TestInstructionsFromPersona_AllEmptyIsByteIdenticalToTheOldOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
p loader.Persona
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "system prompt wins",
|
||||
p: loader.Persona{Identity: loader.Identity{SystemPrompt: "身份块", Role: "人设"}},
|
||||
want: "身份块",
|
||||
},
|
||||
{
|
||||
name: "role fallback",
|
||||
p: loader.Persona{Identity: loader.Identity{Role: "人设"}},
|
||||
want: "人设",
|
||||
},
|
||||
{
|
||||
name: "minimal fallback",
|
||||
p: loader.Persona{},
|
||||
want: fallbackInstruction,
|
||||
},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := instructionsFromPersona(&tt.p); got != tt.want {
|
||||
t.Fatalf("instructionsFromPersona() = %q, want %q (byte-identical)", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstructionsFromPersona_SectionOrderAndFormat pins the exact concatenation:
|
||||
// identity block first, then personality → values → speechStyle → catchphrase →
|
||||
// background, each with its bracketed header and no blank section.
|
||||
func TestInstructionsFromPersona_SectionOrderAndFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
p := loader.Persona{
|
||||
Identity: loader.Identity{SystemPrompt: "你是昔涟。"},
|
||||
Personality: "温柔体贴",
|
||||
Values: []string{"陪伴", "真诚"},
|
||||
SpeechStyle: "轻声细语",
|
||||
Catchphrase: "嘿嘿♪",
|
||||
Background: "来自翁法罗斯。",
|
||||
}
|
||||
want := "你是昔涟。" +
|
||||
"\n\n【性格】\n温柔体贴" +
|
||||
"\n\n【价值观】\n- 陪伴\n- 真诚" +
|
||||
"\n\n【说话风格】\n轻声细语" +
|
||||
"\n\n【口头禅】\n嘿嘿♪" +
|
||||
"\n\n【背景】\n来自翁法罗斯。"
|
||||
got := instructionsFromPersona(&p)
|
||||
if got != want {
|
||||
t.Fatalf("instructionsFromPersona() = %q\nwant %q", got, want)
|
||||
}
|
||||
// The identity block must remain the first section, unchanged.
|
||||
if !strings.HasPrefix(got, "你是昔涟。\n\n") {
|
||||
t.Fatalf("identity block is not the leading section: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstructionsFromPersona_BlankFieldsProduceNoSection asserts an unset (or
|
||||
// blank) field contributes no header and no empty paragraph, and that the
|
||||
// sections keep their relative order when only some are set.
|
||||
func TestInstructionsFromPersona_BlankFieldsProduceNoSection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
p := loader.Persona{
|
||||
Identity: loader.Identity{SystemPrompt: "身份"},
|
||||
Values: []string{"", " ", "真诚"},
|
||||
Background: " ",
|
||||
}
|
||||
got := instructionsFromPersona(&p)
|
||||
want := "身份\n\n【价值观】\n- 真诚"
|
||||
if got != want {
|
||||
t.Fatalf("instructionsFromPersona() = %q, want %q", got, want)
|
||||
}
|
||||
for _, header := range []string{personalityHeader, speechStyleHeader, catchphraseHeader, backgroundHeader} {
|
||||
if strings.Contains(got, header) {
|
||||
t.Errorf("output %q contains %s for a blank field", got, header)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstructionsFromPersona_RealDefaultPersona checks the shipped default
|
||||
// persona end to end: its five fields are now injected, in order, after the
|
||||
// identity block — and the identity block alone is still a strict prefix (so the
|
||||
// pre-existing prompt is preserved verbatim, nothing rewritten).
|
||||
func TestInstructionsFromPersona_RealDefaultPersona(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
p, err := loader.Load("../../configs/personas/cyrene.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("Load(cyrene.yaml): %v", err)
|
||||
}
|
||||
got := instructionsFromPersona(p)
|
||||
if !strings.HasPrefix(got, p.Identity.SystemPrompt) {
|
||||
t.Fatal("identity.systemPrompt is not the leading block")
|
||||
}
|
||||
last := -1
|
||||
for _, header := range []string{personalityHeader, valuesHeader, speechStyleHeader, catchphraseHeader, backgroundHeader} {
|
||||
idx := strings.Index(got, header)
|
||||
if idx < 0 {
|
||||
t.Fatalf("output is missing %s", header)
|
||||
}
|
||||
if idx <= last {
|
||||
t.Fatalf("%s is out of order (at %d, previous at %d)", header, idx, last)
|
||||
}
|
||||
last = idx
|
||||
}
|
||||
}
|
||||
|
||||
// TestPersonaThinking_MappingPinsTheKnobs asserts the persona → model.Thinking
|
||||
// mapping: only request-affecting fields create a directive (so a bare
|
||||
// showToUser, which is presentation-only, can never switch provider thinking on),
|
||||
// and mode=default never becomes an explicit switch.
|
||||
func TestPersonaThinking_MappingPinsTheKnobs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
show := false
|
||||
cases := []struct {
|
||||
name string
|
||||
thinking loader.ThinkingParams
|
||||
wantNil bool
|
||||
wantBudget int
|
||||
wantEnable *bool
|
||||
wantEffort string
|
||||
}{
|
||||
{name: "nothing", thinking: loader.ThinkingParams{}, wantNil: true},
|
||||
{name: "default mode only", thinking: loader.ThinkingParams{Mode: loader.ThinkingModeDefault}, wantNil: true},
|
||||
{name: "showToUser alone is presentation only", thinking: loader.ThinkingParams{ShowToUser: &show}, wantNil: true},
|
||||
{name: "budget", thinking: loader.ThinkingParams{BudgetTokens: 4096}, wantBudget: 4096},
|
||||
{name: "effort", thinking: loader.ThinkingParams{Effort: " medium "}, wantEffort: "medium"},
|
||||
{name: "mode on", thinking: loader.ThinkingParams{Mode: loader.ThinkingModeOn}, wantEnable: boolPtr(true)},
|
||||
{name: "mode off", thinking: loader.ThinkingParams{Mode: loader.ThinkingModeOff}, wantEnable: boolPtr(false)},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := &loader.Persona{Thinking: tt.thinking}
|
||||
got, effort := personaThinking(p)
|
||||
if tt.wantNil {
|
||||
if got != nil {
|
||||
t.Fatalf("personaThinking() = %+v, want nil", got)
|
||||
}
|
||||
if effort != "" {
|
||||
t.Fatalf("effort = %q, want empty", effort)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("personaThinking() = nil, want a directive")
|
||||
}
|
||||
if got.BudgetTokens != tt.wantBudget {
|
||||
t.Errorf("BudgetTokens = %d, want %d", got.BudgetTokens, tt.wantBudget)
|
||||
}
|
||||
if effort != tt.wantEffort {
|
||||
t.Errorf("effort = %q, want %q", effort, tt.wantEffort)
|
||||
}
|
||||
switch {
|
||||
case tt.wantEnable == nil && got.Enable != nil:
|
||||
t.Errorf("Enable = %v, want nil", *got.Enable)
|
||||
case tt.wantEnable != nil && got.Enable == nil:
|
||||
t.Errorf("Enable = nil, want %v", *tt.wantEnable)
|
||||
case tt.wantEnable != nil && *got.Enable != *tt.wantEnable:
|
||||
t.Errorf("Enable = %v, want %v", *got.Enable, *tt.wantEnable)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultThinkingOptions_FromRegistry walks the wiring the composition root
|
||||
// uses: the default character's persona supplies the loop's knobs, and an
|
||||
// unregistered persona yields the zero options (pre-existing behavior).
|
||||
func TestDefaultThinkingOptions_FromRegistry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reg := personareg.New()
|
||||
show := false
|
||||
p := &loader.Persona{
|
||||
ID: "xilian",
|
||||
Name: "昔涟",
|
||||
Identity: loader.Identity{Role: "r", SystemPrompt: "s"},
|
||||
Thinking: loader.ThinkingParams{BudgetTokens: 2048, Effort: "high", ShowToUser: &show, Mode: loader.ThinkingModeOn},
|
||||
}
|
||||
if err := reg.RegisterPersona(p); err != nil {
|
||||
t.Fatalf("RegisterPersona: %v", err)
|
||||
}
|
||||
if err := reg.RegisterCharacter(&personareg.Character{ID: "xilian", Name: "昔涟", Default: true, PersonaID: "xilian"}); err != nil {
|
||||
t.Fatalf("RegisterCharacter: %v", err)
|
||||
}
|
||||
a := &app{personas: reg}
|
||||
|
||||
thinking, effort, gotShow := a.defaultThinkingOptions()
|
||||
if thinking == nil || thinking.BudgetTokens != 2048 {
|
||||
t.Fatalf("thinking = %+v, want BudgetTokens 2048", thinking)
|
||||
}
|
||||
if thinking.Enable == nil || !*thinking.Enable {
|
||||
t.Fatalf("thinking.Enable = %v, want true (mode: on)", thinking.Enable)
|
||||
}
|
||||
if effort != "high" {
|
||||
t.Fatalf("effort = %q, want high", effort)
|
||||
}
|
||||
if gotShow == nil || *gotShow {
|
||||
t.Fatalf("showToUser = %v, want an explicit false", gotShow)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultThinkingOptions_NoPersona keeps the no-registry case inert.
|
||||
func TestDefaultThinkingOptions_NoPersona(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := &app{personas: personareg.New()}
|
||||
thinking, effort, show := a.defaultThinkingOptions()
|
||||
if thinking != nil || effort != "" || show != nil {
|
||||
t.Fatalf("defaultThinkingOptions() = %+v/%q/%v, want the zero options", thinking, effort, show)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// wirePlugins 在能力注册表与被 gitignore 的 plugins.json 之上构建运行时插件管理器,
|
||||
// 再恢复每个已持久化的 MCP server,使已安装的插件在重启后依然存在。启动时连不上的
|
||||
// server 只记日志并保留在 plugins.json 中(主人可重试或卸载它);启动绝不因某个
|
||||
// 外部 server 不可用而失败。
|
||||
func (a *app) wirePlugins(ctx context.Context) {
|
||||
path := runtimeconfig.DefaultPluginsPath(resolveDataDir(a.cfg))
|
||||
a.plugins = pluginmgr.NewManager(a.caps, path, logger.WithSource(a.log, "capability/plugin"))
|
||||
a.plugins.Restore(ctx)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// pluginFakeMCP is a minimal Streamable HTTP MCP server for the composition-root
|
||||
// test: it answers initialize and tools/list over POST.
|
||||
type pluginFakeMCP struct{ tools []string }
|
||||
|
||||
func (f pluginFakeMCP) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if fl, ok := w.(http.Flusher); ok {
|
||||
fl.Flush()
|
||||
}
|
||||
<-r.Context().Done()
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req struct {
|
||||
ID json.RawMessage `json:"id"`
|
||||
Method string `json:"method"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &req)
|
||||
if len(req.ID) == 0 || string(req.ID) == "null" {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
var result any
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
result = map[string]any{"protocolVersion": "2025-11-25", "capabilities": map[string]any{}, "serverInfo": map[string]any{"name": "fake", "version": "1.0"}}
|
||||
case "tools/list":
|
||||
list := make([]map[string]any, 0, len(f.tools))
|
||||
for _, name := range f.tools {
|
||||
list = append(list, map[string]any{"name": name, "description": name, "inputSchema": map[string]any{"type": "object"}})
|
||||
}
|
||||
result = map[string]any{"tools": list}
|
||||
default:
|
||||
result = map[string]any{}
|
||||
}
|
||||
resp, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": json.RawMessage(req.ID), "result": result})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(resp)
|
||||
}
|
||||
|
||||
// TestWirePlugins_RestoresPersistedServer proves the composition root builds the
|
||||
// plugin manager over the resolved data dir (plugins.json) and reinstalls a
|
||||
// persisted MCP server at boot, so a panel install survives a restart.
|
||||
func TestWirePlugins_RestoresPersistedServer(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mcpSrv := httptest.NewServer(pluginFakeMCP{tools: []string{"add"}})
|
||||
defer mcpSrv.Close()
|
||||
plugins := []runtimeconfig.PluginConfig{{
|
||||
ID: "p1",
|
||||
Name: "calc",
|
||||
Kind: runtimeconfig.PluginKindMCP,
|
||||
Transport: runtimeconfig.TransportHTTP,
|
||||
Endpoint: mcpSrv.URL,
|
||||
CreatedAt: "2026-09-08T00:00:00Z",
|
||||
}}
|
||||
raw, err := json.Marshal(plugins)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "plugins.json"), raw, 0o600); err != nil {
|
||||
t.Fatalf("write plugins.json: %v", err)
|
||||
}
|
||||
|
||||
caps := registry.New()
|
||||
a := &app{caps: caps, cfg: &config.Config{DataDir: dir}, log: testLog()}
|
||||
a.wirePlugins(context.Background())
|
||||
if a.plugins == nil {
|
||||
t.Fatal("plugins manager not wired")
|
||||
}
|
||||
defer a.plugins.Close(context.Background())
|
||||
|
||||
if _, err := caps.Get("add"); err != nil {
|
||||
t.Fatalf("restored tool add not registered: %v", err)
|
||||
}
|
||||
if _, err := caps.Get("mcp:calc"); err != nil {
|
||||
t.Fatalf("mcp:calc capability not registered: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/sessionread"
|
||||
)
|
||||
|
||||
// wireSessionReadTool 注册 design/22 §8-3 的跨会话读取工具,使运行中的角色能把
|
||||
// 另一个 Web 会话的上下文作为外部只读材料拉取过来(design/22 §8.1)。
|
||||
//
|
||||
// 该能力以默认的可达性标记(两者皆 false)注册,从而保持仅主人可用:design/11 §3 的
|
||||
// 策略引擎会对群聊/陌生人会话隐藏其 schema,循环的安全门也会在那里拒绝调用。工具
|
||||
// 自身的 fail-closed 检查(主人环境 + web/web:<uuid> 白名单)运行在那道门之后,
|
||||
// 因此未授权的读取会被双重拒绝。审计链路就是循环的 gate/OnAudit,与其他每个工具
|
||||
// 完全一致 —— 这里不添加任何绕过路径。
|
||||
func (a *app) wireSessionReadTool() {
|
||||
log := logger.WithSource(a.log, "cmd/cyrene")
|
||||
if a.history == nil {
|
||||
log.Warn("session read tool skipped: no history store")
|
||||
return
|
||||
}
|
||||
if a.dispatcher == nil {
|
||||
log.Warn("session read tool skipped: no tool dispatcher")
|
||||
return
|
||||
}
|
||||
reader := sessionread.New(
|
||||
sessionread.WithHistory(a.history),
|
||||
sessionread.WithPersonas(sessionReadPersonas{reg: a.personas}),
|
||||
sessionread.WithLocation(a.sessionReadLocation(log)),
|
||||
sessionread.WithLogger(a.log),
|
||||
)
|
||||
if err := a.dispatcher.Register(reader); err != nil {
|
||||
log.Warn("session read tool register skipped", "error", err)
|
||||
return
|
||||
}
|
||||
log.Info("session read tool registered", "tool", sessionread.ToolName, "owner_only", true)
|
||||
}
|
||||
|
||||
// sessionReadLocation 解析材料时间戳所用的已配置 IANA 时区,使外部 transcript 与
|
||||
// 循环逐轮注入的「当前时间」读同一个时钟(internal/runtime/loop/time.go)。未设置
|
||||
// 时区或加载失败时返回 nil,保持工具的进程本地默认值 —— 读取不该因为一个展示设置
|
||||
// 而失败。
|
||||
func (a *app) sessionReadLocation(log *slog.Logger) *time.Location {
|
||||
if a.cfg == nil || a.cfg.Timezone == "" {
|
||||
return nil
|
||||
}
|
||||
loc, err := time.LoadLocation(a.cfg.Timezone)
|
||||
if err != nil {
|
||||
log.Warn("session read tool timezone load failed; using the process-local zone",
|
||||
"tz", a.cfg.Timezone, "error", err)
|
||||
return nil
|
||||
}
|
||||
return loc
|
||||
}
|
||||
|
||||
// sessionReadPersonas 把人格注册表适配为 sessionread.PersonaNamer。该工具包刻意不
|
||||
// 导入 internal/persona/registry(而是持有一个窄接口,保持分层单向),因此由组合根
|
||||
// 提供显示名查询。
|
||||
type sessionReadPersonas struct {
|
||||
reg *personareg.Registry
|
||||
}
|
||||
|
||||
// PersonaName 实现 sessionread.PersonaNamer:返回人格的显示名;对未知/未注册的角色
|
||||
// 返回 ok=false,使工具展示原始 id 而不是编造一个名字。
|
||||
func (p sessionReadPersonas) PersonaName(id string) (string, bool) {
|
||||
if p.reg == nil || id == "" {
|
||||
return "", false
|
||||
}
|
||||
persona, ok := p.reg.Persona(id)
|
||||
if !ok || persona == nil || persona.Name == "" {
|
||||
return "", false
|
||||
}
|
||||
return persona.Name, true
|
||||
}
|
||||
|
||||
// DefaultPersonaID 实现 sessionread.PersonaNamer:返回库默认角色所绑定的人格,与
|
||||
// (*app).defaultPersonaID 保持一致。空 id 表示材料渲染显式的「未设置角色」标记,
|
||||
// 而不是靠猜。
|
||||
func (p sessionReadPersonas) DefaultPersonaID() string {
|
||||
if p.reg == nil {
|
||||
return ""
|
||||
}
|
||||
if c, ok := p.reg.DefaultCharacter(); ok && c != nil {
|
||||
return c.PersonaID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
|
||||
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/sessionread"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
)
|
||||
|
||||
// testConfigWithTimezone builds the minimal config the location lookup reads.
|
||||
func testConfigWithTimezone(tz string) *config.Config {
|
||||
return &config.Config{Timezone: tz}
|
||||
}
|
||||
|
||||
// newTestHistory opens an isolated in-memory chat-history store for the wiring
|
||||
// tests, mirroring what wire.go hands the composition root.
|
||||
func newTestHistory(t *testing.T) *history.Store {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
conn, err := db.Open(ctx, ":memory:", testLog())
|
||||
if err != nil {
|
||||
t.Fatalf("open memory db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
st, err := history.NewStore(ctx, conn, testLog())
|
||||
if err != nil {
|
||||
t.Fatalf("new history store: %v", err)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// newTestPersonas registers one explicit role plus the default character, so the
|
||||
// adapter's two answers (a role's display name and the default role id) are real.
|
||||
func newTestPersonas(t *testing.T) *personareg.Registry {
|
||||
t.Helper()
|
||||
reg := personareg.New()
|
||||
for _, p := range []*loader.Persona{
|
||||
{
|
||||
ID: "cyrene", Name: "昔涟",
|
||||
Emotion: loader.EmotionParams{InitialState: emotion.Calm, DecayRate: 0.5},
|
||||
Affection: loader.AffectionParams{Initial: 50, Min: 0, Max: 700, Delta: 10},
|
||||
},
|
||||
{
|
||||
ID: "yuki", Name: "雪",
|
||||
Emotion: loader.EmotionParams{InitialState: emotion.Calm, DecayRate: 0.5},
|
||||
Affection: loader.AffectionParams{Initial: 20, Min: 0, Max: 700, Delta: 5},
|
||||
},
|
||||
} {
|
||||
if err := reg.RegisterPersona(p); err != nil {
|
||||
t.Fatalf("RegisterPersona(%s): %v", p.ID, err)
|
||||
}
|
||||
}
|
||||
if err := reg.RegisterCharacter(&personareg.Character{ID: "cyrene", Name: "昔涟", Default: true, PersonaID: "cyrene"}); err != nil {
|
||||
t.Fatalf("RegisterCharacter(cyrene): %v", err)
|
||||
}
|
||||
if err := reg.RegisterCharacter(&personareg.Character{ID: "yuki", Name: "雪", PersonaID: "yuki"}); err != nil {
|
||||
t.Fatalf("RegisterCharacter(yuki): %v", err)
|
||||
}
|
||||
return reg
|
||||
}
|
||||
|
||||
// TestWireSessionReadToolRegistersOwnerOnly locks the composition-root wiring: the
|
||||
// cross-session read tool must land in the capability registry AND carry the
|
||||
// default reachability markers, so a group/stranger session can neither see nor
|
||||
// call it. That is the auditable gate design/22 §8.1 约束 4 relies on — the tool
|
||||
// itself writes no audit record.
|
||||
func TestWireSessionReadToolRegistersOwnerOnly(t *testing.T) {
|
||||
reg := registry.New()
|
||||
d, err := tool.New(reg)
|
||||
if err != nil {
|
||||
t.Fatalf("tool.New: %v", err)
|
||||
}
|
||||
a := &app{log: testLog(), dispatcher: d, history: newTestHistory(t), personas: newTestPersonas(t)}
|
||||
|
||||
a.wireSessionReadTool()
|
||||
|
||||
if _, gerr := reg.Get(sessionread.ToolName); gerr != nil {
|
||||
t.Fatalf("capability %q is not registered: %v", sessionread.ToolName, gerr)
|
||||
}
|
||||
advertised := false
|
||||
for _, s := range d.ToolSchemas() {
|
||||
if s.Name == sessionread.ToolName {
|
||||
advertised = true
|
||||
}
|
||||
}
|
||||
if !advertised {
|
||||
t.Fatalf("tool schemas %v, want %q advertised", d.ToolSchemas(), sessionread.ToolName)
|
||||
}
|
||||
if !restrict.SetContext(reg, restrict.ActorOwner).Allow(sessionread.ToolName) {
|
||||
t.Fatal("owner was denied the session read tool")
|
||||
}
|
||||
if restrict.SetContext(reg, restrict.ActorGroupMember).Allow(sessionread.ToolName) {
|
||||
t.Fatal("group member may invoke the session read tool; want owner-only")
|
||||
}
|
||||
if restrict.SetContext(reg, restrict.ActorStranger).Allow(sessionread.ToolName) {
|
||||
t.Fatal("stranger may invoke the session read tool; want owner-only")
|
||||
}
|
||||
// The registered capability must be the tool itself, not a placeholder.
|
||||
cap, _ := reg.Get(sessionread.ToolName)
|
||||
if _, ok := cap.Value.(*sessionread.Tool); !ok {
|
||||
t.Fatalf("capability value = %T, want *sessionread.Tool", cap.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireSessionReadToolDegradedBoot asserts a boot without a history store or a
|
||||
// dispatcher logs and skips the registration instead of panicking — the tool must
|
||||
// never be registered half-wired and then fail on the model's first call.
|
||||
func TestWireSessionReadToolDegradedBoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
reg := registry.New()
|
||||
d, err := tool.New(reg)
|
||||
if err != nil {
|
||||
t.Fatalf("tool.New: %v", err)
|
||||
}
|
||||
for name, a := range map[string]*app{
|
||||
"no history": {log: testLog(), dispatcher: d},
|
||||
"no dispatcher": {log: testLog(), history: newTestHistory(t)},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
a.wireSessionReadTool()
|
||||
if _, gerr := reg.Get(sessionread.ToolName); gerr == nil {
|
||||
t.Fatalf("the tool was registered in a degraded boot (%s)", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionReadPersonasAdapter locks the adapter the tool's attribution depends
|
||||
// on: a known role answers its display name, an unknown one is reported unknown
|
||||
// (so the raw id is shown instead of a wrong name), and the default is the persona
|
||||
// of the default character.
|
||||
func TestSessionReadPersonasAdapter(t *testing.T) {
|
||||
t.Parallel()
|
||||
adapter := sessionReadPersonas{reg: newTestPersonas(t)}
|
||||
|
||||
if got, ok := adapter.PersonaName("yuki"); !ok || got != "雪" {
|
||||
t.Fatalf("PersonaName(yuki) = %q, %v; want 雪, true", got, ok)
|
||||
}
|
||||
if _, ok := adapter.PersonaName("nobody"); ok {
|
||||
t.Fatal("PersonaName(nobody) reported a name; want unknown")
|
||||
}
|
||||
if got, ok := adapter.PersonaName(""); ok || got != "" {
|
||||
t.Fatalf("PersonaName(\"\") = %q, %v; want \"\", false", got, ok)
|
||||
}
|
||||
if got := adapter.DefaultPersonaID(); got != "cyrene" {
|
||||
t.Fatalf("DefaultPersonaID() = %q, want cyrene", got)
|
||||
}
|
||||
// A nil registry must degrade quietly, not panic: the wiring tolerates a
|
||||
// persona-less boot.
|
||||
empty := sessionReadPersonas{}
|
||||
if _, ok := empty.PersonaName("yuki"); ok || empty.DefaultPersonaID() != "" {
|
||||
t.Fatal("a nil registry answered a role lookup")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionReadLocation asserts the configured zone is honored and an
|
||||
// unresolvable one degrades to nil (the tool's process-local default) instead of
|
||||
// failing the wiring.
|
||||
func TestSessionReadLocation(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := &app{log: testLog(), cfg: testConfigWithTimezone("Asia/Shanghai")}
|
||||
if loc := a.sessionReadLocation(testLog()); loc == nil || loc.String() != "Asia/Shanghai" {
|
||||
t.Fatalf("sessionReadLocation() = %v, want Asia/Shanghai", loc)
|
||||
}
|
||||
bad := &app{log: testLog(), cfg: testConfigWithTimezone("Not/AZone")}
|
||||
if loc := bad.sessionReadLocation(testLog()); loc != nil {
|
||||
t.Fatalf("sessionReadLocation() = %v, want nil for an unresolvable zone", loc)
|
||||
}
|
||||
none := &app{log: testLog()}
|
||||
if loc := none.sessionReadLocation(testLog()); loc != nil {
|
||||
t.Fatalf("sessionReadLocation() = %v, want nil without config", loc)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/skills"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// defaultSkillsDir 是技能 YAML 定义的磁盘目录。它是可选的:目录缺失时只告警并
|
||||
// 跳过技能加载。
|
||||
const defaultSkillsDir = "configs/skills"
|
||||
|
||||
// wireSkills 从 configs/skills 加载技能定义,并把每个技能以 KindSkill 能力注册进
|
||||
// 能力注册表,供插件生态枚举。加载+注册是 M5 的交付物;Runner.Run(端到端执行
|
||||
// 技能)**已实现**(切片 CODE-SK/L2:`Runner.Run` 真执行 + 渐进披露接线;哨兵 `ErrRunPending` 已删除)。
|
||||
func (a *app) wireSkills() {
|
||||
log := logger.WithSource(a.log, "cmd/cyrene")
|
||||
list, err := skills.NewLoader(a.log).LoadDir(defaultSkillsDir)
|
||||
if err != nil {
|
||||
log.Warn("skills load skipped", "dir", defaultSkillsDir, "error", err)
|
||||
return
|
||||
}
|
||||
if len(list) == 0 {
|
||||
log.Debug("no skills defined", "dir", defaultSkillsDir)
|
||||
return
|
||||
}
|
||||
registered := 0
|
||||
for _, s := range list {
|
||||
if err := skills.Register(a.caps, s); err != nil {
|
||||
log.Warn("skill register skipped", "skill", s.Name(), "error", err)
|
||||
continue
|
||||
}
|
||||
registered++
|
||||
}
|
||||
log.Info("skills registered", "registered", registered, "loaded", len(list))
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// streamSubject 是循环的 OnStream 闭包发布每个结构化流事件所用的 bus subject。
|
||||
// gateway hub 订阅 ws.>,并为该 subject 保留一条专用分支,把 model.StreamEvent
|
||||
// 映射为带 seq 序号的下游强类型 WS 事件(message.*/thinking.*/tool_call.*)。
|
||||
//
|
||||
// 它必须与 internal/services/gateway/ws_hub.go 中的 wsStreamSubject 保持一致;
|
||||
// 两个包若共用同一个 const,会把 gateway 拉进主二进制的导入图,因此该字面量
|
||||
// 在此重复一份并加以说明。
|
||||
const streamSubject = "ws.stream"
|
||||
|
||||
// streamChunk 是循环的 OnStream 回调。它把一个结构化流事件发布到 gateway 的流式
|
||||
// subject,由 hub 以对应的强类型 WS 事件广播给每个客户端。未接 bus 时它为空操作,
|
||||
// 且绝不阻塞循环:bus.Publish 是异步的,单个订阅缓冲写满时丢弃事件而不是让模型
|
||||
// 流停摆。
|
||||
func (a *app) streamChunk(ev model.StreamEvent) {
|
||||
if a.bus == nil {
|
||||
return
|
||||
}
|
||||
if err := a.bus.Publish(context.Background(), streamSubject, ev); err != nil {
|
||||
a.log.Warn("stream event publish", "err", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
|
||||
)
|
||||
|
||||
// defaultSubagentTimeout 在别处都未配置时,约束单个子代理的整轮运行。
|
||||
// DevDocs/design/05-子代理.md §7 要求有超时与防失控上限;调度器两者都已实现
|
||||
// (PerAgentTimeout、Abort/CancelAll),因此生产必须给出一个值,否则每代理超时
|
||||
// 形同虚设(调度器里的 0 表示「无超时」)。该值刻意给得宽松 —— 子代理与任何循环
|
||||
// 一样最多跑 8 轮工具 —— 并且是 SUBAGENT_TIMEOUT_SEC 之后的兜底(裁决 02.4.4:
|
||||
// 默认 10min,可配置;subagentToolTimeout 解析出生效值)。
|
||||
const defaultSubagentTimeout = 10 * time.Minute
|
||||
|
||||
// subagentToolTimeout 解析分派工具的默认子代理超时:SUBAGENT_TIMEOUT_SEC 为正时
|
||||
// 以它为准,否则采用代码里的 10 分钟默认值(design/05 §7 裁决 02.4.4)。每次分派
|
||||
// 传入的 timeout_seconds 参数仍会在工具层按次覆盖此值。
|
||||
func (a *app) subagentToolTimeout() time.Duration {
|
||||
if a.cfg != nil && a.cfg.Subagent.TimeoutSec > 0 {
|
||||
return time.Duration(a.cfg.Subagent.TimeoutSec) * time.Second
|
||||
}
|
||||
return defaultSubagentTimeout
|
||||
}
|
||||
|
||||
// wireSubagentTool 向分发器注册子代理分派工具,让 agent 在运行时终于能触达编排层
|
||||
// (在此之前 (*orchestration.Scheduler).Spawn 没有生产调用方,design/05 只是库)。
|
||||
//
|
||||
// 该能力以默认的可达性标记(两者皆 false)注册,从而保持仅主人可用:design/11 §3 的
|
||||
// 策略引擎会对群聊/陌生人会话隐藏其 schema,循环的安全门也会在那里拒绝调用。
|
||||
// design/05 §6/§7 未给群聊/陌生人分派后台 agent 的许可,且能生成子代理属于重操作,
|
||||
// 因此刻意选择安全默认值,而不是让它被意外开启。
|
||||
func (a *app) wireSubagentTool() {
|
||||
log := logger.WithSource(a.log, "cmd/cyrene")
|
||||
if a.scheduler == nil {
|
||||
log.Warn("sub-agent tool skipped: no scheduler")
|
||||
return
|
||||
}
|
||||
spawner := orchestration.NewSpawnTool(a.scheduler,
|
||||
orchestration.WithSpawnLogger(a.log),
|
||||
orchestration.WithSpawnTimeout(a.subagentToolTimeout()))
|
||||
if err := a.dispatcher.Register(spawner); err != nil {
|
||||
log.Warn("sub-agent tool register skipped", "error", err)
|
||||
return
|
||||
}
|
||||
log.Info("sub-agent tool registered", "tool", orchestration.SpawnToolName,
|
||||
"owner_only", true)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
)
|
||||
|
||||
// TestWireSubagentToolRegistersOwnerOnly locks the composition-root wiring: the
|
||||
// dispatch tool must actually land in the capability registry (otherwise
|
||||
// orchestration stays library-only), and it must carry the default reachability
|
||||
// markers so a group/stranger session can neither see nor call it.
|
||||
func TestWireSubagentToolRegistersOwnerOnly(t *testing.T) {
|
||||
reg := registry.New()
|
||||
d, err := tool.New(reg)
|
||||
if err != nil {
|
||||
t.Fatalf("tool.New: %v", err)
|
||||
}
|
||||
a := &app{log: testLog(), dispatcher: d, scheduler: orchestration.New(orchestration.Options{})}
|
||||
|
||||
a.wireSubagentTool()
|
||||
|
||||
if _, gerr := reg.Get(orchestration.SpawnToolName); gerr != nil {
|
||||
t.Fatalf("capability %q is not registered: %v", orchestration.SpawnToolName, gerr)
|
||||
}
|
||||
names := make([]string, 0)
|
||||
for _, s := range d.ToolSchemas() {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
found := false
|
||||
for _, n := range names {
|
||||
if n == orchestration.SpawnToolName {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("tool schemas = %v, want the spawn tool advertised", names)
|
||||
}
|
||||
if !restrict.SetContext(reg, restrict.ActorOwner).Allow(orchestration.SpawnToolName) {
|
||||
t.Fatal("owner was denied the spawn tool")
|
||||
}
|
||||
if restrict.SetContext(reg, restrict.ActorGroupMember).Allow(orchestration.SpawnToolName) {
|
||||
t.Fatal("group member may invoke the spawn tool; want owner-only")
|
||||
}
|
||||
if restrict.SetContext(reg, restrict.ActorStranger).Allow(orchestration.SpawnToolName) {
|
||||
t.Fatal("stranger may invoke the spawn tool; want owner-only")
|
||||
}
|
||||
// The registered capability must be the dispatch tool itself, not a placeholder.
|
||||
cap, _ := reg.Get(orchestration.SpawnToolName)
|
||||
sp, ok := cap.Value.(*orchestration.SpawnTool)
|
||||
if !ok {
|
||||
t.Fatalf("capability value = %T, want *orchestration.SpawnTool", cap.Value)
|
||||
}
|
||||
if _, err := sp.Execute(context.Background(), map[string]any{}); err == nil {
|
||||
t.Fatal("the registered tool accepted a goal-less call")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireSubagentToolNilScheduler asserts a degraded boot (no scheduler) logs and
|
||||
// skips the registration instead of panicking.
|
||||
func TestWireSubagentToolNilScheduler(t *testing.T) {
|
||||
reg := registry.New()
|
||||
d, err := tool.New(reg)
|
||||
if err != nil {
|
||||
t.Fatalf("tool.New: %v", err)
|
||||
}
|
||||
a := &app{log: testLog(), dispatcher: d}
|
||||
|
||||
a.wireSubagentTool()
|
||||
|
||||
if _, gerr := reg.Get(orchestration.SpawnToolName); gerr == nil {
|
||||
t.Fatal("the tool was registered without a scheduler")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPersonaExistsMirrorsAgentPool locks the composition-root half of design/22 §8-2:
|
||||
// the resolver the scheduler validates a dispatched sub-agent's target persona with
|
||||
// must answer exactly like the per-role agent pool — a role is dispatchable precisely
|
||||
// when the loop has an agent carrying its instructions, and nothing else is. So a
|
||||
// persona with no agent is rejected at dispatch time rather than silently answering as
|
||||
// the default role.
|
||||
func TestPersonaExistsMirrorsAgentPool(t *testing.T) {
|
||||
t.Parallel()
|
||||
pool := map[string]*agent.Agent{"yuki": {}, "xilian": {}}
|
||||
exists := personaExists(pool)
|
||||
|
||||
if !exists("yuki") || !exists("xilian") {
|
||||
t.Fatal("a pooled role was reported as unrunnable")
|
||||
}
|
||||
if exists("nobody") || exists("") {
|
||||
t.Fatal("an unpooled role id was accepted as runnable")
|
||||
}
|
||||
// The resolver is what orchestration gates the dispatch on, so wire it the same
|
||||
// way wire.go does and check the gate agrees.
|
||||
sched := orchestration.New(orchestration.Options{PersonaExists: personaExists(map[string]*agent.Agent{})})
|
||||
if sched.PersonaKnown("yuki") {
|
||||
t.Fatal("an empty pool accepted a persona; want the dispatch rejected")
|
||||
}
|
||||
if !sched.PersonaKnown("") {
|
||||
t.Fatal("a persona-less dispatch was rejected by an empty pool; want it unaffected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubagentToolTimeoutFromConfig 锁定组合根的配置接法(design/05 §7 裁决
|
||||
// 02.4.4):未配置时回落 10min 代码默认,SUBAGENT_TIMEOUT_SEC 生效时被真实
|
||||
// 采用——派发工具拿到的是配置值而不是常量。
|
||||
func TestSubagentToolTimeoutFromConfig(t *testing.T) {
|
||||
a := &app{log: testLog()}
|
||||
if got := a.subagentToolTimeout(); got != 10*time.Minute {
|
||||
t.Fatalf("subagentToolTimeout() = %v, want the 10min code default", got)
|
||||
}
|
||||
|
||||
a.cfg = &config.Config{}
|
||||
a.cfg.Subagent.TimeoutSec = 90
|
||||
if got := a.subagentToolTimeout(); got != 90*time.Second {
|
||||
t.Fatalf("subagentToolTimeout() = %v, want 90s from config", got)
|
||||
}
|
||||
|
||||
// 非法(非正)配置不缩小时限:保持代码默认而不是把子代理变成「立即超时」。
|
||||
a.cfg.Subagent.TimeoutSec = -5
|
||||
if got := a.subagentToolTimeout(); got != 10*time.Minute {
|
||||
t.Fatalf("subagentToolTimeout() = %v for a non-positive config, want the code default", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec"
|
||||
)
|
||||
|
||||
// terminalToolName 是 exec/terminal 工具的注册名。
|
||||
const terminalToolName = "terminal"
|
||||
|
||||
// terminalTool 是 sdk.Tool,通过基于授权的 exec 沙箱(exec.Provider)运行命令。
|
||||
// 即使沙箱关闭它也会被注册:此时 Execute 返回 exec.ErrExecutionDisabled,因此
|
||||
// agent 只有在主人显式启用沙箱后才获得执行代码的能力(默认关闭)。
|
||||
type terminalTool struct {
|
||||
prov exec.Provider
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// Name 返回工具标识符。
|
||||
func (t *terminalTool) Name() string { return terminalToolName }
|
||||
|
||||
// Description 说明工具做什么。
|
||||
func (t *terminalTool) Description() string {
|
||||
return "Run a terminal command through the sandboxed exec provider and return its stdout, stderr and exit code."
|
||||
}
|
||||
|
||||
// Parameters 返回输入 JSON Schema。
|
||||
func (t *terminalTool) Parameters() (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"command": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The command line to run; split into argv by whitespace (no shell interpretation).",
|
||||
},
|
||||
"workdir": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional working directory, confined to an allowed sandbox root.",
|
||||
},
|
||||
"timeout": map[string]any{
|
||||
"type": "number",
|
||||
"description": "Optional timeout in seconds (default 30).",
|
||||
},
|
||||
},
|
||||
"required": []any{"command"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Execute 通过 exec provider 运行命令并返回结果。沙箱关闭时 provider 返回
|
||||
// exec.ErrExecutionDisabled,分发器会把它包装成可安全回喂给 LLM 的工具错误。
|
||||
func (t *terminalTool) Execute(ctx context.Context, args any) (any, error) {
|
||||
m, ok := args.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("terminal: args must be an object")
|
||||
}
|
||||
cmd, _ := m["command"].(string)
|
||||
argv := strings.Fields(cmd)
|
||||
if len(argv) == 0 {
|
||||
return nil, fmt.Errorf("terminal: command is required")
|
||||
}
|
||||
if t.log != nil {
|
||||
t.log.Debug("terminal tool invoked", "cmd", strings.Join(argv, " "))
|
||||
}
|
||||
req := exec.ExecRequest{Command: argv, Timeout: timeoutArg(m["timeout"])}
|
||||
if wd, ok := m["workdir"].(string); ok {
|
||||
req.WorkDir = wd
|
||||
}
|
||||
res, err := t.prov.Execute(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"stdout": res.Stdout,
|
||||
"stderr": res.Stderr,
|
||||
"exit": res.ExitCode,
|
||||
"truncated": res.Truncated,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// timeoutArg 把可选的 timeout 参数(秒)转换为 duration。缺失或非正值返回 0,
|
||||
// 由 provider 应用自己的默认值。
|
||||
func timeoutArg(v any) time.Duration {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
if n > 0 {
|
||||
return time.Duration(n * float64(time.Second))
|
||||
}
|
||||
case int:
|
||||
if n > 0 {
|
||||
return time.Duration(n) * time.Second
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// wireTerminalTool 向分发器注册 terminal 工具,使 agent 能发出沙箱内终端命令。
|
||||
// 未接入 exec provider 时它只告警并空操作,以保证调用方安全。
|
||||
func (a *app) wireTerminalTool() {
|
||||
log := logger.WithSource(a.log, "cmd/cyrene")
|
||||
if a.execProv == nil {
|
||||
log.Warn("terminal tool skipped: no exec provider")
|
||||
return
|
||||
}
|
||||
if err := a.dispatcher.Register(&terminalTool{
|
||||
prov: a.execProv,
|
||||
log: logger.WithSource(a.log, "cmd/cyrene"),
|
||||
}); err != nil {
|
||||
log.Warn("terminal tool register skipped", "error", err)
|
||||
return
|
||||
}
|
||||
log.Info("terminal tool registered", "sandbox_enabled", a.cfg.ExecSandbox.Enabled)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/vision"
|
||||
)
|
||||
|
||||
// visionToolName 与 ocrToolName 是 vision 与 OCR 工具的注册名。它们在此与工具注册
|
||||
// 绑定,并由工具分发器通告给 agent。
|
||||
const (
|
||||
visionToolName = "vision"
|
||||
ocrToolName = "ocr"
|
||||
)
|
||||
|
||||
// wireVision 注册 vision 与 OCR 工具以及 vision 模型路由。
|
||||
//
|
||||
// vision 适配器是 OpenAI 兼容的 /chat/completions 端点,与聊天 LLM 共用同一 base
|
||||
// URL(LLM_BASE_URL),配置了专用模型(LLM_VISION_MODEL)时使用该模型。未配置
|
||||
// base URL 时只告警并跳过这些工具,启动因此绝不阻塞;只要配置了 vision 模型
|
||||
// (在 wireRuntime 中),模型路由器仍会绑定到它。
|
||||
func (a *app) wireVision() {
|
||||
log := logger.WithSource(a.log, "cmd/cyrene")
|
||||
if strings.TrimSpace(a.cfg.LLM.BaseURL) == "" {
|
||||
log.Warn("vision/ocr tools skipped: no LLM base url configured")
|
||||
return
|
||||
}
|
||||
adapter := vision.NewOpenAICompatible(a.cfg.LLM,
|
||||
vision.WithModel(a.cfg.LLM.VisionModel),
|
||||
// 同源 upload 产物(`/api/v1/files/<name>`)走**本地读取**:与自己取回相比更快,
|
||||
// 且在「服务端路由不到自己的广告 host」(如安卓模拟器 10.0.2.2、反向代理改写)
|
||||
// 的部署下才取得到图。目录必须与 upload 的落盘位置一致 = `<DataDir>/uploads`。
|
||||
vision.WithUploadsDir(filepath.Join(resolveDataDir(a.cfg), "uploads")),
|
||||
vision.WithLogger(a.log))
|
||||
if err := vision.RegisterTools(a.caps,
|
||||
vision.NewVisionTool(visionToolName, adapter),
|
||||
vision.NewOCRTool(ocrToolName, adapter),
|
||||
); err != nil {
|
||||
log.Warn("vision/ocr tool register skipped", "error", err)
|
||||
return
|
||||
}
|
||||
log.Info("vision/ocr tools registered", "vision_model", a.cfg.LLM.VisionModel)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||||
)
|
||||
|
||||
// wireVoice 从配置构建语音 STT/TTS 降级链并挂到 app 上,供 WebSocket 的
|
||||
// voice_input/voice_output 路径使用。降级链依次为 云 → 本地 → edge-tts/espeak →
|
||||
// 静默,因此端点或 API key 缺失绝不会阻塞启动(设计上接受静默兜底)。
|
||||
func (a *app) wireVoice() {
|
||||
a.voice = voice.New(a.cfg.Voice)
|
||||
logger.WithSource(a.log, "cmd/cyrene").Info("voice chain ready",
|
||||
"tts_provider", a.cfg.Voice.TTSProvider,
|
||||
"stt_provider", a.cfg.Voice.STTProvider,
|
||||
"dashscope_configured", a.cfg.Voice.DashscopeAPIKey != "")
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
package main
|
||||
|
||||
// 本文件是「搜索提供商」页**五个动作本体**的数据面(切片 L1/PLG-2 的执行面接线):
|
||||
// 可变源的读写(含失败分类)、表格行 / 下拉候选 / 掩码回填的翻译、以及请求体形状的解析。
|
||||
//
|
||||
// 上游事实(`plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` /
|
||||
// `plugin.reauthProvider` / `plugin.providers`)由组合根登记到插件管理器上,登记入口在
|
||||
// `wire_plugin_actions.go`,探针的传输层绑定在 `websearch_probe.go`。
|
||||
//
|
||||
// 三条边界(与登记面同):只经 runtimeconfig 读写可变源;凭据只出掩码;形状不对就报错。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||
)
|
||||
|
||||
// --- 四个动作 + 一个候选来源 -------------------------------------------------
|
||||
|
||||
// listProviders 是表格区块的数据源:把可变源里的各条配置翻译成面板行
|
||||
// (provider / state / enabled;**凭据与地址都不进行数据**)。
|
||||
func (e webSearchActionEnv) listProviders(_ context.Context, _ pluginmgr.ActionRequest) (any, error) {
|
||||
f, err := e.loadFile()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"rows": providerRows(f)}, nil
|
||||
}
|
||||
|
||||
// listProviderOptions 给出下拉候选:本期全部已登记的 provider(含尚未配置的家)。
|
||||
//
|
||||
// 每项是 `{value,label}`(search.ProviderOption):**`value` 才是落盘的值** —— 它逐字等于
|
||||
// search.KnownNames(),会被写进 web-search.json 并被注册表按名字取用;`label` 只是显示名
|
||||
// (中文 + 括注 id),前端拿它渲染下拉与表格,任何写盘路径都不该碰它。
|
||||
//
|
||||
// 给全部而不是「已配置的家」的理由见 search.ProviderOptions:表单的用途就是**新增**
|
||||
// 一家,只列已配置的会让第一次配置无从选起。
|
||||
func (e webSearchActionEnv) listProviderOptions(_ context.Context, _ pluginmgr.ActionRequest) (any, error) {
|
||||
return map[string]any{"options": search.ProviderOptions()}, nil
|
||||
}
|
||||
|
||||
// saveProvider 新增 / 修改一条 provider 配置(表单提交,写动作)。
|
||||
//
|
||||
// 三态写回(与前端 `schema/values.ts:planSubmit` 逐条同义):
|
||||
// - apiKey **缺位** ⇒ 保留原值(面板只持有掩码,「留空 = 不改」必须是真的);
|
||||
// - apiKey **空串** ⇒ 显式清空(前端只有勾了「清除」才会送出空串);
|
||||
// - baseUrl / enabled 缺位或空串 ⇒ 保留;给出(含 false / 空地址)⇒ 采用。
|
||||
//
|
||||
// 同一次提交里还可能带三个**文件级**字段(`primary` / `fallback` / `maxResults`):它们不是
|
||||
// 这一条 provider 的属性,而是「谁先用 / 失败后按什么顺序降级 / 单次最多几条」,因此合并到
|
||||
// 文件级而不是条目上(解析与校验在 websearch_selection.go)。缺位 = 不改动;但给出了**不合法**
|
||||
// 的值(如 `maxResults` ≤0 / 非整数)⇒ 整笔 400,**绝不静默丢弃** —— 静默丢弃会让用户以为
|
||||
// 上限已经改了,而跑的还是旧的那个(那正是这一片要消灭的那条「我改了它却没变」)。
|
||||
//
|
||||
// 落盘之后由组合根的热重载事务接管(`websearch_reload.go`:重建工具 + 换注册表条目,
|
||||
// 失败则连同本次落盘一起回滚)—— 本函数只负责「把改动存进去」这一件事。
|
||||
//
|
||||
// 只接受**已登记**的名字:未登记的家存进去也永远不会被选中(注册表按名字取),
|
||||
// 那会让「我明明配了 tavily」变成一句假话 —— 所以在这里就拒。
|
||||
func (e webSearchActionEnv) saveProvider(_ context.Context, req pluginmgr.ActionRequest) (any, error) {
|
||||
values, err := actionValuesObject(req.Payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name, err := requiredProviderName(values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := requireKnownProvider(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cur, err := e.loadFile()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prev, existed := findProviderEntry(cur, name)
|
||||
entry, err := mergeProviderEntry(prev, values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry.Provider = name
|
||||
next, setPrimary, err := e.mergeSelectionPatch(cur, values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
final := runtimeconfig.UpsertProviderEntry(next, entry)
|
||||
// 🔴 校验必须在**并条之后**(顺序理由见 websearch_selection.go 的 mergeSelectionPatch):
|
||||
// 主 provider 配没配齐,取决于**这次刚落进文件的凭据** —— 拿旧文件校验会把
|
||||
// 「第一次填 key + 同一次提交里选它」整笔回滚,连 key 一起丢。
|
||||
if setPrimary {
|
||||
if err := e.checkPrimaryUsable(final, final.Provider); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := runtimeconfig.SaveWebSearch(e.path, final); err != nil {
|
||||
e.log.Error("web-search panel config write failed", "path", e.path, "provider", name, "error", err)
|
||||
return nil, pluginmgr.NewActionError(actionCodeConfigWriteFailed,
|
||||
"面板配置写不进去(磁盘或权限问题),本次改动**没有**生效", http.StatusInternalServerError, err)
|
||||
}
|
||||
e.log.Info("web-search provider saved from panel",
|
||||
"path", e.path, "provider", name, "created", !existed, "enabled", entry.IsEnabled())
|
||||
return map[string]any{
|
||||
"saved": true,
|
||||
"provider": name,
|
||||
"created": !existed,
|
||||
"enabled": entry.IsEnabled(),
|
||||
// 文件级取值的**落盘后**样子(没给这几个键时也如实回显当前值):面板按它回显
|
||||
// 「谁先用」,也让「这次保存有没有动到选择 / 上限」有可机读的答案。
|
||||
"primary": strings.ToLower(strings.TrimSpace(final.Provider)),
|
||||
"fallback": selectionNames(final.Fallback),
|
||||
// maxResults 的 0 = 文件里未声明(由 `.env` / 代码默认兜底),原样回显,不编一个假值。
|
||||
"maxResults": final.MaxResults,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// testProvider 对一条配置**真发一次最小检索**(行内动作「测试连通」)。
|
||||
//
|
||||
// 凭据与地址都从可变源读(面板里刚存下的就是它),不从前端传上来的行数据读
|
||||
// —— 行数据里没有 key(列表列刻意不带凭据),也不该由客户端指定要探的目标。
|
||||
//
|
||||
// 失败一律回**结构化非 2xx**:面板的行内动作只在失败时显示文案(成功分支只说
|
||||
// 「已完成」),把「探不通」放进 200 的 data 里等于让界面报一句假成功。
|
||||
func (e webSearchActionEnv) testProvider(ctx context.Context, req pluginmgr.ActionRequest) (any, error) {
|
||||
name, err := providerNameOf(req.Payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := requireKnownProvider(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := e.loadFile()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry, _ := findProviderEntry(f, name)
|
||||
base := e.effectiveBaseURL(name, entry)
|
||||
target, err := newProbeTarget(base)
|
||||
if err != nil {
|
||||
// 地址不合法 / 为空 ⇒ 这条没配齐(探针自己也会这么判,但这里能给出更准的话)。
|
||||
return nil, pluginmgr.NewActionError(search.ProbeNotConfigured,
|
||||
"这条没配齐:接口地址为空或不是 http(s) 绝对地址", http.StatusBadRequest, err)
|
||||
}
|
||||
cfg := search.Config{
|
||||
APIKey: entry.APIKey,
|
||||
BaseURL: base,
|
||||
Client: target.client(e.probeTimeout(f)),
|
||||
MaxBytes: webSearchProbeMaxBytes,
|
||||
Guard: target.guard,
|
||||
}
|
||||
res := search.Probe(ctx, name, cfg, 0)
|
||||
if !res.OK {
|
||||
e.log.Warn("web-search panel probe failed",
|
||||
"provider", name, "code", res.ErrorCode, "latencyMs", res.LatencyMS)
|
||||
return nil, pluginmgr.NewActionError(res.ErrorCode, res.Error, probeErrorStatus(res.ErrorCode), nil)
|
||||
}
|
||||
e.log.Info("web-search panel probe ok", "provider", name, "count", res.Count, "latencyMs", res.LatencyMS)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// reauthProvider 把一条配置**带回表单**(行内动作「重配凭据」,design/09 §5.2.1 示例的
|
||||
// reauth 语义):返回 `{values:{...}}`,前端把它灌进唯一的 form 区块。
|
||||
//
|
||||
// 两处口径:
|
||||
// - apiKey 回**掩码**(读侧无明文,§5.4.2):前端对 secret 字段一律不回填输入框,
|
||||
// 只把它算作「盘上已有值」⇒ 留空 = 不改动、要清空必须显式勾选;
|
||||
// - 其余字段回**盘上那条的原样**,不做默认值回填:回填代码默认地址会让一次
|
||||
// 「只改 key」的保存把默认地址固化进文件(面板显示的是「该条」,不是「生效值」)。
|
||||
//
|
||||
// 文件级字段(`maxResults` 在内)也按同一条口径回填:**盘上有值才回**。上限不回填的话表单
|
||||
// 里那个框是空的,用户既看不见当前上限、也无从改起;而盘上没写(0 = 未声明)时**不回** 0 ——
|
||||
// 0 会被前端当成一个值送回来,写侧又拒 ≤0,那会让「只改 key」的保存整笔进不去。
|
||||
func (e webSearchActionEnv) reauthProvider(_ context.Context, req pluginmgr.ActionRequest) (any, error) {
|
||||
name, err := providerNameOf(req.Payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := requireKnownProvider(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := e.loadFile()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry, exists := findProviderEntry(f, name)
|
||||
values := map[string]any{
|
||||
"provider": name,
|
||||
"baseUrl": entry.BaseURL,
|
||||
"enabled": !exists || entry.IsEnabled(),
|
||||
}
|
||||
// 三个文件级字段一并回填(主 provider / 降级链 / 条数上限):不然用户「清空降级链」在前端
|
||||
// 的三态写回里会落成缺位(= 不改动),「我清掉了它」就成了一句假话;上限同理 ——
|
||||
// 不回填就看不见盘上的值,改上限也就无从下手。
|
||||
addSelectionValues(values, f)
|
||||
if masked, ok := maskedProviderEntry(f, name); ok && strings.TrimSpace(masked.APIKey) != "" {
|
||||
values["apiKey"] = masked.APIKey
|
||||
}
|
||||
return map[string]any{"values": values}, nil
|
||||
}
|
||||
|
||||
// --- 可变源读写(含失败分类) -------------------------------------------------
|
||||
|
||||
// loadFile 读可变源。
|
||||
//
|
||||
// 文件**缺失或为空** = 「还没配过」(启动期已由 `.env` 播种;极端情况下面板先于启动期
|
||||
// 被调用)⇒ 返回空文件,不是错误。
|
||||
// 文件**存在但不合法** ⇒ 结构化失败:绝不把损坏配置当成空配置 —— 那会让一次保存
|
||||
// 静默盖掉一份人工写坏但可救回的内容(与启动期的处置同向:回落但不覆盖)。
|
||||
func (e webSearchActionEnv) loadFile() (runtimeconfig.WebSearchFile, error) {
|
||||
f, found, err := runtimeconfig.LoadWebSearch(e.path)
|
||||
if err != nil {
|
||||
e.log.Error("web-search panel config unreadable", "path", e.path, "error", err)
|
||||
return runtimeconfig.WebSearchFile{}, pluginmgr.NewActionError(actionCodeConfigUnreadable,
|
||||
"面板配置读不出来(文件损坏或权限不足),本次**没有**改动它;详见服务端日志",
|
||||
http.StatusInternalServerError, err)
|
||||
}
|
||||
if !found {
|
||||
return runtimeconfig.WebSearchFile{
|
||||
Version: runtimeconfig.WebSearchVersion,
|
||||
Providers: []runtimeconfig.ProviderEntry{},
|
||||
}, nil
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// providerRows 把各条配置翻译成面板表格行(凭据与地址都不进行数据)。
|
||||
func providerRows(f runtimeconfig.WebSearchFile) []search.ProviderRow {
|
||||
inputs := make([]search.ProviderInput, 0, len(f.Providers))
|
||||
for _, p := range f.Providers {
|
||||
inputs = append(inputs, search.ProviderInput{
|
||||
Provider: p.Provider,
|
||||
APIKey: p.APIKey,
|
||||
BaseURL: p.BaseURL,
|
||||
Enabled: p.IsEnabled(),
|
||||
})
|
||||
}
|
||||
return search.BuildRows(inputs)
|
||||
}
|
||||
|
||||
// findProviderEntry 按名字取一条配置(大小写与首尾空白不敏感,与
|
||||
// UpsertProviderEntry 的匹配键同一口径)。
|
||||
func findProviderEntry(f runtimeconfig.WebSearchFile, name string) (runtimeconfig.ProviderEntry, bool) {
|
||||
want := strings.ToLower(strings.TrimSpace(name))
|
||||
for _, p := range f.Providers {
|
||||
if strings.EqualFold(strings.TrimSpace(p.Provider), want) {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return runtimeconfig.ProviderEntry{}, false
|
||||
}
|
||||
|
||||
// maskedProviderEntry 取某条配置的**掩码副本**(复用 runtimeconfig 的唯一掩码出口,
|
||||
// 不在这里另写一份 maskToken)。
|
||||
func maskedProviderEntry(f runtimeconfig.WebSearchFile, name string) (runtimeconfig.ProviderEntry, bool) {
|
||||
return findProviderEntry(runtimeconfig.MaskedWebSearchFile(f), name)
|
||||
}
|
||||
|
||||
// effectiveBaseURL 给出一条配置**实际会用的**基地址:盘上有就用盘上的,没有就回落到
|
||||
// **该接入位的代码默认基地址**(`plugins/tools/io/search` 的接入位表,唯一出处)。
|
||||
//
|
||||
// 进位链与装配期**同一个函数**(resolveBaseURL):searxng 的默认基地址是空 —— 它的地址
|
||||
// 为空本身就是「关掉这家」,回落会让「清空地址」变成假话。
|
||||
func (e webSearchActionEnv) effectiveBaseURL(name string, entry runtimeconfig.ProviderEntry) string {
|
||||
return resolveBaseURL(name, entry.BaseURL)
|
||||
}
|
||||
|
||||
// probeTimeout 给出一次探针的超时上限:面板文件的 timeout 优先(面板里刚存的就是它),
|
||||
// 其次 `.env`/代码默认值,最后是包内兜底 —— 与工具运行时的裁决顺序一致。
|
||||
func (e webSearchActionEnv) probeTimeout(f runtimeconfig.WebSearchFile) time.Duration {
|
||||
if d, err := f.TimeoutDuration(); err == nil && d > 0 {
|
||||
return d
|
||||
}
|
||||
if e.envCfg.Timeout > 0 {
|
||||
return e.envCfg.Timeout
|
||||
}
|
||||
return webSearchProbeTimeout
|
||||
}
|
||||
|
||||
// --- 载荷解析(形状不对就报错,不静默当空) ------------------------------------
|
||||
|
||||
// actionObject 取请求体的 JSON 对象。空体(nil)返回 (nil, nil):少数动作不需要参数。
|
||||
func actionObject(payload any) (map[string]any, error) {
|
||||
if payload == nil {
|
||||
return nil, nil
|
||||
}
|
||||
obj, ok := payload.(map[string]any)
|
||||
if !ok {
|
||||
return nil, invalidPayloadActionError("请求体必须是一个 JSON 对象")
|
||||
}
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
// actionValuesObject 取 `{values:{...}}`(表单提交的形状,见前端 formState.useFormSubmit)。
|
||||
func actionValuesObject(payload any) (map[string]any, error) {
|
||||
obj, err := actionObject(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if obj == nil {
|
||||
return nil, invalidPayloadActionError("缺少 values 对象(表单提交的形状是 {values:{...}})")
|
||||
}
|
||||
values, ok := obj["values"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, invalidPayloadActionError("缺少 values 对象(表单提交的形状是 {values:{...}})")
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// providerNameOf 从载荷里取 provider 名(三种**显式**来源,不猜字段):
|
||||
// 行内动作给 `{row:{provider}}`(前端 tableState),也可给 `{values:{provider}}`
|
||||
// (与表单同一形状),或顶层直接给 `provider`。
|
||||
func providerNameOf(payload any) (string, error) {
|
||||
obj, err := actionObject(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if obj == nil {
|
||||
return "", invalidPayloadActionError("缺少 provider 名(行内动作给 row.provider)")
|
||||
}
|
||||
for _, key := range []string{"row", "values", "provider"} {
|
||||
switch v := obj[key].(type) {
|
||||
case string:
|
||||
if key == "provider" && strings.TrimSpace(v) != "" {
|
||||
return normalizeProviderName(v), nil
|
||||
}
|
||||
case map[string]any:
|
||||
if name := actionString(v, "provider"); name != "" {
|
||||
return normalizeProviderName(name), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", invalidPayloadActionError("缺少 provider 名(行内动作给 row.provider,表单给 values.provider)")
|
||||
}
|
||||
|
||||
// requiredProviderName 取表单里的 provider 名(必填)。
|
||||
func requiredProviderName(values map[string]any) (string, error) {
|
||||
name := actionString(values, "provider")
|
||||
if name == "" {
|
||||
return "", invalidPayloadActionError("provider 必填:请选择 / 填写要配置的提供商名")
|
||||
}
|
||||
return normalizeProviderName(name), nil
|
||||
}
|
||||
|
||||
// requireKnownProvider 拒掉本期没登记的名字(存了也永远不会被选中;探它只会得到
|
||||
// 「未登记」)。错误码与文案与 search 的探针口径一致,前端可按同一个码本地化。
|
||||
func requireKnownProvider(name string) error {
|
||||
if search.Known(name) {
|
||||
return nil
|
||||
}
|
||||
return pluginmgr.NewActionError(actionCodeUnknownProvider,
|
||||
fmt.Sprintf("未登记的搜索提供商 %q(本期接入位:%s)", name, strings.Join(search.KnownNames(), ", ")),
|
||||
http.StatusBadRequest, nil)
|
||||
}
|
||||
|
||||
// actionString 取一个字符串字段(去首尾空白;非字符串与空串都算「没给」)。
|
||||
func actionString(m map[string]any, key string) string {
|
||||
s, ok := m[key].(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// normalizeProviderName 归一 provider 名(小写 + 去空白),与注册表/可变源的匹配键同口径。
|
||||
func normalizeProviderName(name string) string {
|
||||
return strings.ToLower(strings.TrimSpace(name))
|
||||
}
|
||||
|
||||
// mergeProviderEntry 把表单补丁合并进一条配置(三态写回,见 saveProvider 的注释)。
|
||||
// enabled 给了非布尔值时**显式报错**:静默保留会让「我关了它」变成假话。
|
||||
func mergeProviderEntry(prev runtimeconfig.ProviderEntry, values map[string]any) (runtimeconfig.ProviderEntry, error) {
|
||||
out := prev
|
||||
if v, ok := values["apiKey"]; ok {
|
||||
key, err := actionOptionalString(v, "apiKey")
|
||||
if err != nil {
|
||||
return runtimeconfig.ProviderEntry{}, err
|
||||
}
|
||||
out.APIKey = key
|
||||
}
|
||||
if v, ok := values["baseUrl"]; ok {
|
||||
base, err := actionOptionalString(v, "baseUrl")
|
||||
if err != nil {
|
||||
return runtimeconfig.ProviderEntry{}, err
|
||||
}
|
||||
out.BaseURL = base
|
||||
}
|
||||
on, set, err := actionOptionalBool(values["enabled"], "enabled")
|
||||
if err != nil {
|
||||
return runtimeconfig.ProviderEntry{}, err
|
||||
}
|
||||
if set {
|
||||
out.Enabled = runtimeconfig.EnableFlag(on)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// actionOptionalString 校验一个可选字符串字段:缺位 ⇒ 空串(调用方按「未给」处理);
|
||||
// 非字符串 ⇒ 报错(把类型错静默当成空串 = 一次保存悄悄清空了某个字段)。
|
||||
func actionOptionalString(v any, key string) (string, error) {
|
||||
if v == nil {
|
||||
return "", nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", invalidPayloadActionError(fmt.Sprintf("%s 必须是字符串", key))
|
||||
}
|
||||
return strings.TrimSpace(s), nil
|
||||
}
|
||||
|
||||
// actionOptionalBool 解析可选开关:缺位 / nil / 空串 ⇒ 未改动;bool ⇒ 采用;
|
||||
// 其它类型 ⇒ 报错(理由同 actionOptionalString)。
|
||||
func actionOptionalBool(v any, key string) (value bool, set bool, err error) {
|
||||
switch raw := v.(type) {
|
||||
case nil:
|
||||
return false, false, nil
|
||||
case bool:
|
||||
return raw, true, nil
|
||||
case string:
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return false, false, nil
|
||||
}
|
||||
return false, false, invalidPayloadActionError(fmt.Sprintf("%s 必须是布尔值", key))
|
||||
default:
|
||||
return false, false, invalidPayloadActionError(fmt.Sprintf("%s 必须是布尔值", key))
|
||||
}
|
||||
}
|
||||
|
||||
// invalidPayloadActionError 造一个 400 invalid_payload(形状错 = 调用方错,不是上游错)。
|
||||
func invalidPayloadActionError(message string) error {
|
||||
return pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload, message, http.StatusBadRequest, nil)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
// 本文件钉住搜索提供商动作面的**危险/边界结局**(切片 L1/PLG-2):损坏配置不许被覆盖、
|
||||
// 装配期要如实点名「声明了却没实现」的动作。它们与主用例集分开成文件的理由只有一条:
|
||||
// `wire_plugin_actions_test.go` 已接近「文件 ≤500 行」的上限(AGENTS §10)。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||
)
|
||||
|
||||
// TestWebSearchActionConfigUnreadableIsReported 锁定「文件存在但不合法」的口径:
|
||||
// 结构化失败(500 config_unreadable),**不覆盖**原文件 —— 损坏的配置可以人工救回。
|
||||
func TestWebSearchActionConfigUnreadableIsReported(t *testing.T) {
|
||||
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||
const broken = `{"version":1,"providers":[{"provider":"bocha"},{"provider":"BOCHA"}]}`
|
||||
wsWriteRawPanel(t, env.path, broken)
|
||||
|
||||
_, err := env.dispatch(t, search.ActionListProviders, map[string]any{})
|
||||
ae, ok := pluginmgr.AsActionError(err)
|
||||
if !ok || ae.Code != actionCodeConfigUnreadable || ae.Status != http.StatusInternalServerError {
|
||||
t.Fatalf("err = %#v, want 500 config_unreadable", err)
|
||||
}
|
||||
if _, err := env.dispatch(t, search.ActionSaveProvider, map[string]any{
|
||||
"values": map[string]any{"provider": search.NameBocha, "apiKey": "sk-x"},
|
||||
}); err == nil {
|
||||
t.Fatal("配置不合法时必须拒绝保存(否则会静默盖掉可救回的原文件)")
|
||||
}
|
||||
raw, rerr := os.ReadFile(env.path)
|
||||
if rerr != nil {
|
||||
t.Fatalf("read: %v", rerr)
|
||||
}
|
||||
if string(raw) != broken {
|
||||
t.Fatalf("原文件被改动了:%q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnImpliedButMissingNamesGaps 锁定装配期的**核对**:声明里有、登记里没有的动作
|
||||
// 必须在日志里点名(否则用户只会看到一个点了回 501 的按钮)。这里直接覆盖核对函数本身。
|
||||
func TestWarnImpliedButMissingNamesGaps(t *testing.T) {
|
||||
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||
b := bus.New()
|
||||
defer b.Close()
|
||||
mgr := pluginmgr.NewManager(registry.New(), filepath.Join(cfg.DataDir, "plugins.json"), testLog())
|
||||
defer mgr.Close(context.Background())
|
||||
|
||||
var buf bytes.Buffer
|
||||
log := logger.New(logger.Options{Out: &buf, Level: "debug"})
|
||||
wirePanelHub(&app{cfg: cfg, log: log, bus: b, plugins: mgr})
|
||||
hub := mgr.Panels()
|
||||
|
||||
// 模拟「插件声明新增了一个动作,但宿主忘了给它接线」:补一条声明(Declare 是整体
|
||||
// 替换语义 ⇒ 原有四个动作 + 候选来源仍在),只多一个没人实现的名字。
|
||||
extra := sdk.Panel{
|
||||
ID: "extra", Title: "额外页",
|
||||
Sections: []sdk.Section{{
|
||||
Type: sdk.SectionTable, ID: "t", Title: "额外表",
|
||||
Source: &sdk.Source{Action: "plugin.notWired"},
|
||||
Columns: []sdk.Column{{Key: "k", Label: "K"}},
|
||||
}},
|
||||
}
|
||||
if _, err := hub.Declare(search.PanelPluginID, append(search.AdminPanels(), extra)); err != nil {
|
||||
t.Fatalf("Declare: %v", err)
|
||||
}
|
||||
warnImpliedButMissing(hub, log)
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "plugin.notWired") || !strings.Contains(out, "no handler") {
|
||||
t.Fatalf("声明了却没实现的动作必须被点名(否则只剩一个回 501 的按钮): %q", out)
|
||||
}
|
||||
// 已登记的动作不得被误报。
|
||||
if idx := strings.Index(out, "action=plugin.listProviders"); idx >= 0 &&
|
||||
strings.Contains(out[idx:], "no handler") {
|
||||
t.Fatalf("已登记的动作被误报成缺处理器: %q", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package main
|
||||
|
||||
// 本文件钉住「面板文件的 `maxResults` 就是运行期的条数上限」这条路**通了**
|
||||
// (主人 2026-10-07 裁定;口径见 design/27 §3 与 `cmd/cyrene/wire_io_tools.go` 的
|
||||
// `effectiveSearchConfig` —— 面板文件是可变源,`.env` 只播种)。
|
||||
//
|
||||
// 上半部分直接写盘(测投影:文件里的 `maxResults` 就是真上限);下半部分走**面板动作**
|
||||
// (`plugin.saveProvider` 带上 `maxResults` ⇒ 热重载 ⇒ 下一次搜索真的最多那么多条),
|
||||
// 并钉住三态:缺位 = 不改动、正整数 = 采用、其余 = 400 invalid_payload(绝不静默丢弃)。
|
||||
//
|
||||
// 夹具一律写进**本用例自己的** `$DATA_DIR`(`wsLoadConfig` 的 `t.TempDir()`):
|
||||
// 生产文件 `data/config/plugins/web-search.json` 不在测试的触碰范围内。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||
)
|
||||
|
||||
// wsManyResultsBody 造一份 searxng 形状、n 条结果的假上游应答。
|
||||
// link 逐条不同:`search.Finalize` 按 link 去重,重复的 link 会让「截断到 N」看不出效果。
|
||||
func wsManyResultsBody(n int) string {
|
||||
items := make([]string, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
items = append(items, fmt.Sprintf(
|
||||
`{"title":"T%d","url":"https://r%d.example/%d","content":"c%d"}`, i, i, i, i))
|
||||
}
|
||||
return `{"results":[` + strings.Join(items, ",") + `]}`
|
||||
}
|
||||
|
||||
// wsPanelWithMaxResults 把一份夹具面板配置写进 cfg 的数据目录(maxResults 给 0 = 未声明)。
|
||||
func wsPanelWithMaxResults(t *testing.T, cfg *config.Config, maxResults int, upstream string) {
|
||||
t.Helper()
|
||||
if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{
|
||||
Version: runtimeconfig.WebSearchVersion,
|
||||
Provider: search.NameSearXNG,
|
||||
Fallback: []string{},
|
||||
MaxResults: maxResults,
|
||||
Providers: []runtimeconfig.ProviderEntry{
|
||||
{Provider: search.NameSearXNG, BaseURL: upstream, Enabled: runtimeconfig.EnableFlag(true)},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("写面板配置: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// wsSearchCount 走注册面里那把 web-search(组合根装配出来的那一把)打一次,返回条数。
|
||||
func wsSearchCount(t *testing.T, a *app, args map[string]any) int {
|
||||
t.Helper()
|
||||
got, err := a.dispatcher.Invoke(context.Background(), webSearchToolName, args)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
m, ok := got.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("工具返回值类型 = %T", got)
|
||||
}
|
||||
n, ok := m["count"].(int)
|
||||
if !ok {
|
||||
t.Fatalf("count 不是 int: %#v", m["count"])
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestWebSearchPanelMaxResultsIsTheRuntimeLimit 是这条路的主判据:`.env` 写 5、
|
||||
// 面板文件写 20 ⇒ 生效值必须是 **20**(面板优先),且它仍是**真上限**
|
||||
// (模型要 50 也只给 20、要 3 就给 3)。
|
||||
func TestWebSearchPanelMaxResultsIsTheRuntimeLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
up := newWSFakeUpstream(t, http.StatusOK, wsManyResultsBody(30))
|
||||
cfg := wsLoadConfig(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
|
||||
"WEB_SEARCH_MAX_RESULTS=5",
|
||||
)
|
||||
wsPanelWithMaxResults(t, cfg, 20, up.url)
|
||||
a := wsSurfaceApp(t, cfg, nil)
|
||||
|
||||
if got := wsSearchCount(t, a, map[string]any{"query": "cyrene", "count": 50}); got != 20 {
|
||||
t.Fatalf("count = %d, want 20(模型要 50,上限是面板文件里的 20)", got)
|
||||
}
|
||||
if got := wsSearchCount(t, a, map[string]any{"query": "cyrene", "count": 3}); got != 3 {
|
||||
t.Fatalf("count = %d, want 3(模型要得更少时尊重模型)", got)
|
||||
}
|
||||
if got := wsSearchCount(t, a, map[string]any{"query": "cyrene"}); got != 20 {
|
||||
t.Fatalf("count = %d, want 20(模型没给 count ⇒ 用面板文件的上限)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchPanelMaxResultsUnsetFallsBackToEnv 钉住投影的另一半:
|
||||
// 面板文件里 `maxResults` **缺位**(0 = 未声明)⇒ 沿用 `.env` 那条既有键的值。
|
||||
func TestWebSearchPanelMaxResultsUnsetFallsBackToEnv(t *testing.T) {
|
||||
t.Parallel()
|
||||
up := newWSFakeUpstream(t, http.StatusOK, wsManyResultsBody(30))
|
||||
cfg := wsLoadConfig(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
|
||||
"WEB_SEARCH_MAX_RESULTS=7",
|
||||
)
|
||||
wsPanelWithMaxResults(t, cfg, 0, up.url)
|
||||
a := wsSurfaceApp(t, cfg, nil)
|
||||
|
||||
if got := wsSearchCount(t, a, map[string]any{"query": "cyrene", "count": 50}); got != 7 {
|
||||
t.Fatalf("count = %d, want 7(面板 maxResults 未声明 ⇒ 回落 .env 的 7)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchDefaultsToTenWithoutPanelOrEnvGivesTen 钉住代码默认值这一档:
|
||||
// `.env` 与面板文件都没给条数 ⇒ io 包的共享上限(= 10),且它同样是真上限。
|
||||
func TestWebSearchDefaultsToTenWithoutPanelOrEnvGivesTen(t *testing.T) {
|
||||
t.Parallel()
|
||||
up := newWSFakeUpstream(t, http.StatusOK, wsManyResultsBody(30))
|
||||
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||
wsPanelWithMaxResults(t, cfg, 0, up.url) // 0 = 未声明;.env 也没给
|
||||
a := wsSurfaceApp(t, cfg, nil)
|
||||
|
||||
if got := wsSearchCount(t, a, map[string]any{"query": "cyrene", "count": 50}); got != 10 {
|
||||
t.Fatalf("count = %d, want 10(代码默认值)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 面板动作这一侧:存得进去、真的生效、非法值如实报错 -------------------------
|
||||
|
||||
// wsMaxResultsEnv 是一份「`.env` 给 envMaxResults、假上游答 30 条」的面板动作夹具
|
||||
// (热重载面 + 分派器都在:`newReloadTestEnv`)。`.env` 那个值是留给「面板压过 `.env`」
|
||||
// 这条判据当对照用的。
|
||||
func wsMaxResultsEnv(t *testing.T, envMaxResults int) (*reloadTestEnv, *wsFakeUpstream) {
|
||||
t.Helper()
|
||||
up := newWSFakeUpstream(t, http.StatusOK, wsManyResultsBody(30))
|
||||
env := newReloadTestEnv(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
|
||||
"WEB_SEARCH_ENDPOINT="+up.url,
|
||||
fmt.Sprintf("WEB_SEARCH_MAX_RESULTS=%d", envMaxResults),
|
||||
)
|
||||
return env, up
|
||||
}
|
||||
|
||||
// TestWebSearchPanelSaveAppliesMaxResults 是本片的主判据(端到端):面板提交里带上
|
||||
// `maxResults: 20` ⇒ 落盘 20 ⇒ **热重载**后下一次搜索真的最多 20 条 —— `.env` 的 5 不作数,
|
||||
// 也没有重启后端。
|
||||
func TestWebSearchPanelSaveAppliesMaxResults(t *testing.T) {
|
||||
env, up := wsMaxResultsEnv(t, 5)
|
||||
outcome := env.mustSave(t, map[string]any{
|
||||
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
|
||||
"maxResults": 20,
|
||||
})
|
||||
if !outcome.Applied || !outcome.Registered {
|
||||
t.Fatalf("hotReload = %+v, want applied 且 registered", outcome)
|
||||
}
|
||||
if got := env.loadFile(t).MaxResults; got != 20 {
|
||||
t.Fatalf("盘上 maxResults = %d, want 20(面板给的值必须真的落盘)", got)
|
||||
}
|
||||
if got := wsSearchCount(t, env.app, map[string]any{"query": "cyrene", "count": 50}); got != 20 {
|
||||
t.Fatalf("count = %d, want 20(模型要 50,上限是面板刚存下的 20 —— 且没重启)", got)
|
||||
}
|
||||
if got := wsSearchCount(t, env.app, map[string]any{"query": "cyrene", "count": 3}); got != 3 {
|
||||
t.Fatalf("count = %d, want 3(模型要得更少时尊重模型)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchPanelSaveWithoutMaxResultsKeepsStoredValue 钉住三态的第一态:提交里**缺位**
|
||||
// ⇒ 盘上那个上限一个字节都不动(「我没改它」必须真的是没改),运行中的生效值也照旧。
|
||||
//
|
||||
// 前置的 20 是**直接写盘**的(不经过写侧):这条判据只关心「缺位的那次保存有没有动它」,
|
||||
// 不该依赖另一条用例的能力。
|
||||
func TestWebSearchPanelSaveWithoutMaxResultsKeepsStoredValue(t *testing.T) {
|
||||
env, up := wsMaxResultsEnv(t, 5)
|
||||
if err := runtimeconfig.SaveWebSearch(env.path, runtimeconfig.WebSearchFile{
|
||||
Version: runtimeconfig.WebSearchVersion,
|
||||
Provider: search.NameSearXNG,
|
||||
MaxResults: 20,
|
||||
Providers: []runtimeconfig.ProviderEntry{
|
||||
{Provider: search.NameSearXNG, BaseURL: up.url, Enabled: runtimeconfig.EnableFlag(true)},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("写面板配置: %v", err)
|
||||
}
|
||||
env.mustSave(t, map[string]any{
|
||||
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
|
||||
})
|
||||
if got := env.loadFile(t).MaxResults; got != 20 {
|
||||
t.Fatalf("缺位的一次保存把上限改成了 %d, want 20(缺位 = 不改动)", got)
|
||||
}
|
||||
if got := wsSearchCount(t, env.app, map[string]any{"query": "cyrene", "count": 50}); got != 20 {
|
||||
t.Fatalf("count = %d, want 20(上限没被动过,热重载后的生效值也该照旧)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchPanelSaveRejectsInvalidMaxResults 钉住三态的第三态:给了值却用不了 ⇒
|
||||
// **400 invalid_payload**,盘上一个字节都不动 —— 绝不静默忽略。静默忽略正是这一片要消灭的
|
||||
// 那条「我改了它却没变」(旧行为就是:手塞进去的值被写侧无声丢掉,文件不变、也不报错)。
|
||||
func TestWebSearchPanelSaveRejectsInvalidMaxResults(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
value any
|
||||
}{
|
||||
{"零", 0},
|
||||
{"负数", -3},
|
||||
{"小数", 2.5},
|
||||
{"空串(前端清空输入框送的就是它)", ""},
|
||||
{"非数字字符串", "twenty"},
|
||||
{"布尔", true},
|
||||
{"数组", []any{20}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
env, up := wsMaxResultsEnv(t, 5)
|
||||
before := env.rawFile(t)
|
||||
_, err := env.save(map[string]any{
|
||||
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
|
||||
"maxResults": tc.value,
|
||||
})
|
||||
ae, ok := pluginmgr.AsActionError(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %v, want 结构化 ActionError(非法上限绝不能被静默忽略)", err)
|
||||
}
|
||||
if ae.Code != pluginmgr.ActionCodeInvalidPayload || ae.Status != http.StatusBadRequest {
|
||||
t.Fatalf("失败 = %d/%s, want 400/%s", ae.Status, ae.Code, pluginmgr.ActionCodeInvalidPayload)
|
||||
}
|
||||
if got := env.rawFile(t); !bytes.Equal(got, before) {
|
||||
t.Fatalf("被拒的保存动了盘上的文件:\nbefore=%s\nafter =%s", before, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchReauthEchoesMaxResults:回填必须带上盘上的上限 —— 不回填的话表单里那个框是空的,
|
||||
// 用户既看不见当前上限,也无从改起。
|
||||
func TestWebSearchReauthEchoesMaxResults(t *testing.T) {
|
||||
env, up := wsMaxResultsEnv(t, 5)
|
||||
env.mustSave(t, map[string]any{
|
||||
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true, "maxResults": 20,
|
||||
})
|
||||
values := wsReauthValues(t, env, search.NameSearXNG)
|
||||
if got := values[search.FieldMaxResults]; got != 20 {
|
||||
t.Fatalf("回填的 %s = %#v, want 20", search.FieldMaxResults, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchReauthOmitsUnsetMaxResults:盘上**没给**上限(`maxResults` 缺位 ⇒ Go 侧 0 =
|
||||
// 未声明,由 `.env` 兜底)时不得回填一个 0 —— 0 会被前端当成一个值送回来,而写侧拒 ≤0,
|
||||
// 那会让「只改 key」的一次保存整笔进不去。
|
||||
func TestWebSearchReauthOmitsUnsetMaxResults(t *testing.T) {
|
||||
env, up := wsMaxResultsEnv(t, 5)
|
||||
if err := runtimeconfig.SaveWebSearch(env.path, runtimeconfig.WebSearchFile{
|
||||
Version: runtimeconfig.WebSearchVersion,
|
||||
Provider: search.NameSearXNG,
|
||||
// 刻意不写 maxResults(= 未声明)。
|
||||
Providers: []runtimeconfig.ProviderEntry{
|
||||
{Provider: search.NameSearXNG, BaseURL: up.url, Enabled: runtimeconfig.EnableFlag(true)},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("写面板配置: %v", err)
|
||||
}
|
||||
if got := env.loadFile(t).MaxResults; got != 0 {
|
||||
t.Fatalf("前置不成立:盘上 maxResults = %d, want 0(未声明)", got)
|
||||
}
|
||||
values := wsReauthValues(t, env, search.NameSearXNG)
|
||||
if raw, ok := values[search.FieldMaxResults]; ok {
|
||||
t.Fatalf("未声明时回填了 %#v(前端会把它当值送回,写侧又拒 ≤0)", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// wsReauthValues 走 reauth 动作,取出回填的 values。
|
||||
func wsReauthValues(t *testing.T, env *reloadTestEnv, provider string) map[string]any {
|
||||
t.Helper()
|
||||
out, err := env.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
|
||||
PluginID: search.PanelPluginID, Action: search.ActionReauthProvider,
|
||||
Payload: map[string]any{"row": map[string]any{"provider": provider}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("reauth: %v", err)
|
||||
}
|
||||
values, _ := out.(map[string]any)["values"].(map[string]any)
|
||||
if values == nil {
|
||||
t.Fatalf("reauth 结果缺 values: %#v", out)
|
||||
}
|
||||
return values
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package main
|
||||
|
||||
// 本文件钉住本片新增的两家(`tencent_wsa` / `metaso`)在**组合根里真的接通了**:
|
||||
// 它们**没有 `.env` 字段**(`.env` 已冻结),因此凭据与地址只能来自面板文件
|
||||
// `$DATA_DIR/config/plugins/web-search.json`,而组合根**零改动**地按接入位表把它们投影出来
|
||||
// (`resolveSearchProviders` 逐家走 `search.Descriptors()`)。
|
||||
//
|
||||
// 为什么值得端到端一条:`cmd/cyrene/websearch_providers.go` 里**一行都没改**——这正是
|
||||
// 「按接入位表投影」的设计意图;没有这条用例,「零改动」就只是一句没有证据的话。
|
||||
//
|
||||
// 夹具(假上游、`wsSurfaceApp`、`wsExecuteSearch`)在 websearch_testenv_test.go /
|
||||
// websearch_projection_test.go;`.env` 字段表的完整性由 wire_web_search_*_test.go 钉住。
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||
)
|
||||
|
||||
// TestWebSearchPanelOnlyProvidersWiredEndToEnd 是判据:面板文件里配好一家**没有 env 字段**
|
||||
// 的家 ⇒ 工具进注册面 ⇒ 真的把请求打到面板里那个地址、带上面板里那把 key,且结果里如实
|
||||
// 标注 provider。
|
||||
func TestWebSearchPanelOnlyProvidersWiredEndToEnd(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
fixture string
|
||||
}{
|
||||
{
|
||||
name: search.NameTencentWSA,
|
||||
path: "/SearchPro",
|
||||
// Pages 的元素是 JSON **字符串**(官方 Array of String 形状)。
|
||||
fixture: `{"Response":{"Pages":["{\"title\":\"元宝结果\",\"url\":\"https://t.example/1\",` +
|
||||
`\"passage\":\"元宝摘要\"}"],"Query":"昔涟","RequestId":"r"}}`,
|
||||
},
|
||||
{
|
||||
name: search.NameMetaso,
|
||||
path: "/api/v1/search",
|
||||
fixture: `{"webpages":[{"title":"秘塔结果","link":"https://m.example/1","snippet":"秘塔摘要"}]}`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
const key = "test-panel-only-key"
|
||||
up := newWSFakeUpstream(t, http.StatusOK, tc.fixture)
|
||||
// `.env` 只给「谁当主家」:这两家在 `.env` 里没有字段,凭据与地址只能来自面板文件。
|
||||
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+tc.name)
|
||||
// 前提:它确实不在 `.env` 字段表里(否则这条用例会退化成既有 6 家的回归)。
|
||||
for _, p := range searchProviderVector(cfg.WebSearch) {
|
||||
if strings.EqualFold(p.Name, tc.name) {
|
||||
t.Fatalf("前提被破坏:%s 不该出现在 .env 的字段表里", tc.name)
|
||||
}
|
||||
}
|
||||
// 前提:接入位表里确有它(否则组合根根本投影不出来)。
|
||||
if !search.Known(tc.name) {
|
||||
t.Fatalf("前提被破坏:%s 应已在接入位表里登记", tc.name)
|
||||
}
|
||||
wsSavePanel(t, cfg, runtimeconfig.WebSearchFile{
|
||||
Provider: tc.name,
|
||||
Providers: []runtimeconfig.ProviderEntry{
|
||||
{Provider: tc.name, APIKey: key, BaseURL: up.url, Enabled: runtimeconfig.EnableFlag(true)},
|
||||
},
|
||||
})
|
||||
|
||||
a := wsSurfaceApp(t, cfg, nil)
|
||||
if !wsHasTool(a, webSearchToolName) {
|
||||
t.Fatalf("面板文件里配好的 %s 应让工具进注册面,实际工具面 = %v", tc.name, toolNames(a))
|
||||
}
|
||||
if got := wsExecuteSearch(t, a, "昔涟"); got["provider"] != tc.name {
|
||||
t.Fatalf("provider = %v, want %q(如实标注实际应答的家)", got["provider"], tc.name)
|
||||
}
|
||||
select {
|
||||
case r := <-up.hits:
|
||||
if r.URL.Path != tc.path {
|
||||
t.Errorf("请求路径 = %q, want %q(各家固定路径)", r.URL.Path, tc.path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer "+key {
|
||||
t.Errorf("Authorization = %q,want 面板文件里那把 key(凭据必须以文件为准)", got)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("%s 一个请求都没收到 —— 面板里的配置没接到 provider 上", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
// 本文件是「搜索提供商」页**探针**的传输层绑定(切片 L1/PLG-2 的执行面接线的一部分):
|
||||
// 探针要用的 HTTP 客户端、目标约束(同主机守卫)与失败状态映射。
|
||||
// 动作本体的语义在 `websearch_action_data.go`,登记面在 `wire_plugin_actions.go`。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||
)
|
||||
|
||||
// 探针的边界值。名字化的理由与 io 包同一套:不许裸魔法数字(项目规范 §10)。
|
||||
const (
|
||||
// webSearchProbeTimeout 是探针的兜底超时(面板文件与 `.env` 都没声明时用它)——
|
||||
// 取 io 工具集的默认 http 超时同值(15s),免得「面板测通了、工具超时」这种怪象。
|
||||
webSearchProbeTimeout = 15 * time.Second
|
||||
// webSearchProbeMaxBytes 是探针响应体的读取上限(与 io 包的 http 响应上限同口径 1 MiB)。
|
||||
webSearchProbeMaxBytes = 1 << 20
|
||||
// webSearchProbeMaxRedirects 是探针允许的跳转次数(与 io 的 maxRedirects 同值)。
|
||||
webSearchProbeMaxRedirects = 3
|
||||
)
|
||||
|
||||
// probeErrorStatus 按探针码给出 HTTP 状态:调用方/配置问题 ⇒ 400;上游或网络 ⇒ 502。
|
||||
//
|
||||
// 它**不**改变探针的语义(no_results 仍然是 no_results,不是「成功」也不是「该家不可用」),
|
||||
// 只是给前端一个非 2xx 的信号,好让面板把结论显示出来(见 testProvider 的注释)。
|
||||
func probeErrorStatus(code string) int {
|
||||
switch code {
|
||||
case search.ProbeUnknownProvider, search.ProbeNotConfigured:
|
||||
return http.StatusBadRequest
|
||||
default:
|
||||
return http.StatusBadGateway
|
||||
}
|
||||
}
|
||||
|
||||
// probeTarget 是一次探针的目标约束:**只允许打到配置里那台主机**。
|
||||
//
|
||||
// 与 io 包 SSRF 守卫的关系(写清楚,免得被当成第二份安全策略):地址来自面板里存下的
|
||||
// provider 基地址 —— 它是**运维输入**(owner 通过 owner/admin 写动作落下的),不是模型
|
||||
// 输入。io 的守卫用「私网/回环黑名单 + 允许清单」,而那个守卫在 `plugins/tools/io`
|
||||
// 内部未导出,本片**不改他人文件**(硬边界),因此这里用一条**更严**的规则替代:
|
||||
// 探针只与配置主机通话,跨主机(含跨主机重定向)一律拒绝。
|
||||
// 缺口如实登记:两处策略不逐字同源(见报告 §未做)。
|
||||
type probeTarget struct {
|
||||
// host 是配置主机名(小写、无端口)。
|
||||
host string
|
||||
}
|
||||
|
||||
// newProbeTarget 解析出配置主机;地址为空 / 非 http(s) / 没有主机名时返回错误。
|
||||
func newProbeTarget(baseURL string) (probeTarget, error) {
|
||||
raw := strings.TrimSpace(baseURL)
|
||||
if raw == "" {
|
||||
return probeTarget{}, fmt.Errorf("web-search probe: empty base url")
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return probeTarget{}, fmt.Errorf("web-search probe: parse base url: %w", err)
|
||||
}
|
||||
if !isHTTPURL(u) {
|
||||
return probeTarget{}, fmt.Errorf("web-search probe: unsupported scheme %q", u.Scheme)
|
||||
}
|
||||
host := strings.ToLower(u.Hostname())
|
||||
if host == "" {
|
||||
return probeTarget{}, fmt.Errorf("web-search probe: base url has no host")
|
||||
}
|
||||
return probeTarget{host: host}, nil
|
||||
}
|
||||
|
||||
// client 造这次探针要用的 HTTP 客户端:超时上限 + 同主机的跳转策略。
|
||||
// 它是**每次调用新建**的(探针很少发生,不值得为它维护一份共享客户端与连接池);
|
||||
// 也因此不牵动 http / web-fetch / web-search 共用的那份客户端与白名单。
|
||||
func (t probeTarget) client(timeout time.Duration) *http.Client {
|
||||
return &http.Client{Timeout: timeout, CheckRedirect: t.checkRedirect}
|
||||
}
|
||||
|
||||
// guard 是注入给 search.Config 的地址判定(发请求前对最终地址做一次判定)。
|
||||
func (t probeTarget) guard(_ context.Context, rawURL string) error { return t.check(rawURL) }
|
||||
|
||||
// checkRedirect 是跳转策略:次数上限 + 每一跳都要过同一条判定。
|
||||
func (t probeTarget) checkRedirect(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= webSearchProbeMaxRedirects {
|
||||
return fmt.Errorf("web-search probe: too many redirects")
|
||||
}
|
||||
if req.URL == nil {
|
||||
return fmt.Errorf("web-search probe: nil redirect target")
|
||||
}
|
||||
return t.check(req.URL.String())
|
||||
}
|
||||
|
||||
// check 判定一个地址能否访问:必须是 http(s) 且主机等于配置主机。
|
||||
func (t probeTarget) check(rawURL string) error {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("web-search probe: parse target: %w", err)
|
||||
}
|
||||
if !isHTTPURL(u) {
|
||||
return fmt.Errorf("web-search probe: unsupported scheme %q", u.Scheme)
|
||||
}
|
||||
if !strings.EqualFold(u.Hostname(), t.host) {
|
||||
return fmt.Errorf("web-search probe: refused cross-host target %q", u.Hostname())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isHTTPURL 报告地址是否是可访问的 http(s) 绝对地址。
|
||||
func isHTTPURL(u *url.URL) bool {
|
||||
if u == nil {
|
||||
return false
|
||||
}
|
||||
return u.Scheme == "http" || u.Scheme == "https"
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
package main
|
||||
|
||||
// 本文件钉住 web-search「各家 provider 配置投影」的新形状(切片 WS-P2 的「去 env 化」)。
|
||||
//
|
||||
// 为什么要有这一片:旧形状是**一家一个 `.env` 字段**(`config.WebSearchConfig` 的
|
||||
// `ZhipuAPIKey` / `BochaAPIKey` / …)+ 组合根里一个逐家写死的 switch。那个形状的后果是
|
||||
// 「加一家 provider 就必须先加一个 env 键」—— 直接破主人红线(`.env` 已冻结)。
|
||||
// 新形状把名字表收回到插件自己的接入位表(`plugins/tools/io/search`),组合根只按名字
|
||||
// 从**面板文件**里取值;`.env` 退居**播种层**(只在文件缺失/为空时落盘一次)。
|
||||
//
|
||||
// 四条判据(任务书 ①②③④)与一条边界(文件权威 vs `.env` 字段表)都在这里:
|
||||
// ① 文件里有 zhipu 的 apiKey ⇒ 该家进注册表;
|
||||
// ② 文件里没有 zhipu、但 `.env` 有(播种层)⇒ 与改动前逐字一致;
|
||||
// ③ **没有 env 字段的家**在文件里有 key ⇒ 也能生效(本片的核心判据,用假接入位驱动);
|
||||
// ④ 文件里的 baseUrl 缺位 ⇒ 回落该接入位的代码默认基地址。
|
||||
//
|
||||
// 夹具(假上游、日志字段读取、`wsSurfaceApp`)在 wire_web_search_test.go 与
|
||||
// websearch_testenv_test.go;`.env` 与面板文件的既有语义在各 wire_web_search_*_test.go。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||
)
|
||||
|
||||
// wsFakeNewProviderDef 造一个**测试专用的假接入位**:模拟「下一片要接的新家」。
|
||||
//
|
||||
// 它在 search 包里**没有**登记(`search.Known` 为 false),也没有对应的 `.env` 字段 ——
|
||||
// 于是它正好是判据 ③ 的载体:「没有 env 字段的家,只认文件也能被投影出生效配置」。
|
||||
//
|
||||
// ⚠️ 名字取 `fake_next_provider` 而不是某个真接入位(早先这里写的是 `tencent_wsa`):
|
||||
// `tencent_wsa` 已经**真的登记**(本片新增),沿用旧名会让这条用例的前提断言直接失败 ——
|
||||
// 假接入位必须是一个**永远不会被登记**的名字,用例才钉得住「没 env 字段的家」这件事。
|
||||
func wsFakeNewProviderDef() search.Descriptor {
|
||||
return search.Descriptor{
|
||||
Name: "fake_next_provider",
|
||||
NeedKey: true,
|
||||
DefaultBaseURL: "https://fake-next.example",
|
||||
}
|
||||
}
|
||||
|
||||
// wsSavePanel 写一份面板文件(临时 DataDir 里的真实落点,与面板保存走同一个写口)。
|
||||
func wsSavePanel(t *testing.T, cfg *config.Config, f runtimeconfig.WebSearchFile) {
|
||||
t.Helper()
|
||||
if f.Version == 0 {
|
||||
f.Version = runtimeconfig.WebSearchVersion
|
||||
}
|
||||
if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), f); err != nil {
|
||||
t.Fatalf("写面板配置: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// wsEffectiveProviders 走组合根的**同一条解析路**(effectiveWebSearch),返回生效配置与来源标签。
|
||||
func wsEffectiveProviders(t *testing.T, cfg *config.Config) (effectiveWebSearchConfig, string) {
|
||||
t.Helper()
|
||||
a := &app{cfg: cfg, log: testLog()}
|
||||
return a.effectiveWebSearch(a.log)
|
||||
}
|
||||
|
||||
// wsFindWire 在投影里按名字取一家(名字大小写与空白不敏感,与注册表同口径)。
|
||||
func wsFindWire(providers []searchProviderWire, name string) (searchProviderWire, bool) {
|
||||
for _, p := range providers {
|
||||
if strings.EqualFold(p.Name, name) {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return searchProviderWire{}, false
|
||||
}
|
||||
|
||||
// wsZhipuSearchBody 造一份智谱官方形状的应答(`search_result[]`,见 search 包的 zhipuSpec)。
|
||||
func wsZhipuSearchBody(title string) string {
|
||||
return `{"search_result":[{"title":"` + title + `","link":"https://z.example/1","content":"` +
|
||||
title + ` 正文","publish_date":"2025-05-23"}]}`
|
||||
}
|
||||
|
||||
// wsExecuteSearch 从注册面取出 web-search 真打一次(返回线格式结果)。
|
||||
func wsExecuteSearch(t *testing.T, a *app, query string) map[string]any {
|
||||
t.Helper()
|
||||
tl, err := a.caps.Get(webSearchToolName)
|
||||
if err != nil {
|
||||
t.Fatalf("web-search 不在注册面里: %v(工具面 = %v)", err, toolNames(a))
|
||||
}
|
||||
exec, ok := tl.Value.(interface {
|
||||
Execute(context.Context, any) (any, error)
|
||||
})
|
||||
if !ok {
|
||||
t.Fatalf("注册表里的值类型 = %T, 不是可执行工具", tl.Value)
|
||||
}
|
||||
got, err := exec.Execute(context.Background(), map[string]any{"query": query})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute(%q): %v", query, err)
|
||||
}
|
||||
m, ok := got.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("返回值类型 = %T", got)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// --- ④ 默认基地址只有一个出处:接入位表 vs `.env` 标签默认值,逐字对齐 ----------
|
||||
|
||||
// TestWebSearchAccessSlotDefaultsAlignWithEnvDefaults:接入位表里的默认基地址必须与
|
||||
// `.env` 解析出的**标签默认值**逐字一致。
|
||||
//
|
||||
// 只钉一侧不够:`.env` 的那几个键仍然存在(冻结的基础键,仍然参与播种),接入位表是本片
|
||||
// 新增的出处。两侧漂移时,同一家 provider 会因「值从哪来」指向两个不同的上游。
|
||||
func TestWebSearchAccessSlotDefaultsAlignWithEnvDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
// 一个 WEB_SEARCH_*_BASE_URL 都不写 ⇒ 下面每项取的都是标签默认值。
|
||||
cfg := wsLoadConfig(t)
|
||||
cases := []struct {
|
||||
name string
|
||||
envDefault string
|
||||
}{
|
||||
{search.NameZhipu, cfg.WebSearch.ZhipuBaseURL},
|
||||
{search.NameAliyunIQS, cfg.WebSearch.AliyunIQSBaseURL},
|
||||
{search.NameBocha, cfg.WebSearch.BochaBaseURL},
|
||||
{search.NameVolcengine, cfg.WebSearch.VolcengineBaseURL},
|
||||
{search.NameBaiduQianfan, cfg.WebSearch.BaiduQianfanBaseURL},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
// 进程环境里的同名覆盖会让「标签默认值」这一侧变成用户值 —— 那不是本表要钉的事实
|
||||
// (缺省值),如实跳过而不是报假红。
|
||||
envKey := "WEB_SEARCH_" + strings.ToUpper(tc.name) + "_BASE_URL"
|
||||
if os.Getenv(envKey) != "" {
|
||||
t.Skipf("%s 在进程环境里有覆盖值,跳过默认值对齐(本表只钉缺省值)", envKey)
|
||||
}
|
||||
if tc.envDefault == "" {
|
||||
t.Fatalf("%s 的标签默认值解析出来是空的(.env 冻结键不许被清空)", tc.name)
|
||||
}
|
||||
if got := search.DefaultBaseURL(tc.name); got != tc.envDefault {
|
||||
t.Errorf("接入位表 %s 的默认基地址 = %q,.env 的标签默认值 = %q(必须逐字一致)",
|
||||
tc.name, got, tc.envDefault)
|
||||
}
|
||||
}
|
||||
// searxng 的「默认地址」是 WEB_SEARCH_ENDPOINT 的标签默认值,且**不进**接入位表的默认表
|
||||
// ——它的地址为空是「关掉这家」的开关语义。
|
||||
if got := search.DefaultBaseURL(search.NameSearXNG); got != "" || cfg.WebSearch.Endpoint == "" {
|
||||
t.Errorf("searxng: 接入位默认地址 = %q(want 空),.env 端点默认值 = %q(want 非空)",
|
||||
got, cfg.WebSearch.Endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ① 文件里有 key ⇒ 进注册表 ----------------------------------------------
|
||||
|
||||
// TestWebSearchFileProviderEntersRegistry 是判据 ①:**文件里有 zhipu 的 apiKey** ⇒ 该家进
|
||||
// 注册表(`.env` 一个 zhipu 凭据键都没写),且请求真的带着文件里那把 key 打到文件里那个地址。
|
||||
func TestWebSearchFileProviderEntersRegistry(t *testing.T) {
|
||||
t.Parallel()
|
||||
const key = "test-file-zhipu-key"
|
||||
up := newWSFakeUpstream(t, http.StatusOK, wsZhipuSearchBody("文件里的智谱"))
|
||||
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameZhipu)
|
||||
wsSavePanel(t, cfg, runtimeconfig.WebSearchFile{
|
||||
Provider: search.NameZhipu,
|
||||
Providers: []runtimeconfig.ProviderEntry{
|
||||
{Provider: search.NameZhipu, APIKey: key, BaseURL: up.url, Enabled: runtimeconfig.EnableFlag(true)},
|
||||
},
|
||||
})
|
||||
|
||||
eff, source := wsEffectiveProviders(t, cfg)
|
||||
if source != webSearchSourcePanel {
|
||||
t.Fatalf("configSource = %q, want %q(文件存在时它是唯一来源)", source, webSearchSourcePanel)
|
||||
}
|
||||
if got := availableProviderNames(searchProviderSpecs(eff.Scalar, eff.Providers)); len(got) != 1 || got[0] != search.NameZhipu {
|
||||
t.Fatalf("可用清单 = %v, want [%s](凭据来自面板文件)", got, search.NameZhipu)
|
||||
}
|
||||
|
||||
a := wsSurfaceApp(t, cfg, nil)
|
||||
if !wsHasTool(a, webSearchToolName) {
|
||||
t.Fatalf("文件里配好的主家应让工具进注册面: %v", toolNames(a))
|
||||
}
|
||||
if got := wsExecuteSearch(t, a, "昔涟"); got["provider"] != search.NameZhipu {
|
||||
t.Fatalf("provider = %v, want %q", got["provider"], search.NameZhipu)
|
||||
}
|
||||
select {
|
||||
case r := <-up.hits:
|
||||
if r.Header.Get("Authorization") != "Bearer "+key {
|
||||
t.Errorf("Authorization = %q,want 文件里那把 key(凭据必须以文件为准)", r.Header.Get("Authorization"))
|
||||
}
|
||||
default:
|
||||
t.Fatal("文件里配好的家一个请求都没收到")
|
||||
}
|
||||
}
|
||||
|
||||
// --- ② `.env` 是播种层:文件缺失时与改动前逐字一致 ----------------------------
|
||||
|
||||
// TestWebSearchProviderFromEnvSeedLayer 是判据 ②:**文件里没有 zhipu、但 `.env` 有**
|
||||
// ⇒ 与改动前逐字一致:文件缺失/为空时由 `.env` 播种落盘,本次就按 `.env` 运行。
|
||||
//
|
||||
// 「与改动前一致」的落点有三处:播种内容、来源标签(env)、生效凭据。
|
||||
func TestWebSearchProviderFromEnvSeedLayer(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameZhipu,
|
||||
"WEB_SEARCH_ZHIPU_API_KEY=sk-env-001",
|
||||
)
|
||||
// 第一次解析发生在文件缺失时 ⇒ 它就是「播种那一次」。
|
||||
eff, source := wsEffectiveProviders(t, cfg)
|
||||
if source != webSearchSourceEnv {
|
||||
t.Fatalf("configSource = %q, want %q(文件缺失时按 .env 运行)", source, webSearchSourceEnv)
|
||||
}
|
||||
z, ok := wsFindWire(eff.Providers, search.NameZhipu)
|
||||
if !ok || z.APIKey != "sk-env-001" || z.BaseURL != search.DefaultBaseURL(search.NameZhipu) {
|
||||
t.Fatalf("播种层的 zhipu 投影 = %+v, want 凭据来自 .env、基地址回落默认值", z)
|
||||
}
|
||||
if got := availableProviderNames(searchProviderSpecs(eff.Scalar, eff.Providers)); len(got) != 2 {
|
||||
t.Fatalf("可用清单 = %v, want searxng + zhipu 两家", got)
|
||||
}
|
||||
|
||||
// 播种真的落了盘:条目里带 `.env` 的凭据(`.env` 是播种层的输入)。
|
||||
f, found, err := runtimeconfig.LoadWebSearch(wsPanelPath(cfg))
|
||||
if err != nil || !found {
|
||||
t.Fatalf("文件缺失时必须按 .env 播种落盘: found=%v err=%v", found, err)
|
||||
}
|
||||
entry, ok := findProviderEntry(f, search.NameZhipu)
|
||||
if !ok || entry.APIKey != "sk-env-001" {
|
||||
t.Fatalf("播种的 zhipu 条目 = %+v, want 带 .env 的凭据", entry)
|
||||
}
|
||||
if f.Provider != search.NameZhipu {
|
||||
t.Fatalf("播种的主 provider = %q, want %q", f.Provider, search.NameZhipu)
|
||||
}
|
||||
|
||||
// 播种之后工具照常在注册面里(`.env` 走的是与面板键同一条装配路)。
|
||||
if !wsHasTool(wsSurfaceApp(t, cfg, nil), webSearchToolName) {
|
||||
t.Fatal("按 .env 播种后 web-search 应在注册面里")
|
||||
}
|
||||
}
|
||||
|
||||
// --- ③ 没有 env 字段的家:只认文件(本片的**核心判据**) ----------------------
|
||||
|
||||
// TestWebSearchProviderWithoutEnvFieldIsProjected 是判据 ③ 的投影层:**没有 env 字段的家**
|
||||
// (测试专用的假接入位 `fake_next_provider`)在文件里有 key ⇒ 照样被投影出生效配置。
|
||||
//
|
||||
// 这条判据就是本片存在的理由:旧形状的逐家 switch 只认 `.env` 字段表里的 6 家,第 7 家
|
||||
// 想进来只能先加 env 键(破红线)。新形状按**接入位表**逐家投影,因此第 7 家只需在
|
||||
// `plugins/tools/io/search` 登记(加一个接入位文件 + 表里一行),组合根零改动
|
||||
// —— 本片新接的 `tencent_wsa` / `metaso` 走的正是这条路(它们没有 env 键)。
|
||||
//
|
||||
// 最后一跳(进注册表)由同文件的 TestWebSearchFileOnlyProviderEntersRegistry 用真实接入位
|
||||
// 走完:注册表按名字查的正是同一张接入位表。
|
||||
func TestWebSearchProviderWithoutEnvFieldIsProjected(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := wsFakeNewProviderDef()
|
||||
cfg := wsLoadConfig(t)
|
||||
|
||||
// 前提一:它确实不在 `.env` 的字段表里(否则这条用例会退化成「既有 6 家」的回归)。
|
||||
for _, p := range searchProviderVector(cfg.WebSearch) {
|
||||
if strings.EqualFold(p.Name, fake.Name) {
|
||||
t.Fatalf("前提被破坏:%s 不该出现在 .env 的字段表里", fake.Name)
|
||||
}
|
||||
}
|
||||
// 前提二:它确实还没登记(假接入位的全部意义就在于此)。
|
||||
if search.Known(fake.Name) {
|
||||
t.Fatalf("前提被破坏:假接入位 %s 不该已经登记", fake.Name)
|
||||
}
|
||||
|
||||
f := runtimeconfig.WebSearchFile{
|
||||
Version: runtimeconfig.WebSearchVersion,
|
||||
Providers: []runtimeconfig.ProviderEntry{
|
||||
// 连 baseUrl 都不给:顺带钉住判据 ④ 在「没有 env 字段」的家上同样成立。
|
||||
{Provider: fake.Name, APIKey: "sk-wsa-001"},
|
||||
},
|
||||
}
|
||||
got := resolveSearchProviders(append(search.Descriptors(), fake), f)
|
||||
w, ok := wsFindWire(got, fake.Name)
|
||||
if !ok {
|
||||
t.Fatalf("没有 env 字段的家在文件里有 key 时也必须被投影出来,实际 = %+v", got)
|
||||
}
|
||||
if w.APIKey != "sk-wsa-001" {
|
||||
t.Fatalf("投影的凭据 = %q, want 文件里的 sk-wsa-001", w.APIKey)
|
||||
}
|
||||
if w.BaseURL != fake.DefaultBaseURL {
|
||||
t.Fatalf("投影的基地址 = %q, want 接入位默认值 %q", w.BaseURL, fake.DefaultBaseURL)
|
||||
}
|
||||
// 没配的家照样占位(它带着默认基地址进 SSRF 白名单)。
|
||||
if _, ok := wsFindWire(got, search.NameBaiduQianfan); !ok {
|
||||
t.Fatalf("未配置的家也要出现在投影里(否则 SSRF 白名单会少一家): %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchFileOnlyProviderEntersRegistry 是判据 ③ 的端到端:文件里有 key、
|
||||
// `.env` 里没有任何该家的值 ⇒ 该家真的进注册表,且请求带着**文件里那把** key 打出去。
|
||||
//
|
||||
// 落在真实接入位(bocha)上,是因为注册表按名字查的正是那张接入位表:这条路径一旦通了,
|
||||
// 「没有 env 字段的家」登记之后走的就是同一条路。
|
||||
func TestWebSearchFileOnlyProviderEntersRegistry(t *testing.T) {
|
||||
t.Parallel()
|
||||
const key = "test-file-only-key"
|
||||
got := make(chan *http.Request, 1)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got <- r
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"webPages":{"value":[{"name":"博查结果","url":"https://b.example/1","summary":"正文"}]}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// `.env` 只给「谁当主家」,凭据与地址一个都不给。
|
||||
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameBocha)
|
||||
wsSavePanel(t, cfg, runtimeconfig.WebSearchFile{
|
||||
Provider: search.NameBocha,
|
||||
Providers: []runtimeconfig.ProviderEntry{
|
||||
{Provider: search.NameBocha, APIKey: key, BaseURL: srv.URL, Enabled: runtimeconfig.EnableFlag(true)},
|
||||
},
|
||||
})
|
||||
|
||||
a := wsSurfaceApp(t, cfg, nil)
|
||||
if !wsHasTool(a, webSearchToolName) {
|
||||
t.Fatalf("文件里配好的家应让工具进注册面: %v", toolNames(a))
|
||||
}
|
||||
if m := wsExecuteSearch(t, a, "昔涟"); m["provider"] != search.NameBocha {
|
||||
t.Fatalf("provider = %v, want %q", m["provider"], search.NameBocha)
|
||||
}
|
||||
select {
|
||||
case r := <-got:
|
||||
if r.Header.Get("Authorization") != "Bearer "+key {
|
||||
t.Errorf("Authorization = %q,want 文件里那把 key", r.Header.Get("Authorization"))
|
||||
}
|
||||
default:
|
||||
t.Fatal("文件里配好的家一个请求都没收到")
|
||||
}
|
||||
}
|
||||
|
||||
// --- ④ 基地址的进位链:文件值 → 接入位默认值 ---------------------------------
|
||||
|
||||
// TestWebSearchBaseURLFallbackChain 是判据 ④ 的表驱动:
|
||||
// 文件里的 baseUrl 缺位 ⇒ 回落**该接入位的代码默认基地址**(逐字对上 registry 里的值)。
|
||||
func TestWebSearchBaseURLFallbackChain(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
entries []runtimeconfig.ProviderEntry
|
||||
wantKey string
|
||||
wantURL string
|
||||
}{
|
||||
{
|
||||
"文件没给地址 ⇒ 回落接入位默认值",
|
||||
[]runtimeconfig.ProviderEntry{{Provider: search.NameZhipu, APIKey: "sk-1"}},
|
||||
"sk-1", search.DefaultBaseURL(search.NameZhipu),
|
||||
},
|
||||
{
|
||||
"文件给了地址 ⇒ 用文件的(进位链第一跳)",
|
||||
[]runtimeconfig.ProviderEntry{{Provider: search.NameZhipu, APIKey: "sk-1", BaseURL: "https://proxy.example"}},
|
||||
"sk-1", "https://proxy.example",
|
||||
},
|
||||
{
|
||||
"空串地址等同于没给 ⇒ 回落接入位默认值",
|
||||
[]runtimeconfig.ProviderEntry{{Provider: search.NameZhipu, APIKey: "sk-1", BaseURL: " "}},
|
||||
"sk-1", search.DefaultBaseURL(search.NameZhipu),
|
||||
},
|
||||
{
|
||||
"被停用的家仍带默认地址(白名单口径),但没有凭据",
|
||||
[]runtimeconfig.ProviderEntry{
|
||||
{Provider: search.NameZhipu, APIKey: "sk-1", Enabled: runtimeconfig.EnableFlag(false)},
|
||||
},
|
||||
"", search.DefaultBaseURL(search.NameZhipu),
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := runtimeconfig.WebSearchFile{Version: runtimeconfig.WebSearchVersion, Providers: tc.entries}
|
||||
got := resolveSearchProviders(search.Descriptors(), f)
|
||||
w, ok := wsFindWire(got, search.NameZhipu)
|
||||
if !ok {
|
||||
t.Fatalf("zhipu 必须出现在投影里: %+v", got)
|
||||
}
|
||||
if w.BaseURL != tc.wantURL {
|
||||
t.Errorf("基地址 = %q, want %q", w.BaseURL, tc.wantURL)
|
||||
}
|
||||
if w.APIKey != tc.wantKey {
|
||||
t.Errorf("凭据 = %q, want %q", w.APIKey, tc.wantKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
// 逐字对齐:这个「接入位默认值」必须就是 registry 里那张表的值(不是从 `.env` 抄来的)。
|
||||
if got := search.DefaultBaseURL(search.NameZhipu); got != "https://open.bigmodel.cn" {
|
||||
t.Fatalf("registry 的默认基地址 = %q, want 逐字 https://open.bigmodel.cn", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 边界:文件存在时它**就是**权威(`.env` 不复活任何条目) ------------------
|
||||
|
||||
// TestWebSearchFileAuthorityOverEnvFieldTable 钉住投影层的权威边界:文件里**没有** zhipu
|
||||
// 条目 ⇒ 即使 `.env` 里有 zhipu 的 key,这家也不生效(凭据不复活)。`.env` 只在**文件
|
||||
// 缺失/为空**时经 seedWebSearchFile 播种(判据 ②)—— 文件一旦存在,权威就在文件那边。
|
||||
//
|
||||
// 这是工具层既有回归用例(`wire_web_search_panel_test.go` 的
|
||||
// `TestWireIOTools_PanelConfigClearedProviderDoesNotReviveFromEnv`:主家因此没配齐 ⇒ 启动
|
||||
// 报错)在**投影层**的同一条口径,也是与本体形状契约(runtimeconfig:providers 为空
|
||||
// 数组 ⇒ 一家都没配)一致的行为。
|
||||
func TestWebSearchFileAuthorityOverEnvFieldTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameZhipu,
|
||||
"WEB_SEARCH_ZHIPU_API_KEY=sk-env-stale",
|
||||
)
|
||||
f := runtimeconfig.WebSearchFile{
|
||||
Version: runtimeconfig.WebSearchVersion,
|
||||
Provider: search.NameZhipu,
|
||||
Providers: []runtimeconfig.ProviderEntry{
|
||||
{Provider: search.NameSearXNG, BaseURL: "http://127.0.0.1:38471"},
|
||||
},
|
||||
}
|
||||
// 文件真的落到盘上(否则「文件里没有这家」这条用例会退化成「文件缺失 ⇒ 播种」)。
|
||||
wsSavePanel(t, cfg, f)
|
||||
|
||||
got := resolveSearchProviders(search.Descriptors(), f)
|
||||
w, ok := wsFindWire(got, search.NameZhipu)
|
||||
if !ok {
|
||||
t.Fatalf("未配置的家也要在投影里占位(它带着默认基地址进 SSRF 白名单): %+v", got)
|
||||
}
|
||||
if w.APIKey != "" {
|
||||
t.Fatalf("文件里没有的家不得从 .env 复活凭据,得到 %q(.env 只是播种层)", w.APIKey)
|
||||
}
|
||||
if w.BaseURL != search.DefaultBaseURL(search.NameZhipu) {
|
||||
t.Fatalf("基地址 = %q, want 接入位默认值 %q", w.BaseURL, search.DefaultBaseURL(search.NameZhipu))
|
||||
}
|
||||
// 与 `.env` 无关的旁证:那份 `.env` 里确实有 key(否则这条用例是空转)。
|
||||
if cfg.WebSearch.ZhipuAPIKey != "sk-env-stale" {
|
||||
t.Fatalf(".env 里的 zhipu key = %q(用例前提不成立)", cfg.WebSearch.ZhipuAPIKey)
|
||||
}
|
||||
|
||||
// 工具层:主家没配齐 ⇒ 启动报错(既不复活,也不静默换一家)。
|
||||
if err := wsNewTools(t, cfg); !errors.Is(err, search.ErrNotConfigured) {
|
||||
t.Fatalf("err = %v, want errors.Is(..., ErrNotConfigured):文件里没有的家不得从 .env 复活", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package main
|
||||
|
||||
// 本文件是 web-search「**各家 provider 的配置投影**」—— 组合根里唯一把配置翻译成各家接线
|
||||
// 的地方。它存在的理由是一条硬约束:**`.env` 已冻结**(基础键不加/不改/不删)。
|
||||
//
|
||||
// 旧形状是「一家一个 env 字段」:`config.WebSearchConfig` 有 `ZhipuAPIKey` / `BochaAPIKey` / …,
|
||||
// 组合根再逐家读这些字段(外加一个逐家写死的 switch 把面板文件的值搬回去)。那个形状的
|
||||
// 后果是**加一家 provider 就要先加一个 env 键** —— 直接破 `.env` 冻结红线。
|
||||
//
|
||||
// 新形状把**名字表收回到插件自己手里**(`plugins/tools/io/search` 的接入位表):
|
||||
//
|
||||
// - 各家叫什么、要不要 key、没给基地址时回落哪个默认地址 ⇒ 全在 `search.Descriptors()`;
|
||||
// - 组合根只做一件事:**按名字**在面板文件里找同名条目,有就取文件的值,没有就是没配;
|
||||
// - `.env` 退居**播种层**:只有「文件缺失/为空」时才由 `seedWebSearchFile` 落盘一次
|
||||
// (既有 6 家的键与值逐字不变,主人已在用的配置不受影响)。
|
||||
//
|
||||
// ⇒ 加一家 provider = `plugins/tools/io/search` 加一个接入位文件 + 表里一行,**本文件一行
|
||||
// 都不用改**,`.env` 一个键都不用加。`tencent_wsa` / `metaso` 就是这么进来的(两家都
|
||||
// **没有** env 字段:`.env` 已冻结,它们的 key / 基地址只从面板文件取)。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||
)
|
||||
|
||||
// effectiveWebSearchConfig 是一次装配的**生效配置**,分两层:
|
||||
//
|
||||
// - Scalar:文件级标量(主 provider / 降级链 / 条数上限 / 超时)与 searxng 的端点。
|
||||
// 它沿用 config.WebSearchConfig,因为那几个裁决函数都挂在它上面
|
||||
// (ResolveProvider / ResolveEndpoint),而它们是 `.env` 与面板**共用**的一套语义。
|
||||
// - Providers:各家(**不含 searxng**)的生效投影,按**接入位表**逐家给出。
|
||||
//
|
||||
// 为什么各家的 key/基地址不再写回 Scalar 的逐家字段:那张字段表是 `.env` 的形状,碰它就要
|
||||
// 动 env 键(见文件头)。逐家字段在这个结构里**不再承载生效值**(清零),免得被读成
|
||||
// 「生效配置里的凭据」—— 那正是旧形状最容易被误用的地方。
|
||||
type effectiveWebSearchConfig struct {
|
||||
// Scalar 是文件级标量与 searxng 端点(逐家字段一律清零,见上方说明)。
|
||||
Scalar config.WebSearchConfig
|
||||
// Providers 是各家的生效投影(不含 searxng),顺序 = 接入位表的展示顺序。
|
||||
Providers []searchProviderWire
|
||||
}
|
||||
|
||||
// searchProviderWire 是「一家云 provider 的配置投影」:名字 + 凭据 + 基地址。
|
||||
// searxng 不在这张表里(它的「基地址」就是端点,由 ResolveEndpoint 裁决整把工具的存亡)。
|
||||
type searchProviderWire struct {
|
||||
Name string
|
||||
APIKey string
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
// searchProviderVector 是 **`.env` 播种层**给出的各家投影(既有 6 家的 env 字段表,
|
||||
// 云家部分)。它只服务两处:`.env` 缺失时的播种内容(seedWebSearchFile),以及
|
||||
// 「没有可用文件」时的生效投影(envEffectiveWebSearch)。
|
||||
//
|
||||
// ⚠️ 它**不是**「本期内置几家」的名单:登记表里还有两家**没有 env 字段**的家
|
||||
// (tencent_wsa / metaso,`.env` 已冻结 ⇒ 不给它们加键),它们只从面板文件生效
|
||||
// (`resolveSearchProviders` 按 `search.Descriptors()` 逐家投影,不经过本函数)。
|
||||
// 它也**不是**逐条的兜底层:文件存在时,文件里没有的家就是没有(见 resolveSearchProviders)。
|
||||
// 这条口径由 `wire_web_search_panel_test.go` 的
|
||||
// `TestWireIOTools_PanelConfigClearedProviderDoesNotReviveFromEnv` 钉住。
|
||||
func searchProviderVector(ws config.WebSearchConfig) []searchProviderWire {
|
||||
return []searchProviderWire{
|
||||
{Name: search.NameZhipu, APIKey: ws.ZhipuAPIKey, BaseURL: ws.ZhipuBaseURL},
|
||||
{Name: search.NameAliyunIQS, APIKey: ws.AliyunIQSAPIKey, BaseURL: ws.AliyunIQSBaseURL},
|
||||
{Name: search.NameBocha, APIKey: ws.BochaAPIKey, BaseURL: ws.BochaBaseURL},
|
||||
{Name: search.NameVolcengine, APIKey: ws.VolcengineAPIKey, BaseURL: ws.VolcengineBaseURL},
|
||||
{Name: search.NameBaiduQianfan, APIKey: ws.BaiduQianfanAPIKey, BaseURL: ws.BaiduQianfanBaseURL},
|
||||
}
|
||||
}
|
||||
|
||||
// envSearchProviders 是 `.env` 播种层的**全量**投影:searxng(基地址 = WEB_SEARCH_ENDPOINT)
|
||||
// 打头,其余各家跟随 —— 顺序与既有播种逐字一致。
|
||||
func envSearchProviders(ws config.WebSearchConfig) []searchProviderWire {
|
||||
out := make([]searchProviderWire, 0, len(searchProviderVector(ws))+1)
|
||||
out = append(out, searchProviderWire{Name: search.NameSearXNG, BaseURL: seedEndpoint(ws.Endpoint)})
|
||||
return append(out, searchProviderVector(ws)...)
|
||||
}
|
||||
|
||||
// effectiveSearchProviders 是各家(**不含 searxng**)在本次装配里的生效投影。
|
||||
//
|
||||
// 生产路径只有薄薄这一层:接入位表从插件自己那里取(`search.Descriptors()`)。加一家
|
||||
// provider 时这里**零改动** —— 这正是本片「去 env 化」的核心。
|
||||
func effectiveSearchProviders(f runtimeconfig.WebSearchFile) []searchProviderWire {
|
||||
return resolveSearchProviders(search.Descriptors(), f)
|
||||
}
|
||||
|
||||
// resolveSearchProviders 逐家算出生效投影。**判定顺序(文件权威)**:
|
||||
//
|
||||
// 1. 面板文件里有同名条目且**启用** ⇒ 取条目里的 apiKey / baseUrl(凭据以文件为准:
|
||||
// 条目里 key 为空 = 这家没配,绝不拿 `.env` 的旧 key 顶上,否则「我在面板里删了它」
|
||||
// 会变成一句假话);
|
||||
// 2. 面板文件里有同名条目但**被显式停用** ⇒ 这家不生效(停用是显式选择,不回落别处);
|
||||
// 3. 面板文件里**没有**该条目 ⇒ 这家不生效(`.env` 只在**文件缺失/为空**时播种,文件一旦
|
||||
// 存在就是权威;见 searchProviderVector 的注释与那条既有回归用例)。
|
||||
//
|
||||
// ⚠️ **一处经核对的偏差(记在这里,免得后人踩)**:任务书里有一句「既有 6 家在文件里没有该
|
||||
// 条目时仍按现状回落 `.env` 值」。本仓现状**没有**这层回落,且有两条既有约束钉着它:
|
||||
// ① `runtimeconfig.WebSearchFile.Providers` 的形状契约写明「允许为空数组(= 一家都没配 ⇒
|
||||
// 组合根不注册 web-search)」;② 回归用例 `TestWireIOTools_PanelConfigClearedProviderDoesNot
|
||||
// ReviveFromEnv` 明确要求「文件里没有的家不得从 `.env` 复活」。本片按「**不改变既有行为**」
|
||||
// 实现(既有 websearch 系列用例逐条未改语义、全绿),需要那层回落时改的就是下面这个循环。
|
||||
//
|
||||
// 被跳过与未配置的家**仍带着基地址**(回落到接入位的默认值):接线表的 BaseURL 会被组合根
|
||||
// 逐个加进 SSRF 白名单(io 包的 withSearchEndpointHost),这里悄悄少一家等于改了那份白名单。
|
||||
//
|
||||
// defs 由调用方给(生产 = `search.Descriptors()`):测试可注入一个**假接入位**,从而驱动
|
||||
// 「没有 env 字段的家也能只认文件生效」这条判据 —— 那是本片存在的理由,必须可证。
|
||||
func resolveSearchProviders(defs []search.Descriptor, f runtimeconfig.WebSearchFile) []searchProviderWire {
|
||||
out := make([]searchProviderWire, 0, len(defs))
|
||||
for _, d := range defs {
|
||||
// searxng 的「基地址」是端点(`WEB_SEARCH_ENDPOINT` / 文件里那条的 baseUrl),
|
||||
// 由 effectiveSearchConfig 单独裁决:空地址 = 关掉这家,语义与云家不同。
|
||||
if strings.EqualFold(d.Name, search.NameSearXNG) {
|
||||
continue
|
||||
}
|
||||
entry, found := findProviderEntry(f, d.Name)
|
||||
if !found || !entry.IsEnabled() {
|
||||
out = append(out, searchProviderWire{Name: d.Name, BaseURL: baseURLChain("", d.DefaultBaseURL)})
|
||||
continue
|
||||
}
|
||||
out = append(out, searchProviderWire{
|
||||
Name: d.Name,
|
||||
APIKey: strings.TrimSpace(entry.APIKey),
|
||||
BaseURL: baseURLChain(entry.BaseURL, d.DefaultBaseURL),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// baseURLChain 是「一家的基地址」的**唯一进位链**:条目里的值 → 该接入位的代码默认基地址
|
||||
// → 空。
|
||||
//
|
||||
// 空只可能出现在没有默认地址的家上(searxng):它的地址为空本身就是「关掉这家」的开关语义,
|
||||
// 回落一个本机地址会让「清空地址」变成假话。
|
||||
func baseURLChain(fromEntry, accessSlotDefault string) string {
|
||||
if s := strings.TrimSpace(fromEntry); s != "" {
|
||||
return s
|
||||
}
|
||||
return accessSlotDefault
|
||||
}
|
||||
|
||||
// resolveBaseURL 按**名字**走同一条进位链(动作面只拿得到名字与条目):默认基地址由接入位表
|
||||
// 给出(`search.DefaultBaseURL`,未登记的名字 ⇒ 空)。
|
||||
//
|
||||
// 装配期**不用**这个入口而是直接用 baseURLChain:那里手里已经有接入位描述,而「没有 env
|
||||
// 字段的家」在登记之前按名字查不到默认地址 —— 那正是本片要打通的那一类家。
|
||||
func resolveBaseURL(name, fromEntry string) string {
|
||||
return baseURLChain(fromEntry, search.DefaultBaseURL(name))
|
||||
}
|
||||
|
||||
// searchProviderSpecs 把生效配置翻译成各家接线。
|
||||
//
|
||||
// searxng 单独一行:它的基地址是端点、可用性由 ResolveEndpoint 裁决(不是「有没有 key」);
|
||||
// 其余各家按接入位表逐家给出,Configured 的口径仍由 `search.Configured` 统一判定
|
||||
// (searxng 看端点、云家看 key;false ⇒ 该家不进注册表、不出现在可用清单里)。
|
||||
//
|
||||
// **密钥只在这里流转,绝不写日志。**
|
||||
func searchProviderSpecs(ws config.WebSearchConfig, providers []searchProviderWire) []iotools.SearchProviderSpec {
|
||||
endpoint := ws.ResolveEndpoint()
|
||||
specs := make([]iotools.SearchProviderSpec, 0, len(providers)+1)
|
||||
specs = append(specs, iotools.SearchProviderSpec{
|
||||
Name: search.NameSearXNG,
|
||||
BaseURL: endpoint.URL,
|
||||
Configured: endpoint.Enabled,
|
||||
})
|
||||
for _, p := range providers {
|
||||
specs = append(specs, iotools.SearchProviderSpec{
|
||||
Name: p.Name,
|
||||
APIKey: p.APIKey,
|
||||
BaseURL: p.BaseURL,
|
||||
Configured: search.Configured(p.Name, p.APIKey, p.BaseURL),
|
||||
})
|
||||
}
|
||||
return specs
|
||||
}
|
||||
|
||||
// availableProviderNames 返回已配置的家(即「可用清单」),供启动日志如实展示。
|
||||
// **不含任何密钥** —— 只回名字。
|
||||
func availableProviderNames(specs []iotools.SearchProviderSpec) []string {
|
||||
out := make([]string, 0, len(specs))
|
||||
for _, s := range specs {
|
||||
if s.Configured {
|
||||
out = append(out, s.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// seedEndpoint 把 `WEB_SEARCH_ENDPOINT` 的取值播进 searxng 条目:显式关闭的哨兵落成空串
|
||||
// (两者语义相同:都表示不出这家,但面板里不该显示一个不是地址的 "off")。
|
||||
func seedEndpoint(endpoint string) string {
|
||||
if config.IsWebSearchOffSentinel(endpoint) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(endpoint)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package main
|
||||
|
||||
// 本文件是「搜索提供商」页的**热重载**(切片 W3;`DECISIONS-2026-10-06` 裁决 #1):
|
||||
// 面板保存后,**重建** web-search 工具并**替换**能力注册表里的条目,使改动在下一轮对话
|
||||
// 即生效 —— 不再要求「重启后端」。
|
||||
//
|
||||
// 为什么必须做(而不是把「重启后生效」写进面板文案):面板的意义就是「不改 `.env`、
|
||||
// 不重启」,留着「重启后生效」等于把配置面做成摆设(裁决 #1 的理由原文)。design/09 §5.5
|
||||
// 的端到端判据也正是「面板加一家 ⇒ 不改 `.env`、不重启 ⇒ 下一次搜索真的走它」。
|
||||
//
|
||||
// 三条口径:
|
||||
//
|
||||
// 1. **一次保存 = 一次事务**:写可变源 → 重建工具 → 替换注册表条目。任何一步失败都把
|
||||
// 文件**回滚**到保存前那一份,绝不留「盘上是新的、内存是旧的」或「盘上的配置让后端
|
||||
// 下次起不来」这两种半状态。
|
||||
// 2. **重建走启动期同一条路**(`buildWebSearchTool` → `webSearchOptions`):重载后的工具
|
||||
// 与启动期构造的逐字段同源,不是第二份接线逻辑(避免两处语义漂移)。
|
||||
// 3. **替换是原子的、保留策略**:走 `registry.ReplaceTool` 而不是 Remove+Register
|
||||
// (详见该方法注释:中间无空窗,且分组/陌生人可达标记不被重置)。
|
||||
//
|
||||
// 触发面只有一处:`wire_plugin_actions.go` 把**写可变源**的那个动作(`plugin.saveProvider`)
|
||||
// 包上 `webSearchReloader.wrap`。动作本体(websearch_action_data.go 的 saveProvider)保持
|
||||
// 「只负责把这条存进去」的单一职责,重载与回滚都在本文件。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||
)
|
||||
|
||||
// webSearchReloadLogSource 是本文件日志的来源标签(项目规范 §10:禁止无来源裸日志)。
|
||||
const webSearchReloadLogSource = "cmd/cyrene/websearch_reload"
|
||||
|
||||
// actionCodeConfigUnstartable 报告「这次保存的结果会让后端**下一次启动直接失败**」——
|
||||
// 文件已回滚,改动**没有**生效(406 / 409 这类「与当前状态冲突」的档,不是 500 内部错)。
|
||||
const actionCodeConfigUnstartable = "config_unstartable"
|
||||
|
||||
// webSearchRollbackFileMode 是回滚写回时的文件权限:与 runtimeconfig 落盘可变源的 0600
|
||||
// 同口径(那份文件带 API Key)。硬编码成 0600 而不是读对方的常量,是因为那个常量未导出,
|
||||
// 而变化方向是「收紧」,不存在比 0600 更松的风险。
|
||||
const webSearchRollbackFileMode os.FileMode = 0o600
|
||||
|
||||
// webSearchReloadOutcome 报告一次热重载的结局,并作为动作响应里可机读的事实。
|
||||
type webSearchReloadOutcome struct {
|
||||
// Applied 报告这次保存**真的触发了**热重载(false = 没有能力注册表可换,
|
||||
// 例如测试夹具;此时只落了盘,运行中的工具没动)。
|
||||
Applied bool `json:"applied"`
|
||||
// Registered 报告保存之后 web-search **在不在模型的工具面里**(Applied 为 true
|
||||
// 时才有意义):关掉主 provider / 端点会让它整把消失,这是设计语义,不是失败。
|
||||
Registered bool `json:"registered"`
|
||||
}
|
||||
|
||||
// webSearchReloader 把「保存可变源 → 重建工具 → 换注册表条目」做成一次事务。
|
||||
//
|
||||
// 锁的粒度是**整次保存**(含动作本体那一步):两个并发的面板保存如果交错,可能出现
|
||||
// 「A 的写 + B 的重载」这种盘上与内存对不上的组合。面板保存是低频动作,串行化的代价
|
||||
// 可以忽略,换来的是「最后一次保存定终局」这条可证的顺序。
|
||||
type webSearchReloader struct {
|
||||
mu sync.Mutex
|
||||
app *app
|
||||
path string
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// webSearchActionHandlersWithHotReload 在既有的动作处理器表之上,给**写可变源**的动作
|
||||
// 套上热重载事务(其余动作不动:`testProvider` / `reauthProvider` 不改文件,无需重载)。
|
||||
//
|
||||
// 包在 `wireBuiltinPluginActions` 里调用(登记入口一处决定「谁会被热重载」),因此
|
||||
// 动作名到处理器的映射关系在这里仍然可见,不额外引入第二张表。
|
||||
func webSearchActionHandlersWithHotReload(
|
||||
env webSearchActionEnv, a *app, log *slog.Logger,
|
||||
) map[string]pluginmgr.ActionHandler {
|
||||
handlers := webSearchActionHandlers(env)
|
||||
r := &webSearchReloader{app: a, path: env.path, log: logger.WithSource(log, webSearchReloadLogSource)}
|
||||
for _, action := range []string{search.ActionSaveProvider} {
|
||||
if inner, ok := handlers[action]; ok && inner != nil {
|
||||
handlers[action] = r.wrap(inner)
|
||||
}
|
||||
}
|
||||
return handlers
|
||||
}
|
||||
|
||||
// wrap 把一次「写可变源的保存」变成事务:
|
||||
//
|
||||
// ① 快照文件(回滚的凭据);
|
||||
// ② 跑动作本体(它负责把改动落盘);
|
||||
// ③ 按**落盘后的配置**重建工具并替换注册表条目;
|
||||
// ④ ③ 失败 ⇒ 回滚文件(内存没动过,因此回滚后两处一致)并回结构化错误。
|
||||
func (r *webSearchReloader) wrap(inner pluginmgr.ActionHandler) pluginmgr.ActionHandler {
|
||||
return func(ctx context.Context, req pluginmgr.ActionRequest) (any, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
snap, err := r.snapshot()
|
||||
if err != nil {
|
||||
return nil, pluginmgr.NewActionError(actionCodeConfigUnreadable,
|
||||
"面板配置读不出来,本次**没有**改动它;详见服务端日志", http.StatusInternalServerError, err)
|
||||
}
|
||||
res, err := inner(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outcome, rerr := r.reload()
|
||||
if rerr == nil {
|
||||
return withReloadOutcome(res, outcome), nil
|
||||
}
|
||||
if rberr := r.rollback(snap); rberr != nil {
|
||||
// 回滚都失败了 ⇒ 只能如实说「盘上可能已经变了」,绝不假装改动没发生。
|
||||
r.log.Error("web-search config rollback failed after an unstartable save",
|
||||
"path", r.path, "reloadError", rerr, "rollbackError", rberr)
|
||||
return nil, pluginmgr.NewActionError(actionCodeConfigWriteFailed,
|
||||
"这次改动会让 web-search 起不来,且回滚也失败了:请人工检查 "+
|
||||
r.path+"(详见服务端日志)", http.StatusInternalServerError, errors.Join(rerr, rberr))
|
||||
}
|
||||
r.log.Warn("web-search panel save rolled back: the resulting config cannot start",
|
||||
"path", r.path, "error", rerr)
|
||||
return nil, pluginmgr.NewActionError(actionCodeConfigUnstartable,
|
||||
unstartableMessage(rerr), http.StatusConflict, rerr)
|
||||
}
|
||||
}
|
||||
|
||||
// reload 按落盘后的配置重建工具并替换注册表条目。
|
||||
//
|
||||
// 三种结局:换掉(工具该在)/摘掉(当前配置下它不该在注册面里,与启动期语义一致)/
|
||||
// 报错(这份配置起不来 ⇒ 调用方回滚)。没有能力注册表时如实报告 Applied=false。
|
||||
func (r *webSearchReloader) reload() (webSearchReloadOutcome, error) {
|
||||
if r.app == nil || r.app.cfg == nil || r.app.caps == nil {
|
||||
// 没有注册表可换(测试夹具 / 装配未完成):只落了盘,工具没动。
|
||||
// 不静默当成「换过了」——否则响应里的 registered 会是一句假话。
|
||||
r.log.Debug("web-search hot reload skipped: no capability registry",
|
||||
"path", r.path, "effect", "the running tool keeps the startup wiring")
|
||||
return webSearchReloadOutcome{}, nil
|
||||
}
|
||||
tool, err := r.app.buildWebSearchTool(webSearchCausePanelSave)
|
||||
if err != nil {
|
||||
return webSearchReloadOutcome{}, err
|
||||
}
|
||||
if tool == nil {
|
||||
return webSearchReloadOutcome{Applied: true}, r.app.removeSearchTool(r.log)
|
||||
}
|
||||
if err := r.app.replaceSearchTool(tool); err != nil {
|
||||
return webSearchReloadOutcome{}, err
|
||||
}
|
||||
r.log.Info("web-search tool hot-reloaded after panel save",
|
||||
"path", r.path, "action", search.ActionSaveProvider,
|
||||
"effect", "the next turn uses the new wiring; no restart needed")
|
||||
return webSearchReloadOutcome{Applied: true, Registered: true}, nil
|
||||
}
|
||||
|
||||
// replaceSearchTool 把重建出来的工具放进注册面:**已登记就就地替换**(原子、保留策略),
|
||||
// 之前不在注册面里(例如上一次保存把它关了)就新增。
|
||||
func (a *app) replaceSearchTool(t sdk.Tool) error {
|
||||
err := a.caps.ReplaceTool(t)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, registry.ErrNotFound) {
|
||||
return fmt.Errorf("web-search: replace tool: %w", err)
|
||||
}
|
||||
if rerr := a.caps.RegisterTool(t); rerr != nil {
|
||||
return fmt.Errorf("web-search: register tool: %w", rerr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeSearchTool 把 web-search 从注册面摘掉(当前配置下它不该在:端点/主 provider 被
|
||||
// 显式关闭,或选定 provider 没配齐)。本来就不在时是幂等的。
|
||||
func (a *app) removeSearchTool(log *slog.Logger) error {
|
||||
err := a.caps.Remove(webSearchToolName)
|
||||
if err != nil && !errors.Is(err, registry.ErrNotFound) {
|
||||
return fmt.Errorf("web-search: remove tool: %w", err)
|
||||
}
|
||||
log.Info("web-search tool removed from the registry after panel save",
|
||||
"action", search.ActionSaveProvider,
|
||||
"effect", "the model no longer sees a web-search tool aimed at a closed provider")
|
||||
return nil
|
||||
}
|
||||
|
||||
// fileSnapshot 是可变源在**一次保存之前**的样子(回滚的凭据)。
|
||||
//
|
||||
// 存原始字节而不是解析后的结构:回滚的语义是「一字不差地回到那一份」。经结构往返
|
||||
// (Load + Save)会丢掉人工写在文件里的、本包不认识的键 —— 那正是「损坏配置可人工救回」
|
||||
// 场景里最不该丢的东西。
|
||||
type fileSnapshot struct {
|
||||
// exists 报告保存前文件在不在(不存在时回滚 = 把它删掉)。
|
||||
exists bool
|
||||
// raw 是保存前的原始内容(exists 为 false 时无意义)。
|
||||
raw []byte
|
||||
}
|
||||
|
||||
// snapshot 读一份保存前的快照。文件不存在 = 合法的「还没配过」状态,不是错误。
|
||||
func (r *webSearchReloader) snapshot() (fileSnapshot, error) {
|
||||
raw, err := os.ReadFile(r.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fileSnapshot{}, nil
|
||||
}
|
||||
return fileSnapshot{}, fmt.Errorf("web-search: snapshot %s: %w", r.path, err)
|
||||
}
|
||||
return fileSnapshot{exists: true, raw: raw}, nil
|
||||
}
|
||||
|
||||
// rollback 把文件恢复成快照那一份(temp+rename,与 runtimeconfig 的落盘口径同源:
|
||||
// 并发读者永远看不到半个文件)。
|
||||
func (r *webSearchReloader) rollback(snap fileSnapshot) error {
|
||||
if !snap.exists {
|
||||
if err := os.Remove(r.path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("web-search: rollback remove %s: %w", r.path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
tmp := r.path + ".rollback.tmp"
|
||||
if err := os.WriteFile(tmp, snap.raw, webSearchRollbackFileMode); err != nil {
|
||||
return fmt.Errorf("web-search: rollback write %s: %w", tmp, err)
|
||||
}
|
||||
if err := os.Rename(tmp, r.path); err != nil {
|
||||
return fmt.Errorf("web-search: rollback rename %s: %w", r.path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// unstartableMessage 把「重建失败」的病因翻成**能照着改**的一句话:主 provider 未配齐
|
||||
// 与名字未登记是两种不同的修法,笼统说一句「配置不可用」等于让用户自己猜。
|
||||
func unstartableMessage(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, search.ErrNotConfigured):
|
||||
return "这次改动会让 web-search 没有可用的后端(主提供商未配齐:缺 API Key 或接口地址)," +
|
||||
"后端下次启动会直接失败 ⇒ 已回滚,改动**没有**生效。请先给主提供商补齐凭据,或改选一家已配齐的。"
|
||||
case errors.Is(err, search.ErrUnknownProvider):
|
||||
return "这次改动指向了一个未登记的搜索提供商(名字写错了 / 本期没做这一家)," +
|
||||
"后端下次启动会直接失败 ⇒ 已回滚,改动**没有**生效。"
|
||||
default:
|
||||
return "这次改动会让 web-search 起不来(后端下次启动会直接失败)⇒ 已回滚,改动**没有**生效;" +
|
||||
"详见服务端日志。"
|
||||
}
|
||||
}
|
||||
|
||||
// withReloadOutcome 把热重载的结局挂到动作响应上(响应体是对象时追加一个 `hotReload`
|
||||
// 字段;不是对象时原样返回 —— 宁可少一个字段,也不把结果包成另一种形状)。
|
||||
func withReloadOutcome(res any, outcome webSearchReloadOutcome) any {
|
||||
m, ok := res.(map[string]any)
|
||||
if !ok {
|
||||
return res
|
||||
}
|
||||
m["hotReload"] = outcome
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
package main
|
||||
|
||||
// 本文件钉住切片 W3 的**热重载**(`DECISIONS-2026-10-06` 裁决 #1):
|
||||
//
|
||||
// - 面板保存 ⇒ 重建 web-search 工具 ⇒ 替换注册表条目 ⇒ **下一轮即生效**
|
||||
// (design/09 §5.5 的端到端判据:不改 `.env`、不重启,下一次搜索真的走新配置);
|
||||
// - 主 provider / 降级链经同一个表单落到文件级字段后**立刻接上工具**;
|
||||
// - 保存结果会让后端**下次起不来**时:非 2xx + 结构化码 + **按字节回滚**;
|
||||
// - 用户真正拿到的那一面(管理端点 HTTP)也逐条核对。
|
||||
//
|
||||
// 夹具与假上游在 websearch_testenv_test.go;面板选择字段与解析口径在
|
||||
// websearch_selection_test.go。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||
)
|
||||
|
||||
// --- ① 保存后热重载:下一次搜索真的走新配置 -------------------------------
|
||||
|
||||
// TestWebSearchHotReloadPointsAtSavedEndpoint 是热重载的主判据(design/09 §5.5):
|
||||
// 面板把 searxng 的地址改成另一个上游 ⇒ **不重启** ⇒ 下一轮搜索必须打到新那家,
|
||||
// 且旧那家不再收到请求。同时钉住「注册表里的值是真的换了一把」(不是改了旧对象)。
|
||||
func TestWebSearchHotReloadPointsAtSavedEndpoint(t *testing.T) {
|
||||
old := newWSFakeSearxng(t, "old")
|
||||
fresh := newWSFakeSearxng(t, "fresh")
|
||||
|
||||
env := newReloadTestEnv(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
|
||||
"WEB_SEARCH_ENDPOINT="+old.url,
|
||||
)
|
||||
if _, err := env.search(t, "cyrene"); err != nil {
|
||||
t.Fatalf("启动期搜索失败: %v", err)
|
||||
}
|
||||
if len(old.hits) == 0 {
|
||||
t.Fatal("启动期没打到配置里的上游 —— 接线本身有问题")
|
||||
}
|
||||
before := env.toolValue(t)
|
||||
old.drain()
|
||||
|
||||
outcome := env.mustSave(t, map[string]any{
|
||||
"provider": search.NameSearXNG, "baseUrl": fresh.url, "enabled": true,
|
||||
})
|
||||
if !outcome.Applied || !outcome.Registered {
|
||||
t.Fatalf("hotReload = %+v, want applied 且 registered", outcome)
|
||||
}
|
||||
if env.toolValue(t) == before {
|
||||
t.Fatal("注册表里的 web-search 还是启动期那一把(没被替换)")
|
||||
}
|
||||
got, err := env.search(t, "cyrene")
|
||||
if err != nil {
|
||||
t.Fatalf("保存后搜索失败: %v", err)
|
||||
}
|
||||
if len(fresh.hits) == 0 {
|
||||
t.Fatal("保存后的搜索没打到新上游 —— 改动没生效(还得重启)")
|
||||
}
|
||||
old.noHit(t)
|
||||
if got["provider"] != search.NameSearXNG {
|
||||
t.Fatalf("provider = %v, want %q", got["provider"], search.NameSearXNG)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchHotReloadKeepsCapabilityPolicy:热替换必须**保留**能力面策略
|
||||
// (分组/陌生人可达标记是运行期配置,不属于工具实现)。丢了它 = 一次面板保存让某个群的
|
||||
// 工具面悄悄缩水。
|
||||
func TestWebSearchHotReloadKeepsCapabilityPolicy(t *testing.T) {
|
||||
up := newWSFakeSearxng(t, "a")
|
||||
env := newReloadTestEnv(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
|
||||
"WEB_SEARCH_ENDPOINT="+up.url,
|
||||
)
|
||||
if err := env.app.caps.SetAllowInStranger(webSearchToolName, true); err != nil {
|
||||
t.Fatalf("SetAllowInStranger: %v", err)
|
||||
}
|
||||
env.mustSave(t, map[string]any{
|
||||
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
|
||||
})
|
||||
pol, err := env.app.caps.Policy(webSearchToolName)
|
||||
if err != nil {
|
||||
t.Fatalf("Policy: %v", err)
|
||||
}
|
||||
if !pol.AllowInStranger {
|
||||
t.Fatal("热替换丢掉了 AllowInStranger(策略是运行期配置,必须沿用)")
|
||||
}
|
||||
}
|
||||
|
||||
// --- ② 主 provider / 降级链:面板可配且立刻生效 -----------------------------
|
||||
|
||||
// TestWebSearchHotReloadSwitchesPrimaryAndFallback 钉住裁决 #2 的端到端语义:
|
||||
// 面板选中谁当主家,下一次搜索就走谁;主家不可用时按降级链前进并**如实标注**实际应答的家。
|
||||
func TestWebSearchHotReloadSwitchesPrimaryAndFallback(t *testing.T) {
|
||||
searxng := newWSFakeUpstream(t, http.StatusInternalServerError, `{"error":"boom"}`)
|
||||
bocha := newWSFakeUpstream(t, http.StatusOK,
|
||||
`{"webPages":{"value":[{"name":"博查结果","url":"https://b.example/1","summary":"正文"}]}}`)
|
||||
|
||||
env := newReloadTestEnv(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
|
||||
"WEB_SEARCH_ENDPOINT="+searxng.url,
|
||||
"WEB_SEARCH_BOCHA_API_KEY=sk-bocha",
|
||||
"WEB_SEARCH_BOCHA_BASE_URL="+bocha.url,
|
||||
)
|
||||
|
||||
// 选 bocha 当主家(文件级 primary),降级链留 searxng:两者都在盘上、都已配齐。
|
||||
outcome := env.mustSave(t, map[string]any{
|
||||
"provider": search.NameBocha, "apiKey": "sk-bocha", "baseUrl": bocha.url, "enabled": true,
|
||||
"primary": search.NameBocha,
|
||||
"fallback": []any{search.NameSearXNG},
|
||||
})
|
||||
if !outcome.Applied {
|
||||
t.Fatalf("hotReload = %+v, want applied", outcome)
|
||||
}
|
||||
got, err := env.search(t, "cyrene")
|
||||
if err != nil {
|
||||
t.Fatalf("切换主家后搜索失败: %v", err)
|
||||
}
|
||||
if got["provider"] != search.NameBocha {
|
||||
t.Fatalf("provider = %v, want %q(面板选的主家必须立刻生效)", got["provider"], search.NameBocha)
|
||||
}
|
||||
if len(bocha.hits) == 0 {
|
||||
t.Fatal("选中的新主家一个请求都没收到")
|
||||
}
|
||||
|
||||
// 把主家换回 searxng(它 500)⇒ 必须**降级到 bocha** 并如实标注是谁答的。
|
||||
bocha.drain()
|
||||
searxng.drain()
|
||||
env.mustSave(t, map[string]any{
|
||||
"provider": search.NameSearXNG, "baseUrl": searxng.url, "enabled": true,
|
||||
"primary": search.NameSearXNG,
|
||||
"fallback": []any{search.NameBocha},
|
||||
})
|
||||
got, err = env.search(t, "cyrene")
|
||||
if err != nil {
|
||||
t.Fatalf("降级链搜索失败: %v", err)
|
||||
}
|
||||
if got["provider"] != search.NameBocha {
|
||||
t.Fatalf("provider = %v, want %q(主力 500 时必须前进一格并如实标注)", got["provider"], search.NameBocha)
|
||||
}
|
||||
if len(searxng.hits) == 0 {
|
||||
t.Fatal("降级链没先试主力 searxng")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchHotReloadTogglesToolByPrimary 钉住「关掉就整把消失、再开就回来」:
|
||||
// 主 provider 写成 off 哨兵 ⇒ 模型看不见这把工具(与启动期语义一致);改回一家可用的
|
||||
// ⇒ 同一进程内它又回到注册面。
|
||||
func TestWebSearchHotReloadTogglesToolByPrimary(t *testing.T) {
|
||||
up := newWSFakeSearxng(t, "a")
|
||||
env := newReloadTestEnv(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
|
||||
"WEB_SEARCH_ENDPOINT="+up.url,
|
||||
)
|
||||
off := env.mustSave(t, map[string]any{
|
||||
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
|
||||
"primary": "off",
|
||||
})
|
||||
if !off.Applied || off.Registered {
|
||||
t.Fatalf("hotReload = %+v, want applied=true registered=false", off)
|
||||
}
|
||||
if wsHasTool(env.app, webSearchToolName) {
|
||||
t.Fatalf("主 provider 写成 off 后 web-search 不得留在注册面里: %v", toolNames(env.app))
|
||||
}
|
||||
back := env.mustSave(t, map[string]any{
|
||||
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
|
||||
"primary": search.NameSearXNG,
|
||||
})
|
||||
if !back.Applied || !back.Registered {
|
||||
t.Fatalf("hotReload = %+v, want applied=true registered=true", back)
|
||||
}
|
||||
if _, err := env.search(t, "cyrene"); err != nil {
|
||||
t.Fatalf("改回可用主家后搜索应当成功: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ③ 回滚:不让面板存出一份「下次起不来」的配置 ---------------------------
|
||||
|
||||
// TestWebSearchHotReloadRollsBackUnstartableSave 是安全网的判据:面板把**当前主家**的
|
||||
// 凭据清空后,这份配置会让后端下次启动直接失败(design/27 §3 的 fail-fast)⇒ 保存必须
|
||||
// 诚实失败、把文件**按字节**回滚,并且运行中的工具必须仍是原来那一把。
|
||||
func TestWebSearchHotReloadRollsBackUnstartableSave(t *testing.T) {
|
||||
bocha := newWSFakeUpstream(t, http.StatusOK,
|
||||
`{"webPages":{"value":[{"name":"博查","url":"https://b.example/1","summary":"正文"}]}}`)
|
||||
env := newReloadTestEnv(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameBocha,
|
||||
"WEB_SEARCH_BOCHA_API_KEY=sk-keep",
|
||||
"WEB_SEARCH_BOCHA_BASE_URL="+bocha.url,
|
||||
)
|
||||
before := env.rawFile(t)
|
||||
toolBefore := env.toolValue(t)
|
||||
|
||||
_, err := env.save(map[string]any{"provider": search.NameBocha, "apiKey": ""})
|
||||
ae, ok := pluginmgr.AsActionError(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %v,期望结构化 ActionError(清空主家凭据会让后端起不来)", err)
|
||||
}
|
||||
if ae.Code != actionCodeConfigUnstartable {
|
||||
t.Fatalf("失败码 = %q, want %q", ae.Code, actionCodeConfigUnstartable)
|
||||
}
|
||||
if ae.Status < 400 || ae.Status > 599 {
|
||||
t.Fatalf("失败状态 = %d,必须是非 2xx 的结构化失败", ae.Status)
|
||||
}
|
||||
if strings.Contains(ae.Message, "sk-keep") {
|
||||
t.Fatalf("失败文案泄漏了凭据: %q", ae.Message)
|
||||
}
|
||||
|
||||
if got := env.rawFile(t); !bytes.Equal(got, before) {
|
||||
t.Fatalf("回滚后文件与保存前不逐字节相同:\nbefore=%s\nafter =%s", before, got)
|
||||
}
|
||||
if env.toolValue(t) != toolBefore {
|
||||
t.Fatal("被回滚的保存却把注册表里的工具换掉了(盘上与内存不一致)")
|
||||
}
|
||||
got, err := env.search(t, "cyrene")
|
||||
if err != nil {
|
||||
t.Fatalf("回滚后搜索应当照常成功: %v", err)
|
||||
}
|
||||
if got["provider"] != search.NameBocha {
|
||||
t.Fatalf("回滚后 provider = %v, want %q", got["provider"], search.NameBocha)
|
||||
}
|
||||
if out := env.out.String(); !strings.Contains(out, "rolled back") {
|
||||
t.Fatalf("回滚必须留下可诊断的日志: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchReloadWithoutRegistryIsHonest:没有能力注册表(夹具 / 装配未完成)时,
|
||||
// 热重载**如实报告 applied=false**,绝不假装换过了 —— 否则响应里的 registered 会是一句假话。
|
||||
func TestWebSearchReloadWithoutRegistryIsHonest(t *testing.T) {
|
||||
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||
b := bus.New()
|
||||
defer b.Close()
|
||||
mgr := pluginmgr.NewManager(registry.New(), filepath.Join(cfg.DataDir, "plugins.json"), testLog())
|
||||
defer mgr.Close(context.Background())
|
||||
|
||||
// 故意不设 caps:这正是既有动作夹具(actionTestEnv)的形状。
|
||||
a := &app{cfg: cfg, log: testLog(), bus: b, plugins: mgr}
|
||||
wirePanelHub(a)
|
||||
res, err := mgr.Panels().Dispatch(context.Background(), pluginmgr.ActionRequest{
|
||||
PluginID: search.PanelPluginID, Action: search.ActionSaveProvider,
|
||||
Payload: map[string]any{"values": map[string]any{
|
||||
"provider": search.NameSearXNG, "baseUrl": "http://127.0.0.1:38471", "enabled": true,
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("保存失败: %v", err)
|
||||
}
|
||||
outcome, ok := res.(map[string]any)["hotReload"].(webSearchReloadOutcome)
|
||||
if !ok {
|
||||
t.Fatalf("响应里没有 hotReload: %#v", res)
|
||||
}
|
||||
if outcome.Applied || outcome.Registered {
|
||||
t.Fatalf("没有注册表可换时必须如实报 applied=false: %+v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ④ 用户真正拿到的那一面:管理端点(HTTP)---------------------------------
|
||||
|
||||
// adminActionHTTP 把组合根装配出来的动作端点架起来,返回 base URL 与 owner 令牌。
|
||||
//
|
||||
// 网关这一段(鉴权 → 声明 → 分发 → 状态码映射)不是本片改的,但用户拿到的是它:
|
||||
// 只在动作中枢上验,没法证明「契约里那个 409 真的到得了客户端」。
|
||||
func adminActionHTTP(t *testing.T, env *reloadTestEnv) (string, string) {
|
||||
t.Helper()
|
||||
jwtSvc := auth.New(config.AuthConfig{JWTSecret: obsTestSecret})
|
||||
gw, err := gateway.New(&config.Config{
|
||||
Auth: config.AuthConfig{JWTSecret: obsTestSecret},
|
||||
}, gateway.Deps{Log: testLog(), Auth: jwtSvc, Plugins: env.app.plugins})
|
||||
if err != nil {
|
||||
t.Fatalf("gateway.New: %v", err)
|
||||
}
|
||||
t.Cleanup(gw.Close)
|
||||
ts := httptest.NewServer(gw.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
tok, err := jwtSvc.Sign("acct", auth.RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("sign token: %v", err)
|
||||
}
|
||||
return ts.URL + "/api/v1/admin/plugins/" + search.PanelPluginID + "/actions/" + search.ActionSaveProvider, tok
|
||||
}
|
||||
|
||||
// TestWebSearchHotReloadOverAdminHTTP 核对成功路径的 HTTP 契约:200 信封里的
|
||||
// `result.hotReload` 必须如实,而且保存后下一次搜索**真的**打到新上游。
|
||||
func TestWebSearchHotReloadOverAdminHTTP(t *testing.T) {
|
||||
cloud := newWSFakeUpstream(t, http.StatusOK,
|
||||
`{"webPages":{"value":[{"name":"博查","url":"https://b.example/1","summary":"正文"}]}}`)
|
||||
env := newReloadTestEnv(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameBocha,
|
||||
"WEB_SEARCH_BOCHA_API_KEY=sk-keep",
|
||||
"WEB_SEARCH_BOCHA_BASE_URL="+cloud.url,
|
||||
)
|
||||
url, tok := adminActionHTTP(t, env)
|
||||
|
||||
status, raw := wsPostAction(t, url, tok, map[string]any{"values": map[string]any{
|
||||
"provider": search.NameSearXNG, "baseUrl": cloud.url, "enabled": true,
|
||||
"primary": search.NameBocha,
|
||||
}})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("保存状态 = %d, want 200(body=%s)", status, raw)
|
||||
}
|
||||
var body struct {
|
||||
Data struct {
|
||||
Action string `json:"action"`
|
||||
Kind string `json:"kind"`
|
||||
Result struct {
|
||||
Saved bool `json:"saved"`
|
||||
HotReload struct {
|
||||
Applied bool `json:"applied"`
|
||||
Registered bool `json:"registered"`
|
||||
} `json:"hotReload"`
|
||||
} `json:"result"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Fatalf("decode: %v (body=%s)", err, raw)
|
||||
}
|
||||
if body.Data.Kind != string(pluginmgr.ActionWrite) {
|
||||
t.Errorf("kind = %q, want %q(写动作必须按写面鉴权)", body.Data.Kind, pluginmgr.ActionWrite)
|
||||
}
|
||||
if !body.Data.Result.Saved || !body.Data.Result.HotReload.Applied || !body.Data.Result.HotReload.Registered {
|
||||
t.Fatalf("响应 envelope 里的结果不如实: %s", raw)
|
||||
}
|
||||
got, err := env.search(t, "cyrene")
|
||||
if err != nil {
|
||||
t.Fatalf("HTTP 保存后搜索失败: %v", err)
|
||||
}
|
||||
if got["provider"] != search.NameBocha {
|
||||
t.Fatalf("provider = %v, want %q(HTTP 保存也必须热重载)", got["provider"], search.NameBocha)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchSaveOverAdminHTTPRollsBackUnstartable 核对失败路径的 HTTP 契约:
|
||||
// **非 2xx** + `{error:{code,message}}`,且盘上文件真的回到了保存前。
|
||||
func TestWebSearchSaveOverAdminHTTPRollsBackUnstartable(t *testing.T) {
|
||||
cloud := newWSFakeUpstream(t, http.StatusOK,
|
||||
`{"webPages":{"value":[{"name":"博查","url":"https://b.example/1","summary":"正文"}]}}`)
|
||||
env := newReloadTestEnv(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameBocha,
|
||||
"WEB_SEARCH_BOCHA_API_KEY=sk-keep",
|
||||
"WEB_SEARCH_BOCHA_BASE_URL="+cloud.url,
|
||||
)
|
||||
url, tok := adminActionHTTP(t, env)
|
||||
before := env.rawFile(t)
|
||||
|
||||
status, raw := wsPostAction(t, url, tok, map[string]any{"values": map[string]any{
|
||||
"provider": search.NameBocha, "apiKey": "",
|
||||
}})
|
||||
if status != http.StatusConflict {
|
||||
t.Fatalf("状态 = %d, want 409(body=%s)", status, raw)
|
||||
}
|
||||
var body struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Fatalf("decode error body: %v (body=%s)", err, raw)
|
||||
}
|
||||
if body.Error.Code != actionCodeConfigUnstartable {
|
||||
t.Fatalf("错误码 = %q, want %q(body=%s)", body.Error.Code, actionCodeConfigUnstartable, raw)
|
||||
}
|
||||
if strings.Contains(body.Error.Message, "sk-keep") || body.Error.Message == "" {
|
||||
t.Fatalf("错误文案 = %q(不得为空、不得含凭据)", body.Error.Message)
|
||||
}
|
||||
if got := env.rawFile(t); !bytes.Equal(got, before) {
|
||||
t.Fatalf("HTTP 层的回滚不彻底:\nbefore=%s\nafter =%s", before, got)
|
||||
}
|
||||
}
|
||||
|
||||
// wsPostAction 向管理动作端点发一次 POST(Bearer + JSON 体),返回状态码与响应体。
|
||||
func wsPostAction(t *testing.T, url, token string, payload any) (int, []byte) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("POST %s: %v", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", url, err)
|
||||
}
|
||||
return resp.StatusCode, body
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package main
|
||||
|
||||
// 本文件是「搜索提供商」页表单里**文件级字段**的解析与落盘(切片 W3;
|
||||
// `DECISIONS-2026-10-06` 裁决 #2:声明面必须能配「谁先用」——`primary` + `fallback[]`;
|
||||
// 后续切片又加了 `maxResults`:结果条数上限也得能在面板上改)。
|
||||
//
|
||||
// 它们与其余字段(provider / apiKey / baseUrl / enabled)的区别:
|
||||
//
|
||||
// - 其余字段描述**一条 provider 条目**(providers[] 里的一个元素);
|
||||
// - 这三个描述**整份配置**:`primary` / `fallback` 是 design/27 §3 的「主后端 + 降级链」,
|
||||
// `maxResults` 是单次检索的条数上限 —— 三者都是可变源顶层的同名键。
|
||||
//
|
||||
// 为什么挂在**同一个** form 区块里,而不是另开一个:前端的「带回表单」(reauth)只在面板
|
||||
// **恰好一个** form 区块时转交(`frontend/web/src/admin/plugin/PluginPanel.tsx` 的
|
||||
// `singleForm`),加第二个 form 会让既有的「重配凭据」回填整条失灵。因此这些项作为文件级
|
||||
// 字段进既有表单,回填值由 `reauthProvider` 一并给出(见 addSelectionValues 的调用点)。
|
||||
//
|
||||
// 字段 key 刻意不叫 `provider`:表单里的 `provider` 已经是「这一条 provider 的名字」,
|
||||
// 两个语义不同的东西共用一个 key 会让「我在改谁」变成一句要猜的话。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||
)
|
||||
|
||||
// 表单里三个文件级字段的 key 由**声明侧**给出(search.FieldPrimary / FieldFallback /
|
||||
// FieldMaxResults):它们必须与 panel.go 里写下的字段声明逐字一致(本文件读的正是这些 key),
|
||||
// 而「声明里写一个 key、处理器里读另一个」这类漂移只有共用一个常量才拦得住 ——
|
||||
// 在 cmd/cyrene 里再写一遍字面量就等于第二份真源。
|
||||
const (
|
||||
// selectionPrimaryKey 是主 provider 的字段 key(落到文件级 `provider`)。
|
||||
selectionPrimaryKey = search.FieldPrimary
|
||||
// selectionFallbackKey 是降级链的字段 key(落到文件级 `fallback`)。
|
||||
selectionFallbackKey = search.FieldFallback
|
||||
// selectionMaxResultsKey 是结果条数上限的字段 key(落到文件级 `maxResults`)。
|
||||
selectionMaxResultsKey = search.FieldMaxResults
|
||||
)
|
||||
|
||||
// actionCodePrimaryNotConfigured 报告「选中的主 provider 在本文件里还没配齐」——
|
||||
// 这不是一次可以放着不管的保存:那份配置会让后端**下次启动直接失败**(design/27 §3 的
|
||||
// fail-fast),所以在写盘之前就拒掉,而不是先写进盘里等着下次起不来。
|
||||
const actionCodePrimaryNotConfigured = "primary_not_configured"
|
||||
|
||||
// mergeSelectionPatch 把表单里的三个**文件级**字段(主 provider / 降级链 / 结果条数上限)
|
||||
// 合并进文件(三态:**缺位 = 不改动**),并回报「这次有没有给出 `primary`」
|
||||
// —— **校验不在这里**。
|
||||
//
|
||||
// 与其余字段同一套哨兵语义(websearch_action_data.go 的 saveProvider 注释):
|
||||
// `primary` 缺位 ⇒ 保留盘上的;给了值(含显式空串 / off / none)⇒ 采用 —— 空串与
|
||||
// `off`/`none` 是本项目**唯一一套**关闭语义(config.IsWebSearchOffSentinel),面板不另造一套。
|
||||
//
|
||||
// `maxResults` 的第三态**比另外两个严**:它没有「空串 = 关闭」这种解释,因此 ≤0 与非整数
|
||||
// 一律 400(口径与理由见 selectionMaxResults)—— 那正是这一片要消灭的「我改了它却没变」。
|
||||
//
|
||||
// 🔴 **顺序硬约束(缺陷修复 2026-10-07,真机报障)**:主 provider 的可用性校验必须由调用方
|
||||
// 在「**本次这条 provider 条目已经并进文件之后**」做(`checkPrimaryUsable(final, …)`)。
|
||||
// 本函数此前自己顺手校验,用的是**盘上的旧文件** ⇒ 面板上第一次给某家填 key、又在**同一次
|
||||
// 提交**里把它选为主 provider 时,校验看不到那个还没落盘的 key,于是整笔(**连同刚填的 key**)
|
||||
// 被 400 `primary_not_configured` 回滚 —— 界面上表现为「怎么填都存不进去」,而盘上文件
|
||||
// mtime 一动不动(2026-10-07 09:50 日志连续两次 `plugin.saveProvider … code=primary_not_configured`)。
|
||||
// 调用点见 websearch_action_data.go 的 saveProvider:先 `UpsertProviderEntry`,再校验,最后写盘。
|
||||
func (e webSearchActionEnv) mergeSelectionPatch(
|
||||
f runtimeconfig.WebSearchFile, values map[string]any,
|
||||
) (runtimeconfig.WebSearchFile, bool, error) {
|
||||
primary, setPrimary, err := selectionPrimary(values)
|
||||
if err != nil {
|
||||
return f, false, err
|
||||
}
|
||||
fallback, setFallback, err := selectionFallback(values)
|
||||
if err != nil {
|
||||
return f, false, err
|
||||
}
|
||||
maxResults, setMaxResults, err := selectionMaxResults(values)
|
||||
if err != nil {
|
||||
return f, false, err
|
||||
}
|
||||
if setPrimary {
|
||||
f.Provider = primary
|
||||
}
|
||||
if setFallback {
|
||||
f.Fallback = fallback
|
||||
}
|
||||
if setMaxResults {
|
||||
f.MaxResults = maxResults
|
||||
}
|
||||
return f, setPrimary, nil
|
||||
}
|
||||
|
||||
// checkPrimaryUsable 判定一个主 provider 名在**落盘后的文件**里是否可用。
|
||||
//
|
||||
// 判定口径与工具接线同源(resolveSearchProviders + resolveBaseURL):云家看 API Key、
|
||||
// searxng 看接口地址,基地址缺位时回落**该接入位的代码默认基地址**;条目被显式停用
|
||||
// 视同没配(装配期会跳过它)。判据本身**不放松**:真的没配齐的主家仍必须被拒。
|
||||
func (e webSearchActionEnv) checkPrimaryUsable(f runtimeconfig.WebSearchFile, name string) error {
|
||||
// 显式关闭(空串 / off / none)永远合法:它表示「不要这把工具」,工具不进注册面,
|
||||
// 后端照常启动(与 WEB_SEARCH_PROVIDER 的既有语义逐字一致)。
|
||||
if config.IsWebSearchOffSentinel(name) {
|
||||
return nil
|
||||
}
|
||||
if err := requireKnownProvider(name); err != nil {
|
||||
return err
|
||||
}
|
||||
entry, found := findProviderEntry(f, name)
|
||||
if !found || !entry.IsEnabled() {
|
||||
return primaryNotConfiguredError(name)
|
||||
}
|
||||
// 基地址的进位链与装配期**同一个函数**:条目里的值 → 该接入位的默认基地址 → 空。
|
||||
// 云家的可用性只看 key(地址取默认值也照样能打通),searxng 的默认值是空
|
||||
// —— 因此「searxng 主家必须有地址」这条判据与既往逐字一致。
|
||||
if !search.Configured(name, entry.APIKey, resolveBaseURL(name, entry.BaseURL)) {
|
||||
return primaryNotConfiguredError(name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// primaryNotConfiguredError 造「主家没配齐」这一条的 400:文案与码只有一份
|
||||
// (两个判据分支共用,免得两处措辞漂移)。
|
||||
func primaryNotConfiguredError(name string) error {
|
||||
return pluginmgr.NewActionError(actionCodePrimaryNotConfigured,
|
||||
fmt.Sprintf("主提供商 %q 还没配齐(缺 API Key / 接口地址,或这条被停用了):"+
|
||||
"先把它配好再选它 —— 否则这份配置会让后端下一次启动直接失败", name),
|
||||
http.StatusBadRequest, nil)
|
||||
}
|
||||
|
||||
// selectionNames 返回降级链的线形状(**非 nil 空切片**:nil 会在 JSON 里变成 null,
|
||||
// 而「一条降级都没有」与「这个字段没值」在前端不该长得不一样)。
|
||||
func selectionNames(names []string) []string {
|
||||
if names == nil {
|
||||
return []string{}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// addSelectionValues 把盘上的**文件级取值**回填进 `reauth` 的 values。
|
||||
//
|
||||
// 回填的必要性在前端的三态写回里:只有 `initial` 有值,用户「清空降级链」才会被送成
|
||||
// 显式空(`planSubmit`),否则会落成「缺位 = 不改动」——「我清掉了它」就成了一句假话。
|
||||
// `maxResults` 同理:不回填的话表单里那个框是空的,用户看不见当前上限、也就无从改起。
|
||||
func addSelectionValues(values map[string]any, f runtimeconfig.WebSearchFile) {
|
||||
values[selectionPrimaryKey] = f.Provider
|
||||
fb := f.Fallback
|
||||
if fb == nil {
|
||||
fb = []string{}
|
||||
}
|
||||
values[selectionFallbackKey] = fb
|
||||
// 上限只在盘上**真的给过**时才回填(`maxResults` 的 0 = 未声明):回填一个 0 会被前端
|
||||
// 当成一个值送回来,而写侧拒 ≤0 —— 用户「什么都没改」的一次保存就整笔进不去了。
|
||||
if f.MaxResults > 0 {
|
||||
values[selectionMaxResultsKey] = f.MaxResults
|
||||
}
|
||||
}
|
||||
|
||||
// selectionMaxResults 解析结果条数上限字段(落点:文件级 `maxResults`)。**三态**:
|
||||
//
|
||||
// - 缺位 / JSON null ⇒ 不改动;
|
||||
// - 正整数 ⇒ 采用(落盘后由热重载事务接上工具,下一次搜索就用它);
|
||||
// - 其余(0、负数、小数、空串、非数字字符串、布尔、数组…)⇒ **400 invalid_payload**。
|
||||
//
|
||||
// 为什么第三态必须报错,而不是像 `enabled` 那样「空串 = 未改动」:这一片的来由正是
|
||||
// 「我改了它却没变」—— 手工塞进提交体的上限被写侧静默丢弃(文件不变、也不报错)。既然
|
||||
// 面板上给了这个输入框,任何**给了值却没被采用**的提交都必须说得出原因;否则用户以为上限
|
||||
// 是 20,跑的还是 10。0 / 负数在这里也没有第二种解释:文件形状里 `maxResults` 的 0 =
|
||||
// 「未声明」(`omitempty` 不落盘),把 0 当「去掉上限」会让「去掉」与「从没配过」长得一模一样。
|
||||
func selectionMaxResults(values map[string]any) (int, bool, error) {
|
||||
raw, ok := values[selectionMaxResultsKey]
|
||||
if !ok || raw == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
n, ok := wholeNumber(raw)
|
||||
if !ok || n < 1 {
|
||||
// %q + Sprint:字符串会带引号(空串才看得见),数字/布尔照原样。
|
||||
return 0, false, invalidPayloadActionError(fmt.Sprintf(
|
||||
"%s 必须是正整数(≥1),收到 %q:这个框不能靠留空或 0 去掉上限,请填一个具体条数",
|
||||
selectionMaxResultsKey, fmt.Sprint(raw)))
|
||||
}
|
||||
return n, true, nil
|
||||
}
|
||||
|
||||
// wholeNumber 把一个补丁值还原成整数:JSON 数字(float64,必须**没有小数部分**)、Go 侧构造的
|
||||
// int、以及整数字符串(与 `wire_browser_panel_data.go` 的 patchInt 同一取舍:某些控件会把值以
|
||||
// 字符串送出)。其余类型一律拒 —— 不做静默转换,那正是本片要消灭的那类假成功。
|
||||
func wholeNumber(raw any) (int, bool) {
|
||||
switch v := raw.(type) {
|
||||
case float64:
|
||||
if v != math.Trunc(v) {
|
||||
return 0, false
|
||||
}
|
||||
return int(v), true
|
||||
case int:
|
||||
return v, true
|
||||
case string:
|
||||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// selectionPrimary 解析主 provider 字段:缺位 ⇒ (…, false, nil);非字符串 ⇒ 400。
|
||||
func selectionPrimary(values map[string]any) (string, bool, error) {
|
||||
raw, ok := values[selectionPrimaryKey]
|
||||
if !ok || raw == nil {
|
||||
return "", false, nil
|
||||
}
|
||||
s, ok := raw.(string)
|
||||
if !ok {
|
||||
return "", false, invalidPayloadActionError(
|
||||
fmt.Sprintf("%s 必须是字符串(提供商名)", selectionPrimaryKey))
|
||||
}
|
||||
return normalizeProviderName(s), true, nil
|
||||
}
|
||||
|
||||
// selectionFallback 解析降级链字段:缺位 ⇒ (…, false, nil);形状不对 / 名字未登记 ⇒ 400。
|
||||
func selectionFallback(values map[string]any) ([]string, bool, error) {
|
||||
raw, ok := values[selectionFallbackKey]
|
||||
if !ok || raw == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
items, err := selectionItems(raw)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
seen := make(map[string]bool, len(items))
|
||||
for _, item := range items {
|
||||
name := normalizeProviderName(item)
|
||||
if name == "" || seen[name] {
|
||||
continue
|
||||
}
|
||||
// 未登记的家存进降级链也永远不会被选中(注册表按名字取)⇒ 这里就拒,与
|
||||
// 条目保存的 requireKnownProvider 同一个码与同一句文案。
|
||||
if err := requireKnownProvider(name); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
seen[name] = true
|
||||
out = append(out, name)
|
||||
}
|
||||
return out, true, nil
|
||||
}
|
||||
|
||||
// selectionItems 把多选值规范化成字符串切片。
|
||||
//
|
||||
// 三种形状都接受:
|
||||
// - `[]string`(Go 侧直接构造的载荷,测试与内部调用用);
|
||||
// - `[]any`(JSON 解码后的数组 —— 前端 multiSelect 送的就是它);
|
||||
// - 逗号 / 顿号 / 换行分隔的字符串(与前端控件「逗号分隔」的呈现同源,人工 curl 友好)。
|
||||
//
|
||||
// 其余类型一律 400:静默当空数组 = 一次保存悄悄清空降级链(与 apiKey 的类型错同一条口径)。
|
||||
func selectionItems(raw any) ([]string, error) {
|
||||
switch v := raw.(type) {
|
||||
case []string:
|
||||
return v, nil
|
||||
case []any:
|
||||
out := make([]string, 0, len(v))
|
||||
for i, item := range v {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, invalidPayloadActionError(
|
||||
fmt.Sprintf("%s[%d] 必须是字符串(提供商名)", selectionFallbackKey, i))
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
case string:
|
||||
return strings.FieldsFunc(v, func(r rune) bool {
|
||||
return r == ',' || r == ',' || r == '、' || r == ';' || r == '\n'
|
||||
}), nil
|
||||
default:
|
||||
return nil, invalidPayloadActionError(
|
||||
fmt.Sprintf("%s 必须是字符串数组(如 [\"bocha\",\"zhipu\"])", selectionFallbackKey))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package main
|
||||
|
||||
// 本文件钉住切片 W3 的另外三条判据(`DECISIONS-2026-10-06` 裁决 #2 / 点 ③ / 点 ④):
|
||||
//
|
||||
// - **主 provider / 降级链可配**:两个文件级字段(`primary` / `fallback`)经表单落到
|
||||
// 可变源,回填(reauth)也带着它们;没配齐的主家**写盘之前**就拒掉;
|
||||
// - **主 provider 解析口径**(配了可变源 / 只配 env / 都没配)各选谁;
|
||||
// - **探针失败如实**:非 2xx + 结构化码,不存在「200 带 ok:false」这条路径。
|
||||
//
|
||||
// 夹具与假上游在 websearch_testenv_test.go;热重载本体在 websearch_reload_test.go。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||
)
|
||||
|
||||
// --- ① 面板校验:选一个没配齐的主家当场拒掉 ---------------------------------
|
||||
|
||||
// TestWebSearchSelectionRejectsUnusablePrimary 钉住「写盘之前就拦」:主 provider 选了
|
||||
// 一家没配齐的(缺 key)必须回 400 primary_not_configured,且**盘上一个字节都没变**
|
||||
// (这不是「存下来再说」,那份配置会让后端下次起不来)。
|
||||
func TestWebSearchSelectionRejectsUnusablePrimary(t *testing.T) {
|
||||
env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||
before := env.rawFile(t)
|
||||
|
||||
_, err := env.save(map[string]any{
|
||||
"provider": search.NameSearXNG, "enabled": true, "primary": search.NameZhipu,
|
||||
})
|
||||
ae, ok := pluginmgr.AsActionError(err)
|
||||
if !ok || ae.Code != actionCodePrimaryNotConfigured || ae.Status != http.StatusBadRequest {
|
||||
t.Fatalf("err = %#v, want 400 %s", err, actionCodePrimaryNotConfigured)
|
||||
}
|
||||
if got := env.rawFile(t); !bytes.Equal(got, before) {
|
||||
t.Fatalf("被拒的保存动了盘上的文件:\nbefore=%s\nafter =%s", before, got)
|
||||
}
|
||||
|
||||
// 未登记的名字:与条目级保存同一个码同一句口径。
|
||||
_, err = env.save(map[string]any{
|
||||
"provider": search.NameSearXNG, "enabled": true, "primary": "tavily",
|
||||
})
|
||||
if ae, ok := pluginmgr.AsActionError(err); !ok || ae.Code != actionCodeUnknownProvider {
|
||||
t.Fatalf("err = %#v, want %s", err, actionCodeUnknownProvider)
|
||||
}
|
||||
// 降级链里的未登记名字同样拒(它永远不会被选中,存下去就是假话)。
|
||||
_, err = env.save(map[string]any{
|
||||
"provider": search.NameSearXNG, "enabled": true, "fallback": []any{"tavily"},
|
||||
})
|
||||
if ae, ok := pluginmgr.AsActionError(err); !ok || ae.Code != actionCodeUnknownProvider {
|
||||
t.Fatalf("err = %#v, want %s", err, actionCodeUnknownProvider)
|
||||
}
|
||||
// 形状错(不是数组)⇒ 400,绝不静默当空数组清空降级链。
|
||||
_, err = env.save(map[string]any{
|
||||
"provider": search.NameSearXNG, "enabled": true, "fallback": 42,
|
||||
})
|
||||
if ae, ok := pluginmgr.AsActionError(err); !ok || ae.Code != pluginmgr.ActionCodeInvalidPayload {
|
||||
t.Fatalf("err = %#v, want %s", err, pluginmgr.ActionCodeInvalidPayload)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchSelectionAcceptsConfiguredCloudFallback 钉住「降级链允许放还没配齐的家」:
|
||||
// 未配置的家在运行期会被跳过(不是错误),所以面板存得下去;而主家必须是配齐的。
|
||||
// 同时钉住归一化:去空白、去重、丢空项。
|
||||
func TestWebSearchSelectionAcceptsConfiguredCloudFallback(t *testing.T) {
|
||||
up := newWSFakeSearxng(t, "a")
|
||||
env := newReloadTestEnv(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
|
||||
"WEB_SEARCH_ENDPOINT="+up.url,
|
||||
)
|
||||
env.mustSave(t, map[string]any{
|
||||
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
|
||||
"fallback": []any{search.NameZhipu, " bocha ", search.NameZhipu, ""},
|
||||
})
|
||||
if got := env.loadFile(t).Fallback; strings.Join(got, ",") != search.NameZhipu+","+search.NameBocha {
|
||||
t.Fatalf("降级链落盘 = %v, want [zhipu bocha]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchReauthEchoesSelection:表单回填必须带上两个文件级字段,否则用户
|
||||
// 「清空降级链」在前端的三态写回里会落成「缺位 = 不改动」。
|
||||
func TestWebSearchReauthEchoesSelection(t *testing.T) {
|
||||
up := newWSFakeSearxng(t, "a")
|
||||
env := newReloadTestEnv(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
|
||||
"WEB_SEARCH_ENDPOINT="+up.url,
|
||||
)
|
||||
env.mustSave(t, map[string]any{
|
||||
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
|
||||
"primary": search.NameSearXNG, "fallback": []any{search.NameBocha},
|
||||
})
|
||||
out, err := env.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
|
||||
PluginID: search.PanelPluginID, Action: search.ActionReauthProvider,
|
||||
Payload: map[string]any{"row": map[string]any{"provider": search.NameSearXNG}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("reauth: %v", err)
|
||||
}
|
||||
values, _ := out.(map[string]any)["values"].(map[string]any)
|
||||
if values == nil {
|
||||
t.Fatalf("reauth 结果缺 values: %#v", out)
|
||||
}
|
||||
if values[search.FieldPrimary] != search.NameSearXNG {
|
||||
t.Fatalf("回填的 primary = %v, want %q", values[search.FieldPrimary], search.NameSearXNG)
|
||||
}
|
||||
fb, ok := values[search.FieldFallback].([]string)
|
||||
if !ok || strings.Join(fb, ",") != search.NameBocha {
|
||||
t.Fatalf("回填的 fallback = %#v, want [%s]", values[search.FieldFallback], search.NameBocha)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ② 主 provider 解析口径(三情形 + 清空)---------------------------------
|
||||
|
||||
// TestWebSearchPrimaryResolutionFromEnvOnly:**只配 `.env`**(可变源文件缺失)⇒ 选 `.env`
|
||||
// 那家,来源标 `env`,且播种真的落了盘。
|
||||
func TestWebSearchPrimaryResolutionFromEnvOnly(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
cfg := wsLoadConfig(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameBocha,
|
||||
"WEB_SEARCH_BOCHA_API_KEY=sk-env",
|
||||
)
|
||||
wsSurfaceApp(t, cfg, &out)
|
||||
if got := wsLogField(t, out.String(), "primary"); got != search.NameBocha {
|
||||
t.Fatalf("primary = %q, want %q(.env 是唯一来源)", got, search.NameBocha)
|
||||
}
|
||||
if got := wsLogField(t, out.String(), "configSource"); got != webSearchSourceEnv {
|
||||
t.Fatalf("configSource = %q, want %q", got, webSearchSourceEnv)
|
||||
}
|
||||
if _, found, err := runtimeconfig.LoadWebSearch(wsPanelPath(cfg)); err != nil || !found {
|
||||
t.Fatalf("文件缺失时必须按 .env 播种落盘: found=%v err=%v", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchPrimaryResolutionPanelWins:**配了可变源** ⇒ 选文件里那家(`.env` 说的
|
||||
// 另一家不作数),来源标 panel。
|
||||
func TestWebSearchPrimaryResolutionPanelWins(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
cfg := wsLoadConfig(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameZhipu,
|
||||
"WEB_SEARCH_ZHIPU_API_KEY=sk-env-zhipu",
|
||||
)
|
||||
if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{
|
||||
Version: runtimeconfig.WebSearchVersion,
|
||||
Provider: search.NameBocha,
|
||||
Providers: []runtimeconfig.ProviderEntry{
|
||||
{Provider: search.NameBocha, APIKey: "sk-panel", Enabled: runtimeconfig.EnableFlag(true)},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("写面板配置: %v", err)
|
||||
}
|
||||
a := wsSurfaceApp(t, cfg, &out)
|
||||
if !wsHasTool(a, webSearchToolName) {
|
||||
t.Fatalf("面板里 bocha 已配齐,工具应在注册面里: %v", toolNames(a))
|
||||
}
|
||||
if got := wsLogField(t, out.String(), "primary"); got != search.NameBocha {
|
||||
t.Fatalf("primary = %q, want %q(面板文件是唯一来源,.env 说的 zhipu 不作数)", got, search.NameBocha)
|
||||
}
|
||||
if got := wsLogField(t, out.String(), "configSource"); got != webSearchSourcePanel {
|
||||
t.Fatalf("configSource = %q, want %q", got, webSearchSourcePanel)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchPrimaryResolutionDefaultsToSearxng:**都没配** ⇒ 代码默认 searxng
|
||||
// (本机兜底端点),工具照常在注册面里。
|
||||
func TestWebSearchPrimaryResolutionDefaultsToSearxng(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
cfg := wsLoadConfig(t)
|
||||
a := wsSurfaceApp(t, cfg, &out)
|
||||
if !wsHasTool(a, webSearchToolName) {
|
||||
t.Fatalf("都没配时代码默认(searxng 本机兜底)应当生效: %v", toolNames(a))
|
||||
}
|
||||
if got := wsLogField(t, out.String(), "primary"); got != search.NameSearXNG {
|
||||
t.Fatalf("primary = %q, want %q(代码默认值)", got, search.NameSearXNG)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchPrimaryResolutionClearedFileMeansOff:文件里把 provider 清空 ⇒ **关闭**
|
||||
// (不是回落 `.env`)——「清空 = 关」是文件形状里写死的语义,面板与 `.env` 不许有两套。
|
||||
func TestWebSearchPrimaryResolutionClearedFileMeansOff(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
cfg := wsLoadConfig(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameBocha,
|
||||
"WEB_SEARCH_BOCHA_API_KEY=sk-env",
|
||||
)
|
||||
if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{
|
||||
Version: runtimeconfig.WebSearchVersion,
|
||||
// 显式清空主 provider:哨兵语义 = 关掉整把工具。
|
||||
Provider: "off",
|
||||
Providers: []runtimeconfig.ProviderEntry{
|
||||
{Provider: search.NameBocha, APIKey: "sk-panel", Enabled: runtimeconfig.EnableFlag(true)},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("写面板配置: %v", err)
|
||||
}
|
||||
a := wsSurfaceApp(t, cfg, &out)
|
||||
if wsHasTool(a, webSearchToolName) {
|
||||
t.Fatalf("文件里显式关闭(off)⇒ 工具不得进注册面: %v", toolNames(a))
|
||||
}
|
||||
if out := out.String(); !strings.Contains(out, "web-search tool not registered") {
|
||||
t.Fatalf("关闭必须留下可诊断的 WARN: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ③ 探针失败如实:非 2xx + 结构化码,绝不「200 带 ok:false」----------------
|
||||
|
||||
// TestWebSearchProbeFailuresAreStructuredNon2xx 钉住裁决 #4(点 ④):每一次探针失败都必须是
|
||||
// **结构化非 2xx**。前端行内动作只在失败时显示文案,把「探不通」放进 200 的 data 里
|
||||
// 等于让界面报一句假成功。
|
||||
//
|
||||
// 探针的成功/失败分类由 search.Probe 给出(那里已有单测),这里查的是**动作面**有没有
|
||||
// 把失败原样交出去:码稳定、状态码落在 4xx/5xx、文案不带上游原文。
|
||||
func TestWebSearchProbeFailuresAreStructuredNon2xx(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
wantCode string
|
||||
want int
|
||||
}{
|
||||
{"凭据被拒", http.StatusUnauthorized, `{"error":"bad key sk-leak"}`, search.ProbeUnauthorized, http.StatusBadGateway},
|
||||
{"被限流", http.StatusTooManyRequests, `{}`, search.ProbeRateLimited, http.StatusBadGateway},
|
||||
{"上游 5xx", http.StatusInternalServerError, `{}`, search.ProbeUpstream, http.StatusBadGateway},
|
||||
{"应答不是预期结构", http.StatusOK, `not json`, search.ProbeBadResponse, http.StatusBadGateway},
|
||||
{"连通但零结果", http.StatusOK, `{"results":[]}`, search.ProbeNoResults, http.StatusBadGateway},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
up := newWSFakeUpstream(t, tc.status, tc.body)
|
||||
env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||
env.mustSave(t, map[string]any{
|
||||
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
|
||||
})
|
||||
out, err := env.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
|
||||
PluginID: search.PanelPluginID, Action: search.ActionTestProvider,
|
||||
Payload: map[string]any{"row": map[string]any{"provider": search.NameSearXNG}},
|
||||
})
|
||||
if err == nil {
|
||||
// 「200 带 ok:false」正是这条判据要堵的路:要么成功(ok=true 的结果),
|
||||
// 要么结构化失败,没有第三种。
|
||||
if res, ok := out.(search.ProbeResult); ok && !res.OK {
|
||||
t.Fatalf("探针失败却回了成功响应(200 + ok:false):%+v", res)
|
||||
}
|
||||
t.Fatalf("探针失败却回了 200(结果 = %#v)", out)
|
||||
}
|
||||
ae, ok := pluginmgr.AsActionError(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %v,期望结构化 ActionError", err)
|
||||
}
|
||||
if ae.Code != tc.wantCode {
|
||||
t.Fatalf("失败码 = %q, want %q", ae.Code, tc.wantCode)
|
||||
}
|
||||
if ae.Status != tc.want {
|
||||
t.Fatalf("失败状态 = %d, want %d(非 2xx 才算如实)", ae.Status, tc.want)
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"code": ae.Code, "message": ae.Message})
|
||||
if strings.Contains(string(raw), "sk-leak") {
|
||||
t.Fatalf("失败文案泄漏了上游原文: %s", raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchProbeNotConfiguredIs400:没配齐是**调用方/配置**的问题(400),
|
||||
// 不是上游不可用(502)—— 前端据此区分「该改配置」还是「该重试」。
|
||||
func TestWebSearchProbeNotConfiguredIs400(t *testing.T) {
|
||||
env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||
_, err := env.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
|
||||
PluginID: search.PanelPluginID, Action: search.ActionTestProvider,
|
||||
Payload: map[string]any{"row": map[string]any{"provider": search.NameZhipu}},
|
||||
})
|
||||
ae, ok := pluginmgr.AsActionError(err)
|
||||
if !ok || ae.Code != search.ProbeNotConfigured || ae.Status != http.StatusBadRequest {
|
||||
t.Fatalf("err = %#v, want 400 %s", err, search.ProbeNotConfigured)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchSaveProviderAndPrimaryInOneSubmit 是 2026-10-07 真机报障的回归:
|
||||
// 面板上第一次给某家填 key、又在**同一次提交**里把它选为主 provider,必须**一次成功**。
|
||||
//
|
||||
// 缺陷原形:主 provider 的可用性校验拿的是**盘上的旧文件**(那条 provider 还没 key)
|
||||
// ⇒ 整笔(**连同刚填的 key**)被 400 primary_not_configured 回滚,界面上表现为
|
||||
// 「怎么填都存不进去」,而盘上文件 mtime 一动不动。修法:校验移到「本次条目已并进
|
||||
// 文件之后」(websearch_action_data.go 的 saveProvider 先 UpsertProviderEntry 再 check)。
|
||||
func TestWebSearchSaveProviderAndPrimaryInOneSubmit(t *testing.T) {
|
||||
env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||
|
||||
// 前置:盘上那条 zhipu 没有 key —— 面板第一次配置它时正是这个状态。
|
||||
if e, ok := findProviderEntry(env.loadFile(t), search.NameZhipu); !ok || strings.TrimSpace(e.APIKey) != "" {
|
||||
t.Fatalf("前置不成立:盘上 zhipu 应存在且尚无 key(found=%v, key=%q)", ok, e.APIKey)
|
||||
}
|
||||
|
||||
if _, err := env.save(map[string]any{
|
||||
"provider": search.NameZhipu, "apiKey": "test-key-001", "enabled": true,
|
||||
"primary": search.NameZhipu,
|
||||
}); err != nil {
|
||||
t.Fatalf("同一次提交里填 key + 选它当主家被拒(键与选择必须能一起落盘): %v", err)
|
||||
}
|
||||
|
||||
after := env.loadFile(t)
|
||||
if got := strings.ToLower(strings.TrimSpace(after.Provider)); got != search.NameZhipu {
|
||||
t.Fatalf("盘上 primary = %q, want %q", got, search.NameZhipu)
|
||||
}
|
||||
entry, ok := findProviderEntry(after, search.NameZhipu)
|
||||
if !ok || entry.APIKey != "test-key-001" {
|
||||
t.Fatalf("盘上 zhipu 条目 = %+v, want 带本次填的 key", entry)
|
||||
}
|
||||
|
||||
// 反向护栏:**真的没配齐**的主家仍必须被拒,且盘上一个字节都不动。
|
||||
// (本次修复只改「校验看哪一份文件」,不改判据。)
|
||||
beforeReject := env.rawFile(t)
|
||||
_, err := env.save(map[string]any{
|
||||
"provider": search.NameBocha, "enabled": true, "primary": search.NameBocha,
|
||||
})
|
||||
ae, ok := pluginmgr.AsActionError(err)
|
||||
if !ok || ae.Code != actionCodePrimaryNotConfigured {
|
||||
t.Fatalf("err = %#v, want 400 %s(bocha 这次没填 key)", err, actionCodePrimaryNotConfigured)
|
||||
}
|
||||
if got := env.rawFile(t); !bytes.Equal(got, beforeReject) {
|
||||
t.Fatalf("被拒的保存动了盘上的文件:\nbefore=%s\nafter =%s", beforeReject, got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package main
|
||||
|
||||
// 本文件是切片 W3 那几组测试的**夹具**(`newReloadTestEnv` / 假上游 / 日志字段读取):
|
||||
// 单独成一个测试文件,是为了让三个主题文件(热重载、面板选择字段、解析口径与探针)
|
||||
// 各自都在 AGENTS §10 的文件行数之内,且共享的接线只写一份。
|
||||
//
|
||||
// 夹具的关键差别(相对 wire_plugin_actions_test.go 的 actionTestEnv):它带
|
||||
// `a.caps` / `a.dispatcher` —— 热重载换的就是注册表里的条目,没有能力面的夹具
|
||||
// 根本证明不了这件事(那正是 L1/PLG-2 当时如实登记的缺口)。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||
)
|
||||
|
||||
// reloadTestEnv 是一个「组合根真的接好了能力面 + 面板动作面」的夹具。
|
||||
type reloadTestEnv struct {
|
||||
app *app
|
||||
hub *pluginmgr.Panels
|
||||
disp *tool.Dispatcher
|
||||
path string
|
||||
out *bytes.Buffer
|
||||
}
|
||||
|
||||
func newReloadTestEnv(t *testing.T, extra ...string) *reloadTestEnv {
|
||||
t.Helper()
|
||||
cfg := wsLoadConfig(t, extra...)
|
||||
b := bus.New()
|
||||
t.Cleanup(func() { _ = b.Close() })
|
||||
mgr := pluginmgr.NewManager(registry.New(), filepath.Join(cfg.DataDir, "plugins.json"), testLog())
|
||||
t.Cleanup(func() { mgr.Close(context.Background()) })
|
||||
|
||||
var out bytes.Buffer
|
||||
caps := registry.New()
|
||||
disp, err := tool.New(caps)
|
||||
if err != nil {
|
||||
t.Fatalf("tool.New: %v", err)
|
||||
}
|
||||
a := &app{
|
||||
cfg: cfg,
|
||||
log: logger.New(logger.Options{Out: &out, Level: "debug"}),
|
||||
bus: b,
|
||||
plugins: mgr,
|
||||
caps: caps,
|
||||
dispatcher: disp,
|
||||
}
|
||||
// 启动期那条路(与 wsSurfaceApp 同源):ioToolOptions → iotools.New → 注册面。
|
||||
set, err := iotools.New(cfg, a.ioToolOptions()...)
|
||||
if err != nil {
|
||||
t.Fatalf("iotools.New: %v", err)
|
||||
}
|
||||
for _, tl := range set {
|
||||
if rerr := disp.Register(tl); rerr != nil {
|
||||
t.Fatalf("register %s: %v", tl.Name(), rerr)
|
||||
}
|
||||
}
|
||||
wirePanelHub(a)
|
||||
if mgr.Panels() == nil {
|
||||
t.Fatal("wirePanelHub 未把面板中枢接到插件管理器上")
|
||||
}
|
||||
return &reloadTestEnv{
|
||||
app: a, hub: mgr.Panels(), disp: disp,
|
||||
path: runtimeconfig.WebSearchConfigPath(cfg.DataDir), out: &out,
|
||||
}
|
||||
}
|
||||
|
||||
// save 走保存动作(表单形状 `{values:{...}}`),返回结果的**原始 any 与错误**。
|
||||
func (e *reloadTestEnv) save(values map[string]any) (any, error) {
|
||||
return e.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
|
||||
PluginID: search.PanelPluginID,
|
||||
Action: search.ActionSaveProvider,
|
||||
Payload: map[string]any{"values": values},
|
||||
})
|
||||
}
|
||||
|
||||
// mustSave 要求保存成功,并返回响应里的 `hotReload` 块(缺了就失败:它是本片新增的
|
||||
// 「这次保存有没有热重载」的可机读事实)。
|
||||
func (e *reloadTestEnv) mustSave(t *testing.T, values map[string]any) webSearchReloadOutcome {
|
||||
t.Helper()
|
||||
res, err := e.save(values)
|
||||
if err != nil {
|
||||
t.Fatalf("保存失败: %v", err)
|
||||
}
|
||||
m, ok := res.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("保存结果类型 = %T", res)
|
||||
}
|
||||
outcome, ok := m["hotReload"].(webSearchReloadOutcome)
|
||||
if !ok {
|
||||
t.Fatalf("响应里没有 hotReload(热重载没被接线): %#v", m)
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
// search 走**分派器**(模型真正调工具的那条路)打一次 web-search,返回线格式结果。
|
||||
func (e *reloadTestEnv) search(t *testing.T, query string) (map[string]any, error) {
|
||||
t.Helper()
|
||||
got, err := e.disp.Invoke(context.Background(), webSearchToolName, map[string]any{"query": query})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, ok := got.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("工具返回值类型 = %T", got)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// toolValue 取注册面里那一把 web-search 的**当前**值(不在注册面里就失败)。
|
||||
func (e *reloadTestEnv) toolValue(t *testing.T) any {
|
||||
t.Helper()
|
||||
cap, err := e.app.caps.Get(webSearchToolName)
|
||||
if err != nil {
|
||||
t.Fatalf("web-search 不在注册面里: %v(工具面 = %v)", err, toolNames(e.app))
|
||||
}
|
||||
return cap.Value
|
||||
}
|
||||
|
||||
// rawFile 读可变源的**原始字节**(「字节级没变」是回滚判据里最强的那一种)。
|
||||
func (e *reloadTestEnv) rawFile(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(e.path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", e.path, err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// loadFile 读盘上的可变源。
|
||||
func (e *reloadTestEnv) loadFile(t *testing.T) runtimeconfig.WebSearchFile {
|
||||
t.Helper()
|
||||
f, found, err := runtimeconfig.LoadWebSearch(e.path)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("LoadWebSearch(%s): found=%v err=%v", e.path, found, err)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// wsFakeUpstream 是一个假搜索上游:记下每一次命中,按预设状态码与响应体作答。
|
||||
type wsFakeUpstream struct {
|
||||
url string
|
||||
hits chan *http.Request
|
||||
}
|
||||
|
||||
func newWSFakeUpstream(t *testing.T, status int, body string) *wsFakeUpstream {
|
||||
t.Helper()
|
||||
hits := make(chan *http.Request, 8)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits <- r
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return &wsFakeUpstream{url: srv.URL, hits: hits}
|
||||
}
|
||||
|
||||
// newWSFakeSearxng 是一个「答一条结果」的 searxng 假上游(成功路径用)。
|
||||
func newWSFakeSearxng(t *testing.T, title string) *wsFakeUpstream {
|
||||
t.Helper()
|
||||
return newWSFakeUpstream(t, http.StatusOK,
|
||||
`{"results":[{"title":"`+title+`","url":"http://x.example/1","content":"`+title+`"}]}`)
|
||||
}
|
||||
|
||||
// drain 清掉已经排队的命中(用于「重载之后打的是不是新那家」的判定)。
|
||||
func (f *wsFakeUpstream) drain() {
|
||||
for {
|
||||
select {
|
||||
case <-f.hits:
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// noHit 断言此刻没有新的命中(重载后**不该**再打到旧那家)。
|
||||
func (f *wsFakeUpstream) noHit(t *testing.T) {
|
||||
t.Helper()
|
||||
select {
|
||||
case r := <-f.hits:
|
||||
t.Fatalf("旧上游又收到一个请求(%s)—— 重载没真的换掉工具", r.URL)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// wsLogField 从启动日志里取一个字段值(slog TextHandler 的 `key=value`)。
|
||||
func wsLogField(t *testing.T, out, key string) string {
|
||||
t.Helper()
|
||||
re := regexp.MustCompile(`(?:^|\s)` + regexp.QuoteMeta(key) + `=(\S+)`)
|
||||
m := re.FindStringSubmatch(out)
|
||||
if m == nil {
|
||||
t.Fatalf("启动日志里找不到 %s=…: %q", key, out)
|
||||
}
|
||||
return strings.Trim(m[1], `"`)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/mcp"
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/transcode"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
|
||||
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/flowgraph"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/task"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/automations"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/channelbridge"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||
)
|
||||
|
||||
// defaultOwnerPath 与 defaultPersonasDir 指向随仓库分发的磁盘上人格/主人定义,
|
||||
// 路径相对工作目录。
|
||||
const (
|
||||
defaultOwnerPath = "configs/users/owner.yaml"
|
||||
defaultPersonasDir = "configs/personas"
|
||||
defaultSQLitePath = "./data/cyrene.db"
|
||||
fallbackInstruction = "You are Cyrene, a warm and attentive companion."
|
||||
|
||||
// 上界与旋钮常量。编译期定值,运行时路径上不出现裸魔法数字(项目规范 §10)。
|
||||
defaultMaxRounds = 8
|
||||
defaultMaxParallel = 4
|
||||
defaultAgentID = "orchestrator"
|
||||
defaultToolTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// app 持有单二进制 Cyrene 组装的全部子系统:由 newApp 构建,关闭时由 close 拆除。
|
||||
type app struct {
|
||||
cfg *config.Config
|
||||
log *slog.Logger
|
||||
db *sql.DB
|
||||
dbPath string
|
||||
bus bus.Bus
|
||||
modelAdapter model.Adapter
|
||||
llm *model.RuntimeLLM
|
||||
personas *personareg.Registry
|
||||
caps *registry.Registry
|
||||
// transcoder 是本地转码引擎(design/25):进程内、同步、纯本地,供渠道与其它
|
||||
// 模块经依赖注入调用;同一个引擎也以 `transcode` 工具注册进 caps。装配只告警、
|
||||
// 绝不阻塞启动,见 wire_transcode.go。
|
||||
transcoder transcode.Transcoder
|
||||
dispatcher *tool.Dispatcher
|
||||
memStore *memory.Store
|
||||
retriever *memory.Retriever
|
||||
// embedder 与 memoryTidy 属于记忆整理(design/23):整理只在 embedder
|
||||
// 上算召回相似度,绝不接触会话/历史读数。
|
||||
embedder embedding.Embedder
|
||||
memoryTidy *memory.TidyService
|
||||
// memoryReview 是 D3「入库前审查」(design/23 §15.1 D3):新记忆落库后由
|
||||
// 独立的记忆管理会话判定 merge/modify/create/skip。它同样只依赖记忆存储
|
||||
// 与 embedder,拿不到任何会话/历史读数。未启用时为 nil(写入路径不变)。
|
||||
memoryReview *memory.PreInsertReviewer
|
||||
projStore *projectindex.Store
|
||||
loop *loop.Loop
|
||||
scheduler *orchestration.Scheduler
|
||||
tasks *task.Manager
|
||||
execProv exec.Provider
|
||||
iot *iot.Manager
|
||||
voice *voice.Chain
|
||||
mcpRegs []*mcp.Registry
|
||||
plugins *pluginmgr.Manager
|
||||
sessions *session.SessionManager
|
||||
automation *automations.Service
|
||||
backthink *backthink.Backthink
|
||||
vindex vector.Index
|
||||
owner *user.OwnerProfile
|
||||
history *history.Store
|
||||
gw *gateway.Server
|
||||
channelBridge *channelbridge.Bridge
|
||||
channels gateway.ChannelConfigStore
|
||||
// docGC 是文档回收的常驻任务(切片 S7):未被引用且超过保留期的文档连同
|
||||
// sidecar 与元数据行一起回收。GC 被配置关闭时为 nil(见 wire_document_gc.go)。
|
||||
docGC *documentGC
|
||||
// channelEvents 是渠道事件留存面(design/26 §4;切片 E3+E4):非消息事件逐条
|
||||
// 落行 / T1 计数都经它,桥接层由 wire_channels.go 注入。库未接时为 nil
|
||||
// (桥接层随即走内存计数降级,见 wire_channel_events.go)。
|
||||
channelEvents *events.SQLStore
|
||||
// channelEventKeeper 是事件留存的常驻维护任务(保留期清扫 + 计数落盘),
|
||||
// 由 main 的 startChannelEvents 拉起(见 wire_channel_events.go)。
|
||||
channelEventKeeper *channelEventKeeper
|
||||
// usage 是 LLM 用量的异步落库出口(切片 U1):注入模型栈后,每次成功模型
|
||||
// 调用都会把非敏感计数样本塞进它的有界队列。库未接时为 nil(= 只记日志)。
|
||||
usage *model.UsageWriter
|
||||
// usageGC 是用量样本的保留期清理常驻任务。`LLM_USAGE_RETENTION ≤ 0` 时为
|
||||
// nil(清理关闭,见 wire_usage.go)。
|
||||
usageGC *usageRetentionGC
|
||||
// capacitySink 把 capacity_awaken 状态迁移广播给 Web UI(design/14 §5)。
|
||||
// 由 *gateway.Server 满足(BroadcastCapacityAwaken);以接口形式持有,
|
||||
// 组合根才能用假出口做单元测试。
|
||||
capacitySink capacityEmitter
|
||||
// emotion 把每一轮落定的对话施加到所绑人格的情绪机与好感度上(design/01 §2),
|
||||
// 并把变化交给 moodSink。没有注册人格时为 nil,此时不追踪、不持久化任何心情。
|
||||
emotion *emotion.Observer
|
||||
// moodSink 把心情变化广播给 Web UI(design/07 §3)。由 *gateway.Server 满足
|
||||
// (BroadcastMood);以接口形式持有,组合根才能用假出口做单元测试。
|
||||
moodSink moodEmitter
|
||||
// traceWriter 把完成的 trace span 扇出到 JSONL stderr 记录器,网关出现后也送往
|
||||
// 网关的可观测性缓冲(挂在 GET /api/v1/admin/observability/trace 后面,
|
||||
// design/19 §2)。持有它是为了让 wireFrontend 能往已在运行的 tracer 上注册
|
||||
// 网关出口。
|
||||
traceWriter *traceFanout
|
||||
// flow 是 loop 的实时链路图发布器(design/19 §1)。持有它是因为网关还要一个
|
||||
// **只读**出口:`GET /api/v1/admin/flow/graph` 回「最近一轮」的快照,补上
|
||||
// 「轮次结束后才打开的页面」看不到图的那个洞(契约 v0.71)。组合根把**同一个**
|
||||
// 发布器同时交给 loop/orchestration(写入面)与网关(查询面),因此查询到的
|
||||
// 就是实时发布的那张图,不会出现两份状态。
|
||||
flow *loop.FlowPublisher
|
||||
// flowStore 是链路图的**本地落盘**(design/19 §4 P3;切片 F2;迁移 0022):发布器
|
||||
// 每轮收尾经它落一条,网关按 turnId / turns 读回。库未接时为 nil(发布器随即走
|
||||
// 纯内存路径,见 wire_flow_graphs.go)。它与 flow 是两件事:flow 是实时内存图,
|
||||
// flowStore 是收尾存档。
|
||||
flowStore *flowgraph.SQLStore
|
||||
// flowGraphKeeper 是链路图留存的常驻维护任务(保留期清扫),由 main 的
|
||||
// startFlowGraphs 拉起(见 wire_flow_graphs.go)。
|
||||
flowGraphKeeper *flowGraphKeeper
|
||||
}
|
||||
|
||||
// newApp 依据 cfg 与 log 组装完整的 Cyrene 运行时:打开数据库、迁移表结构,接线
|
||||
// 人格注册表、模型适配器、工具分发器、记忆栈、agent 循环、编排、执行、IoT、
|
||||
// 渠道会话/适配器与网关,最后返回就绪的 app。
|
||||
//
|
||||
// 致命的接线失败(数据库打开/迁移、必需的认证、网关)以错误返回;可选子系统则
|
||||
// 优雅降级,使得未配置模型密钥或外部渠道时二进制仍能启动。
|
||||
func newApp(ctx context.Context, cfg *config.Config, log *slog.Logger) (*app, error) {
|
||||
dataDir := resolveDataDir(cfg)
|
||||
databasePath := resolveSQLitePath(cfg, dataDir)
|
||||
dbCfg := cfg.DB
|
||||
dbCfg.SQLitePath = databasePath
|
||||
|
||||
sqlDB, err := db.Open(ctx, databasePath, log)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
if err := db.MigrateUp(ctx, sqlDB, log); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, fmt.Errorf("migrate database: %w", err)
|
||||
}
|
||||
|
||||
eventBus := bus.New()
|
||||
a := &app{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
db: sqlDB,
|
||||
dbPath: databasePath,
|
||||
bus: eventBus,
|
||||
caps: registry.New(),
|
||||
}
|
||||
|
||||
// 后续任一接线步骤失败时,释放已经打开的资源,但不打整套服务关闭日志
|
||||
// (半途启动不等同于一次干净退出)。
|
||||
completed := false
|
||||
defer func() {
|
||||
if completed {
|
||||
return
|
||||
}
|
||||
if a.vindex != nil {
|
||||
_ = a.vindex.Close()
|
||||
}
|
||||
if a.tasks != nil {
|
||||
_ = a.tasks.Close()
|
||||
}
|
||||
if a.bus != nil {
|
||||
_ = a.bus.Close()
|
||||
}
|
||||
if a.db != nil {
|
||||
_ = a.db.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
if err := a.buildPersonas(); err != nil {
|
||||
return a, err
|
||||
}
|
||||
if err := a.wireMemory(ctx); err != nil {
|
||||
return a, err
|
||||
}
|
||||
if err := a.wireRuntime(); err != nil {
|
||||
return a, err
|
||||
}
|
||||
if err := a.wireAuxiliary(); err != nil {
|
||||
return a, err
|
||||
}
|
||||
a.wirePlugins(ctx)
|
||||
// 渠道事件留存面(design/26 §4;切片 E3+E4):必须在 wireChannels 之前构造 ——
|
||||
// 桥接层在构造时就把它注入(Options.EventStore),运行时不再重取。
|
||||
a.wireChannelEvents()
|
||||
if err := a.wireChannels(); err != nil {
|
||||
return a, err
|
||||
}
|
||||
if err := a.wireFrontend(); err != nil {
|
||||
return a, err
|
||||
}
|
||||
completed = true
|
||||
return a, nil
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/audit"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/approvalreview"
|
||||
)
|
||||
|
||||
// 本文件是 design/11 §4「敏感操作代审」在组合根上的全部接线(裁决 13.4.3 +
|
||||
// 主人 2026-10-05 裁决的口径 A):
|
||||
// - 装配**实质判定**(是否子代理发起、判「有没有越界」、fail-closed);
|
||||
// - 构建代审门与其归属解析;
|
||||
// - 构建 loop 的准入钩子,把代审记录交网关落 audit_logs 并广播前端小字,
|
||||
// 并让判定理由出现在那行小字里。
|
||||
//
|
||||
// 判定本身与拒绝语义在 audit.Approver(边界判定、fail-closed)与
|
||||
// services/approvalreview(轻量审查模型调用)。
|
||||
|
||||
// approvalJudgeFor 构建敏感操作代审的实质判定器:复用既有审查模型范式
|
||||
// (design/12 §6 策略四的轻量审查模型,见 services/approvalreview 与
|
||||
// wire_channels.go 的 reviewFor),注入**同一个** model.Adapter,不另造 LLM 包装。
|
||||
//
|
||||
// 无模型适配器时返回 nil:门对**子代理**的敏感调用随即 fail-closed(判不出来不放行),
|
||||
// 而主会话自身的调用不受影响(口径 A 只管子代理)。
|
||||
func approvalJudgeFor(llm model.Adapter, log *slog.Logger) audit.CallJudge {
|
||||
if llm == nil {
|
||||
return nil
|
||||
}
|
||||
return approvalreview.NewLLMJudge(llm, logger.WithSource(log, "services/approvalreview"))
|
||||
}
|
||||
|
||||
// approvalExplainWithReason 把小字与判定理由拼在一起(design/11 §4 的固定句式在前,
|
||||
// 理由作括号后缀在后),使前端那行小字能说清「为什么被拒」。reason 为空时原样返回,
|
||||
// 因此放行与未判定的路径与加入判定之前逐字一致。
|
||||
func approvalExplainWithReason(explain, reason string) string {
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
return explain
|
||||
}
|
||||
return explain + "(" + reason + ")"
|
||||
}
|
||||
|
||||
// buildApprovalGate 构建敏感操作代审门(design/11 §4;裁决 13.4.3)。
|
||||
//
|
||||
// 归属解析取 Run 上下文里的会话 id 与该 turn 的角色名,使前端小字既投到
|
||||
// 正确的会话、也写明是谁想动手(design/11 §4 的「xxx(角色名)」)。会话 id 用
|
||||
// loop.FlowOwnerFromContext:子代理的敏感调用归属到**派生它的那个会话**
|
||||
// (与 design/19 §2 的 flow 归属同一口径),所以小字出现在主人正在看的对话里,
|
||||
// 而不是一个没人查看的子代理 id 下。
|
||||
func (a *app) buildApprovalGate(approver *audit.Approver) *restrict.Gate {
|
||||
return restrict.NewGate(
|
||||
auditReviewer{approver: approver},
|
||||
func(ctx context.Context) (string, string) {
|
||||
return loop.FlowOwnerFromContext(ctx), a.approvalActor(ctx)
|
||||
},
|
||||
restrict.WithGateLogger(a.log),
|
||||
)
|
||||
}
|
||||
|
||||
// toolAdmissionHooks 构建 loop 的工具准入钩子(design/11 §4;裁决 13.4.3):
|
||||
// 敏感调用先经主代理代审,代审记录交给网关落 audit_logs 并广播前端小字;
|
||||
// 未获代审的调用以软结果回灌给模型(绝不执行,绝不静默)。非敏感调用直接放行。
|
||||
//
|
||||
// 只装 CallAdmit:敏感与否取决于**该次调用的参数**(file 的 read 自主、
|
||||
// write 代审),批量钩子(BatchAdmit)看不到参数,做不出这个区分。
|
||||
// 组合根按 loop 的既有契约装配,因此这条链也覆盖子代理的敏感调用
|
||||
// (编排层派生的 loop 共享同一组钩子)——而那正是口径 A 的判定对象。
|
||||
func (a *app) toolAdmissionHooks(gate *restrict.Gate) loop.ToolLoopHooks {
|
||||
return loop.ToolLoopHooks{
|
||||
CallAdmit: func(ctx context.Context, call agent.ToolCall) *loop.ToolCallVeto {
|
||||
// 判定理由经按调用的理由盒带出来:代审门的 Reviewer 契约只回
|
||||
// (allowed, error),而小字要说清「为什么被拒」(口径 A)。
|
||||
ctx, note := audit.WithDecisionNote(ctx)
|
||||
decision := gate.Admit(ctx, call.Name, call.Args)
|
||||
if decision.Approval != nil {
|
||||
decision.Approval.Explain = approvalExplainWithReason(decision.Approval.Explain, note.Reason())
|
||||
// a.gw 在 wireFrontend 才建好;RecordApproval 对 nil 接收者安全。
|
||||
a.gw.RecordApproval(ctx, *decision.Approval)
|
||||
}
|
||||
if !decision.Allow {
|
||||
return &loop.ToolCallVeto{Block: true, Reason: decision.Reason}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// auditReviewer 把 orchestration/audit 的 Approver 适配成代审门的 Reviewer
|
||||
// (design/11 §4;裁决 13.4.3 要求复用该 Approver,而不是另起一套代审)。
|
||||
// 它把 Approval 收敛成 (allowed, error),并把被拒时的判定理由写进 ctx 上的
|
||||
// 理由盒(audit.DecisionNote),使冻结的 Reviewer 契约之外仍有一条结构化通道
|
||||
// 把「为什么被拒」送到前端小字。前端小字文案本身仍由 restrict 复用
|
||||
// audit.Approval.Explain 的固定句式产出。
|
||||
type auditReviewer struct{ approver *audit.Approver }
|
||||
|
||||
// ReviewAndApprove 执行一次代审;approver 缺席时复用编排层的 ErrNoApprover
|
||||
// (门据此 fail-closed 拦截敏感操作,绝不静默放行)。
|
||||
func (r auditReviewer) ReviewAndApprove(ctx context.Context, actor, action, describe string) (bool, error) {
|
||||
if r.approver == nil {
|
||||
return false, fmt.Errorf("review-and-approve: %w", orchestration.ErrNoApprover)
|
||||
}
|
||||
ap, err := r.approver.ReviewAndApprove(ctx, actor, action, describe)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("review-and-approve: %w", err)
|
||||
}
|
||||
if !ap.Allowed {
|
||||
audit.DecisionNoteFrom(ctx).SetReason(ap.Reason)
|
||||
}
|
||||
return ap.Allowed, nil
|
||||
}
|
||||
|
||||
// approvalActor 解析代审小字里的「谁想动手」:该 turn 绑定的角色名,
|
||||
// 未绑定时回落到默认角色;角色名解析不出来就用角色 id(小字宁可用 id
|
||||
// 也要说清是谁,但绝不编造一个名字)。
|
||||
func (a *app) approvalActor(ctx context.Context) string {
|
||||
pid := loop.SessionEnvFrom(ctx).PersonaID
|
||||
if pid == "" {
|
||||
pid = a.defaultPersonaID()
|
||||
}
|
||||
if name := a.personaName(pid); name != "" {
|
||||
return name
|
||||
}
|
||||
return pid
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/audit"
|
||||
)
|
||||
|
||||
// 本文件锁定组合根上口径 A(主人 2026-10-05 裁决)的接线:
|
||||
// - 子代理发起的敏感调用 ⇒ 进判定,判越界即拦下(绝不放行、绝不静默);
|
||||
// - 主会话自身的敏感调用 ⇒ 不进判定(零模型调用)、照旧放行;
|
||||
// - 判定理由经按调用的理由盒带出代审门,最终出现在前端小字里。
|
||||
|
||||
// stubJudge 是代审判定器桩:记录调用次数并给出既定结论。
|
||||
type stubJudge struct {
|
||||
verdict audit.JudgeVerdict
|
||||
err error
|
||||
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
}
|
||||
|
||||
func (j *stubJudge) JudgeSubagentCall(context.Context, audit.JudgeRequest) (audit.JudgeVerdict, error) {
|
||||
j.mu.Lock()
|
||||
j.calls++
|
||||
j.mu.Unlock()
|
||||
return j.verdict, j.err
|
||||
}
|
||||
|
||||
func (j *stubJudge) callCount() int {
|
||||
j.mu.Lock()
|
||||
defer j.mu.Unlock()
|
||||
return j.calls
|
||||
}
|
||||
|
||||
// noteSpy 包住真实的代审者,读出代审链写进 ctx 理由盒的内容——它就是
|
||||
// 「为什么被拒」带出冻结契约的那条通道的证据。
|
||||
type noteSpy struct {
|
||||
inner restrict.Reviewer
|
||||
mu sync.Mutex
|
||||
seen string
|
||||
}
|
||||
|
||||
func (s *noteSpy) ReviewAndApprove(ctx context.Context, actor, action, describe string) (bool, error) {
|
||||
allowed, err := s.inner.ReviewAndApprove(ctx, actor, action, describe)
|
||||
s.mu.Lock()
|
||||
s.seen = audit.DecisionNoteFrom(ctx).Reason()
|
||||
s.mu.Unlock()
|
||||
return allowed, err
|
||||
}
|
||||
|
||||
func (s *noteSpy) reason() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.seen
|
||||
}
|
||||
|
||||
// terminalCall 是一次真实的敏感调用(exec command 类,见 restrict.SensitiveCall)。
|
||||
func terminalCall() agent.ToolCall {
|
||||
return agent.ToolCall{Name: "terminal", Args: map[string]any{"command": "rm -rf /tmp/x"}}
|
||||
}
|
||||
|
||||
// admit 用一条生产同形的准入链跑一次调用。
|
||||
func admit(t *testing.T, ctx context.Context, judge audit.CallJudge, call agent.ToolCall) (*loop.ToolCallVeto, string) {
|
||||
t.Helper()
|
||||
if _, _, sensitive := restrict.SensitiveCall(call.Name, call.Args); !sensitive {
|
||||
t.Fatalf("%s 不是敏感调用,用例前提不成立", call.Name)
|
||||
}
|
||||
approver := audit.NewApprover(testLog(),
|
||||
audit.WithSubagentOrigin(loop.SubagentTaskFrom),
|
||||
audit.WithCallJudge(judge),
|
||||
)
|
||||
spy := ¬eSpy{inner: auditReviewer{approver: approver}}
|
||||
gate := restrict.NewGate(spy, func(context.Context) (string, string) { return "web:main", "昔涟" })
|
||||
a := &app{log: testLog()}
|
||||
return a.toolAdmissionHooks(gate).CallAdmit(ctx, call), spy.reason()
|
||||
}
|
||||
|
||||
// TestToolAdmission_SubagentOutOfScopeIsBlockedWithReason:子代理发起的敏感调用被
|
||||
// 判定为越界 ⇒ 拦下(软结果回灌,绝不执行),且理由经理由盒带出代审门(会进小字)。
|
||||
func TestToolAdmission_SubagentOutOfScopeIsBlockedWithReason(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
judge := &stubJudge{verdict: audit.JudgeVerdict{Allowed: false, Reason: "任务只要求整理导出文件,不需要执行 shell 命令"}}
|
||||
ctx := loop.WithSubagentTask(context.Background(), "整理 data/ 下的导出文件")
|
||||
|
||||
veto, reason := admit(t, ctx, judge, terminalCall())
|
||||
if veto == nil || !veto.Block {
|
||||
t.Fatalf("veto = %+v, want 拦下(越界的敏感调用绝不执行)", veto)
|
||||
}
|
||||
if !strings.Contains(veto.Reason, "not_approved") {
|
||||
t.Fatalf("回灌给模型的原因 = %q, want 既有 not_approved 体例", veto.Reason)
|
||||
}
|
||||
if judge.callCount() != 1 {
|
||||
t.Fatalf("判定调用次数 = %d, want 1", judge.callCount())
|
||||
}
|
||||
if !strings.Contains(reason, "不需要执行 shell 命令") {
|
||||
t.Fatalf("理由盒 = %q, want 判定给出的理由(小字要说清为什么被拒)", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolAdmission_SubagentInScopePasses:判定放行的子代理敏感调用照常执行。
|
||||
func TestToolAdmission_SubagentInScopePasses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
judge := &stubJudge{verdict: audit.JudgeVerdict{Allowed: true}}
|
||||
ctx := loop.WithSubagentTask(context.Background(), "清理临时目录 /tmp/x")
|
||||
|
||||
veto, reason := admit(t, ctx, judge, terminalCall())
|
||||
if veto != nil {
|
||||
t.Fatalf("veto = %+v, want 放行", veto)
|
||||
}
|
||||
if judge.callCount() != 1 {
|
||||
t.Fatalf("判定调用次数 = %d, want 1", judge.callCount())
|
||||
}
|
||||
if reason != "" {
|
||||
t.Fatalf("放行时理由盒 = %q, want 空(小字不带理由)", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolAdmission_MainSessionIsNotJudged:主会话自身的敏感调用**不进判定**
|
||||
// (零模型调用)且放行——主人自己显式要求的操作(例如读 models.json / cyrene.db)
|
||||
// 绝不能被这套判定拦死(口径 A 的另一半)。
|
||||
func TestToolAdmission_MainSessionIsNotJudged(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
judge := &stubJudge{verdict: audit.JudgeVerdict{Allowed: false, Reason: "无论如何都拒绝"}}
|
||||
veto, reason := admit(t, context.Background(), judge, terminalCall())
|
||||
if veto != nil {
|
||||
t.Fatalf("veto = %+v, want 主会话调用放行", veto)
|
||||
}
|
||||
if judge.callCount() != 0 {
|
||||
t.Fatalf("判定调用次数 = %d, want 0(主会话调用零模型调用)", judge.callCount())
|
||||
}
|
||||
if reason != "" {
|
||||
t.Fatalf("理由盒 = %q, want 空", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolAdmission_FailClosedWhenJudgeUnavailable:未装配判定器(或无模型适配器)
|
||||
// 时,子代理的敏感调用一律拦下——绝不因为「没得判」就放行。
|
||||
func TestToolAdmission_FailClosedWhenJudgeUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := loop.WithSubagentTask(context.Background(), "整理导出文件")
|
||||
veto, reason := admit(t, ctx, nil, terminalCall())
|
||||
if veto == nil || !veto.Block {
|
||||
t.Fatalf("veto = %+v, want 拦下(fail-closed)", veto)
|
||||
}
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
t.Fatal("拒绝没有理由(静默拒绝)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalExplainWithReason:判定理由拼进小字固定句式之后;没有理由时逐字不变
|
||||
// (放行与主会话路径的小字与加入判定之前完全一致)。
|
||||
func TestApprovalExplainWithReason(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fixed := "「昔涟 想要干什么,我审查后拒绝了」"
|
||||
if got := approvalExplainWithReason(fixed, ""); got != fixed {
|
||||
t.Fatalf("无理由时 = %q, want 原样 %q", got, fixed)
|
||||
}
|
||||
if got := approvalExplainWithReason(fixed, " "); got != fixed {
|
||||
t.Fatalf("空白理由时 = %q, want 原样 %q", got, fixed)
|
||||
}
|
||||
got := approvalExplainWithReason(fixed, "这次调用与派给它的任务不一致")
|
||||
want := fixed + "(这次调用与派给它的任务不一致)"
|
||||
if got != want {
|
||||
t.Fatalf("小字 = %q, want %q", got, want)
|
||||
}
|
||||
if !strings.HasPrefix(got, fixed) {
|
||||
t.Fatalf("小字 = %q, want 以 design/11 §4 的固定句式开头", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package main
|
||||
|
||||
// 本文件是 design/28 §1 M5 / §8 B5「浏览器渲染取正文」降级档的**组合根挂载点** ——
|
||||
// 切片 Z5 把降级链做好了、但生产接不通(能力面没有取正文入口 ⇒ 没东西可注入 ⇒
|
||||
// 这一档实际关着),本文件就是那次收口留下的缺口的解封。
|
||||
//
|
||||
// 六条口径:
|
||||
//
|
||||
// 1. **挂载条件 = 能力面里确实有可用浏览器**(注册面里那把 browser 工具的驱动
|
||||
// `Available() == true`)。这条判据来自 design/28 §7 判据 1 的诚实口径
|
||||
// 「都没有 ⇒ 工具不注册(不假装可用)」:拿不到来源就不该把一条注定走不通的档挂上。
|
||||
// 2. **不新增开关**。设计**没有**为这条降级档定义配置项(§8 的配置面清单是「端点、
|
||||
// 有头/无头、视口、超时、并发、截图上限」,§8 末的配置面约束又写死「`.env` 已冻结、
|
||||
// 可调项只落配置文件 + 面板」)⇒ 自造一个 `bodyFallback: bool` 字段属于 AGENTS §1 的
|
||||
// 「自行发挥」。挂载条件因此由能力面自身给出,而不是第二处开关。
|
||||
// 3. **io 包的「默认关」口径没有被改写**:`iotools.WithBrowserFallback` 不传 / 传 nil
|
||||
// 仍然是关(`plugins/tools/io` 的库默认)。本文件只把「确实有来源」的那一种情形
|
||||
// 打开 —— 从「永远关」变成「没来源就关」,这正是 design/28 §2 把 M5 列进推荐组合、
|
||||
// §8 把 B5 列为实施项的意思。
|
||||
// 4. **每次调用现取管理器**:理由见 browserBodyRenderer 的文档(面板保存会换掉那一把
|
||||
// 工具与它背后的管理器)。
|
||||
// 5. **只读装配**:本文件不发请求、不起进程、不注册工具 —— `ioToolOptions()` 是只读
|
||||
// 装配,探测的副作用归 `wireBrowser()`(`wire_browser_test.go` 钉住不许在这里探测)。
|
||||
// 6. **失败如实**:挂不上只记一条**带来源**的 WARN(原因码 + 这一档对模型面的实际含义),
|
||||
// 不阻断启动 —— 浏览器是可选增强(design/28 §8 逐片落地状态)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
|
||||
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||||
)
|
||||
|
||||
// 挂载点的文案常量(进日志与结果,不美化)。
|
||||
const (
|
||||
// browserFallbackReasonNoSource 是「这一档没挂上」的原因码:能力面里没有可用浏览器。
|
||||
// 分类原因码而不是「失败」两个字,照 web-search「拿不到后端就不注册」的口径。
|
||||
browserFallbackReasonNoSource = "no-usable-browser"
|
||||
|
||||
// browserFallbackTrigger 写清这一档在什么条件下被走(进启动日志,免得「它到底管什么」
|
||||
// 只能靠读代码)。
|
||||
browserFallbackTrigger = "direct fetch failed or the page was judged dynamic"
|
||||
|
||||
// browserFallbackOffEffect 写清「没挂」对模型面的**实际**含义:不是说结果会假,
|
||||
// 而是说这一档没有第二次机会(直连的真实遭遇仍然照实报出来)。
|
||||
browserFallbackOffEffect = "web-fetch keeps the direct path only; the real outcome is still reported (dynamicSuspect / directError), there is just no browser second look"
|
||||
)
|
||||
|
||||
// bodyFallbackOption 是 design/28 §8 B5 的**挂载点**,落地本文件顶部的六条口径。
|
||||
//
|
||||
// 两种去处都只有一个出口,口径不会在分支里漂移:
|
||||
// - 没有可用浏览器 ⇒ 不挂(`WithBrowserFallback(nil)`)+ 一条带来源的 WARN,说明原因;
|
||||
// - 有可用浏览器 ⇒ 挂上适配器,并记一条 INFO 说明这一档什么时候会被走。
|
||||
func (a *app) bodyFallbackOption() iotools.Option {
|
||||
log := logger.WithSource(a.log, ioLogSource)
|
||||
mgr := browserManagerOf(a.caps)
|
||||
if mgr == nil || !mgr.Available() {
|
||||
log.Warn("web-fetch browser fallback not wired",
|
||||
"reason", browserFallbackReasonNoSource,
|
||||
"effect", browserFallbackOffEffect)
|
||||
return iotools.WithBrowserFallback(nil)
|
||||
}
|
||||
log.Info("web-fetch browser fallback wired", "trigger", browserFallbackTrigger)
|
||||
return iotools.WithBrowserFallback(&browserBodyRenderer{caps: a.caps, log: log})
|
||||
}
|
||||
|
||||
// browserFallbackReasonBudget 是「这一档**没有发起**」的原因码:调用方这一轮的预算已经
|
||||
// 耗尽。它与 `no-usable-browser` 是两件事(一个是「没时间了」,一个是「没有浏览器」),
|
||||
// 混成一句会把人带偏(真机就出现过:窗口上是浏览器的抱怨,日志里像浏览器坏了)。
|
||||
const browserFallbackReasonBudget = "budget-exhausted"
|
||||
|
||||
// browserFallbackBudgetNote 写清这一刻的**实际**含义:浏览器没坏、这一档没被尝试。
|
||||
// 它进错误文本(顺 web-fetch 的 browserError 面出来),因此必须一眼可读、不含猜测。
|
||||
const browserFallbackBudgetNote = "本轮工具预算已耗尽(调用方上下文已结束)⇒ 未尝试浏览器渲染,这不是浏览器故障"
|
||||
|
||||
// browserBodyRenderer 把浏览器能力面(`*browsercap.Manager.RenderText`)适配成 io 包的
|
||||
// `iotools.BodyRenderer` —— 这是「进程内接口 + 依赖注入」的适配器,不引入第二个浏览器栈、
|
||||
// 不新开任何监听端口(AGENTS §9)。
|
||||
//
|
||||
// **为什么每次调用现取管理器**:面板保存 browser 配置会重建那把工具并换掉它背后的管理器
|
||||
// (`wire_browser_panel.go` 的热重载 + `ReplaceTool`),装配时抓住的旧指针会在一次保存之后
|
||||
// 变成一具尸体 —— 那条降级档会静默退化成「每次都如实失败」,而这是最难查的一类失效。
|
||||
// 从**注册面**取当前那一把,拿到的就是正在跑的那一个。
|
||||
//
|
||||
// 红线(与 `iotools.BodyRenderer` 的注释同源):只做对外连接;SSRF 判定由浏览器侧用
|
||||
// **同一份** `io.ValidateRequestURL` 自己再过一遍(本适配器不替它免检,原始 URL 原样交过去);
|
||||
// 正文在渲染完成后才取(由 `Manager.RenderText` 保证)。
|
||||
//
|
||||
// 空正文**不在这里**判:`RenderText` 的「渲染成功但正文为空」是读数不是错误,而 io 的
|
||||
// 降级档有自己的口径(`ErrEmptyRenderedBody`)—— 本适配器原样交出去,不替上层下结论。
|
||||
type browserBodyRenderer struct {
|
||||
caps *registry.Registry
|
||||
// log 是挂载点的日志(已带来源)。可为 nil(单测直接构造):那就不记,不做替代语义。
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// RenderBody 实现 iotools.BodyRenderer。
|
||||
//
|
||||
// **预算口径**(design/28 §1 M5「降级档」+ §7 判据 4「如实标注」+ §6 坑 5「失败要如实报」,
|
||||
// 设计没有为这一档规定独立预算 ⇒ 取最小的诚实口径):ctx 已经结束(这一轮的工具预算耗尽 /
|
||||
// 会话被取消)时**不发起**,如实报「预算耗尽 ⇒ 未尝试」,而**不是**报一个看起来像
|
||||
// 「浏览器坏了」的错误。三条理由:
|
||||
//
|
||||
// - 这一档要冷启动真浏览器(秒级),启动所需的预算此刻已经不存在;
|
||||
// - 工具派发层在 ctx 结束时就要把结果丢掉(`internal/runtime/tool` 的 callTool 在
|
||||
// ctx.Done 与结果之间二选一,且 ctx 结束时返回 ctx.Err())⇒ 硬起一个浏览器只会留下
|
||||
// 一个被立刻杀掉的窗口,**结果没人读**(真机踩过:窗口上跳出浏览器的抱怨);
|
||||
// - 没试过就不能报成「试了失败」:`fetchedBy` / `browserError` 的语义(见
|
||||
// `plugins/tools/io/bodyfetch.go`)一字不动,本适配器只交「没发起」这个事实。
|
||||
//
|
||||
// ctx 还活着时**不动**它的预算(不另造一个第二预算):浏览器会话可能是热的(同一次
|
||||
// 会话里刚渲染过),渲染只要一两秒;真有取消,浏览器侧会把它如实分类成「预算耗尽」
|
||||
// (见 `internal/capability/browser` 的分类标记),不会再冒充「浏览器坏了」。
|
||||
func (r *browserBodyRenderer) RenderBody(ctx context.Context, rawURL string) (iotools.RenderedBody, error) {
|
||||
mgr := browserManagerOf(r.caps)
|
||||
if mgr == nil || !mgr.Available() {
|
||||
// 诚实降级:此时是「这一档没有来源」,**不是**「取到了空正文」。
|
||||
return iotools.RenderedBody{}, errors.New("web-fetch browser fallback: no usable browser available")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
if r.log != nil {
|
||||
r.log.Info("web-fetch browser fallback not attempted",
|
||||
"reason", browserFallbackReasonBudget, "error", err)
|
||||
}
|
||||
return iotools.RenderedBody{}, fmt.Errorf("web-fetch browser fallback: %s: %s: %w",
|
||||
browserFallbackReasonBudget, browserFallbackBudgetNote, err)
|
||||
}
|
||||
rendered, err := mgr.RenderText(ctx, rawURL)
|
||||
if err != nil {
|
||||
return iotools.RenderedBody{}, fmt.Errorf("browser render: %w", err)
|
||||
}
|
||||
return iotools.RenderedBody{Text: rendered.Text, Source: rendered.Source}, nil
|
||||
}
|
||||
|
||||
// browserManagerOf 从能力面取回**当前**的浏览器管理器;没有可用来源(没注册 / 注册的
|
||||
// 不是这把工具 / 驱动不是本机的管理器,例如单测夹具)时返回 nil。
|
||||
//
|
||||
// 它**只读**注册面:不发请求、不起进程、不注册工具 —— 本文件被 `ioToolOptions()` 调用,
|
||||
// 那是一条只读装配路径(口径 5)。
|
||||
func browserManagerOf(caps *registry.Registry) *browser.Manager {
|
||||
if caps == nil {
|
||||
return nil
|
||||
}
|
||||
entry, err := caps.Get(browsertools.ToolName)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
tool, ok := entry.Value.(*browsertools.Tool)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return tool.Manager()
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
package main
|
||||
|
||||
// 本文件钉住切片 Z5 解封的那条接线(design/28 §1 M5 / §8 B5):把浏览器能力面的
|
||||
// 「渲染取正文」适配成 web-fetch 的降级档,并在组合根决定**挂不挂**。
|
||||
//
|
||||
// 三件事必须有测试拦(它们删掉之后运行期都没有症状):
|
||||
//
|
||||
// 1. 能力面里**没有**可用浏览器 ⇒ 这一档**不挂**,且如实留一条带来源的原因;
|
||||
// 2. 能力面里**有**可用浏览器 ⇒ 这一档真的被挂上、被调用(判据是浏览器那一档的
|
||||
// 真实遭遇出现在返回体里,而不是「某个函数被调过」);
|
||||
// 3. 适配器在「取正文时已经没有可用浏览器」时如实报错,**不返回空串冒充成功**。
|
||||
//
|
||||
// 另外两条是接线形状的钉子(照 `TestWireRuntime_CallsWireBrowser` 的先例):挂载点不能
|
||||
// 从生产组装里消失,装配顺序不能把浏览器排在 io 工具集之后。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
|
||||
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||||
)
|
||||
|
||||
// webFetchToolName 是 web-fetch 在注册面里的名字(io 包里的字面量,这里按同一份契约引用)。
|
||||
const webFetchToolName = "web-fetch"
|
||||
|
||||
// serveSkeletonPage 起一个「直连能成功、但只有前端框架挂载点」的页面:它是这条降级档的
|
||||
// 入口条件之一(直连成功却 `dynamicSuspect`)。
|
||||
func serveSkeletonPage(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`<html><body><div id="app"></div><script src="/x.js"></script></body></html>`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// endpointHost 是白名单条目的写法:host:port(判定源会剥端口比对)。
|
||||
func endpointHost(srv *httptest.Server) string { return strings.TrimPrefix(srv.URL, "http://") }
|
||||
|
||||
// fallbackFetchTool 造一把**走挂载点**的 web-fetch:选项里带 `bodyFallbackOption()`,
|
||||
// 测试服务放进 SSRF 白名单(否则直连那一档先被守卫拒掉,降级档根本不会被触发)。
|
||||
//
|
||||
// 选项走**生产路径**:`a.ioToolOptions()`(挂载点就在里面),而不是只把 `bodyFallbackOption()`
|
||||
// 单独拿出来 —— 这样两个用例证明的就是「启动期真正交出去的那份选项」。
|
||||
func fallbackFetchTool(t *testing.T, a *app, srv *httptest.Server) sdk.Tool {
|
||||
t.Helper()
|
||||
opts := append(a.ioToolOptions(),
|
||||
iotools.WithAllowedHosts(endpointHost(srv)), iotools.WithHTTPClient(srv.Client()))
|
||||
set, err := iotools.New(a.cfg, opts...)
|
||||
if err != nil {
|
||||
t.Fatalf("iotools.New: %v", err)
|
||||
}
|
||||
for _, tool := range set {
|
||||
if tool.Name() == webFetchToolName {
|
||||
return tool
|
||||
}
|
||||
}
|
||||
t.Fatalf("工具集里没有 %s", webFetchToolName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// execFetch 跑一次 web-fetch 并把它返回的 map 取出来。
|
||||
func execFetch(t *testing.T, tool sdk.Tool, rawURL string) map[string]any {
|
||||
t.Helper()
|
||||
got, err := tool.Execute(context.Background(), map[string]any{"url": rawURL})
|
||||
if err != nil {
|
||||
t.Fatalf("web-fetch Execute: %v", err)
|
||||
}
|
||||
m, ok := got.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("web-fetch 返回的不是对象: %#v", got)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// newWiredBrowserManager 造一个**报告可用**的浏览器管理器:探测结论说「本机有 Chrome」,
|
||||
// 但可执行文件故意不存在 —— `Available()` 为 true(于是降级档会被挂上),而任何一次真实
|
||||
// 会话都会以 ErrLaunch 如实失败。
|
||||
func newWiredBrowserManager(t *testing.T, cfg *config.Config, allowedHost string) *browsercap.Manager {
|
||||
t.Helper()
|
||||
mgr := browsercap.NewManager(browsercap.Options{
|
||||
Availability: browsercfg.Availability{
|
||||
Usable: true, Source: browsercfg.SourceLocalChrome,
|
||||
Executable: "definitely-not-a-browser.exe", Detail: "假来源(本用例不真起浏览器)",
|
||||
},
|
||||
Config: browsercfg.Defaults(cfg.DataDir),
|
||||
DataDir: cfg.DataDir,
|
||||
AllowedHosts: []string{allowedHost},
|
||||
})
|
||||
t.Cleanup(func() { _ = mgr.Close() })
|
||||
return mgr
|
||||
}
|
||||
|
||||
// ① 没有可用浏览器 ⇒ 这一档不挂:直连的结论照实交出来,并如实说明「没有浏览器第二眼」;
|
||||
// 日志里留下分类原因码与来源标签。
|
||||
func TestBodyFallback_NoBrowserMeansTierNotWired(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t)
|
||||
var out bytes.Buffer
|
||||
a := browserTestApp(t, cfg, &out) // 注册面里没有 browser 工具 = 这台机器没有可用来源
|
||||
srv := serveSkeletonPage(t)
|
||||
|
||||
m := execFetch(t, fallbackFetchTool(t, a, srv), srv.URL)
|
||||
|
||||
if m["fetchedBy"] != iotools.FetchedByDirect {
|
||||
t.Errorf("fetchedBy = %v, want %q(浏览器没取到,来源仍是直连)", m["fetchedBy"], iotools.FetchedByDirect)
|
||||
}
|
||||
if m["dynamicSuspect"] != true {
|
||||
t.Errorf("dynamicSuspect = %v,骨架页必须被判成可疑", m["dynamicSuspect"])
|
||||
}
|
||||
be, _ := m["browserError"].(string)
|
||||
if !strings.Contains(be, "browser fallback disabled") {
|
||||
t.Errorf("这一档没挂时必须如实说明(browserError = %q)", be)
|
||||
}
|
||||
if !strings.Contains(out.String(), "reason="+browserFallbackReasonNoSource) {
|
||||
t.Errorf("日志里必须留下分类原因码 reason=%s(实得:%s)", browserFallbackReasonNoSource, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "source=cmd/cyrene/wire_io_tools") {
|
||||
t.Errorf("这条日志必须带来源标签(AGENTS §10):%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ② 有可用浏览器 ⇒ 这一档真的挂上并被调用:浏览器那一档的**真实遭遇**(这里是不存在的
|
||||
// 可执行文件 ⇒ ErrLaunch)出现在返回体里,而不是被吞掉。
|
||||
func TestBodyFallback_AvailableBrowserIsWiredAndHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t)
|
||||
var out bytes.Buffer
|
||||
a := browserTestApp(t, cfg, &out)
|
||||
srv := serveSkeletonPage(t)
|
||||
if err := a.caps.RegisterTool(browsertools.New(newWiredBrowserManager(t, cfg, endpointHost(srv)))); err != nil {
|
||||
t.Fatalf("注册 browser 工具: %v", err)
|
||||
}
|
||||
|
||||
m := execFetch(t, fallbackFetchTool(t, a, srv), srv.URL)
|
||||
|
||||
be, _ := m["browserError"].(string)
|
||||
if !strings.Contains(be, "启动本机浏览器失败") {
|
||||
t.Errorf("浏览器这一档被挂上后就该被真调用,并把失败如实写进结果;browserError = %q", be)
|
||||
}
|
||||
if m["fetchedBy"] != iotools.FetchedByDirect {
|
||||
t.Errorf("fetchedBy = %v, want %q(浏览器没取到正文)", m["fetchedBy"], iotools.FetchedByDirect)
|
||||
}
|
||||
if !strings.Contains(out.String(), "web-fetch browser fallback wired") {
|
||||
t.Errorf("挂上时应有带来源的 INFO:%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 适配器在「取正文时已经没有可用浏览器」时如实报错,不返回空串冒充成功。
|
||||
func TestBrowserBodyRenderer_NoUsableBrowserIsNotSilentSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t)
|
||||
withUnavailable := registry.New()
|
||||
if err := withUnavailable.RegisterTool(browsertools.New(
|
||||
newUnavailableBrowserManager(t, cfg))); err != nil {
|
||||
t.Fatalf("注册 browser 工具: %v", err)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
caps *registry.Registry
|
||||
}{
|
||||
{"注册面为 nil", nil},
|
||||
{"注册面里没有 browser", registry.New()},
|
||||
{"工具在但驱动不可用", withUnavailable},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := &browserBodyRenderer{caps: tc.caps}
|
||||
got, err := r.RenderBody(context.Background(), "https://example.com/")
|
||||
if err == nil {
|
||||
t.Fatalf("没有可用浏览器时必须报错(实得 %+v)", got)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no usable browser") {
|
||||
t.Errorf("err = %v,应说清是「这一档没有来源」", err)
|
||||
}
|
||||
if got.Text != "" || got.Source != "" {
|
||||
t.Errorf("失败时不该交出半个结论: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// newUnavailableBrowserManager 造一个「探测不到来源」的管理器(Available() == false)。
|
||||
func newUnavailableBrowserManager(t *testing.T, cfg *config.Config) *browsercap.Manager {
|
||||
t.Helper()
|
||||
mgr := browsercap.NewManager(browsercap.Options{
|
||||
Availability: browsercfg.Availability{Usable: false, Detail: "本机没有浏览器"},
|
||||
Config: browsercfg.Defaults(cfg.DataDir),
|
||||
DataDir: cfg.DataDir,
|
||||
})
|
||||
t.Cleanup(func() { _ = mgr.Close() })
|
||||
return mgr
|
||||
}
|
||||
|
||||
// ④ 适配器跟的是**注册面里的当前那一把**,不是装配时的那一个:面板保存 browser 配置会
|
||||
// 用 `ReplaceTool` 换掉工具与它背后的管理器,抓着旧指针会让这条降级档在一次保存之后变成
|
||||
// 一具尸体(而且只在「取正文时」才暴露)。
|
||||
func TestBrowserBodyRenderer_FollowsRegistryReplacement(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t)
|
||||
var out bytes.Buffer
|
||||
a := browserTestApp(t, cfg, &out)
|
||||
if err := a.caps.RegisterTool(browsertools.New(
|
||||
newWiredBrowserManager(t, cfg, "example.com"))); err != nil {
|
||||
t.Fatalf("注册 browser 工具: %v", err)
|
||||
}
|
||||
r := &browserBodyRenderer{caps: a.caps}
|
||||
|
||||
if _, err := r.RenderBody(context.Background(), "https://example.com/"); err == nil ||
|
||||
strings.Contains(err.Error(), "no usable browser") {
|
||||
t.Fatalf("替换前应当用**可用的**那一把(真去渲染,因假 exe 而失败);实得 %v", err)
|
||||
}
|
||||
// 面板保存 → 换掉注册面里的那一把(新的管理器不可用)。
|
||||
if err := a.caps.ReplaceTool(browsertools.New(newUnavailableBrowserManager(t, cfg))); err != nil {
|
||||
t.Fatalf("替换 browser 工具: %v", err)
|
||||
}
|
||||
if _, err := r.RenderBody(context.Background(), "https://example.com/"); err == nil ||
|
||||
!strings.Contains(err.Error(), "no usable browser") {
|
||||
t.Fatalf("替换后适配器必须改口成「没有可用来源」,实得 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 挂载点不能从生产组装里消失:`ioToolOptions()` 是启动期唯一把降级档交给 io 包的地方,
|
||||
// 它被删掉之后编译照过、启动照起,只有模型那边少一次「浏览器第二眼」。
|
||||
func TestIOToolOptions_IncludesBodyFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
src := readSource(t, "wire_io_tools.go")
|
||||
if !strings.Contains(src, "\t\ta.bodyFallbackOption(),") {
|
||||
t.Fatal("wire_io_tools.go 里找不到 `a.bodyFallbackOption()` —— 浏览器降级档不会被挂上" +
|
||||
"(J5 在 wire_body_fallback.go,挂载点必须留在生产组装里)")
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ 装配顺序:浏览器必须在 io 工具集**之前**装配。降级档的挂载条件读的就是注册面,
|
||||
// 反过来写会让它永远读到「没有浏览器」⇒ 静默关死(没有任何运行期症状)。
|
||||
func TestWireRuntime_BrowserWiredBeforeIoTools(t *testing.T) {
|
||||
t.Parallel()
|
||||
src := readSource(t, "wire_runtime.go")
|
||||
browse := strings.Index(src, "\ta.wireBrowser()")
|
||||
tools := strings.Index(src, "ioTools, ioErr := iotools.New(a.cfg, a.ioToolOptions()...")
|
||||
if browse < 0 || tools < 0 {
|
||||
t.Fatalf("wire_runtime.go 里找不到装配点(browser=%d, ioTools=%d)", browse, tools)
|
||||
}
|
||||
if browse > tools {
|
||||
t.Fatal("wire_runtime.go 把浏览器装配排在了 io 工具集之后 —— web-fetch 的浏览器降级档" +
|
||||
"会静默关死(挂载条件读的是注册面)")
|
||||
}
|
||||
}
|
||||
|
||||
// ⑥ 预算口径:这一轮的预算已经耗尽(ctx 已结束)时,这一档**不发起**,如实报
|
||||
// 「预算耗尽 ⇒ 未尝试浏览器渲染」。判据是两句话都要在,而那句误导性的
|
||||
// 「启动本机浏览器失败」绝不能出现 —— 假 exe 一定启动失败,它出现就证明我们真去起了
|
||||
// 浏览器(那正是真机踩的坑:窗口上跳出浏览器的抱怨,日志里像浏览器坏了)。
|
||||
func TestBrowserBodyRenderer_ExhaustedBudgetIsNotABrowserFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t)
|
||||
var out bytes.Buffer
|
||||
a := browserTestApp(t, cfg, &out)
|
||||
if err := a.caps.RegisterTool(browsertools.New(
|
||||
newWiredBrowserManager(t, cfg, "example.com"))); err != nil {
|
||||
t.Fatalf("注册 browser 工具: %v", err)
|
||||
}
|
||||
r := &browserBodyRenderer{caps: a.caps, log: logger.WithSource(a.log, ioLogSource)}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
ctx func() (context.Context, context.CancelFunc)
|
||||
}{
|
||||
{"已取消", func() (context.Context, context.CancelFunc) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
return ctx, cancel
|
||||
}},
|
||||
{"已到期", func() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 0)
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx, cancel := tc.ctx()
|
||||
defer cancel()
|
||||
got, err := r.RenderBody(ctx, "https://example.com/")
|
||||
if err == nil {
|
||||
t.Fatalf("预算耗尽时必须报错(不许拿空正文冒充成功): %+v", got)
|
||||
}
|
||||
t.Logf("实测 err=%v", err)
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, browserFallbackReasonBudget) {
|
||||
t.Errorf("err = %v,应带原因码 %q", err, browserFallbackReasonBudget)
|
||||
}
|
||||
if !strings.Contains(msg, "未尝试浏览器渲染") {
|
||||
t.Errorf("err = %v,应说清「没有试过」", err)
|
||||
}
|
||||
if strings.Contains(msg, "启动本机浏览器失败") {
|
||||
t.Errorf("预算耗尽不许被报成浏览器故障: %v", err)
|
||||
}
|
||||
if strings.Contains(msg, "no usable browser") {
|
||||
t.Errorf("「预算耗尽」与「没有可用来源」是两件事,不许混成一句: %v", err)
|
||||
}
|
||||
if got.Text != "" || got.Source != "" {
|
||||
t.Errorf("失败时不该交出半个结论: %+v", got)
|
||||
}
|
||||
if !strings.Contains(out.String(), "reason="+browserFallbackReasonBudget) {
|
||||
t.Errorf("日志里必须留下原因码(实得:%s)", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "source="+ioLogSource) {
|
||||
t.Errorf("这条日志必须带来源标签(AGENTS §10):%s", out.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ⑦ 端到端(走生产接线的选项):预算耗尽的那一轮,web-fetch 的错误里**两条档的真实遭遇
|
||||
// 都要在** —— 直连为什么没成 + 浏览器这一档为什么**没试**(预算耗尽)。io 层的
|
||||
// `fetchedBy` / `browserError` 语义不许变(那是它的红线),所以这里断言的是它们的**输出**
|
||||
// 仍然照实:两条失败各自出现,谁也别替谁背锅。
|
||||
func TestBodyFallback_ExhaustedBudgetIsHonestEndToEnd(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t)
|
||||
var out bytes.Buffer
|
||||
a := browserTestApp(t, cfg, &out)
|
||||
srv := serveSkeletonPage(t)
|
||||
if err := a.caps.RegisterTool(browsertools.New(
|
||||
newWiredBrowserManager(t, cfg, endpointHost(srv)))); err != nil {
|
||||
t.Fatalf("注册 browser 工具: %v", err)
|
||||
}
|
||||
tool := fallbackFetchTool(t, a, srv)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
got, err := tool.Execute(ctx, map[string]any{"url": srv.URL})
|
||||
if err == nil {
|
||||
t.Fatalf("两条档都没成时必须报错(实得 %#v)", got)
|
||||
}
|
||||
t.Logf("实测 web-fetch 错误:%v", err)
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "browser fallback failed too") {
|
||||
t.Errorf("io 层「两条失败都照实报」的口径不许变: %v", err)
|
||||
}
|
||||
if !strings.Contains(msg, browserFallbackReasonBudget) {
|
||||
t.Errorf("浏览器那一档要如实说「预算耗尽 ⇒ 未尝试」: %v", err)
|
||||
}
|
||||
if strings.Contains(msg, "启动本机浏览器失败") {
|
||||
t.Errorf("预算耗尽不许被说成浏览器故障(真去起了浏览器): %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("失败时不该交出结果: %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package main
|
||||
|
||||
// 本文件是 design/28(浏览器与页面截图)B1→B2 的**组合根接线**:把纯库
|
||||
// `internal/capability/browsercfg`(配置读写 + 可用性探测)接进能力面,并在可用时
|
||||
// 注册 `browser` 工具(`plugins/tools/browser` 的薄层)。
|
||||
//
|
||||
// 六条口径:
|
||||
//
|
||||
// 1. **配置只从文件来**:`$DATA_DIR/config/plugins/browser.json`(design/13 §2.1 的
|
||||
// 插件配置落点、design/28 §1 M2)。**不读任何新增 env 键** —— `.env` 已冻结为
|
||||
// 「最小化功能可跑」的引导面(AGENTS §10);本文件通篇没有 `os.Getenv`。
|
||||
// 2. **文件缺失 = 交给自动探测**(`browsercfg.Resolve(nil, …)` 走「本机
|
||||
// Chrome/Chromium → 已在跑的 CDP 端点」);**文件非法 = 不注册**(如实报错,
|
||||
// 不静默拿默认值顶替 —— 那会让「面板里那份坏配置」永远看不见)。
|
||||
// 3. **不可用就不注册**(照 web-search `off` 的诚实口径,design/28 §7 判据 1):
|
||||
// 模型根本看不见这个工具,而不是看得见、一调就报错。原因**分类**打进日志
|
||||
// (`reason=no_source` / `executable_missing` / `cdp_unreachable`),不糊成
|
||||
// 「失败」两个字。
|
||||
// 4. **profile 目录含凭据**:注册之前先过 `EnsureProfileDir`(0700 + 不得位于 git
|
||||
// 工作树内,design/28 §2 三件套之 2、§4);不过关就不注册。
|
||||
// 5. **两条失败都不阻塞启动**(与 wireVision / wireTranscode 同族):浏览器是**可选
|
||||
// 增强**,磁盘上没有 Chrome 的机器照样得把整个进程拉起来。
|
||||
// 6. **幂等**:重复装配只发现工具已在注册表里并跳过,不重复注册、不重复探测。
|
||||
//
|
||||
// 关于挂点(切片 X3 归位):本文件的 `wireBrowser()` 由 `wireRuntime` 调用,位置与
|
||||
// `wireVision()` / `wireTranscode()` 并列 —— 三者同类(可选增强,不可用就不注册)。
|
||||
// 它**不再**借 `ioToolOptions()` 转交:那件函数是「组装 io 选项」的只读装配,顺带探测
|
||||
// 并注册浏览器会让所有只为要一份选项而调它的路径(web-search 的接线测试就是)连带起
|
||||
// 一次浏览器探测。它的关闭挂点在 `a.close`(wire_services.go),与这一行成对。
|
||||
//
|
||||
// 构造路径**只有一条**(`buildBrowserTool`):启动期注册(本文件)与面板保存后的
|
||||
// 热重载(`wire_browser_panel.go`)走的是同一段代码,两处语义不可能漂移。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
|
||||
)
|
||||
|
||||
const (
|
||||
// browserLogSource 是本文件日志的来源标签(AGENTS §10:禁止无来源裸日志)。
|
||||
browserLogSource = "cmd/cyrene/wire_browser"
|
||||
|
||||
// browserProbeTimeout 约束可用性探测。探测问的是「有没有在听」(本机可执行文件
|
||||
// 是否存在、CDP 端点是否回话),不是「页面能不能加载」,所以给得短:最坏情况
|
||||
// 也只是启动路径上多等 3 秒,而不是拖住整个组合根。
|
||||
browserProbeTimeout = 3 * time.Second
|
||||
|
||||
// 生效配置的来源标签(只进日志):一眼看出本次结论是「面板文件说的」还是
|
||||
// 「没有配置文件、自动探测出来的」。
|
||||
browserConfigSourceFile = "panel-file"
|
||||
browserConfigSourceProbe = "probe"
|
||||
|
||||
// 不注册工具时的原因码(进日志的 reason 字段,与 browsercfg.Reason 同风格)。
|
||||
browserReasonInvalidConfig = "invalid-config"
|
||||
browserReasonProfileRejected = "profile-dir-rejected"
|
||||
|
||||
// browserNotRegisteredEffect 是「不注册」这件事对模型面**实际**意味着什么:
|
||||
// 写死在日志里,免得读日志的人以为只是「降级运行」。
|
||||
browserNotRegisteredEffect = "the model will not see a browser tool at all"
|
||||
)
|
||||
|
||||
// browserBuild 是「按当前配置把 browser 工具建出来」的结论(成功与失败共用一个形状,
|
||||
// 使每一条失败路径都只能经**同一个出口**报原因,不会在两条分支里各写各的文案)。
|
||||
type browserBuild struct {
|
||||
// tool / mgr 是成功时的产物(失败时两者皆 nil)。
|
||||
tool *browsertools.Tool
|
||||
mgr *browsercap.Manager
|
||||
// reason 失败时非空:分类原因码(`browsercfg.Reason*` 或本文件的 browserReason*)。
|
||||
reason string
|
||||
// detail 是给人看的一句话(失败原因细节;不含凭据、不含图片内容)。
|
||||
detail string
|
||||
// configPath / configSource 是本次生效配置的来路(进日志,便于对照面板)。
|
||||
configPath string
|
||||
configSource string
|
||||
// source / headless 是探测结论(只进日志)。
|
||||
source string
|
||||
headless bool
|
||||
// available 报告这次是否真的建出了可用的工具。
|
||||
available bool
|
||||
}
|
||||
|
||||
// buildBrowserTool 走「读文件 → 探测可用性 → profile 守卫 → 建管理器与工具」这四步。
|
||||
//
|
||||
// carry 是「要沿用的截图附件箱」:面板保存触发热重载时,重建前的待送截图不能丢
|
||||
// (模型刚拍完还没送出去的那一张)。为 nil 时工具自带一个新箱子。
|
||||
func (a *app) buildBrowserTool(carry *browsertools.ShotOutbox) browserBuild {
|
||||
dataDir := resolveDataDir(a.cfg)
|
||||
configPath := browsercfg.DefaultConfigPath(dataDir)
|
||||
fileCfg, err := browsercfg.Load(configPath, dataDir)
|
||||
if err != nil {
|
||||
return browserBuild{
|
||||
reason: browserReasonInvalidConfig,
|
||||
detail: err.Error(),
|
||||
configPath: configPath,
|
||||
}
|
||||
}
|
||||
effective := browsercfg.Defaults(dataDir)
|
||||
configSource := browserConfigSourceProbe
|
||||
if fileCfg != nil {
|
||||
effective = *fileCfg
|
||||
configSource = browserConfigSourceFile
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), browserProbeTimeout)
|
||||
avail := browsercfg.Resolve(ctx, fileCfg, browsercfg.NewProber())
|
||||
cancel()
|
||||
if !avail.Usable {
|
||||
return browserBuild{
|
||||
reason: string(avail.Reason),
|
||||
detail: avail.Detail,
|
||||
configPath: configPath,
|
||||
configSource: configSource,
|
||||
}
|
||||
}
|
||||
// profile 含 cookie / 登录态:0700,且不得落在 git 工作树内(会被 git add 收走)。
|
||||
if err := browsercfg.EnsureProfileDir(effective.ProfileDir, dataDir); err != nil {
|
||||
return browserBuild{
|
||||
reason: browserReasonProfileRejected,
|
||||
detail: err.Error(),
|
||||
configPath: configPath,
|
||||
configSource: configSource,
|
||||
}
|
||||
}
|
||||
mgr := browsercap.NewManager(browsercap.Options{
|
||||
Availability: avail,
|
||||
Config: effective,
|
||||
DataDir: dataDir,
|
||||
Logger: a.log,
|
||||
})
|
||||
opts := []browsertools.Option{browsertools.WithLogger(a.log)}
|
||||
if carry != nil {
|
||||
opts = append(opts, browsertools.WithShotOutbox(carry))
|
||||
}
|
||||
return browserBuild{
|
||||
tool: browsertools.New(mgr, opts...),
|
||||
mgr: mgr,
|
||||
configPath: configPath,
|
||||
configSource: configSource,
|
||||
source: string(avail.Source),
|
||||
headless: avail.Headless,
|
||||
available: true,
|
||||
}
|
||||
}
|
||||
|
||||
// wireBrowser 装配 `browser` 工具。它没有错误返回值:一切失败都是**如实降级**
|
||||
// (记分类原因 + 不注册),绝不阻塞启动。
|
||||
func (a *app) wireBrowser() {
|
||||
log := logger.WithSource(a.log, browserLogSource)
|
||||
if a.caps == nil || a.cfg == nil {
|
||||
// 部分测试夹具只填了 app 的几个字段;没有注册面就没有「注册」这件事。
|
||||
return
|
||||
}
|
||||
if _, err := a.caps.Get(browsertools.ToolName); err == nil {
|
||||
return // 同一 app 上重复装配:已经注册过,不重复探测
|
||||
}
|
||||
build, ok := a.registerBrowserBuild(log, nil)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log.Info("browser tool registered",
|
||||
"source", build.source, "headless", build.headless,
|
||||
"configSource", build.configSource, "configPath", build.configPath,
|
||||
"shotsDir", browsercfg.DefaultShotsDir(resolveDataDir(a.cfg)),
|
||||
"richActions", "click/type/wait/extract 由 MCP(Playwright/Puppeteer server)提供,本工具不重造")
|
||||
}
|
||||
|
||||
// registerBrowserBuild 建出工具并注册进能力面;任何一步失败都**只**在日志里报分类原因。
|
||||
// 抽成独立函数是为了让每一处「不注册」都只有一个出口(口径不会在两条分支里漂移)。
|
||||
func (a *app) registerBrowserBuild(log *slog.Logger, carry *browsertools.ShotOutbox) (browserBuild, bool) {
|
||||
build := a.buildBrowserTool(carry)
|
||||
if !build.available {
|
||||
log.Warn("browser tool not registered",
|
||||
"reason", build.reason, "detail", build.detail,
|
||||
"configPath", build.configPath, "configSource", build.configSource,
|
||||
"effect", browserNotRegisteredEffect)
|
||||
return browserBuild{}, false
|
||||
}
|
||||
if err := a.caps.RegisterTool(build.tool); err != nil {
|
||||
// 注册失败就把刚建的管理器关掉:它可能已经持有 profile 锁与清扫协程,
|
||||
// 留着就是一次凭据目录的泄漏(registerBrowserBuild 的失败路径只有这一条)。
|
||||
if cerr := build.mgr.Close(); cerr != nil {
|
||||
log.Warn("browser manager close failed after register error", "error", cerr)
|
||||
}
|
||||
log.Warn("browser tool register skipped", "tool", browsertools.ToolName, "error", err)
|
||||
return browserBuild{}, false
|
||||
}
|
||||
return build, true
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package main
|
||||
|
||||
// 本文件是 design/28 §7 判据 2(M4 闭环)的**组合根落点**:把「截图 → 进本回合上下文」
|
||||
// 接成一条自动的路。三件事各归各家,本文件只做中间那一段接线:
|
||||
//
|
||||
// plugins/tools/browser(交图) → 本文件(装配期并入本轮视图) → multimodal/vision(成图/成文)
|
||||
//
|
||||
// 具体地:
|
||||
//
|
||||
// 1. `browser` 工具的 `screenshot` 动作落盘后把这张图放进它**自己的附件箱**
|
||||
// (`plugins/tools/browser/attach.go`,按 `loop.SessionFromContext` 的会话分桶);
|
||||
// 2. 本文件给出的 `TransformContext` 钩子在**每个模型步**之前被循环调用
|
||||
// (`internal/runtime/loop/memory.go` 的 assembleContext 末尾),把该会话的待送截图
|
||||
// **取走即消费**,经 `vision.ShotAttacher` 变成一条带图(或带文字描述)的 user 消息,
|
||||
// 追加进本轮视图;
|
||||
// 3. `ShotAttacher` 用**既有**的视觉路由决定形态:主模型能收图 ⇒ 内联成真的 image 部件;
|
||||
// 收不了图 ⇒ vision/OCR 工具产出的文字(design/28 §5 的那一行)。
|
||||
//
|
||||
// 为什么用 `TransformContext` 而不是自己往 msgs 里写:循环对这个钩子的契约就是
|
||||
// 「投影、过滤或重排模型所见的消息」(`loop.Options.TransformContext`),而且它
|
||||
// **只影响本轮视图**、不写回运行窗口 —— 截图因此不会进 checkpoint 快照,也不会进
|
||||
// 后台记忆提取的输入面(design/28 §4 第 3 条:「默认不进长期记忆」,截图不是记忆)。
|
||||
//
|
||||
// ⚠️ **接线状态(如实)**:本文件提供 `a.browserShotTransform(...)`,生产接线需要
|
||||
// `wire_runtime.go` 的 `loop.New(loop.Options{…})` 里加一行:
|
||||
//
|
||||
// TransformContext: a.browserShotTransform(visionResolver),
|
||||
//
|
||||
// 本片(Y4)的文件面**不含** `wire_runtime.go`,因此那一行由主会话落地;落地前本文件
|
||||
// 只在测试里被使用(`wire_browser_context_test.go` 用真实 loop + 探针适配器验证
|
||||
// 「装上它之后,模型请求里确实出现 image 部件」)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/vision"
|
||||
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
|
||||
)
|
||||
|
||||
// browserShotLogSource 是本文件日志的来源标签(AGENTS §10:禁止无来源裸日志)。
|
||||
const browserShotLogSource = "cmd/cyrene/wire_browser_context"
|
||||
|
||||
// cropToolName 是裁切能力的工具名(`plugins/tools/io/crop.go` 的 Name)。
|
||||
// 「大图先过 crop」这条要求落在这里:视觉层按区域裁一块再内联,不自己实现裁剪。
|
||||
const cropToolName = "image-crop"
|
||||
|
||||
// browserShotTransform 造出装给 `loop.Options.TransformContext` 的钩子。
|
||||
//
|
||||
// resolver 就是运行时那一个 `*vision.MessageResolver`(组合根已经建好的那个实例),
|
||||
// 因此截图与用户附图共用同一个 Router、同一份 data: URI 缓存 —— 两处各建一个会让
|
||||
// 「同一种图两种处理」变成可能。
|
||||
func (a *app) browserShotTransform(resolver *vision.MessageResolver) func(context.Context, []model.Message, int) []model.Message {
|
||||
injector := &browserShotInjector{
|
||||
app: a,
|
||||
log: browserShotLogger(a.log),
|
||||
}
|
||||
if resolver != nil {
|
||||
injector.attacher = vision.NewShotAttacher(resolver,
|
||||
vision.WithShotCropper(browserShotCropper{invoke: a.browserInvoker()}))
|
||||
}
|
||||
return injector.apply
|
||||
}
|
||||
|
||||
// browserShotLogger 给日志补来源标签并兜住 nil:`a.log` 为 nil 的夹具下
|
||||
// `logger.WithSource` 会返回 nil,而注入器会直接调用它(nil 的 *slog.Logger 会 panic)。
|
||||
func browserShotLogger(base *slog.Logger) *slog.Logger {
|
||||
if base == nil {
|
||||
return logger.WithSource(slog.New(slog.NewTextHandler(io.Discard, nil)), browserShotLogSource)
|
||||
}
|
||||
return logger.WithSource(base, browserShotLogSource)
|
||||
}
|
||||
|
||||
// browserShotInjector 是钩子的实现体(把状态与函数分开,便于单测直接构造)。
|
||||
type browserShotInjector struct {
|
||||
app *app
|
||||
attacher *vision.ShotAttacher
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// apply 在每个模型步之前把待送截图并入本轮视图。
|
||||
//
|
||||
// 它**绝不**因为一张图的问题让整个回合失败:任何一步失败都如实降级(图读不出来就只发
|
||||
// 一句「没送成」的说明,模型仍能按路径自己读),因为「截图送不进去」远比「回合直接报错」
|
||||
// 轻(同 vision 的既有口径:图像解析失败保留原消息、回合继续)。
|
||||
func (in *browserShotInjector) apply(ctx context.Context, msgs []model.Message, _ int) []model.Message {
|
||||
outbox := in.outbox()
|
||||
if outbox == nil || in.attacher == nil {
|
||||
return msgs
|
||||
}
|
||||
shots := outbox.Drain(loop.SessionFromContext(ctx))
|
||||
if len(shots) == 0 {
|
||||
return msgs
|
||||
}
|
||||
out := msgs
|
||||
for _, shot := range shots {
|
||||
att, err := in.attacher.Attach(ctx, vision.ShotRef{
|
||||
Path: shot.Path,
|
||||
Bytes: shot.Bytes,
|
||||
Width: shot.Width,
|
||||
Height: shot.Height,
|
||||
Format: shot.Format,
|
||||
})
|
||||
if err != nil {
|
||||
// 如实说明「这张图没送成」,并给出路径:模型仍可用 vision / image-crop 自己读。
|
||||
in.log.Warn("browser screenshot not attached to the turn",
|
||||
"path", shot.Path, "bytes", shot.Bytes, "error", err)
|
||||
out = append(out, attachmentFailureMessage(shot.Path, err))
|
||||
continue
|
||||
}
|
||||
in.log.Info("browser screenshot attached to the turn",
|
||||
"path", shot.Path, "bytes", shot.Bytes, "route", att.Route, "cropped", att.Cropped)
|
||||
out = append(out, attachmentMessage(att))
|
||||
}
|
||||
// 触发一次 len 变化后返回新切片(不修改入参切片:钩子的契约是「可以投影」,
|
||||
// 而原地追加会改到循环持有的 msgs 底层数组)。
|
||||
return append([]model.Message(nil), out...)
|
||||
}
|
||||
|
||||
// outbox 取回 browser 工具的附件箱。工具没注册(本机没有浏览器)时返回 nil。
|
||||
//
|
||||
// 用**结构断言**而不是具体类型:将来若给工具套一层装饰(例如审计包装),
|
||||
// 只要它还暴露 ShotOutbox 就仍然接得上,不必改这里。
|
||||
func (in *browserShotInjector) outbox() *browsertools.ShotOutbox {
|
||||
if in.app == nil || in.app.caps == nil {
|
||||
return nil
|
||||
}
|
||||
entry, err := in.app.caps.Get(browsertools.ToolName)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
holder, ok := entry.Value.(interface {
|
||||
ShotOutbox() *browsertools.ShotOutbox
|
||||
})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return holder.ShotOutbox()
|
||||
}
|
||||
|
||||
// browserInvoker 返回工具调用器(`image-crop` 经它调)。dispatcher 未接时返回 nil,
|
||||
// 视觉层据此判断「没有裁切能力」并如实降级(见 vision.ShotAttacher.cropped)。
|
||||
func (a *app) browserInvoker() vision.ToolInvoker {
|
||||
if a == nil || a.dispatcher == nil {
|
||||
return nil
|
||||
}
|
||||
return a.dispatcher.Invoke
|
||||
}
|
||||
|
||||
// attachmentMessage 把一条附件结论渲染成**一条 user 消息**。
|
||||
//
|
||||
// 为什么是 user 角色:DeepSeek / Anthropic 协议的图像部件**只允许出现在 user 消息**里
|
||||
// (design/24 §2.3 第 3 条),而工具结果回合在本项目是 `RoleTool`(文本-only 形状,
|
||||
// `loop.toolResultMessage`)—— 往那里塞 image 部件会被编码器静默丢掉。
|
||||
func attachmentMessage(att vision.ShotAttachment) model.Message {
|
||||
note := strings.TrimSpace(att.Note)
|
||||
if note == "" {
|
||||
note = "[截图附件]这一步在浏览器里拍下的一页。"
|
||||
}
|
||||
parts := make([]model.Content, 0, 2)
|
||||
parts = append(parts, model.Content{Type: model.ContentTypeText, Text: note})
|
||||
if part := att.Content; part.Type == model.ContentTypeImage || strings.TrimSpace(part.Text) != "" {
|
||||
parts = append(parts, part)
|
||||
}
|
||||
return model.Message{Role: model.RoleUser, Content: parts}
|
||||
}
|
||||
|
||||
// attachmentFailureMessage 是「这张图没送成」的如实说明(**不带**图片内容,
|
||||
// 只带路径与病因分类),使模型不会以为页面里什么都没有。
|
||||
func attachmentFailureMessage(path string, err error) model.Message {
|
||||
text := fmt.Sprintf("[截图附件]刚才那张截图**没有**并入本轮上下文(%v)。完整图在 %s,"+
|
||||
"可用 vision、OCR 或 image-crop 工具按路径自行读取。", err, path)
|
||||
return model.Message{Role: model.RoleUser, Content: []model.Content{
|
||||
{Type: model.ContentTypeText, Text: text},
|
||||
}}
|
||||
}
|
||||
|
||||
// browserShotCropper 是「大图先过 crop」的执行者:把区域裁切交给既有的 `image-crop`
|
||||
// 工具(同一份实现、同一份上限与同一套坐标语义),本层不重写裁剪。
|
||||
type browserShotCropper struct {
|
||||
invoke vision.ToolInvoker
|
||||
}
|
||||
|
||||
// CropRegion 实现 vision.ShotCropper。
|
||||
func (c browserShotCropper) CropRegion(ctx context.Context, path string, x, y, w, h int) (string, error) {
|
||||
if c.invoke == nil {
|
||||
return "", fmt.Errorf("browser: 没有可用的裁切能力(工具分发器未接线)")
|
||||
}
|
||||
res, err := c.invoke(ctx, cropToolName, map[string]any{
|
||||
"image": path,
|
||||
"x": x,
|
||||
"y": y,
|
||||
"width": w,
|
||||
"height": h,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("browser: %s: %w", cropToolName, err)
|
||||
}
|
||||
m, ok := res.(map[string]any)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("browser: %s 返回了非对象结果(%T)", cropToolName, res)
|
||||
}
|
||||
uri, _ := m["image"].(string)
|
||||
if strings.TrimSpace(uri) == "" {
|
||||
return "", fmt.Errorf("browser: %s 没返回裁切结果", cropToolName)
|
||||
}
|
||||
return uri, nil
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package main
|
||||
|
||||
// 本文件是 design/28 §7 **判据 2(M4 闭环)的真跑**(切片 Y4 / B3):
|
||||
//
|
||||
// 一张**图里才有、HTML 里没有**的内容(这里由一张真实 PNG 代表)⇒ 它进了模型请求。
|
||||
//
|
||||
// 走的是**真实循环**(`loop.New` + 探针适配器,与组合根同一个装配形状),因此它验的是
|
||||
// 完整那一段:截图箱 → `TransformContext` 钩子 → 视觉路由(主模型能收图 ⇒ 真 image 部件;
|
||||
// 收不了图 ⇒ vision 工具产出的文字)→ 模型请求。
|
||||
//
|
||||
// 三段各自归各家、这里只做端到端:
|
||||
// - 截图**落盘**(真 Chrome、真 CDP)由 `wire_browser_e2e_test.go` 与
|
||||
// `internal/capability/browser/compare_test.go` 覆盖(需要真机,默认 Skip);
|
||||
// - 「图 → 内容部件」的路由与裁切由 `internal/runtime/multimodal/vision/shot_test.go` 覆盖;
|
||||
// - 本文件把两者接起来,并且**断言模型侧确实拿到 image 部件**(判据 2 的原文要求)。
|
||||
//
|
||||
// 生产侧的**两条挂点**都由本文件盯住(它们删掉之后都没有任何运行期症状):
|
||||
// `TestWireRuntime_WiresBrowserShotTransform` 钉 `wire_runtime.go` 的 `TransformContext`;
|
||||
// `TestWireFrontend_WiresBrowserPanel` 钉 `wire_frontend.go` 的 `wireBrowserPanel` 调用
|
||||
// (`TestWirePanelHub_DeclaresBrowserPanel` 是它的**行为侧**配偶:真装配一遍再读回目录)。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||||
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/vision"
|
||||
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
|
||||
)
|
||||
|
||||
// b3Session 是本文件用的会话 id(截图箱按会话分桶,测试与循环必须用同一个)。
|
||||
const b3Session = "sess-b3"
|
||||
|
||||
// b3RenderOnlyText 是「图里才有、HTML 里没有」的那句话:走工具形态时它必须出现在
|
||||
// 模型请求里(纯文本路径的正面证据)。
|
||||
const b3RenderOnlyText = "计费周期:2026-09 至 2026-10(这是渲染后才出现的文字)"
|
||||
|
||||
// b3PNG 造一张真实 PNG(视觉层对字节**嗅探**,假字节会被拒)。
|
||||
func b3PNG(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, 6, 4))
|
||||
img.Set(0, 0, color.RGBA{R: 0x10, G: 0x20, B: 0x30, A: 0xff})
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("编码 PNG: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// b3Fixture 是一次闭环验证的夹具:真 loop + 探针适配器 + 装着截图的 browser 工具。
|
||||
type b3Fixture struct {
|
||||
adapter *compactProbeAdapter
|
||||
loop *loop.Loop
|
||||
outbox *browsertools.ShotOutbox
|
||||
shot string
|
||||
}
|
||||
|
||||
// newB3Fixture 装配夹具。sighted 决定主模型能不能收图(两条闭环路径的分岔点就在这里)。
|
||||
func newB3Fixture(t *testing.T, sighted bool) *b3Fixture {
|
||||
t.Helper()
|
||||
data := b3PNG(t)
|
||||
shotPath := filepath.Join(t.TempDir(), "shot-b3.png")
|
||||
if err := os.WriteFile(shotPath, data, 0o600); err != nil {
|
||||
t.Fatalf("写截图: %v", err)
|
||||
}
|
||||
|
||||
outbox := browsertools.NewShotOutbox()
|
||||
outbox.Put(b3Session, browsercapShot(shotPath, len(data)))
|
||||
|
||||
// 只接**附件箱**这一面:本用例验的是「截图落盘之后」那一段,真浏览器由真机 e2e 覆盖。
|
||||
tool := browsertools.New(nil, browsertools.WithShotOutbox(outbox))
|
||||
var out bytes.Buffer
|
||||
a := &app{
|
||||
cfg: &config.Config{DataDir: t.TempDir()},
|
||||
log: logger.New(logger.Options{Out: &out, Level: "debug"}),
|
||||
caps: registry.New(),
|
||||
}
|
||||
if err := a.caps.RegisterTool(tool); err != nil {
|
||||
t.Fatalf("注册 browser 工具: %v", err)
|
||||
}
|
||||
|
||||
adapter := &compactProbeAdapter{}
|
||||
resolver := vision.NewMessageResolver(
|
||||
vision.NewRouter(vision.WithVisionTool("vision"), vision.WithOCRTool("ocr")),
|
||||
func() vision.ModelInfo {
|
||||
return vision.ModelInfo{Name: "main", SupportsVision: sighted}
|
||||
},
|
||||
func(_ context.Context, _ string, _ map[string]any) (any, error) {
|
||||
// 工具形态的假实现:返回「只有渲染后才看得见」的那句话。
|
||||
return map[string]any{"description": b3RenderOnlyText, "text": b3RenderOnlyText}, nil
|
||||
},
|
||||
)
|
||||
ag, err := agent.New("你是昔涟。", nil, nil, agent.WithModel(adapter, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("agent.New: %v", err)
|
||||
}
|
||||
return &b3Fixture{
|
||||
adapter: adapter,
|
||||
outbox: outbox,
|
||||
shot: shotPath,
|
||||
loop: loop.New(loop.Options{
|
||||
Agent: ag,
|
||||
// ← 与生产接线逐字同一行(wire_runtime.go 的 loop.Options.TransformContext),
|
||||
// 由 TestWireRuntime_WiresBrowserShotTransform 源码级盯住。
|
||||
TransformContext: a.browserShotTransform(resolver),
|
||||
OnStream: func(model.StreamEvent) {},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// browsercapShot 造与落盘结论同形的截图值。
|
||||
func browsercapShot(path string, size int) browsercap.Shot {
|
||||
return browsercap.Shot{Path: path, Bytes: size, Width: 6, Height: 4, Format: "png",
|
||||
DimensionsKnown: true}
|
||||
}
|
||||
|
||||
// TestBrowserShotClosure_SightedModelReceivesTheImage 是判据 2 的正面:
|
||||
// 主模型能收图 ⇒ 模型请求里**确实有 image 部件**,且是服务端内联的 data: URI。
|
||||
func TestBrowserShotClosure_SightedModelReceivesTheImage(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newB3Fixture(t, true)
|
||||
if _, err := fx.loop.Run(context.Background(), b3Session,
|
||||
[]model.Message{model.TextMessage(model.RoleUser, "这一页写了什么?")}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if len(fx.adapter.reqs) == 0 {
|
||||
t.Fatal("没有捕获到模型请求")
|
||||
}
|
||||
req := fx.adapter.reqs[len(fx.adapter.reqs)-1]
|
||||
part, ok := findImagePart(req.Messages)
|
||||
if !ok {
|
||||
t.Fatalf("模型请求里没有 image 部件 —— 截图没有进上下文(判据 2 不成立);消息数=%d",
|
||||
len(req.Messages))
|
||||
}
|
||||
if !strings.HasPrefix(part.ImageURL, "data:image/png;base64,") {
|
||||
t.Errorf("image 部件不是内联的 data: URI(云 provider 取不到本地路径):%.60s", part.ImageURL)
|
||||
}
|
||||
if !imagePartIsOnUserMessage(req.Messages) {
|
||||
t.Error("image 部件不在 user 消息上:Anthropic/DeepSeek 只接受 user 消息里的图像部件")
|
||||
}
|
||||
if note := lastUserText(req.Messages); !strings.Contains(note, fx.shot) {
|
||||
t.Errorf("附件说明里没给出截图路径(模型无法据此裁区域精读):%q", note)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserShotClosure_TextOnlyModelGetsToolFormText 是判据 2 的另一条路:
|
||||
// 主模型读不了图 ⇒ 图**不进**上下文,进去的是 vision 工具的读数(「渲染后才出现的文字」)。
|
||||
func TestBrowserShotClosure_TextOnlyModelGetsToolFormText(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newB3Fixture(t, false)
|
||||
if _, err := fx.loop.Run(context.Background(), b3Session,
|
||||
[]model.Message{model.TextMessage(model.RoleUser, "这一页写了什么?")}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
req := fx.adapter.reqs[len(fx.adapter.reqs)-1]
|
||||
if _, hasImage := findImagePart(req.Messages); hasImage {
|
||||
t.Error("主模型读不了图时不该把 image 部件塞进上下文(那是无意义的 token 与 400 风险)")
|
||||
}
|
||||
text := allText(req.Messages)
|
||||
if !strings.Contains(text, b3RenderOnlyText) {
|
||||
t.Errorf("工具形态的文字没进上下文:%q", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserShotClosure_ShotIsConsumedOnce 钉住「取走即消费」:同一张截图不会被送到两次
|
||||
// (重复送图既费预算,又会诱导模型以为「又拍了一张」)。
|
||||
func TestBrowserShotClosure_ShotIsConsumedOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newB3Fixture(t, true)
|
||||
msgs := []model.Message{model.TextMessage(model.RoleUser, "看看这一页")}
|
||||
if _, err := fx.loop.Run(context.Background(), b3Session, msgs); err != nil {
|
||||
t.Fatalf("第一次 Run: %v", err)
|
||||
}
|
||||
if fx.outbox.Pending(b3Session) != 0 {
|
||||
t.Fatalf("第一次之后箱子里还剩 %d 张(取走即消费被破坏)", fx.outbox.Pending(b3Session))
|
||||
}
|
||||
if _, err := fx.loop.Run(context.Background(), b3Session, msgs); err != nil {
|
||||
t.Fatalf("第二次 Run: %v", err)
|
||||
}
|
||||
if _, ok := findImagePart(fx.adapter.reqs[len(fx.adapter.reqs)-1].Messages); ok {
|
||||
t.Error("同一张截图在第二次回合里又被送了一次")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireRuntime_WiresBrowserShotTransform 盯住生产接线那一行(B3 的生产挂点)。
|
||||
//
|
||||
// 它拦的是「以后有人把那行删了」:删掉之后**没有任何运行期症状**——编译照过、回合照跑,
|
||||
// 只是截图不再进上下文。因此断言的是**一整行字段赋值**(行首到行尾),被注释掉的
|
||||
// (`// TransformContext: …`)或写在别处的同名字符串都不会让它通过。
|
||||
func TestWireRuntime_WiresBrowserShotTransform(t *testing.T) {
|
||||
t.Parallel()
|
||||
src := readSource(t, "wire_runtime.go")
|
||||
|
||||
const assignment = "TransformContext: a.browserShotTransform(visionResolver),"
|
||||
if !hasNormalizedCodeLine(src, assignment) {
|
||||
t.Fatalf("wire_runtime.go 的 loop.Options 里找不到 %q —— B3 截图闭环的生产挂点缺失:"+
|
||||
"截图照落盘、回合照跑,只是**一张图都不会进上下文**", assignment)
|
||||
}
|
||||
// 钩子捕获的就是 `visionResolver`,因此它必须在 loop.New 之前建好;顺序反过来装上的
|
||||
// 就是一个 nil 解析器(ShotAttacher 为 nil),截图静默不送——同样是**零症状**的坏法。
|
||||
idxResolver := strings.Index(src, "visionResolver := vision.NewMessageResolver(")
|
||||
idxLoop := strings.Index(src, "a.loop = loop.New(loop.Options{")
|
||||
if idxResolver < 0 || idxLoop < 0 || idxResolver > idxLoop {
|
||||
t.Fatalf("visionResolver 与 loop.New 的先后不对(偏移 %d vs %d):钩子必须在解析器**之后**装配",
|
||||
idxResolver, idxLoop)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireFrontend_WiresBrowserPanel 盯住 B4 的生产挂点:`wirePanelHub` 必须在
|
||||
// `declareBuiltinPanels` 之后调用 `wireBrowserPanel`。
|
||||
//
|
||||
// 与上面那条同族——删掉之后**没有任何编译期或运行期症状**:只是面板目录里没有 browser 页、
|
||||
// 六个动作全回 501(`GET /api/v1/admin/panels` 少一条、点击全部 501),而整树测试照绿。
|
||||
// 断言同样锚定**一整行调用**,注释掉那一行不会通过。
|
||||
func TestWireFrontend_WiresBrowserPanel(t *testing.T) {
|
||||
t.Parallel()
|
||||
src := readSource(t, "wire_frontend.go")
|
||||
|
||||
const call = "wireBrowserPanel(hub, a, a.log)"
|
||||
if !hasNormalizedCodeLine(src, call) {
|
||||
t.Fatalf("wire_frontend.go 的 wirePanelHub 里找不到 %q —— B4 的 browser 面板声明"+
|
||||
"与六个动作都进不了目录(面板无 browser 页、动作全 501)", call)
|
||||
}
|
||||
// 位置:声明在前、登记在后是 PLG-2 的硬要求(登记期会核对有没有对应声明)。这里钉住
|
||||
// 它**在 wirePanelHub 内**且**在 declareBuiltinPanels 之后**,防止被挪到别处或提前。
|
||||
idxDeclare := strings.Index(src, "declareBuiltinPanels(hub, log)")
|
||||
idxBrowser := strings.Index(src, call)
|
||||
if idxDeclare < 0 || idxBrowser < idxDeclare {
|
||||
t.Fatalf("wireBrowserPanel 的调用位置不对(declareBuiltinPanels 偏移 %d,调用偏移 %d):"+
|
||||
"它必须在声明之后(wirePanelHub 内部)", idxDeclare, idxBrowser)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWirePanelHub_DeclaresBrowserPanel 是上面那条源码级断言的**行为侧配偶**:真的走一遍
|
||||
// `wirePanelHub` 装配,再从面板中枢里读回目录与动作白名单 —— 声明在、六个动作也登记上,
|
||||
// 才算 B4 的接线成立。
|
||||
//
|
||||
// 两条合起来才够:源码级那条拦「那一行被删掉」,这条拦「那一行还在、但调用链断了」
|
||||
// (wireBrowserPanel 被改成早返回、hub 传错、或声明/登记顺序被调反 ⇒ 只会得到一串 WARN)。
|
||||
// 这台机器上**没有**浏览器也没关系:不可用时面板仍要如实声明(`available=false`),
|
||||
// 这正是 design/09 §5.2.1 的「该面板不注册 + 显式报错」之外的正常档。
|
||||
func TestWirePanelHub_DeclaresBrowserPanel(t *testing.T) {
|
||||
t.Parallel()
|
||||
mgr := pluginmgr.NewManager(registry.New(), filepath.Join(t.TempDir(), "plugins.json"), testLog())
|
||||
defer mgr.Close(context.Background())
|
||||
b := bus.New()
|
||||
defer b.Close()
|
||||
|
||||
wirePanelHub(&app{cfg: &config.Config{DataDir: t.TempDir()}, plugins: mgr, bus: b, log: testLog()})
|
||||
hub := mgr.Panels()
|
||||
if hub == nil {
|
||||
t.Fatal("wirePanelHub 未把面板中枢接到插件管理器上")
|
||||
}
|
||||
entries, err := hub.List(auth.RoleOwner)
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, e := range entries {
|
||||
if e.PluginID == browsertools.PanelPluginID && e.Panel.ID == browsertools.PanelIDSettings {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("面板目录里没有 browser/settings(目录=%+v):B4 的挂点没生效", entries)
|
||||
}
|
||||
for _, action := range []string{
|
||||
browsertools.ActionState, browsertools.ActionFingerprint, browsertools.ActionShots,
|
||||
browsertools.ActionShoot, browsertools.ActionCheck, browsertools.ActionSaveConfig,
|
||||
} {
|
||||
if _, ok := hub.Action(browsertools.PanelPluginID, action); !ok {
|
||||
t.Errorf("动作 %q 没进白名单(声明有、没人执行 ⇒ 面板点下去只会回 501)", action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* 断言助手
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
// hasNormalizedCodeLine 报告源码里是否存在这样**一行代码**:去掉行首空白、把连续的空白
|
||||
// 折叠成一个空格之后,与 want 逐字相等。被 `//` 注释掉的行一律不算。
|
||||
//
|
||||
// 为什么要折叠空白:字段赋值在 gofmt 里按块对齐(`TransformContext:` 与
|
||||
// `DocumentMaxInjectChars:` 同一组时中间是 7 个空格,格式而非语义),逐字节比对整行会
|
||||
// 在**一次无关的字段增删触发重新对齐**时误红;而折叠后逐行相等仍然能挡住
|
||||
// 「注释掉那一行」与「别处的同名字符串」这两种假绿。
|
||||
func hasNormalizedCodeLine(src, want string) bool {
|
||||
for _, line := range strings.Split(src, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "//") {
|
||||
continue
|
||||
}
|
||||
if strings.Join(strings.Fields(line), " ") == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// findImagePart 返回请求里的第一个 image 部件。
|
||||
func findImagePart(msgs []model.Message) (model.Content, bool) {
|
||||
for _, m := range msgs {
|
||||
for _, part := range m.Content {
|
||||
if part.Type == model.ContentTypeImage {
|
||||
return part, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return model.Content{}, false
|
||||
}
|
||||
|
||||
// imagePartIsOnUserMessage 报告 image 部件是否挂在 user 消息上(协议硬要求)。
|
||||
func imagePartIsOnUserMessage(msgs []model.Message) bool {
|
||||
for _, m := range msgs {
|
||||
for _, part := range m.Content {
|
||||
if part.Type == model.ContentTypeImage {
|
||||
return m.Role == model.RoleUser
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// allText 拼出请求里全部文本(断言工具形态的文字是否进了上下文)。
|
||||
func allText(msgs []model.Message) string {
|
||||
var b strings.Builder
|
||||
for _, m := range msgs {
|
||||
b.WriteString(loopText(m))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// lastUserText 取最后一条 user 消息的文本(附件说明挂在它上面)。
|
||||
func lastUserText(msgs []model.Message) string {
|
||||
for i := len(msgs) - 1; i >= 0; i-- {
|
||||
if msgs[i].Role == model.RoleUser {
|
||||
return loopText(msgs[i])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package main
|
||||
|
||||
// 本文件是 `browser` 接线的**真机端到端**(默认不跑):从浏览器配置 → 组合根接线 →
|
||||
// 注册面 → 模型会调用的那把工具 → 真 Chrome → 截图落盘,全程不假任何一层。
|
||||
//
|
||||
// 用法(Windows / Git Bash):
|
||||
//
|
||||
// CYRENE_BROWSER_E2E=1 go test ./cmd/cyrene/ -run TestBrowserTool_RealHostEndToEnd -v -timeout 5m
|
||||
//
|
||||
// 为什么默认 Skip:它会**真的起一个浏览器**、并且要打一次外网(本机没有 Chrome 时
|
||||
// 自动 Skip)。这类验证属于「人工按需复跑」,不该混进普通 `go test`(AGENTS §10
|
||||
// 的压测纪律:不在验收里无界占机)。
|
||||
//
|
||||
// 为什么用**无头**:这是自动化验证,不该在主人的桌面上弹一个窗口;有头路径的
|
||||
// 真实验证走 `internal/capability/browser/compare_test.go` 的对照脚本(它默认无头/有头各跑一遍)。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// browserE2EEnabled 报告是否显式开启了真机 e2e。
|
||||
func browserE2EEnabled() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("CYRENE_BROWSER_E2E"))) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserTool_RealHostEndToEnd 走完整条链并断言**页面自报**的事实。
|
||||
func TestBrowserTool_RealHostEndToEnd(t *testing.T) {
|
||||
if !browserE2EEnabled() {
|
||||
t.Skip("未设 CYRENE_BROWSER_E2E=1:真机端到端默认不跑(用法见本文件顶部)")
|
||||
}
|
||||
exe, ok := browsercfg.NewProber().FindLocalBrowser()
|
||||
if !ok {
|
||||
t.Skip("本机没有 Chrome/Chromium,真机 e2e 无法进行")
|
||||
}
|
||||
cfg := wsLoadConfig(t)
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"executable": exe,
|
||||
"headless": true,
|
||||
"timeoutSeconds": 60,
|
||||
})
|
||||
writeBrowserConfig(t, cfg, string(raw))
|
||||
var out bytes.Buffer
|
||||
a := browserTestApp(t, cfg, &out)
|
||||
|
||||
a.wireBrowser()
|
||||
cap, ok := browserEntry(a)
|
||||
if !ok {
|
||||
t.Fatalf("可用时 browser 应进注册面(日志:%s)", out.String())
|
||||
}
|
||||
tool, ok := cap.Value.(sdk.Tool)
|
||||
if !ok {
|
||||
t.Fatalf("注册的值不是 sdk.Tool: %T", cap.Value)
|
||||
}
|
||||
// 收尾必须真的把浏览器关掉:用例结束会删临时目录(含 profile),Chrome 还在跑就会被
|
||||
// 占用而删不掉。这条 defer 同时是**生产缺口**的现场证明(Tool.Close 目前无人调用)。
|
||||
if closer, ok := tool.(interface{ Close() error }); ok {
|
||||
defer func() {
|
||||
if cerr := closer.Close(); cerr != nil {
|
||||
t.Errorf("Close: %v", cerr)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
t.Error("工具没实现 Close:组合根将没有关闭浏览器的挂点")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// ① 指纹自检:证明「这次真的是没在自曝」——navigator.webdriver 必须是 false。
|
||||
info, err := tool.Execute(ctx, map[string]any{"action": "info"})
|
||||
if err != nil {
|
||||
t.Fatalf("action=info: %v", err)
|
||||
}
|
||||
fp, ok := info.(browsercap.Fingerprint)
|
||||
if !ok {
|
||||
t.Fatalf("info 结果类型 = %T", info)
|
||||
}
|
||||
if fp.Webdriver {
|
||||
t.Errorf("navigator.webdriver = true:自动化痕迹没关掉(%+v)", fp)
|
||||
}
|
||||
t.Logf("指纹自检:headless=%v webdriver=%v ua=%s tz=%s lang=%s screen=%dx%d outer=%dx%d",
|
||||
fp.Headless, fp.Webdriver, fp.UserAgent, fp.Timezone, fp.Language,
|
||||
fp.ScreenWidth, fp.ScreenHeight, fp.OuterWidth, fp.OuterHeight)
|
||||
if strings.Contains(strings.ToLower(fp.UserAgent), "headlesschrome") {
|
||||
t.Logf("提示:无头档的 UA 带 HeadlessChrome —— 这是无头与有头唯一必然不同的一位")
|
||||
}
|
||||
|
||||
// ② 导航:读回页面**自报**的标题(不是我们以为的)。
|
||||
opened, err := tool.Execute(ctx, map[string]any{"action": "open", "url": "https://example.com/"})
|
||||
if err != nil {
|
||||
t.Fatalf("action=open: %v", err)
|
||||
}
|
||||
page, ok := opened.(browsercap.PageResult)
|
||||
if !ok {
|
||||
t.Fatalf("open 结果类型 = %T", opened)
|
||||
}
|
||||
if !strings.Contains(page.Title, "Example") {
|
||||
t.Errorf("标题 = %q,期望页面自报的 Example Domain", page.Title)
|
||||
}
|
||||
if page.ReadyState != "complete" {
|
||||
t.Errorf("readyState = %q", page.ReadyState)
|
||||
}
|
||||
t.Logf("导航:title=%q url=%q readyState=%q source=%q", page.Title, page.URL, page.ReadyState, page.Source)
|
||||
|
||||
// ③ 截图落盘:路径存在、非空、尺寸来自字节本身。
|
||||
shotRaw, err := tool.Execute(ctx, map[string]any{"action": "screenshot"})
|
||||
if err != nil {
|
||||
t.Fatalf("action=screenshot: %v", err)
|
||||
}
|
||||
shot := shotRaw.(map[string]any)
|
||||
path, _ := shot["path"].(string)
|
||||
if path == "" {
|
||||
t.Fatalf("截图没有路径: %#v", shot)
|
||||
}
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("截图没落盘: %v", err)
|
||||
}
|
||||
if fi.Size() == 0 {
|
||||
t.Fatal("截图是 0 字节")
|
||||
}
|
||||
if !strings.HasPrefix(path, filepath.Join(cfg.DataDir, "browser", "shots")) {
|
||||
t.Errorf("截图没落在 $DATA_DIR/browser/shots 下: %s", path)
|
||||
}
|
||||
width, _ := shot["width"].(int)
|
||||
height, _ := shot["height"].(int)
|
||||
if width <= 0 || height <= 0 {
|
||||
t.Errorf("尺寸没解出来: %#v", shot)
|
||||
}
|
||||
t.Logf("截图:path=%s bytes=%v %dx%d sha256=%v", path, shot["bytes"], width, height, shot["sha256"])
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package main
|
||||
|
||||
// 本文件钉住 `browser` 工具的**生命周期两端**(切片 X3 的收口):
|
||||
//
|
||||
// - **装配端**:接线挂点在 `wireRuntime`(与 wireVision / wireTranscode 并列),
|
||||
// **不再**借 `ioToolOptions()` 转交 —— 挂点是源码级事实,只有读源码才钉得住,
|
||||
// 因此这里照 wire_channels_test.go 的既有做法断言那一行。
|
||||
// - **关闭端**:`browser` 工具持有**进程外**资源(我们起的真 Chromium 子进程 + CDP
|
||||
// 连接 + profile 锁),进程退出不会带走它(Windows 上父进程死不会杀子进程)。
|
||||
// 常驻用例证明「关闭链真的走到了那一把工具背后的管理器」;`CYRENE_BROWSER_E2E=1`
|
||||
// 的真机用例进一步证明「真 Chrome 的调试端点真的不再应答」。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// TestWireRuntime_CallsWireBrowser 钉住**挂点归位**(切片 X3):browser 的接线由
|
||||
// `wireRuntime` 调用,位置与 wireVision / wireTranscode 并列。
|
||||
//
|
||||
// 为什么必须钉:这条调用是「浏览器工具在不在注册面里」的唯一开关,而它删掉之后
|
||||
// **没有任何运行期症状** —— 编译照过、启动照起、日志照打,只有模型那边少了一个工具
|
||||
// (本机有 Chrome 也不会注册)。这与 wire_channels_test.go 钉 VoiceTranscoder 注入
|
||||
// 是同一类判据:能力静默消失只能靠测试拦。
|
||||
func TestWireRuntime_CallsWireBrowser(t *testing.T) {
|
||||
t.Parallel()
|
||||
src := readSource(t, "wire_runtime.go")
|
||||
if !strings.Contains(src, "\ta.wireBrowser()") {
|
||||
t.Fatal("wire_runtime.go 里找不到 `a.wireBrowser()` 这一行 —— browser 工具不会被装配," +
|
||||
"模型面里静默少一个工具(挂点应与之并列 wireVision / wireTranscode)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppClose_ClosesBrowserSession 钉住**关闭链真的关掉了浏览器管理器**。
|
||||
//
|
||||
// 判据不是「Close 被调过」(那只能证明调用点存在),而是**关闭之后的第一个动作**:
|
||||
// 管理器已进入 closed 态 ⇒ 如实报 ErrNoSession,而不是偷偷再拉起一个浏览器
|
||||
// (拉起会得到 ErrLaunch,两者一眼可分)。
|
||||
func TestAppClose_ClosesBrowserSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t)
|
||||
// 可执行文件只需**存在**:本用例不启动它,只验证关闭链把管理器关停了。
|
||||
exe := filepath.Join(t.TempDir(), "fake-browser.exe")
|
||||
if err := os.WriteFile(exe, []byte("x"), 0o700); err != nil {
|
||||
t.Fatalf("造假可执行文件: %v", err)
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"executable": exe})
|
||||
writeBrowserConfig(t, cfg, string(raw))
|
||||
var out bytes.Buffer
|
||||
a := browserTestApp(t, cfg, &out)
|
||||
|
||||
a.wireBrowser()
|
||||
cap, ok := browserEntry(a)
|
||||
if !ok {
|
||||
t.Fatalf("可用时 browser 应进注册面(日志:%s)", out.String())
|
||||
}
|
||||
tool, ok := cap.Value.(sdk.Tool)
|
||||
if !ok {
|
||||
t.Fatalf("注册的值不是 sdk.Tool: %T", cap.Value)
|
||||
}
|
||||
|
||||
a.close(context.Background())
|
||||
|
||||
if !strings.Contains(out.String(), "browser tool closed") {
|
||||
t.Errorf("关闭链没有留下收尾记录(日志:%s)", out.String())
|
||||
}
|
||||
if _, err := tool.Execute(context.Background(), map[string]any{"action": "info"}); !errors.Is(err, browsercap.ErrNoSession) {
|
||||
t.Fatalf("关闭链没有关掉浏览器管理器:err = %v,want ErrNoSession(ErrLaunch 说明它又拉起了一个)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppClose_RealChromeStopsOnShutdown 是真机端到端(默认 Skip):走**组合根的真实
|
||||
// 关闭链**关掉一个**真的在跑**的 Chrome,并验证它的调试端点不再应答。
|
||||
//
|
||||
// 用法(Windows / Git Bash):
|
||||
//
|
||||
// CYRENE_BROWSER_E2E=1 go test ./cmd/cyrene/ -run TestAppClose_RealChromeStopsOnShutdown -v -timeout 5m
|
||||
//
|
||||
// 为什么默认 Skip:它会真的起一个浏览器(headless,不弹窗),属于「人工按需复跑」,
|
||||
// 不该混进普通 `go test`(AGENTS §10 的压测纪律:验收里不无界占机)。
|
||||
func TestAppClose_RealChromeStopsOnShutdown(t *testing.T) {
|
||||
if !browserE2EEnabled() {
|
||||
t.Skip("未设 CYRENE_BROWSER_E2E=1:真机端到端默认不跑(用法见本文件顶部)")
|
||||
}
|
||||
exe, ok := browsercfg.NewProber().FindLocalBrowser()
|
||||
if !ok {
|
||||
t.Skip("本机没有 Chrome/Chromium,真机 e2e 无法进行")
|
||||
}
|
||||
cfg := wsLoadConfig(t)
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"executable": exe,
|
||||
"headless": true,
|
||||
"timeoutSeconds": 60,
|
||||
"idleTimeoutSeconds": 600,
|
||||
})
|
||||
writeBrowserConfig(t, cfg, string(raw))
|
||||
var out bytes.Buffer
|
||||
a := browserTestApp(t, cfg, &out)
|
||||
|
||||
a.wireBrowser()
|
||||
cap, ok := browserEntry(a)
|
||||
if !ok {
|
||||
t.Fatalf("可用时 browser 应进注册面(日志:%s)", out.String())
|
||||
}
|
||||
tool, ok := cap.Value.(sdk.Tool)
|
||||
if !ok {
|
||||
t.Fatalf("注册的值不是 sdk.Tool: %T", cap.Value)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// ① 起一个真浏览器(指纹自检走 CDP,无需外网),并取回它的调试端点。
|
||||
if _, err := tool.Execute(ctx, map[string]any{"action": "info"}); err != nil {
|
||||
t.Fatalf("action=info(起真浏览器): %v", err)
|
||||
}
|
||||
endpoint, ok := browsercap.DebugEndpoint(browsercfg.DefaultProfileDir(cfg.DataDir))
|
||||
if !ok {
|
||||
t.Fatalf("会话已建立,但 profile %s 下没有端口记录(显式端口由我们自己记,见 portrecord.go)",
|
||||
browsercfg.DefaultProfileDir(cfg.DataDir))
|
||||
}
|
||||
if !endpointAlive(endpoint) {
|
||||
t.Fatalf("会话已建立但调试端点 %s 不应答:本用例的前提不成立", endpoint)
|
||||
}
|
||||
t.Logf("真机前置:浏览器在跑,endpoint=%s", endpoint)
|
||||
|
||||
// ② 走生产路径关闭:组合根的 a.close(不是直接 tool.Close)。
|
||||
shutCtx, shutCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer shutCancel()
|
||||
a.close(shutCtx)
|
||||
|
||||
// ③ 进程外的那个浏览器必须真的没了:调试端点不再应答(轮询给一点收尾时间)。
|
||||
if !waitEndpointGone(endpoint, 15*time.Second) {
|
||||
t.Fatalf("a.close 之后 %s 仍在应答:浏览器没被关掉(孤儿 Chrome 会占着 profile 锁)", endpoint)
|
||||
}
|
||||
// ④ 管理器进入 closed 态:再调动作如实报「已关闭」,不是偷偷重起一个。
|
||||
if _, err := tool.Execute(context.Background(), map[string]any{"action": "info"}); !errors.Is(err, browsercap.ErrNoSession) {
|
||||
t.Errorf("关闭后 err = %v, want ErrNoSession", err)
|
||||
}
|
||||
t.Logf("真机关闭链:endpoint=%s 已停,管理器态=closed(日志尾部:%s)",
|
||||
endpoint, tailOfLog(out.String()))
|
||||
}
|
||||
|
||||
// endpointAlive 报告调试端点是否在应答(一次 /json/version)。
|
||||
func endpointAlive(endpoint string) bool {
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
resp, err := client.Get(endpoint + "/json/version")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
return resp.StatusCode == http.StatusOK
|
||||
}
|
||||
|
||||
// waitEndpointGone 等端点停止应答,最多等 budget。
|
||||
func waitEndpointGone(endpoint string, budget time.Duration) bool {
|
||||
deadline := time.Now().Add(budget)
|
||||
for {
|
||||
if !endpointAlive(endpoint) {
|
||||
return true
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return false
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// tailOfLog 取日志末尾若干字符,供真机用例把现场证据打进 t.Log。
|
||||
func tailOfLog(s string) string {
|
||||
const keep = 400
|
||||
if len(s) <= keep {
|
||||
return s
|
||||
}
|
||||
return s[len(s)-keep:]
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package main
|
||||
|
||||
// 本文件是 design/28 §8 **B4(M3 面板入口)的组合根接线**:把 `browser` 插件声明的
|
||||
// 面板页与六个动作登记到插件管理器上(design/09 §5.2.1 的「插件管理器 = 唯一中枢」)。
|
||||
//
|
||||
// 五条口径:
|
||||
//
|
||||
// 1. **不开任何路由**:动作走宿主的通用动作端点
|
||||
// `POST /api/v1/admin/plugins/{id}/actions/{action}`(PLG-2),插件只给动作名;
|
||||
// 2. **面板声明与动作处理器同源**:动作名常量都在 `plugins/tools/browser/panel.go`,
|
||||
// 本文件只做「名字 → 处理器」的映射,不另抄一份名字;
|
||||
// 3. **读面不起浏览器**:`plugin.getBrowserState` / `plugin.listShots` /
|
||||
// `plugin.getFingerprint` 只读内存事实(`state.go` 口径 1)。真正会起浏览器的只有两个
|
||||
// **写**动作(`plugin.shootPage` / `plugin.checkFingerprint`),它们按 §5.4.1 只对
|
||||
// owner/admin 可见可调;
|
||||
// 4. **保存即热重载**:`plugin.saveBrowserConfig` 落 `$DATA_DIR/config/plugins/browser.json`
|
||||
// 后**重建**工具并替换注册表条目(`registry.ReplaceTool`),所以面板文案说的
|
||||
// 「保存后立即生效」是真话(design/09 §5.5 的同一条口径);
|
||||
// 5. **失败要能分类**:一律经 `pluginmgr.ActionError` 交出「错误码 + 文案 + HTTP 状态」,
|
||||
// 绝不回「已保存」而实际没生效(§5.4.6)。
|
||||
//
|
||||
// ⚠️ **接线状态(如实)**:本文件提供 `wireBrowserPanel(hub, a, log)`,生产接线需要在
|
||||
// `wire_frontend.go` 的 `wirePanelHub` 里加一行(与 search 的声明并列):
|
||||
//
|
||||
// wireBrowserPanel(hub, a, log)
|
||||
//
|
||||
// 本片(Y4)的文件面**不含** `wire_frontend.go`,因此那一行由主会话落地;落地前本文件
|
||||
// 只在测试里被使用(`wire_browser_panel_test.go` 用真实 hub + 假浏览器验证「声明过校验、
|
||||
// 六个动作真能分发」)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
|
||||
)
|
||||
|
||||
// browserPanelLogSource 是本文件日志的来源标签(AGENTS §10:禁止无来源裸日志)。
|
||||
const browserPanelLogSource = "cmd/cyrene/wire_browser_panel"
|
||||
|
||||
// 动作失败码(宿主级,只在 browser 面板这组文件里使用)。领域自己的码来自
|
||||
// `browsercap` 的分类原因码。
|
||||
const (
|
||||
// browserCodeConfigUnreadable 报告面板配置存在但读不出来(损坏 / 权限)。
|
||||
browserCodeConfigUnreadable = "config_unreadable"
|
||||
// browserCodeConfigRejected 报告这次保存的配置**非法**(越界 / 端点非法):没落盘。
|
||||
browserCodeConfigRejected = "config_rejected"
|
||||
// browserCodeConfigWriteFailed 报告配置写不进去(磁盘 / 权限)。
|
||||
browserCodeConfigWriteFailed = "config_write_failed"
|
||||
// browserCodeUnavailable 报告本机现在没有可用浏览器来源(工具没进能力面)。
|
||||
browserCodeUnavailable = "browser_unavailable"
|
||||
// browserCodeNoSession 报告「当前没有打开的页面」:拍一张没有目标(面板不替模型开页)。
|
||||
browserCodeNoSession = "browser_session_not_open"
|
||||
// browserCodeActionFailed 报告浏览器动作执行失败(分类原因见日志)。
|
||||
browserCodeActionFailed = "browser_action_failed"
|
||||
)
|
||||
|
||||
// wireBrowserPanel 声明 browser 的面板页并把六个动作登记到面板中枢。
|
||||
//
|
||||
// 声明被拒只告警、不阻断启动(与 search 的处置一致:§5.2.1 的口径是「该面板不注册 +
|
||||
// 显式报错」,不是「宿主陪葬」)。登记失败同理(面板会以 501 如实作答)。
|
||||
func wireBrowserPanel(hub *pluginmgr.Panels, a *app, log *slog.Logger) {
|
||||
if hub == nil || a == nil {
|
||||
return
|
||||
}
|
||||
env := browserPanelEnv{
|
||||
app: a,
|
||||
hub: hub,
|
||||
log: browserPanelLogger(log),
|
||||
path: browsercfg.DefaultConfigPath(resolveDataDir(a.cfg)),
|
||||
}
|
||||
env.declare()
|
||||
if err := hub.RegisterActions(browsertools.PanelPluginID, env.handlers()); err != nil {
|
||||
env.log.Error("browser panel actions not registered; panel actions will answer 501",
|
||||
"pluginId", browsertools.PanelPluginID, "error", err)
|
||||
return
|
||||
}
|
||||
env.warnImpliedButMissing()
|
||||
}
|
||||
|
||||
// browserPanelLogger 给日志补上本文件的来源标签,并兜住 nil(`logger.WithSource(nil, …)`
|
||||
// 返回 nil,而本文件多处直接 `env.log.Info/Warn` —— 对 nil 的 *slog.Logger 调用会 panic)。
|
||||
// 与 `plugins/tools/browser` 的 discardLog 同一手法:nil 的语义是「不输出」,不是「不标注」。
|
||||
func browserPanelLogger(base *slog.Logger) *slog.Logger {
|
||||
if base == nil {
|
||||
return logger.WithSource(slog.New(slog.NewTextHandler(io.Discard, nil)), browserPanelLogSource)
|
||||
}
|
||||
return logger.WithSource(base, browserPanelLogSource)
|
||||
}
|
||||
|
||||
// browserPanelEnv 是六个动作共享的接线事实(一次装配、多动作复用)。
|
||||
//
|
||||
// 刻意只装**在多个动作间共享**的东西(日志出口、配置文件路径、面板中枢),每次调用各自
|
||||
// 读文件/读注册面:动作之间不共享「当前配置」这份可变状态,就不会出现「A 动作读到的是
|
||||
// B 动作刚改之前的那份」。
|
||||
type browserPanelEnv struct {
|
||||
app *app
|
||||
hub *pluginmgr.Panels
|
||||
log *slog.Logger
|
||||
path string
|
||||
}
|
||||
|
||||
// handlers 返回本插件声明的**六个动作**的处理器(键 = 声明里的动作名,同源引用常量)。
|
||||
func (e browserPanelEnv) handlers() map[string]pluginmgr.ActionHandler {
|
||||
return map[string]pluginmgr.ActionHandler{
|
||||
browsertools.ActionState: e.state,
|
||||
browsertools.ActionShots: e.shots,
|
||||
browsertools.ActionFingerprint: e.fingerprint,
|
||||
browsertools.ActionShoot: e.shoot,
|
||||
browsertools.ActionCheck: e.check,
|
||||
browsertools.ActionSaveConfig: e.saveConfig,
|
||||
}
|
||||
}
|
||||
|
||||
// declare 用**当前盘上的配置**重新声明面板(表单默认值 = 现状)。保存后也会再调一次,
|
||||
// 使面板上的值刷新成刚落盘的那份(前端提交成功后会重取快照)。
|
||||
func (e browserPanelEnv) declare() {
|
||||
cfg, err := e.currentConfig()
|
||||
if err != nil {
|
||||
e.log.Warn("browser panel declared with default config values",
|
||||
"path", e.path, "error", err)
|
||||
cfg = browsercfg.Defaults(resolveDataDir(e.app.cfg))
|
||||
}
|
||||
available := e.tool() != nil
|
||||
accepted, derr := e.hub.Declare(browsertools.PanelPluginID, browsertools.AdminPanels(browsertools.PanelInput{
|
||||
Config: cfg,
|
||||
Available: available,
|
||||
Source: e.source(),
|
||||
}))
|
||||
if derr != nil {
|
||||
e.log.Warn("browser panel declaration rejected",
|
||||
"pluginId", browsertools.PanelPluginID, "accepted", accepted, "error", derr)
|
||||
return
|
||||
}
|
||||
e.log.Info("browser panel declared",
|
||||
"pluginId", browsertools.PanelPluginID, "panels", accepted, "available", available)
|
||||
}
|
||||
|
||||
// warnImpliedButMissing 把「声明里有、登记里没有」的动作如实报出来(端点对它的结算
|
||||
// 就是 501)。它是 §5.2.1「表里写了一个没实现的动作,review 时一眼可见」的运行期版本。
|
||||
func (e browserPanelEnv) warnImpliedButMissing() {
|
||||
registered := make(map[string]bool)
|
||||
for _, name := range e.hub.ActionHandlerNames(browsertools.PanelPluginID) {
|
||||
registered[name] = true
|
||||
}
|
||||
for _, name := range e.hub.DeclaredActions(browsertools.PanelPluginID) {
|
||||
if !registered[name] {
|
||||
e.log.Warn("declared browser panel action has no handler; it will answer 501",
|
||||
"pluginId", browsertools.PanelPluginID, "action", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// currentConfig 读盘上的 browser 配置(缺失 ⇒ 默认),已 Normalize。
|
||||
func (e browserPanelEnv) currentConfig() (browsercfg.Config, error) {
|
||||
dataDir := resolveDataDir(e.app.cfg)
|
||||
fileCfg, err := browsercfg.Load(e.path, dataDir)
|
||||
if err != nil {
|
||||
return browsercfg.Config{}, err
|
||||
}
|
||||
out := browsercfg.Defaults(dataDir)
|
||||
if fileCfg != nil {
|
||||
out = *fileCfg
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// tool 从能力面取回 browser 工具(没注册 ⇒ nil:面板要如实说明「这台机器上没有浏览器」,
|
||||
// 而不是报一个空表把「没接线」伪装成「没有数据」)。
|
||||
func (e browserPanelEnv) tool() *browsertools.Tool {
|
||||
if e.app == nil || e.app.caps == nil {
|
||||
return nil
|
||||
}
|
||||
entry, err := e.app.caps.Get(browsertools.ToolName)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
tool, _ := entry.Value.(*browsertools.Tool)
|
||||
return tool
|
||||
}
|
||||
|
||||
// source 返回来源描述(只进面板描述文案)。
|
||||
func (e browserPanelEnv) source() string {
|
||||
if tool := e.tool(); tool != nil {
|
||||
return tool.State().Source
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// state 是 `plugin.getBrowserState`:运行状态只读事实面的一行。**不启动浏览器**。
|
||||
func (e browserPanelEnv) state(_ context.Context, _ pluginmgr.ActionRequest) (any, error) {
|
||||
cfg, err := e.currentConfig()
|
||||
if err != nil {
|
||||
cfg = browsercfg.Defaults(resolveDataDir(e.app.cfg))
|
||||
}
|
||||
snap := browsercapState(e.tool(), cfg)
|
||||
return map[string]any{"rows": browsertools.StateRows(snap)}, nil
|
||||
}
|
||||
|
||||
// fingerprint 是 `plugin.getFingerprint`:**上一次**自检的读数(未自检过 ⇒ 如实说「未自检」)。
|
||||
func (e browserPanelEnv) fingerprint(_ context.Context, _ pluginmgr.ActionRequest) (any, error) {
|
||||
snap := browsercapState(e.tool(), browsercfg.Config{})
|
||||
return map[string]any{"rows": browsertools.FingerprintRows(snap.Fingerprint, snap.FingerprintAt)}, nil
|
||||
}
|
||||
|
||||
// shots 是 `plugin.listShots`:最近截图统计(**不含路径与内容**)。
|
||||
func (e browserPanelEnv) shots(_ context.Context, _ pluginmgr.ActionRequest) (any, error) {
|
||||
snap := browsercapState(e.tool(), browsercfg.Config{})
|
||||
return map[string]any{"rows": browsertools.ShotRows(snap.Shots)}, nil
|
||||
}
|
||||
|
||||
// shoot 是 `plugin.shootPage`(写动作,owner-only):拍一张**当前页面**。
|
||||
//
|
||||
// 为什么需要会话已在跑:面板**不替模型开页**(否则它就成了「任意 URL 的截图器」,
|
||||
// design/28 §1 M3 明确否掉的那个面)。主人先在对话里让它打开某页(或人工在有头窗口里
|
||||
// 接管),再从面板拍一张 —— 真实排障流程就是这样。
|
||||
func (e browserPanelEnv) shoot(ctx context.Context, _ pluginmgr.ActionRequest) (any, error) {
|
||||
tool := e.tool()
|
||||
if tool == nil {
|
||||
return nil, pluginmgr.NewActionError(browserCodeUnavailable,
|
||||
"本机没有可用的浏览器来源(未探测到 Chrome/Chromium,也没有在跑的 CDP 端点)",
|
||||
http.StatusConflict, nil)
|
||||
}
|
||||
if !tool.State().SessionLive {
|
||||
return nil, pluginmgr.NewActionError(browserCodeNoSession,
|
||||
"浏览器当前没有打开的页面:先让她打开一个页面(或在有头窗口里人工接管),再回面板拍一张",
|
||||
http.StatusConflict, nil)
|
||||
}
|
||||
res, err := tool.Execute(ctx, map[string]any{"action": "screenshot"})
|
||||
if err != nil {
|
||||
e.log.Warn("panel screenshot failed", "error", err)
|
||||
return nil, pluginmgr.NewActionError(browserCodeActionFailed,
|
||||
"截图失败:"+err.Error(), http.StatusBadGateway, err)
|
||||
}
|
||||
return map[string]any{"shot": res}, nil
|
||||
}
|
||||
|
||||
// check 是 `plugin.checkFingerprint`(写动作,owner-only):真起一次浏览器读指纹。
|
||||
// 它是本页唯一「更新只读事实面」的写动作 —— 读面本身绝不启动浏览器(口径 1)。
|
||||
func (e browserPanelEnv) check(ctx context.Context, _ pluginmgr.ActionRequest) (any, error) {
|
||||
tool := e.tool()
|
||||
if tool == nil {
|
||||
return nil, pluginmgr.NewActionError(browserCodeUnavailable,
|
||||
"本机没有可用的浏览器来源,无法自检", http.StatusConflict, nil)
|
||||
}
|
||||
if _, err := tool.Execute(ctx, map[string]any{"action": "info"}); err != nil {
|
||||
e.log.Warn("panel fingerprint check failed", "error", err)
|
||||
return nil, pluginmgr.NewActionError(browserCodeActionFailed,
|
||||
"指纹自检失败:"+err.Error(), http.StatusBadGateway, err)
|
||||
}
|
||||
snap := tool.State()
|
||||
return map[string]any{
|
||||
"rows": browsertools.FingerprintRows(snap.Fingerprint, snap.FingerprintAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// saveConfig 是 `plugin.saveBrowserConfig`(写动作,owner-only):落盘 + 热重载。
|
||||
//
|
||||
// 事务形状与 web-search 的热重载同族(`websearch_reload.go`):
|
||||
//
|
||||
// ① 快照文件 → ② 校验并落盘 → ③ 按落盘后的配置重建工具并替换注册表条目;
|
||||
// ④ **保存前本来有可用工具**而 ③ 失败 ⇒ 回滚文件 + 409:绝不让一次保存把「盘上的配置」
|
||||
// 与「跑着的那个浏览器」静默拆开(那正是 design/09 §5.4.6 点名要防的「报成功但没生效」)。
|
||||
// 保存前本来**没有**可用工具(例如第一次配一个还没起来的容器)⇒ 文件留下、如实回
|
||||
// `applied=false` + 原因码:那种情形没有能力可丢,报错反而会让主人**没法先配好**。
|
||||
func (e browserPanelEnv) saveConfig(ctx context.Context, req pluginmgr.ActionRequest) (any, error) {
|
||||
patch, err := browserConfigPatch(req.Payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cur, err := e.currentConfig()
|
||||
if err != nil {
|
||||
return nil, pluginmgr.NewActionError(browserCodeConfigUnreadable,
|
||||
"面板配置读不出来,本次**没有**改动它;详见服务端日志", http.StatusInternalServerError, err)
|
||||
}
|
||||
next, err := applyBrowserConfigPatch(cur, patch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hadTool := e.tool() != nil
|
||||
snap, snapExisted, serr := readConfigSnapshot(e.path)
|
||||
if serr != nil {
|
||||
return nil, pluginmgr.NewActionError(browserCodeConfigUnreadable,
|
||||
"面板配置读不出来,本次**没有**改动它", http.StatusInternalServerError, serr)
|
||||
}
|
||||
if err := browsercfg.Save(e.path, resolveDataDir(e.app.cfg), next); err != nil {
|
||||
return nil, pluginmgr.NewActionError(browserCodeConfigWriteFailed,
|
||||
"配置写不进去(磁盘或权限)", http.StatusInternalServerError, err)
|
||||
}
|
||||
applied, registered, reason := e.app.replaceBrowserTool(e.log)
|
||||
if !applied && hadTool {
|
||||
if rerr := restoreConfigSnapshot(e.path, snap, snapExisted); rerr != nil {
|
||||
e.log.Error("browser config rollback failed after a save that did not apply",
|
||||
"path", e.path, "reason", reason, "rollbackError", rerr)
|
||||
return nil, pluginmgr.NewActionError(browserCodeConfigWriteFailed,
|
||||
"这份配置没能生效,且回滚失败:盘上的文件可能已经变了(详见服务端日志)",
|
||||
http.StatusInternalServerError, rerr)
|
||||
}
|
||||
return nil, pluginmgr.NewActionError(browserCodeConfigRejected,
|
||||
"这份配置没能生效(已回滚到保存前那一份):"+reason, http.StatusConflict, nil)
|
||||
}
|
||||
e.declare()
|
||||
return map[string]any{
|
||||
"saved": true,
|
||||
"applied": applied,
|
||||
"registered": registered,
|
||||
"reason": reason,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// browsercapState 取只读事实快照:没有工具时用**盘上的配置**兜底(面板仍要显示现状),
|
||||
// 而不是回一个空对象让页面显示一片「—」。
|
||||
func browsercapState(tool *browsertools.Tool, cfg browsercfg.Config) browsercap.StateSnapshot {
|
||||
if tool != nil {
|
||||
return tool.State()
|
||||
}
|
||||
return browsercap.StateSnapshot{
|
||||
Available: false,
|
||||
Headless: cfg.Headless,
|
||||
ViewportWidth: cfg.ViewportWidth,
|
||||
ViewportHeight: cfg.ViewportHeight,
|
||||
ProfileDir: cfg.ProfileDir,
|
||||
Concurrency: cfg.Concurrency,
|
||||
IdleTimeoutSeconds: cfg.IdleTimeoutSeconds,
|
||||
ScreenshotMaxBytes: cfg.ScreenshotMaxBytes,
|
||||
}
|
||||
}
|
||||
|
||||
// decodeUserValues 把动作载荷里的 `values` 取出来(前端表单的提交形状是 `{values:{…}}`)。
|
||||
// 形状不对时返回结构化错误,绝不「当成空补丁」静默保存一份什么都没改的配置。
|
||||
func decodeUserValues(payload any) (map[string]any, error) {
|
||||
m, ok := payload.(map[string]any)
|
||||
if !ok {
|
||||
return nil, pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload,
|
||||
"请求体不是对象:表单提交的形状是 {\"values\":{…}}", http.StatusBadRequest, nil)
|
||||
}
|
||||
raw, exists := m["values"]
|
||||
if !exists || raw == nil {
|
||||
return nil, pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload,
|
||||
"请求体缺少 values(没有要保存的内容)", http.StatusBadRequest, nil)
|
||||
}
|
||||
values, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload,
|
||||
"values 不是对象", http.StatusBadRequest, nil)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// requireString 取一个字符串补丁值(空串是合法值:清空 profile/端点即「回落自动探测」)。
|
||||
func requireString(key string, raw any) (string, error) {
|
||||
s, ok := raw.(string)
|
||||
if !ok {
|
||||
return "", pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload,
|
||||
fmt.Sprintf("字段 %s 需要一个字符串", key), http.StatusBadRequest, nil)
|
||||
}
|
||||
return strings.TrimSpace(s), nil
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package main
|
||||
|
||||
// 本文件是 browser 面板的**数据面**(切片 Y4 / B4):把动作载荷翻成配置补丁、把配置
|
||||
// 写盘/回滚,以及保存之后的**热重载**(重建工具 + 换注册表条目)。
|
||||
//
|
||||
// 拆出来的理由同 web-search 那一组:`wire_browser_panel.go` 只留「名字 → 处理器」的接线,
|
||||
// 载荷解析与文件事务各自成文件,函数才守得住 ≤80 行。
|
||||
//
|
||||
// 两条口径:
|
||||
//
|
||||
// 1. **补丁是补丁**:前端提交的是 `{values:{…}}`,而 `planSubmit` 只发**改动过的**字段
|
||||
// (见 `frontend/web/src/admin/schema/values.ts`)⇒ 没出现的键一律**保持原值**,
|
||||
// 绝不把「没提到」当成「清空」(那是静默清空的经典事故,design/09 §5.4.2);
|
||||
// 2. **回滚是凭据**:保存前先把盘上那一份读进内存,热重载失败就写回去 —— 绝不把后端
|
||||
// 留在「盘上这份配置让它下次起不来」的状态。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
|
||||
)
|
||||
|
||||
// browserConfigRollbackMode 是回滚写回时的文件权限:与 `browsercfg.Save` 的 0600 同口径
|
||||
// (那份文件带显式 CDP 端点与 profile 位置)。硬编码 0600 而不是引用对方的常量,是因为
|
||||
// 变化方向是「收紧」,不存在比 0600 更松的风险。
|
||||
const browserConfigRollbackMode os.FileMode = 0o600
|
||||
|
||||
// browserConfigPatch 解析表单提交的载荷(`{values:{…}}`)并做**形状**校验:
|
||||
// 只接受本面板声明过的键,未知键一律拒(**绝不静默忽略** —— 静默忽略会让「我改了但它没生效」
|
||||
// 变成查不出的谜,design/09 §5.2.1 的同一条口径)。
|
||||
func browserConfigPatch(payload any) (map[string]any, error) {
|
||||
values, err := decodeUserValues(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
known := map[string]bool{
|
||||
browsertools.FieldHeadless: true,
|
||||
browsertools.FieldExecutable: true,
|
||||
browsertools.FieldCDPEndpoint: true,
|
||||
browsertools.FieldProfileDir: true,
|
||||
browsertools.FieldViewportWidth: true,
|
||||
browsertools.FieldViewportHeight: true,
|
||||
browsertools.FieldIdleTimeout: true,
|
||||
browsertools.FieldConcurrency: true,
|
||||
}
|
||||
out := make(map[string]any, len(values))
|
||||
for key, raw := range values {
|
||||
if !known[key] {
|
||||
return nil, pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload,
|
||||
fmt.Sprintf("未知字段 %q:本面板只接受 %d 个已声明字段", key, len(known)),
|
||||
http.StatusBadRequest, nil)
|
||||
}
|
||||
out[key] = raw
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// applyBrowserConfigPatch 把补丁合进当前配置。未出现的键保持原值(见文件头口径 1)。
|
||||
//
|
||||
// 类型不匹配一律报 400(`values` 已由前端按声明类型还原:number → 数字、switch → 布尔);
|
||||
// 越界值交给 `browsercfg.Validate` 判(它只判**原始值**,不 Normalize —— 悄悄改小会让
|
||||
// 面板显示的与生效的不是一回事)。
|
||||
func applyBrowserConfigPatch(cur browsercfg.Config, patch map[string]any) (browsercfg.Config, error) {
|
||||
out := cur
|
||||
for key, raw := range patch {
|
||||
var err error
|
||||
switch key {
|
||||
case browsertools.FieldHeadless:
|
||||
out.Headless, err = patchBool(key, raw)
|
||||
case browsertools.FieldExecutable:
|
||||
out.Executable, err = requireString(key, raw)
|
||||
case browsertools.FieldCDPEndpoint:
|
||||
out.CDPEndpoint, err = requireString(key, raw)
|
||||
case browsertools.FieldProfileDir:
|
||||
out.ProfileDir, err = requireString(key, raw)
|
||||
case browsertools.FieldViewportWidth:
|
||||
out.ViewportWidth, err = patchInt(key, raw)
|
||||
case browsertools.FieldViewportHeight:
|
||||
out.ViewportHeight, err = patchInt(key, raw)
|
||||
case browsertools.FieldIdleTimeout:
|
||||
out.IdleTimeoutSeconds, err = patchInt(key, raw)
|
||||
case browsertools.FieldConcurrency:
|
||||
out.Concurrency, err = patchInt(key, raw)
|
||||
default:
|
||||
err = fmt.Errorf("未知字段 %q", key)
|
||||
}
|
||||
if err != nil {
|
||||
return browsercfg.Config{}, pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload,
|
||||
fmt.Sprintf("字段 %s 的取值不合法:%v", key, err), http.StatusBadRequest, err)
|
||||
}
|
||||
}
|
||||
if verr := out.Validate(); verr != nil {
|
||||
return browsercfg.Config{}, pluginmgr.NewActionError(browserCodeConfigRejected,
|
||||
"这份配置超出硬上限,**没有**落盘:"+verr.Error(), http.StatusConflict, verr)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// patchBool 取一个布尔补丁值。
|
||||
func patchBool(key string, raw any) (bool, error) {
|
||||
v, ok := raw.(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("需要布尔值")
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// patchInt 取一个数值补丁值(JSON 数字解出来是 float64;整数字符串也认,因为
|
||||
// 「留空/清空」在某些控件上会以空串抵达)。
|
||||
func patchInt(key string, raw any) (int, error) {
|
||||
switch v := raw.(type) {
|
||||
case float64:
|
||||
if v != float64(int(v)) {
|
||||
return 0, fmt.Errorf("需要整数")
|
||||
}
|
||||
return int(v), nil
|
||||
case int:
|
||||
return v, nil
|
||||
case string:
|
||||
trimmed := strings.TrimSpace(v)
|
||||
if trimmed == "" {
|
||||
// 清空 = 回落默认(Normalize 的口径:≤0 ⇒ 用默认值)。
|
||||
return 0, nil
|
||||
}
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(trimmed, "%d", &n); err != nil {
|
||||
return 0, fmt.Errorf("需要整数")
|
||||
}
|
||||
return n, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("需要整数")
|
||||
}
|
||||
}
|
||||
|
||||
// readConfigSnapshot 读一份文件快照(回滚凭据)。文件不存在时返回 (nil, false, nil) ——
|
||||
// 那是「保存前没有配置文件」这种合法状态,回滚时要把它恢复成「没有文件」。
|
||||
func readConfigSnapshot(path string) ([]byte, bool, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, fmt.Errorf("读配置快照 %s: %w", path, err)
|
||||
}
|
||||
return raw, true, nil
|
||||
}
|
||||
|
||||
// restoreConfigSnapshot 把文件恢复到快照那一份;快照本来不存在 ⇒ 删掉它。
|
||||
func restoreConfigSnapshot(path string, raw []byte, existed bool) error {
|
||||
if !existed {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("删除配置 %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := os.WriteFile(path, raw, browserConfigRollbackMode); err != nil {
|
||||
return fmt.Errorf("回写配置 %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// replaceBrowserTool 按**落盘后的配置**重建 browser 工具并替换能力面里的条目
|
||||
// (design/09 §5.5 的口径:面板保存后下一轮即用新接线,不必重启)。
|
||||
//
|
||||
// 返回 (applied, registered, reason):
|
||||
// - applied=true 报告工具真的被换掉/加上(reason 为空);
|
||||
// - applied=false 时 reason 是分类原因码(`browsercfg.Reason*` 或本文件的
|
||||
// "replace-failed"/"register-failed"/"no-registry"),调用方据此决定回滚与文案。
|
||||
//
|
||||
// 两种「不生效」都**不改动既有的工具**:没有可用来源时工具不该存在(判据 1 的诚实口径),
|
||||
// 但也没理由因为一次保存把已经在跑的那一把弄没(那是「保存一次就少一个能力」的事故形状)。
|
||||
func (a *app) replaceBrowserTool(log *slog.Logger) (bool, bool, string) {
|
||||
if a == nil || a.caps == nil {
|
||||
return false, false, "no-registry"
|
||||
}
|
||||
existing, gerr := a.caps.Get(browsertools.ToolName)
|
||||
haveExisting := gerr == nil
|
||||
carry := shotOutboxOf(existing.Value)
|
||||
|
||||
build := a.buildBrowserTool(carry)
|
||||
if !build.available {
|
||||
// 新配置下没有可用来源:工具**不注册**(design/28 §7 判据 1 的诚实口径)。
|
||||
log.Warn("browser tool not rebuilt after config save",
|
||||
"reason", build.reason, "detail", build.detail,
|
||||
"configPath", build.configPath, "effect", browserNotRegisteredEffect)
|
||||
return false, haveExisting, build.reason
|
||||
}
|
||||
if haveExisting {
|
||||
if err := a.caps.ReplaceTool(build.tool); err != nil {
|
||||
closeBrowserManager(log, build.mgr)
|
||||
log.Warn("browser tool replace failed", "error", err)
|
||||
return false, true, "replace-failed"
|
||||
}
|
||||
closeOldBrowserTool(log, existing.Value)
|
||||
return true, true, ""
|
||||
}
|
||||
if err := a.caps.RegisterTool(build.tool); err != nil {
|
||||
closeBrowserManager(log, build.mgr)
|
||||
log.Warn("browser tool register failed after config save", "error", err)
|
||||
return false, false, "register-failed"
|
||||
}
|
||||
log.Info("browser tool registered after config save",
|
||||
"source", build.source, "headless", build.headless, "configSource", build.configSource)
|
||||
return true, true, ""
|
||||
}
|
||||
|
||||
// shotOutboxOf 取旧条目上的附件箱(重建时沿用:模型刚拍完还没送出去的那一张不能丢)。
|
||||
// 用结构断言而不是具体类型:换工具实现只要还暴露 ShotOutbox 就接得上。
|
||||
func shotOutboxOf(value any) *browsertools.ShotOutbox {
|
||||
holder, ok := value.(interface {
|
||||
ShotOutbox() *browsertools.ShotOutbox
|
||||
})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return holder.ShotOutbox()
|
||||
}
|
||||
|
||||
// closeOldBrowserTool 关掉被换下来的工具(它持有进程外资源:我们起的真 Chromium、CDP 连接、
|
||||
// profile 锁)。不关就是每次保存泄漏一个浏览器进程。
|
||||
func closeOldBrowserTool(log *slog.Logger, value any) {
|
||||
closer, ok := value.(interface{ Close() error })
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := closer.Close(); err != nil {
|
||||
log.Warn("old browser tool close failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// closeBrowserManager 关掉刚建好但没能接上的管理器(注册被拒 / 替换失败时的收尾)。
|
||||
func closeBrowserManager(log *slog.Logger, mgr *browsercap.Manager) {
|
||||
if mgr == nil {
|
||||
return
|
||||
}
|
||||
if err := mgr.Close(); err != nil {
|
||||
log.Warn("browser manager close failed", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
package main
|
||||
|
||||
// 本文件是 design/28 §8 **B4(M3 面板入口)的接线验证**(切片 Y4):
|
||||
// 声明真的进了面板目录、六个动作真的能分发、读写面分类正确、保存真的热重载。
|
||||
//
|
||||
// 为什么这些断言必须走**真实面板中枢**(`pluginmgr.NewPanels`)而不是只测函数:
|
||||
//
|
||||
// - 「动作可不可调」由声明推出(`pluginmgr.actionIndex`),viewer 的写面在**快照裁剪**
|
||||
// 里被摘掉(`paneldecl.filterPanel`)—— 两件事都不是本包能自证的;
|
||||
// - 保存的语义是「盘上改了 + 运行里的工具也换了」,只有从**能力注册表**读回工具才能验。
|
||||
//
|
||||
// 真浏览器不在这里:`buildBrowserTool` 只按配置探测**可执行文件是否存在**,因此本文件用
|
||||
// 一个假的可执行文件就能走完装配(真机 e2e 见 `wire_browser_e2e_test.go`,默认 Skip)。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
auth "git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
|
||||
)
|
||||
|
||||
// browserPanelFixture 是 B4 的夹具:真 hub + 已注册的 browser 工具(假可执行文件)。
|
||||
type browserPanelFixture struct {
|
||||
app *app
|
||||
hub *pluginmgr.Panels
|
||||
log *slog.Logger
|
||||
out *bytes.Buffer
|
||||
path string
|
||||
}
|
||||
|
||||
// newBrowserPanelFixture 造夹具并完成接线。
|
||||
func newBrowserPanelFixture(t *testing.T) *browserPanelFixture {
|
||||
t.Helper()
|
||||
cfg := wsLoadConfig(t)
|
||||
exe := filepath.Join(t.TempDir(), "fake-browser.exe")
|
||||
if err := os.WriteFile(exe, []byte("x"), 0o700); err != nil {
|
||||
t.Fatalf("造假可执行文件: %v", err)
|
||||
}
|
||||
writeBrowserConfig(t, cfg, `{"executable":`+strconv.Quote(exe)+`}`)
|
||||
|
||||
var out bytes.Buffer
|
||||
a := &app{
|
||||
cfg: cfg,
|
||||
log: logger.New(logger.Options{Out: &out, Level: "debug"}),
|
||||
caps: registry.New(),
|
||||
}
|
||||
a.wireBrowser()
|
||||
if _, ok := browserEntry(a); !ok {
|
||||
t.Fatalf("夹具前提不成立:明明可用却没注册(日志:%s)", out.String())
|
||||
}
|
||||
hub, err := pluginmgr.NewPanels(logger.WithSource(a.log, "test/panel"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewPanels: %v", err)
|
||||
}
|
||||
log := logger.WithSource(a.log, "test/panel")
|
||||
wireBrowserPanel(hub, a, log)
|
||||
fx := &browserPanelFixture{
|
||||
app: a, hub: hub, log: log, out: &out,
|
||||
path: browsercfg.DefaultConfigPath(cfg.DataDir),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if entry, err := a.caps.Get(browsertools.ToolName); err == nil {
|
||||
if closer, ok := entry.Value.(interface{ Close() error }); ok {
|
||||
_ = closer.Close()
|
||||
}
|
||||
}
|
||||
})
|
||||
return fx
|
||||
}
|
||||
|
||||
// dispatch 调一次动作(等价于宿主动作端点的分发路径:声明 → 启用 → 处理器)。
|
||||
func (fx *browserPanelFixture) dispatch(t *testing.T, action string, payload any) (any, error) {
|
||||
t.Helper()
|
||||
return fx.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
|
||||
PluginID: browsertools.PanelPluginID, Action: action, Payload: payload,
|
||||
})
|
||||
}
|
||||
|
||||
// TestWireBrowserPanel_DeclaresPanelAndActionKinds 钉住声明进了目录,且六个动作的读写面
|
||||
// 分类正确(读面 viewer 可调、写面只 owner/admin —— 这是 §5.4.1 的门)。
|
||||
func TestWireBrowserPanel_DeclaresPanelAndActionKinds(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newBrowserPanelFixture(t)
|
||||
groups, err := fx.hub.Groups(auth.RoleOwner)
|
||||
if err != nil {
|
||||
t.Fatalf("Groups(owner): %v", err)
|
||||
}
|
||||
if len(groups) != 1 || len(groups[0].Panels) != 1 {
|
||||
t.Fatalf("面板快照形状 = %+v", groups)
|
||||
}
|
||||
item := groups[0].Panels[0]
|
||||
if item.PluginID != browsertools.PanelPluginID || item.Panel.ID != browsertools.PanelIDSettings {
|
||||
t.Fatalf("面板标识 = %s/%s", item.PluginID, item.Panel.ID)
|
||||
}
|
||||
if len(item.Panel.Sections) != 4 {
|
||||
t.Fatalf("区块数 = %d, want 4(配置 / 状态 / 指纹 / 截图)", len(item.Panel.Sections))
|
||||
}
|
||||
wantKinds := map[string]pluginmgr.ActionKind{
|
||||
browsertools.ActionState: pluginmgr.ActionRead,
|
||||
browsertools.ActionShots: pluginmgr.ActionRead,
|
||||
browsertools.ActionFingerprint: pluginmgr.ActionRead,
|
||||
browsertools.ActionSaveConfig: pluginmgr.ActionWrite,
|
||||
browsertools.ActionShoot: pluginmgr.ActionWrite,
|
||||
browsertools.ActionCheck: pluginmgr.ActionWrite,
|
||||
}
|
||||
for name, want := range wantKinds {
|
||||
got, ok := fx.hub.Action(browsertools.PanelPluginID, name)
|
||||
if !ok {
|
||||
t.Errorf("动作 %s 没进声明索引(面板会被宿主拒掉或点了没反应)", name)
|
||||
continue
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("动作 %s 的读写面 = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireBrowserPanel_ViewerSeesReadOnlyProjection 钉住角色裁剪:viewer 看到的分组里
|
||||
// **没有**表单(写面)与行内动作(写动作),只留只读表格 —— 这是「只读监控投影」的定义。
|
||||
func TestWireBrowserPanel_ViewerSeesReadOnlyProjection(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newBrowserPanelFixture(t)
|
||||
groups, err := fx.hub.Groups(auth.RoleViewer)
|
||||
if err != nil {
|
||||
t.Fatalf("Groups(viewer): %v", err)
|
||||
}
|
||||
if len(groups) != 1 || len(groups[0].Panels) != 1 {
|
||||
t.Fatalf("viewer 的面板快照形状 = %+v", groups)
|
||||
}
|
||||
for _, s := range groups[0].Panels[0].Panel.Sections {
|
||||
if s.Type == "form" {
|
||||
t.Errorf("viewer 拿到了写表单(%s)", s.ID)
|
||||
}
|
||||
if len(s.RowActions) > 0 {
|
||||
t.Errorf("viewer 拿到了行内写动作(%s:%+v)", s.ID, s.RowActions)
|
||||
}
|
||||
}
|
||||
// 对照:owner 看得到表单与行内动作(否则上面的断言在「什么都没渲染」时也会过)。
|
||||
ownerGroups, err := fx.hub.Groups(auth.RoleOwner)
|
||||
if err != nil {
|
||||
t.Fatalf("Groups(owner): %v", err)
|
||||
}
|
||||
forms, rowActions := 0, 0
|
||||
for _, s := range ownerGroups[0].Panels[0].Panel.Sections {
|
||||
if s.Type == "form" {
|
||||
forms++
|
||||
}
|
||||
rowActions += len(s.RowActions)
|
||||
}
|
||||
if forms != 1 || rowActions != 2 {
|
||||
t.Fatalf("owner 看到的表单/行内动作 = %d/%d, want 1/2", forms, rowActions)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireBrowserPanel_ReadActionsAnswerWithoutLaunchingBrowser 钉住三件事:
|
||||
// 读动作真的能分发、回的是 `{rows:[…]}`、且**没有**启动浏览器会话(面板取数无副作用)。
|
||||
func TestWireBrowserPanel_ReadActionsAnswerWithoutLaunchingBrowser(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newBrowserPanelFixture(t)
|
||||
for _, action := range []string{
|
||||
browsertools.ActionState, browsertools.ActionShots, browsertools.ActionFingerprint,
|
||||
} {
|
||||
res, err := fx.dispatch(t, action, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", action, err)
|
||||
}
|
||||
m, ok := res.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("%s 结果类型 = %T", action, res)
|
||||
}
|
||||
rows, ok := m["rows"].([]map[string]any)
|
||||
if !ok || rows == nil {
|
||||
t.Fatalf("%s 的 rows = %#v(必须是数组,哪怕是空的)", action, m["rows"])
|
||||
}
|
||||
// 状态与指纹**恒有一行**(事实面),最近截图在没有截图时如实为空表。
|
||||
if action != browsertools.ActionShots && len(rows) == 0 {
|
||||
t.Fatalf("%s 应当至少一行事实:%#v", action, m["rows"])
|
||||
}
|
||||
}
|
||||
tool := browserToolValue(t, fx)
|
||||
if snap := tool.State(); snap.SessionLive {
|
||||
t.Error("读动作启动了浏览器会话:面板取数必须无进程外副作用")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireBrowserPanel_SaveConfigHotReloads 钉住「保存即生效」:落盘 + 换掉运行中的工具
|
||||
// (面板的意义就是不改 `.env`、不重启)。
|
||||
func TestWireBrowserPanel_SaveConfigHotReloads(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newBrowserPanelFixture(t)
|
||||
res, err := fx.dispatch(t, browsertools.ActionSaveConfig, map[string]any{
|
||||
"values": map[string]any{
|
||||
browsertools.FieldViewportWidth: float64(1600),
|
||||
browsertools.FieldViewportHeight: float64(900),
|
||||
browsertools.FieldIdleTimeout: float64(120),
|
||||
browsertools.FieldHeadless: true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("saveBrowserConfig: %v", err)
|
||||
}
|
||||
m, _ := res.(map[string]any)
|
||||
if m["applied"] != true || m["registered"] != true {
|
||||
t.Fatalf("保存结论 = %#v(要求盘上与运行中都生效)", m)
|
||||
}
|
||||
snap := browserToolValue(t, fx).State()
|
||||
if snap.ViewportWidth != 1600 || snap.ViewportHeight != 900 || snap.IdleTimeoutSeconds != 120 || !snap.Headless {
|
||||
t.Fatalf("热重载后的生效配置 = %+v,与保存内容不一致", snap)
|
||||
}
|
||||
raw, err := os.ReadFile(fx.path)
|
||||
if err != nil {
|
||||
t.Fatalf("读回配置: %v", err)
|
||||
}
|
||||
var onDisk browsercfg.Config
|
||||
if err := json.Unmarshal(raw, &onDisk); err != nil {
|
||||
t.Fatalf("配置不是合法 JSON: %v", err)
|
||||
}
|
||||
if onDisk.ViewportWidth != 1600 || !onDisk.Headless {
|
||||
t.Fatalf("盘上的配置 = %+v", onDisk)
|
||||
}
|
||||
// 面板默认值跟着刷新(前端提交成功后会重取快照,表单因此显示「刚落盘的那份」)。
|
||||
groups, _ := fx.hub.Groups(auth.RoleOwner)
|
||||
for _, s := range groups[0].Panels[0].Panel.Sections {
|
||||
if s.Type != "form" {
|
||||
continue
|
||||
}
|
||||
for _, f := range s.Fields {
|
||||
if f.Key == browsertools.FieldViewportWidth && f.Default != 1600 {
|
||||
t.Errorf("面板默认值没刷新:viewportWidth=%v", f.Default)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireBrowserPanel_SaveConfigRejectsUnknownField 钉住「未知字段不许静默忽略」:
|
||||
// 静默忽略会让「我改了但它没生效」变成查不出的谜,而且不该在盘上留下半份改动。
|
||||
func TestWireBrowserPanel_SaveConfigRejectsUnknownField(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newBrowserPanelFixture(t)
|
||||
before, _ := os.ReadFile(fx.path)
|
||||
_, err := fx.dispatch(t, browsertools.ActionSaveConfig, map[string]any{
|
||||
"values": map[string]any{"nope": "x"},
|
||||
})
|
||||
ae := requireActionError(t, err)
|
||||
if ae.Code != pluginmgr.ActionCodeInvalidPayload || ae.Status != http.StatusBadRequest {
|
||||
t.Fatalf("失败形状 = %+v, want invalid_payload/400", ae)
|
||||
}
|
||||
after, _ := os.ReadFile(fx.path)
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Error("被拒的保存改动了盘上的配置文件")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireBrowserPanel_SaveConfigRollsBackWhenNotUsable 钉住事务:保存前有可用工具而新配置
|
||||
// 让它不可用(路径写错)⇒ **回滚**且如实报冲突,绝不让「盘上的」与「跑着的」静默拆开。
|
||||
func TestWireBrowserPanel_SaveConfigRollsBackWhenNotUsable(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newBrowserPanelFixture(t)
|
||||
before, _ := os.ReadFile(fx.path)
|
||||
_, err := fx.dispatch(t, browsertools.ActionSaveConfig, map[string]any{
|
||||
"values": map[string]any{
|
||||
browsertools.FieldExecutable: filepath.Join(t.TempDir(), "no-such-browser.exe"),
|
||||
},
|
||||
})
|
||||
ae := requireActionError(t, err)
|
||||
if ae.Code != browserCodeConfigRejected || ae.Status != http.StatusConflict {
|
||||
t.Fatalf("失败形状 = %+v, want %s/409", ae, browserCodeConfigRejected)
|
||||
}
|
||||
after, _ := os.ReadFile(fx.path)
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Error("没能生效的保存没有回滚:盘上留下了会让工具消失的配置")
|
||||
}
|
||||
if _, ok := browserEntry(fx.app); !ok {
|
||||
t.Error("回滚之后原来的工具也不见了(应当原样保留)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireBrowserPanel_WriteActionsAreHonestWithoutTarget 钉住两个写动作的诚实下限:
|
||||
// 没有打开的页面时「拍一张」如实报 browser_session_not_open,绝不偷偷替模型开一页。
|
||||
func TestWireBrowserPanel_WriteActionsAreHonestWithoutTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newBrowserPanelFixture(t)
|
||||
_, err := fx.dispatch(t, browsertools.ActionShoot, map[string]any{
|
||||
"row": map[string]any{"source": "本机 Chrome"},
|
||||
})
|
||||
ae := requireActionError(t, err)
|
||||
if ae.Code != browserCodeNoSession || ae.Status != http.StatusConflict {
|
||||
t.Fatalf("拍一张的失败形状 = %+v, want %s/409", ae, browserCodeNoSession)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireBrowserPanel_WithoutToolIsHonest 钉住「没有浏览器来源」时的两个写动作:如实报
|
||||
// browser_unavailable(而不是 500 / 假成功),且声明仍然在(面板可以配,配好保存即生效)。
|
||||
func TestWireBrowserPanel_WithoutToolIsHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t)
|
||||
var out bytes.Buffer
|
||||
a := &app{cfg: cfg, log: logger.New(logger.Options{Out: &out, Level: "debug"}), caps: registry.New()}
|
||||
hub, err := pluginmgr.NewPanels(logger.WithSource(a.log, "test/panel"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewPanels: %v", err)
|
||||
}
|
||||
wireBrowserPanel(hub, a, logger.WithSource(a.log, "test/panel"))
|
||||
groups, gerr := hub.Groups(auth.RoleOwner)
|
||||
if gerr != nil || len(groups) != 1 || len(groups[0].Panels) != 1 {
|
||||
t.Fatalf("没有工具时面板也该声明(可以在面板里配):%+v, %v", groups, gerr)
|
||||
}
|
||||
for _, action := range []string{browsertools.ActionShoot, browsertools.ActionCheck} {
|
||||
_, derr := hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
|
||||
PluginID: browsertools.PanelPluginID, Action: action,
|
||||
})
|
||||
ae := requireActionError(t, derr)
|
||||
if ae.Code != browserCodeUnavailable {
|
||||
t.Errorf("%s 的失败形状 = %+v, want %s", action, ae, browserCodeUnavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* 助手
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
// browserToolValue 从能力面取回 browser 工具。
|
||||
func browserToolValue(t *testing.T, fx *browserPanelFixture) *browsertools.Tool {
|
||||
t.Helper()
|
||||
entry, err := fx.app.caps.Get(browsertools.ToolName)
|
||||
if err != nil {
|
||||
t.Fatalf("能力面里没有 browser 工具: %v", err)
|
||||
}
|
||||
tool, ok := entry.Value.(*browsertools.Tool)
|
||||
if !ok {
|
||||
t.Fatalf("能力面里的值不是 browser 工具: %T", entry.Value)
|
||||
}
|
||||
return tool
|
||||
}
|
||||
|
||||
// requireActionError 断言错误是一条**结构化动作失败**(错误码 + 文案 + 状态)。
|
||||
func requireActionError(t *testing.T, err error) *pluginmgr.ActionError {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("期望一个结构化失败,得到 nil")
|
||||
}
|
||||
ae, ok := pluginmgr.AsActionError(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %v(%T),不是 structured ActionError", err, err)
|
||||
}
|
||||
if ae.Message == "" {
|
||||
t.Error("结构化失败没有文案(§5.4.6 要求错误码与文案分离且都在)")
|
||||
}
|
||||
return ae
|
||||
}
|
||||
|
||||
// TestWireBrowserPanel_ConfigFileIsPrivate 钉住落盘权限:browser.json 可能带显式 CDP 端点
|
||||
// 与 profile 位置,按 0600 落盘(`browsercfg.Save` 的口径,这里从**面板保存**这条路上验)。
|
||||
func TestWireBrowserPanel_ConfigFileIsPrivate(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newBrowserPanelFixture(t)
|
||||
if _, err := fx.dispatch(t, browsertools.ActionSaveConfig, map[string]any{
|
||||
"values": map[string]any{browsertools.FieldIdleTimeout: float64(90)},
|
||||
}); err != nil {
|
||||
t.Fatalf("saveBrowserConfig: %v", err)
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
// Windows 不体现 POSIX 权限位(仓库既有的 store_test.go 同口径跳过)。
|
||||
t.Skip("Windows 不体现 POSIX 权限位;0600 的判定在 store_test.go 覆盖")
|
||||
}
|
||||
info, err := os.Stat(fx.path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat 配置: %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Errorf("配置权限 = %o, want 600", perm)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserPanelEnv_CurrentConfigSurfacesCorruptFile 钉住「坏配置不静默顶替」:
|
||||
// 文件非法时 `currentConfig` 返回错误(面板据此如实告知),不悄悄回落到默认值。
|
||||
func TestBrowserPanelEnv_CurrentConfigSurfacesCorruptFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newBrowserPanelFixture(t)
|
||||
if err := os.WriteFile(fx.path, []byte("{ not json"), 0o600); err != nil {
|
||||
t.Fatalf("写坏配置: %v", err)
|
||||
}
|
||||
if _, err := fx.dispatch(t, browsertools.ActionState, map[string]any{}); err != nil {
|
||||
t.Fatalf("读动作不该因为配置坏而失败(它用默认值兜底显示现状): %v", err)
|
||||
}
|
||||
_, err := fx.dispatch(t, browsertools.ActionSaveConfig, map[string]any{
|
||||
"values": map[string]any{browsertools.FieldIdleTimeout: float64(90)},
|
||||
})
|
||||
ae := requireActionError(t, err)
|
||||
if ae.Code != browserCodeConfigUnreadable {
|
||||
t.Fatalf("坏配置下保存的失败形状 = %+v, want %s", ae, browserCodeConfigUnreadable)
|
||||
}
|
||||
raw, _ := os.ReadFile(fx.path)
|
||||
if string(raw) != "{ not json" {
|
||||
t.Error("坏配置在保存失败后被改写了(不该动它)")
|
||||
}
|
||||
if !errors.Is(err, browsercfg.ErrInvalidConfig) {
|
||||
t.Logf("note: 错误链未含 ErrInvalidConfig(%v)", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package main
|
||||
|
||||
// 本文件钉住切片 V5 的组合根接线:`browser` 工具**只在有可用来源时**进注册面,
|
||||
// 每一个「不注册」的原因都**分类**写进日志(照 web-search `off` 的诚实口径),
|
||||
// 而 profile 目录的凭据守卫(不得进 git 工作树)在注册之前生效。
|
||||
//
|
||||
// 断言落在**注册表**(模型真正看到的那一面)与**日志字段**上,而不是源码字符串。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
|
||||
)
|
||||
|
||||
// browserTestApp 造一个「有注册面 + 有日志出口」的 app:browser 的接线只依赖这三件。
|
||||
func browserTestApp(t *testing.T, cfg *config.Config, out *bytes.Buffer) *app {
|
||||
t.Helper()
|
||||
return &app{
|
||||
cfg: cfg,
|
||||
log: logger.New(logger.Options{Out: out, Level: "debug"}),
|
||||
caps: registry.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// writeBrowserConfig 把一份 browser.json 写进 $DATA_DIR/config/plugins/(原样字节)。
|
||||
func writeBrowserConfig(t *testing.T, cfg *config.Config, raw string) string {
|
||||
t.Helper()
|
||||
path := browsercfg.DefaultConfigPath(cfg.DataDir)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("建配置目录: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
|
||||
t.Fatalf("写 %s: %v", path, err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// browserEntry 报告注册面里有没有 browser 工具。
|
||||
func browserEntry(a *app) (registry.Capability, bool) {
|
||||
cap, err := a.caps.Get(browsertools.ToolName)
|
||||
return cap, err == nil
|
||||
}
|
||||
|
||||
// TestWireBrowser_InvalidConfigDoesNotRegister 钉住「配置非法 ⇒ 不注册 + 分类原因」:
|
||||
// 静默拿默认值顶替会让面板里那份坏配置永远看不见。
|
||||
func TestWireBrowser_InvalidConfigDoesNotRegister(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t)
|
||||
writeBrowserConfig(t, cfg, "{ this is not json")
|
||||
var out bytes.Buffer
|
||||
a := browserTestApp(t, cfg, &out)
|
||||
|
||||
a.wireBrowser()
|
||||
|
||||
if _, ok := browserEntry(a); ok {
|
||||
t.Fatal("配置非法时 browser 不该进注册面")
|
||||
}
|
||||
if got := wsLogField(t, out.String(), "reason"); got != browserReasonInvalidConfig {
|
||||
t.Errorf("reason = %q, want %q", got, browserReasonInvalidConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireBrowser_MissingExecutableDoesNotRegister 钉住「面板把路径写错 ⇒ 不注册」,
|
||||
// 且原因来自 browsercfg 的分类码(不偷偷回退到自动探测)。
|
||||
func TestWireBrowser_MissingExecutableDoesNotRegister(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t)
|
||||
raw, _ := json.Marshal(map[string]any{"executable": filepath.Join(t.TempDir(), "no-such-browser.exe")})
|
||||
writeBrowserConfig(t, cfg, string(raw))
|
||||
var out bytes.Buffer
|
||||
a := browserTestApp(t, cfg, &out)
|
||||
|
||||
a.wireBrowser()
|
||||
|
||||
if _, ok := browserEntry(a); ok {
|
||||
t.Fatal("可执行文件不存在时 browser 不该进注册面")
|
||||
}
|
||||
if got := wsLogField(t, out.String(), "reason"); got != string(browsercfg.ReasonExecutableMissing) {
|
||||
t.Errorf("reason = %q, want %q", got, browsercfg.ReasonExecutableMissing)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireBrowser_UnreachableEndpointDoesNotRegister 钉住 CDP 端点连不上 ⇒ 不注册,
|
||||
// 原因分类为 cdp_unreachable(配置显式给了端点却不可达,就是「我配的容器没生效」,
|
||||
// 必须让人看见)。
|
||||
func TestWireBrowser_UnreachableEndpointDoesNotRegister(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t)
|
||||
// 127.0.0.1 上一个确定没人听的端口:连不上会立刻返回(不是超时)。
|
||||
raw, _ := json.Marshal(map[string]any{"cdpEndpoint": "http://127.0.0.1:1"})
|
||||
writeBrowserConfig(t, cfg, string(raw))
|
||||
var out bytes.Buffer
|
||||
a := browserTestApp(t, cfg, &out)
|
||||
|
||||
a.wireBrowser()
|
||||
|
||||
if _, ok := browserEntry(a); ok {
|
||||
t.Fatal("CDP 端点不可达时 browser 不该进注册面")
|
||||
}
|
||||
if got := wsLogField(t, out.String(), "reason"); got != string(browsercfg.ReasonCDPUnreachable) {
|
||||
t.Errorf("reason = %q, want %q", got, browsercfg.ReasonCDPUnreachable)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireBrowser_UsableExecutableRegisters 钉住正向路径:配置给出的可执行文件存在
|
||||
// ⇒ 工具进注册面,且 profile 目录被按 0700 建好(含凭据的目录不能是 0755)。
|
||||
func TestWireBrowser_UsableExecutableRegisters(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t)
|
||||
exe := filepath.Join(t.TempDir(), "fake-browser.exe")
|
||||
if err := os.WriteFile(exe, []byte("#!/bin/sh\n"), 0o700); err != nil {
|
||||
t.Fatalf("造假可执行文件: %v", err)
|
||||
}
|
||||
profileDir := filepath.Join(cfg.DataDir, "browser", "profile-x")
|
||||
raw, _ := json.Marshal(map[string]any{"executable": exe, "profileDir": profileDir})
|
||||
writeBrowserConfig(t, cfg, string(raw))
|
||||
var out bytes.Buffer
|
||||
a := browserTestApp(t, cfg, &out)
|
||||
|
||||
a.wireBrowser()
|
||||
|
||||
cap, ok := browserEntry(a)
|
||||
if !ok {
|
||||
t.Fatalf("可用时 browser 应进注册面(日志:%s)", out.String())
|
||||
}
|
||||
if cap.Kind != registry.KindTool {
|
||||
t.Errorf("Kind = %q, want tool", cap.Kind)
|
||||
}
|
||||
// 默认权限口径:owner-only(两布尔皆 false)。
|
||||
if cap.Policy.AllowInGroup || cap.Policy.AllowInStranger {
|
||||
t.Errorf("Policy = %+v,默认应是 owner-only(design/28 §4 第 2 条)", cap.Policy)
|
||||
}
|
||||
if _, err := os.Stat(profileDir); err != nil {
|
||||
t.Errorf("profile 目录应先被建好: %v", err)
|
||||
}
|
||||
// 工具名/动作面:注册进去的那把工具就是薄层那一个。
|
||||
if tool, ok := cap.Value.(interface{ Name() string }); !ok || tool.Name() != browsertools.ToolName {
|
||||
t.Errorf("注册的值不是 browser 工具: %#v", cap.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireBrowser_ProfileInGitTreeIsRejected 钉住凭据守卫:profile 落在 git 工作树里
|
||||
// ⇒ **不注册**(被 `git add` 收走就是把登录态提交出去,design/28 §4)。
|
||||
func TestWireBrowser_ProfileInGitTreeIsRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t)
|
||||
exe := filepath.Join(t.TempDir(), "fake-browser.exe")
|
||||
if err := os.WriteFile(exe, []byte("x"), 0o700); err != nil {
|
||||
t.Fatalf("造假可执行文件: %v", err)
|
||||
}
|
||||
// 造一个「像仓库」的目录:里面有 `.git`,profile 落在它下面、且在 dataDir 之外。
|
||||
repo := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o755); err != nil {
|
||||
t.Fatalf("造 .git: %v", err)
|
||||
}
|
||||
profileDir := filepath.Join(repo, "browser-profile")
|
||||
raw, _ := json.Marshal(map[string]any{"executable": exe, "profileDir": profileDir})
|
||||
writeBrowserConfig(t, cfg, string(raw))
|
||||
var out bytes.Buffer
|
||||
a := browserTestApp(t, cfg, &out)
|
||||
|
||||
a.wireBrowser()
|
||||
|
||||
if _, ok := browserEntry(a); ok {
|
||||
t.Fatal("profile 位于 git 工作树内时 browser 不该进注册面")
|
||||
}
|
||||
if got := wsLogField(t, out.String(), "reason"); got != browserReasonProfileRejected {
|
||||
t.Errorf("reason = %q, want %q", got, browserReasonProfileRejected)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIOToolOptions_DoesNotWireBrowser 钉住**临时挂点已删**(切片 X3 把挂点归位到
|
||||
// `wireRuntime`):browser 的接线原先借 `ioToolOptions()` 转交(V5 改不了
|
||||
// wire_runtime.go 时的权宜),代价是「取一份 io 选项」这件只读的事会顺带探测并注册
|
||||
// 浏览器工具 —— 所有只为要选项而调本函数的路径(web-search 的接线测试就是)都会连带
|
||||
// 起一次浏览器探测。归位后的挂点在 wire_runtime.go,由
|
||||
// TestWireRuntime_CallsWireBrowser 钉住;这里钉住 io 侧**不再有第二个挂点** ——
|
||||
// 两处并存不会报错(函数幂等),只会静默多一次探测,因此只有测试能拦住它回流。
|
||||
func TestIOToolOptions_DoesNotWireBrowser(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t)
|
||||
exe := filepath.Join(t.TempDir(), "fake-browser.exe")
|
||||
if err := os.WriteFile(exe, []byte("x"), 0o700); err != nil {
|
||||
t.Fatalf("造假可执行文件: %v", err)
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"executable": exe})
|
||||
writeBrowserConfig(t, cfg, string(raw))
|
||||
var out bytes.Buffer
|
||||
a := browserTestApp(t, cfg, &out)
|
||||
|
||||
a.ioToolOptions()
|
||||
|
||||
if _, ok := browserEntry(a); ok {
|
||||
t.Fatalf("ioToolOptions 不该再挂 browser(挂点已归位 wire_runtime.go;日志:%s)", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireBrowser_Idempotent 钉住**幂等**:同一个 app 上重复装配只发现工具已在注册表里
|
||||
// 并跳过,不重复注册、不重复探测。挂点归位后幂等仍是唯一的保险(两条路径万一偶遇)。
|
||||
func TestWireBrowser_Idempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t)
|
||||
exe := filepath.Join(t.TempDir(), "fake-browser.exe")
|
||||
if err := os.WriteFile(exe, []byte("x"), 0o700); err != nil {
|
||||
t.Fatalf("造假可执行文件: %v", err)
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"executable": exe})
|
||||
writeBrowserConfig(t, cfg, string(raw))
|
||||
var out bytes.Buffer
|
||||
a := browserTestApp(t, cfg, &out)
|
||||
|
||||
a.wireBrowser()
|
||||
first, ok := browserEntry(a)
|
||||
if !ok {
|
||||
t.Fatalf("可用时 browser 应进注册面(日志:%s)", out.String())
|
||||
}
|
||||
a.wireBrowser()
|
||||
second, ok := browserEntry(a)
|
||||
if !ok {
|
||||
t.Fatal("第二次调用后 browser 不该消失")
|
||||
}
|
||||
if first.Name != second.Name || first.Kind != second.Kind {
|
||||
t.Errorf("重复装配改写了条目:%+v -> %+v", first, second)
|
||||
}
|
||||
// 注册面里只该有一个 browser(registry 重名会被拒;这里确认没被静默换掉)。
|
||||
count := 0
|
||||
for _, c := range a.caps.List(registry.KindTool) {
|
||||
if c.Name == browsertools.ToolName {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("注册面里 browser 条目数 = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireBrowser_NilRegistryIsSkipped 钉住夹具友好性:没有注册面时**不 panic**
|
||||
// (部分既有测试夹具只填了 app 的几个字段)。
|
||||
func TestWireBrowser_NilRegistryIsSkipped(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := &app{}
|
||||
a.wireBrowser()
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// 本文件是 X1(思考轮工具面补齐 / 主人裁决 15)的组合根接线:让**外围子系统**真的
|
||||
// 出现在能力面里,并给她一把只读的自查工具。它只装配,不新造注册机制、不开端口、
|
||||
// 不跨进程(单二进制红线)。
|
||||
|
||||
// capabilitySurfaceLogSource 是本步日志的来源标记。
|
||||
const capabilitySurfaceLogSource = "cmd/cyrene"
|
||||
|
||||
// iotCapabilityName 是 IoT 子系统在能力注册表里的条目名。
|
||||
const iotCapabilityName = "iot"
|
||||
|
||||
// IoT 只读状态摘要的边界(编译期常量):台数、每台的关键状态键数、单个键值长度与
|
||||
// 整段摘要字符数。整段上限必须**小于** registry 侧 statusCharLimit,否则真正生效的
|
||||
// 是那边的截断,而这里以为没截断。
|
||||
const (
|
||||
iotStatusMaxDevices = 20
|
||||
iotStatusMaxKeys = 6
|
||||
iotStatusMaxValueLen = 40
|
||||
iotStatusMaxChars = 380
|
||||
)
|
||||
|
||||
// wireCapabilitySurface 把「她能看见自己有什么、外围在什么状态」这条面接进组合根
|
||||
// (X1 / 裁决 15;design/12 §1「允许调用工具(查设备/心率/日历/天气等)」、
|
||||
// design/20 §2「能调用工具:与主人会话同等的工具集」):
|
||||
//
|
||||
// 1. **外围子系统进能力面**:IoT 以它既有的 `KindIoT` 身份注册进能力注册表 ——
|
||||
// 此前全仓**无人注册 KindIoT**,于是外围子系统在能力面里根本不存在(面板与
|
||||
// 清单都列不出);
|
||||
// 2. **只读自查工具**:`capability_list`(internal/capability/registry 的
|
||||
// ListTool)注册进同一张注册表,并注入 IoT 的只读状态源,使她无论走 chat 还是
|
||||
// 主动思考轮,都能列出「有哪些能力/插件 + 外围状态」。
|
||||
//
|
||||
// 「chat 与 backthink 同一套工具面」不靠本文件成立,而靠**同一个注册表 / 分发器**:
|
||||
// backthink 的 LoopThinker 包的就是 `a.loop`(wire_services.go),而 `a.loop` 的工具面
|
||||
// 就是 `a.dispatcher`(wire_runtime.go),它建立在 `a.caps` 之上。因此这里绝不再造
|
||||
// 第二套注册机制 —— 把工具塞进 `a.caps` 就同时被两条车道看见。
|
||||
//
|
||||
// 失败只告警、绝不阻塞启动:本步只影响「清单可见性」,其余子系统不受影响。
|
||||
func (a *app) wireCapabilitySurface() {
|
||||
log := logger.WithSource(a.log, capabilitySurfaceLogSource)
|
||||
if a.caps == nil {
|
||||
log.Warn("capability surface skipped: no capability registry")
|
||||
return
|
||||
}
|
||||
a.registerIoTCapability(log)
|
||||
|
||||
opts := make([]registry.ListOption, 0, 2)
|
||||
if a.iot != nil {
|
||||
opts = append(opts, registry.WithStatusSource(iotStatusSource{mgr: a.iot}))
|
||||
}
|
||||
opts = append(opts, registry.WithListLogger(a.log))
|
||||
|
||||
listTool, err := registry.NewListTool(a.caps, opts...)
|
||||
if err != nil {
|
||||
log.Warn("capability list tool build skipped", "error", err)
|
||||
return
|
||||
}
|
||||
if a.dispatcher == nil {
|
||||
log.Warn("capability list tool register skipped: no dispatcher")
|
||||
return
|
||||
}
|
||||
if err := a.dispatcher.Register(listTool); err != nil {
|
||||
log.Warn("capability list tool register skipped", "error", err)
|
||||
return
|
||||
}
|
||||
log.Info("capability list tool registered", "tool", registry.ToolName, "iot_status", a.iot != nil)
|
||||
}
|
||||
|
||||
// registerIoTCapability 把 IoT 门面以 KindIoT 注册进能力注册表。已注册(重名)时
|
||||
// 只告警:能力面里已经有一条,不是启动失败的理由。
|
||||
func (a *app) registerIoTCapability(log *slog.Logger) {
|
||||
if a.iot == nil {
|
||||
return
|
||||
}
|
||||
err := a.caps.Register(registry.Capability{
|
||||
Name: iotCapabilityName,
|
||||
Kind: registry.KindIoT,
|
||||
Description: "IoT peripherals (device list, status, control)",
|
||||
Value: a.iot,
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn("iot capability register skipped", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// iotStatusSource 把 IoT 子系统接成清单工具的**只读**状态源:它只调 Manager.List
|
||||
// (纯读),按固定上限渲染设备清单,绝不 Control、绝不改任何设备状态。
|
||||
type iotStatusSource struct {
|
||||
mgr *iot.Manager
|
||||
}
|
||||
|
||||
// Name 返回状态分组名。
|
||||
func (s iotStatusSource) Name() string { return iotCapabilityName }
|
||||
|
||||
// Status 渲染一份有界的只读摘要:设备/在线台数,然后是每台设备一行
|
||||
// (名称/类型/在线/关键状态键值)。超过上限的台数与字符都被截断并显式标注。
|
||||
func (s iotStatusSource) Status(ctx context.Context) (string, error) {
|
||||
if s.mgr == nil {
|
||||
return "", fmt.Errorf("capability surface: iot status: %w", iot.ErrProviderNotRegistered)
|
||||
}
|
||||
devices, err := s.mgr.List(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("capability surface: iot status: %w", err)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "devices=%d online=%d", len(devices), onlineCount(devices))
|
||||
rendered := 0
|
||||
for _, d := range devices {
|
||||
if rendered >= iotStatusMaxDevices {
|
||||
break
|
||||
}
|
||||
line := "\n" + deviceLine(d)
|
||||
if b.Len()+len(line) > iotStatusMaxChars {
|
||||
break
|
||||
}
|
||||
b.WriteString(line)
|
||||
rendered++
|
||||
}
|
||||
if rendered < len(devices) {
|
||||
fmt.Fprintf(&b, "\n…(另有 %d 台未列出)", len(devices)-rendered)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// onlineCount 统计在线设备台数。
|
||||
func onlineCount(devices []iot.Device) int {
|
||||
n := 0
|
||||
for _, d := range devices {
|
||||
if d.Online {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// deviceLine 把一台设备渲染成单行只读摘要:名称(类型,在线/离线):键=值,…
|
||||
// 状态键按字典序取前 iotStatusMaxKeys 个,保证同一台设备每次渲染一致。
|
||||
func deviceLine(d iot.Device) string {
|
||||
status := "离线"
|
||||
if d.Online {
|
||||
status = "在线"
|
||||
}
|
||||
keys := make([]string, 0, len(d.State))
|
||||
for k := range d.State {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
pairs := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
if len(pairs) >= iotStatusMaxKeys {
|
||||
break
|
||||
}
|
||||
pairs = append(pairs, k+"="+truncateValue(d.State[k]))
|
||||
}
|
||||
line := fmt.Sprintf("%s(%s,%s)", d.Name, d.Type, status)
|
||||
if len(pairs) > 0 {
|
||||
line += ":" + strings.Join(pairs, ",")
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// truncateValue 把设备状态值渲染成有界文本:非文本值走 %v,超长即截断。
|
||||
func truncateValue(v any) string {
|
||||
s := fmt.Sprintf("%v", v)
|
||||
if len([]rune(s)) <= iotStatusMaxValueLen {
|
||||
return s
|
||||
}
|
||||
return string([]rune(s)[:iotStatusMaxValueLen]) + "…"
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
)
|
||||
|
||||
// surfaceApp 组装一个只含能力面所需的 app:注册表 + 分发表 + mock IoT 子系统。
|
||||
// 它走的是与生产相同的两个入口(wireTranscode / wireCapabilitySurface),
|
||||
// 因此断言的是真实接线,而不是测试自造的替身。
|
||||
func surfaceApp(t *testing.T) *app {
|
||||
t.Helper()
|
||||
|
||||
caps := registry.New()
|
||||
d, err := tool.New(caps)
|
||||
if err != nil {
|
||||
t.Fatalf("tool.New: %v", err)
|
||||
}
|
||||
mgr, err := iot.New(config.IoTConfig{Provider: "mock"}, testLog())
|
||||
if err != nil {
|
||||
t.Fatalf("iot.New: %v", err)
|
||||
}
|
||||
a := &app{log: testLog(), caps: caps, dispatcher: d, iot: mgr}
|
||||
a.cfg = &config.Config{}
|
||||
a.cfg.Capacity.Enabled = true
|
||||
a.wireTranscode()
|
||||
a.wireCapabilitySurface()
|
||||
return a
|
||||
}
|
||||
|
||||
// toolNames 返回注册表里当前所有**可调用工具**的名字(模型真正会看到的那一面)。
|
||||
func toolNames(a *app) []string {
|
||||
caps := a.caps.List(registry.KindTool)
|
||||
out := make([]string, 0, len(caps))
|
||||
for _, c := range caps {
|
||||
out = append(out, c.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestWireCapabilitySurface_RegistersIoTCapabilityAndListTool 断言 (a)+(c) 的组合根
|
||||
// 落地:IoT 子系统以 KindIoT 进能力面(此前全仓无人注册该 kind)、只读的
|
||||
// capability_list 进注册表并被广告给模型,且它读出的外围状态经真实分发器可用。
|
||||
func TestWireCapabilitySurface_RegistersIoTCapabilityAndListTool(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := surfaceApp(t)
|
||||
|
||||
iotCap, err := a.caps.Get(iotCapabilityName)
|
||||
if err != nil {
|
||||
t.Fatalf("IoT 子系统没进能力注册表: %v", err)
|
||||
}
|
||||
if iotCap.Kind != registry.KindIoT {
|
||||
t.Fatalf("IoT 能力类型 = %q, want %q", iotCap.Kind, registry.KindIoT)
|
||||
}
|
||||
if _, ok := iotCap.Value.(*iot.Manager); !ok {
|
||||
t.Fatalf("IoT 能力值类型 = %T, want *iot.Manager", iotCap.Value)
|
||||
}
|
||||
|
||||
listCap, err := a.caps.Get(registry.ToolName)
|
||||
if err != nil {
|
||||
t.Fatalf("capability_list 没进能力注册表: %v", err)
|
||||
}
|
||||
if listCap.Kind != registry.KindTool {
|
||||
t.Fatalf("清单能力类型 = %q, want %q", listCap.Kind, registry.KindTool)
|
||||
}
|
||||
if listCap.Policy.AllowInGroup || listCap.Policy.AllowInStranger {
|
||||
t.Fatalf("清单工具的策略必须保持 fail-closed(owner-only),got %+v", listCap.Policy)
|
||||
}
|
||||
|
||||
advertised := false
|
||||
for _, s := range a.dispatcher.ToolSchemas() {
|
||||
if s.Name == registry.ToolName {
|
||||
advertised = true
|
||||
}
|
||||
}
|
||||
if !advertised {
|
||||
t.Fatalf("capability_list 未被广告给模型:%v", toolNames(a))
|
||||
}
|
||||
|
||||
// 走真实分发器(含 JSON-Schema 校验与工具执行),断言外围只读状态真的读得出来。
|
||||
devices, err := a.iot.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("iot.List: %v", err)
|
||||
}
|
||||
val, err := a.dispatcher.Invoke(context.Background(), registry.ToolName, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke(%s): %v", registry.ToolName, err)
|
||||
}
|
||||
raw, err := json.Marshal(val)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
text := string(raw)
|
||||
if !strings.Contains(text, fmt.Sprintf(`"devices=%d`, len(devices))) {
|
||||
t.Fatalf("清单没报出外围设备台数(want devices=%d):%s", len(devices), text)
|
||||
}
|
||||
if !strings.Contains(text, `"name":"iot"`) || !strings.Contains(text, `"kind":"iot"`) {
|
||||
t.Fatalf("清单没列出 IoT 子系统条目:%s", text)
|
||||
}
|
||||
if !strings.Contains(text, `"name":"transcode"`) {
|
||||
t.Fatalf("清单没列出已注册的 transcode 工具:%s", text)
|
||||
}
|
||||
|
||||
// 只读:清单调用(含外围状态读取)不得改动任何设备。
|
||||
after, err := a.iot.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("iot.List (after): %v", err)
|
||||
}
|
||||
if got, want := deviceSnapshot(after), deviceSnapshot(devices); got != want {
|
||||
t.Fatalf("清单调用改动了设备状态(只读红线):\nwant=%s\ngot=%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// deviceSnapshot 把设备清单渲染成可比较的快照(按 id 排序,状态键排序)。
|
||||
func deviceSnapshot(devices []iot.Device) string {
|
||||
lines := make([]string, 0, len(devices))
|
||||
for _, d := range devices {
|
||||
keys := make([]string, 0, len(d.State))
|
||||
for k := range d.State {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
pairs := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
pairs = append(pairs, fmt.Sprintf("%s=%v", k, d.State[k]))
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%s|%s|%s|%t|%s", d.ID, d.Name, d.Type, d.Online, strings.Join(pairs, ",")))
|
||||
}
|
||||
sort.Strings(lines)
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// TestWireCapabilitySurface_NewToolsReachableUnderCapacity 断言 (b):CAPACITY_ENABLED=true
|
||||
// 时**新工具**(transcode / capability_list)在静息与唤醒两个内置档位集的允许集内。
|
||||
//
|
||||
// 判据用**注册表里的真实工具名**驱动判定器:若 signals.go 的档位集字面量与注册名
|
||||
// 漂移(例如把 capability_list 写成 capability-list),该名字就不在 AllToolNames 里、
|
||||
// 交集后消失 ⇒ 本用例变红。
|
||||
func TestWireCapabilitySurface_NewToolsReachableUnderCapacity(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := surfaceApp(t)
|
||||
dec := a.buildCapacity()
|
||||
if dec == nil || !dec.Enabled() {
|
||||
t.Fatal("CAPACITY_ENABLED=true 时判定器必须启用")
|
||||
}
|
||||
names := toolNames(a)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
msg string
|
||||
want capacity.State
|
||||
}{
|
||||
{"静息", "你好", capacity.StateQuiet},
|
||||
{"唤醒", "帮我查一下资料", capacity.StateAwake},
|
||||
}
|
||||
for _, c := range cases {
|
||||
dec := dec.Decide(context.Background(), capacity.Input{
|
||||
Messages: []string{c.msg},
|
||||
CurrentState: capacity.StateQuiet,
|
||||
AllToolNames: names,
|
||||
})
|
||||
if dec.State != c.want {
|
||||
t.Fatalf("输入 %q 判定态 = %s, want %s", c.msg, dec.State, c.want)
|
||||
}
|
||||
for _, want := range []string{"transcode", registry.ToolName} {
|
||||
if !containsTool(dec.AllowedTools, want) {
|
||||
t.Fatalf("%s档位允许集缺 %q:%v(新工具被挡住 = 裁决 15 在安静态失效)",
|
||||
c.name, want, dec.AllowedTools)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// containsTool 报告名字是否在允许集里。
|
||||
func containsTool(names []string, want string) bool {
|
||||
for _, n := range names {
|
||||
if n == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
|
||||
)
|
||||
|
||||
// 本文件拥有唤醒式能力(capacity)决策层的装配:判定器构造、工具集 JSON 解析、
|
||||
// L3 缓存 TTL 解析与 capacity_awaken 事件出口。它从 wire_runtime.go 拆出
|
||||
// (AGENTS §10 文件 ≤500 行;wire.go 拆分建议同样适用于本文件)。
|
||||
|
||||
// buildCapacity 在启用时构造唤醒式能力判定器;未启用则返回 nil(loop 保持默认的
|
||||
// 「全工具」行为)。它由 CAPACITY_ENABLED 显式开启;允许集映射取自
|
||||
// CAPACITY_TOOL_SETS,解析失败时回落到内置默认值。
|
||||
//
|
||||
// 模型辅助的 L3 层(裁决 14.4.4)是第二个、彼此独立的开关
|
||||
// (CAPACITY_L3_ENABLED,默认 false):即使能力层已开,L3 仍保持静默,除非被
|
||||
// 显式要求;其判定结果的缓存 TTL(CAPACITY_L3_CACHE_TTL,默认 5m)在此解析,
|
||||
// 因为配置加载器只承载标量。
|
||||
//
|
||||
// 人格级唤醒覆盖(裁决 14.4.3)分两处进入决策层。本函数填**全局**兜底
|
||||
// (Config.Awakening),取自当前主角色 YAML 里的块(见 defaultAwakening):
|
||||
// 未声明该块的人格继承的就是它。每个 turn 的取值由 awakeningFor 解析,loop 经
|
||||
// loop.Options.PersonaAwakening 对该 turn 自己的角色调用它——没有它,非默认
|
||||
// 角色的 `awakening` 块会被解析、校验,然后被静默忽略(design/01 §2.1 禁止)。
|
||||
func (a *app) buildCapacity() *capacity.Decider {
|
||||
if !a.cfg.Capacity.Enabled {
|
||||
return nil
|
||||
}
|
||||
sets, err := parseCapacityToolSets(a.cfg.Capacity.ToolSetsJSON)
|
||||
if err != nil {
|
||||
a.log.Warn("capacity tool sets parse failed, using built-in defaults", "error", err)
|
||||
sets = nil
|
||||
}
|
||||
awakening := a.defaultAwakening()
|
||||
if !awakening.IsZero() {
|
||||
logger.WithSource(a.log, "cmd/cyrene").Info("capacity awakening override applied",
|
||||
"default_state", awakening.DefaultState, "min_confidence", awakening.MinConfidence,
|
||||
"force_state", awakening.ForceState, "persona", a.defaultPersonaID())
|
||||
}
|
||||
cfg := capacity.Config{
|
||||
Enabled: true,
|
||||
MinConfidence: a.cfg.Capacity.MinConfidence,
|
||||
ToolSets: sets,
|
||||
Awakening: awakening,
|
||||
}
|
||||
if a.cfg.Capacity.L3Enabled {
|
||||
cfg.L3Enabled = true
|
||||
cfg.L3Classifier = capacity.NewLLMClassifier(a.modelAdapter, a.log)
|
||||
cfg.L3CacheTTL = parseL3CacheTTL(a.cfg.Capacity.L3CacheTTL, a.log)
|
||||
}
|
||||
return capacity.New(cfg, capacity.WithLogger(a.log))
|
||||
}
|
||||
|
||||
// parseL3CacheTTL 解析 CAPACITY_L3_CACHE_TTL 这个时长字符串。空值选代码默认
|
||||
// (5m,裁决 14.4.4 的落地建议);无法解析的值会告警并回落到同一默认,而不是
|
||||
// 静默关掉缓存。
|
||||
func parseL3CacheTTL(raw string, log *slog.Logger) time.Duration {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return capacity.DefaultL3CacheTTL
|
||||
}
|
||||
d, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
logger.WithSource(log, "cmd/cyrene").Warn("capacity L3 cache ttl unparsable, using the default",
|
||||
"value", raw, "default", capacity.DefaultL3CacheTTL.String(), "error", err)
|
||||
return capacity.DefaultL3CacheTTL
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// parseCapacityToolSets 解码 CAPACITY_TOOL_SETS 的 JSON 映射:态 → 允许的工具名
|
||||
// 列表。空白串产出 nil(用内置默认);某态取 null 表示不受限(全部工具)。
|
||||
func parseCapacityToolSets(raw string) (map[capacity.State][]string, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var m map[string][]string
|
||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||
return nil, fmt.Errorf("capacity tool sets: %w", err)
|
||||
}
|
||||
out := make(map[capacity.State][]string, len(m))
|
||||
for k, v := range m {
|
||||
out[capacity.State(k)] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// defaultAwakening 取当前主角色(`configs/users/owner.yaml` 的 `defaultPersona`,
|
||||
// 未配置时回落 `cyrene`)档案里的 `awakening` 块。它填的是决策层的**全局兜底**
|
||||
// (capacity.Config.Awakening);每个 turn 真正生效的档位由 awakeningFor 按该
|
||||
// turn 的角色解析(裁决 14.4.3 的后半)。两者共用同一处映射,因此「全局兜底」
|
||||
// 与「按角色取值」的口径绝不会分叉。
|
||||
//
|
||||
// 未装配人格注册表(降级启动 / 单测构造的 app)时返回零值 —— 注册表缺席时连
|
||||
// 默认角色都问不出来,此时不补任何档位。
|
||||
func (a *app) defaultAwakening() capacity.Awakening {
|
||||
if a.personas == nil {
|
||||
return capacity.Awakening{}
|
||||
}
|
||||
return a.awakeningFor(a.defaultPersonaID())
|
||||
}
|
||||
|
||||
// awakeningFor 取**该角色**档案里的 `awakening` 块,映射为决策层的人格档位覆盖
|
||||
// (design/14 §3,裁决 14.4.3)。它是 loop.Options.PersonaAwakening 的实现:循环
|
||||
// 每个 turn 按该 turn 的角色 id 调它,因此绑定非默认角色的会话用**自己**的档位
|
||||
// 判定能力面,而不是只吃默认角色的全局兜底。
|
||||
//
|
||||
// 下列情形一律返回零值 = **无覆盖**,由决策层走全局默认(Config.Awakening +
|
||||
// 全局阈值),绝不在此处凭空补档位:
|
||||
// - 尚未装配人格注册表(降级启动 / 单测构造的 app);
|
||||
// - 角色 id 为空或该角色不在注册表里(未知/已删除人格);
|
||||
// - 该角色档案里没写 `awakening` 块(缺席就是没有)。
|
||||
//
|
||||
// 档位是倾向/呈现,不是能力边界——它只影响本轮暴露的工具面与唤醒阈值。
|
||||
func (a *app) awakeningFor(personaID string) capacity.Awakening {
|
||||
if a.personas == nil || personaID == "" {
|
||||
return capacity.Awakening{}
|
||||
}
|
||||
p, ok := a.personas.Persona(personaID)
|
||||
if !ok || p == nil {
|
||||
return capacity.Awakening{}
|
||||
}
|
||||
return a.awakeningOf(p)
|
||||
}
|
||||
|
||||
// awakeningOf 把一份人格档案的 `awakening` 块映射为决策层的档位覆盖。映射不了
|
||||
// 的档位名按「无该档位」处理(awakeningStateOf 会告警),因此非法值绝不会被塞进
|
||||
// 决策层(未知态会落进「不受限工具集」分支)。
|
||||
func (a *app) awakeningOf(p *loader.Persona) capacity.Awakening {
|
||||
if p == nil || p.Awakening == nil {
|
||||
return capacity.Awakening{}
|
||||
}
|
||||
aw := capacity.Awakening{
|
||||
DefaultState: a.awakeningStateOf(p.Awakening.DefaultState, p.ID, "default_state"),
|
||||
}
|
||||
if ov := p.Awakening.Overrides; ov != nil {
|
||||
aw.MinConfidence = ov.MinConfidence
|
||||
aw.ForceState = a.awakeningStateOf(ov.ForceState, p.ID, "overrides.force_state")
|
||||
}
|
||||
return aw
|
||||
}
|
||||
|
||||
// awakeningStateOf 映射单个档位字面量;映射不了就**告警**再按「无该档位」处理。
|
||||
// 经 loader.Load 进来的人格不可能走到告警分支(载入期已归一化并拒绝非法值),
|
||||
// 因此它是给「代码构造的人格」留的响铃:宁可吵一声,也不让配置静默失效
|
||||
// (design/01 §2.1 禁止静默无效的配置)。
|
||||
func (a *app) awakeningStateOf(raw, personaID, field string) capacity.State {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
state := awakeningState(raw)
|
||||
if state == "" {
|
||||
logger.WithSource(a.log, "cmd/cyrene").Warn("capacity awakening state unrecognized, treated as no override",
|
||||
"value", raw, "field", field, "persona", personaID)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// awakeningState 把 loader 归一化后的档位名映射到决策层的态。空串(未声明、或未知
|
||||
// 值)返回零值态——映射层不做默认填充,缺失的档位就是缺失。
|
||||
func awakeningState(raw string) capacity.State {
|
||||
switch raw {
|
||||
case loader.AwakeningStateQuiet:
|
||||
return capacity.StateQuiet
|
||||
case loader.AwakeningStateAwake:
|
||||
return capacity.StateAwake
|
||||
case loader.AwakeningStateFull:
|
||||
return capacity.StateFull
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// onCapacity 是 loop 的 OnCapacity 出口:它观测一次 capacity_awaken 跃迁,并以
|
||||
// `capacity_awaken` 帧推给 Web UI(design/14 §5)。loop 只在真实发生状态跃迁时
|
||||
// 才发(绝非每轮都发);事件携带发起会话 id,因此路由到那个会话而非全局广播。
|
||||
// 载荷是前端的 AwakenPayload {fromState,toState,tool}。
|
||||
func (a *app) onCapacity(ev capacity.Event) {
|
||||
logger.WithSource(a.log, "cmd/cyrene").Debug("capacity event",
|
||||
"type", ev.Type, "from", ev.FromState, "to", ev.ToState,
|
||||
"tool", ev.Tool, "at", ev.At, "session", ev.SessionID)
|
||||
if a.capacitySink == nil {
|
||||
return
|
||||
}
|
||||
a.capacitySink.BroadcastCapacityAwaken(ev.SessionID, string(ev.FromState), string(ev.ToState), ev.Tool)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
|
||||
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
|
||||
)
|
||||
|
||||
// 端到端覆盖裁决 14.4.3 的接线:人格 YAML 的 `awakening` 块 → loader 解析 →
|
||||
// 注册表 → 组合根 buildCapacity → capacity.Config.Awakening → 真实判定结果。
|
||||
// 之所以断言「判定结果」而不是只断言配置字段,是因为这个档位曾经只「写了没人读」
|
||||
// ——只看字段填没填,抓不住「读侧被摘掉」的回归。
|
||||
|
||||
const awakeningPersonaBody = `name: 测试角色
|
||||
characterRef: test-role
|
||||
identity:
|
||||
role: r
|
||||
systemPrompt: s
|
||||
`
|
||||
|
||||
// appWithPersonaYAML 造一个最小 app:注册表里装一个人格文件,并把它设为默认角色
|
||||
// (等价于 owner.yaml 的 defaultPersona 指向它)。
|
||||
func appWithPersonaYAML(t *testing.T, body string) *app {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "test-role.yaml")
|
||||
if err := os.WriteFile(path, []byte("id: test-role\n"+body), 0o600); err != nil {
|
||||
t.Fatalf("write persona yaml: %v", err)
|
||||
}
|
||||
return appWithPersonaFile(t, path)
|
||||
}
|
||||
|
||||
// appWithPersonaFile 同上,但从给定的档案路径载入(用于校验随仓库发布的主角色档案)。
|
||||
func appWithPersonaFile(t *testing.T, path string) *app {
|
||||
t.Helper()
|
||||
|
||||
p, err := loader.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("loader.Load(%s): %v", path, err)
|
||||
}
|
||||
reg := personareg.New()
|
||||
if err := reg.RegisterPersona(p); err != nil {
|
||||
t.Fatalf("RegisterPersona: %v", err)
|
||||
}
|
||||
if err := reg.RegisterCharacter(&personareg.Character{
|
||||
ID: p.ID, Name: p.Name, Default: true, PersonaID: p.ID,
|
||||
}); err != nil {
|
||||
t.Fatalf("RegisterCharacter: %v", err)
|
||||
}
|
||||
|
||||
a := &app{log: testLog(), personas: reg}
|
||||
a.cfg = &config.Config{}
|
||||
a.cfg.Capacity.Enabled = true
|
||||
return a
|
||||
}
|
||||
|
||||
// decide 跑一次真实判定(无覆盖的基线判定器与接线后的判定器都用它)。
|
||||
func decide(dec *capacity.Decider, messages ...string) capacity.State {
|
||||
return dec.Decide(context.Background(), capacity.Input{Messages: messages}).State
|
||||
}
|
||||
|
||||
// baselineDecider 是「没有接线 awakening」的对照判定器:同一个全局阈值,零覆盖。
|
||||
func baselineDecider() *capacity.Decider {
|
||||
return capacity.New(capacity.Config{Enabled: true}, capacity.WithLogger(testLog()))
|
||||
}
|
||||
|
||||
// TestCapacityAwakening_AwakePersonaWakesAmbiguousTurn 端到端正向:档案里声明
|
||||
// `default_state: 唤醒` 后,一个意图各层都拿不准的回合真的落到唤醒态——而同一
|
||||
// 回合在没有该档位时是静息。这就是「档位真的参与判定」的证据。
|
||||
func TestCapacityAwakening_AwakePersonaWakesAmbiguousTurn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := appWithPersonaYAML(t, awakeningPersonaBody+"awakening:\n default_state: 唤醒\n")
|
||||
dec := a.buildCapacity()
|
||||
|
||||
if got := decide(dec, "这个"); got != capacity.StateAwake {
|
||||
t.Fatalf("declared default_state=唤醒 → state %s, want awake", got)
|
||||
}
|
||||
if got := decide(baselineDecider(), "这个"); got != capacity.StateQuiet {
|
||||
t.Fatalf("baseline (no awakening) → state %s, want quiet", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCapacityAwakening_AbsentIsNoOverride 端到端反向:档案里没写该块时,解析
|
||||
// 结果为空、不报错,组合根传出零值覆盖,判定结果与「完全没接线」的基线逐字一致。
|
||||
func TestCapacityAwakening_AbsentIsNoOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := appWithPersonaYAML(t, awakeningPersonaBody)
|
||||
if aw := a.defaultAwakening(); !aw.IsZero() {
|
||||
t.Fatalf("defaultAwakening() = %+v, want zero(缺席就是没有)", aw)
|
||||
}
|
||||
|
||||
yamlPersona, ok := a.defaultPersona()
|
||||
if !ok {
|
||||
t.Fatal("default persona not resolvable")
|
||||
}
|
||||
if yamlPersona.Awakening != nil {
|
||||
t.Fatalf("Awakening = %+v, want nil", yamlPersona.Awakening)
|
||||
}
|
||||
|
||||
dec := a.buildCapacity()
|
||||
for _, tc := range [][]string{{"这个"}, {"帮我查一下资料", "继续"}} {
|
||||
if got, want := decide(dec, tc...), decide(baselineDecider(), tc...); got != want {
|
||||
t.Fatalf("决定 %v = %s, baseline = %s(无档位必须与基线一致)", tc, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCapacityAwakening_RealDefaultPersonaIsWired 锁定主人点名的那件事:随仓库
|
||||
// 发布的**当前主角色**档案(configs/personas/cyrene.yaml)真的声明了该档位,且
|
||||
// 它确实改变判定——「温柔陪伴」倾向把靠上下文趋势猜出的任务(L2,置信 0.66)
|
||||
// 压回静息,而明确的办事指令(L1,置信 0.9)照旧唤醒。
|
||||
func TestCapacityAwakening_RealDefaultPersonaIsWired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := appWithPersonaFile(t, filepath.Join("..", "..", "configs", "personas", "cyrene.yaml"))
|
||||
|
||||
p, ok := a.defaultPersona()
|
||||
if !ok || p.Awakening == nil {
|
||||
t.Fatalf("cyrene.yaml 缺少 awakening 块(裁决 14.4.3 要求主角色填上);persona=%+v", p)
|
||||
}
|
||||
if p.Awakening.DefaultState != loader.AwakeningStateQuiet {
|
||||
t.Fatalf("cyrene default_state = %q, want %q(温柔陪伴倾向)",
|
||||
p.Awakening.DefaultState, loader.AwakeningStateQuiet)
|
||||
}
|
||||
if ov := p.Awakening.Overrides; ov == nil || ov.MinConfidence <= capacity.DefaultMinConfidence {
|
||||
t.Fatalf("cyrene overrides = %+v, want a threshold above the global %.2f(更不易被打扰)",
|
||||
p.Awakening.Overrides, capacity.DefaultMinConfidence)
|
||||
}
|
||||
|
||||
dec := a.buildCapacity()
|
||||
trend := []string{"帮我查一下资料", "继续"}
|
||||
if got := decide(dec, trend...); got != capacity.StateQuiet {
|
||||
t.Fatalf("紧跟任务的话题延续 → %s, want quiet(人格级阈值压回静息)", got)
|
||||
}
|
||||
if got := decide(baselineDecider(), trend...); got != capacity.StateAwake {
|
||||
t.Fatalf("同一回合在无档位基线 → %s, want awake(证明差异来自该档位)", got)
|
||||
}
|
||||
if got := decide(dec, "帮我查一下资料"); got != capacity.StateAwake {
|
||||
t.Fatalf("明确的办事指令 → %s, want awake(抬高阈值不得把真任务也压掉)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCapacityAwakening_NilRegistryAndUnknownState 守住两个降级口:人格注册表还
|
||||
// 没装配时不 panic(返回无覆盖),未知档位名不落进决策层(宁可无覆盖并告警,
|
||||
// 也不让决策层收到非法态——非法态会命中「不受限工具集」分支)。
|
||||
func TestCapacityAwakening_NilRegistryAndUnknownState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var noReg *app = &app{log: testLog()}
|
||||
if aw := noReg.defaultAwakening(); !aw.IsZero() {
|
||||
t.Fatalf("no registry → %+v, want zero", aw)
|
||||
}
|
||||
|
||||
// loader 会把中文字面量归一化成规范态名,所以组合根这一层只认规范名;
|
||||
// 未声明(空串)与任何未知值都映射为「无该档位」。
|
||||
cases := map[string]capacity.State{
|
||||
"": "",
|
||||
"quiet": capacity.StateQuiet,
|
||||
"awake": capacity.StateAwake,
|
||||
"full": capacity.StateFull,
|
||||
"静息": "",
|
||||
"AWAKE": "",
|
||||
"nonsense": "",
|
||||
}
|
||||
for raw, want := range cases {
|
||||
if got := awakeningState(raw); got != want {
|
||||
t.Errorf("awakeningState(%q) = %q, want %q", raw, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 一个「代码构造」的人格(绕过 loader):非法档位名不得静默生效。
|
||||
reg := personareg.New()
|
||||
bad := &loader.Persona{
|
||||
ID: "bad", Name: "bad", CharacterRef: "bad",
|
||||
Identity: loader.Identity{Role: "r", SystemPrompt: "s"},
|
||||
Awakening: &loader.AwakeningParams{DefaultState: "睡觉"},
|
||||
}
|
||||
if err := reg.RegisterPersona(bad); err != nil {
|
||||
t.Fatalf("RegisterPersona: %v", err)
|
||||
}
|
||||
if err := reg.RegisterCharacter(&personareg.Character{ID: "bad", Name: "bad", Default: true, PersonaID: "bad"}); err != nil {
|
||||
t.Fatalf("RegisterCharacter: %v", err)
|
||||
}
|
||||
a := &app{log: testLog(), personas: reg}
|
||||
if aw := a.defaultAwakening(); !aw.IsZero() {
|
||||
t.Fatalf("unrecognized state → %+v, want zero(按无档位处理)", aw)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
|
||||
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
)
|
||||
|
||||
// 本文件端到端锁定裁决 14.4.3 的后半:**每个 turn 用该 turn 所属角色的唤醒档位**。
|
||||
// 只用默认角色的档位填 Config.Awakening(全局兜底)是不够的 —— 绑定非默认角色的
|
||||
// 会话会「档位被解析、被校验、却静默不生效」(design/01 §2.1 禁止静默无效配置)。
|
||||
// 因此这里的断言落在**模型实际看到的工具面**上:链路是 persona YAML → loader →
|
||||
// 注册表 → app.awakeningFor → loop.Options.PersonaAwakening → capacity.Input.Awakening
|
||||
// → Decide → 广告给模型的 schema。
|
||||
|
||||
// 加在两个角色身上的工具名取自 capacity 的内建档位集:terminal 在静息态就可见,
|
||||
// web-search 只在唤醒/全能态出现,因此「被广告的工具集」直接暴露判定结果。
|
||||
const (
|
||||
quietToolName = "terminal"
|
||||
awakeToolName = "web-search"
|
||||
)
|
||||
|
||||
// roleProbeTool 是只为广告工具名存在的 sdk.Tool 替身:本文件断言的是
|
||||
// 「哪些工具被广告给模型」,工具本身从不执行。
|
||||
type roleProbeTool string
|
||||
|
||||
func (t roleProbeTool) Name() string { return string(t) }
|
||||
func (t roleProbeTool) Description() string { return "role probe tool " + string(t) }
|
||||
func (t roleProbeTool) Parameters() (map[string]any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (t roleProbeTool) Execute(context.Context, any) (any, error) { return nil, nil }
|
||||
|
||||
// roleProbeAdapter 每次都记下流式请求,使测试能读回该 turn 广告给模型的工具面。
|
||||
type roleProbeAdapter struct{ streamReqs []model.Request }
|
||||
|
||||
func (a *roleProbeAdapter) Name() string { return "role-probe" }
|
||||
|
||||
func (a *roleProbeAdapter) Chat(context.Context, model.Request, model.Options) (model.Response, error) {
|
||||
return model.Response{Content: "好的"}, nil
|
||||
}
|
||||
|
||||
func (a *roleProbeAdapter) ChatStream(_ context.Context, req model.Request, on func(model.StreamEvent)) (model.Response, error) {
|
||||
a.streamReqs = append(a.streamReqs, req)
|
||||
if on != nil {
|
||||
on(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: "好的"})
|
||||
}
|
||||
return model.Response{Content: "好的"}, nil
|
||||
}
|
||||
|
||||
// twoRoleApp 注册两个角色,两者都来自真实 YAML(走与生产相同的载入期解析与校验):
|
||||
// `cyrene` 是默认角色且档案里**没有** `awakening` 块,`tasky` 带调用方给定的块。
|
||||
// 默认角色不声明档位,正是「不声明者继承全局兜底(零覆盖)」的现场。
|
||||
func twoRoleApp(t *testing.T, taskyAwakening string) *app {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
reg := personareg.New()
|
||||
for _, spec := range []struct {
|
||||
name string
|
||||
body string
|
||||
isDefault bool
|
||||
}{
|
||||
{"cyrene", awakeningPersonaBody, true},
|
||||
{"tasky", awakeningPersonaBody + taskyAwakening, false},
|
||||
} {
|
||||
path := filepath.Join(dir, spec.name+".yaml")
|
||||
if err := os.WriteFile(path, []byte("id: "+spec.name+"\n"+spec.body), 0o600); err != nil {
|
||||
t.Fatalf("write %s yaml: %v", spec.name, err)
|
||||
}
|
||||
p, err := loader.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("loader.Load(%s): %v", spec.name, err)
|
||||
}
|
||||
if err := reg.RegisterPersona(p); err != nil {
|
||||
t.Fatalf("RegisterPersona(%s): %v", spec.name, err)
|
||||
}
|
||||
if err := reg.RegisterCharacter(&personareg.Character{
|
||||
ID: p.ID, Name: p.Name, Default: spec.isDefault, PersonaID: p.ID,
|
||||
}); err != nil {
|
||||
t.Fatalf("RegisterCharacter(%s): %v", spec.name, err)
|
||||
}
|
||||
}
|
||||
a := &app{log: testLog(), personas: reg}
|
||||
a.cfg = &config.Config{}
|
||||
a.cfg.Capacity.Enabled = true
|
||||
return a
|
||||
}
|
||||
|
||||
// roleProbeLoop 按生产接线的形状组装带 capacity 的循环:逐 turn 的档位经
|
||||
// `resolve` 注入(生产传的正是 `a.awakeningFor`),`resolve` 为 nil 时是
|
||||
// 「接线前」的对照循环。
|
||||
func roleProbeLoop(t *testing.T, dec *capacity.Decider, ad model.Adapter, resolve func(string) capacity.Awakening) *loop.Loop {
|
||||
t.Helper()
|
||||
|
||||
caps := registry.New()
|
||||
d, err := tool.New(caps)
|
||||
if err != nil {
|
||||
t.Fatalf("tool.New: %v", err)
|
||||
}
|
||||
for _, name := range []string{quietToolName, awakeToolName} {
|
||||
if err := d.Register(roleProbeTool(name)); err != nil {
|
||||
t.Fatalf("register %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
pool := make(map[string]*agent.Agent, 2)
|
||||
for _, id := range []string{"cyrene", "tasky"} {
|
||||
ag, err := agent.New("你是"+id+"。", d, nil, agent.WithModel(ad, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("agent.New(%s): %v", id, err)
|
||||
}
|
||||
pool[id] = ag
|
||||
}
|
||||
return loop.New(loop.Options{
|
||||
Agent: pool["cyrene"],
|
||||
Agents: pool,
|
||||
Tools: d,
|
||||
Capacity: dec,
|
||||
PersonaID: "cyrene",
|
||||
PersonaAwakening: resolve,
|
||||
OnStream: func(model.StreamEvent) {},
|
||||
})
|
||||
}
|
||||
|
||||
// roleTurnTools 以 personaID 的会话身份驱动一个 turn,并返回模型实际看到的工具名。
|
||||
// 多个 texts 按时间顺序入列,最后一个即本轮的用户消息(与判定器看到的窗口一致)。
|
||||
func roleTurnTools(t *testing.T, l *loop.Loop, ad *roleProbeAdapter, personaID string, texts ...string) map[string]bool {
|
||||
t.Helper()
|
||||
|
||||
msgs := make([]model.Message, 0, len(texts))
|
||||
for _, txt := range texts {
|
||||
msgs = append(msgs, model.TextMessage(model.RoleUser, txt))
|
||||
}
|
||||
env := loop.SessionEnv{ActorEnv: restrict.ActorOwner, MemWritable: true, PersonaID: personaID}
|
||||
ctx := loop.WithSessionEnv(context.Background(), env)
|
||||
if _, err := l.Run(ctx, "web:"+personaID, msgs); err != nil {
|
||||
t.Fatalf("Run(%s): %v", personaID, err)
|
||||
}
|
||||
if len(ad.streamReqs) == 0 {
|
||||
t.Fatalf("no streaming request captured for %s", personaID)
|
||||
}
|
||||
req := ad.streamReqs[len(ad.streamReqs)-1]
|
||||
out := make(map[string]bool, len(req.Tools))
|
||||
for _, s := range req.Tools {
|
||||
out[s.Name] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestCapacityAwakening_PerRoleAppliesToItsOwnTurns 是裁决 14.4.3 后半的端到端断言:
|
||||
// 同一个**意图各层都拿不准**的输入("这个")下,声明了档位的角色与没声明的角色
|
||||
// 得到不同的工具面,而没声明的角色与「完全没接线」的对照循环逐字一致。
|
||||
func TestCapacityAwakening_PerRoleAppliesToItsOwnTurns(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := twoRoleApp(t, "awakening:\n overrides:\n force_state: 唤醒\n")
|
||||
|
||||
// 档位确实按角色取出:非默认角色读自己的块,默认角色缺席即没有。
|
||||
if aw := a.awakeningFor("tasky"); aw.ForceState != capacity.StateAwake {
|
||||
t.Fatalf("awakeningFor(tasky) = %+v, want force_state awake(取自己的档案)", aw)
|
||||
}
|
||||
if aw := a.awakeningFor("cyrene"); !aw.IsZero() {
|
||||
t.Fatalf("awakeningFor(cyrene) = %+v, want zero(档案里没写该块)", aw)
|
||||
}
|
||||
|
||||
dec := a.buildCapacity()
|
||||
const ambiguous = "这个"
|
||||
|
||||
wired := &roleProbeAdapter{}
|
||||
withResolver := roleProbeLoop(t, dec, wired, a.awakeningFor)
|
||||
baseline := &roleProbeAdapter{}
|
||||
// 对照循环就是接线前的形状:判定器照旧,只是没有人按 turn 传档位。
|
||||
before := roleProbeLoop(t, dec, baseline, nil)
|
||||
|
||||
declaring := roleTurnTools(t, withResolver, wired, "tasky", ambiguous)
|
||||
undeclaring := roleTurnTools(t, withResolver, wired, "cyrene", ambiguous)
|
||||
// 对照循环跑**同一个**角色与同一个输入。
|
||||
undeclaringBefore := roleTurnTools(t, before, baseline, "cyrene", ambiguous)
|
||||
|
||||
if !declaring[awakeToolName] || !declaring[quietToolName] {
|
||||
t.Fatalf("声明档位的角色工具面 = %v, want terminal+web-search(force_state 唤醒)", declaring)
|
||||
}
|
||||
if undeclaring[awakeToolName] {
|
||||
t.Fatalf("未声明档位的角色工具面 = %v, want 静息(web-search 不得被广告)", undeclaring)
|
||||
}
|
||||
if len(declaring) == len(undeclaring) {
|
||||
t.Fatalf("同一模糊输入下两个角色的工具面相同(%v)—— 档位没有按 turn 生效", declaring)
|
||||
}
|
||||
if !sameToolSet(undeclaring, undeclaringBefore) {
|
||||
t.Fatalf("未声明档位者接线上后行为变了:%v vs 接线前 %v", undeclaring, undeclaringBefore)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCapacityAwakening_ThresholdRoleDiffersFromGlobal 同一落点的第二个判据:档位
|
||||
// 也可以只抬高阈值(min_confidence)。默认角色(零覆盖)在话题延续的模糊回合会因
|
||||
// L2 趋势猜到「办事」而唤醒,非默认角色把自己的阈值抬到 0.95 后同一回合回到静息。
|
||||
func TestCapacityAwakening_ThresholdRoleDiffersFromGlobal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := twoRoleApp(t, "awakening:\n overrides:\n min_confidence: 0.95\n")
|
||||
dec := a.buildCapacity()
|
||||
// L2 趋势(置信 0.66 量级):默认角色零覆盖 → 唤醒;抬高阈值者 → 静息。
|
||||
trend := []string{"帮我查一下资料", "继续"}
|
||||
|
||||
wired := &roleProbeAdapter{}
|
||||
withResolver := roleProbeLoop(t, dec, wired, a.awakeningFor)
|
||||
|
||||
// 对照:完全没接线的循环在同一序列上唤醒(证明差异来自该角色的档位)。
|
||||
baseline := &roleProbeAdapter{}
|
||||
before := roleProbeLoop(t, dec, baseline, nil)
|
||||
|
||||
loose := roleTurnTools(t, before, baseline, "cyrene", trend...)
|
||||
strict := roleTurnTools(t, withResolver, wired, "tasky", trend...)
|
||||
if !loose[awakeToolName] {
|
||||
t.Fatalf("对照循环工具面 = %v, want 唤醒(web-search 被广告)", loose)
|
||||
}
|
||||
if strict[awakeToolName] {
|
||||
t.Fatalf("抬高阈值的角色工具面 = %v, want 静息(0.95 阈值压回)", strict)
|
||||
}
|
||||
}
|
||||
|
||||
// sameToolSet 比较两个工具名集合是否逐项相同(忽略注册顺序)。
|
||||
func sameToolSet(a, b map[string]bool) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for name := range a {
|
||||
if !b[name] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package main
|
||||
|
||||
// 本文件把**渠道事件留存**接进组合根(design/26 §4.3/§4.4;切片 E3+E4):按配置构造
|
||||
// 事件存储、交给桥接层注入(wire_channels.go 的 channelbridge.Options.EventStore),
|
||||
// 拉起常驻的「保留期清扫 + 内存计数落盘」循环,并在退出前最后一次落盘。
|
||||
//
|
||||
// 三条取舍逐字对齐参照实现 `wire_document_gc.go`(design/26 §4.4 明写「不另造」):
|
||||
//
|
||||
// 1. **启动期清扫不阻塞启动**:它是常驻 goroutine 的第一件事(紧随子系统启动之后
|
||||
// 开始,网关监听之前就绪),而不是在 newApp/run 里同步等它跑完。
|
||||
// 2. **失败不退出进程**:清扫 / 落盘失败只记一条 WARN 并等下一轮;一轮失败只是
|
||||
// 「这一轮没扫干净」,绝不让进程退出。
|
||||
// 3. **关闭 = 显式配 ≤0**:`CHANNEL_EVENT_RETENTION=0`(或负值)或
|
||||
// `CHANNEL_EVENT_GC_INTERVAL=0` 都不开启清扫,NewStore 会把关闭原因记进日志
|
||||
// (静默的「配了却不生效」不存在)。
|
||||
//
|
||||
// ⚠️ 与参照实现的一处**已知差异**(如实登记,不假装对齐):docGC 的 `stop()` 由
|
||||
// `wire_services.go` 的 close 调用,而该文件由别的切片在改(本片不许动)。因此这里的
|
||||
// 退出路径做成**循环自己的 ctx.Done 分支**:ctx 取消即用独立超时的 context 落最后一次盘。
|
||||
// 语义与「退出前最后一次 Flush」相同,区别只在**不阻塞** close —— 计数本就是尽力而为的
|
||||
// 近似值(§4.3),并与关库并发(真撞上只记 WARN,不丢留存面本身)。
|
||||
//
|
||||
// 三个键的读取走 `config.LoadDurationEnv`(进程环境 > `.env` > E1 导出的默认常量):
|
||||
// `.env` 不写回进程环境,直接 os.Getenv 会让只写在 `.env` 里的声明静默失效。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
|
||||
)
|
||||
|
||||
// 三个 env 键名:必须与 `.env.example` **逐字一致**(AGENTS §10 的 E6 条目)。
|
||||
const (
|
||||
channelEventRetentionEnv = "CHANNEL_EVENT_RETENTION"
|
||||
channelEventGCIntervalEnv = "CHANNEL_EVENT_GC_INTERVAL"
|
||||
channelEventCounterFlushEnv = "CHANNEL_EVENT_COUNTER_FLUSH"
|
||||
)
|
||||
|
||||
// channelEventFlushTimeout 是**退出前最后一次 Flush** 的时长上界。它必须配一个独立的
|
||||
// context:ctx 在这一刻已被取消,拿它去写库只会立刻拿到 context.Canceled —— 那正是
|
||||
// 「退出时最后一次 flush」最容易悄悄失效的地方。
|
||||
const channelEventFlushTimeout = 5 * time.Second
|
||||
|
||||
// wireChannelEvents 构造事件留存面并挂到 app 上(随后由 wireChannels 注入桥接层)。
|
||||
// 它**不**启动任何 goroutine:常驻循环由 main 的 startChannelEvents 在子系统启动之后
|
||||
// 拉起(与文档 GC 的分工逐字相同)。
|
||||
func (a *app) wireChannelEvents() {
|
||||
a.channelEvents = a.newChannelEventStore(envPath)
|
||||
}
|
||||
|
||||
// newChannelEventStore 按配置构造事件存储。库未接(`a.db == nil`,只可能出现在测试
|
||||
// 夹具里)时返回 nil:桥接层随即走「未接线」的 fail-visible 降级路径(内存计数 +
|
||||
// 首次一条 DEBUG),入站流水线照常,绝不因为没有存储而失败或丢帧。
|
||||
//
|
||||
// 默认值一律取 E1(`internal/runtime/events`)导出的常量 —— **不在这里另写一份字面量**:
|
||||
// 两处各写一份 720h/1h/30s,改一处就会让「文档承诺的默认值」与「代码里的默认值」漂移。
|
||||
// `MaxPendingKeys` 没有 env 键(design/26 §4.4 只给了三个旋钮),同样取 E1 的默认常量。
|
||||
func (a *app) newChannelEventStore(envFilePath string) *events.SQLStore {
|
||||
if a.db == nil {
|
||||
a.log.Warn("channel event store skipped: no database connection")
|
||||
return nil
|
||||
}
|
||||
store, err := events.NewStore(events.Config{
|
||||
DB: a.db,
|
||||
Log: a.log,
|
||||
FlushInterval: durationEnv(a.log, envFilePath, channelEventCounterFlushEnv, events.DefaultFlushInterval),
|
||||
MaxPendingKeys: events.DefaultMaxPendingKeys,
|
||||
GCInterval: durationEnv(a.log, envFilePath, channelEventGCIntervalEnv, events.DefaultGCInterval),
|
||||
Retention: durationEnv(a.log, envFilePath, channelEventRetentionEnv, events.DefaultRetention),
|
||||
})
|
||||
if err != nil {
|
||||
// NewStore 唯一的失败是 DB 为 nil(上面已挡掉):仍如实处理,绝不吞错。
|
||||
a.log.Warn("channel event store unavailable", "err", err)
|
||||
return nil
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
// durationEnv 读一个 duration 型 env 键(进程环境 > `.env` > def)。配了但读不懂时记
|
||||
// 一条带键名与原始取值的 WARN 并回落 def —— 错的是那一个键,不该让整条留存面失效
|
||||
// (AGENTS §10:非法配置不许静默失效,但也不许一票否决)。消息刻意保持中立(不带
|
||||
// 具体功能名),使渠道事件与链路图两处共用同一份读取口径(切片 E4 / F2)。
|
||||
func durationEnv(log *slog.Logger, envFilePath, key string, def time.Duration) time.Duration {
|
||||
value, err := config.LoadDurationEnv(envFilePath, key, def)
|
||||
if err != nil {
|
||||
log.Warn("duration env ignored, falling back to default",
|
||||
"env", key, "error", err, "fallback", def.String())
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// channelEventStore 返回注入桥接层的留存面:未接线时返回**真正的 nil 接口**,而不是
|
||||
// 「装着 nil 指针的接口」—— 后者在 Go 里不等于 nil,会让桥接层的「未接线 ⇒ 内存计数」
|
||||
// 判据失效(症状是每帧都撞一次 nil 指针 panic)。
|
||||
func (a *app) channelEventStore() events.Store {
|
||||
if a.channelEvents == nil {
|
||||
return nil
|
||||
}
|
||||
return a.channelEvents
|
||||
}
|
||||
|
||||
// startChannelEvents 拉起留存维护循环(未接线时是空操作)。
|
||||
func (a *app) startChannelEvents(ctx context.Context) {
|
||||
if a.channelEvents == nil {
|
||||
a.log.Info("channel event upkeep disabled: store not wired",
|
||||
"effect", "events are counted in memory only (bridged to the api layer counters)")
|
||||
return
|
||||
}
|
||||
keeper := &channelEventKeeper{
|
||||
store: a.channelEvents,
|
||||
log: logger.WithSource(a.log, "cmd/cyrene/wire_channel_events"),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
a.channelEventKeeper = keeper
|
||||
go keeper.loop(ctx)
|
||||
}
|
||||
|
||||
// channelEventKeeper 是渠道事件留存的常驻维护任务:保留期清扫 + 内存计数落盘 +
|
||||
// 退出前最后一次落盘。它持有**存储句柄**(不是 app),因此生命周期与用法一目了然。
|
||||
type channelEventKeeper struct {
|
||||
store *events.SQLStore
|
||||
log *slog.Logger
|
||||
// done 在循环退出时关闭。它让「退出前最后一次 Flush」**可被观测**:测试据此确认
|
||||
// 内存里的计数真的落了盘,而不是随 goroutine 一起消失。
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// loop 是维护循环:启动期先清扫一次、落一次盘,此后按各自间隔定时跑,直到 ctx 被取消
|
||||
// (取消时落最后一次盘再退出)。
|
||||
func (k *channelEventKeeper) loop(ctx context.Context) {
|
||||
defer close(k.done)
|
||||
k.sweep(ctx)
|
||||
k.flush(ctx)
|
||||
|
||||
gcTicks, stopGC := k.gcTicker()
|
||||
defer stopGC()
|
||||
flushTicks := time.NewTicker(k.store.FlushInterval())
|
||||
defer flushTicks.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
k.flushOnShutdown()
|
||||
return
|
||||
case <-gcTicks:
|
||||
k.sweep(ctx)
|
||||
case <-flushTicks.C:
|
||||
k.flush(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// gcTicker 给出清扫的定时通道;GC 关闭时返回 **nil 通道**(select 上永不触发)。
|
||||
// `time.NewTicker(0)` 会 panic,所以「配了 ≤0 = 关闭」必须在**这里**就分岔,而不是
|
||||
// 建一个零间隔 ticker 再指望它不跑。
|
||||
func (k *channelEventKeeper) gcTicker() (<-chan time.Time, func()) {
|
||||
if !k.store.Enabled() {
|
||||
return nil, func() {}
|
||||
}
|
||||
ticker := time.NewTicker(k.store.Interval())
|
||||
return ticker.C, ticker.Stop
|
||||
}
|
||||
|
||||
// sweep 跑一轮保留期清扫。每轮的**成功**汇总由 store 自己记(E1 的 Sweep 已记一条,
|
||||
// 含删除行数与批次),这里只负责把「这一轮整个没跑成」记成 WARN 并让循环继续。
|
||||
func (k *channelEventKeeper) sweep(ctx context.Context) {
|
||||
if _, err := k.store.Sweep(ctx); err != nil {
|
||||
k.log.Warn("channel event sweep failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// flush 把内存里待落盘的计数写进库;失败只 WARN(计数是尽力而为的近似值,§4.3)。
|
||||
func (k *channelEventKeeper) flush(ctx context.Context) {
|
||||
if err := k.store.Flush(ctx); err != nil {
|
||||
k.log.Warn("channel event counter flush failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// flushOnShutdown 是退出前的最后一次落盘:用独立超时的 context(此刻调用方的 ctx
|
||||
// 已取消),失败只 WARN —— 退出路径绝不因为一次写库失败而 panic 或阻塞。
|
||||
func (k *channelEventKeeper) flushOnShutdown() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), channelEventFlushTimeout)
|
||||
defer cancel()
|
||||
k.flush(ctx)
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package main
|
||||
|
||||
// 本文件是**渠道事件留存组合根接线**的验收(design/26 §4.3/§4.4;切片 E3+E4)。
|
||||
//
|
||||
// 两道并行的取证:
|
||||
//
|
||||
// 1. **接线存在性**(与 TestWireDocumentGC_IsWired 同一手法):组合根本身无法在单元
|
||||
// 测试里实例化(开库、迁移、绑端口,而后端还在运行),因此「存储真的被构造」「真的
|
||||
// 注入桥接层」「常驻循环真的被拉起」「退出前真的落最后一次盘」只能在源码层钉住 ——
|
||||
// 少任何一处,留存都会**静默不工作**(事件照丢、日志里一句都没有),而这不
|
||||
// 会让任何既有用例变红。最后一段还核对 `.env.example` 的三个键(AGENTS §10)。
|
||||
// 2. **行为**:三个 env 键的默认值必须来自 E1 导出的常量(**不另写一份字面量**)、
|
||||
// 显式 ≤0 = 关闭清扫、只写在 `.env` 里也真实生效、非法取值回落默认 + WARN;
|
||||
// 以及「退出前最后一次落盘」真的把内存里的计数写进库。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
|
||||
)
|
||||
|
||||
// TestWireChannelEvents_IsWired 钉住组合根的四个接缝 + `.env.example` 同步。
|
||||
func TestWireChannelEvents_IsWired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
seams := map[string][]string{
|
||||
"wire_channel_events.go": {
|
||||
// 存储真的被构造,且默认值取 E1 导出的常量(不另写字面量)。
|
||||
"events.NewStore(events.Config{",
|
||||
"MaxPendingKeys: events.DefaultMaxPendingKeys",
|
||||
"durationEnv(a.log, envFilePath, channelEventCounterFlushEnv, events.DefaultFlushInterval)",
|
||||
"durationEnv(a.log, envFilePath, channelEventGCIntervalEnv, events.DefaultGCInterval)",
|
||||
"durationEnv(a.log, envFilePath, channelEventRetentionEnv, events.DefaultRetention)",
|
||||
// 未接线时交出**真 nil 接口**(装 nil 指针的接口不等于 nil,会让桥接层的
|
||||
// 「未接线 ⇒ 内存计数」降级判据失效)。
|
||||
"func (a *app) channelEventStore() events.Store {",
|
||||
// GC 关闭时不许建零间隔 ticker(time.NewTicker(0) 会 panic)。
|
||||
"if !k.store.Enabled() {",
|
||||
// 退出前最后一次落盘,用独立 ctx(此刻调用方的 ctx 已取消)。
|
||||
"k.flushOnShutdown()",
|
||||
},
|
||||
"wire_channels.go": {
|
||||
"EventStore: a.channelEventStore(),",
|
||||
},
|
||||
"wire.go": {
|
||||
// 必须在 wireChannels 之前构造:桥接层在构造时就注入它。
|
||||
"a.wireChannelEvents()",
|
||||
},
|
||||
"main.go": {
|
||||
// 常驻循环:没有这一行,留存面只存在于代码里而从不维护(清扫与计数落盘都不跑)。
|
||||
"a.startChannelEvents(ctx)",
|
||||
},
|
||||
}
|
||||
for file, wants := range seams {
|
||||
src, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", file, err)
|
||||
}
|
||||
for _, want := range wants {
|
||||
if !strings.Contains(string(src), want) {
|
||||
t.Fatalf("%s 里找不到 %q —— 渠道事件留存没有被接上组合根", file, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 三个新键必须与代码里的键名**逐字一致**(AGENTS §10 的 E6 条目)。
|
||||
src, err := os.ReadFile("wire_channel_events.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read wire_channel_events.go: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
|
||||
if err != nil {
|
||||
t.Fatalf("read .env.example: %v", err)
|
||||
}
|
||||
for _, key := range []string{channelEventRetentionEnv, channelEventGCIntervalEnv, channelEventCounterFlushEnv} {
|
||||
if !strings.Contains(string(src), `"`+key+`"`) {
|
||||
t.Fatalf("wire_channel_events.go 里没有键名常量 %q", key)
|
||||
}
|
||||
if !strings.Contains(string(raw), key+"=") {
|
||||
t.Fatalf(".env.example 里找不到 %q(新 env 键必须同步模板,AGENTS §10)", key+"=")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// channelEventTestApp 构造一个只带留存面所需依赖的 app(库 + 静默 logger)。
|
||||
func channelEventTestApp(t *testing.T, log *slog.Logger) *app {
|
||||
t.Helper()
|
||||
if log == nil {
|
||||
log = testLog()
|
||||
}
|
||||
return &app{db: openMigratedTestDB(t, filepath.Join(t.TempDir(), "cyrene.db")), log: log}
|
||||
}
|
||||
|
||||
// unsetChannelEventEnv 把三个键从**进程环境**里拿掉并在用例结束后复原。
|
||||
//
|
||||
// ⚠️ 不能用 `t.Setenv(key, "")` 代替:空值在优先级链里仍然「存在」,会**盖住** `.env`
|
||||
// 里的同名声明(lookupRaw 先看 os.LookupEnv),而「只写在 .env 里也生效」正是要验的东西。
|
||||
func unsetChannelEventEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, key := range []string{channelEventRetentionEnv, channelEventGCIntervalEnv, channelEventCounterFlushEnv} {
|
||||
key := key
|
||||
prev, had := os.LookupEnv(key)
|
||||
if had {
|
||||
t.Cleanup(func() { _ = os.Setenv(key, prev) })
|
||||
} else {
|
||||
t.Cleanup(func() { _ = os.Unsetenv(key) })
|
||||
}
|
||||
if err := os.Unsetenv(key); err != nil {
|
||||
t.Fatalf("unset %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewChannelEventStore_默认值取E1导出常量_正常:未配置时三个旋钮必须**就是**
|
||||
// E1 导出的默认值(720h / 1h / 30s),并且清扫是开启的 —— 若这里另写了一份字面量,
|
||||
// 「文档承诺的默认值」与「代码里的默认值」迟早漂移。
|
||||
func TestNewChannelEventStore_默认值取E1导出常量_正常(t *testing.T) {
|
||||
unsetChannelEventEnv(t)
|
||||
store := channelEventTestApp(t, nil).newChannelEventStore("")
|
||||
if store == nil {
|
||||
t.Fatal("有库时必须构造出事件存储")
|
||||
}
|
||||
if store.Retention() != events.DefaultRetention {
|
||||
t.Fatalf("Retention = %v, want %v(E1 导出的默认常量)", store.Retention(), events.DefaultRetention)
|
||||
}
|
||||
if store.Interval() != events.DefaultGCInterval {
|
||||
t.Fatalf("GC 间隔 = %v, want %v", store.Interval(), events.DefaultGCInterval)
|
||||
}
|
||||
if store.FlushInterval() != events.DefaultFlushInterval {
|
||||
t.Fatalf("计数落盘间隔 = %v, want %v", store.FlushInterval(), events.DefaultFlushInterval)
|
||||
}
|
||||
if !store.Enabled() {
|
||||
t.Fatal("未配置时清扫必须开启(漏配 != 关闭)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewChannelEventStore_显式零关闭清扫_边界:`≤0 = 关闭`(design/26 §4.4)——
|
||||
// 0 刻意**不**解释成「立即清扫」,那会让一次笔误抹掉唯一的取证面。
|
||||
func TestNewChannelEventStore_显式零关闭清扫_边界(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
key string
|
||||
value string
|
||||
}{
|
||||
{"保留期为 0", channelEventRetentionEnv, "0"},
|
||||
{"保留期为负", channelEventRetentionEnv, "-1h"},
|
||||
{"清扫间隔为 0", channelEventGCIntervalEnv, "0"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
unsetChannelEventEnv(t)
|
||||
t.Setenv(tc.key, tc.value)
|
||||
store := channelEventTestApp(t, nil).newChannelEventStore("")
|
||||
if store == nil {
|
||||
t.Fatal("配置非法不该让存储整个消失")
|
||||
}
|
||||
if store.Enabled() {
|
||||
t.Fatalf("%s=%s 时清扫仍开启,want 关闭(显式 ≤0 = 关闭)", tc.key, tc.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewChannelEventStore_只写在env文件里也生效_正常:`.env` 不写回进程环境,直接
|
||||
// os.Getenv 会**静默忽略**它(= 配了却不生效)。本用例把三个键只写进临时 `.env`,
|
||||
// 取值必须原样生效。
|
||||
func TestNewChannelEventStore_只写在env文件里也生效_正常(t *testing.T) {
|
||||
unsetChannelEventEnv(t)
|
||||
path := filepath.Join(t.TempDir(), ".env")
|
||||
body := strings.Join([]string{
|
||||
channelEventRetentionEnv + "=24h",
|
||||
channelEventGCIntervalEnv + "=30m",
|
||||
channelEventCounterFlushEnv + "=5s",
|
||||
}, "\n") + "\n"
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("写临时 .env: %v", err)
|
||||
}
|
||||
|
||||
store := channelEventTestApp(t, nil).newChannelEventStore(path)
|
||||
if store == nil {
|
||||
t.Fatal("有库时必须构造出事件存储")
|
||||
}
|
||||
if store.Retention() != 24*time.Hour || store.Interval() != 30*time.Minute || store.FlushInterval() != 5*time.Second {
|
||||
t.Fatalf("取值 = (%v, %v, %v), want (24h, 30m, 5s)",
|
||||
store.Retention(), store.Interval(), store.FlushInterval())
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewChannelEventStore_非法取值回落默认并告警_异常:配了但读不懂 ⇒ 回落 E1 默认值
|
||||
// **且**记一条带键名与原始取值的 WARN(AGENTS §10:非法配置不许静默失效,也不许一票
|
||||
// 否决整条留存面)。
|
||||
func TestNewChannelEventStore_非法取值回落默认并告警_异常(t *testing.T) {
|
||||
unsetChannelEventEnv(t)
|
||||
path := filepath.Join(t.TempDir(), ".env")
|
||||
if err := os.WriteFile(path, []byte(channelEventRetentionEnv+"=三十天\n"), 0o600); err != nil {
|
||||
t.Fatalf("写临时 .env: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
|
||||
store := channelEventTestApp(t, log).newChannelEventStore(path)
|
||||
if store == nil {
|
||||
t.Fatal("单个键非法不该让存储整个消失")
|
||||
}
|
||||
if store.Retention() != events.DefaultRetention {
|
||||
t.Fatalf("非法取值时的保留期 = %v, want %v", store.Retention(), events.DefaultRetention)
|
||||
}
|
||||
logged := buf.String()
|
||||
if !strings.Contains(logged, "level=WARN") || !strings.Contains(logged, channelEventRetentionEnv) {
|
||||
t.Fatalf("非法取值没有留下带键名的 WARN:%q", logged)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelEventStore_未接线时交出真nil接口_边界:未接数据库(或构造失败)时
|
||||
// `channelEventStore()` 必须返回**真 nil 接口** —— 返回「装着 nil 指针的接口」会让
|
||||
// 桥接层的 nil 判据失效,症状是每帧撞一次 nil 指针 panic,而单测全绿。
|
||||
func TestChannelEventStore_未接线时交出真nil接口_边界(t *testing.T) {
|
||||
blank := &app{log: testLog()}
|
||||
if store := blank.newChannelEventStore(""); store != nil {
|
||||
t.Fatalf("库未接时不该构造存储:%+v", store)
|
||||
}
|
||||
var asInterface events.Store = blank.channelEventStore()
|
||||
if asInterface != nil {
|
||||
t.Fatalf("未接线时必须交出真 nil 接口,拿到的是 %#v", asInterface)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelEventKeeper_退出前最后一次落盘_正常:ctx 取消时循环必须用**独立**的
|
||||
// context 把内存里的计数写进库。这里把已取消的 ctx 直接交给 loop(同步跑完)——
|
||||
// 若 `flushOnShutdown` 用调用方那个已取消的 ctx 落盘,它会立刻拿到 context.Canceled,
|
||||
// 待落盘的计数就会留在内存里(用例据此变红)。
|
||||
func TestChannelEventKeeper_退出前最后一次落盘_正常(t *testing.T) {
|
||||
unsetChannelEventEnv(t)
|
||||
a := channelEventTestApp(t, nil)
|
||||
store := a.newChannelEventStore("")
|
||||
if store == nil {
|
||||
t.Fatal("有库时必须构造出事件存储")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
// 一条只计数的 T1 事件:它在内存里待落盘(逐条落行的那类会立刻写库,掩盖问题)。
|
||||
if err := store.Record(ctx, events.Event{
|
||||
Platform: events.PlatformQQ,
|
||||
Tier: events.TierT1,
|
||||
Category: events.CategoryHeartbeat,
|
||||
PlatformType: "heartbeat",
|
||||
}); err != nil {
|
||||
t.Fatalf("Record: %v", err)
|
||||
}
|
||||
if store.PendingKeys() == 0 {
|
||||
t.Fatal("计数没有留在内存里,用例前提不成立")
|
||||
}
|
||||
|
||||
cancelled, cancel := context.WithCancel(ctx)
|
||||
cancel()
|
||||
keeper := &channelEventKeeper{store: store, log: testLog(), done: make(chan struct{})}
|
||||
keeper.loop(cancelled)
|
||||
|
||||
if store.PendingKeys() != 0 {
|
||||
t.Fatalf("退出时仍有 %d 个键待落盘:最后一次 Flush 没跑", store.PendingKeys())
|
||||
}
|
||||
counters, err := store.Counters(ctx, events.PlatformQQ)
|
||||
if err != nil {
|
||||
t.Fatalf("Counters: %v", err)
|
||||
}
|
||||
if len(counters) != 1 || counters[0].Count != 1 {
|
||||
t.Fatalf("退出后库里的计数 = %+v, want 一条 count=1 的心跳", counters)
|
||||
}
|
||||
select {
|
||||
case <-keeper.done:
|
||||
default:
|
||||
t.Fatal("loop 退出时没有关闭 done(退出路径不可观测)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelEventKeeper_GC关闭时不建零间隔ticker_边界:`time.NewTicker(0)` 会 panic,
|
||||
// 所以「配了 ≤0 = 关闭清扫」必须在建 ticker 之前分岔(关闭 ⇒ nil 通道,select 上永不触发)。
|
||||
func TestChannelEventKeeper_GC关闭时不建零间隔ticker_边界(t *testing.T) {
|
||||
t.Run("关闭时不建 ticker", func(t *testing.T) {
|
||||
unsetChannelEventEnv(t)
|
||||
t.Setenv(channelEventGCIntervalEnv, "0")
|
||||
store := channelEventTestApp(t, nil).newChannelEventStore("")
|
||||
if store == nil || store.Enabled() {
|
||||
t.Fatalf("GC 应关闭:store=%v enabled=%v", store, store != nil && store.Enabled())
|
||||
}
|
||||
ticks, stop := (&channelEventKeeper{store: store, log: testLog()}).gcTicker()
|
||||
if ticks != nil {
|
||||
t.Fatal("GC 关闭时不该建 ticker(零间隔 ticker 会 panic)")
|
||||
}
|
||||
stop()
|
||||
})
|
||||
t.Run("开启时给出定时通道", func(t *testing.T) {
|
||||
unsetChannelEventEnv(t)
|
||||
store := channelEventTestApp(t, nil).newChannelEventStore("")
|
||||
if store == nil || !store.Enabled() {
|
||||
t.Fatal("默认配置下 GC 应开启")
|
||||
}
|
||||
ticks, stop := (&channelEventKeeper{store: store, log: testLog()}).gcTicker()
|
||||
if ticks == nil {
|
||||
t.Fatal("GC 开启时必须给出定时通道")
|
||||
}
|
||||
stop()
|
||||
})
|
||||
}
|
||||
|
||||
// TestChannelEventEnv_模板取值是合法duration_正常 是 E6(协同清单)自查的机械化版本:
|
||||
// 三个键名必须能在 `.env.example` 里逐字找到,且模板值本身就是合法 duration(占位符形态,
|
||||
// 从不放凭据)—— 否则照模板部署的新实例会得到一条 WARN 并**静默**回落到默认值。
|
||||
func TestChannelEventEnv_模板取值是合法duration_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
|
||||
if err != nil {
|
||||
t.Fatalf("read .env.example: %v", err)
|
||||
}
|
||||
lines := strings.Split(string(raw), "\n")
|
||||
for _, key := range []string{channelEventRetentionEnv, channelEventGCIntervalEnv, channelEventCounterFlushEnv} {
|
||||
found := false
|
||||
for _, line := range lines {
|
||||
value, ok := strings.CutPrefix(line, key+"=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if _, err := time.ParseDuration(value); err != nil {
|
||||
t.Fatalf("%s 的模板值 %q 不是合法 duration", key, value)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf(".env.example 缺少 %s(AGENTS §10:键名必须与代码逐字一致)", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/channelbridge"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// ownerRegistrar 实现 gateway.OwnerRegistrar:它把一个渠道账号 id 持久化进主人
|
||||
// 白名单(configs/users/owner.yaml),**并**热更新渠道 bridge 持有的活主人档案,
|
||||
// 因此微信扫码登录的确认会被立即认成主人——无需重启,也无需手工改配置。path 与
|
||||
// 活档案都在接线时捕获:启动加载的 *user.OwnerProfile 就是 bridge 判定 actor 时
|
||||
// 依据的同一个指针,所以在它上面调 RegisterAccount 对 ResolveActor 立即可见,
|
||||
// 无需触碰 bridge。
|
||||
type ownerRegistrar struct {
|
||||
path string
|
||||
owner *user.OwnerProfile
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func (r *ownerRegistrar) RegisterChannelOwner(_ context.Context, platform, accountID string) error {
|
||||
if strings.TrimSpace(accountID) == "" {
|
||||
return nil
|
||||
}
|
||||
if err := user.RegisterOwnerAccount(r.path, platform, accountID); err != nil {
|
||||
return fmt.Errorf("register owner %s:%s: %w", platform, accountID, err)
|
||||
}
|
||||
// 热生效:把这次登记扩到 bridge 已经指向的那份活档案上。若档案在启动时加载
|
||||
// 失败(r.owner 为 nil),上面的落盘依然写进磁盘、重启后即被识别;此处打日志
|
||||
// 是为了让运维知道热路径当时不可用。
|
||||
if r.owner != nil {
|
||||
r.owner.RegisterAccount(platform, accountID)
|
||||
} else {
|
||||
r.log.Warn("owner registrar: no live profile, whitelist persisted to disk only (restart to take effect)",
|
||||
"platform", platform, "accountId", accountID)
|
||||
}
|
||||
r.log.Info("owner whitelist registered", "platform", platform, "accountId", accountID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LiveOwnerProfile 是 ownerRegistrar 对 gateway.liveOwnerProfile 可选扩展的实现(裁决
|
||||
// 10.4.5):管理面板 PUT /admin/owner-profile 保存后,gateway 需要拿到**同一个**活档案指针
|
||||
// 做热替换——渠道 bridge 判定 actor 用的就是这个指针,所以热替换无需重启即生效。启动时档案
|
||||
// 加载失败则为 nil,gateway 会退回「真实落盘 + 提示需重启」,绝不假装热生效。
|
||||
func (r *ownerRegistrar) LiveOwnerProfile() *user.OwnerProfile { return r.owner }
|
||||
|
||||
// channelCaps 把能力注册表适配成 channelbridge.ChannelRegistry(热重载同步注册表的
|
||||
// 窄端口)。替换时写入的形状与 wireChannels 启动注册**逐字相同**
|
||||
// (Name/Kind/Description/Value):Description 取适配器名、Value 取适配器本身 ——
|
||||
// adminListChannels 把 Value 断言成 sdk.Channel、面板按注册名精确匹配,
|
||||
// 形状一旦不一致,渠道就会从面板上消失。
|
||||
type channelCaps struct{ caps *registry.Registry }
|
||||
|
||||
func (c channelCaps) ReplaceChannel(ch sdk.Channel) error {
|
||||
if ch == nil {
|
||||
return fmt.Errorf("wire channel registry: %w", registry.ErrInvalidArgument)
|
||||
}
|
||||
if err := c.caps.ReplaceChannel(ch); err != nil {
|
||||
return fmt.Errorf("wire channel registry %q: %w", ch.Name(), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c channelCaps) RemoveChannel(name string) error {
|
||||
if err := c.caps.Remove(name); err != nil {
|
||||
return fmt.Errorf("wire channel registry %q: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// wireChannels 依环境配置构造四个 IM 渠道适配器,把它们注册进能力注册表(管理
|
||||
// API 据此枚举),并接进渠道 bridge——让已配置的适配器连上,把入站消息按
|
||||
// session→actor→loop→reply 路由。缺凭据的适配器保持未连接(只记日志,绝不作
|
||||
// 致命错误):拿空配置去连要么失败,要么(weixin-ilink)触发阻塞式扫码登录。
|
||||
func (a *app) wireChannels() error {
|
||||
// 渠道配置可在运行时编辑:channels.json 存在且有效时以它为准,否则回落到 env
|
||||
// 里的渠道块(design/04 §3.1)。工厂与配置读取读的是同一个文件,因此面板保存 +
|
||||
// 热重载能让启动口径与运行口径保持一致。
|
||||
channelsPath := runtimeconfig.DefaultChannelsPath(resolveDataDir(a.cfg))
|
||||
factory := &channelFactory{path: channelsPath, fallback: channelAccountsFromEnv(), log: a.log}
|
||||
adapters := buildChannelAdapters(factory)
|
||||
for _, ch := range adapters {
|
||||
capp := registry.Capability{
|
||||
Name: ch.Name(),
|
||||
Kind: registry.KindChannel,
|
||||
Description: ch.Name(),
|
||||
Value: ch,
|
||||
}
|
||||
if err := a.caps.Register(capp); err != nil {
|
||||
a.log.Warn("channel register skipped", "channel", ch.Name(), "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 入站附件的目录口径与文档摄取层(wire_runtime.go 的 docResolver)同源,
|
||||
// 两处必须指向同一棵目录树,否则附件会落在一个地方、抽取去另一个地方找。
|
||||
docDataDir := resolveDataDir(a.cfg)
|
||||
bridge, berr := channelbridge.New(channelbridge.Options{
|
||||
Log: logger.WithSource(a.log, "services/channelbridge"),
|
||||
Loop: a.loop,
|
||||
Sessions: a.sessions,
|
||||
Owner: a.owner,
|
||||
History: a.history,
|
||||
QuietStartHour: a.cfg.Background.QuietStartHour,
|
||||
QuietEndHour: a.cfg.Background.QuietEndHour,
|
||||
// 入站附件摄取(design/24 §3;切片 M1):IM 收到的受支持格式文件落进与
|
||||
// Web 上传**同一个**存储核心(目录口径与 wire_runtime.go 的文档摄取层
|
||||
// 同源:`<DataDir>/uploads/documents`),元数据也写进同一张 `documents`
|
||||
// 表——`documents` 表的写入路径因此全仓只有一条(S7 的引用计数与 GC 有
|
||||
// 单一事实来源)。未配置数据目录时写入面为 nil,桥接器走「未接线」的
|
||||
// 诚实路径(模型知道有附件读不到),绝不假装读到了。
|
||||
DocumentStore: documents.StoreConfig{
|
||||
DataDir: docDataDir,
|
||||
UploadsDir: filepath.Join(docDataDir, "uploads", "documents"),
|
||||
Meta: documents.MetadataWriterFor(a.dbPath, a.log),
|
||||
Log: a.log,
|
||||
},
|
||||
// EventStore 是渠道事件留存面(design/26 §4/§5;切片 E3+E4):非消息事件
|
||||
// (含未知类型与解析失败)由桥接层**无条件**写进它 —— T2/T3/unknown 逐条落行、
|
||||
// T1 只累加计数;「进不进上下文」另由传入前审查决定,两者互不决定。
|
||||
// 通道由 channelEventStore() 归一:未接线时交出**真 nil 接口**,桥接层据此走
|
||||
// 内存计数降级(绝不因为缺存储而中断入站)。
|
||||
EventStore: a.channelEventStore(),
|
||||
// 可选的复审模型在接近单适配器上限时剔除低价值排队消息
|
||||
// (design/12 §6 策略四)。适配器为 nil 时,闸门回落到 FIFO 驱逐。
|
||||
Review: reviewFor(a.modelAdapter, logger.WithSource(a.log, "services/channelbridge/review")),
|
||||
// Light 是 design/18 §3 的参与判定器:对触发命中的群聊/陌生人消息做一次
|
||||
// 成本受控的轻量 LLM 决策(角色名提及 / 概率 / 活跃窗口)。只有真正触发
|
||||
// 才把消息喂进群聊上下文。
|
||||
Light: engageLightFor(a.modelAdapter, logger.WithSource(a.log, "services/channelbridge/engage")),
|
||||
TriggerWords: a.defaultTriggerWords(),
|
||||
PersonaPrompt: a.defaultInstructions(),
|
||||
Engage: channelbridge.DefaultEngageConfig(),
|
||||
// EngageFor 留空(P2a);按群覆盖随 P2c 到来。
|
||||
// Factory 让面板保存时热替换某平台的适配器而无需重启(design/04 §3.1)。
|
||||
Factory: factory,
|
||||
// Channels 让热重载把新适配器写回能力注册表:管理面板(渠道卡片、账号在线
|
||||
// 状态、open/close 的目标解析)读的就是注册表,不同步它就只能看到旧实例,
|
||||
// 于是卡片永远「未连接」、点「连接」驱动的是旧 URL 的那把。
|
||||
Channels: channelCaps{caps: a.caps},
|
||||
// ChannelPersona 从工厂构建所用的同一份配置里解析账号的渠道级角色
|
||||
// (design/22 §2 方案 b);bridge 按已连接的适配器缓存它,并在每次重载时刷新。
|
||||
ChannelPersona: factory.Persona,
|
||||
// VoiceSTT 把进程内语音链注入入站流水线(切片 IM-ASR;design/06 §1.1):
|
||||
// IM 语音在没有平台自带转写时由它在**流水线最前端**归一成文本(写回
|
||||
// msg.Content),因此丢空判据 / 最小打扰判定 / 落库 / 模型可见消息看到的是同
|
||||
// 一份文本。它在「静默档位」处**不会被当成成功**——语音链末端总是成功,所以
|
||||
// bridge 用的是 `STTOutcome`(带档位与被掩盖的真实原因),并据此做临时熔断。
|
||||
// a.voice 同时服务 Web 侧 WS 的 voice_input(同一条降级链,不另建一条)。
|
||||
VoiceSTT: a.voice,
|
||||
VoiceSTTTimeout: time.Duration(a.cfg.Voice.VoiceSTTTimeoutSec) * time.Second,
|
||||
// VoiceTranscoder 把本地转码引擎交给入站语音副本链(design/25 §7.1 第 ④ 步;
|
||||
// 裁决 11):**不可播格式(微信/QQ 的 AMR)必须先转成可播副本再落盘**,否则
|
||||
// 那条语音只能不落副本(`category=transcode_not_wired` 的诚实 WARN),客户端
|
||||
// 便拿不到可回放的字节。注入的是 `wireTranscode` 装配的**同一个**引擎实例
|
||||
// (与工具面的 `transcode` 工具同源),不是第二条降级链。
|
||||
VoiceTranscoder: a.transcoder,
|
||||
})
|
||||
if berr != nil {
|
||||
return fmt.Errorf("channel bridge: %w", berr)
|
||||
}
|
||||
for _, ch := range adapters {
|
||||
bridge.Add(ch)
|
||||
}
|
||||
bridge.Start()
|
||||
a.channelBridge = bridge
|
||||
// 保留具体类型:gateway 只看到 ChannelConfigStore 接口,而启动播种管理员白名单
|
||||
// 需要 concrete runtimeChannels 的方法。
|
||||
rc := &runtimeChannels{
|
||||
path: channelsPath,
|
||||
fallback: factory.fallback,
|
||||
bridge: bridge,
|
||||
owner: a.owner,
|
||||
log: a.log,
|
||||
}
|
||||
// 启动即灌一次每渠道管理员白名单(裁决 06.4.2):不必等面板打开或热重载,
|
||||
// channels.json 里声明的 adminIds 从第一帧入站消息起就参与判主。
|
||||
rc.applyAdminIDs(rc.loadAccounts())
|
||||
a.channels = rc
|
||||
a.log.Info("channel adapters registered", "count", len(adapters))
|
||||
return nil
|
||||
}
|
||||
|
||||
// runtimeChannels 是「可运行时编辑的渠道配置」面向 gateway 的视图。Load 重读
|
||||
// channels.json(文件优先 → env 兜底),Reload 委托给 bridge 的热替换,因此面板的
|
||||
// 保存/重载路径驱动的正是 bridge 启动时的同一份来源。
|
||||
type runtimeChannels struct {
|
||||
path string
|
||||
fallback map[string][]config.ChannelAccount
|
||||
bridge *channelbridge.Bridge
|
||||
// owner 是渠道 bridge 判 actor 用的**同一个**活档案指针:读配置时把每渠道管理员
|
||||
// 白名单灌进它的 adminIDs 层(裁决 06.4.2),面板保存后无需重启即生效。
|
||||
owner *user.OwnerProfile
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func (r *runtimeChannels) Path() string { return r.path }
|
||||
|
||||
func (r *runtimeChannels) Load() map[string][]config.ChannelAccount {
|
||||
accounts := r.loadAccounts()
|
||||
r.applyAdminIDs(accounts)
|
||||
return accounts
|
||||
}
|
||||
|
||||
// loadAccounts 只负责「读配置」:文件优先、env 兜底(design/04 §3.1)。
|
||||
func (r *runtimeChannels) loadAccounts() map[string][]config.ChannelAccount {
|
||||
accounts, warn := runtimeconfig.LoadChannels(r.path, r.fallback)
|
||||
if warn != nil {
|
||||
r.log.Warn("channels.json load fell back to env", "error", warn)
|
||||
}
|
||||
return accounts
|
||||
}
|
||||
|
||||
// applyAdminIDs 把各平台声明的 adminIds 整体替换进活主人档案(裁决 06.4.2)。整体替换
|
||||
// 语义保证热重载后已删除的管理员 id 立即失效;owner.yaml 的账号白名单不受影响。
|
||||
func (r *runtimeChannels) applyAdminIDs(accounts map[string][]config.ChannelAccount) {
|
||||
if r.owner == nil {
|
||||
return
|
||||
}
|
||||
for platform, ids := range runtimeconfig.AdminIDs(accounts) {
|
||||
r.owner.SetPlatformAdmins(platform, ids)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *runtimeChannels) Reload(platform string) error {
|
||||
// 先灌管理员白名单再重载适配器:面板一次保存(adminIds + 凭据)两条路径同时生效。
|
||||
r.applyAdminIDs(r.loadAccounts())
|
||||
return r.bridge.Reload(platform)
|
||||
}
|
||||
|
||||
// reviewFor 构建渠道 bridge 的复审模型(design/12 §6 策略四);无模型适配器可用时
|
||||
// 返回 nil,让闸门回落到 FIFO 驱逐——绝不在适配器为 nil 时调用复审器(那会在
|
||||
// .Chat 上 panic)。
|
||||
func reviewFor(llm model.Adapter, log *slog.Logger) adapter.Reviewer {
|
||||
if llm == nil {
|
||||
return nil
|
||||
}
|
||||
return channelbridge.NewLLMReviewer(llm, log)
|
||||
}
|
||||
|
||||
// engageLightFor 构建 design/18 §3 的参与判定器;无模型适配器可用时返回 nil,让
|
||||
// bridge 的参与引擎朝「沉默」方向 fail-closed(触发命中但无判定器的消息绝不作答)。
|
||||
func engageLightFor(llm model.Adapter, log *slog.Logger) channelbridge.LightJudge {
|
||||
if llm == nil {
|
||||
return nil
|
||||
}
|
||||
return channelbridge.NewLLMLightJudge(llm, log)
|
||||
}
|
||||
|
||||
// defaultTriggerWords 返回默认人格的角色名提醒词(design/18 §2),让渠道 bridge
|
||||
// 知道哪些群聊消息会引发 PROBING。
|
||||
func (a *app) defaultTriggerWords() []string {
|
||||
if c, ok := a.personas.DefaultCharacter(); ok {
|
||||
if p, ok := a.personas.Persona(c.PersonaID); ok {
|
||||
return append([]string(nil), p.TriggerWords...)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/channelbridge"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||
)
|
||||
|
||||
// wireTestChatter / wireTestSessions 是构造真 *channelbridge.Bridge 用的最小桩:
|
||||
// 本文件的用例只关心「白名单播种的顺序」,不跑消息流水线,所以两个接口各给一个空实现。
|
||||
type wireTestChatter struct{}
|
||||
|
||||
func (wireTestChatter) Run(context.Context, string, []model.Message) (*loop.Result, error) {
|
||||
return &loop.Result{}, nil
|
||||
}
|
||||
|
||||
type wireTestSessions struct{}
|
||||
|
||||
func (wireTestSessions) GetOrCreate(context.Context, adapter.UnifiedMessage, *user.OwnerProfile) (session.Session, error) {
|
||||
return session.Session{}, nil
|
||||
}
|
||||
|
||||
// newTestRuntimeChannels 造一个指向临时 channels.json 的运行时渠道视图,外加一个 Accounts
|
||||
// 白名单为空的活主人档案——这样断言命中的就只可能是「每渠道管理员层」这一条来源。
|
||||
func newTestRuntimeChannels(t *testing.T, bridge *channelbridge.Bridge) (*runtimeChannels, *user.OwnerProfile, string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "channels.json")
|
||||
profile := &user.OwnerProfile{ID: "owner"}
|
||||
rc := &runtimeChannels{path: path, bridge: bridge, owner: profile, log: testLog()}
|
||||
return rc, profile, path
|
||||
}
|
||||
|
||||
func writeTestChannels(t *testing.T, path, body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("写 channels.json 失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 裁决 06.4.2:文件里声明的 adminIds 必须经 Load 灌进活主人档案并成为判主依据。
|
||||
func TestRuntimeChannelsLoadSeedsPlatformAdmins(t *testing.T) {
|
||||
rc, profile, path := newTestRuntimeChannels(t, nil)
|
||||
writeTestChannels(t, path, `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret"},"adminIds":["admin-1"," admin-1 ",""]}]}`)
|
||||
|
||||
rc.Load()
|
||||
|
||||
if !profile.IsOwner("kook", "admin-1") {
|
||||
t.Fatal("Load 后 kook:admin-1 应被判为主人(adminIds 未生效)")
|
||||
}
|
||||
if profile.IsOwner("kook", "someone-else") {
|
||||
t.Fatal("未声明的账号不应被判为主人")
|
||||
}
|
||||
if profile.IsOwner("qq_obv11", "admin-1") {
|
||||
t.Fatal("管理员层必须按平台隔离:kook 声明的 id 不应在别的平台生效")
|
||||
}
|
||||
}
|
||||
|
||||
// 裁决 06.4.2:整体替换语义——热重载后文件里删掉的管理员 id 必须立即失效,不残留悬挂管理员。
|
||||
func TestRuntimeChannelsLoadReplacesRemovedAdmin(t *testing.T) {
|
||||
rc, profile, path := newTestRuntimeChannels(t, nil)
|
||||
writeTestChannels(t, path, `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret"},"adminIds":["admin-1","admin-2"]}]}`)
|
||||
rc.Load()
|
||||
if !profile.IsOwner("kook", "admin-2") {
|
||||
t.Fatal("首次 Load 后 kook:admin-2 应被判为主人")
|
||||
}
|
||||
|
||||
writeTestChannels(t, path, `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret"}}]}`)
|
||||
rc.Load()
|
||||
|
||||
if profile.IsOwner("kook", "admin-2") || profile.IsOwner("kook", "admin-1") {
|
||||
t.Fatal("重载后已从配置移除的管理员 id 仍然是主人(整体替换语义失效)")
|
||||
}
|
||||
}
|
||||
|
||||
// 裁决 06.4.2:Reload 必须先把白名单灌进活档案、再委托 bridge 重载适配器——面板一次保存
|
||||
// (adminIds + 凭据)两条路径同时生效;这里用未注入 factory 的 bridge 证明「顺序」而非「重载成功」。
|
||||
func TestRuntimeChannelsReloadSeedsAdminsBeforeDelegating(t *testing.T) {
|
||||
bridge, err := channelbridge.New(channelbridge.Options{
|
||||
Log: testLog(),
|
||||
Loop: wireTestChatter{},
|
||||
Sessions: wireTestSessions{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("channelbridge.New: %v", err)
|
||||
}
|
||||
rc, profile, path := newTestRuntimeChannels(t, bridge)
|
||||
writeTestChannels(t, path, `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret"},"adminIds":["admin-9"]}]}`)
|
||||
|
||||
if err := rc.Reload("kook"); err != channelbridge.ErrNilFactory {
|
||||
t.Fatalf("Reload err = %v, 期望 ErrNilFactory(测试前提:bridge 无 factory)", err)
|
||||
}
|
||||
if !profile.IsOwner("kook", "admin-9") {
|
||||
t.Fatal("Reload 应先把 adminIds 灌进活档案再委托 bridge,实际被跳过")
|
||||
}
|
||||
}
|
||||
|
||||
// 裁决 10.4.5:组合根必须交出渠道 bridge 判主用的**同一个**活档案指针,热替换才真的生效;
|
||||
// 档案未加载时返回 nil,让 gateway 退回「真实落盘 + 提示重启」而不是假装成功。
|
||||
func TestOwnerRegistrarLiveOwnerProfile(t *testing.T) {
|
||||
profile := &user.OwnerProfile{ID: "owner"}
|
||||
r := &ownerRegistrar{path: filepath.Join(t.TempDir(), "owner.yaml"), owner: profile, log: testLog()}
|
||||
if got := r.LiveOwnerProfile(); got != profile {
|
||||
t.Fatalf("LiveOwnerProfile 必须返回同一个指针: got %p want %p", got, profile)
|
||||
}
|
||||
|
||||
var orphan ownerRegistrar
|
||||
if orphan.LiveOwnerProfile() != nil {
|
||||
t.Fatal("档案未加载时必须返回 nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestWireChannels_WiresInboundDocumentStore 是一道「接线存在性」防回归检查
|
||||
// (切片 M1;与 `TestWireRuntime_UsesTheCompositionRootSeams` 同一手法)。
|
||||
//
|
||||
// 组合根本身无法在单元测试里实例化(开库、迁移、绑端口,而后端还在运行),
|
||||
// 因此「IM 入站附件确实被接上了共享文档存储」只能在源码层钉住:列出的字符串
|
||||
// 少任何一个,桥接器都会静默退回「未接线」的诚实路径 —— 附件不再落库、S7 的 GC
|
||||
// 也看不到 IM 来源的文件,而这一切**不会**让任何既有用例变红。所以必须显式钉住。
|
||||
//
|
||||
// 最后一对断言是**成对契约**:入站附件落盘的目录与文档摄取层读原文件的目录
|
||||
// 必须逐字同源(`<DataDir>/uploads/documents`),否则附件会落在一个地方、
|
||||
// 装配期去另一个地方找 —— 症状是「附件全都读不到」,且两边各自都自洽。
|
||||
func TestWireChannels_WiresInboundDocumentStore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const dirSeam = `UploadsDir: filepath.Join(docDataDir, "uploads", "documents")`
|
||||
src, err := os.ReadFile("wire_channels.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read wire_channels.go: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"DocumentStore: documents.StoreConfig{",
|
||||
dirSeam,
|
||||
"Meta: documents.MetadataWriterFor(a.dbPath, a.log)",
|
||||
"docDataDir := resolveDataDir(a.cfg)",
|
||||
} {
|
||||
if !strings.Contains(string(src), want) {
|
||||
t.Fatalf("wire_channels.go 里找不到 %q —— IM 入站附件没有被接上共享文档存储", want)
|
||||
}
|
||||
}
|
||||
|
||||
reader, err := os.ReadFile("wire_runtime.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read wire_runtime.go: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(reader), dirSeam) {
|
||||
t.Fatalf("文档摄取层的目录与 IM 入站附件的目录不同源(wire_runtime.go 里找不到 %q)—— "+
|
||||
"附件会落在一个地方、装配期去另一个地方找", dirSeam)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/transcode"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/channelbridge"
|
||||
)
|
||||
|
||||
// TestWireChannels_InjectsVoiceTranscoder 是一道「接线存在性」防回归检查
|
||||
// (裁决 11;与同目录 `TestWireChannels_WiresInboundDocumentStore` 同一手法)。
|
||||
//
|
||||
// 钉住的事实:组合根把 `wireTranscode` 装配的本地转码引擎交给渠道 bridge,于是**不可播
|
||||
// 格式的入站语音(微信 / QQ 的 AMR)在落副本之前先被转成可播副本**
|
||||
// (design/25 §7.1 第 ④ 步)。少了这一行,bridge 按 nil 走它的诚实路径 —— 那句
|
||||
// `category=transcode_not_wired` 的 WARN 照记、消息一个字不丢,**但那条语音不落副本**
|
||||
// ⇒ 客户端拿不到可回放的字节,而**没有任何既有用例会变红**。所以必须显式钉住。
|
||||
//
|
||||
// 为什么是源码级检查而不是端到端:组合根本身无法在单元测试里实例化(开库、迁移、绑端口,
|
||||
// 而后端还在运行);退化的行为侧(「转码不可用 ⇒ 不落副本」)已由
|
||||
// `internal/services/channelbridge/inbound_voice_copy_test.go` 覆盖,这里要钉的只是
|
||||
// **那一行注入**(以及它的**时机**,见下)。
|
||||
func TestWireChannels_InjectsVoiceTranscoder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
src := readSource(t, "wire_channels.go")
|
||||
|
||||
// 断言的是**一整行字段赋值**(行首到行尾),因此被注释掉的那一行
|
||||
// (`\t\t// VoiceTranscoder: …`)或写在别处的同名字符串都不会让它通过。
|
||||
const assignment = "\n\t\tVoiceTranscoder: a.transcoder,"
|
||||
if !strings.Contains(src, assignment) {
|
||||
t.Fatalf("wire_channels.go 里找不到 %q —— 入站语音的 AMR 不会被转成可播副本,"+
|
||||
"微信语音因此不落副本(design/25 §7.1 第 ④ 步 / 裁决 11)", strings.TrimSpace(assignment))
|
||||
}
|
||||
|
||||
// 注入面必须真装得下我们交进去的那个值:字段类型与 `a.transcoder` 的静态类型同源,
|
||||
// 否则「接线成功」这句话就只是字面巧合(例如字段被改成一条自定义窄接口)。
|
||||
field, ok := reflect.TypeOf(channelbridge.Options{}).FieldByName("VoiceTranscoder")
|
||||
if !ok {
|
||||
t.Fatal("channelbridge.Options 没有 VoiceTranscoder 字段:注入面无处可放(设计改了?)")
|
||||
}
|
||||
if want := reflect.TypeOf((*transcode.Transcoder)(nil)).Elem(); field.Type != want {
|
||||
t.Fatalf("channelbridge.Options.VoiceTranscoder 的静态类型 = %v,want %v", field.Type, want)
|
||||
}
|
||||
|
||||
// 注入的**时机**同样要钉住:`a.transcoder` 由 `wireTranscode()`(在 `wireRuntime` 里)
|
||||
// 赋值,所以 `wireRuntime` 必须先于 `wireChannels` 执行。反过来的话上面那行注入的是
|
||||
// **nil** —— 一切照旧、一条日志都不差,只有「AMR 语音落副本」这条能力静默消失。
|
||||
app := readSource(t, "wire.go")
|
||||
idxRuntime, idxChannels := strings.Index(app, "a.wireRuntime()"), strings.Index(app, "a.wireChannels()")
|
||||
if idxRuntime < 0 || idxChannels < 0 || idxRuntime > idxChannels {
|
||||
t.Fatalf("wire.go 里 wireRuntime / wireChannels 的先后不对(偏移 %d vs %d):转码引擎必须"+
|
||||
"在渠道桥被构造**之前**装配好,否则 VoiceTranscoder 注入的是 nil", idxRuntime, idxChannels)
|
||||
}
|
||||
}
|
||||
|
||||
// readSource 读一个组合根源文件并把行尾归一成 LF(断言里的 `\n` 因此与平台无关)。
|
||||
func readSource(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
return strings.ReplaceAll(string(raw), "\r\n", "\n")
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
)
|
||||
|
||||
// 本文件是**上下文预算的唯一决策点**(组合根):把「配置 + 生效模型组」摊平成
|
||||
// config.ContextBudgetInput,喂给纯函数 config.DeriveContextBudget,再把结论交给
|
||||
// loop.Options.MaxContextTokens 与 loop.Options.DocumentMaxInjectChars。
|
||||
//
|
||||
// 为什么推导放在这里而不是配置层:窗口与 maxTokens 都在 models.json 的**生效组**
|
||||
// 上(面板可编辑、热重载),而 CONTEXT_MAX_TOKENS 在 .env。两个来源的会合点只有
|
||||
// 组合根,推导函数本身因此保持纯粹、可表驱动测试。
|
||||
//
|
||||
// 语义(design/04 §1.3 + 附录 A.3):
|
||||
// - CONTEXT_MAX_TOKENS 未设置 ⇒ 按窗口推导 budget = min(窗口 × 比例, 窗口 − 输出保留);
|
||||
// - > 0 ⇒ 显式覆盖;0 / 负数 ⇒ 关闭压缩(与接线前一致);
|
||||
// - 窗口未声明 ⇒ 回落 config.DefaultModelContextWindow 并记**恰好一条** WARN;
|
||||
// - 比例非法(CONTEXT_BUDGET_RATIO)⇒ 记一条带原始取值的 WARN 并回落 2/3。
|
||||
|
||||
// ctxBudget 是组合根推导出的两项预算:循环的溢出压缩预算与单份附件注入预算。
|
||||
type ctxBudget struct {
|
||||
// Budget 是压缩预算的完整结论(含推导来源,供日志与断言)。
|
||||
Budget config.ContextBudget
|
||||
// DocumentInjectChars 是单份附件注入上下文的字符上限(T1 §7.2)。
|
||||
DocumentInjectChars int
|
||||
}
|
||||
|
||||
// contextBudget 计算本次运行生效的上下文预算。envPath 是启动时合并的 .env 路径
|
||||
// (比例键的读取与其余配置同一套优先级;空串表示不看 .env)。
|
||||
//
|
||||
// 模型组取**生效组**(models.json 下标 0,或 .env 合成的组):压缩预算与注入预算
|
||||
// 都是循环级的单一值,而生效组才是新请求默认用的那个。
|
||||
func (a *app) contextBudget(envPath string) ctxBudget {
|
||||
in := config.ContextBudgetInput{}
|
||||
docOverride := 0
|
||||
if a.cfg != nil {
|
||||
in.Override = a.cfg.Context.MaxTokens
|
||||
in.OverrideConfigured = a.cfg.Context.MaxTokensSet
|
||||
docOverride = a.cfg.Context.DocumentMaxInjectChars
|
||||
}
|
||||
if a.llm != nil {
|
||||
g := a.llm.Group()
|
||||
in.Window = g.ContextWindow
|
||||
in.GroupMaxTokens = g.MaxTokens
|
||||
}
|
||||
return deriveContextBudget(sourcedWiringLog(a.log), envPath, in, docOverride)
|
||||
}
|
||||
|
||||
// deriveContextBudget 是 contextBudget 的可测内核:取值来源收成显式参数,
|
||||
// 日志与「恰好一条 WARN」也在这里(测试用捕获型 handler 断言条数)。
|
||||
func deriveContextBudget(log *slog.Logger, envPath string, in config.ContextBudgetInput, docOverride int) ctxBudget {
|
||||
ratio, err := config.LoadContextBudgetRatio(envPath)
|
||||
if err != nil {
|
||||
// 非法比例:记一条带原始取值的 WARN 并回落默认(AGENTS §10:不许静默失效)。
|
||||
log.Warn("context budget ratio invalid, falling back",
|
||||
"env", config.ContextBudgetRatioEnv, "error", err,
|
||||
"fallback_ratio", config.DefaultContextBudgetRatio)
|
||||
ratio = config.DefaultContextBudgetRatio
|
||||
}
|
||||
in.Ratio = ratio
|
||||
budget := config.DeriveContextBudget(in)
|
||||
// 单份附件注入预算:显式覆盖优先,否则随推导出的预算走。
|
||||
docChars := docOverride
|
||||
if docChars <= 0 {
|
||||
docChars = loop.DocumentInjectCharsFor(budget.Tokens)
|
||||
}
|
||||
if budget.Mode == config.ContextBudgetDerived && !budget.WindowDeclared {
|
||||
// 窗口未声明:**恰好一条** WARN(每次启动只推导一次)。
|
||||
log.Warn("model context window not declared, falling back",
|
||||
"env", "LLM_CONTEXT_WINDOW", "json_key", "contextWindow",
|
||||
"window", budget.Window, "hint", "声明窗口才能吃满模型的真实窗口")
|
||||
}
|
||||
log.Info("context budget",
|
||||
"mode", string(budget.Mode), "tokens", budget.Tokens,
|
||||
"window", budget.Window, "window_declared", budget.WindowDeclared,
|
||||
"ratio", budget.Ratio, "output_reserve", budget.OutputReserve,
|
||||
"document_inject_chars", docChars)
|
||||
return ctxBudget{Budget: budget, DocumentInjectChars: docChars}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// 本文件锁定上下文预算的取值链(design/04 §1.3 + 附录 A.3):
|
||||
//
|
||||
// - **窗口声明**(models.json 组的 contextWindow / LLM_CONTEXT_WINDOW)与推导
|
||||
// budget = min(窗口 × 比例, 窗口 − 输出保留);
|
||||
// - CONTEXT_MAX_TOKENS 的三态(未设置 ⇒ 推导 / >0 ⇒ 覆盖 / 0 或负 ⇒ 关闭);
|
||||
// - 窗口未声明时**恰好一条** WARN;
|
||||
// - 单份附件的注入预算随推导预算走,且真的让「清单游标 → document-read」这条
|
||||
// 续读链路跑通(S4 实测的断裂,切片 T1 §7.2)。
|
||||
|
||||
// loopCompactSummaryMarker 是 loop 压缩摘要消息的开头(internal/runtime/loop/
|
||||
// compactor.go 的 summaryMarker)。测试按这个可观测契约判定「摘要进了 prompt」。
|
||||
const loopCompactSummaryMarker = "[前文摘要 · history summary]"
|
||||
|
||||
// compactProbeAdapter 记下每次流式请求,使测试能读回模型实际看到的上下文。
|
||||
type compactProbeAdapter struct{ reqs []model.Request }
|
||||
|
||||
func (a *compactProbeAdapter) Name() string { return "compact-probe" }
|
||||
|
||||
func (a *compactProbeAdapter) Chat(context.Context, model.Request, model.Options) (model.Response, error) {
|
||||
return model.Response{Content: "好"}, nil
|
||||
}
|
||||
|
||||
func (a *compactProbeAdapter) ChatStream(_ context.Context, req model.Request, on func(model.StreamEvent)) (model.Response, error) {
|
||||
a.reqs = append(a.reqs, req)
|
||||
if on != nil {
|
||||
on(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: "好"})
|
||||
}
|
||||
return model.Response{Content: "好"}, nil
|
||||
}
|
||||
|
||||
// longConversation 造一段「比小预算长得多、单看每条都很正常」的对话:
|
||||
// 12 轮问答、每条约 220 个汉字(离线估算约 114 token/条,共约 2700 token)。
|
||||
func longConversation() []model.Message {
|
||||
nums := []string{"一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "十一", "十二"}
|
||||
filler := strings.Repeat("今天也要好好吃饭早点休息哦", 17)
|
||||
msgs := make([]model.Message, 0, 2*len(nums))
|
||||
for _, n := range nums {
|
||||
msgs = append(msgs,
|
||||
model.TextMessage(model.RoleUser, "第"+n+"件事:"+filler),
|
||||
model.TextMessage(model.RoleAssistant, "好呀,人家记下了♪"+filler),
|
||||
)
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
// runWithBudget 用给定预算驱动一次真实循环(装配形状与组合根一致:
|
||||
// MaxContextTokens 取自推导结论,Compactor 不注入 —— 由 loop 选用内置的结构化
|
||||
// compactor),返回模型实际看到的请求。
|
||||
func runWithBudget(t *testing.T, budget int, msgs []model.Message) model.Request {
|
||||
t.Helper()
|
||||
|
||||
adapter := &compactProbeAdapter{}
|
||||
ag, err := agent.New("你是昔涟。", nil, nil, agent.WithModel(adapter, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("agent.New: %v", err)
|
||||
}
|
||||
l := loop.New(loop.Options{
|
||||
Agent: ag,
|
||||
MaxContextTokens: budget,
|
||||
OnStream: func(model.StreamEvent) {},
|
||||
})
|
||||
env := loop.SessionEnv{ActorEnv: restrict.ActorOwner, MemWritable: true, PersonaID: "cyrene"}
|
||||
ctx := loop.WithSessionEnv(context.Background(), env)
|
||||
if _, err := l.Run(ctx, "web", msgs); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if len(adapter.reqs) == 0 {
|
||||
t.Fatal("没有捕获到任何模型请求")
|
||||
}
|
||||
return adapter.reqs[len(adapter.reqs)-1]
|
||||
}
|
||||
|
||||
// summaryMessages 返回请求里所有压缩摘要消息的下标。
|
||||
func summaryMessages(req model.Request) []int {
|
||||
var idx []int
|
||||
for i, m := range req.Messages {
|
||||
if strings.HasPrefix(loopText(m), loopCompactSummaryMarker) {
|
||||
idx = append(idx, i)
|
||||
}
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
// loopText 取一条消息的文本部件拼接(与 loop 的 messageText 同义)。
|
||||
func loopText(m model.Message) string {
|
||||
var b strings.Builder
|
||||
for _, c := range m.Content {
|
||||
if c.Type == model.ContentTypeText {
|
||||
b.WriteString(c.Text)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// capturingLog 返回输出 JSON 记录的 logger 与缓冲:这里要按**条数**断言 WARN
|
||||
// (「窗口未声明恰好一条」),文本子串数不出来。
|
||||
func capturingLog() (*slog.Logger, *bytes.Buffer) {
|
||||
buf := &bytes.Buffer{}
|
||||
return slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})), buf
|
||||
}
|
||||
|
||||
// logRecords 把缓冲里的 JSON 记录逐条解出来。
|
||||
func logRecords(t *testing.T, buf *bytes.Buffer) []map[string]any {
|
||||
t.Helper()
|
||||
out := []map[string]any{}
|
||||
for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
rec := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
||||
t.Fatalf("日志行不是合法 JSON: %v(%q)", err, line)
|
||||
}
|
||||
out = append(out, rec)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// countRecords 数出消息里包含 substr 的记录条数(只看 msg 与 level)。
|
||||
func countRecords(records []map[string]any, level, substr string) int {
|
||||
n := 0
|
||||
for _, rec := range records {
|
||||
msg, _ := rec["msg"].(string)
|
||||
if level != "" && rec["level"] != level {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(msg, substr) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// envWindowStack 造一个「生效组声明了窗口」的模型栈:models.json 不存在时
|
||||
// LoadModels 会回落到 .env 合成的组,而 LLM_CONTEXT_WINDOW 正是该组的窗口来源。
|
||||
func envWindowStack(t *testing.T, window, maxTokens int) *model.RuntimeLLM {
|
||||
t.Helper()
|
||||
return model.NewRuntimeLLM(filepath.Join(t.TempDir(), "models.json"), config.LLMConfig{
|
||||
Provider: "openai",
|
||||
BaseURL: "https://api.example/v1",
|
||||
Model: "test-model",
|
||||
ContextWindow: window,
|
||||
}, testLog())
|
||||
}
|
||||
|
||||
// modelsWindowStack 造一个「生效组是 models.json 里那一组」的模型栈:组级
|
||||
// contextWindow / maxTokens 只在 models.json 里有出处,因此这里真写一份文件。
|
||||
func modelsWindowStack(t *testing.T, window, maxTokens int) *model.RuntimeLLM {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "models.json")
|
||||
raw, err := json.Marshal([]map[string]any{{
|
||||
"id": "deepseek", "provider": "openai", "baseUrl": "https://api.example/v1",
|
||||
"apiKey": "sk-test", "model": "test-model",
|
||||
"contextWindow": window, "maxTokens": maxTokens,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal models.json: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, raw, 0o600); err != nil {
|
||||
t.Fatalf("write models.json: %v", err)
|
||||
}
|
||||
return model.NewRuntimeLLM(path, config.LLMConfig{}, testLog())
|
||||
}
|
||||
|
||||
// TestContextBudget_DerivedFromTheDeclaredWindow 钉住推导链的接线:窗口取自
|
||||
// **生效模型组**,预算按附录 A.3 的公式算,比例来自 CONTEXT_BUDGET_RATIO。
|
||||
func TestContextBudget_DerivedFromTheDeclaredWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
envPath := filepath.Join(t.TempDir(), "absent.env")
|
||||
log, buf := capturingLog()
|
||||
a := &app{log: log, cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)}
|
||||
|
||||
got := a.contextBudget(envPath)
|
||||
if got.Budget.Tokens != 699050 || got.Budget.Mode != config.ContextBudgetDerived {
|
||||
t.Fatalf("1M 窗口的预算 = %+v, want 699050/derived", got.Budget)
|
||||
}
|
||||
// 单份附件注入预算随推导预算走:1M 窗口下远大于旧的 64K 卡死值。
|
||||
if got.DocumentInjectChars <= 64*1024 {
|
||||
t.Fatalf("1M 窗口下的注入预算 = %d, want > 64K", got.DocumentInjectChars)
|
||||
}
|
||||
if n := countRecords(logRecords(t, buf), "WARN", "not declared"); n != 0 {
|
||||
t.Fatalf("窗口已声明却记了 %d 条「未声明」WARN", n)
|
||||
}
|
||||
|
||||
// 组级 maxTokens=384K(思考模式上限)⇒ 输出保留吃满 ⇒ min(699050, 1M−384K)。
|
||||
a.llm = modelsWindowStack(t, 1048576, 384*1024)
|
||||
got = a.contextBudget(envPath)
|
||||
if got.Budget.Tokens != 655360 || got.Budget.OutputReserve != 384*1024 {
|
||||
t.Fatalf("带输出保留的预算 = %+v, want 655360 / reserve 393216", got.Budget)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextBudget_UndeclaredWindowWarnsExactlyOnce 钉住回落与「恰好一条 WARN」:
|
||||
// 没声明窗口的部署照常工作,但必须被告知「要吃到 1M 得声明窗口」,且只被告知一次。
|
||||
func TestContextBudget_UndeclaredWindowWarnsExactlyOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
envPath := filepath.Join(t.TempDir(), "absent.env")
|
||||
log, buf := capturingLog()
|
||||
a := &app{log: log, cfg: &config.Config{}, llm: envWindowStack(t, 0, 0)}
|
||||
|
||||
got := a.contextBudget(envPath)
|
||||
if got.Budget.Window != config.DefaultModelContextWindow || got.Budget.WindowDeclared {
|
||||
t.Fatalf("未声明窗口 = %+v, want 回落 %d 且 WindowDeclared=false",
|
||||
got.Budget, config.DefaultModelContextWindow)
|
||||
}
|
||||
if got.Budget.Tokens != 87381 {
|
||||
t.Fatalf("回落窗口下的预算 = %d, want 87381(131072 × 2/3 截尾)", got.Budget.Tokens)
|
||||
}
|
||||
records := logRecords(t, buf)
|
||||
if n := countRecords(records, "WARN", "not declared"); n != 1 {
|
||||
t.Fatalf("「窗口未声明」WARN 条数 = %d, want 恰好 1(记录:%v)", n, records)
|
||||
}
|
||||
// WARN 必须点出两个声明入口,否则主人不知道该改哪儿。
|
||||
warn := ""
|
||||
for _, rec := range records {
|
||||
if msg, _ := rec["msg"].(string); strings.Contains(msg, "not declared") {
|
||||
warn = msg
|
||||
if rec["env"] != "LLM_CONTEXT_WINDOW" || rec["json_key"] != "contextWindow" {
|
||||
t.Fatalf("WARN 没点出声明入口: %v", rec)
|
||||
}
|
||||
}
|
||||
}
|
||||
if warn == "" {
|
||||
t.Fatal("没有找到窗口未声明的 WARN")
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextBudget_OverrideAndOff 钉住 CONTEXT_MAX_TOKENS 的三态:>0 覆盖、
|
||||
// 显式 0 关闭、负值关闭 —— 且关闭时**不推导**(窗口未声明也不该冒出 WARN)。
|
||||
func TestContextBudget_OverrideAndOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
envPath := filepath.Join(t.TempDir(), "absent.env")
|
||||
|
||||
log, buf := capturingLog()
|
||||
a := &app{log: log, cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)}
|
||||
a.cfg.Context.MaxTokens = 600
|
||||
if got := a.contextBudget(envPath); got.Budget.Tokens != 600 || got.Budget.Mode != config.ContextBudgetOverride {
|
||||
t.Fatalf("显式覆盖 = %+v, want 600/override", got.Budget)
|
||||
}
|
||||
if n := countRecords(logRecords(t, buf), "WARN", "not declared"); n != 0 {
|
||||
t.Fatalf("显式覆盖与窗口无关,却记了 %d 条「未声明」WARN", n)
|
||||
}
|
||||
|
||||
for _, override := range []int{0, -1} {
|
||||
a.cfg.Context.MaxTokens = override
|
||||
a.cfg.Context.MaxTokensSet = true // 显式 0 = 关闭(0 与「未设置」必须分开)
|
||||
got := a.contextBudget(envPath)
|
||||
if got.Budget.Tokens != override || got.Budget.Mode != config.ContextBudgetOff {
|
||||
t.Fatalf("override=%d ⇒ %+v, want off", override, got.Budget)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextBudget_InvalidRatioWarnsAndFallsBack 钉住非法比例的处理:记一条带
|
||||
// 原始取值的 WARN 并回落 2/3,绝不静默生效(AGENTS §10)。
|
||||
func TestContextBudget_InvalidRatioWarnsAndFallsBack(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, "test.env")
|
||||
if err := os.WriteFile(envPath, []byte("CONTEXT_BUDGET_RATIO=四分之三\n"), 0o600); err != nil {
|
||||
t.Fatalf("写 .env: %v", err)
|
||||
}
|
||||
log, buf := capturingLog()
|
||||
a := &app{log: log, cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)}
|
||||
|
||||
got := a.contextBudget(envPath)
|
||||
if got.Budget.Ratio != config.DefaultContextBudgetRatio || got.Budget.Tokens != 699050 {
|
||||
t.Fatalf("非法比例下的预算 = %+v, want 回落 2/3 ⇒ 699050", got.Budget)
|
||||
}
|
||||
records := logRecords(t, buf)
|
||||
if n := countRecords(records, "WARN", "ratio invalid"); n != 1 {
|
||||
t.Fatalf("非法比例的 WARN 条数 = %d, want 1(记录:%v)", n, records)
|
||||
}
|
||||
|
||||
// 合法比例真的生效:1/2 × 1M = 524288。
|
||||
if err := os.WriteFile(envPath, []byte("CONTEXT_BUDGET_RATIO=1/2\n"), 0o600); err != nil {
|
||||
t.Fatalf("写 .env: %v", err)
|
||||
}
|
||||
if got := a.contextBudget(envPath); got.Budget.Tokens != 524288 {
|
||||
t.Fatalf("1/2 比例下的预算 = %d, want 524288", got.Budget.Tokens)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextBudget_DocumentInjectCharsOverride 钉住注入预算的显式覆盖优先:
|
||||
// DOCUMENT_MAX_INJECT_CHARS 是「至少可配」的落点(任务书 §2 第 7 条)。
|
||||
func TestContextBudget_DocumentInjectCharsOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := &app{log: testLog(), cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)}
|
||||
a.cfg.Context.DocumentMaxInjectChars = 4096
|
||||
got := a.contextBudget(filepath.Join(t.TempDir(), "absent.env"))
|
||||
if got.DocumentInjectChars != 4096 {
|
||||
t.Fatalf("显式注入预算 = %d, want 4096", got.DocumentInjectChars)
|
||||
}
|
||||
if got.Budget.Tokens != 699050 {
|
||||
t.Fatalf("注入预算的覆盖不该影响压缩预算:%d", got.Budget.Tokens)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextBudget_DefaultsToNoiseFreeLoopDefault 锁定取值链的兜底:显式覆盖与
|
||||
// 推导都不适用时(这里用显式覆盖举最小的例子),组合根读的就是配置值本身。
|
||||
func TestContextBudget_DefaultsToNoiseFreeLoopDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
log, buf := capturingLog()
|
||||
a := &app{log: log, cfg: &config.Config{}}
|
||||
a.cfg.Context.MaxTokens = config.MinContextOutputReserve
|
||||
a.cfg.Context.MaxTokensSet = true
|
||||
|
||||
if got := a.contextBudget(filepath.Join(t.TempDir(), "absent.env")); got.Budget.Tokens != config.MinContextOutputReserve {
|
||||
t.Fatalf("contextBudget = %+v, want %d", got.Budget, config.MinContextOutputReserve)
|
||||
}
|
||||
if n := countRecords(logRecords(t, buf), "WARN", "not declared"); n != 0 {
|
||||
t.Fatalf("覆盖态下不该有窗口 WARN(记了 %d 条)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextCompaction_OnLongConversationGetsSummarizedIntoThePrompt 是真断言①:
|
||||
// 预算为正且对话超出预算时,会话**真的被压缩**,且摘要消息进了模型请求。
|
||||
func TestContextCompaction_OnLongConversationGetsSummarizedIntoThePrompt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := &app{log: testLog(), cfg: &config.Config{}}
|
||||
a.cfg.Context.MaxTokens = 600 // 远小于 longConversation 的体量
|
||||
msgs := longConversation()
|
||||
|
||||
req := runWithBudget(t, a.contextBudget(filepath.Join(t.TempDir(), "absent.env")).Budget.Tokens, msgs)
|
||||
|
||||
summaries := summaryMessages(req)
|
||||
if len(summaries) != 1 {
|
||||
t.Fatalf("请求里的摘要消息数 = %d(共 %d 条消息),want 1", len(summaries), len(req.Messages))
|
||||
}
|
||||
if summaries[0] != 1 {
|
||||
t.Fatalf("摘要消息的位置 = %d,want 1(紧跟指令消息的近期窗口之前)", summaries[0])
|
||||
}
|
||||
if len(req.Messages) >= len(msgs)+1 {
|
||||
t.Fatalf("请求消息数 = %d(喂入 %d 条),压缩没有生效", len(req.Messages), len(msgs))
|
||||
}
|
||||
// 近期尾部保留(压缩不是把历史全丢掉):最后一条用户消息仍在。
|
||||
all := ""
|
||||
for _, m := range req.Messages {
|
||||
all += loopText(m)
|
||||
}
|
||||
if !strings.Contains(all, "第十二件事") {
|
||||
t.Fatalf("最近一轮已被压掉:请求里找不到最后一轮用户消息")
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextCompaction_ZeroBudgetKeepsTodaysBehavior 是真断言②:显式 0
|
||||
// (关闭)时,行为与接线前完全一致 —— 喂进去的消息一条不少地进了请求,
|
||||
// 且没有任何摘要消息。
|
||||
func TestContextCompaction_ZeroBudgetKeepsTodaysBehavior(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := &app{log: testLog(), cfg: &config.Config{}}
|
||||
a.cfg.Context.MaxTokens = 0
|
||||
a.cfg.Context.MaxTokensSet = true // 显式 0 = 关闭(未设置会走推导)
|
||||
msgs := longConversation()
|
||||
|
||||
req := runWithBudget(t, a.contextBudget(filepath.Join(t.TempDir(), "absent.env")).Budget.Tokens, msgs)
|
||||
|
||||
if len(req.Messages) != len(msgs)+1 {
|
||||
t.Fatalf("请求消息数 = %d,want %d(指令 + 全部喂入消息)", len(req.Messages), len(msgs)+1)
|
||||
}
|
||||
if summaries := summaryMessages(req); len(summaries) != 0 {
|
||||
t.Fatalf("关闭压缩却出现了摘要消息:%v", summaries)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireRuntime_UsesTheCompositionRootSeams 是一道「接线存在性」防回归检查。
|
||||
//
|
||||
// 组合根本身(wireRuntime → newApp)无法在单元测试里实例化:它要开库、迁移、
|
||||
// 建网关并绑定端口,在主人后端正在运行时尤其不能碰。因此「组合根确实走了这些缝」
|
||||
// 只能在源码层钉住:wire_runtime.go 必须把预算取自 a.contextBudget(envPath),
|
||||
// 在这份预算上建护栏管线(buildGuardrailPipeline(ctxBudget)),并把两项预算都交给 loop。
|
||||
func TestWireRuntime_UsesTheCompositionRootSeams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
src, err := os.ReadFile("wire_runtime.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read wire_runtime.go: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"a.buildGuardrailPipeline(ctxBudget)",
|
||||
"a.contextBudget(envPath)",
|
||||
"MaxContextTokens: ctxBudget.Budget.Tokens",
|
||||
"DocumentMaxInjectChars: ctxBudget.DocumentInjectChars",
|
||||
} {
|
||||
if !strings.Contains(string(src), want) {
|
||||
t.Fatalf("wire_runtime.go 里找不到 %q —— 组合根没有走这条缝", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextCompaction_HugeBudgetIsAlsoInert 是变异反证:同一段对话只把预算
|
||||
// 放大到不可能超出的值,压缩同样不发生 —— 证明上一条的压缩确实由「预算」驱动,
|
||||
// 而不是别的什么副作用。
|
||||
func TestContextCompaction_HugeBudgetIsAlsoInert(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
msgs := longConversation()
|
||||
req := runWithBudget(t, 1<<30, msgs)
|
||||
|
||||
if len(req.Messages) != len(msgs)+1 {
|
||||
t.Fatalf("巨大预算下请求消息数 = %d,want %d", len(req.Messages), len(msgs)+1)
|
||||
}
|
||||
if summaries := summaryMessages(req); len(summaries) != 0 {
|
||||
t.Fatalf("巨大预算下不该出现摘要消息:%v", summaries)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||||
)
|
||||
|
||||
// 本文件是切片 T1 §7.2 的**端到端**成对契约(S4 实测断裂的正面用例):
|
||||
// 装配期注入预算与文档读侧共用同一套字符坐标系,因此清单行给的续读游标真的能
|
||||
// 被 document-read 用起来。预算推导本身的用例在 wire_context_test.go。
|
||||
|
||||
// docE2EName 是合法存储名形态(32 位十六进制 + .txt)。
|
||||
const docE2EName = "0123456789abcdef0123456789abcdef.txt"
|
||||
|
||||
// docBudgetFixture 是一份端到端夹具:隔离数据目录 + 已落盘的正文 + 生产解析器 +
|
||||
// 生产 document-read 工具 + 一个能捕获模型请求的循环。
|
||||
type docBudgetFixture struct {
|
||||
cfg *config.Config
|
||||
body string
|
||||
ad *compactProbeAdapter
|
||||
read sdk.Tool
|
||||
cursor int
|
||||
}
|
||||
|
||||
// newDocBudgetFixture 造夹具并跑**一次真实的回合**,返回模型看到的清单游标。
|
||||
func newDocBudgetFixture(t *testing.T, injectBudget int) *docBudgetFixture {
|
||||
t.Helper()
|
||||
dataDir := t.TempDir()
|
||||
uploads := filepath.Join(dataDir, "uploads", "documents")
|
||||
if err := os.MkdirAll(uploads, 0o700); err != nil {
|
||||
t.Fatalf("建上传目录: %v", err)
|
||||
}
|
||||
fx := &docBudgetFixture{cfg: &config.Config{DataDir: dataDir}, body: strings.Repeat("abcdefghij", 20000)}
|
||||
if err := os.WriteFile(filepath.Join(uploads, docE2EName), []byte(fx.body), 0o600); err != nil {
|
||||
t.Fatalf("写上传件: %v", err)
|
||||
}
|
||||
|
||||
adapter := &compactProbeAdapter{}
|
||||
ag, err := agent.New("你是昔涟。", nil, nil, agent.WithModel(adapter, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("agent.New: %v", err)
|
||||
}
|
||||
l := loop.New(loop.Options{
|
||||
Agent: ag,
|
||||
DocumentResolver: documents.NewResolver(documents.Config{DataDir: dataDir, UploadsDir: uploads}),
|
||||
DocumentMaxInjectChars: injectBudget,
|
||||
OnStream: func(model.StreamEvent) {},
|
||||
})
|
||||
msg := model.DocumentMessage(model.RoleUser, "看看这个附件",
|
||||
model.Document{Name: docE2EName, MIME: "text/plain", Size: int64(len(fx.body))})
|
||||
if _, err := l.Run(context.Background(), "sess-doc", []model.Message{msg}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if len(adapter.reqs) == 0 {
|
||||
t.Fatal("没有捕获到模型请求")
|
||||
}
|
||||
fx.ad = adapter
|
||||
|
||||
tools, err := iotools.New(fx.cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("iotools.New: %v", err)
|
||||
}
|
||||
fx.read = documentReadToolByName(t, tools)
|
||||
|
||||
req := adapter.reqs[len(adapter.reqs)-1]
|
||||
prompt := loopText(req.Messages[len(req.Messages)-1])
|
||||
fx.cursor, _ = cursorFromManifest(t, prompt)
|
||||
return fx
|
||||
}
|
||||
|
||||
// TestDocumentCursor_ContinuationReadsBeyondTheInjectionBudget 是切片 T1 §7.2 的
|
||||
// **端到端**成对契约(S4 实测断裂的正面用例):正文远大于注入预算时
|
||||
//
|
||||
// ①清单行带 truncated + 续读游标(游标 = 实际注入的字符数);
|
||||
// ②把清单行里那个游标原样交给 `document-read`,拿到的是注入预算**之外**的正文。
|
||||
//
|
||||
// 三个角色都是生产实现:装配期是真实的 loop.resolveDocuments,sidecar 由真实的
|
||||
// documents.Resolver 写出,读取走真实的 document-read 工具。唯一被单独钉住的是
|
||||
// 「注入预算 = 1000 字符」,那正是本切片新增的可配置项。
|
||||
func TestDocumentCursor_ContinuationReadsBeyondTheInjectionBudget(t *testing.T) {
|
||||
t.Parallel()
|
||||
const injectBudget = 1000
|
||||
fx := newDocBudgetFixture(t, injectBudget)
|
||||
|
||||
if fx.cursor != injectBudget {
|
||||
t.Fatalf("清单游标 = %d, want %d(= 实际注入的字符数)", fx.cursor, injectBudget)
|
||||
}
|
||||
prompt := loopText(fx.ad.reqs[len(fx.ad.reqs)-1].Messages[len(fx.ad.reqs[len(fx.ad.reqs)-1].Messages)-1])
|
||||
_, injected := cursorFromManifest(t, prompt)
|
||||
if injected != fx.body[:injectBudget] {
|
||||
t.Fatalf("注入正文 = %d 字符,want 正文的前 %d 字符", len(injected), injectBudget)
|
||||
}
|
||||
|
||||
// ② 照游标续读:拿到的是注入预算之外的正文(旧实现这里恒为空 —— 断裂所在)。
|
||||
first := callDocumentRead(t, fx.read, map[string]any{"name": docE2EName, "offset": fx.cursor})
|
||||
if first["offset"] != float64(fx.cursor) {
|
||||
t.Fatalf("续读 offset = %v, want %d", first["offset"], fx.cursor)
|
||||
}
|
||||
text, _ := first["text"].(string)
|
||||
if text == "" {
|
||||
t.Fatal("按清单游标续读读到了空正文 —— 清单给的承诺是假的")
|
||||
}
|
||||
if want := fx.body[fx.cursor : fx.cursor+len(text)]; text != want {
|
||||
t.Fatalf("续读正文不是注入预算之外的那一段:\n got %q\nwant %q", text, want)
|
||||
}
|
||||
|
||||
// 一路翻页到底:拼起来必须正好是注入预算之外的全文(两边同一坐标系)。
|
||||
joined := text
|
||||
offset := fx.cursor + len(text)
|
||||
for i := 0; i < 100 && first["truncated"] == true; i++ {
|
||||
first = callDocumentRead(t, fx.read, map[string]any{"name": docE2EName, "offset": offset})
|
||||
page, _ := first["text"].(string)
|
||||
joined += page
|
||||
offset += len(page)
|
||||
}
|
||||
if joined != fx.body[fx.cursor:] {
|
||||
t.Fatalf("分页续读拼出 %d 字符,want %d(注入预算之外的全文)", len(joined), len(fx.body)-fx.cursor)
|
||||
}
|
||||
if total, _ := first["totalChars"].(float64); int(total) != len(fx.body) {
|
||||
t.Fatalf("totalChars = %v, want 全文 %d 字符", first["totalChars"], len(fx.body))
|
||||
}
|
||||
}
|
||||
|
||||
// cursorFromManifest 从模型看到的上下文里取出清单行与续读游标(offset=N)。它刻意
|
||||
// **解析文本**而不是读内部字段:模型能看到的就只有这一行,游标能不能被模型用起来,
|
||||
// 正是这条契约要证明的事。返回游标与注入块(清单行 + 换行 + 注入正文)。
|
||||
func cursorFromManifest(t *testing.T, prompt string) (int, string) {
|
||||
t.Helper()
|
||||
start := strings.Index(prompt, "[附件 ")
|
||||
if start < 0 {
|
||||
t.Fatalf("上下文里没有清单行:%q", prompt)
|
||||
}
|
||||
block := prompt[start:]
|
||||
line, injected, ok := strings.Cut(block, "]\n")
|
||||
if !ok {
|
||||
t.Fatalf("清单行没有闭合或没有正文行:%q", block)
|
||||
}
|
||||
if !strings.Contains(line, "已截断") {
|
||||
t.Fatalf("清单行没有如实标注截断:%q", line)
|
||||
}
|
||||
i := strings.Index(line, "offset=")
|
||||
if i < 0 {
|
||||
t.Fatalf("清单行没有续读游标:%q", line)
|
||||
}
|
||||
digits := line[i+len("offset="):]
|
||||
end := 0
|
||||
for end < len(digits) && digits[end] >= '0' && digits[end] <= '9' {
|
||||
end++
|
||||
}
|
||||
n, err := strconv.Atoi(digits[:end])
|
||||
if err != nil {
|
||||
t.Fatalf("续读游标不是整数:%q", digits[:end])
|
||||
}
|
||||
return n, injected
|
||||
}
|
||||
|
||||
// documentReadToolByName 在真实工具集里找出 document-read 工具。
|
||||
func documentReadToolByName(t *testing.T, tools []sdk.Tool) sdk.Tool {
|
||||
t.Helper()
|
||||
for _, tl := range tools {
|
||||
if tl.Name() == "document-read" {
|
||||
return tl
|
||||
}
|
||||
}
|
||||
t.Fatal("工具集里没有 document-read")
|
||||
return nil
|
||||
}
|
||||
|
||||
// callDocumentRead 执行一次 document-read 并把返回的 JSON 文本解回 map。
|
||||
func callDocumentRead(t *testing.T, tl sdk.Tool, args map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
raw, err := tl.Execute(context.Background(), args)
|
||||
if err != nil {
|
||||
t.Fatalf("document-read: %v", err)
|
||||
}
|
||||
text, ok := raw.(string)
|
||||
if !ok {
|
||||
t.Fatalf("返回值类型 = %T, want string(JSON 文本)", raw)
|
||||
}
|
||||
out := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(text), &out); err != nil {
|
||||
t.Fatalf("返回值不是合法 JSON: %v(%q)", err, text)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
// 本文件把**文档回收(GC)**接进组合根(design/24 §3.2 的配额与清理,切片 S7):
|
||||
// 按配置构造回收器、在启动期清扫一次、此后按间隔定时清扫,退出时干净停下。
|
||||
//
|
||||
// 三条取舍写在这里(实现细节见 `internal/runtime/documents/gc.go`):
|
||||
//
|
||||
// 1. **启动期清扫不阻塞启动**:它是常驻 goroutine 的第一件事(紧随子系统启动之后
|
||||
// 开始,网关监听之前就绪),而不是在 newApp/run 里同步等它跑完 —— 数据目录在
|
||||
// 慢盘/网络盘上时,一次清扫不该把「昔涟能不能起来」绑在磁盘上。
|
||||
// 2. **失败不退出进程**:查询失败(库不可用、迁移没跑)只记一条 WARN 并等下一轮;
|
||||
// 单件文件删除失败由 gc.go 记 WARN 后继续,整轮照常跑完。
|
||||
// 3. **关闭 = 显式配 0**:`DOCUMENT_GC_INTERVAL=0` 或 `DOCUMENT_RETENTION=0`
|
||||
// 都不构造回收器,NewGC 会把关闭原因记进日志(静默的「配了却不生效」不存在)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||||
)
|
||||
|
||||
// documentGC 是常驻的文档回收任务:一个回收器 + 一个可停的循环。
|
||||
type documentGC struct {
|
||||
gc *documents.GC
|
||||
log *slog.Logger
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// newDocumentGC 按当前配置构造回收器;GC 关闭时返回 nil(NewGC 已记下关闭原因)。
|
||||
// 目录口径与网关的上传目录、摄取层的读取目录**逐字同源**
|
||||
// (`<DataDir>/uploads/documents`,sidecar 在 `<DataDir>/extracted`)。
|
||||
func (a *app) newDocumentGC() *documentGC {
|
||||
docDataDir := resolveDataDir(a.cfg)
|
||||
gc := documents.NewGC(documents.GCConfig{
|
||||
DB: a.db,
|
||||
DataDir: docDataDir,
|
||||
UploadsDir: filepath.Join(docDataDir, "uploads", "documents"),
|
||||
Interval: a.cfg.Server.DocumentGCInterval,
|
||||
Retention: a.cfg.Server.DocumentRetention,
|
||||
Log: a.log,
|
||||
})
|
||||
if !gc.Enabled() {
|
||||
return nil
|
||||
}
|
||||
return &documentGC{gc: gc, log: logger.WithSource(a.log, "cmd/cyrene")}
|
||||
}
|
||||
|
||||
// startDocumentGC 拉起常驻回收(无操作 = GC 关闭):立刻清扫一次,然后按间隔定时
|
||||
// 清扫,直到 ctx 被取消或 stop 被调用。
|
||||
func (a *app) startDocumentGC(ctx context.Context) {
|
||||
runner := a.newDocumentGC()
|
||||
if runner == nil {
|
||||
return
|
||||
}
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
runner.cancel = cancel
|
||||
runner.done = make(chan struct{})
|
||||
a.docGC = runner
|
||||
go runner.loop(runCtx)
|
||||
}
|
||||
|
||||
// loop 是回收循环:启动期清扫一次 → 定时清扫。
|
||||
func (d *documentGC) loop(ctx context.Context) {
|
||||
defer close(d.done)
|
||||
d.sweep(ctx)
|
||||
ticker := time.NewTicker(d.gc.Interval())
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
d.sweep(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sweep 跑一轮回收。每轮的成功汇总由 gc.Sweep 自己记(删了几件/释放多少字节,
|
||||
// **绝不记正文**);这里只负责把「这一轮整个没跑成」记成 WARN 并让循环继续。
|
||||
func (d *documentGC) sweep(ctx context.Context) {
|
||||
if _, err := d.gc.Sweep(ctx); err != nil {
|
||||
d.log.Warn("document gc sweep failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// stop 停掉回收循环并等它退出;未启动时是空操作。它必须在关库**之前**调用
|
||||
// (清扫要用那条连接池)。
|
||||
func (d *documentGC) stop() {
|
||||
if d == nil || d.cancel == nil {
|
||||
return
|
||||
}
|
||||
d.cancel()
|
||||
<-d.done
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestWireDocumentGC_IsWired 是一道「接线存在性」防回归检查(切片 S7;与
|
||||
// TestWireChannels_WiresInboundDocumentStore 同一手法)。
|
||||
//
|
||||
// 组合根本身无法在单元测试里实例化(开库、迁移、绑端口,而后端还在运行),因此
|
||||
// 「引用计数确实被接上」「启动期/定时清扫确实被拉起」「关闭时确实被停掉」只能在
|
||||
// 源码层钉住:少任何一处,GC 都会**静默不工作**(消息照写、文件照留、日志里一句
|
||||
// 都没有),而这不会让任何既有用例变红。
|
||||
//
|
||||
// 最后一段是 `.env.example` 的同步检查(AGENTS §10 铁律):新键必须与代码里的
|
||||
// env 标签**逐字一致**,否则照模板部署的新实例会漏配且静默失效。
|
||||
func TestWireDocumentGC_IsWired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
seams := map[string][]string{
|
||||
"wire_services.go": {
|
||||
// 引用计数写入面注入 history(消息落库/编辑/删除都在那里增减)。
|
||||
"history.WithDocumentRefs(documents.NewRefCounter(a.log))",
|
||||
// 回收循环必须在关库之前停下(清扫用那条连接池)。
|
||||
"a.docGC.stop()",
|
||||
},
|
||||
"wire_document_gc.go": {
|
||||
"documents.NewGC(documents.GCConfig{",
|
||||
// 目录口径与网关上传、摄取层读取逐字同源(`<DataDir>/uploads/documents`)。
|
||||
`UploadsDir: filepath.Join(docDataDir, "uploads", "documents")`,
|
||||
"Interval: a.cfg.Server.DocumentGCInterval",
|
||||
"Retention: a.cfg.Server.DocumentRetention",
|
||||
},
|
||||
"main.go": {
|
||||
// 启动期清扫 + 定时:没有这一行,GC 只存在于代码里而从不运行。
|
||||
"a.startDocumentGC(ctx)",
|
||||
},
|
||||
}
|
||||
for file, wants := range seams {
|
||||
src, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", file, err)
|
||||
}
|
||||
for _, want := range wants {
|
||||
if !strings.Contains(string(src), want) {
|
||||
t.Fatalf("%s 里找不到 %q —— 文档引用计数/回收没有被接上组合根", file, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// .env.example 的两个新键必须与代码里的 env 标签逐字一致。
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
|
||||
if err != nil {
|
||||
t.Fatalf("read .env.example: %v", err)
|
||||
}
|
||||
for _, key := range []string{"DOCUMENT_GC_INTERVAL=", "DOCUMENT_RETENTION="} {
|
||||
if !strings.Contains(string(raw), key) {
|
||||
t.Fatalf(".env.example 里找不到 %q(新 env 键必须同步模板,AGENTS §10)", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package main
|
||||
|
||||
// 本文件把**链路图落盘**接进组合根(design/19 §4 P3;切片 F2):按配置构造链路图
|
||||
// 存储,交给发布器当「每轮收尾落一条」的出口,并拉起常驻的保留期清扫循环。网关的
|
||||
// 只读出口(契约 v0.73 的 `?turnId=` 与 `turns[]`)在 wire_frontend.go 里注入**同一个**
|
||||
// 存储句柄,因此「写进去的」与「查得到的」永远是一份状态。
|
||||
//
|
||||
// 三条取舍逐字对齐参照实现 `wire_channel_events.go` / `wire_document_gc.go`
|
||||
// (design 明写「不另造」):
|
||||
//
|
||||
// 1. **启动期清扫不阻塞启动**:它是常驻 goroutine 的第一件事,而不是在 newApp/run 里
|
||||
// 同步等它跑完。
|
||||
// 2. **失败不退出进程**:清扫失败只记一条 WARN 并等下一轮;一轮失败只是「这一轮没
|
||||
// 扫干净」,绝不让进程退出。
|
||||
// 3. **关闭 = 显式配 ≤0**:`FLOW_GRAPH_RETENTION=0`(或负值)或
|
||||
// `FLOW_GRAPH_GC_INTERVAL=0` 都不开启清扫,NewStore 会把关闭原因记进日志(静默的
|
||||
// 「配了却不生效」不存在)。
|
||||
//
|
||||
// ⚠️ 与 channelEventKeeper 的一处刻意留白:本 keeper 只在 ctx 取消时退出循环(不额外
|
||||
// 挂退出钩子)—— 与 `wire_channel_events.go` 的已知差异一致,因为 `wire_services.go`
|
||||
// 由别的切片在改(本片不许动)。清扫本就是幂等且尽力而为的。
|
||||
//
|
||||
// 两个键的读取走 `config.LoadDurationEnv`(进程环境 > `.env` > 本包导出的默认常量):
|
||||
// `.env` 不写回进程环境,直接 os.Getenv 会让只写在 `.env` 里的声明静默失效。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/flowgraph"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
|
||||
)
|
||||
|
||||
// 两个 env 键名:必须与 `.env.example` **逐字一致**(AGENTS §10 的 E6 条目)。
|
||||
const (
|
||||
flowGraphRetentionEnv = "FLOW_GRAPH_RETENTION"
|
||||
flowGraphGCIntervalEnv = "FLOW_GRAPH_GC_INTERVAL"
|
||||
)
|
||||
|
||||
// wireFlowGraphs 构造链路图存储并挂到 app 上(随后 wireRuntime 把它交给发布器作落盘
|
||||
// 出口,wireFrontend 把它交给网关作只读查询出口)。它**不**启动任何 goroutine:常驻
|
||||
// 循环由 main 的 startFlowGraphs 在子系统启动之后拉起(与渠道事件留存的分工逐字相同)。
|
||||
func (a *app) wireFlowGraphs() {
|
||||
a.flowStore = a.newFlowGraphStore(envPath)
|
||||
}
|
||||
|
||||
// newFlowGraphStore 按配置构造链路图存储。库未接(`a.db == nil`,只可能出现在测试
|
||||
// 夹具里)时返回 nil:发布器随即走「不落盘」的纯内存路径(F1 之前的行为),会话收尾
|
||||
// 照常,绝不因为没有存储而失败或改变实时帧。
|
||||
//
|
||||
// 默认值一律取 `internal/runtime/flowgraph` 导出的常量 —— **不在这里另写一份字面量**:
|
||||
// 两处各写一份 168h/1h,改一处就会让「文档承诺的默认值」与「代码里的默认值」漂移。
|
||||
func (a *app) newFlowGraphStore(envFilePath string) *flowgraph.SQLStore {
|
||||
if a.db == nil {
|
||||
a.log.Warn("flow graph store skipped: no database connection")
|
||||
return nil
|
||||
}
|
||||
store, err := flowgraph.NewStore(flowgraph.Config{
|
||||
DB: a.db,
|
||||
Log: a.log,
|
||||
GCInterval: durationEnv(a.log, envFilePath, flowGraphGCIntervalEnv, flowgraph.DefaultGCInterval),
|
||||
Retention: durationEnv(a.log, envFilePath, flowGraphRetentionEnv, flowgraph.DefaultRetention),
|
||||
})
|
||||
if err != nil {
|
||||
// NewStore 唯一的失败是 DB 为 nil(上面已挡掉):仍如实处理,绝不吞错。
|
||||
a.log.Warn("flow graph store unavailable", "err", err)
|
||||
return nil
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
// flowRoundRecorder 返回注入发布器的落盘出口:未接线时返回**真正的 nil 接口**,而不是
|
||||
// 「装着 nil 指针的接口」—— 后者在 Go 里不等于 nil,会让发布器的 `p.record != nil`
|
||||
// 判据失效(症状是每一轮收尾都撞一次 nil 接收者并记一条 WARN)。
|
||||
func (a *app) flowRoundRecorder() loop.RoundRecorder {
|
||||
if a.flowStore == nil {
|
||||
return nil
|
||||
}
|
||||
return a.flowStore
|
||||
}
|
||||
|
||||
// flowGraphStore 返回注入网关的只读出口:未接线时同样交出**真 nil 接口**(与
|
||||
// channelEventStore 同一取舍)。发布器与网关拿到的是**同一个**存储句柄。
|
||||
func (a *app) flowGraphStore() gateway.FlowGraphStore {
|
||||
if a.flowStore == nil {
|
||||
return nil
|
||||
}
|
||||
return a.flowStore
|
||||
}
|
||||
|
||||
// startFlowGraphs 拉起链路图留存的维护循环(未接线时是空操作)。
|
||||
func (a *app) startFlowGraphs(ctx context.Context) {
|
||||
if a.flowStore == nil {
|
||||
a.log.Info("flow graph upkeep disabled: store not wired",
|
||||
"effect", "rounds are kept in memory only (no local history)")
|
||||
return
|
||||
}
|
||||
keeper := &flowGraphKeeper{
|
||||
store: a.flowStore,
|
||||
log: logger.WithSource(a.log, "cmd/cyrene/wire_flow_graphs"),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
a.flowGraphKeeper = keeper
|
||||
go keeper.loop(ctx)
|
||||
}
|
||||
|
||||
// flowGraphKeeper 是链路图留存的常驻维护任务(保留期清扫)。它持有**存储句柄**
|
||||
// (不是 app),因此生命周期与用法一目了然。
|
||||
type flowGraphKeeper struct {
|
||||
store *flowgraph.SQLStore
|
||||
log *slog.Logger
|
||||
// done 在循环退出时关闭(让退出路径可观测)。
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// loop 是维护循环:启动期先清扫一次,此后按间隔定时清扫,直到 ctx 被取消。
|
||||
// GC 关闭时启动期那次是空操作,循环随即返回(不留空转 goroutine)。
|
||||
func (k *flowGraphKeeper) loop(ctx context.Context) {
|
||||
defer close(k.done)
|
||||
k.sweep(ctx)
|
||||
ticks, stop := k.gcTicker()
|
||||
defer stop()
|
||||
if ticks == nil {
|
||||
return
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticks:
|
||||
k.sweep(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// gcTicker 给出清扫的定时通道;GC 关闭时返回 **nil 通道**(select 上永不触发)。
|
||||
// `time.NewTicker(0)` 会 panic,所以「配了 ≤0 = 关闭」必须**在这里**就分岔,而不是
|
||||
// 建一个零间隔 ticker 再指望它不跑。
|
||||
func (k *flowGraphKeeper) gcTicker() (<-chan time.Time, func()) {
|
||||
if !k.store.Enabled() {
|
||||
return nil, func() {}
|
||||
}
|
||||
ticker := time.NewTicker(k.store.Interval())
|
||||
return ticker.C, ticker.Stop
|
||||
}
|
||||
|
||||
// sweep 跑一轮保留期清扫。每轮的**成功**汇总由 store 自己记(含删除行数与批次),
|
||||
// 这里只负责把「这一轮整个没跑成」记成 WARN 并让循环继续。
|
||||
func (k *flowGraphKeeper) sweep(ctx context.Context) {
|
||||
if _, err := k.store.Sweep(ctx); err != nil {
|
||||
k.log.Warn("flow graph sweep failed", "err", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package main
|
||||
|
||||
// 本文件是**链路图落盘组合根接线**的验收(切片 F2;design/19 §4 P3)。
|
||||
//
|
||||
// 两道并行的取证(与 `wire_channel_events_test.go` 同一手法):
|
||||
//
|
||||
// 1. **接线存在性**:组合根本身无法在单元测试里实例化(开库、迁移、绑端口,而后端还在
|
||||
// 运行),因此「存储真的被构造」「真的交给发布器作落盘出口」「真的注入网关作查询
|
||||
// 出口」「常驻循环真的被拉起」只能在源码层钉住 —— 少任何一处,落盘会**静默不工作**
|
||||
// (图照旧只在内存、日志里一句都没有),而这不红任何既有用例。最后一段还核对
|
||||
// `.env.example` 的两个键(AGENTS §10 的 E6 条目)。
|
||||
// 2. **行为**:两个 env 键的默认值必须来自 `flowgraph` 导出的常量(**不另写一份字面量**)、
|
||||
// 显式 ≤0 = 关闭清扫、只写在 `.env` 里也真实生效、非法取值回落默认 + WARN;以及
|
||||
// GC 关闭时不建零间隔 ticker(`time.NewTicker(0)` 会 panic)。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/flowgraph"
|
||||
)
|
||||
|
||||
// TestWireFlowGraphs_IsWired 钉住组合根的接缝 + `.env.example` 同步。
|
||||
func TestWireFlowGraphs_IsWired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
seams := map[string][]string{
|
||||
"wire_flow_graphs.go": {
|
||||
// 存储真的被构造,且默认值取 flowgraph 导出的常量(不另写字面量)。
|
||||
"flowgraph.NewStore(flowgraph.Config{",
|
||||
"durationEnv(a.log, envFilePath, flowGraphGCIntervalEnv, flowgraph.DefaultGCInterval)",
|
||||
"durationEnv(a.log, envFilePath, flowGraphRetentionEnv, flowgraph.DefaultRetention)",
|
||||
// 未接线时交出**真 nil 接口**(装 nil 指针的接口不等于 nil,会让发布器/网关的
|
||||
// 未接线判据失效)。
|
||||
"func (a *app) flowRoundRecorder() loop.RoundRecorder {",
|
||||
"func (a *app) flowGraphStore() gateway.FlowGraphStore {",
|
||||
// GC 关闭时不许建零间隔 ticker(time.NewTicker(0) 会 panic)。
|
||||
"if !k.store.Enabled() {",
|
||||
},
|
||||
"wire_runtime.go": {
|
||||
// 先建存储,再把它交给发布器作每轮落盘出口。
|
||||
"a.wireFlowGraphs()",
|
||||
"loop.WithRoundRecorder(a.flowRoundRecorder())",
|
||||
},
|
||||
"wire_frontend.go": {
|
||||
// 网关只读出口注入**同一个**存储句柄。
|
||||
"FlowGraphs: a.flowGraphStore(),",
|
||||
},
|
||||
"main.go": {
|
||||
// 常驻循环:没有这一行,落盘的轮次图从不被清理(保留期形同虚设、无限增长)。
|
||||
"a.startFlowGraphs(ctx)",
|
||||
},
|
||||
}
|
||||
for file, wants := range seams {
|
||||
src, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", file, err)
|
||||
}
|
||||
for _, want := range wants {
|
||||
if !strings.Contains(string(src), want) {
|
||||
t.Fatalf("%s 里找不到 %q —— 链路图落盘没有被接上组合根", file, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 两个新键必须与代码里的键名**逐字一致**(AGENTS §10 的 E6 条目)。
|
||||
src, err := os.ReadFile("wire_flow_graphs.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read wire_flow_graphs.go: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
|
||||
if err != nil {
|
||||
t.Fatalf("read .env.example: %v", err)
|
||||
}
|
||||
for _, key := range []string{flowGraphRetentionEnv, flowGraphGCIntervalEnv} {
|
||||
if !strings.Contains(string(src), `"`+key+`"`) {
|
||||
t.Fatalf("wire_flow_graphs.go 里没有键名常量 %q", key)
|
||||
}
|
||||
if !strings.Contains(string(raw), key+"=") {
|
||||
t.Fatalf(".env.example 里找不到 %q(新 env 键必须同步模板,AGENTS §10)", key+"=")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flowGraphTestApp 构造一个只带落盘面所需依赖的 app(库 + 静默 logger)。
|
||||
func flowGraphTestApp(t *testing.T, log *slog.Logger) *app {
|
||||
t.Helper()
|
||||
if log == nil {
|
||||
log = testLog()
|
||||
}
|
||||
return &app{db: openMigratedTestDB(t, filepath.Join(t.TempDir(), "cyrene.db")), log: log}
|
||||
}
|
||||
|
||||
// unsetFlowGraphEnv 把两个键从**进程环境**里拿掉并在用例结束后复原。
|
||||
// ⚠️ 不能用 `t.Setenv(key, "")` 代替:空值在优先级链里仍然「存在」,会盖住 `.env`
|
||||
// 里的同名声明,而「只写在 .env 里也生效」正是要验的东西(与渠道事件同一取舍)。
|
||||
func unsetFlowGraphEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, key := range []string{flowGraphRetentionEnv, flowGraphGCIntervalEnv} {
|
||||
key := key
|
||||
prev, had := os.LookupEnv(key)
|
||||
if had {
|
||||
t.Cleanup(func() { _ = os.Setenv(key, prev) })
|
||||
} else {
|
||||
t.Cleanup(func() { _ = os.Unsetenv(key) })
|
||||
}
|
||||
if err := os.Unsetenv(key); err != nil {
|
||||
t.Fatalf("unset %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewFlowGraphStore_默认值取导出常量_正常:未配置时两个旋钮必须**就是** flowgraph
|
||||
// 导出的默认值(168h / 1h),且清扫开启 —— 另写字面量迟早与文档承诺漂移。
|
||||
func TestNewFlowGraphStore_默认值取导出常量_正常(t *testing.T) {
|
||||
unsetFlowGraphEnv(t)
|
||||
store := flowGraphTestApp(t, nil).newFlowGraphStore("")
|
||||
if store == nil {
|
||||
t.Fatal("有库时必须构造出链路图存储")
|
||||
}
|
||||
if store.Retention() != flowgraph.DefaultRetention {
|
||||
t.Fatalf("Retention = %v, want %v(导出的默认常量)", store.Retention(), flowgraph.DefaultRetention)
|
||||
}
|
||||
if store.Interval() != flowgraph.DefaultGCInterval {
|
||||
t.Fatalf("GC 间隔 = %v, want %v", store.Interval(), flowgraph.DefaultGCInterval)
|
||||
}
|
||||
if !store.Enabled() {
|
||||
t.Fatal("未配置时清扫必须开启(漏配 != 关闭)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewFlowGraphStore_显式零关闭清扫_边界:`≤0 = 关闭`—— 0 刻意不解释成「立即清空」,
|
||||
// 那会让一次笔误抹掉刚落盘的历史轮。
|
||||
func TestNewFlowGraphStore_显式零关闭清扫_边界(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
key string
|
||||
value string
|
||||
}{
|
||||
{"保留期为 0", flowGraphRetentionEnv, "0"},
|
||||
{"保留期为负", flowGraphRetentionEnv, "-1h"},
|
||||
{"清扫间隔为 0", flowGraphGCIntervalEnv, "0"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
unsetFlowGraphEnv(t)
|
||||
t.Setenv(tc.key, tc.value)
|
||||
store := flowGraphTestApp(t, nil).newFlowGraphStore("")
|
||||
if store == nil {
|
||||
t.Fatal("配置非法不该让存储整个消失")
|
||||
}
|
||||
if store.Enabled() {
|
||||
t.Fatalf("%s=%s 时清扫仍开启,want 关闭(显式 ≤0 = 关闭)", tc.key, tc.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewFlowGraphStore_只写在env文件里也生效_正常:`.env` 不写回进程环境,直接
|
||||
// os.Getenv 会**静默忽略**它。本用例把两个键只写进临时 `.env`,取值必须原样生效。
|
||||
func TestNewFlowGraphStore_只写在env文件里也生效_正常(t *testing.T) {
|
||||
unsetFlowGraphEnv(t)
|
||||
path := filepath.Join(t.TempDir(), ".env")
|
||||
body := strings.Join([]string{
|
||||
flowGraphRetentionEnv + "=24h",
|
||||
flowGraphGCIntervalEnv + "=30m",
|
||||
}, "\n") + "\n"
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("写临时 .env: %v", err)
|
||||
}
|
||||
store := flowGraphTestApp(t, nil).newFlowGraphStore(path)
|
||||
if store == nil {
|
||||
t.Fatal("有库时必须构造出链路图存储")
|
||||
}
|
||||
if store.Retention() != 24*time.Hour || store.Interval() != 30*time.Minute {
|
||||
t.Fatalf("取值 = (%v, %v), want (24h, 30m)", store.Retention(), store.Interval())
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewFlowGraphStore_非法取值回落默认并告警_异常:配了但读不懂 ⇒ 回落默认值**且**记
|
||||
// 一条带键名的 WARN(非法配置不许静默失效,也不许一票否决整条落盘面)。
|
||||
func TestNewFlowGraphStore_非法取值回落默认并告警_异常(t *testing.T) {
|
||||
unsetFlowGraphEnv(t)
|
||||
path := filepath.Join(t.TempDir(), ".env")
|
||||
if err := os.WriteFile(path, []byte(flowGraphRetentionEnv+"=七天\n"), 0o600); err != nil {
|
||||
t.Fatalf("写临时 .env: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
|
||||
store := flowGraphTestApp(t, log).newFlowGraphStore(path)
|
||||
if store == nil {
|
||||
t.Fatal("单个键非法不该让存储整个消失")
|
||||
}
|
||||
if store.Retention() != flowgraph.DefaultRetention {
|
||||
t.Fatalf("非法取值时的保留期 = %v, want %v", store.Retention(), flowgraph.DefaultRetention)
|
||||
}
|
||||
logged := buf.String()
|
||||
if !strings.Contains(logged, "level=WARN") || !strings.Contains(logged, flowGraphRetentionEnv) {
|
||||
t.Fatalf("非法取值没有留下带键名的 WARN:%q", logged)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowGraphStore_未接线时交出真nil接口_边界:未接数据库时两个出口都必须返回**真
|
||||
// nil 接口** —— 返回「装着 nil 指针的接口」会让发布器/网关的 nil 判据失效。
|
||||
func TestFlowGraphStore_未接线时交出真nil接口_边界(t *testing.T) {
|
||||
blank := &app{log: testLog()}
|
||||
if store := blank.newFlowGraphStore(""); store != nil {
|
||||
t.Fatalf("库未接时不该构造存储:%+v", store)
|
||||
}
|
||||
if rec := blank.flowRoundRecorder(); rec != nil {
|
||||
t.Fatalf("未接线时落盘出口必须是真 nil 接口,拿到的是 %#v", rec)
|
||||
}
|
||||
if got := blank.flowGraphStore(); got != nil {
|
||||
t.Fatalf("未接线时网关只读出口必须是真 nil 接口,拿到的是 %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowGraphKeeper_GC关闭时不建零间隔ticker_边界:`time.NewTicker(0)` 会 panic,所以
|
||||
// 「配了 ≤0 = 关闭清扫」必须在建 ticker 之前分岔,且 loop 立即返回(不留空转 goroutine)。
|
||||
func TestFlowGraphKeeper_GC关闭时不建零间隔ticker_边界(t *testing.T) {
|
||||
unsetFlowGraphEnv(t)
|
||||
t.Setenv(flowGraphGCIntervalEnv, "0")
|
||||
store := flowGraphTestApp(t, nil).newFlowGraphStore("")
|
||||
if store == nil || store.Enabled() {
|
||||
t.Fatalf("GC 应关闭:store=%v enabled=%v", store, store != nil && store.Enabled())
|
||||
}
|
||||
keeper := &flowGraphKeeper{store: store, log: testLog(), done: make(chan struct{})}
|
||||
ticks, stop := keeper.gcTicker()
|
||||
if ticks != nil {
|
||||
t.Fatal("GC 关闭时不该建 ticker(零间隔 ticker 会 panic)")
|
||||
}
|
||||
stop()
|
||||
|
||||
// loop 在 GC 关闭时必须立刻返回并关闭 done(否则常驻 goroutine 泄漏)。
|
||||
keeper.loop(context.Background())
|
||||
select {
|
||||
case <-keeper.done:
|
||||
default:
|
||||
t.Fatal("GC 关闭时 loop 应立即返回并关闭 done")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowGraphKeeper_开启时给出定时通道_正常:默认配置下 GC 开启,必须给出非 nil 通道。
|
||||
func TestFlowGraphKeeper_开启时给出定时通道_正常(t *testing.T) {
|
||||
unsetFlowGraphEnv(t)
|
||||
store := flowGraphTestApp(t, nil).newFlowGraphStore("")
|
||||
if store == nil || !store.Enabled() {
|
||||
t.Fatal("默认配置下 GC 应开启")
|
||||
}
|
||||
ticks, stop := (&flowGraphKeeper{store: store, log: testLog()}).gcTicker()
|
||||
if ticks == nil {
|
||||
t.Fatal("GC 开启时必须给出定时通道")
|
||||
}
|
||||
stop()
|
||||
}
|
||||
|
||||
// TestFlowGraphEnv_模板取值是合法duration_正常 是 E6 自查的机械化版本:两个键名必须能
|
||||
// 在 `.env.example` 里逐字找到,且模板值是合法 duration(占位符形态,从不放凭据)。
|
||||
func TestFlowGraphEnv_模板取值是合法duration_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
|
||||
if err != nil {
|
||||
t.Fatalf("read .env.example: %v", err)
|
||||
}
|
||||
lines := strings.Split(string(raw), "\n")
|
||||
for _, key := range []string{flowGraphRetentionEnv, flowGraphGCIntervalEnv} {
|
||||
found := false
|
||||
for _, line := range lines {
|
||||
value, ok := strings.CutPrefix(line, key+"=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if _, err := time.ParseDuration(value); err != nil {
|
||||
t.Fatalf("%s 的模板值 %q 不是合法 duration", key, value)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf(".env.example 缺少 %s(AGENTS §10:键名必须与代码逐字一致)", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||
)
|
||||
|
||||
// capacityEmitter 是面向网关的 capacity_awaken 广播出口。
|
||||
// *gateway.Server 实现它;测试用假实现替换以捕获入参。
|
||||
type capacityEmitter interface {
|
||||
BroadcastCapacityAwaken(sessionID, fromState, toState, tool string)
|
||||
}
|
||||
|
||||
// wireFrontend 用内嵌/外部 SPA 文件系统与 JWT/RBAC 认证器构建网关。
|
||||
// Session/Persona/Memories 接到具体的管理器上,管理端/用户端的列表接口才会
|
||||
// 返回真实数据。
|
||||
func (a *app) wireFrontend() error {
|
||||
jwt := auth.New(a.cfg.Auth)
|
||||
apiKeys := a.wireAPIKeys()
|
||||
// 插件管理面板声明中枢(design/09 §5.2.1;切片 PLG-2)必须挂在**同一个**插件管理器上
|
||||
// (两处各存一份生命周期事实必然分叉),且在 gateway.New 之前接好:网关的两条面板端点
|
||||
// 都经 s.plugins.Panels() 取它。见 wirePanelHub。
|
||||
wirePanelHub(a)
|
||||
ragStore := knowledge.New(knowledge.Deps{
|
||||
Projects: a.projStore,
|
||||
Retriever: a.retriever,
|
||||
DefaultPersona: a.defaultPersonaID(),
|
||||
VectorOn: a.vindex != nil,
|
||||
Log: a.log,
|
||||
})
|
||||
auditStore, aerr := gateway.NewSQLiteAuditStore(context.Background(), a.db, a.log)
|
||||
if aerr != nil {
|
||||
a.log.Warn("audit store unavailable, falling back to in-memory", "error", aerr)
|
||||
auditStore = nil
|
||||
}
|
||||
// 不透明刷新令牌(B2)与主 SQLite 句柄共用同一连接。这里失败要优雅降级:
|
||||
// 登录只是不下发 refreshToken,/auth/refresh 报「未配置」,而不是让整个
|
||||
// 启动失败。store 以接口形式持有,构造失败就保持 nil(若存成带类型的 nil
|
||||
// 指针,接口值非 nil,首次调用会 panic)。
|
||||
var refreshStore gateway.RefreshGrantStore
|
||||
if store, rerr := auth.NewRefreshStore(context.Background(), a.db, a.log); rerr != nil {
|
||||
a.log.Warn("refresh token store unavailable", "error", rerr)
|
||||
} else {
|
||||
refreshStore = store
|
||||
}
|
||||
// a.history 在 wireAuxiliary 里构建,让渠道桥(wireChannels)与网关共用同一个
|
||||
// 持久化存储。IM 通路之前 History 是个空转依赖,因此把它的创建顺序提前不会
|
||||
// 影响任何下游。
|
||||
//
|
||||
// 记忆整理「试算」出口(契约 v0.75;切片 MT1):注入的正是低峰心跳跑的那个
|
||||
// *memory.TidyService(同一套规则、同一份配置)。**必须**在它为 nil 时留成
|
||||
// **真 nil 接口** —— 带类型的 nil 指针会让网关的 `== nil` 判断失效,端点就会
|
||||
// 去调一个空接收者。
|
||||
var tidyDryRunner gateway.MemoryTidyDryRunner
|
||||
if a.memoryTidy != nil {
|
||||
tidyDryRunner = a.memoryTidy
|
||||
}
|
||||
gw, err := gateway.New(a.cfg, gateway.Deps{
|
||||
Log: logger.WithSource(a.log, "gateway"),
|
||||
Auth: jwt,
|
||||
Bus: a.bus,
|
||||
Registry: a.caps,
|
||||
Loop: a.loop,
|
||||
Session: a.sessions,
|
||||
Personas: a.personas,
|
||||
Memories: a.memStore,
|
||||
History: a.history,
|
||||
IoT: a.iot,
|
||||
Scheduler: a.scheduler,
|
||||
Tasks: a.tasks,
|
||||
Automations: a.automation,
|
||||
Users: apiKeys,
|
||||
Refresh: refreshStore,
|
||||
Backthink: a.backthink,
|
||||
RAG: ragStore,
|
||||
LLM: a.llm,
|
||||
// EnvPath 与 main.go 启动时 config.Load(envPath) 用同一条路径:面板
|
||||
// 「重置为 env 默认」(切片 R1)按既有解析入口重读同一份 `.env` 声明,
|
||||
// 网关自己不 fopen `.env`。
|
||||
EnvPath: envPath,
|
||||
Channels: a.channels,
|
||||
Plugins: a.plugins,
|
||||
OwnerRegistrar: &ownerRegistrar{path: defaultOwnerPath, owner: a.owner, log: a.log},
|
||||
AuditStore: auditStore,
|
||||
Streaming: true,
|
||||
Frontend: resolveFrontendFS(a.log),
|
||||
Webhooks: a.channelBridge.WebhookHandlers(),
|
||||
DatabasePath: a.dbPath,
|
||||
DataDir: resolveDataDir(a.cfg),
|
||||
Database: a.db,
|
||||
Vector: a.vectorState(),
|
||||
// Flow 是「最近一轮链路图」的只读出口(契约 v0.71):注入的正是上面
|
||||
// loop/orchestration 用的**同一个** *loop.FlowPublisher,因此
|
||||
// GET /api/v1/admin/flow/graph 读到的就是实时发布的那张图。
|
||||
Flow: a.flow,
|
||||
// FlowGraphs 是**已落盘轮次图**的只读出口(契约 v0.73;切片 F2):注入的正是
|
||||
// 发布器每轮落盘用的**同一个**存储句柄,因此 `?turnId=` 与 `turns[]` 读到的就是
|
||||
// 收尾时写进去的历史轮。未接线时为真 nil(端点回退到只有内存的行为)。
|
||||
FlowGraphs: a.flowGraphStore(),
|
||||
// Usage 是 `llm_usage` 表的只读汇总出口(切片 U1):网关复用组合根已打开的
|
||||
// 连接池,自己不开池、不读环境变量。库未接时为 nil(端点回 500
|
||||
// usage_unavailable,不回假汇总)。
|
||||
Usage: model.NewUsageReader(a.db),
|
||||
// EventStore 是渠道事件留存的只读查询出口(design/26 §4.5;切片 E6):
|
||||
// 复用组合根已按 CHANNEL_EVENT_* 建好的那份 store。未接线时为**真 nil**
|
||||
// (`channelEventStore()` 的取舍),端点据此回 500 channel_events_unavailable,
|
||||
// 绝不回空 200 假装「没有事件」。
|
||||
EventStore: a.channelEventStore(),
|
||||
// MemoryTidy 是记忆整理「试算」的只读出口(契约 v0.75;切片 MT1):上面按
|
||||
// **真 nil** 取舍过的接口值。
|
||||
MemoryTidy: tidyDryRunner,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("gateway: %w", err)
|
||||
}
|
||||
gw.SetVoice(a.voice)
|
||||
a.gw = gw // 必须在下面接线 hook 之前赋值——那些 hook 捕获了 a.gw / gw(否则会 nil 接收者 panic)
|
||||
// 可观测性 trace 出口(design/19 §2):网关已存在,其环形缓冲必须由循环产出的
|
||||
// span 喂养,否则管理端 trace/metrics 接口会一直是空的。
|
||||
wireGatewayTraceSink(a, gw)
|
||||
if a.channelBridge != nil {
|
||||
// 把渠道桥中「主人私聊」持久化的消息接入编排器所用的同一个
|
||||
// `message_persisted` 广播,让 Web 客户端用同一套统一帧来镜像 IM 消息
|
||||
// (design/16 §4.4)。
|
||||
a.channelBridge.SetOnPersisted(a.gw.BroadcastPersisted)
|
||||
// IM 路由的**主动推送**另走一条通知通道(切片 P0b):它落的也是主会话,但帧顶
|
||||
// 带结构化判据 `proactive:true`,客户端据此在非前台补一发浏览器通知。此前 IM
|
||||
// 路由只有上面那条普通帧 ⇒ 走 IM 的主动消息一条都不弹(而 `auto` 提示在主
|
||||
// 人有已连接 IM 渠道时优先走 IM,恰恰是最常见的路径)。
|
||||
a.channelBridge.SetOnProactivePersisted(a.gw.BroadcastProactivePersisted)
|
||||
// design/16 §4.6 §6:被平台白/黑名单(allow/deny)丢弃的消息会记入网关审计
|
||||
// 轨迹,这样即使它从未到达循环,这次拒绝也「audit 可查」。
|
||||
a.channelBridge.SetOnRejected(func(msg adapter.UnifiedMessage, reason string) {
|
||||
a.gw.AuditEvent("bridge:"+msg.Platform, "channel.reject", reason)
|
||||
})
|
||||
}
|
||||
a.capacitySink = gw
|
||||
a.moodSink = gw
|
||||
|
||||
// 网关 hub(活跃客户端通路)与渠道桥(IM 回落)都已在位,现在接线 backthink 的
|
||||
// 主动推送出口。backthink.New 执行时它们都还不存在(hub 需要循环,桥需要循环与
|
||||
// 主人档案,这些都更晚才组装),若不在此处补接线,推送器会解析出 RouteNone。
|
||||
// 每个 setter 都在服务的生命周期锁下重建推送器。
|
||||
if a.backthink != nil {
|
||||
a.backthink.SetActiveSink(gw)
|
||||
if a.channelBridge != nil {
|
||||
a.backthink.SetIMSink(a.channelBridge)
|
||||
}
|
||||
// design/12 §N C.5:自排的四个动作(queued/coalesced/capped/denied)走与渠道
|
||||
// 黑名单拒绝同一条审计链(谁/什么/何时/结果),因此它们**面板可查**,不需要
|
||||
// 新端点(GET /api/v1/admin/observability/audit 已存在)。同样是晚绑定:
|
||||
// backthink.New 时网关还没组装。
|
||||
a.backthink.SetAuditSink(gw)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// wirePanelHub 给已构造的插件管理器接上面板声明中枢,并把它接到既有进程内 bus 的
|
||||
// `ws.panels_changed` 上(design/09 §5.2.1;切片 PLG-2)。之后再把内置插件的**动作
|
||||
// 处理器**登记到管理器上(切片 L1/PLG-2 的执行面接线):没有这一步,面板的每个动作都
|
||||
// 只能诚实回 501(声明在、没人执行)。
|
||||
//
|
||||
// 三条约束都在这里落地:
|
||||
// - **不新开端口**:热更新信号只走进程内 bus(hub 订阅 ws.> ⇒ 帧类型 panels_changed),
|
||||
// 与既有实时帧同一根管子;
|
||||
// - **帧不带内容**:载荷只有 reason / pluginId,前端按自己的角色重拉一次
|
||||
// GET /api/v1/admin/panels,避免在帧里再实现一遍角色过滤(也就不会漏过门);
|
||||
// - **接线失败不阻断启动**:中枢构造失败只告警(网关随后对两条面板端点诚实回 501),
|
||||
// 与 refresh store / key store 的降级口径一致。
|
||||
func wirePanelHub(a *app) {
|
||||
if a.plugins == nil {
|
||||
return
|
||||
}
|
||||
log := logger.WithSource(a.log, "capability/plugin/panels")
|
||||
hub, err := pluginmgr.NewPanels(log)
|
||||
if err != nil {
|
||||
a.log.Warn("panel hub unavailable, plugin panel endpoints will answer 501", "error", err)
|
||||
return
|
||||
}
|
||||
hub.SetPublisher(func(ch pluginmgr.PanelChange) {
|
||||
if a.bus == nil {
|
||||
return
|
||||
}
|
||||
if err := a.bus.Publish(context.Background(), "ws.panels_changed", map[string]any{
|
||||
"reason": ch.Reason,
|
||||
"pluginId": ch.PluginID,
|
||||
}); err != nil {
|
||||
a.log.Warn("panels_changed publish failed", "reason", ch.Reason, "pluginId", ch.PluginID, "error", err)
|
||||
}
|
||||
})
|
||||
a.plugins.AttachPanels(hub)
|
||||
declareBuiltinPanels(hub, log)
|
||||
// browser 插件(design/28 §8 B4)的面板声明 + 六个动作:它自己就把「先声明、后登记
|
||||
// 动作」这一步走完(wire_browser_panel.go 一处),因此与 search 的纯声明并列。
|
||||
//
|
||||
// 为什么要挡一下 `cfg == nil`:浏览器面板的配置路径与表单默认值**全部**由
|
||||
// `$DATA_DIR` 推出(wire_browser_panel.go 里 `resolveDataDir(a.cfg)`),而部分只读
|
||||
// 夹具(wire_panels_test.go / wire_plugin_actions_test.go 只填了 plugins/bus/log)
|
||||
// 没有配置 ⇒ 直调会解空指针。生产路径上 wireFrontend 跑在 config.Load 之后,
|
||||
// `a.cfg` 恒非 nil,因此这一支只为「不完整夹具」存在(同 wireBrowser 的取舍)。
|
||||
if a.cfg != nil {
|
||||
wireBrowserPanel(hub, a, a.log)
|
||||
}
|
||||
// 登记动作处理器必须在**声明之后**:登记期会核对「登记的处理器有没有对应声明」,
|
||||
// 顺序反过来只会得到一串「声明还没到」的 WARN。
|
||||
// 日志来源另标一处(动作接线 ≠ 面板中枢):`source=cmd/cyrene/wire_plugin_actions`
|
||||
// 让「谁登记了哪些动作 / 哪些声明没人实现」能在日志里独立定位。
|
||||
wireBuiltinPluginActions(a.plugins, hub, a, logger.WithSource(a.log, actionWireLogSource))
|
||||
}
|
||||
|
||||
// declareBuiltinPanels 把**编译进二进制的内置插件**的面板声明交给中枢
|
||||
// (design/09 §5.5 的 dogfood:「网络搜索 → 搜索提供商」页)。
|
||||
//
|
||||
// 为什么在组合根做这一步:内置插件的声明与它自己的能力同源(`plugins/tools/io/search`
|
||||
// 里就摆着 `AdminPanels()`),没有卸载/安装路径 ⇒ 只有「装配期登记一次」这一种生命周期。
|
||||
// 热装 MCP 插件的声明走另一条路(manifest 读取 + 生命周期重算,后续切片)。
|
||||
//
|
||||
// 声明被拒只告警、不阻断启动:一个坏声明不该让整个后端起不来(§5.2.1 的口径是
|
||||
// 「该面板不注册 + 显式报错」,不是「宿主陪葬」)。
|
||||
func declareBuiltinPanels(hub *pluginmgr.Panels, log *slog.Logger) {
|
||||
declarations := []struct {
|
||||
pluginID string
|
||||
panels []sdk.Panel
|
||||
}{
|
||||
{search.PanelPluginID, search.AdminPanels()},
|
||||
}
|
||||
for _, d := range declarations {
|
||||
accepted, err := hub.Declare(d.pluginID, d.panels)
|
||||
if err != nil {
|
||||
log.Warn("builtin panel declaration rejected",
|
||||
"pluginId", d.pluginID, "accepted", accepted, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wireAPIKeys 构造 API key 管理器,并把落盘载体接到它上面(K1:修「每次重启 key
|
||||
// 全失效」——此前 key 表只在内存里,主人刚签的 key 被一次重启全部打掉,签发端点
|
||||
// 长凭据的语义与「重启即作废」自相矛盾)。
|
||||
//
|
||||
// 降级口径与上面的 refresh store 一致:构造或载入失败**不阻断启动**,key 仍可签发
|
||||
// 与校验,只是不跨重启存活;后果写进日志,不静默。注意 AttachKeyStore 在 Load 失败
|
||||
// 时仍会把 store 接上(见其注释)——那样之后的签发会响亮地失败,而不是又一次
|
||||
// 「面板报成功、重启全 401」。
|
||||
func (a *app) wireAPIKeys() *auth.APIKey {
|
||||
keys := auth.NewAPIKey(a.cfg.Auth)
|
||||
if a.db == nil {
|
||||
return keys
|
||||
}
|
||||
store, err := auth.NewSQLiteKeyStore(a.db, a.log)
|
||||
if err != nil {
|
||||
a.log.Warn("api key store unavailable, issued keys will not survive restart", "error", err)
|
||||
return keys
|
||||
}
|
||||
if err := keys.AttachKeyStore(context.Background(), store); err != nil {
|
||||
a.log.Warn("api key store load failed, keys issued earlier may be rejected", "error", err)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// vectorState 把「PG 增强到底有没有生效」的**运行时真相**报给网关,供管理侧
|
||||
// 「存储状态」只读端点作答(design/03 §3;面板首页的存储卡)。
|
||||
//
|
||||
// 真源就在本组合根,且只有两项事实,因此这里**复用**运行时状态而不新造探测:
|
||||
//
|
||||
// - Configured = `DB_PG_URL` 是否配置(非空即算)—— 开关在不在;
|
||||
// - Live = 活着的向量索引确实落在 pgvector 后端上 —— 真的在不在跑。
|
||||
//
|
||||
// 两者会不一致:配置了 PG 但连接/schema 准备失败时,wireMemory 把 vindex 留成
|
||||
// nil(语义检索降级),此时 Configured=true、Live=false,面板据此显示「已配置但
|
||||
// 未就绪」,而不是把「配了」当成「生效了」。这里绝不新开 PG 连接去探测:那既多
|
||||
// 一次网络往返,也可能与运行中的索引给出不同答案。
|
||||
func (a *app) vectorState() gateway.VectorState {
|
||||
_, live := a.vindex.(*vector.PGVectorIndex)
|
||||
return gateway.VectorState{
|
||||
Configured: strings.TrimSpace(a.cfg.DB.PGURL) != "",
|
||||
Live: live,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
)
|
||||
|
||||
// 本文件锁定组合根接线(K1):wireFrontend 调用的 wireAPIKeys 必须真的把 SQLite
|
||||
// 载体接到 key 管理器,否则签发的 key 又回到「重启即失效」。
|
||||
//
|
||||
// 为什么在组合根测这件事:newApp 无法在单元测试里实例化(要开库、迁移、拉起全部
|
||||
// 子系统,见 wire_context_test.go 的说明),所以这里直接对 wireAPIKeys 这一步
|
||||
// 接线取证 —— 它与 newApp 里调用的是同一个函数。
|
||||
|
||||
// openMigratedTestDB 打开一个临时文件库并跑完全部迁移。用文件而非 :memory:,
|
||||
// 因为「重启仍在」必须跨连接读同一份数据。
|
||||
func openMigratedTestDB(t *testing.T, path string) *sql.DB {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
conn, err := db.Open(ctx, path, testLog())
|
||||
if err != nil {
|
||||
t.Fatalf("db.Open(%s): %v", path, err)
|
||||
}
|
||||
if err := db.MigrateUp(ctx, conn, testLog()); err != nil {
|
||||
_ = conn.Close()
|
||||
t.Fatalf("db.MigrateUp: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
return conn
|
||||
}
|
||||
|
||||
// TestWireAPIKeys_KeySurvivesRestart 断言组合根接的线真的落盘:签发 → 关连接
|
||||
// (进程退出)→ 用同一个库重新接线 → 同一把 key 仍能认证。
|
||||
func TestWireAPIKeys_KeySurvivesRestart(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "cyrene.db")
|
||||
|
||||
first := &app{db: openMigratedTestDB(t, path), cfg: &config.Config{}, log: testLog()}
|
||||
keys := first.wireAPIKeys()
|
||||
key, err := keys.Generate()
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if err := first.db.Close(); err != nil {
|
||||
t.Fatalf("close first connection: %v", err)
|
||||
}
|
||||
|
||||
second := &app{db: openMigratedTestDB(t, path), cfg: &config.Config{}, log: testLog()}
|
||||
restarted := second.wireAPIKeys()
|
||||
if !restarted.Verify(key) {
|
||||
t.Fatal("重启后 key 无法认证:组合根没有把落盘载体接上")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireAPIKeys_NilDBFallsBackToMemory 断言没有数据库句柄时(纯内存测试/
|
||||
// 构建路径)接线是空操作且不 panic:key 照旧可签发可校验,只是不跨重启。
|
||||
func TestWireAPIKeys_NilDBFallsBackToMemory(t *testing.T) {
|
||||
a := &app{db: nil, cfg: &config.Config{}, log: testLog()}
|
||||
keys := a.wireAPIKeys() // 必须不 panic
|
||||
key, err := keys.Generate()
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if !keys.Verify(key) {
|
||||
t.Fatal("未接库路径的 key 无法认证")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
)
|
||||
|
||||
// 本文件是组合根的默认护栏策略(design/04 §5)。三条默认策略与它们的依据:
|
||||
//
|
||||
// 1. 敏感信息 = **阻断**档。API key / bearer token / 密码 / 私钥这类东西
|
||||
// 一旦进了请求或回复就是既成事实的泄露,标记档(放行 + 记一笔)救不回来,
|
||||
// 因此默认按阻断处理。
|
||||
// 2. 长度上限 = 只拦病态载荷,不误伤正常对话(两个上限的取值依据见下方常量)。
|
||||
// 3. 关键词表 = **只在配置提供时**挂。项目不内置任何词表:凭空猜的词只会
|
||||
// 误伤正常对话,且没有部署方能为它的命中负责(见 config.GuardrailConfig)。
|
||||
const (
|
||||
// guardrailMinInputChars 是请求侧字符上限的**下限**(256 Ki 字符)。这个检查
|
||||
// 跑在**组装完成的请求**上(系统提示词 + 记忆块 + 整段对话 + 工具结果),
|
||||
// 不是单条用户消息,因此上限按「整个上下文」定。
|
||||
//
|
||||
// 为什么是「下限」而不是「定值」(切片 G2 修的真 bug):单份附件的注入预算随
|
||||
// 推导出的上下文预算走,1M 窗口下已是 349525 字符(T1)。上限若还是硬编码的
|
||||
// 262144,任何正文超过它的附件都会让**整个**回合以 guardrail_input 结束
|
||||
// (端到端实测:`POST /chat` 带一个 744 KB 的 txt ⇒
|
||||
// `{"reply":"input_length_exceeded","stopReason":"guardrail_input"}`),
|
||||
// 也就是「护栏成了上下文预算的瓶颈」,把「分享大文件」这条主线直接打死。
|
||||
// 因此生效值由 guardrailInputLimit 派生:max(本下限, 推导预算 token 数 × CharsPerToken)。
|
||||
// 预算关闭(0)或预算小到乘完仍低于本下限时,上限就是本下限。
|
||||
guardrailMinInputChars = 262144
|
||||
// guardrailMaxOutputChars 是响应侧的字符上限。一条回复正常在几十到几百字,
|
||||
// 长代码/长文也就几千字,65536(64 Ki)字符比正常回复高 2~3 个数量级,
|
||||
// 因此不会截断任何正常输出,只拦失控的生成(例如无限复读)。这一点在主
|
||||
// 流式路径上尤其重要:那里输出侧检查是异步旁路(只会审计,不挡已产出的
|
||||
// 回复),但在静音/非流式路径(backthink、子代理)上否判决会替换答案,
|
||||
// 所以上限必须留足余量。
|
||||
//
|
||||
// **它不随上下文预算派生**:输出失控与「窗口有多大」无关,本切片的输入侧
|
||||
// 解耦不该顺手把它也放宽。
|
||||
guardrailMaxOutputChars = 65536
|
||||
)
|
||||
|
||||
// maxChatDocumentsPerTurn 是**单回合**附件数上限(design/24 §6.1、§9.4-3 的定稿值 5)。
|
||||
// 它是关系不变量「N 份」的那一端:输入上限必须容得下「一整个回合的附件都撞满单份
|
||||
// 注入预算」的装配结果,否则满载附件的回合会在装配完之后被整回合拦死(切片 G2 只
|
||||
// 对了 **1 份**,本切片补 **N 份**)。
|
||||
//
|
||||
// 出处:真源是 documents.MaxPerMessage(网关侧以
|
||||
// `maxChatDocuments = documents.MaxPerMessage` 引用它)。本切片**不跨包引用**它 ——
|
||||
// documents 正被并行切片占用,且这里只需要一个数、不需要它的任何行为;因此取同值,
|
||||
// 并用测试里的来源说明 + 不变量断言钉住。待并行切片落定后统一成一个共享常量。
|
||||
const maxChatDocumentsPerTurn = 5
|
||||
|
||||
// buildGuardrailPipeline 组装循环与各 agent 共用的护栏管线(design/04 §5)。
|
||||
// 它是组合根里唯一一处决定「护栏里到底有什么」的地方,因此测试可以直接
|
||||
// 断言这个函数的产物,而不必启动整个运行时。
|
||||
//
|
||||
// 取 budget 参数而不是自己去推导:输入上限是**上下文预算的函数**(关系不变量
|
||||
// 「单回合全部附件的注入预算合计 ≤ 输入上限」的两端都在这份预算里),而预算的
|
||||
// 推导只该在 wire_context.go 发生一次 —— 各自推导会把「窗口未声明」那条 WARN
|
||||
// 记两遍。调用顺序因此固定为「先算预算,再建管线」(wire_runtime.go)。
|
||||
//
|
||||
// 选项顺序即检查顺序(guardrail.New 按序应用),且先跑开销低、语义硬的
|
||||
// 敏感信息检查:命中即返回该判决,长度与关键词检查不会再跑。
|
||||
func (a *app) buildGuardrailPipeline(budget ctxBudget) *guardrail.Pipeline {
|
||||
opts := []guardrail.Option{
|
||||
// ① 敏感信息外泄必须拦:阻断档。
|
||||
guardrail.WithSensitiveInfo(guardrail.SensitiveBlock),
|
||||
// ② 长度上限:0 会关掉那一侧,因此两侧都给上保守上限。
|
||||
guardrail.WithLengthLimits(a.guardrailInputLimit(budget), guardrailMaxOutputChars),
|
||||
}
|
||||
// ③ 关键词表仅在配置提供时挂:没有配置就不挂(KeywordGuard 空表本也
|
||||
// 全放行,这里显式分支是为了让「没配置」在管线上就是没有这个检查)。
|
||||
if keywords := a.guardrailKeywords(); len(keywords) > 0 {
|
||||
opts = append(opts, guardrail.WithKeywords(keywords...))
|
||||
}
|
||||
return guardrail.New(opts...)
|
||||
}
|
||||
|
||||
// guardrailInputLimit 返回本次运行生效的请求侧字符上限:
|
||||
// 配置显式覆盖优先,否则由推导出的上下文预算派生(见 deriveGuardrailInputLimit)。
|
||||
func (a *app) guardrailInputLimit(budget ctxBudget) int {
|
||||
override := 0
|
||||
if a.cfg != nil {
|
||||
override = a.cfg.Guardrail.MaxInputChars
|
||||
}
|
||||
return deriveGuardrailInputLimit(
|
||||
sourcedWiringLog(a.log), override,
|
||||
budget.Budget.Tokens, budget.DocumentInjectChars, maxChatDocumentsPerTurn,
|
||||
)
|
||||
}
|
||||
|
||||
// deriveGuardrailInputLimit 是 guardrailInputLimit 的可测内核:取值来源收成显式
|
||||
// 参数,两条 WARN(非法覆盖 / 关系不变量被破)都在这里,测试可按条数断言。
|
||||
//
|
||||
// 派生公式:输入上限 = max(guardrailMinInputChars, 推导预算 token 数 × CharsPerToken)。
|
||||
// 换算必须与**附件注入预算**同源(G2 的残留缺口正在这里):G2 把预算 token 数按
|
||||
// 1 字符 ≈ 1 token 兑成字符,而注入预算按 4 字符 ≈ 1 token 兑(loop.CharsPerToken),
|
||||
// 两边两套换算 ⇒ 上限 699050 字符只够 **2 份**各自撞满注入预算的附件
|
||||
// (2 × 349525 = 699050),5 份必被拦。改用注入的同一常量后,1M 窗口的上限是
|
||||
// 2796200 字符,5 份满额注入 1747625 字符仍有明确余量;上限仍然只拦「几百 KB
|
||||
// 整块文本直接灌进来」这种病态载荷(真正的上下文控制由压缩预算负责)。
|
||||
func deriveGuardrailInputLimit(log *slog.Logger, override, budgetTokens, docInjectChars, maxDocs int) int {
|
||||
derived := guardrailMinInputChars
|
||||
if scaled := budgetTokens * loop.CharsPerToken; scaled > derived {
|
||||
derived = scaled
|
||||
}
|
||||
limit := derived
|
||||
switch {
|
||||
case override < 0:
|
||||
// 非法覆盖:记一条带原始取值的 WARN 并回落派生值(AGENTS §10:不许静默)。
|
||||
log.Warn("guardrail input limit invalid, falling back to derived",
|
||||
"env", config.GuardrailMaxInputCharsEnv, "value", override, "fallback", derived)
|
||||
case override > 0:
|
||||
limit = override
|
||||
}
|
||||
if injected := docInjectChars * maxDocs; injected > limit {
|
||||
// 关系不变量被破(**多附件**口径):一个回合最多 maxDocs 份附件,每份可注入
|
||||
// docInjectChars 字符,装配后的请求至少要容得下「全部附件都撞满注入预算」。
|
||||
// 破了就说明满载附件的回合会在装配完之后被整回合拦下,现象是用户看到一个
|
||||
// 没有上下文的 input_length_exceeded。这里明说一句,不静默。
|
||||
log.Warn("document inject budget x max attachments exceeds guardrail input limit; multi-attachment turns may be blocked",
|
||||
"env", config.GuardrailMaxInputCharsEnv,
|
||||
"guardrail_max_input_chars", limit,
|
||||
"max_documents", maxDocs,
|
||||
"document_inject_chars", docInjectChars,
|
||||
"document_inject_total_chars", injected,
|
||||
"hint", "调大本键或调小 DOCUMENT_MAX_INJECT_CHARS,使「单份注入 × 单回合附件数」不超过输入上限")
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
// guardrailKeywords 返回部署方声明的内容策略词表(GUARDRAIL_KEYWORDS);
|
||||
// 未配置时返回 nil,管线里就不含关键词检查。
|
||||
func (a *app) guardrailKeywords() []string {
|
||||
if a.cfg == nil {
|
||||
return nil
|
||||
}
|
||||
return a.cfg.Guardrail.Keywords
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// 本文件锁定切片 G2 / G2b 的输入侧解耦(端到端实测抓到的真 bug:大附件被输入护栏拦死):
|
||||
//
|
||||
// - 输入上限 = max(256 Ki, 推导预算 token 数 × CharsPerToken)(与附件注入预算
|
||||
// **同一套换算**;G2 按 1 字符 ≈ 1 token 兑,于是 2 份满额注入的附件就顶到上限,
|
||||
// G2b 统一到注入侧那个常量);
|
||||
// - GUARDRAIL_MAX_INPUT_CHARS 的三态(未设/0 = 派生、>0 = 覆盖、<0 = WARN 回落);
|
||||
// - 关系不变量「单回合全部附件的注入预算合计 ≤ 输入护栏上限」,人为破坏它必须**有 WARN**;
|
||||
// - 回归:注入正文比旧上限(262144 字符)还大时,回合**不得**以 guardrail_input 结束;
|
||||
// - 反证:显式把上限收紧后,同一个大附件回合**真的**被拦(护栏没被削弱)。
|
||||
//
|
||||
// 多附件的回归与「N 份」不变量的用例在 wire_guardrail_multidoc_test.go。
|
||||
//
|
||||
// 输出上限(guardrailMaxOutputChars)不属本切片:它防的是模型输出失控,与上下文
|
||||
// 预算无关,因此保持 65536 不动(回归见 TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads)。
|
||||
|
||||
// largeDocChars 是回归夹具的附件正文长度:既大于旧上限 262144(否则复现不到 bug),
|
||||
// 又大于 1M 窗口下的注入预算 349525(因此走「已截断 + 续读游标」那条路径,
|
||||
// 与主人实测时历史 DTO 里 `"chars":349525,"truncated":true` 的形状一致)。
|
||||
const largeDocChars = 400000
|
||||
|
||||
// TestGuardrailInputLimit_DerivedFromBudget 钉住派生公式(表驱动):
|
||||
// 输入上限 = max(下限, 推导预算 token 数 × CharsPerToken) —— 小预算落回下限,
|
||||
// 大窗口随预算抬高(换算与附件注入预算同一个常量,否则多附件回合会被拦死)。
|
||||
func TestGuardrailInputLimit_DerivedFromBudget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
tokens int
|
||||
want int
|
||||
}{
|
||||
{"1M 窗口推导出的预算(699050 token)⇒ 按 CharsPerToken 上抬", 699050, 2796200},
|
||||
{"预算正好等于下限 ÷ CharsPerToken ⇒ 下限", guardrailMinInputChars / loop.CharsPerToken, guardrailMinInputChars},
|
||||
{"小预算(600 token)⇒ 回落下限", 600, guardrailMinInputChars},
|
||||
{"关闭压缩(0)⇒ 下限", 0, guardrailMinInputChars},
|
||||
{"负预算 ⇒ 下限", -1, guardrailMinInputChars},
|
||||
}
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
log, _ := capturingLog()
|
||||
if got := deriveGuardrailInputLimit(log, 0, c.tokens, 0, maxChatDocumentsPerTurn); got != c.want {
|
||||
t.Fatalf("deriveGuardrailInputLimit(budget=%d) = %d, want %d", c.tokens, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuardrailInputLimit_OverrideAndIllegalValue 钉住 env 三态:>0 覆盖、<0 记
|
||||
// **恰好一条** WARN 后回落派生值(带原始取值,绝不静默)。
|
||||
func TestGuardrailInputLimit_OverrideAndIllegalValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
log, buf := capturingLog()
|
||||
if got := deriveGuardrailInputLimit(log, 1000, 699050, 0, maxChatDocumentsPerTurn); got != 1000 {
|
||||
t.Fatalf("显式覆盖 = %d, want 1000(覆盖优先于派生)", got)
|
||||
}
|
||||
if n := countRecords(logRecords(t, buf), "WARN", "guardrail input limit invalid"); n != 0 {
|
||||
t.Fatalf("合法覆盖却记了 %d 条 WARN", n)
|
||||
}
|
||||
|
||||
log, buf = capturingLog()
|
||||
if got := deriveGuardrailInputLimit(log, -1, 699050, 0, maxChatDocumentsPerTurn); got != 2796200 {
|
||||
t.Fatalf("非法覆盖后的回落 = %d, want 派生值 2796200", got)
|
||||
}
|
||||
records := logRecords(t, buf)
|
||||
if n := countRecords(records, "WARN", "guardrail input limit invalid"); n != 1 {
|
||||
t.Fatalf("非法覆盖的 WARN 条数 = %d, want 恰好 1(记录:%v)", n, records)
|
||||
}
|
||||
// WARN 必须点出「改哪个键」与原始取值,否则部署方不知道该去改哪儿。
|
||||
found := false
|
||||
for _, rec := range records {
|
||||
if msg, _ := rec["msg"].(string); strings.Contains(msg, "guardrail input limit invalid") {
|
||||
found = true
|
||||
if rec["env"] != config.GuardrailMaxInputCharsEnv {
|
||||
t.Fatalf("WARN 没点出配置键: %v", rec)
|
||||
}
|
||||
if rec["value"] != float64(-1) || rec["fallback"] != float64(2796200) {
|
||||
t.Fatalf("WARN 没带原始取值与回落值: %v", rec)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("没有找到非法覆盖的 WARN")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuardrailInputLimit_RelationInvariantWithInjectBudget 钉住关系不变量
|
||||
// 「单回合全部附件的注入预算合计 ≤ 输入护栏上限」:对 1M 窗口的推导值成立
|
||||
// (maxDocuments 份 × 单份注入),且人为把上限压到之下 ⇒ **记一条 WARN**
|
||||
// (消息与字段都要让人一眼看出是**多附件**问题),不静默。
|
||||
func TestGuardrailInputLimit_RelationInvariantWithInjectBudget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// 1M 窗口的推导:预算 699050 token ⇒ 单份附件注入预算 349525 字符。
|
||||
a := &app{log: testLog(), cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)}
|
||||
budget := a.contextBudget(filepath.Join(t.TempDir(), "absent.env"))
|
||||
if budget.DocumentInjectChars != 349525 {
|
||||
t.Fatalf("1M 窗口下的注入预算 = %d, want 349525(推导链变了,本用例的前提需要复核)",
|
||||
budget.DocumentInjectChars)
|
||||
}
|
||||
|
||||
log, buf := capturingLog()
|
||||
limit := deriveGuardrailInputLimit(log, 0, budget.Budget.Tokens, budget.DocumentInjectChars, maxChatDocumentsPerTurn)
|
||||
if total := budget.DocumentInjectChars * maxChatDocumentsPerTurn; total > limit {
|
||||
t.Fatalf("关系不变量被破:%d 份注入合计 %d > 输入上限 %d",
|
||||
maxChatDocumentsPerTurn, total, limit)
|
||||
}
|
||||
if limit != 2796200 {
|
||||
t.Fatalf("1M 窗口下的输入上限 = %d, want 2796200(= 推导预算 token 数 × %d)",
|
||||
limit, loop.CharsPerToken)
|
||||
}
|
||||
if n := countRecords(logRecords(t, buf), "WARN", "exceeds guardrail input limit"); n != 0 {
|
||||
t.Fatalf("不变量成立却记了 %d 条 WARN", n)
|
||||
}
|
||||
|
||||
// 人为破坏:把上限压到「全部附件注入合计」之下 ⇒ 必须有一条 WARN 明说后果,
|
||||
// 且带上 N 份 / 单份 / 合计三项取值(否则没人看得出这是多附件问题)。
|
||||
log, buf = capturingLog()
|
||||
if got := deriveGuardrailInputLimit(log, 4096, budget.Budget.Tokens, budget.DocumentInjectChars, maxChatDocumentsPerTurn); got != 4096 {
|
||||
t.Fatalf("收紧后的上限 = %d, want 4096", got)
|
||||
}
|
||||
records := logRecords(t, buf)
|
||||
if n := countRecords(records, "WARN", "exceeds guardrail input limit"); n != 1 {
|
||||
t.Fatalf("不变量被破的 WARN 条数 = %d, want 恰好 1(记录:%v)", n, records)
|
||||
}
|
||||
found := false
|
||||
for _, rec := range records {
|
||||
if msg, _ := rec["msg"].(string); strings.Contains(msg, "exceeds guardrail input limit") {
|
||||
found = true
|
||||
if !strings.Contains(msg, "multi-attachment") {
|
||||
t.Fatalf("WARN 文案看不出是多附件问题: %q", msg)
|
||||
}
|
||||
if rec["document_inject_chars"] != float64(budget.DocumentInjectChars) ||
|
||||
rec["guardrail_max_input_chars"] != float64(4096) ||
|
||||
rec["max_documents"] != float64(maxChatDocumentsPerTurn) ||
|
||||
rec["document_inject_total_chars"] != float64(budget.DocumentInjectChars*maxChatDocumentsPerTurn) {
|
||||
t.Fatalf("WARN 没写清 N 份与两端取值: %v", rec)
|
||||
}
|
||||
if hint, _ := rec["hint"].(string); hint == "" {
|
||||
t.Fatalf("WARN 没给出处置提示: %v", rec)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("没有找到关系不变量被破的 WARN")
|
||||
}
|
||||
}
|
||||
|
||||
// largeAttachmentTurn 是「一个回合 + 一份大附件」的观测结果。
|
||||
type attachmentTurn struct {
|
||||
// result 是该回合的循环结论(StopReason / Text / Documents 都在这里)。
|
||||
result *loop.Result
|
||||
// modelCalls 是模型被调用的次数(= 捕获到的请求数)。
|
||||
modelCalls int
|
||||
// assembledChars 是模型实际看到的文本总量(装配后、注入正文已在内)。
|
||||
assembledChars int
|
||||
// perDocInject 是本回合如实记录的**每份**附件注入字符数
|
||||
// (Result.Documents[i].Chars;夹具里各份等长)。
|
||||
perDocInject int
|
||||
// documents 是 Result.Documents 的条数(本回合真的注入了几份)。
|
||||
documents int
|
||||
// g2Limit 是 G2 时代的上限(推导预算 token 数按 1 字符 ≈ 1 token 兑成字符)。
|
||||
// 它让回归用例能自证「装配结果确实落在 G2/G2b 的缺口上」:装配总量超过了它。
|
||||
g2Limit int
|
||||
// inputLimit 是本回合实际生效的输入上限(a.guardrailInputLimit 的产物),
|
||||
// 供用例实证「装配总量落在上限之内」。
|
||||
inputLimit int
|
||||
}
|
||||
|
||||
// runAttachmentTurn 跑一个回合:1M 窗口的推导预算(单份注入 349525 字符)+ docs 份
|
||||
// largeDocChars 字符的 txt 附件 + 真实的护栏管线。inputLimitOverride 非 0 时
|
||||
// 模拟 GUARDRAIL_MAX_INPUT_CHARS 的覆盖态。
|
||||
//
|
||||
// 三个角色都是生产实现:预算取自 a.contextBudget、注入走真实的
|
||||
// loop.resolveDocuments + documents.Resolver、护栏是 a.buildGuardrailPipeline 的产物。
|
||||
// 唯一被单独钉住的是「窗口 = 1M」。
|
||||
func runAttachmentTurn(t *testing.T, docs, inputLimitOverride int) attachmentTurn {
|
||||
t.Helper()
|
||||
if docs < 1 {
|
||||
t.Fatalf("docs = %d, want ≥ 1(夹具至少要一份附件)", docs)
|
||||
}
|
||||
|
||||
dataDir := t.TempDir()
|
||||
uploads := filepath.Join(dataDir, "uploads", "documents")
|
||||
if err := os.MkdirAll(uploads, 0o700); err != nil {
|
||||
t.Fatalf("建上传目录: %v", err)
|
||||
}
|
||||
body := strings.Repeat("abcdefghij", largeDocChars/10)
|
||||
refs := make([]model.Document, 0, docs)
|
||||
for i := 0; i < docs; i++ {
|
||||
name := attachmentName(i)
|
||||
if err := os.WriteFile(filepath.Join(uploads, name), []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("写上传件: %v", err)
|
||||
}
|
||||
refs = append(refs, model.Document{Name: name, MIME: "text/plain", Size: int64(len(body))})
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataDir: dataDir}
|
||||
cfg.Guardrail.MaxInputChars = inputLimitOverride
|
||||
a := &app{log: testLog(), cfg: cfg, llm: envWindowStack(t, 1048576, 0)}
|
||||
budget := a.contextBudget(filepath.Join(t.TempDir(), "absent.env"))
|
||||
guards := a.buildGuardrailPipeline(budget)
|
||||
|
||||
adapter := &compactProbeAdapter{}
|
||||
ag, err := agent.New("你是昔涟。", nil, guards, agent.WithModel(adapter, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("agent.New: %v", err)
|
||||
}
|
||||
l := loop.New(loop.Options{
|
||||
Agent: ag,
|
||||
Guards: guards,
|
||||
DocumentResolver: documents.NewResolver(documents.Config{DataDir: dataDir, UploadsDir: uploads}),
|
||||
DocumentMaxInjectChars: budget.DocumentInjectChars,
|
||||
OnStream: func(model.StreamEvent) {},
|
||||
})
|
||||
res, err := l.Run(context.Background(), "sess-large-doc",
|
||||
[]model.Message{documentTurnMessage("看看这些附件", refs)})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
turn := attachmentTurn{
|
||||
result: res, modelCalls: len(adapter.reqs),
|
||||
documents: len(res.Documents), g2Limit: budget.Budget.Tokens,
|
||||
inputLimit: a.guardrailInputLimit(budget),
|
||||
}
|
||||
if len(res.Documents) > 0 {
|
||||
turn.perDocInject = res.Documents[0].Chars
|
||||
}
|
||||
if len(adapter.reqs) > 0 {
|
||||
req := adapter.reqs[len(adapter.reqs)-1]
|
||||
for _, m := range req.Messages {
|
||||
turn.assembledChars += len(loopText(m))
|
||||
}
|
||||
}
|
||||
return turn
|
||||
}
|
||||
|
||||
// attachmentName 造第 i 份附件的服务端存储名(32 位十六进制 + .txt,与
|
||||
// wire_document_budget_test.go 的 docE2EName 同一形态)。解析器按名取件,
|
||||
// 因此多份附件必须各有一个名字。
|
||||
func attachmentName(i int) string {
|
||||
return fmt.Sprintf("%032x.txt", i+1)
|
||||
}
|
||||
|
||||
// documentTurnMessage 造一条携带 N 份文档引用的用户消息:model 包只提供单份的
|
||||
// 构造器(DocumentMessage),多份按同一形状续部件 —— 这正是「一个回合最多
|
||||
// maxChatDocumentsPerTurn 份附件」的落库形状。
|
||||
func documentTurnMessage(text string, refs []model.Document) model.Message {
|
||||
msg := model.DocumentMessage(model.RoleUser, text, refs[0])
|
||||
for _, ref := range refs[1:] {
|
||||
msg.Content = append(msg.Content, model.Content{
|
||||
Type: model.ContentTypeDocument,
|
||||
DocumentName: ref.Name,
|
||||
DocumentMIME: ref.MIME,
|
||||
DocumentURL: ref.URL,
|
||||
DocumentSize: ref.Size,
|
||||
})
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// TestGuardrailWiring_LargeAttachmentPassesInputGuard 是**回归**,对准主人端到端
|
||||
// 实测抓到的那个真 bug:`POST /api/v1/chat` 带一个 744 KB 的 txt 附件 ⇒
|
||||
// `{"reply":"input_length_exceeded","stopReason":"guardrail_input"}` —— 历史 DTO 里
|
||||
// 已如实写下 `"chars":349525,"truncated":true`,工具 document-read 也正常,
|
||||
// 是回合在**输入护栏**这一层就没被放进去,「分享大文件」这条主线因此被打死。
|
||||
//
|
||||
// 这里在进程内复现同一形状,断言它不再发生:注入正文比旧上限还大,但回合照常
|
||||
// 抵达模型(adapter 被调用一次)并以正常回答结束。
|
||||
func TestGuardrailWiring_LargeAttachmentPassesInputGuard(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
turn := runAttachmentTurn(t, 1, 0)
|
||||
|
||||
// ① bug 的症状先说清楚:回合不得以 guardrail_input 结束,也不得被拦在模型之前。
|
||||
if turn.result.StopReason == agent.StopReasonGuardrailInput {
|
||||
t.Fatalf("大附件回合仍以 guardrail_input 结束(这正是被修的 bug):%q", turn.result.Text)
|
||||
}
|
||||
if turn.modelCalls != 1 {
|
||||
t.Fatalf("大附件回合的模型调用次数 = %d, want 1(护栏把回合拦在模型之前了)", turn.modelCalls)
|
||||
}
|
||||
// ② 前提自证:这一回合真的注入了 349525 字符(= 主人实测 DTO 里那个数),
|
||||
// 比旧上限 262144 大,所以上面那条断言不是碰巧成立。
|
||||
if turn.perDocInject != 349525 {
|
||||
t.Fatalf("注入字符数 = %d, want 349525(1M 窗口的注入预算;形状变了要复核本用例)",
|
||||
turn.perDocInject)
|
||||
}
|
||||
if turn.assembledChars <= guardrailMinInputChars {
|
||||
t.Fatalf("装配后的请求只有 %d 字符(≤ 旧上限 %d),复现不到「大附件被拦死」",
|
||||
turn.assembledChars, guardrailMinInputChars)
|
||||
}
|
||||
if !turn.result.Documents[0].Truncated {
|
||||
t.Fatal("清单没有如实标注注入被截断(design/24 §4.3)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuardrailWiring_TightInputLimitStillBlocks 是**护栏没被削弱**的自证:
|
||||
// 同一个大附件回合,只要显式把输入上限压到 1000 字符(GUARDRAIL_MAX_INPUT_CHARS
|
||||
// 的覆盖态),回合必须真的被拦成 guardrail_input,且**在模型调用之前**拦下。
|
||||
//
|
||||
// 没有这条,上面那条回归有可能是「把输入侧长度检查放空了」才通过的。
|
||||
func TestGuardrailWiring_TightInputLimitStillBlocks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
turn := runAttachmentTurn(t, 1, 1000)
|
||||
|
||||
if turn.result.StopReason != agent.StopReasonGuardrailInput {
|
||||
t.Fatalf("收紧上限后 StopReason = %q, want %q", turn.result.StopReason, agent.StopReasonGuardrailInput)
|
||||
}
|
||||
if !strings.Contains(turn.result.Text, "input_length_exceeded") {
|
||||
t.Fatalf("收紧上限后的回答 = %q, want 含 input_length_exceeded", turn.result.Text)
|
||||
}
|
||||
if turn.modelCalls != 0 {
|
||||
t.Fatalf("被拦的回合仍调用了模型 %d 次 —— 输入侧不是前置门", turn.modelCalls)
|
||||
}
|
||||
// 注入本身照旧发生(记录如实),被拦是**上限**那一侧的判决。
|
||||
if turn.perDocInject != 349525 {
|
||||
t.Fatalf("注入字符数 = %d, want 349525(注入事实不因上限收紧而改写)", turn.perDocInject)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuardrailInputLimit_NotAFunctionOfInjectBudget 是变异反证:注入预算单独变化
|
||||
// **不影响**输入上限(上限只由上下文预算与显式覆盖决定),因此「N 份注入 ≤ 上限」
|
||||
// 不可能靠两边同源凑出来 —— 两端确实各自有出处。
|
||||
func TestGuardrailInputLimit_NotAFunctionOfInjectBudget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
log, _ := capturingLog()
|
||||
if got := deriveGuardrailInputLimit(log, 0, 699050, 0, maxChatDocumentsPerTurn); got != 2796200 {
|
||||
t.Fatalf("注入预算为 0 时的输入上限 = %d, want 2796200", got)
|
||||
}
|
||||
if got := deriveGuardrailInputLimit(log, 0, 699050, 349525, maxChatDocumentsPerTurn); got != 2796200 {
|
||||
t.Fatalf("注入预算 349525 时的输入上限 = %d, want 2796200(不受注入预算影响)", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
)
|
||||
|
||||
// 本文件锁定切片 G2b:把输入护栏上限从「1 份附件」对齐到「**N 份**附件」。
|
||||
//
|
||||
// G2(51c1672)修的是「单份大附件被拦死」,残留缺口是**多附件**:
|
||||
//
|
||||
// - 护栏量的是整段装配请求(字符数),而注入预算是**单份**的:
|
||||
// 1M 窗口下每份注入 349525 字符、上限 699050 字符 ⇒ **2 份**满额注入就顶到上限,
|
||||
// 再加系统提示词/历史即被拦成 guardrail_input,5 份(单回合上限)必被拦;
|
||||
// - 根因是两套换算:上限按 1 char ≈ 1 token 兑,注入按 loop.CharsPerToken(4)兑。
|
||||
//
|
||||
// 修法:上限 = max(guardrailMinInputChars, 推导预算 token 数 × loop.CharsPerToken),
|
||||
// 关系不变量升级为「maxChatDocumentsPerTurn 份 × 单份注入 ≤ 输入上限」,破了就 WARN。
|
||||
|
||||
// TestGuardrailWiring_TwoFullAttachmentsPassInputGuard 是切片 G2b 对准缺口的回归:
|
||||
// **2 份**各自撞满注入预算的附件(+ 系统提示词/历史)**不得**再以 guardrail_input
|
||||
// 结束。G2 之后的上限是 699050 字符(预算 token 数按 1:1 兑),而 2 份满额注入恰好
|
||||
// 就是 699050 字符 ⇒ 加上装配的固定开销必然超限,这正是主人实测的「多附件回合被拦死」。
|
||||
func TestGuardrailWiring_TwoFullAttachmentsPassInputGuard(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
turn := runAttachmentTurn(t, 2, 0)
|
||||
|
||||
// ① 前提自证:这一回合真的带了 2 份附件,且每份都撞满注入预算(349525 字符)。
|
||||
if turn.documents != 2 {
|
||||
t.Fatalf("本回合注入的附件份数 = %d, want 2", turn.documents)
|
||||
}
|
||||
if turn.perDocInject != 349525 {
|
||||
t.Fatalf("每份注入字符数 = %d, want 349525(1M 窗口的注入预算;形状变了要复核本用例)",
|
||||
turn.perDocInject)
|
||||
}
|
||||
// ② bug 的症状先说清楚:回合不得以 guardrail_input 结束,也不得被拦在模型之前。
|
||||
if turn.result.StopReason == agent.StopReasonGuardrailInput {
|
||||
t.Fatalf("2 份大附件回合仍以 guardrail_input 结束(G2b 要修的就是它):%q", turn.result.Text)
|
||||
}
|
||||
if turn.modelCalls != 1 {
|
||||
t.Fatalf("2 份大附件回合的模型调用次数 = %d, want 1(护栏把回合拦在模型之前了)", turn.modelCalls)
|
||||
}
|
||||
// ③ 缺口自证:装配后的请求**超过**了 G2 时代的上限(= 推导预算 token 数),
|
||||
// 也就是「2 份满额注入就顶到上限」—— 上面那条断言因此不是碰巧成立。
|
||||
if turn.assembledChars <= turn.g2Limit {
|
||||
t.Fatalf("装配后的请求只有 %d 字符(≤ G2 时代的上限 %d),复现不到多附件缺口",
|
||||
turn.assembledChars, turn.g2Limit)
|
||||
}
|
||||
// ④ 每份的注入事实如实回传(正文都比预算长 ⇒ 都该标 truncated + 续读游标)。
|
||||
for i, ref := range turn.result.Documents {
|
||||
if ref.Chars != 349525 || !ref.Truncated {
|
||||
t.Fatalf("第 %d 份附件的注入记录 = %+v,want 349525 字符且 truncated", i+1, ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuardrailWiring_FiveFullAttachmentsPassInputGuard 是同一缺口的**上界**回归:
|
||||
// 5 份(单回合上限)满额注入的回合同样要抵达模型。只说「2 份能过」不够 ——
|
||||
// 本切片的不变量是「N 份全满也能过」,这里就按 N 跑一次真回合。
|
||||
func TestGuardrailWiring_FiveFullAttachmentsPassInputGuard(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
turn := runAttachmentTurn(t, maxChatDocumentsPerTurn, 0)
|
||||
|
||||
if turn.documents != maxChatDocumentsPerTurn {
|
||||
t.Fatalf("本回合注入的附件份数 = %d, want %d", turn.documents, maxChatDocumentsPerTurn)
|
||||
}
|
||||
if turn.result.StopReason == agent.StopReasonGuardrailInput {
|
||||
t.Fatalf("%d 份大附件回合仍以 guardrail_input 结束:%q",
|
||||
maxChatDocumentsPerTurn, turn.result.Text)
|
||||
}
|
||||
if turn.modelCalls != 1 {
|
||||
t.Fatalf("%d 份大附件回合的模型调用次数 = %d, want 1(护栏把回合拦在模型之前了)",
|
||||
maxChatDocumentsPerTurn, turn.modelCalls)
|
||||
}
|
||||
// 装配总量必须落在生效上限之内 —— 这条既是「没被拦」的解释,也是不变量的实证。
|
||||
if turn.assembledChars > turn.inputLimit {
|
||||
t.Fatalf("装配后的请求 %d 字符 > 生效输入上限 %d", turn.assembledChars, turn.inputLimit)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuardrailInputLimit_CoversAllAttachmentsOfATurn 钉住**N 份**不变量本身:
|
||||
// 派生态下「单回合全部附件的注入预算合计 ≤ 输入上限」必须成立(且不记 WARN),
|
||||
// 而把上限压到合计之下必须有一条看得懂是**多附件**问题的 WARN。
|
||||
//
|
||||
// maxChatDocumentsPerTurn = 5 的出处:单回合附件数上限的**真源**是
|
||||
// documents.MaxPerMessage(网关侧以 `maxChatDocuments = documents.MaxPerMessage`
|
||||
// 引用它),design/24 §6.1、§9.4-3 的定稿值。本切片**不跨包引用** —— documents 正
|
||||
// 被并行切片占用,而这里只需要一个数;因此取同值并在下面断言它仍是 5,值一旦漂移
|
||||
// 本用例即红。待并行切片落定后统一为一个共享常量(届时这两处都改为引用它)。
|
||||
func TestGuardrailInputLimit_CoversAllAttachmentsOfATurn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if maxChatDocumentsPerTurn != 5 {
|
||||
t.Fatalf("单回合附件数上限 = %d, want 5(documents.MaxPerMessage 的定稿值)",
|
||||
maxChatDocumentsPerTurn)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
window int
|
||||
wantDoc int
|
||||
wantCap int
|
||||
}{
|
||||
// 1M 窗口(声明了窗口的生产部署):预算 699050 token ⇒ 单份 349525、上限 2796200。
|
||||
{name: "1M 窗口", window: 1048576, wantDoc: 349525, wantCap: 2796200},
|
||||
// 未声明窗口的回落部署(131072):预算 87381 token ⇒ 单份落回 64K、上限 349524
|
||||
// (= 87381 × 4 > 256 Ki)。旧的 1:1 兑法在这里只给 262144,5 份 64K 合计 327680
|
||||
// 就会被拦 —— 缺口在回落部署上同样存在,本切片一并修掉。
|
||||
{name: "未声明窗口的回落部署", window: 0, wantDoc: 64 * 1024, wantCap: 87381 * loop.CharsPerToken},
|
||||
}
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := &app{log: testLog(), cfg: &config.Config{}, llm: envWindowStack(t, c.window, 0)}
|
||||
budget := a.contextBudget(filepath.Join(t.TempDir(), "absent.env"))
|
||||
if budget.DocumentInjectChars != c.wantDoc {
|
||||
t.Fatalf("单份注入预算 = %d, want %d(推导链变了,本用例的前提需复核)",
|
||||
budget.DocumentInjectChars, c.wantDoc)
|
||||
}
|
||||
|
||||
log, buf := capturingLog()
|
||||
limit := deriveGuardrailInputLimit(log, 0, budget.Budget.Tokens, budget.DocumentInjectChars, maxChatDocumentsPerTurn)
|
||||
if limit != c.wantCap {
|
||||
t.Fatalf("输入上限 = %d, want %d(= 推导预算 token 数 × %d)",
|
||||
limit, c.wantCap, loop.CharsPerToken)
|
||||
}
|
||||
if total := budget.DocumentInjectChars * maxChatDocumentsPerTurn; total > limit {
|
||||
t.Fatalf("N 份不变量被破:%d 份 × %d = %d > 上限 %d",
|
||||
maxChatDocumentsPerTurn, budget.DocumentInjectChars, total, limit)
|
||||
}
|
||||
if n := countRecords(logRecords(t, buf), "WARN", "exceeds guardrail input limit"); n != 0 {
|
||||
t.Fatalf("不变量成立却记了 %d 条 WARN", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnvExample_GuardrailInputFormulaMatchesCode 钉住模板与实现同步(AGENTS §10 铁律):
|
||||
// 该键在 .env.example 里必须**逐字一致**,且注释里的算例必须与代码里的常量现算结果
|
||||
// 相同 —— 常量或公式漂移时本用例红,照模板部署的新实例才不会漏配/误解口径。
|
||||
func TestEnvExample_GuardrailInputFormulaMatchesCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
|
||||
if err != nil {
|
||||
t.Fatalf("读 .env.example: %v", err)
|
||||
}
|
||||
template := string(raw)
|
||||
|
||||
// 键名逐字一致(从常量拼出来,重命名时本用例即红)。
|
||||
if key := "# " + config.GuardrailMaxInputCharsEnv + "="; !strings.Contains(template, key) {
|
||||
t.Fatalf(".env.example 里找不到 %q(键名必须与代码逐字一致)", key)
|
||||
}
|
||||
// 注释里的两个算例都由代码现算,模板与实现不许各说各话。
|
||||
// 1M 窗口 ⇒ 上限 = 699050 × CharsPerToken;5 份满额注入合计 = 单份注入 × 5。
|
||||
oneMegTokens := 699050
|
||||
needles := []string{
|
||||
fmt.Sprintf("%d", oneMegTokens*loop.CharsPerToken),
|
||||
fmt.Sprintf("%d", loop.DocumentInjectCharsFor(oneMegTokens)*maxChatDocumentsPerTurn),
|
||||
fmt.Sprintf("%d", guardrailMinInputChars),
|
||||
}
|
||||
for _, needle := range needles {
|
||||
if !strings.Contains(template, needle) {
|
||||
t.Fatalf(".env.example 的说明里缺少现算值 %q(模板没跟上代码口径)", needle)
|
||||
}
|
||||
}
|
||||
// 口径本身要写清是**多附件**问题,否则部署方照旧按「单份」理解。
|
||||
for _, needle := range []string{"多附件", "CharsPerToken"} {
|
||||
if !strings.Contains(template, needle) {
|
||||
t.Fatalf(".env.example 的说明里缺少 %q(没写清多附件口径)", needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// 本文件锁定 P0-5:组合根的护栏管线**不是空壳**。断言分两层 ——
|
||||
// 管线本身(正常对话过 / 敏感与超长被拦 / 词表只在配置提供时挂),
|
||||
// 以及端到端(同一个循环:正常消息真被模型回答,敏感消息在模型调用**之前**
|
||||
// 就被拦下,适配器一次都没被调用)。
|
||||
|
||||
// guardProbeAdapter 只回答固定文本并数调用次数,使「护栏是否在模型调用前拦下」
|
||||
// 变成可断言的事实。
|
||||
type guardProbeAdapter struct {
|
||||
reply string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (a *guardProbeAdapter) Name() string { return "guard-probe" }
|
||||
|
||||
func (a *guardProbeAdapter) Chat(context.Context, model.Request, model.Options) (model.Response, error) {
|
||||
a.calls++
|
||||
return model.Response{Content: a.reply}, nil
|
||||
}
|
||||
|
||||
func (a *guardProbeAdapter) ChatStream(ctx context.Context, req model.Request, on func(model.StreamEvent)) (model.Response, error) {
|
||||
if on != nil {
|
||||
on(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: a.reply})
|
||||
}
|
||||
return a.Chat(ctx, req, model.Options{})
|
||||
}
|
||||
|
||||
// noWindowBudget 是「没声明窗口的小部署」的预算形状:压缩预算与注入预算两项
|
||||
// 推导值都是 0,于是派生的输入上限落回下限 guardrailMinInputChars —— 也就是
|
||||
// 接线前那个硬编码值的等价场景。只关心「护栏里有什么」的用例传它即可。
|
||||
func noWindowBudget() ctxBudget { return ctxBudget{} }
|
||||
|
||||
// guardrailApp 返回带给定关键词表的组合根切片;cfg 为 nil 表示「一点配置都没有」。
|
||||
func guardrailApp(t *testing.T, keywords []string) *app {
|
||||
t.Helper()
|
||||
if keywords == nil {
|
||||
return &app{log: testLog()}
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Guardrail.Keywords = keywords
|
||||
return &app{log: testLog(), cfg: cfg}
|
||||
}
|
||||
|
||||
// checkInput / checkOutput 是测试侧的薄封装:把一段文本包成请求或响应再判一次。
|
||||
func checkInput(t *testing.T, p *guardrail.Pipeline, text string) *guardrail.Verdict {
|
||||
t.Helper()
|
||||
req := model.Request{Messages: []model.Message{model.TextMessage(model.RoleUser, text)}}
|
||||
v, err := p.CheckInput(context.Background(), &req)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckInput(%q): %v", truncate(text, 40), err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func checkOutput(t *testing.T, p *guardrail.Pipeline, text string) *guardrail.Verdict {
|
||||
t.Helper()
|
||||
resp := model.Response{Content: text}
|
||||
v, err := p.CheckOutput(context.Background(), &resp)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckOutput: %v", err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
|
||||
// TestGuardrailPipeline_NormalConversationPasses 是真断言①:正常对话照常通过。
|
||||
// 系统提示词 + 日常用户消息 + 一段几千字的正常长回复都必须放行 —— 护栏上线
|
||||
// 不能以「误伤日常聊天」为代价。
|
||||
func TestGuardrailPipeline_NormalConversationPasses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
p := guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget())
|
||||
|
||||
req := model.Request{Messages: []model.Message{
|
||||
model.TextMessage(model.RoleSystem, "你是昔涟,温柔俏皮的小女友。"),
|
||||
model.TextMessage(model.RoleUser, "今天有点累,陪我聊会儿天好不好?"),
|
||||
model.TextMessage(model.RoleAssistant, "好呀,人家陪着你♪ 今天遇到什么啦?"),
|
||||
model.TextMessage(model.RoleUser, "就是工作有点多。"),
|
||||
}}
|
||||
if v, _ := p.CheckInput(context.Background(), &req); !v.Allow {
|
||||
t.Fatalf("正常对话被输入护栏拦下:%+v", v)
|
||||
}
|
||||
|
||||
// 一段普通的长回复(约 3000 字)也不该被长度上限碰到。
|
||||
long := strings.Repeat("今天也要好好吃饭、早点睡觉哦,人家会一直陪着你的♪", 100)
|
||||
if v := checkOutput(t, p, long); !v.Allow {
|
||||
t.Fatalf("正常长回复被输出护栏拦下:%+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuardrailPipeline_BlocksSensitiveInfo 是真断言②:敏感信息真被拦(阻断档,
|
||||
// 不是标记档),且判决里不回流被命中的秘密本身(guardrail 包的既有契约)。
|
||||
func TestGuardrailPipeline_BlocksSensitiveInfo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
p := guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget())
|
||||
|
||||
secret := "sk-abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
secret string
|
||||
}{
|
||||
{name: "api key 参数", text: "api_key = " + secret, secret: secret},
|
||||
{name: "私钥头", text: "-----BEGIN RSA PRIVATE KEY-----\nMIIEow...", secret: "MIIEow"},
|
||||
{name: "bearer token", text: "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9", secret: "eyJhbGciOiJIUzI1NiJ9"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
v := checkOutput(t, p, tc.text)
|
||||
if v.Allow {
|
||||
t.Fatalf("敏感内容未被拦下:%q → %+v", tc.text, v)
|
||||
}
|
||||
if !strings.Contains(v.Reason, "sensitive_information_blocked") {
|
||||
t.Fatalf("拦截原因 = %q,want 含 sensitive_information_blocked", v.Reason)
|
||||
}
|
||||
if strings.Contains(v.Reason, tc.secret) {
|
||||
t.Fatalf("判决里回流了被命中的秘密:%q", v.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 请求侧同样拦(用户把密钥贴进来,不该先消耗一次模型调用)。
|
||||
if v := checkInput(t, p, "帮我看看 password: hunter2 对不对"); v.Allow {
|
||||
t.Fatalf("请求侧敏感内容未被拦下:%+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads 锁定长度上限的
|
||||
// 边界:恰好等于上限放行、超一个字符就被拦;并且「很长但正常」的上下文不被误伤。
|
||||
//
|
||||
// 这里用的是 noWindowBudget(小窗口部署):派生的输入上限落回下限
|
||||
// guardrailMinInputChars,也就是接线前那个 262144 的行为。
|
||||
func TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
p := guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget())
|
||||
if limit := guardrailApp(t, nil).guardrailInputLimit(noWindowBudget()); limit != guardrailMinInputChars {
|
||||
t.Fatalf("小窗口部署的输入上限 = %d, want 下限 %d", limit, guardrailMinInputChars)
|
||||
}
|
||||
// 输出上限是**另一个**数:它防的是模型输出失控,与上下文预算无关,
|
||||
// 因此本切片只动输入侧,输出侧不得顺手放宽(回归钉住取值本身)。
|
||||
if guardrailMaxOutputChars != 65536 {
|
||||
t.Fatalf("输出上限 = %d, want 65536(本切片只动输入侧)", guardrailMaxOutputChars)
|
||||
}
|
||||
|
||||
if v := checkOutput(t, p, strings.Repeat("a", guardrailMaxOutputChars)); !v.Allow {
|
||||
t.Fatalf("恰好到上限的输出被拦:%+v", v)
|
||||
}
|
||||
if v := checkOutput(t, p, strings.Repeat("a", guardrailMaxOutputChars+1)); v.Allow {
|
||||
t.Fatal("超出输出上限一个字符却放行")
|
||||
} else if !strings.Contains(v.Reason, "output_length_exceeded") {
|
||||
t.Fatalf("超长判决 = %q,want output_length_exceeded", v.Reason)
|
||||
}
|
||||
|
||||
// 输入侧检查跑在**组装完成的请求**上,因此上限按整个上下文定:
|
||||
// 10 万字符(约 5 万 token 的上下文)仍属正常,必须放行。
|
||||
req := model.Request{Messages: []model.Message{
|
||||
model.TextMessage(model.RoleSystem, strings.Repeat("设定。", 1000)),
|
||||
model.TextMessage(model.RoleUser, strings.Repeat("闲聊内容。", 10000)),
|
||||
}}
|
||||
if v, _ := p.CheckInput(context.Background(), &req); !v.Allow {
|
||||
t.Fatalf("10 万字符的正常上下文被输入上限拦下:%+v", v)
|
||||
}
|
||||
// 超过上限则被拦。
|
||||
over := model.Request{Messages: []model.Message{
|
||||
model.TextMessage(model.RoleUser, strings.Repeat("a", guardrailMinInputChars+1)),
|
||||
}}
|
||||
if v, _ := p.CheckInput(context.Background(), &over); v.Allow {
|
||||
t.Fatal("超出输入上限的载荷却放行")
|
||||
} else if !strings.Contains(v.Reason, "input_length_exceeded") {
|
||||
t.Fatalf("超长输入判决 = %q,want input_length_exceeded", v.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuardrailPipeline_KeywordsOnlyWhenConfigured 锁定策略③:项目不硬编词表。
|
||||
// 同一段文本,没配置时放行,配置了该词才被拦。
|
||||
func TestGuardrailPipeline_KeywordsOnlyWhenConfigured(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
text := "这段文本里出现了违禁词。"
|
||||
if v := checkInput(t, guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget()), text); !v.Allow {
|
||||
t.Fatalf("未配置词表却拦下了文本:%+v", v)
|
||||
}
|
||||
|
||||
configured := guardrailApp(t, []string{"违禁词"}).buildGuardrailPipeline(noWindowBudget())
|
||||
v := checkInput(t, configured, text)
|
||||
if v.Allow {
|
||||
t.Fatal("配置了词表却没拦下命中文本")
|
||||
}
|
||||
if !strings.Contains(v.Reason, "content_policy_blocked") {
|
||||
t.Fatalf("关键词判决 = %q,want content_policy_blocked", v.Reason)
|
||||
}
|
||||
// 词表只按声明的词命中,不牵连别的正常文本。
|
||||
if v := checkInput(t, configured, "这句很正常。"); !v.Allow {
|
||||
t.Fatalf("词表误伤了正常文本:%+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuardrailPipeline_EmptyPipelineIsTheBugThisFixes 是反证(变异):空管线
|
||||
// (P0-5 的原始形态 `guardrail.New()`)对同一段敏感文本一律放行 —— 证明上面
|
||||
// 的断言不是恒真的,敏感拦截确实来自组合根挂上的检查。
|
||||
func TestGuardrailPipeline_EmptyPipelineIsTheBugThisFixes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
empty := guardrail.New()
|
||||
if v := checkOutput(t, empty, "api_key = sk-abcdefghijklmnopqrstuvwxyz0123456789"); !v.Allow {
|
||||
t.Fatalf("空管线本应全部放行(这正是 P0-5 的空壳),却拦下了:%+v", v)
|
||||
}
|
||||
if v := checkOutput(t, empty, strings.Repeat("a", guardrailMaxOutputChars+1)); !v.Allow {
|
||||
t.Fatalf("空管线本应不做长度检查,却拦下了:%+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuardrailPipeline_RealPromptPasses 是一道防误伤的现实检查:生产里真正被
|
||||
// 检查的输入侧载荷是**组装完成的请求**(真实人格提示词 + 主人身份说明书 + 用户
|
||||
// 消息)。若这几段里恰好命中某条敏感模式,每一轮对话都会被永久拦死 ——
|
||||
// 因此这里拿仓库里真实的两份文件走一遍。
|
||||
func TestGuardrailPipeline_RealPromptPasses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a, p := identityApp(t, filepath.Join("..", "..", "configs", "users", "owner_identity.md"))
|
||||
// 身份说明书自 P0-10 收窄后不再进静态指令,而是按会话注入系统上下文
|
||||
// (persona_identity.go 的 personaKnowledgeFor)。护栏看到的真实载荷仍是
|
||||
// 「人格提示词 + 身份说明书 + 用户消息」,故这里把它拼回同一段文本。
|
||||
instructions := a.personaInstructions(p) +
|
||||
"\n\n" + identityKnowledgeBlock(a.ownerIdentityKnowledge())
|
||||
if !strings.Contains(instructions, "你叫他「亲爱的」") {
|
||||
t.Fatalf("测试前提不成立:真实身份说明书没被读进来:%q", instructions)
|
||||
}
|
||||
|
||||
pipeline := a.buildGuardrailPipeline(noWindowBudget())
|
||||
req := model.Request{Messages: []model.Message{
|
||||
model.TextMessage(model.RoleSystem, instructions),
|
||||
model.TextMessage(model.RoleUser, "亲爱的,今天想我了没?"),
|
||||
}}
|
||||
v, err := pipeline.CheckInput(context.Background(), &req)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckInput: %v", err)
|
||||
}
|
||||
if !v.Allow {
|
||||
t.Fatalf("真实人格提示词 + 身份说明书被护栏拦下(会造成每轮都被拦死):%+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuardrailWiring_EndToEnd 是端到端真断言:同一个循环里,
|
||||
// - 正常消息被模型正常回答(适配器被调用一次);
|
||||
// - 敏感消息在模型调用**之前**就被拦下(适配器一次都没被调用),
|
||||
// 且该回合的 StopReason 是 guardrail_input。
|
||||
func TestGuardrailWiring_EndToEnd(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
guards := guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget())
|
||||
adapter := &guardProbeAdapter{reply: "我在呢♪"}
|
||||
ag, err := agent.New("你是昔涟。", nil, guards, agent.WithModel(adapter, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("agent.New: %v", err)
|
||||
}
|
||||
l := loop.New(loop.Options{
|
||||
Agent: ag,
|
||||
Guards: guards,
|
||||
OnStream: func(model.StreamEvent) {},
|
||||
})
|
||||
|
||||
normal, err := l.Run(context.Background(), "web", []model.Message{
|
||||
model.TextMessage(model.RoleUser, "晚上好呀"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run(正常消息): %v", err)
|
||||
}
|
||||
if normal.Text != "我在呢♪" {
|
||||
t.Fatalf("正常消息的回答 = %q,want 模型回复(护栏不该改动它)", normal.Text)
|
||||
}
|
||||
if adapter.calls != 1 {
|
||||
t.Fatalf("正常消息的模型调用次数 = %d,want 1", adapter.calls)
|
||||
}
|
||||
|
||||
blocked, err := l.Run(context.Background(), "web", []model.Message{
|
||||
model.TextMessage(model.RoleUser, "我的 api_key = sk-abcdefghijklmnopqrstuvwxyz0123456789"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run(敏感消息): %v", err)
|
||||
}
|
||||
if adapter.calls != 1 {
|
||||
t.Fatalf("敏感消息触发了模型调用(calls=%d)—— 输入护栏没有前置拦下", adapter.calls)
|
||||
}
|
||||
if !strings.Contains(blocked.Text, "sensitive_information_blocked") {
|
||||
t.Fatalf("敏感消息的回答 = %q,want 含拦截原因", blocked.Text)
|
||||
}
|
||||
if blocked.StopReason != agent.StopReasonGuardrailInput {
|
||||
t.Fatalf("敏感消息的 StopReason = %q,want %q", blocked.StopReason, agent.StopReasonGuardrailInput)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package main
|
||||
|
||||
// 本文件装配 io 工具集(plugins/tools/io)的选项:知识检索器、日志、
|
||||
// **web-search 的后端与超时**(切片 WS1-A 接线 + WS1-B provider 层 + PLG-4 可变源),
|
||||
// 以及 design/28 §8 B5 的**浏览器降级档挂载**(`bodyFallbackOption()`,实现与口径在
|
||||
// `wire_body_fallback.go` —— 挂载条件 = 能力面里确实有可用浏览器,详见那个文件)。
|
||||
//
|
||||
// 为什么单独成文件而不是塞在 wire_runtime.go 里:那条装配线本就接近文件行数上限,
|
||||
// 而这里新增的裁决(端点/provider 关闭 ⇒ 工具不进注册面;选定 provider 未登记/未配置
|
||||
// ⇒ 启动报错;面板配置优先、缺失由 `.env` 播种)需要一个能单测的落点。
|
||||
//
|
||||
// 六条口径:
|
||||
//
|
||||
// 1. **后端由配置选定**:主 provider 选谁(`WEB_SEARCH_PROVIDER` / 面板文件),降级链
|
||||
// 怎么走,各家的 key 与基地址从哪来(design/27 §3)。
|
||||
// 2. **配置有可变源**(design/09 §5.7 E):先读 `$DATA_DIR/config/plugins/web-search.json`
|
||||
// —— 面板可改、0600 + temp+rename;文件**缺失/为空**时由 `.env` 播种(`.env` 从此只是
|
||||
// 「最小化功能可跑」的引导面,本片**不新增任何 env 键**);文件**非法**时记 WARN 回落
|
||||
// `.env`,且**不覆盖**原文件(损坏的配置可以人工救回)。
|
||||
// 3. **拿不到可用后端就不注册工具**(主人 2026-10-05 报障的核心诉求):端点显式关闭
|
||||
// (空串 / off / none)或不是 http(s) 绝对地址时,web-search **不进注册面** ——
|
||||
// 模型根本看不见它,而不是看得见、一调就报错。
|
||||
// 4. **各家的名字表来自插件,不来自 `.env`**:文件存在时它是**唯一权威**(文件里没有的家
|
||||
// 就是没有),逐家的名字 / 是否需要 key / 默认基地址全取 `plugins/tools/io/search` 的
|
||||
// 接入位表 ⇒ **加一家 provider 不必加 env 键**(`.env` 冻结红线)。实现在
|
||||
// `websearch_providers.go`。
|
||||
// 5. **未配置的家不进可用清单**:`search.Configured` 判定(云家看 key、searxng 看端点),
|
||||
// false ⇒ 该家不出现在注册表里(fail-closed)。选定 provider 未配置时由 io 工具集在
|
||||
// **启动期报错**,绝不静默回落到兜底。
|
||||
// 6. **超时不另造默认值**:`WEB_SEARCH_TIMEOUT` / 面板的 `timeout` 未声明时沿用 io 包既有
|
||||
// 超时;负值是笔误,按未配置处理并记一条带键名的 WARN(不静默)。
|
||||
//
|
||||
// 同一套解析也服务**热重载**(切片 W3,裁决 2026-10-06 #1):面板保存后由
|
||||
// `buildWebSearchTool` 按**当前落盘配置**重建这一把工具并替换注册表条目,因此
|
||||
// 「启动期的接线」与「保存后的接线」恒为同一条路径(不是两份语义)。两份调用的差别
|
||||
// 只有日志里的 cause 字段(startup / panel-save),便于排障时区分。
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||
)
|
||||
|
||||
// web-search 的 env 键名:与 `.env.example` 逐字一致(AGENTS §10 的 E6 条目),
|
||||
// 由 wire_web_search_test.go 逐字核对。它们仍然是**引导面**的键(.env 冻结后不再新增)。
|
||||
const (
|
||||
webSearchProviderEnv = "WEB_SEARCH_PROVIDER"
|
||||
webSearchEndpointEnv = "WEB_SEARCH_ENDPOINT"
|
||||
webSearchTimeoutEnv = "WEB_SEARCH_TIMEOUT"
|
||||
webSearchMaxResultsEnv = "WEB_SEARCH_MAX_RESULTS"
|
||||
webSearchFallbackEnv = "WEB_SEARCH_FALLBACK"
|
||||
)
|
||||
|
||||
// ioLogSource 是本文件日志的来源标签(项目规范 §10:禁止无来源裸日志)。
|
||||
const ioLogSource = "cmd/cyrene/wire_io_tools"
|
||||
|
||||
// 生效配置的来源标签(写进启动日志:一眼看出「这次的 provider 是 .env 给的还是面板给的」)。
|
||||
const (
|
||||
// webSearchSourceEnv 表示本次的配置来自 `.env`(含「文件缺失、刚由 .env 播种」这一步)。
|
||||
webSearchSourceEnv = "env"
|
||||
// webSearchSourcePanel 表示本次的配置来自面板可变源(面板是唯一来源)。
|
||||
webSearchSourcePanel = "panel"
|
||||
)
|
||||
|
||||
// 解析一次 web-search 配置的**触发场景**(只进日志的 cause 字段)。
|
||||
const (
|
||||
// webSearchCauseStartup 是启动期装配(`ioToolOptions`)。
|
||||
webSearchCauseStartup = "startup"
|
||||
// webSearchCausePanelSave 是面板保存后的热重载(`buildWebSearchTool`,见 websearch_reload.go)。
|
||||
webSearchCausePanelSave = "panel-save"
|
||||
)
|
||||
|
||||
// webSearchToolName 是 web-search 工具在能力注册表里的名字。
|
||||
//
|
||||
// 它与 io 包 `func (s *webSearchTool) Name()` 里的字面量同源,而那个名字没有导出常量
|
||||
// (`plugins/tools/io` 不在本片文件面内,不去改它):因此这里用字面量,并由
|
||||
// websearch_reload_test.go 钉住 —— 名字一旦漂移,热重载会立刻找不到工具(测试红)。
|
||||
const webSearchToolName = "web-search"
|
||||
|
||||
// ioToolOptions 组装 io 工具集的全部选项。wire_runtime 把它原样交给 iotools.New,
|
||||
// 因此「注册面里有哪些 io 工具」只由这一处决定。
|
||||
//
|
||||
// 它**只做 io 选项**(切片 X3 的收口):browser 的接线挂点原先是借这里转交的
|
||||
// (V5 当时改不了 wire_runtime.go),现已归位到 `wireRuntime` 里与 `wireVision` /
|
||||
// `wireTranscode` 并列的那一行(见 wire_runtime.go)。转交留下的第二个代价是副作用:
|
||||
// 凡是只为了要一份 iotools.Option 而调用本函数的路径(例如 web-search 的接线测试)
|
||||
// 都会顺带探测并注册浏览器工具 —— 那不该是「取选项」这件事的含义。
|
||||
func (a *app) ioToolOptions() []iotools.Option {
|
||||
opts := []iotools.Option{
|
||||
iotools.WithKnowledgeRetriever(a.retriever),
|
||||
iotools.WithLogger(a.log),
|
||||
// B5 的挂载点:有可用浏览器就挂上浏览器渲染取正文,没有就不挂(口径与实现
|
||||
// 都在 wire_body_fallback.go)。挂载条件必须在**浏览器装配之后**求值 ——
|
||||
// 这就是 wireRuntime 里 `a.wireBrowser()` 排在 io 工具集之前的原因。
|
||||
a.bodyFallbackOption(),
|
||||
}
|
||||
return append(opts, a.webSearchOptions(webSearchCauseStartup)...)
|
||||
}
|
||||
|
||||
// buildWebSearchTool 按**当前生效配置**构造这一把 web-search 工具(面板保存后的热重载用它
|
||||
// 重建工具,见 websearch_reload.go)。
|
||||
//
|
||||
// 三种返回值,调用方必须分开处置:
|
||||
// - (tool, nil) ⇒ 正常,工具该在注册面里;
|
||||
// - (nil, nil) ⇒ 当前配置下它**不该在注册面里**(端点/主 provider 显式关闭,或选定
|
||||
// provider 没配齐被 WithoutSearch 摘掉)—— 调用方据此把旧条目摘掉;
|
||||
// - (nil, err) ⇒ 这份配置会让后端**下次启动直接失败**(主 provider 未登记/未配置,
|
||||
// design/27 §3 的 fail-fast)—— 调用方必须如实报错,绝不留下一个起不来的文件。
|
||||
//
|
||||
// 为什么要构造整份 io 工具集再挑出 web-search:io 包的公开构造入口只有
|
||||
// `iotools.New(cfg, opts...)`(整份集合),没有「只造 web-search」的入口,而
|
||||
// `plugins/tools/io` 不在本片文件面内。代价是一次 New(几个工具对象 + 一个 http.Client
|
||||
// 壳;Transport 是共享的 http.DefaultTransport,不新开连接池)—— 面板保存是低频动作,
|
||||
// 付得起,换来的是**重载后的工具与启动期构造的逐字段同源**(不是第二份接线逻辑)。
|
||||
func (a *app) buildWebSearchTool(cause string) (sdk.Tool, error) {
|
||||
set, err := iotools.New(a.cfg, a.webSearchOptions(cause)...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, t := range set {
|
||||
if t.Name() == webSearchToolName {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// webSearchOptions 把生效配置翻译成 io 选项。cause 说明**为什么**解析这一次(启动装配 /
|
||||
// 面板保存后的热重载),只进日志:同一个函数被两处调用,日志里必须能分清是哪一次。
|
||||
// 端点/后端「关闭 / 非法 / 可用」三种去处都在这里定死(ResolveProvider/ResolveEndpoint)。
|
||||
func (a *app) webSearchOptions(cause string) []iotools.Option {
|
||||
log := logger.WithSource(a.log, ioLogSource)
|
||||
eff, source := a.effectiveWebSearch(log)
|
||||
ws := eff.Scalar
|
||||
|
||||
// ① 后端显式关闭(主 provider = 空 / off / none)⇒ 整件工具不注册。
|
||||
if decision := ws.ResolveProvider(); decision.Disabled {
|
||||
// 配置值**绝不写进日志**(只记键名与原因)。
|
||||
log.Warn("web-search tool not registered",
|
||||
"selectorKey", webSearchProviderEnv, "reason", config.WebSearchReasonOff, "configSource", source,
|
||||
"cause", cause,
|
||||
"effect", "the model will not see a web-search tool at all")
|
||||
return []iotools.Option{iotools.WithoutSearch()}
|
||||
}
|
||||
|
||||
specs := searchProviderSpecs(ws, eff.Providers)
|
||||
|
||||
// ② searxng 的可用性 = 端点裁决(WS1-A 的既有语义):端点显式关闭 / 不是 http(s)
|
||||
// 绝对地址时**整件工具不注册** —— 那两种取值是「关掉搜索」的显式选择,不是配置
|
||||
// 错误,因此不 fatal,只留一条带键名的 WARN。
|
||||
if endpoint := ws.ResolveEndpoint(); !endpoint.Enabled && selectsSearXNG(ws) {
|
||||
log.Warn("web-search tool not registered",
|
||||
"endpointKey", webSearchEndpointEnv, "reason", endpoint.Reason, "configSource", source,
|
||||
"cause", cause,
|
||||
"effect", "the model will not see a web-search tool at all")
|
||||
return []iotools.Option{iotools.WithoutSearch()}
|
||||
}
|
||||
|
||||
// primary 是**生效值**(可能来自面板文件、也可能来自 `.env` 播种)⇒ 它旁边的键名只是
|
||||
// 「这个键在哪份配置里」,而 source 才是「本次的值从哪来」。两者都打出来,不混为一谈。
|
||||
log.Info("web-search providers configured", "cause", cause,
|
||||
"selectorKey", webSearchProviderEnv, "primary", strings.ToLower(strings.TrimSpace(ws.Provider)),
|
||||
"fallbackKey", webSearchFallbackEnv, "fallback", ws.Fallback, "configSource", source,
|
||||
"available", availableProviderNames(specs))
|
||||
|
||||
opts := []iotools.Option{
|
||||
iotools.WithSearchProviders(specs...),
|
||||
// 选定主 provider + 降级链:未登记(如本期未做的 tavily)或未配置(缺 key)
|
||||
// ⇒ io 工具集在启动期报错(不许静默回落)。
|
||||
iotools.WithSearchSelection(ws.Provider, ws.Fallback),
|
||||
iotools.WithSearchMaxResults(ws.MaxResults),
|
||||
}
|
||||
switch {
|
||||
case ws.Timeout > 0:
|
||||
opts = append(opts, iotools.WithSearchTimeout(ws.Timeout))
|
||||
case ws.Timeout < 0:
|
||||
log.Warn("web-search timeout is negative, treated as unset",
|
||||
"key", webSearchTimeoutEnv, "configSource", source, "cause", cause,
|
||||
"effect", "io package default timeout applies")
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// effectiveWebSearch 解析 web-search 的**生效配置**,并报告它是从哪儿来的:
|
||||
//
|
||||
// - 面板文件存在且合法 ⇒ 它是唯一来源(design/09 §5.7 E 的「先读可变源」);
|
||||
// - 文件缺失/为空 ⇒ 用 `.env` 播种落盘,本次行为与 `.env` 完全一致(「缺失回落 `.env`
|
||||
// 播种」,照 models.json materialize 范式);
|
||||
// - 文件存在但非法 ⇒ 记 WARN、本次回落 `.env`,**不覆盖**原文件。
|
||||
//
|
||||
// 返回值里的第二个值是来源标签,只用于日志(`.env` 还是面板),不参与任何裁决。
|
||||
func (a *app) effectiveWebSearch(log *slog.Logger) (effectiveWebSearchConfig, string) {
|
||||
env := a.cfg.WebSearch
|
||||
path := runtimeconfig.WebSearchConfigPath(resolveDataDir(a.cfg))
|
||||
f, found, err := runtimeconfig.LoadWebSearch(path)
|
||||
switch {
|
||||
case err != nil:
|
||||
log.Warn("web-search 面板配置非法,本次按 .env 运行(不覆盖原文件)",
|
||||
"path", path, "error", err)
|
||||
return envEffectiveWebSearch(env), webSearchSourceEnv
|
||||
case !found:
|
||||
return envEffectiveWebSearch(a.seedWebSearchConfig(log, path, env)), webSearchSourceEnv
|
||||
}
|
||||
warnUnknownSearchProviders(log, path, f)
|
||||
eff, convErr := effectiveSearchConfig(env, f)
|
||||
if convErr != nil {
|
||||
log.Warn("web-search 面板配置无法生效,本次按 .env 运行",
|
||||
"path", path, "error", convErr)
|
||||
return envEffectiveWebSearch(env), webSearchSourceEnv
|
||||
}
|
||||
log.Info("web-search 面板配置生效", "path", path, "providers", len(f.Providers))
|
||||
return eff, webSearchSourcePanel
|
||||
}
|
||||
|
||||
// envEffectiveWebSearch 是「**没有可用文件**」时的生效配置:逐字沿用 `.env` 播种层的语义
|
||||
// (= `seedWebSearchFile` 会落盘的那份形状)。
|
||||
//
|
||||
// 它是 `.env` 唯一还能决定各家接线的时刻:文件一旦存在,权威就在文件那边(design/09 §5.7 E
|
||||
// 的「先读可变源、缺失回落 `.env` 播种」)。
|
||||
func envEffectiveWebSearch(env config.WebSearchConfig) effectiveWebSearchConfig {
|
||||
return effectiveWebSearchConfig{Scalar: env, Providers: searchProviderVector(env)}
|
||||
}
|
||||
|
||||
// seedWebSearchConfig 把 `.env` 播种成面板文件(`.env` 只作引导面)。
|
||||
//
|
||||
// 播种失败**不阻断启动**:本次按 `.env` 运行,只记一条 WARN(与 models.json 的处置一致)。
|
||||
func (a *app) seedWebSearchConfig(log *slog.Logger, path string, env config.WebSearchConfig) config.WebSearchConfig {
|
||||
seed := seedWebSearchFile(env)
|
||||
if err := runtimeconfig.SaveWebSearch(path, seed); err != nil {
|
||||
log.Warn("web-search 面板配置播种失败(本次按 .env 运行)", "path", path, "error", err)
|
||||
return env
|
||||
}
|
||||
log.Info("web-search 面板配置已按 .env 播种(此后面板是唯一来源)",
|
||||
"path", path, "providers", len(seed.Providers))
|
||||
return env
|
||||
}
|
||||
|
||||
// seedWebSearchFile 把 `.env` 的 web-search 段投影成可变源的初始形状。
|
||||
//
|
||||
// **本期内置的家全列**(含还没配 key 的):面板的用途就是「一次把各家配齐」,
|
||||
// 让用户先看到完整清单、再逐条填,比只列已配置的家少一轮「名字得自己知道」。
|
||||
//
|
||||
// 「全列」的名单来自 `.env` 的字段表(`envSearchProviders`)—— 那是**播种层**的输入,
|
||||
// 因此这里的条数与被播种的键集逐字保持现状(`.env` 冻结:不加、不改、不删)。
|
||||
func seedWebSearchFile(ws config.WebSearchConfig) runtimeconfig.WebSearchFile {
|
||||
enabled := runtimeconfig.EnableFlag(true)
|
||||
providers := envSearchProviders(ws)
|
||||
entries := make([]runtimeconfig.ProviderEntry, 0, len(providers))
|
||||
for _, p := range providers {
|
||||
entries = append(entries, runtimeconfig.ProviderEntry{
|
||||
Provider: p.Name, APIKey: p.APIKey, BaseURL: p.BaseURL, Enabled: enabled,
|
||||
})
|
||||
}
|
||||
return runtimeconfig.WebSearchFile{
|
||||
Version: runtimeconfig.WebSearchVersion,
|
||||
Provider: ws.Provider,
|
||||
Fallback: ws.Fallback,
|
||||
MaxResults: ws.MaxResults,
|
||||
Timeout: seedTimeout(ws.Timeout),
|
||||
Providers: entries,
|
||||
}
|
||||
}
|
||||
|
||||
// effectiveSearchConfig 把面板文件翻译成工具要的生效配置。
|
||||
//
|
||||
// **权威边界**(写清楚,免得「谁覆盖谁」靠猜):
|
||||
//
|
||||
// - **标量以文件为准**:主 provider / 降级链来自文件(文件里写空 / off / none = 显式关闭,
|
||||
// 与 `.env` 同一套哨兵语义);maxResults / timeout 在「没写」时沿用 `.env`(那两个标量
|
||||
// 在「没写」与「写成空」之间无法区分,而它们的默认值本来就是引导面的一部分)。
|
||||
// - **各家以文件为准**:文件里没有的家就是没有(绝不从 `.env` 复活,否则「我在面板里
|
||||
// 删了它」会变成一句假话),凭据为空 = 这家没配。逐家的名字表**不再由 `.env` 的字段表
|
||||
// 决定**,而是来自接入位表 —— 见 effectiveSearchProviders / resolveSearchProviders。
|
||||
// - **searxng 的端点是开关**:文件里那条的 baseUrl 为空 = 关掉这家,**不回落** `.env`
|
||||
// (回落会让「清空地址」变成假话)。
|
||||
func effectiveSearchConfig(env config.WebSearchConfig, f runtimeconfig.WebSearchFile) (effectiveWebSearchConfig, error) {
|
||||
out := env
|
||||
out.Provider = f.Provider
|
||||
out.Fallback = append([]string(nil), f.Fallback...)
|
||||
if f.MaxResults > 0 {
|
||||
out.MaxResults = f.MaxResults
|
||||
}
|
||||
d, err := f.TimeoutDuration()
|
||||
if err != nil {
|
||||
return effectiveWebSearchConfig{}, err
|
||||
}
|
||||
if d > 0 {
|
||||
out.Timeout = d
|
||||
}
|
||||
// 逐家字段一律清零:它们承载的是 `.env` 的**播种**形状,而生效值现在由 Providers 承载
|
||||
// (见 effectiveWebSearchConfig 的注释)。清零也顺带保证了任何误用都不会把 `.env` 的
|
||||
// 旧凭据当成「生效凭据」。
|
||||
out.ZhipuAPIKey, out.ZhipuBaseURL = "", ""
|
||||
out.AliyunIQSAPIKey, out.AliyunIQSBaseURL = "", ""
|
||||
out.BochaAPIKey, out.BochaBaseURL = "", ""
|
||||
out.VolcengineAPIKey, out.VolcengineBaseURL = "", ""
|
||||
out.BaiduQianfanAPIKey, out.BaiduQianfanBaseURL = "", ""
|
||||
out.Endpoint = ""
|
||||
if e, ok := findProviderEntry(f, search.NameSearXNG); ok && e.IsEnabled() {
|
||||
out.Endpoint = strings.TrimSpace(e.BaseURL)
|
||||
}
|
||||
return effectiveWebSearchConfig{Scalar: out, Providers: effectiveSearchProviders(f)}, nil
|
||||
}
|
||||
|
||||
// warnUnknownSearchProviders 对文件里本期没登记的家记一条 WARN。
|
||||
//
|
||||
// 它们不可能被选中(选中的家由 io 工具集在启动期校验),但「配了却不生效」必须说出来 ——
|
||||
// 静默忽略正是「我明明配了 Tavily 却搜不出」那类报障的来源。
|
||||
func warnUnknownSearchProviders(log *slog.Logger, path string, f runtimeconfig.WebSearchFile) {
|
||||
for _, e := range f.Providers {
|
||||
name := strings.ToLower(strings.TrimSpace(e.Provider))
|
||||
if name == "" || search.Known(name) {
|
||||
continue
|
||||
}
|
||||
log.Warn("web-search 面板配置里有未登记的提供商,已忽略",
|
||||
"path", path, "provider", name, "known", search.KnownNames())
|
||||
}
|
||||
}
|
||||
|
||||
// selectsSearXNG 报告当前配置选的是不是 searxng(大小写与空白不敏感)。
|
||||
func selectsSearXNG(ws config.WebSearchConfig) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(ws.Provider), search.NameSearXNG)
|
||||
}
|
||||
|
||||
// 各家的配置投影(searchProviderWire / searchProviderVector / searchProviderSpecs /
|
||||
// availableProviderNames)在 `websearch_providers.go` —— 那一片是本片「去 env 化」的本体
|
||||
// (按**接入位表**逐家投影,而不是按 `.env` 的字段表)。
|
||||
|
||||
// seedTimeout 把超时播成 duration 字符串:未声明 / 笔误(≤0)落成空 = 未声明。
|
||||
func seedTimeout(d time.Duration) string {
|
||||
if d <= 0 {
|
||||
return ""
|
||||
}
|
||||
return d.String()
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// errT1ModelNotReady 在 T1 需要模型而模型栈尚未就绪时返回。
|
||||
//
|
||||
// 它是 **fail-safe** 信号,不是故障:审查据此回落 `create` + WARN,绝不阻塞、
|
||||
// 绝不丢记忆(design/23 §15 D3-5)。之所以可能存在这一刻:本服务在
|
||||
// `wireMemory` 阶段装配,模型栈在更晚的 `wireRuntime` 阶段才建好。
|
||||
var errT1ModelNotReady = errors.New("memory review: t1 judge: model adapter not ready")
|
||||
|
||||
// 本文件把 D3「入库前审查」(DevDocs/design/23-记忆整理与去重 §15.1 D3)接进
|
||||
// 组合根。三件事只在这里定:
|
||||
//
|
||||
// - **形态**:记忆管理会话(复用既有的 sess-memorytidy 装配,见
|
||||
// memory.TidySessionID)的有界后台队列,不是会话内派 subagent。它与整理
|
||||
// 心跳并列启动、在 close 里先于关库停止;
|
||||
// - **依赖面**:只给它记忆存储、embedder,以及 **T1 的一次性模型调用口**
|
||||
// (memoryT1Judge,§15.1 D3-2 的灰区判定)。服务侧拿不到任何会话/历史读数,
|
||||
// 因此「独立会话、零上下文、不碰主会话上下文」(§15 D3)是结构性成立的,
|
||||
// 而不是靠自觉;
|
||||
// - **审计**:接到网关的 AuditEvent(口径 memory.review.ingest)。出口在调用
|
||||
// 时才解析 a.gw —— 网关比本服务晚组装(wireFrontend),与 memoryTidy 同
|
||||
// 一手法。未就绪时只记日志,绝不假装写过审计。
|
||||
//
|
||||
// 关闭(MEMORY_REVIEW_ENABLED=false)时**不建、不接**:store 的写入路径因此
|
||||
// 逐字回到本次改动之前(纯增量、无破坏性变更)。
|
||||
//
|
||||
// 🔴 两个开关都不在 env 里(`.env` 已冻结,design/13 §2.1),它们读
|
||||
// `$DATA_DIR/config/memory-tidy.json`,与整理自动档同一个文件、**零新 env 键**:
|
||||
// - `{"ingestGate": false}` ⇒ 一键回到「只插不审」(同步入库闸);
|
||||
// - `{"t1Judge": false}` ⇒ 一键回到纯规则(T1 灰区判定,**零模型调用**);
|
||||
// - `{"t1PerWindow": 120, "t1WindowSec": 3600}` ⇒ T1 的**全局限速**额度
|
||||
// (design/23 §15.4);缺位 = 代码默认 120 次/60 分钟;
|
||||
// - `{"tempTtlSec": 604800}` ⇒ 第五态 `defer` 的**临时记忆存活秒数**
|
||||
// (design/03 §9.6);缺位/坏值 = 代码默认 7 天。**它没有 `.env` 档**
|
||||
// (`.env` 已冻结,本键在那里从来没有出处)⇒ 实际是「面板 > 代码默认」。
|
||||
func (a *app) wireMemoryReview() error {
|
||||
cfg, err := config.LoadMemoryReviewConfig(envPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("memory review config: %w", err)
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
logger.WithSource(a.log, "memory/review").Info(
|
||||
"memory ingest review disabled by configuration", "env", "MEMORY_REVIEW_ENABLED")
|
||||
return nil
|
||||
}
|
||||
gateOff := a.memoryIngestGateOff()
|
||||
// T1 灰区模型判定(design/23 §15.1 D3-2):代码默认**开**,`{"t1Judge": false}`
|
||||
// 一键回到纯规则(零模型调用)。与入库闸同一个文件、同一套「没配 = 保持默认」
|
||||
// 口径,**零新 env 键**。
|
||||
t1Off := a.memoryT1JudgeOff()
|
||||
// T1 的全局限速额度(design/23 §15.4):同样读那个文件,缺位/坏文件一律
|
||||
// 交零值给 normalizeReviewConfig 回落**代码默认值**(120 次/60 分钟)——
|
||||
// 坏配置绝不能让一处限速静默变成「无上限」。
|
||||
t1PerWindow, t1WindowSec := a.memoryT1RateLimit()
|
||||
// 临时记忆 TTL(design/03 §9.6):面板 `tempTtlSec` 优先,缺位/坏值回落代码
|
||||
// 默认 **7 天**(经 normalizeReviewConfig 落地)。**中间那一档(`.env`)在本键
|
||||
// 上结构性为空** —— `.env` 已冻结(design/13 §2.1),这个键在那里从来没有出处。
|
||||
tempTTL := a.memoryTempTTL()
|
||||
reviewer, err := memory.NewPreInsertReviewer(memory.ReviewConfig{
|
||||
Enabled: cfg.Enabled,
|
||||
QueueSize: cfg.QueueSize,
|
||||
ScanLimit: cfg.ScanLimit,
|
||||
Timeout: time.Duration(cfg.TimeoutSec) * time.Second,
|
||||
// 召回门复用记忆整理的既有阈值(design/23 §13 开放点 2 = 0.80):
|
||||
// 入库前审查与夜间整理必须用同一把尺子,否则同一对条目会有两个结论。
|
||||
Threshold: a.cfg.MemoryTidy.RecallThreshold,
|
||||
// 同步入库闸:`$DATA_DIR/config/memory-tidy.json` 的 `ingestGate: false`
|
||||
// 一键回到「只插不审」(= 本次改动之前的异步行为)。预算与扫描上限走
|
||||
// 代码默认值(闸的总墙钟预算必须有界,但它是**实现参数**不是运维旋钮)。
|
||||
DisableIngestGate: gateOff,
|
||||
// T1 的时长走代码默认值(8s,且被单条审查的 Timeout 收紧取小):
|
||||
// 它是**实现参数**不是运维旋钮 —— 要让 T1 完全不花钱,就关掉它。
|
||||
DisableT1: t1Off,
|
||||
// T1 的**全局限速**额度(design/23 §15.4):数值来自
|
||||
// `$DATA_DIR/config/memory-tidy.json` 的 `t1PerWindow` / `t1WindowSec`,
|
||||
// 缺位或坏配置时是零值 ⇒ 由 normalizeReviewConfig 回落代码默认
|
||||
// (120 次/60 分钟)。**没有「无上限」这个状态**:要零花费请关 t1Judge。
|
||||
T1PerWindow: t1PerWindow,
|
||||
T1Window: time.Duration(t1WindowSec) * time.Second,
|
||||
// 第五态 `defer` 的落点(design/23 §18.1):`expires_at` = 判定时刻 + 它。
|
||||
// 零值 = 没配 ⇒ 由 normalizeReviewConfig 回落代码默认 7 天(tempTtlSec)。
|
||||
TempTTL: tempTTL,
|
||||
}, memory.ReviewDeps{
|
||||
Store: a.memStore,
|
||||
Embedder: a.embedder,
|
||||
Audit: a.auditMemoryReview,
|
||||
Log: a.log,
|
||||
Judge: a.memoryT1Judge(),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("memory ingest review: %w", err)
|
||||
}
|
||||
a.memStore.AttachIngestReviewer(reviewer)
|
||||
a.memoryReview = reviewer
|
||||
return nil
|
||||
}
|
||||
|
||||
// memoryIngestGateOff 报告同步入库闸是否被配置显式关掉。读不到/解不开
|
||||
// `memory-tidy.json` 时返回 false(= 保持代码默认的「开」)并记 WARN ——
|
||||
// 坏配置绝不能让一处防护静默失效,也绝不能让主人以为它关了却其实开着。
|
||||
func (a *app) memoryIngestGateOff() bool {
|
||||
tuning, path, err := a.readMemoryTidyTuning()
|
||||
if err != nil {
|
||||
logger.WithSource(a.log, "memory/review").Warn(
|
||||
"memory ingest gate tuning unreadable, falling back to code default (enabled)",
|
||||
"path", path, "error", err)
|
||||
return false
|
||||
}
|
||||
return tuning.IngestGateOff()
|
||||
}
|
||||
|
||||
// memoryT1JudgeOff 报告 **T1 灰区模型判定**是否被配置显式关掉。口径与
|
||||
// memoryIngestGateOff 逐字相同:读不到/解不开 `memory-tidy.json` 时返回 false
|
||||
// (= 保持代码默认的「开」)并记 WARN —— 坏配置绝不能让一处防护静默失效,
|
||||
// 也绝不能让主人以为它关了却其实开着。
|
||||
func (a *app) memoryT1JudgeOff() bool {
|
||||
tuning, path, err := a.readMemoryTidyTuning()
|
||||
if err != nil {
|
||||
logger.WithSource(a.log, "memory/review").Warn(
|
||||
"memory t1 judge tuning unreadable, falling back to code default (enabled)",
|
||||
"path", path, "error", err)
|
||||
return false
|
||||
}
|
||||
return tuning.T1JudgeOff()
|
||||
}
|
||||
|
||||
// memoryT1RateLimit 读 **T1 全局限速**的两项旋钮:窗口额度(次)与窗口长度(秒)。
|
||||
//
|
||||
// 口径与 memoryIngestGateOff / memoryT1JudgeOff 逐字相同:读不到/解不开
|
||||
// `memory-tidy.json` 时返回零值(= 不覆盖)并记 WARN —— 零值经
|
||||
// normalizeReviewConfig 回落**代码默认值**(120 次/60 分钟),因此坏配置既不会
|
||||
// 让限速静默变成「无上限」,也不会让 T1 静默失效。
|
||||
//
|
||||
// 只有**两项都写了且为正**才报一条 Info(自解释的配置回执);只写了一项时静默
|
||||
// 回落缺的那一项,不报「已配置」以免报出一个与生效值不符的印象。
|
||||
func (a *app) memoryT1RateLimit() (perWindow int, windowSec int) {
|
||||
tuning, path, err := a.readMemoryTidyTuning()
|
||||
if err != nil {
|
||||
logger.WithSource(a.log, "memory/review").Warn(
|
||||
"memory t1 rate limit tuning unreadable, falling back to code defaults",
|
||||
"path", path, "error", err)
|
||||
return 0, 0
|
||||
}
|
||||
if tuning.T1PerWindow > 0 && tuning.T1WindowSec > 0 {
|
||||
logger.WithSource(a.log, "memory/review").Info(
|
||||
"memory t1 rate limit loaded",
|
||||
"path", path, "t1_per_window", tuning.T1PerWindow, "t1_window_sec", tuning.T1WindowSec)
|
||||
}
|
||||
return tuning.T1PerWindow, tuning.T1WindowSec
|
||||
}
|
||||
|
||||
// memoryTempTTL 返回**临时记忆 TTL**(design/03 §9.6 的 `tempTtlSec`):面板
|
||||
// `$DATA_DIR/config/memory-tidy.json` 的值优先,缺位 / 坏值一律回落**代码默认
|
||||
// 7 天**(返回零值即「不覆盖」,由 NewPreInsertReviewer 的 normalizeReviewConfig
|
||||
// 落地 —— 与 memoryT1RateLimit 返回零值的口径逐字相同)。
|
||||
//
|
||||
// 解析与坏值 WARN 都交给 `memory.ApplyReviewTuning`(与 tidy_tuning.go 对
|
||||
// TidyConfig 是同一手法):runtimeconfig 只管**文件形状**,而「缺位 / 坏值怎么
|
||||
// 回落」属于 ReviewConfig 的归一化语义 —— 那里是**唯一**实现点,组合根不再抄一遍
|
||||
// (抄一遍就意味着两条路径各自决定「什么是坏值」)。
|
||||
//
|
||||
// 读不到 / 解不开那个文件时返回零值 + WARN:坏配置绝不能让 TTL 静默变成
|
||||
// 「不过期」,也不能让每条临时记忆**落库即过期**(一个 ≤0 的 TTL 正是那个形态)。
|
||||
//
|
||||
// 🔴 中间那一档(`.env`)在本键上**结构性为空**:`.env` 已冻结
|
||||
// (design/13 §2.1「不加、不改、不删」),而这个键在那里**从来没有出处**。
|
||||
// 因此本键的实际生效链是「**面板 > 代码默认**」——不是漏配,设计原文
|
||||
// (design/03 §9.6)写的就是这一档。
|
||||
func (a *app) memoryTempTTL() time.Duration {
|
||||
tuning, path, err := a.readMemoryTidyTuning()
|
||||
log := logger.WithSource(a.log, "memory/review")
|
||||
if err != nil {
|
||||
log.Warn("memory temp ttl tuning unreadable, falling back to code default (7 days)",
|
||||
"path", path, "error", err)
|
||||
}
|
||||
return memory.ApplyReviewTuning(memory.ReviewConfig{}, tuning, log).TempTTL
|
||||
}
|
||||
|
||||
// memoryT1Judge 把「调用时才解析的模型适配器」包成 T1 的一次性调用口。
|
||||
//
|
||||
// 为什么是闭包而不是直接塞 `a.modelAdapter`:组合根的顺序是 **wireMemory(建
|
||||
// 记忆栈,含本服务)→ wireRuntime(建模型栈)**,装配这一刻 `a.modelAdapter`
|
||||
// 还是 nil。闭包把解析推迟到调用时,于是既保住了「记忆模块只拿到一个调用口、
|
||||
// 拿不到模型栈」这条依赖面(§15 D3:独立会话、零上下文),又不必改动既有装配
|
||||
// 顺序 —— 与 a.gw 的晚组装是同一手法(见本文件顶部)。
|
||||
//
|
||||
// 用的是**既有**的模型栈本身(含按角色路由与主→备→本地降级),不新造 LLM
|
||||
// 客户端;`PersonaID` 留空即「无角色绑定」,由生效组的默认链服务它。模型栈仍
|
||||
// 未就绪时返回 errT1ModelNotReady:那是 fail-safe(回落 create + WARN)。
|
||||
//
|
||||
// 读 `a.modelAdapter` 无锁是安全的:它在 wireRuntime 里被**写一次**,而任何
|
||||
// 读到它的路径(审查工作协程)都在 main 里 `memoryReview.Start` 之后才开始,
|
||||
// 写入严格先于读取。
|
||||
func (a *app) memoryT1Judge() memory.T1Judge {
|
||||
return func(ctx context.Context, req model.Request, opts model.Options) (model.Response, error) {
|
||||
adapter := a.modelAdapter
|
||||
if adapter == nil {
|
||||
return model.Response{}, errT1ModelNotReady
|
||||
}
|
||||
return adapter.Chat(ctx, req, opts)
|
||||
}
|
||||
}
|
||||
|
||||
// auditMemoryReview 把一条审查结论写进网关审计链。它**只带决定、依据与 id**,
|
||||
// 绝不带任何记忆正文(design §15 D3-8「记忆正文不进日志」)。
|
||||
func (a *app) auditMemoryReview(who, what, result string) {
|
||||
if a.gw != nil {
|
||||
a.gw.AuditEvent(who, what, result)
|
||||
return
|
||||
}
|
||||
logger.WithSource(a.log, "memory/review").Info(
|
||||
"memory ingest review audit dropped: gateway not ready",
|
||||
"who", who, "what", what, "result", result)
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// 本文件锁定 D3「入库前审查」(design/23 §15.1 D3)在**组合根**上的三件事:
|
||||
// 1. 跑在与整理同一个专属隔离会话(sess-memorytidy),不是主人的对话车道;
|
||||
// 2. 接线存在(服务被装配、被接到 store 的写入路径、被启动、被停止),
|
||||
// 少一处就会**静默不工作**(不报错、不审查、日志里一句都没有);
|
||||
// 3. 「独立会话、不碰主会话上下文」是**可验证**的:审查跑完后主会话的消息条数、
|
||||
// 最后一条消息与已读游标逐字不变,且 chat_messages 里没有审查会话的行。
|
||||
|
||||
// reviewEmbedder 是组合根测试用的确定性 embedder:所有文本同向(余弦 1),
|
||||
// 因此「同内容的两条记忆」一定被召回,审查的判定路径真的会跑到。
|
||||
type reviewEmbedder struct{}
|
||||
|
||||
func (reviewEmbedder) Embed(_ context.Context, texts []string) ([]embedding.Vector, error) {
|
||||
out := make([]embedding.Vector, 0, len(texts))
|
||||
for range texts {
|
||||
out = append(out, embedding.Vector{1, 0})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (reviewEmbedder) Dim() int { return 2 }
|
||||
|
||||
// TestMemoryReviewIsWired 是接线存在性防回归:组合根少任何一处,入库前审查
|
||||
// 都会静默不工作,而这不会让别的用例变红。
|
||||
//
|
||||
// 刻意**不**在这里断言 `.env.example`:该文件由并行切片(MT-A)持有,
|
||||
// 新增的 MEMORY_REVIEW_* 键已登记在 MTB-report.md 里,由主会话统一补模板。
|
||||
func TestMemoryReviewIsWired(t *testing.T) {
|
||||
t.Parallel()
|
||||
seams := map[string][]string{
|
||||
"wire_runtime.go": {
|
||||
// 记忆栈建好后就装配审查(它只需要 store + embedder)。
|
||||
"a.wireMemoryReview()",
|
||||
},
|
||||
"wire_memory_review.go": {
|
||||
"config.LoadMemoryReviewConfig(envPath)",
|
||||
"memory.NewPreInsertReviewer(memory.ReviewConfig{",
|
||||
// 依赖面里**没有**任何会话/历史读数 —— 这是「独立会话、零上下文」的
|
||||
// 结构性保证,而不是靠自觉。
|
||||
"Audit: a.auditMemoryReview",
|
||||
// 接到 store 的唯一写入路径上。
|
||||
"a.memStore.AttachIngestReviewer(reviewer)",
|
||||
// 同步入库闸(2026-10-07)的开关来自 `$DATA_DIR/config/memory-tidy.json`
|
||||
// 的 `{"ingestGate": false}`(**零新 env 键**,`.env` 已冻结)。
|
||||
"a.memoryIngestGateOff()",
|
||||
"DisableIngestGate: gateOff",
|
||||
// T1 灰区模型判定(design/23 §15.1 D3-2):开关同样来自那个文件
|
||||
// (`{"t1Judge": false}`),模型出口**在调用时才解析**(本服务早于
|
||||
// 模型栈装配,与 a.gw 的晚组装同一手法)。
|
||||
"a.memoryT1JudgeOff()",
|
||||
"DisableT1: t1Off",
|
||||
"Judge: a.memoryT1Judge()",
|
||||
// T1 的**全局限速**额度(design/23 §15.4)同样来自那个文件,
|
||||
// 缺位/坏文件一律回落代码默认(120 次/60 分钟)。
|
||||
"a.memoryT1RateLimit()",
|
||||
"T1PerWindow: t1PerWindow",
|
||||
"T1Window: time.Duration(t1WindowSec) * time.Second",
|
||||
},
|
||||
"main.go": {
|
||||
"a.memoryReview.Start(ctx)",
|
||||
},
|
||||
"wire_services.go": {
|
||||
// 关库之前先停审查(它要写 memory_items)。
|
||||
"a.memoryReview.Stop(ctx)",
|
||||
},
|
||||
}
|
||||
for file, wants := range seams {
|
||||
src, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", file, err)
|
||||
}
|
||||
for _, want := range wants {
|
||||
if !strings.Contains(string(src), want) {
|
||||
t.Fatalf("%s 里找不到 %q —— 入库前审查没有被接上组合根", file, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// store 侧的唯一写入路径必须投递审查(否则新记忆永远不进队列),
|
||||
// 且必须**先过同步入库闸**(2026-10-07:闸就摆在写路径上,重复当场落 merged)。
|
||||
storeSrc, err := os.ReadFile("../../internal/runtime/memory/store.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read memory/store.go: %v", err)
|
||||
}
|
||||
for _, want := range []string{"s.enqueueReview(item)", "s.planIngest(ctx, item)", "s.finishIngest(ctx, item, plan)"} {
|
||||
if !strings.Contains(string(storeSrc), want) {
|
||||
t.Fatalf("memory/store.go 的写入路径上找不到 %q(唯一写路径上的闸/投递断了)", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemoryIngestGate_ReadsDataDirConfig 钉住**同步入库闸**的开关确实来自
|
||||
// `$DATA_DIR/config/memory-tidy.json`(它没有 env 键:`.env` 已冻结)——
|
||||
// 若组合根没读那个文件,「一键回到只插不审」就是一句空话。
|
||||
//
|
||||
// 口径与自动档同一条:没配 = 开;显式 false = 关;坏文件 = 回落代码默认(开)
|
||||
// 并记 WARN,**绝不静默把一处防护关掉**。
|
||||
func TestMemoryIngestGate_ReadsDataDirConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
empty := &app{cfg: &config.Config{DataDir: t.TempDir()}, log: tidyTestLog()}
|
||||
if empty.memoryIngestGateOff() {
|
||||
t.Fatal("没配过时入库闸应当保持代码默认(开)")
|
||||
}
|
||||
|
||||
dataDir := t.TempDir()
|
||||
dir := filepath.Join(dataDir, "config")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "memory-tidy.json")
|
||||
if err := os.WriteFile(path, []byte(`{"ingestGate": false}`), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
off := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
|
||||
if !off.memoryIngestGateOff() {
|
||||
t.Fatal("ingestGate: false 没被读到(闸没有一键关掉的路径)")
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(`{"ingestGate": "nope"}`), 0o600); err != nil {
|
||||
t.Fatalf("write broken: %v", err)
|
||||
}
|
||||
broken := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
|
||||
if broken.memoryIngestGateOff() {
|
||||
t.Fatal("坏配置把入库闸静默关掉了:必须回落代码默认(开)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemoryT1Judge_ReadsDataDirConfig 钉住 **T1 灰区模型判定**的开关确实来自
|
||||
// `$DATA_DIR/config/memory-tidy.json` 的 `t1Judge`(它没有 env 键:`.env` 已冻结)
|
||||
// —— T1 是一处**会花掉模型额度**的动作,「一行配置关掉」必须真的成立。
|
||||
//
|
||||
// 口径与入库闸同一条:没配 = 开(主人 2026-10-07 要的就是「真的走记忆管理会话」);
|
||||
// 显式 false = 关(回落纯规则、零模型调用);坏文件 = 回落代码默认(开)并记
|
||||
// WARN,**绝不静默把一处防护关掉**。
|
||||
func TestMemoryT1Judge_ReadsDataDirConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
empty := &app{cfg: &config.Config{DataDir: t.TempDir()}, log: tidyTestLog()}
|
||||
if empty.memoryT1JudgeOff() {
|
||||
t.Fatal("没配过时 T1 应当保持代码默认(开)")
|
||||
}
|
||||
|
||||
dataDir := t.TempDir()
|
||||
dir := filepath.Join(dataDir, "config")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "memory-tidy.json")
|
||||
// 只写一个上限键:它不许把 T1 静默关掉(指针字段的存在理由)。
|
||||
if err := os.WriteFile(path, []byte(`{"autoApplyPerRound": 5}`), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
partial := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
|
||||
if partial.memoryT1JudgeOff() {
|
||||
t.Fatal("只写了别的键时 T1 被静默关掉了(必须保持代码默认的「开」)")
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(`{"t1Judge": false}`), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
off := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
|
||||
if !off.memoryT1JudgeOff() {
|
||||
t.Fatal("t1Judge: false 没被读到(T1 没有一键关掉的路径)")
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(`{"t1Judge": "nope"}`), 0o600); err != nil {
|
||||
t.Fatalf("write broken: %v", err)
|
||||
}
|
||||
broken := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
|
||||
if broken.memoryT1JudgeOff() {
|
||||
t.Fatal("坏配置把 T1 静默关掉了:必须回落代码默认(开)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemoryT1RateLimit_ReadsDataDirConfig 钉住 **T1 全局限速**的额度确实来自
|
||||
// `$DATA_DIR/config/memory-tidy.json` 的 `t1PerWindow` / `t1WindowSec`
|
||||
// (design/23 §15.4):T1 默认开,每次有灰区候选的审查就发一次调用 ⇒ 额度必须能
|
||||
// 按现网节奏调,而 `.env` 已冻结、不许为它开 env 键。
|
||||
//
|
||||
// 口径与入库闸/T1 开关同一条:没配 = 返回零值(交给 normalizeReviewConfig 回落
|
||||
// 代码默认 120 次/60 分钟);坏文件 = 同样回落默认额 **并记 WARN** ——
|
||||
// 坏配置绝不能让限速静默变成「无上限」。
|
||||
func TestMemoryT1RateLimit_ReadsDataDirConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
empty := &app{cfg: &config.Config{DataDir: t.TempDir()}, log: tidyTestLog()}
|
||||
if perWindow, windowSec := empty.memoryT1RateLimit(); perWindow != 0 || windowSec != 0 {
|
||||
t.Fatalf("没配过时应当返回零值(= 回落代码默认):%d/%d", perWindow, windowSec)
|
||||
}
|
||||
|
||||
dataDir := t.TempDir()
|
||||
dir := filepath.Join(dataDir, "config")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "memory-tidy.json")
|
||||
if err := os.WriteFile(path, []byte(`{"t1PerWindow": 240, "t1WindowSec": 1800}`), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
set := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
|
||||
if perWindow, windowSec := set.memoryT1RateLimit(); perWindow != 240 || windowSec != 1800 {
|
||||
t.Fatalf("额度没被读到:%d/%d, want 240/1800", perWindow, windowSec)
|
||||
}
|
||||
|
||||
// 坏配置:回落默认额(零值)+ 一条 WARN,绝不静默不限流。
|
||||
if err := os.WriteFile(path, []byte(`{"t1PerWindow": "many"}`), 0o600); err != nil {
|
||||
t.Fatalf("write broken: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
broken := &app{cfg: &config.Config{DataDir: dataDir},
|
||||
log: slog.New(slog.NewTextHandler(&buf, nil))}
|
||||
if perWindow, windowSec := broken.memoryT1RateLimit(); perWindow != 0 || windowSec != 0 {
|
||||
t.Fatalf("坏配置下应当回落零值:%d/%d", perWindow, windowSec)
|
||||
}
|
||||
if logged := buf.String(); !strings.Contains(logged, "t1 rate limit tuning unreadable") ||
|
||||
!strings.Contains(logged, "level=WARN") {
|
||||
t.Fatalf("坏配置没有留下 WARN:%q", logged)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemoryT1Judge_UsesTheWiredModelStack 钉住 T1 的模型出口**复用既有模型栈**
|
||||
// (不新造 LLM 客户端),且返回一个闭包而不是当场取值 —— 本服务在 wireMemory
|
||||
// 阶段装配,而 a.modelAdapter 要等更晚的 wireRuntime 才建好。
|
||||
func TestMemoryT1Judge_UsesTheWiredModelStack(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := &app{log: tidyTestLog()}
|
||||
judge := a.memoryT1Judge()
|
||||
if judge == nil {
|
||||
t.Fatal("memoryT1Judge 返回了 nil")
|
||||
}
|
||||
// 模型栈还没建好:必须 fail-safe(返回错误),绝不 panic、绝不假装成功。
|
||||
if _, err := judge(context.Background(), model.Request{}, model.Options{}); !errors.Is(err, errT1ModelNotReady) {
|
||||
t.Fatalf("模型栈未就绪时 err = %v, want %v", err, errT1ModelNotReady)
|
||||
}
|
||||
// 接上模型栈后,同一个闭包就能发请求(晚解析的证据)。
|
||||
stub := &reviewAdapterStub{reply: "{}"}
|
||||
a.modelAdapter = stub
|
||||
if _, err := judge(context.Background(), model.Request{Purpose: "memory_t1_judge"}, model.Options{}); err != nil {
|
||||
t.Fatalf("接上模型栈后调用失败:%v", err)
|
||||
}
|
||||
if stub.calls != 1 {
|
||||
t.Fatalf("模型栈被调用 %d 次, want 1", stub.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// reviewAdapterStub 是最小的 model.Adapter 桩(只为验「闭包解析到既有的那个
|
||||
// 模型栈」这件事,不参与判定正确性)。
|
||||
type reviewAdapterStub struct {
|
||||
calls int
|
||||
reply string
|
||||
}
|
||||
|
||||
func (s *reviewAdapterStub) Name() string { return "stub" }
|
||||
|
||||
func (s *reviewAdapterStub) Chat(context.Context, model.Request, model.Options) (model.Response, error) {
|
||||
s.calls++
|
||||
return model.Response{Content: s.reply}, nil
|
||||
}
|
||||
|
||||
func (s *reviewAdapterStub) ChatStream(
|
||||
ctx context.Context, req model.Request, onEvent func(model.StreamEvent),
|
||||
) (model.Response, error) {
|
||||
return s.Chat(ctx, req, model.Options{})
|
||||
}
|
||||
|
||||
// TestMemoryReviewSessionIsNotConversationLane 与整理同一条口径:审查会话不落在
|
||||
// 主人的对话车道("web" / "web:<uuid>")上,因此不改她的心情、不进主会话泳道。
|
||||
func TestMemoryReviewSessionIsNotConversationLane(t *testing.T) {
|
||||
t.Parallel()
|
||||
if isConversationLane(memory.TidySessionID) {
|
||||
t.Fatalf("审查会话 %q 被判成主人对话回合", memory.TidySessionID)
|
||||
}
|
||||
if !isConversationLane(history.MainSessionID) {
|
||||
t.Fatal("主人车道判定被改坏了")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemoryReviewLeavesChatHistoryUntouched 是 design/23 §15 D3-7 的可验证版本:
|
||||
// 审查真的干活(把重复行折回既有行)之后,主会话的消息条数、最后一条消息与
|
||||
// read_at 游标逐字不变,且 chat_messages 里没有任何属于审查会话的行。
|
||||
func TestMemoryReviewLeavesChatHistoryUntouched(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conn, err := db.Open(ctx, ":memory:", tidyTestLog())
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
if err := db.MigrateUp(ctx, conn, tidyTestLog()); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
hist, err := history.NewStore(ctx, conn, tidyTestLog())
|
||||
if err != nil {
|
||||
t.Fatalf("history store: %v", err)
|
||||
}
|
||||
if _, err := hist.AppendFull(ctx, history.Message{
|
||||
SessionID: history.MainSessionID, Role: history.RoleUser,
|
||||
Content: "今晚又要加班到很晚", CreatedAt: 1_700_000_000_000,
|
||||
}); err != nil {
|
||||
t.Fatalf("append user message: %v", err)
|
||||
}
|
||||
last, err := hist.AppendFull(ctx, history.Message{
|
||||
SessionID: history.MainSessionID, Role: history.RoleAssistant,
|
||||
Content: "别熬太晚,我陪你", CreatedAt: 1_700_000_001_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("append assistant message: %v", err)
|
||||
}
|
||||
if _, err := hist.MarkSessionRead(ctx, history.MainSessionID, time.UnixMilli(1_700_000_000_500)); err != nil {
|
||||
t.Fatalf("mark read: %v", err)
|
||||
}
|
||||
before := readConversationFingerprint(t, ctx, conn, history.MainSessionID)
|
||||
|
||||
store, err := memory.NewStore(ctx, conn, tidyTestLog())
|
||||
if err != nil {
|
||||
t.Fatalf("memory store: %v", err)
|
||||
}
|
||||
content := "主人常在深夜与助手聊天,有熬夜习惯"
|
||||
seed := func(at time.Time) memory.MemoryItem {
|
||||
got, err := store.Add(ctx, memory.MemoryItem{
|
||||
PersonaID: "persona.a", Scope: memory.ScopePersona, Kind: memory.KindRelationship,
|
||||
Content: content, Confidence: 0.9, CreatedAt: at,
|
||||
SourceConversation: memory.FormatSourceConversation(history.MainSessionID, "turn-1"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed memory: %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
seed(time.UnixMilli(1_700_000_000_000))
|
||||
|
||||
reviewer, err := memory.NewPreInsertReviewer(memory.ReviewConfig{
|
||||
Enabled: true, QueueSize: 8, ScanLimit: 20, Timeout: 5 * time.Second, Threshold: 0.8,
|
||||
}, memory.ReviewDeps{Store: store, Embedder: reviewEmbedder{}, Log: tidyTestLog()})
|
||||
if err != nil {
|
||||
t.Fatalf("NewPreInsertReviewer: %v", err)
|
||||
}
|
||||
store.AttachIngestReviewer(reviewer)
|
||||
reviewer.Start(ctx)
|
||||
defer func() {
|
||||
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = reviewer.Stop(stopCtx)
|
||||
}()
|
||||
|
||||
dup := seed(time.UnixMilli(1_700_000_060_000))
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var status string
|
||||
if err := conn.QueryRowContext(ctx,
|
||||
"SELECT status FROM memory_items WHERE id = ?", dup.ID).Scan(&status); err != nil {
|
||||
t.Fatalf("read dup status: %v", err)
|
||||
}
|
||||
if status == "merged" {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
|
||||
after := readConversationFingerprint(t, ctx, conn, history.MainSessionID)
|
||||
if before != after {
|
||||
t.Fatalf("审查改动了主会话上下文:\n before=%+v\n after=%+v", before, after)
|
||||
}
|
||||
if after.lastContent != last.Content || after.lastID != last.ID {
|
||||
t.Fatalf("主会话最后一条消息变了:%+v", after)
|
||||
}
|
||||
var reviewMsgs int
|
||||
if err := conn.QueryRowContext(ctx,
|
||||
"SELECT count(*) FROM chat_messages WHERE session_id = ?", memory.TidySessionID).Scan(&reviewMsgs); err != nil {
|
||||
t.Fatalf("count review messages: %v", err)
|
||||
}
|
||||
if reviewMsgs != 0 {
|
||||
t.Fatalf("审查向 chat_messages 写入了 %d 行(§15 D3-7 要求零写入)", reviewMsgs)
|
||||
}
|
||||
reviews, err := store.ListIngestReviews(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListIngestReviews: %v", err)
|
||||
}
|
||||
if len(reviews) != 1 || reviews[0].Decision != memory.IngestSkip {
|
||||
t.Fatalf("审查留痕 = %+v, want 恰为一条 skip", reviews)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
)
|
||||
|
||||
// 本文件把记忆整理(DevDocs/design/23-记忆整理与去重 §4)接进组合根。
|
||||
//
|
||||
// 四件事只在这里定:
|
||||
// - **形态**:定期后台任务(安静时段降频)的心跳(memory.TidyService),不是
|
||||
// 会话内派 subagent。它在 main.go 与 backthink 并列启动、在 close 里先于关库停止;
|
||||
// - **依赖面**:只给它记忆存储与 embedder —— 服务侧拿不到任何会话/历史读数,
|
||||
// 因此 design §4.1「绝不碰主会话上下文」是结构性成立的,而不是靠自觉;
|
||||
// - **审计**:接到网关的 AuditEvent(design §8 的 memory.tidy.*),出口在调用
|
||||
// 时才解析 a.gw —— 网关比本服务晚组装(wireFrontend),与 onToolAudit
|
||||
// 同一手法。未就绪时只记日志,绝不假装写过审计;
|
||||
// - **运行期调优**(2026-10-07 新增):从 `$DATA_DIR/config/memory-tidy.json`
|
||||
// 读「自动落地开关 + 每轮上限」,以及(同日追加)**整理频率**四项
|
||||
// (intervalSec / quietIntervalSec / quietStartHour / quietEndHour)。它是
|
||||
// `.env` 冻结(design/13 §2.1)之后新旋钮的唯一去处,**零新 env 键**;面板优先、
|
||||
// `.env` 兜底,键缺位或值不可用一律回落下一档并记 WARN(坏配置绝不能让自动档
|
||||
// 变成「无限制」,也不能让整理频率变成忙轮询);
|
||||
// - **启动播种**(2026-10-07 追加,主人「后端**自动创建**独立的面板配置文件**要做**」):
|
||||
// 那个文件不存在时由后端自己写一份「当前生效值」进去(seedMemoryTidyPanel),
|
||||
// 使「面板优先」从第一次启动起就成立,而不是靠 `.env` 一直兜着。
|
||||
//
|
||||
// 刻意**不做**的一件事(design §4.1 F):
|
||||
// - 不把 sess-memorytidy 加进 flowPub 的图源白名单(默认关:整理是系统任务,
|
||||
// 不是主人的会话)。
|
||||
func (a *app) wireMemoryTidy() error {
|
||||
cfg := memory.TidyConfigFromConfig(a.cfg.MemoryTidy)
|
||||
a.applyMemoryTidyTuning(&cfg)
|
||||
// 启动播种(主人 2026-10-07:「后端**自动创建**独立的面板配置文件**要做**」):
|
||||
// 文件不存在/空白 ⇒ 把**此刻的生效值**写成面板的一等来源,此后 `.env` 只兜底。
|
||||
// 放在 applyMemoryTidyTuning **之后**是刻意的:那一刻的 cfg 就是
|
||||
// 「面板 > `.env` > 代码默认」的结果(面板缺失时即 `.env` + 默认),播它 ⇒ 行为
|
||||
// 零变化。文件已存在(含损坏)⇒ 一个字节都不动(幂等,见 runtimeconfig.SeedMemoryTidy)。
|
||||
a.seedMemoryTidyPanel(cfg)
|
||||
svc, err := memory.NewTidyService(cfg, memory.TidyDeps{
|
||||
Store: a.memStore,
|
||||
Embedder: a.embedder,
|
||||
Audit: a.auditMemoryTidy,
|
||||
Log: logger.WithSource(a.log, "memory/tidy"),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("memory tidy: %w", err)
|
||||
}
|
||||
a.memoryTidy = svc
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedMemoryTidyPanel 在 `$DATA_DIR/config/memory-tidy.json` 缺失/空白时播种一份
|
||||
// 「当前生效值」快照(`memory.TidyTuningSnapshot`),使面板配置从第一次启动起就自足,
|
||||
// 而不是每次都靠 `.env` 兜。失败只记日志、**绝不阻断启动**:运行期仍按 `.env` +
|
||||
// 代码默认跑,行为与播种前逐字相同(与 materializeModels 同一口径)。
|
||||
//
|
||||
// 闸与 T1 的四键(ingestGate / t1Judge / t1PerWindow / t1WindowSec)在 `.env` 里
|
||||
// **没有出处**(`.env` 已冻结,它们只由这个面板文件与代码默认决定,见
|
||||
// wireMemoryReview 的三个 helper),而播种只发生在**面板文件不存在**时 —— 那正是
|
||||
// 「零值 + normalizeReviewConfig」这一条路径,因此这里给零值 ReviewConfig 就是
|
||||
// **当时**的生效值,不是猜的。审查开关本身(MEMORY_REVIEW_ENABLED)不在这十键里,
|
||||
// 因此不必为它去读一次 env。
|
||||
func (a *app) seedMemoryTidyPanel(tidy memory.TidyConfig) {
|
||||
path := runtimeconfig.DefaultMemoryTidyPath(resolveDataDir(a.cfg))
|
||||
log := logger.WithSource(a.log, "memory/tidy")
|
||||
// 播种函数自己会把「跳过 / 无法解析」记成带路径的日志(含 WARN),这里只报
|
||||
// 「该写却没写成」那一种 —— 不重复记同一条 WARN。
|
||||
if _, err := runtimeconfig.SeedMemoryTidy(path, memory.TidyTuningSnapshot(tidy, memory.ReviewConfig{}), log); err != nil {
|
||||
log.Warn("memory-tidy.json 播种失败(面板配置仍缺位,.env 继续兜底)", "path", path, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// applyMemoryTidyTuning 把 `$DATA_DIR/config/memory-tidy.json` 叠到 env 侧的
|
||||
// TidyConfig 上(见 runtimeconfig.MemoryTidyTuning 的零值口径):
|
||||
// - 文件里**没写** autoApply ⇒ 保持代码默认(开);
|
||||
// - 写 `false` ⇒ DisableAutoApply = true(一键回到「只产建议」,不必改代码);
|
||||
// - autoApplyPerRound ≤ 0 ⇒ 不覆盖,交给 normalizeTidyConfig 填默认上限;
|
||||
// - **调度四键**(intervalSec / quietIntervalSec / quietStartHour /
|
||||
// quietEndHour)⇒ 交 memory.ApplyTidyScheduleTuning 逐键解析(面板 >
|
||||
// `.env` > 代码默认),坏值回落 + WARN。
|
||||
//
|
||||
// 读不到/解不开时只记 WARN 并保持 env 侧的值:那正是「回落下一档」——坏文件时
|
||||
// tuning 是零值(四个调度键都是 nil),因此 ApplyTidyScheduleTuning 一个键都不覆盖,
|
||||
// env 值原样留着(`.env` 也缺位时由 normalizeTidyConfig 落到代码默认)。
|
||||
func (a *app) applyMemoryTidyTuning(cfg *memory.TidyConfig) {
|
||||
tuning, path, err := a.readMemoryTidyTuning()
|
||||
log := logger.WithSource(a.log, "memory/tidy")
|
||||
if err != nil {
|
||||
log.Warn(
|
||||
"memory tidy tuning unreadable, falling back to the env layer (.env values kept)",
|
||||
"path", path, "error", err)
|
||||
} else {
|
||||
cfg.DisableAutoApply = tuning.AutoApplyOff()
|
||||
if tuning.AutoApplyPerRound > 0 {
|
||||
cfg.AutoApplyPerRound = tuning.AutoApplyPerRound
|
||||
}
|
||||
}
|
||||
// 调度四键在两种情形下都要过一遍:成功时按面板值覆盖,失败时 tuning 是零值
|
||||
// ⇒ 原样返回(= 保持 env 值),因此不必为它再分一条路径。
|
||||
*cfg = memory.ApplyTidyScheduleTuning(*cfg, tuning, log)
|
||||
// 这里打的是**归一化之前**的值(quiet_interval_sec 可能还是 0 = 「缺省,等于
|
||||
// intervalSec」):真正生效的两档周期由 TidyService.Start 的启动日志报。
|
||||
// panel_decoded = 「文件缺失/空白/解析成功」三者之一(LoadMemoryTidyTuning 把
|
||||
// 前两者都当作「还没配过」而不是错误),为 false 即坏文件 ⇒ 四个键都回落 `.env`。
|
||||
log.Info(
|
||||
"memory tidy tuning applied (panel > .env > code defaults)",
|
||||
"path", path, "panel_decoded", err == nil, "auto_apply", !cfg.DisableAutoApply,
|
||||
"auto_apply_per_round", cfg.AutoApplyPerRound,
|
||||
"interval_sec", cfg.IntervalSec, "quiet_interval_sec", cfg.QuietIntervalSec,
|
||||
"quiet_hours", fmt.Sprintf("%02d:00-%02d:00", cfg.QuietStartHour, cfg.QuietEndHour))
|
||||
}
|
||||
|
||||
// readMemoryTidyTuning 读一次 `$DATA_DIR/config/memory-tidy.json`(**只读**)。
|
||||
// 它把「落点 + 读取 + 路径回报」收在一处,供整理自动档与**同步入库闸**两个
|
||||
// 消费者共用 —— 后者(wireMemoryReview)不属于整理服务,但它的开关就在同一个
|
||||
// 文件里(零新 env 键,design/13 §2.1)。错误由调用方记 WARN 并回落代码默认值。
|
||||
func (a *app) readMemoryTidyTuning() (runtimeconfig.MemoryTidyTuning, string, error) {
|
||||
path := runtimeconfig.DefaultMemoryTidyPath(resolveDataDir(a.cfg))
|
||||
tuning, err := runtimeconfig.LoadMemoryTidyTuning(path)
|
||||
if err != nil {
|
||||
return runtimeconfig.MemoryTidyTuning{}, path, err
|
||||
}
|
||||
return tuning, path, nil
|
||||
}
|
||||
|
||||
// auditMemoryTidy 把一轮整理的计数写进网关审计链。它**只带计数**,绝不带
|
||||
// 任何记忆正文(design §8 的 who/what/when/result 口径)。
|
||||
func (a *app) auditMemoryTidy(who, what, result string) {
|
||||
if a.gw != nil {
|
||||
a.gw.AuditEvent(who, what, result)
|
||||
return
|
||||
}
|
||||
logger.WithSource(a.log, "memory/tidy").Info(
|
||||
"memory tidy audit dropped: gateway not ready", "who", who, "what", what, "result", result)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
)
|
||||
|
||||
// 本文件锁定**启动播种在组合根上的接线**(wire_memory_tidy.go 的 seedMemoryTidyPanel;
|
||||
// 主人 2026-10-07:「`.env` 只做兜底用 —— 后端**自动创建**独立的面板配置文件**要做**」):
|
||||
//
|
||||
// ① 文件不存在 ⇒ 后端自己写一份「当时生效值」(`.env` 给了的用 `.env`,没给的用代码
|
||||
// 默认),面板配置自此自足;
|
||||
// ② 播种之后再走一遍解析,生效配置与没播种时**逐字相同**(播种那一刻行为零变化);
|
||||
// ③ 幂等:再跑一次不改字节(文件已在 ⇒ 走「绝不覆盖」那条路)。
|
||||
//
|
||||
// 用例全部落在临时 DataDir(`t.TempDir()`):生产那份 data/config/memory-tidy.json 是
|
||||
// 主人手工建的,用例不许依赖它存在与否,也不许碰它。
|
||||
|
||||
// panelPath 返回某个 DataDir 下面板配置的落点(与组合根同一套路径推导)。
|
||||
func panelPath(dataDir string) string {
|
||||
return runtimeconfig.DefaultMemoryTidyPath(dataDir)
|
||||
}
|
||||
|
||||
// readPanel 读回面板文件原文(幂等断言要比字节,不能只看解出来的键)。
|
||||
func readPanel(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("读回 %s: %v", path, err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// TestWireMemoryTidySeedsPanelOnBoot 是最接近主人场景的一条:直接调**组合根的真实
|
||||
// 装配函数** `wireMemoryTidy`(它被 wireMemory → newApp 调用,即进程启动那条路),
|
||||
// 断言这一次启动就**自己把面板配置文件建出来**了;再跑一次(= 第二次启动)字节不变。
|
||||
//
|
||||
// 为什么不用「跑一遍真二进制」来验收:那要再起一个 :8080 的同进程服务,与「后端常驻」
|
||||
// 那条红线冲突(不许为验收停/重启正在跑的后端)。因此这里走的是**同一个函数**、同一套
|
||||
// 路径推导,只把 DataDir 换成临时目录。
|
||||
func TestWireMemoryTidySeedsPanelOnBoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
conn, err := db.Open(ctx, ":memory:", tidyTestLog())
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
if err := db.MigrateUp(ctx, conn, tidyTestLog()); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
store, err := memory.NewStore(ctx, conn, tidyTestLog())
|
||||
if err != nil {
|
||||
t.Fatalf("memory store: %v", err)
|
||||
}
|
||||
|
||||
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 3600, QuietStartHour: 23, QuietEndHour: 7}
|
||||
dataDir := t.TempDir()
|
||||
a := &app{
|
||||
cfg: &config.Config{DataDir: dataDir, MemoryTidy: env},
|
||||
memStore: store,
|
||||
log: tidyTestLog(),
|
||||
}
|
||||
if err := a.wireMemoryTidy(); err != nil {
|
||||
t.Fatalf("wireMemoryTidy: %v", err)
|
||||
}
|
||||
|
||||
path := panelPath(dataDir)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("启动装配没有自动创建 %s:%v(这正是主人要求的那条「后端自动创建要做」)", path, err)
|
||||
}
|
||||
got, err := runtimeconfig.LoadMemoryTidyTuning(path)
|
||||
if err != nil {
|
||||
t.Fatalf("回读播种结果: %v", err)
|
||||
}
|
||||
if got.IntervalSec == nil || *got.IntervalSec != 3600 ||
|
||||
got.QuietIntervalSec == nil || *got.QuietIntervalSec != 3600 {
|
||||
t.Fatalf("两档周期 = %v/%v, want 3600/3600(取 `.env`)", got.IntervalSec, got.QuietIntervalSec)
|
||||
}
|
||||
if got.QuietStartHour == nil || *got.QuietStartHour != 23 ||
|
||||
got.QuietEndHour == nil || *got.QuietEndHour != 7 {
|
||||
t.Fatalf("安静时段 = %v-%v, want 23-7", got.QuietStartHour, got.QuietEndHour)
|
||||
}
|
||||
|
||||
// 第二次启动(同一个装配函数再跑一次):文件已在 ⇒ 一个字节都不动。
|
||||
before := readPanel(t, path)
|
||||
second := &app{
|
||||
cfg: &config.Config{DataDir: dataDir, MemoryTidy: env},
|
||||
memStore: store,
|
||||
log: tidyTestLog(),
|
||||
}
|
||||
if err := second.wireMemoryTidy(); err != nil {
|
||||
t.Fatalf("第二次 wireMemoryTidy: %v", err)
|
||||
}
|
||||
if after := readPanel(t, path); after != before {
|
||||
t.Fatalf("第二次启动改动了面板文件:\n before=%s\n after =%s", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedMemoryTidyPanel_WritesEffectiveValuesOnBoot 钉住「自动创建」这件事本身:
|
||||
// 组合根启动时必须自己把面板文件建出来,内容 = 当时生效值(`.env` 档 + 代码默认)。
|
||||
func TestSeedMemoryTidyPanel_WritesEffectiveValuesOnBoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// `.env` 层:与代码默认**故意不同**,这样「播的是生效值」与「播了一份硬编码默认值」
|
||||
// 一眼可分。
|
||||
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 10800, QuietStartHour: 22, QuietEndHour: 6}
|
||||
dataDir := t.TempDir()
|
||||
a := &app{cfg: &config.Config{DataDir: dataDir, MemoryTidy: env}, log: tidyTestLog()}
|
||||
path := panelPath(dataDir)
|
||||
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("前置:%s 不该存在", path)
|
||||
}
|
||||
a.seedMemoryTidyPanel(memory.TidyConfigFromConfig(env))
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("启动播种没有把面板文件建出来:%v", err)
|
||||
}
|
||||
|
||||
got, err := runtimeconfig.LoadMemoryTidyTuning(path)
|
||||
if err != nil {
|
||||
t.Fatalf("回读播种结果: %v", err)
|
||||
}
|
||||
// `.env` 档:三键如实落进快照(quietIntervalSec 没有 env 键 ⇒ 缺省 = intervalSec)。
|
||||
if got.IntervalSec == nil || *got.IntervalSec != 10800 {
|
||||
t.Fatalf("intervalSec = %v, want 10800(取 `.env`)", got.IntervalSec)
|
||||
}
|
||||
if got.QuietIntervalSec == nil || *got.QuietIntervalSec != 10800 {
|
||||
t.Fatalf("quietIntervalSec = %v, want 10800(缺省 = intervalSec)", got.QuietIntervalSec)
|
||||
}
|
||||
if got.QuietStartHour == nil || *got.QuietStartHour != 22 ||
|
||||
got.QuietEndHour == nil || *got.QuietEndHour != 6 {
|
||||
t.Fatalf("安静时段 = %v-%v, want 22-6(取 `.env`)", got.QuietStartHour, got.QuietEndHour)
|
||||
}
|
||||
// 代码默认档:老六键必须齐(否则「面板能管住自动档/闸/T1」仍是空话)。
|
||||
if got.AutoApply == nil || !*got.AutoApply || got.AutoApplyPerRound <= 0 {
|
||||
t.Fatalf("自动档两键 = %v/%d, want 显式 true 且正数上限", got.AutoApply, got.AutoApplyPerRound)
|
||||
}
|
||||
if got.IngestGate == nil || !*got.IngestGate || got.T1Judge == nil || !*got.T1Judge {
|
||||
t.Fatalf("闸/T1 开关 = %v/%v, want 显式 true", got.IngestGate, got.T1Judge)
|
||||
}
|
||||
if got.T1PerWindow <= 0 || got.T1WindowSec <= 0 {
|
||||
t.Fatalf("T1 限速 = %d/%d, want 正数", got.T1PerWindow, got.T1WindowSec)
|
||||
}
|
||||
|
||||
// 幂等 + 「绝不覆盖」:再跑一次,字节一个不动。
|
||||
before := readPanel(t, path)
|
||||
a.seedMemoryTidyPanel(memory.TidyConfigFromConfig(env))
|
||||
if after := readPanel(t, path); after != before {
|
||||
t.Fatalf("第二次播种改动了面板文件:\n before=%s\n after =%s", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedMemoryTidyPanel_KeepsRuntimeBehaviorIdentical 钉住「播种那一刻行为零变化」:
|
||||
// 播种前后各按组合根的次序解析一次,得到的生效配置必须逐字相同 —— 否则播种就成了
|
||||
// 一次没人察觉的行为变更,而不是「把现状写进文件」。
|
||||
func TestSeedMemoryTidyPanel_KeepsRuntimeBehaviorIdentical(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 7200, QuietStartHour: 1, QuietEndHour: 5}
|
||||
dataDir := t.TempDir()
|
||||
a := &app{cfg: &config.Config{DataDir: dataDir, MemoryTidy: env}, log: tidyTestLog()}
|
||||
|
||||
// 比对的是**归一化之后**的生效配置:TidyConfig 是归一化前的中间形态,面板写不写
|
||||
// quietIntervalSec / autoApplyPerRound 会让它在 0 与「等于生效值」之间变化,语义却
|
||||
// 相同 —— 因此必须走 normalizeTidyConfig 这一层(memory.NewTidier 的既有入口)才能
|
||||
// 说「行为逐字未变」。
|
||||
ctx := context.Background()
|
||||
conn, err := db.Open(ctx, ":memory:", tidyTestLog())
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
if err := db.MigrateUp(ctx, conn, tidyTestLog()); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
store, err := memory.NewStore(ctx, conn, tidyTestLog())
|
||||
if err != nil {
|
||||
t.Fatalf("memory store: %v", err)
|
||||
}
|
||||
effective := func(t *testing.T, cfg memory.TidyConfig) memory.TidyConfig {
|
||||
t.Helper()
|
||||
tidier, err := memory.NewTidier(cfg, memory.TidyDeps{Store: store, Log: tidyTestLog()})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTidier: %v", err)
|
||||
}
|
||||
return tidier.Config() // 已套默认值
|
||||
}
|
||||
|
||||
// ① 播种前:面板文件不存在 ⇒ 只有 `.env` + 代码默认这一档。
|
||||
before := memory.TidyConfigFromConfig(env)
|
||||
a.applyMemoryTidyTuning(&before)
|
||||
before = effective(t, before)
|
||||
// ② 启动播种。
|
||||
a.seedMemoryTidyPanel(memory.TidyConfigFromConfig(env))
|
||||
// ③ 播种后:面板文件已在,同样的 env 再解析一次。
|
||||
after := memory.TidyConfigFromConfig(env)
|
||||
a.applyMemoryTidyTuning(&after)
|
||||
after = effective(t, after)
|
||||
|
||||
if before != after {
|
||||
t.Fatalf("播种改变了生效配置:\n before=%+v\n after =%+v", before, after)
|
||||
}
|
||||
// 两档周期(intervalAt 的真判据)也要如实等价:安静时段窗口内用 quietInterval。
|
||||
if after.QuietInterval != after.Interval || after.Interval != time.Duration(env.IntervalSec)*time.Second {
|
||||
t.Fatalf("两档周期 = %s/%s, want 都等于 `.env` 的 %s",
|
||||
after.Interval, after.QuietInterval, time.Duration(env.IntervalSec)*time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedMemoryTidyPanel_PanelTakesOverAfterSeeding 是主人口径的终点:
|
||||
// 「后端自动创建独立的面板配置文件之后就走面板配置文件」—— 播种之后,改那个文件
|
||||
// 必须真的改变生效值(否则「自动创建」只是造了个没人读的文件)。
|
||||
func TestSeedMemoryTidyPanel_PanelTakesOverAfterSeeding(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
|
||||
dataDir := t.TempDir()
|
||||
a := &app{cfg: &config.Config{DataDir: dataDir, MemoryTidy: env}, log: tidyTestLog()}
|
||||
a.seedMemoryTidyPanel(memory.TidyConfigFromConfig(env))
|
||||
|
||||
path := panelPath(dataDir)
|
||||
if err := os.WriteFile(path, []byte(`{"intervalSec": 3600}`), 0o600); err != nil {
|
||||
t.Fatalf("改写面板文件: %v", err)
|
||||
}
|
||||
cfg := memory.TidyConfigFromConfig(env)
|
||||
a.applyMemoryTidyTuning(&cfg)
|
||||
if cfg.IntervalSec != 3600 {
|
||||
t.Fatalf("intervalSec = %d, want 3600(面板优先,`.env` 只兜底)", cfg.IntervalSec)
|
||||
}
|
||||
// 面板没写的键仍由 `.env` 兜底(面板只管它写了的那几个键)。
|
||||
if cfg.QuietStartHour != 23 || cfg.QuietEndHour != 7 {
|
||||
t.Fatalf("安静时段 = %02d:00-%02d:00, want `.env` 的 23:00-07:00", cfg.QuietStartHour, cfg.QuietEndHour)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedMemoryTidyPanel_LeavesExistingFileAlone 钉住组合根这一侧的「绝不覆盖」:
|
||||
// 盘上已有一份主人手写的配置(哪怕缺键、哪怕带未知字段)⇒ 启动播种一个字节都不动。
|
||||
func TestSeedMemoryTidyPanel_LeavesExistingFileAlone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
|
||||
dataDir := t.TempDir()
|
||||
dir := filepath.Join(dataDir, "config")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
body := "{\n \"autoApply\": false,\n \"手工写的未知键\": 1\n}\n"
|
||||
path := panelPath(dataDir)
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("写手写的配置: %v", err)
|
||||
}
|
||||
a := &app{cfg: &config.Config{DataDir: dataDir, MemoryTidy: env}, log: tidyTestLog()}
|
||||
a.seedMemoryTidyPanel(memory.TidyConfigFromConfig(env))
|
||||
if got := readPanel(t, path); got != body {
|
||||
t.Fatalf("已存在的面板文件被改了:\n want=%s\n got =%s", body, got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
)
|
||||
|
||||
// 本文件锁定记忆整理(design/23 §4.1)在**组合根**上的三件事:
|
||||
// 1. 跑在专属隔离会话,绝不是主人的对话车道(isConversationLane 对它恒 false);
|
||||
// 2. 接线存在(服务被装配、被启动、被停止,env 键同步),少一处就会静默不工作;
|
||||
// 3. 「绝不碰主会话上下文」是**可验证**的:跑完一轮整理后,主会话的消息条数、
|
||||
// 最后一条消息、已读游标逐字不变,且对 chat_messages 零写入。
|
||||
|
||||
// tidyTestLog 返回不写任何地方的 logger,保持测试输出干净。
|
||||
func tidyTestLog() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// TestMemoryTidySessionIsNotConversationLane 是 design/23 §4.1 B 的对外断言:
|
||||
// 整理会话不落在主人的对话车道("web" / "web:<uuid>")上。
|
||||
func TestMemoryTidySessionIsNotConversationLane(t *testing.T) {
|
||||
t.Parallel()
|
||||
if isConversationLane(memory.TidySessionID) {
|
||||
t.Fatalf("整理会话 %q 被判成主人对话回合(会改写她的心情、进主会话泳道)", memory.TidySessionID)
|
||||
}
|
||||
// 对照组:主人车道本身必须仍然为 true(避免把断言写成「函数恒 false」)。
|
||||
if !isConversationLane(history.MainSessionID) || !isConversationLane(history.MainSessionID+":sub-1") {
|
||||
t.Fatal("主人车道判定被改坏了")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemoryTidyIsWired 是接线存在性防回归:组合根少任何一处,整理都会**静默
|
||||
// 不工作**(不报错、不产候选、日志里一句都没有),而这不会让别的用例变红。
|
||||
func TestMemoryTidyIsWired(t *testing.T) {
|
||||
t.Parallel()
|
||||
seams := map[string][]string{
|
||||
"wire_runtime.go": {
|
||||
// 记忆栈建好后就装配整理(它只需要 store + embedder)。
|
||||
"a.wireMemoryTidy()",
|
||||
"a.embedder = embedder",
|
||||
},
|
||||
"wire_memory_tidy.go": {
|
||||
"memory.NewTidyService(cfg, memory.TidyDeps{",
|
||||
// 调优(自动落地开关 + 每轮上限)从 $DATA_DIR/config/memory-tidy.json
|
||||
// 来,**零新 env 键**(`.env` 已冻结,design/13 §2.1)。少了这一句,
|
||||
// 那个文件就成了「写了没人读」的摆设,而自动档仍然开着 —— 静默。
|
||||
"runtimeconfig.LoadMemoryTidyTuning",
|
||||
// 启动播种(2026-10-07 追加,主人「后端**自动创建**独立的面板配置文件
|
||||
// **要做**」):少了这两句,那个文件在生产里**根本不存在** ⇒「面板优先」
|
||||
// 永远是空话,生效的仍是 `.env` 兜底那一档 —— 而不会有任何用例变红。
|
||||
"a.seedMemoryTidyPanel(cfg)",
|
||||
"runtimeconfig.SeedMemoryTidy(",
|
||||
// 依赖面里**没有**任何会话/历史读数 —— 这是「绝不碰主会话上下文」
|
||||
// 的结构性保证,而不是靠自觉。
|
||||
"Audit: a.auditMemoryTidy",
|
||||
},
|
||||
"main.go": {
|
||||
"a.memoryTidy.Start(ctx)",
|
||||
},
|
||||
"wire_services.go": {
|
||||
// 关库之前先停整理(它每轮要用连接池)。
|
||||
"a.memoryTidy.Stop(ctx)",
|
||||
},
|
||||
}
|
||||
for file, wants := range seams {
|
||||
src, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", file, err)
|
||||
}
|
||||
for _, want := range wants {
|
||||
if !strings.Contains(string(src), want) {
|
||||
t.Fatalf("%s 里找不到 %q —— 记忆整理没有被接上组合根", file, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
|
||||
if err != nil {
|
||||
t.Fatalf("read .env.example: %v", err)
|
||||
}
|
||||
for _, key := range []string{
|
||||
"MEMORY_TIDY_ENABLED=", "MEMORY_TIDY_INTERVAL_SEC=", "MEMORY_TIDY_QUIET_START_HOUR=",
|
||||
"MEMORY_TIDY_QUIET_END_HOUR=", "MEMORY_TIDY_RECALL_THRESHOLD=",
|
||||
"MEMORY_TIDY_SCAN_LIMIT=", "MEMORY_TIDY_CANDIDATE_LIMIT=",
|
||||
} {
|
||||
if !strings.Contains(string(raw), key) {
|
||||
t.Fatalf(".env.example 里找不到 %q(新 env 键必须同步模板,AGENTS §10 协同 E6)", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemoryTidyRunLeavesChatHistoryUntouched 是 design/23 §4.1 E 的可验证版本:
|
||||
// 整理跑完一轮后,主会话的消息条数、最后一条消息、read_at 游标**逐字不变**,
|
||||
// 且 chat_messages 里没有任何属于整理会话的行。
|
||||
//
|
||||
// 用例让整理真的干活(有可合并的一对),否则断言会是空的。
|
||||
func TestMemoryTidyRunLeavesChatHistoryUntouched(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conn, err := db.Open(ctx, ":memory:", tidyTestLog())
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
if err := db.MigrateUp(ctx, conn, tidyTestLog()); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
hist, err := history.NewStore(ctx, conn, tidyTestLog())
|
||||
if err != nil {
|
||||
t.Fatalf("history store: %v", err)
|
||||
}
|
||||
if _, err := hist.AppendFull(ctx, history.Message{
|
||||
SessionID: history.MainSessionID, Role: history.RoleUser, Content: "今晚又要加班到很晚", CreatedAt: 1_700_000_000_000,
|
||||
}); err != nil {
|
||||
t.Fatalf("append user message: %v", err)
|
||||
}
|
||||
last, err := hist.AppendFull(ctx, history.Message{
|
||||
SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "别熬太晚,我陪你", CreatedAt: 1_700_000_001_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("append assistant message: %v", err)
|
||||
}
|
||||
if _, err := hist.MarkSessionRead(ctx, history.MainSessionID, time.UnixMilli(1_700_000_000_500)); err != nil {
|
||||
t.Fatalf("mark read: %v", err)
|
||||
}
|
||||
|
||||
before := readConversationFingerprint(t, ctx, conn, history.MainSessionID)
|
||||
|
||||
store, err := memory.NewStore(ctx, conn, tidyTestLog())
|
||||
if err != nil {
|
||||
t.Fatalf("memory store: %v", err)
|
||||
}
|
||||
seed := []string{"主人常在深夜与助手聊天,有熬夜习惯", "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚"}
|
||||
for i, content := range seed {
|
||||
if _, err := store.Add(ctx, memory.MemoryItem{
|
||||
PersonaID: "persona.a", Scope: memory.ScopePersona, Kind: memory.KindRelationship,
|
||||
Content: content, Confidence: 0.9,
|
||||
CreatedAt: time.UnixMilli(1_700_000_000_000 + int64(i)*60_000),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed memory %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
svc, err := memory.NewTidyService(memory.TidyConfigFromConfig(config.MemoryTidyConfig{
|
||||
Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7,
|
||||
RecallThreshold: 0.80, ScanLimit: 100, CandidateLimit: 20,
|
||||
}), memory.TidyDeps{Store: store, Log: tidyTestLog()})
|
||||
if err != nil {
|
||||
t.Fatalf("tidy service: %v", err)
|
||||
}
|
||||
stats, err := svc.RunOnce(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("tidy RunOnce: %v", err)
|
||||
}
|
||||
if stats.Scanned != 2 || stats.Candidates == 0 {
|
||||
t.Fatalf("整理没真干活,断言会落空:%+v", stats)
|
||||
}
|
||||
|
||||
after := readConversationFingerprint(t, ctx, conn, history.MainSessionID)
|
||||
if before != after {
|
||||
t.Fatalf("整理改动了主会话上下文:\n before=%+v\n after=%+v", before, after)
|
||||
}
|
||||
if after.lastContent != last.Content || after.lastID != last.ID {
|
||||
t.Fatalf("主会话最后一条消息变了:%+v", after)
|
||||
}
|
||||
|
||||
var tidyMsgs int
|
||||
if err := conn.QueryRowContext(ctx,
|
||||
"SELECT count(*) FROM chat_messages WHERE session_id = ?", memory.TidySessionID).Scan(&tidyMsgs); err != nil {
|
||||
t.Fatalf("count tidy messages: %v", err)
|
||||
}
|
||||
if tidyMsgs != 0 {
|
||||
t.Fatalf("整理向 chat_messages 写入了 %d 行(design §4.1 D 要求零写入)", tidyMsgs)
|
||||
}
|
||||
}
|
||||
|
||||
// conversationFingerprint 是主会话的「逐字不变」指纹:消息条数、最后一条消息
|
||||
// 的 id 与正文、以及会话已读游标。
|
||||
type conversationFingerprint struct {
|
||||
messages int
|
||||
lastID string
|
||||
lastContent string
|
||||
readAt int64
|
||||
}
|
||||
|
||||
// readConversationFingerprint 直接读 chat_messages / chat_sessions(主会话只由
|
||||
// 消息写入路径触碰,因此这是「整理有没有碰会话」最直接的证据)。
|
||||
func readConversationFingerprint(t *testing.T, ctx context.Context, conn *sql.DB, sessionID string) conversationFingerprint {
|
||||
t.Helper()
|
||||
var fp conversationFingerprint
|
||||
if err := conn.QueryRowContext(ctx,
|
||||
"SELECT count(*) FROM chat_messages WHERE session_id = ?", sessionID).Scan(&fp.messages); err != nil {
|
||||
t.Fatalf("count messages: %v", err)
|
||||
}
|
||||
if err := conn.QueryRowContext(ctx,
|
||||
"SELECT id, content FROM chat_messages WHERE session_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1",
|
||||
sessionID).Scan(&fp.lastID, &fp.lastContent); err != nil {
|
||||
t.Fatalf("read last message: %v", err)
|
||||
}
|
||||
if err := conn.QueryRowContext(ctx,
|
||||
"SELECT read_at FROM chat_sessions WHERE id = ?", sessionID).Scan(&fp.readAt); err != nil {
|
||||
t.Fatalf("read cursor: %v", err)
|
||||
}
|
||||
return fp
|
||||
}
|
||||
|
||||
// TestApplyMemoryTidyTuning_ReadsDataDirConfig 钉住「自动档开关确实来自
|
||||
// $DATA_DIR/config/memory-tidy.json」这条接线:它没有 env 键(`.env` 已冻结),
|
||||
// 因此若组合根没读那个文件,「一键回到只产建议」就是一句空话 —— 文件写了没人看。
|
||||
func TestApplyMemoryTidyTuning_ReadsDataDirConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// ① 文件不存在 ⇒ 保持代码默认(自动档开着)。
|
||||
empty := &app{cfg: &config.Config{DataDir: t.TempDir()}, log: tidyTestLog()}
|
||||
cfg := memory.TidyConfigFromConfig(config.MemoryTidyConfig{Enabled: true})
|
||||
empty.applyMemoryTidyTuning(&cfg)
|
||||
if cfg.DisableAutoApply || cfg.AutoApplyPerRound != 0 {
|
||||
t.Fatalf("没配过时 cfg = %+v, want 保持零值(由 normalize 填代码默认)", cfg)
|
||||
}
|
||||
|
||||
// ② 写 {"autoApply": false, "autoApplyPerRound": 5} ⇒ 关掉 + 抬高上限。
|
||||
dataDir := t.TempDir()
|
||||
dir := filepath.Join(dataDir, "config")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "memory-tidy.json")
|
||||
if err := os.WriteFile(path, []byte(`{"autoApply": false, "autoApplyPerRound": 5}`), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
off := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
|
||||
cfg = memory.TidyConfigFromConfig(config.MemoryTidyConfig{Enabled: true})
|
||||
off.applyMemoryTidyTuning(&cfg)
|
||||
if !cfg.DisableAutoApply || cfg.AutoApplyPerRound != 5 {
|
||||
t.Fatalf("cfg = %+v, want DisableAutoApply=true 且 AutoApplyPerRound=5", cfg)
|
||||
}
|
||||
|
||||
// ③ 坏文件 ⇒ 记 WARN 并回落代码默认(绝不把自动档读成「无限制」或「关掉」)。
|
||||
if err := os.WriteFile(path, []byte(`{"autoApply": "nope"}`), 0o600); err != nil {
|
||||
t.Fatalf("write broken: %v", err)
|
||||
}
|
||||
broken := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
|
||||
cfg = memory.TidyConfigFromConfig(config.MemoryTidyConfig{Enabled: true})
|
||||
broken.applyMemoryTidyTuning(&cfg)
|
||||
if cfg.DisableAutoApply || cfg.AutoApplyPerRound != 0 {
|
||||
t.Fatalf("坏配置时 cfg = %+v, want 回落零值(默认值由 normalize 填)", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyMemoryTidyTuning_ScheduleKnobs 钉住**整理频率**四键在组合根上的接线:
|
||||
// 面板($DATA_DIR/config/memory-tidy.json)优先、`.env` 兜底 —— 主人 2026-10-07
|
||||
// 口径「.env 只做兜底用 …… 这些键优先从面板配置上取」。
|
||||
//
|
||||
// 这里只断言「叠完之后 cfg 里是什么」,不重复 memory 包的优先级链用例:组合根这一层
|
||||
// 要证明的是**那个文件真的被读了、真的落到了 cfg 上**(少这一句,面板改了频率也无效,
|
||||
// 而不会有任何用例变红)。
|
||||
func TestApplyMemoryTidyTuning_ScheduleKnobs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// env 层:与面板值故意对立。
|
||||
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 7200, QuietStartHour: 23, QuietEndHour: 7}
|
||||
|
||||
newApp := func(dataDir string) *app {
|
||||
return &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
|
||||
}
|
||||
apply := func(dataDir string) memory.TidyConfig {
|
||||
cfg := memory.TidyConfigFromConfig(env)
|
||||
newApp(dataDir).applyMemoryTidyTuning(&cfg)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// ① 面板四键都写了 ⇒ 四个都用面板值(覆盖 `.env`)。
|
||||
dataDir := t.TempDir()
|
||||
dir := filepath.Join(dataDir, "config")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "memory-tidy.json")
|
||||
body := `{"intervalSec": 3600, "quietIntervalSec": 10800, "quietStartHour": 1, "quietEndHour": 5}`
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
cfg := apply(dataDir)
|
||||
if cfg.IntervalSec != 3600 {
|
||||
t.Fatalf("intervalSec = %d, want 3600(面板优先)", cfg.IntervalSec)
|
||||
}
|
||||
if cfg.QuietIntervalSec != 10800 {
|
||||
t.Fatalf("quietIntervalSec = %d, want 10800(面板优先)", cfg.QuietIntervalSec)
|
||||
}
|
||||
if cfg.QuietStartHour != 1 || cfg.QuietEndHour != 5 {
|
||||
t.Fatalf("安静时段 = %02d:00-%02d:00, want 01:00-05:00(面板优先)",
|
||||
cfg.QuietStartHour, cfg.QuietEndHour)
|
||||
}
|
||||
|
||||
// ② 文件不存在 ⇒ 四键保持 `.env` 值(面板只是「能覆盖」,不是「必须有」)。
|
||||
empty := t.TempDir()
|
||||
cfg = apply(empty)
|
||||
if cfg.IntervalSec != 7200 || cfg.QuietStartHour != 23 || cfg.QuietEndHour != 7 {
|
||||
t.Fatalf("没配过时应保持 env 值,得到 %+v", cfg)
|
||||
}
|
||||
|
||||
// ③ 坏文件(类型写错)⇒ 整份回落 `.env`,且**不启动失败**:组合根照旧拿到
|
||||
// 可用的 cfg(一个坏键不得让整理停摆)。
|
||||
if err := os.WriteFile(path, []byte(`{"intervalSec": "3600"}`), 0o600); err != nil {
|
||||
t.Fatalf("write broken: %v", err)
|
||||
}
|
||||
cfg = apply(dataDir)
|
||||
if cfg.IntervalSec != 7200 || cfg.QuietStartHour != 23 || cfg.QuietEndHour != 7 {
|
||||
t.Fatalf("坏文件时应回落 env 值,得到 %+v", cfg)
|
||||
}
|
||||
if cfg.QuietIntervalSec != 0 {
|
||||
t.Fatalf("坏文件时 quietIntervalSec 应当是零值(由 normalize 取 = intervalSec),得到 %d",
|
||||
cfg.QuietIntervalSec)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||
)
|
||||
|
||||
// 本文件锁定组合根的面板接线(切片 PLG-2):wirePanelHub 必须把插件管理器的面板
|
||||
// 中枢接到**既有进程内 bus** 的 `ws.panels_changed` 上 —— 前端热更新靠这条帧,
|
||||
// 且不新开任何端口(单端口红线)。
|
||||
//
|
||||
// 为什么在组合根测:帧的主题名与载荷形状是跨片契约(前端片按它消费),
|
||||
// 只有对 wirePanelHub 取证才能证明「组合根真的发的是这一条」。
|
||||
|
||||
// TestWirePanelHubPublishesPanelsChanged 断言生命周期变化真的经 bus 推出
|
||||
// `ws.panels_changed`,且载荷带 reason / pluginId(不含任何面板内容)。
|
||||
func TestWirePanelHubPublishesPanelsChanged(t *testing.T) {
|
||||
b := bus.New()
|
||||
defer b.Close()
|
||||
mgr := pluginmgr.NewManager(registry.New(), filepath.Join(t.TempDir(), "plugins.json"), testLog())
|
||||
defer mgr.Close(context.Background())
|
||||
|
||||
wirePanelHub(&app{plugins: mgr, bus: b, log: testLog()})
|
||||
hub := mgr.Panels()
|
||||
if hub == nil {
|
||||
t.Fatal("wirePanelHub 未把面板中枢接到插件管理器上")
|
||||
}
|
||||
|
||||
events := make(chan bus.Event, 8)
|
||||
if _, err := b.Subscribe(context.Background(), "ws.panels_changed", func(_ context.Context, e bus.Event) error {
|
||||
events <- e
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("subscribe: %v", err)
|
||||
}
|
||||
|
||||
enabled := true
|
||||
panels := []sdk.Panel{{
|
||||
ID: "providers", Title: "搜索提供商", Order: 1,
|
||||
When: &sdk.When{Enabled: &enabled},
|
||||
Sections: []sdk.Section{{
|
||||
Type: sdk.SectionStatus, ID: "chain", Title: "降级链",
|
||||
Fields: []sdk.Field{{Key: "active", Type: sdk.FieldText}},
|
||||
}},
|
||||
}}
|
||||
if _, err := hub.Declare("web-search", panels); err != nil {
|
||||
t.Fatalf("Declare: %v", err)
|
||||
}
|
||||
hub.SetEnabled("web-search", false)
|
||||
|
||||
for _, want := range []string{"declare", "disable"} {
|
||||
select {
|
||||
case e := <-events:
|
||||
if e.Subject != "ws.panels_changed" {
|
||||
t.Fatalf("subject = %q", e.Subject)
|
||||
}
|
||||
payload, ok := e.Payload.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("载荷类型 = %T,期望 map[string]any", e.Payload)
|
||||
}
|
||||
if payload["reason"] != want {
|
||||
t.Fatalf("reason = %v,期望 %q", payload["reason"], want)
|
||||
}
|
||||
if payload["pluginId"] != "web-search" {
|
||||
t.Fatalf("pluginId = %v,期望 web-search", payload["pluginId"])
|
||||
}
|
||||
// 帧刻意不带内容:前端按自己的角色重拉快照,避免在这里再实现一遍角色过滤。
|
||||
if _, leaked := payload["panels"]; leaked {
|
||||
t.Fatalf("panels_changed 帧不得携带面板内容:%v", payload)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("未收到 %q 的 panels_changed 帧", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWirePanelHubDeclaresBuiltinSearchPanel 断言内置插件(web-search)的面板声明
|
||||
// 真的进了目录 —— 没有这一步,契约面在运行期就是一份空快照(design/09 §5.5 的 dogfood
|
||||
// 也就无从验起)。顺带锁定「声明通过校验」:search.AdminPanels() 若被改坏,这里会红。
|
||||
func TestWirePanelHubDeclaresBuiltinSearchPanel(t *testing.T) {
|
||||
b := bus.New()
|
||||
defer b.Close()
|
||||
mgr := pluginmgr.NewManager(registry.New(), filepath.Join(t.TempDir(), "plugins.json"), testLog())
|
||||
defer mgr.Close(context.Background())
|
||||
|
||||
wirePanelHub(&app{plugins: mgr, bus: b, log: testLog()})
|
||||
hub := mgr.Panels()
|
||||
|
||||
entries, err := hub.List(auth.RoleOwner)
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].PluginID != search.PanelPluginID ||
|
||||
entries[0].Panel.ID != search.PanelIDProviders {
|
||||
t.Fatalf("内置面板 = %+v,期望 web-search/providers 一条", entries)
|
||||
}
|
||||
for _, name := range search.ActionNames() {
|
||||
if _, ok := hub.Action(search.PanelPluginID, name); !ok {
|
||||
t.Fatalf("内置插件声明的动作 %q 未进白名单", name)
|
||||
}
|
||||
}
|
||||
// viewer 拿到的必须是裁掉 form 的只读投影。
|
||||
viewer, err := hub.List(auth.RoleViewer)
|
||||
if err != nil {
|
||||
t.Fatalf("List(viewer): %v", err)
|
||||
}
|
||||
if len(viewer) != 1 || len(viewer[0].Panel.Sections) != 1 {
|
||||
t.Fatalf("viewer 的只读投影 = %+v,期望只剩表格区块", viewer)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWirePanelHubNilPluginsIsNoop 断言未构造插件管理器时接线是空操作(不 panic)。
|
||||
func TestWirePanelHubNilPluginsIsNoop(t *testing.T) {
|
||||
wirePanelHub(&app{bus: bus.New(), log: testLog()})
|
||||
}
|
||||
|
||||
// TestWirePanelHubWithoutBusIsNoop 断言没有 bus 时也只告警、不 panic
|
||||
// (发布器内部判空,与 refresh store / key store 的降级口径一致)。
|
||||
func TestWirePanelHubWithoutBusIsNoop(t *testing.T) {
|
||||
mgr := pluginmgr.NewManager(registry.New(), filepath.Join(t.TempDir(), "plugins.json"), testLog())
|
||||
defer mgr.Close(context.Background())
|
||||
wirePanelHub(&app{plugins: mgr, log: testLog()})
|
||||
hub := mgr.Panels()
|
||||
if hub == nil {
|
||||
t.Fatal("没有 bus 时仍应接上面板中枢(只是不发帧)")
|
||||
}
|
||||
if _, err := hub.Declare("web-search", nil); err != nil {
|
||||
t.Fatalf("Declare: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package main
|
||||
|
||||
// 本文件是**内置插件动作面的登记入口**(design/09 §5.2.1「动作分发」;切片 L1/PLG-2 的
|
||||
// 执行面接线):把编译进二进制的插件声明的动作处理器登记到插件管理器上。首个使用者是
|
||||
// web-search 的「搜索提供商」页(§5.5 dogfood):四个动作
|
||||
// plugin.listProviders / plugin.saveProvider / plugin.testProvider / plugin.reauthProvider,
|
||||
// 外加下拉候选来源 `plugin:providers`(前端按 `optionsFrom` 推出这个名字)。
|
||||
//
|
||||
// 三个文件的分工(都在本包):
|
||||
// - 本文件:登记入口、处理器环境、动作名映射、「声明了却没实现」的装配期核对;
|
||||
// - `websearch_action_data.go`:动作本体的业务数据面(可变源的读写与翻译、载荷解析);
|
||||
// - `websearch_probe.go`:探针的传输层绑定(HTTP 客户端 + 同主机守卫 + 状态映射);
|
||||
// - `websearch_reload.go`:保存后的**热重载**事务(重建工具 + 换注册表条目 + 失败回滚);
|
||||
// - `websearch_selection.go`:表单里文件级字段(主 provider / 降级链 / 条数上限)的读写。
|
||||
//
|
||||
// 三条边界:
|
||||
//
|
||||
// 1. **数据面只读写可变源**:`$DATA_DIR/config/plugins/web-search.json`(design/13 §2.1),
|
||||
// 经 runtimeconfig 的读写入口(0600 + temp+rename),宿主自己不开新路径、不猜形状;
|
||||
// 2. **探针真发一次请求**:走 `search.Probe`(同一家 provider 构造、同一份归一化),
|
||||
// 不靠「配置看起来齐了」假装连通(§5.5 的「测试连通」);
|
||||
// 3. **凭据不出后端**:读侧只回掩码(`MaskedWebSearchFile`),日志只记 provider 名与
|
||||
// 结果码,绝不记 key / 地址。
|
||||
//
|
||||
// 热重载(切片 W3;裁决 2026-10-06 #1):保存动作被 `webSearchActionHandlersWithHotReload`
|
||||
// 包上事务 —— 落盘成功后重建 web-search 工具并替换注册表条目,**下一轮对话即生效**
|
||||
// (design/09 §5.5 的端到端判据「面板加一家 ⇒ 不改 `.env`、不重启 ⇒ 下一次搜索真的走它」)。
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||
)
|
||||
|
||||
// actionWireLogSource 是本文件日志的来源标签(项目规范 §10:禁止无来源裸日志)。
|
||||
const actionWireLogSource = "cmd/cyrene/wire_plugin_actions"
|
||||
|
||||
// 动作失败码(宿主级,且只在动作面这组文件里使用)。领域自己的码来自 search(探针码)
|
||||
// 与 pluginmgr.ActionCodeInvalidPayload。
|
||||
const (
|
||||
// actionCodeUnknownProvider 报告请求点名了一家本期没登记的 provider。
|
||||
actionCodeUnknownProvider = "unknown_provider"
|
||||
// actionCodeConfigUnreadable 报告面板配置存在但读不出来(损坏/权限)。
|
||||
actionCodeConfigUnreadable = "config_unreadable"
|
||||
// actionCodeConfigWriteFailed 报告面板配置写不进去(磁盘/权限)。
|
||||
actionCodeConfigWriteFailed = "config_write_failed"
|
||||
)
|
||||
|
||||
// wireBuiltinPluginActions 把内置插件的动作处理器登记到插件管理器上(§5.2.1:
|
||||
// 插件在组合根注册自己的动作处理器)。
|
||||
//
|
||||
// 登记之后旧账要结清:**声明里写了但没人实现**的动作会如实记一条 WARN(端点对它的
|
||||
// 结算就是 501)。这正是 §5.2.1「表里写了一个没实现的动作,review 时一眼可见」的
|
||||
// 运行期版本 —— 装配期就把差异说出来,而不是等用户点出一个 501。
|
||||
//
|
||||
// 登记失败(命名空间非法 / 中枢未接线)只记 ERROR、不阻断启动:一个坏的动作面不该
|
||||
// 让整个后端起不来(与面板声明的处置一致:该面板不注册 + 显式报错)。
|
||||
func wireBuiltinPluginActions(mgr *pluginmgr.Manager, hub *pluginmgr.Panels, a *app, log *slog.Logger) {
|
||||
if mgr == nil || hub == nil {
|
||||
return
|
||||
}
|
||||
env, ok := a.webSearchActionEnv(log)
|
||||
if !ok {
|
||||
log.Warn("builtin plugin actions not registered: no config/data dir",
|
||||
"pluginId", search.PanelPluginID, "effect", "panel actions will answer 501")
|
||||
return
|
||||
}
|
||||
handlers := webSearchActionHandlersWithHotReload(env, a, log)
|
||||
for optionsFrom, handler := range webSearchOptionsHandlers(env) {
|
||||
name := pluginmgr.OptionsActionName(optionsFrom)
|
||||
if name == "" {
|
||||
log.Error("optionsFrom 无法映射成动作名,候选来源未接线",
|
||||
"pluginId", search.PanelPluginID, "optionsFrom", optionsFrom)
|
||||
continue
|
||||
}
|
||||
handlers[name] = handler
|
||||
}
|
||||
if err := mgr.RegisterActions(search.PanelPluginID, handlers); err != nil {
|
||||
log.Error("builtin plugin actions not registered; panel actions will answer 501",
|
||||
"pluginId", search.PanelPluginID, "error", err)
|
||||
return
|
||||
}
|
||||
log.Info("builtin plugin actions registered",
|
||||
"pluginId", search.PanelPluginID, "actions", registeredActionNames(hub))
|
||||
warnImpliedButMissing(hub, log)
|
||||
}
|
||||
|
||||
// warnImpliedButMissing 把「声明里有、登记里没有」的动作与候选来源如实报出来
|
||||
// (前者的结算就是 501 action_not_implemented;后者会让前端静默降级成文本框)。
|
||||
func warnImpliedButMissing(hub *pluginmgr.Panels, log *slog.Logger) {
|
||||
registered := make(map[string]bool)
|
||||
for _, name := range hub.ActionHandlerNames(search.PanelPluginID) {
|
||||
registered[name] = true
|
||||
}
|
||||
for _, name := range hub.DeclaredActions(search.PanelPluginID) {
|
||||
if !registered[name] {
|
||||
log.Warn("declared plugin action has no handler; it will answer 501",
|
||||
"pluginId", search.PanelPluginID, "action", name)
|
||||
}
|
||||
}
|
||||
for _, src := range declaredOptionsSources(search.AdminPanels()) {
|
||||
name := pluginmgr.OptionsActionName(src)
|
||||
if name == "" || !registered[name] {
|
||||
log.Warn("declared optionsFrom has no handler; the control degrades to a text input",
|
||||
"pluginId", search.PanelPluginID, "optionsFrom", src, "action", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// registeredActionNames 返回已登记的处理器的动作名(诊断日志用,已排序)。
|
||||
func registeredActionNames(hub *pluginmgr.Panels) []string {
|
||||
names := hub.ActionHandlerNames(search.PanelPluginID)
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// declaredOptionsSources 从声明里收集全部 `optionsFrom` 取值(去重、排序)。
|
||||
//
|
||||
// 用途是**反向核对**:声明里出现了一个没有实现的候选来源时,装配期就要说出来 ——
|
||||
// 否则前端只会静默降级成文本框,没人知道是「声明多写了一个」。
|
||||
func declaredOptionsSources(panels []sdk.Panel) []string {
|
||||
seen := make(map[string]bool)
|
||||
for _, panel := range panels {
|
||||
for _, s := range panel.Sections {
|
||||
for _, f := range s.Fields {
|
||||
if key := strings.TrimSpace(f.OptionsFrom); key != "" {
|
||||
seen[key] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for key := range seen {
|
||||
out = append(out, key)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// webSearchActionEnv 是 web-search 动作处理器共享的接线事实(一次装配、多动作复用)。
|
||||
//
|
||||
// 刻意只装**在多个动作间共享**的东西(日志出口、可变源路径、`.env` 解析后的配置),
|
||||
// 每次调用各自读文件:动作之间不共享「当前配置」这份可变状态,就不会出现
|
||||
// 「A 动作读到的是 B 动作刚改之前的那份」。
|
||||
type webSearchActionEnv struct {
|
||||
log *slog.Logger
|
||||
// path 是可变源落点 `$DATA_DIR/config/plugins/web-search.json`。
|
||||
path string
|
||||
// envCfg 是 `.env` 解析结果(**只用来取标量默认值**:探针超时;各家的默认基地址改由
|
||||
// 接入位表给出,见 resolveBaseURL)。
|
||||
envCfg config.WebSearchConfig
|
||||
}
|
||||
|
||||
// webSearchActionEnv 构造处理器环境。ok=false 表示没有配置/数据目录
|
||||
// (未装配完成或测试夹具),此时不登记动作,由调用方如实告警。
|
||||
func (a *app) webSearchActionEnv(log *slog.Logger) (webSearchActionEnv, bool) {
|
||||
if a.cfg == nil {
|
||||
return webSearchActionEnv{}, false
|
||||
}
|
||||
return webSearchActionEnv{
|
||||
log: log,
|
||||
path: runtimeconfig.WebSearchConfigPath(resolveDataDir(a.cfg)),
|
||||
envCfg: a.cfg.WebSearch,
|
||||
}, true
|
||||
}
|
||||
|
||||
// webSearchActionHandlers 返回 web-search 插件声明的**四个动作**的处理器。
|
||||
func webSearchActionHandlers(env webSearchActionEnv) map[string]pluginmgr.ActionHandler {
|
||||
return map[string]pluginmgr.ActionHandler{
|
||||
search.ActionListProviders: env.listProviders,
|
||||
search.ActionSaveProvider: env.saveProvider,
|
||||
search.ActionTestProvider: env.testProvider,
|
||||
search.ActionReauthProvider: env.reauthProvider,
|
||||
}
|
||||
}
|
||||
|
||||
// webSearchOptionsHandlers 返回「下拉候选来源」的处理器(键 = 声明里的 `optionsFrom` 取值)。
|
||||
//
|
||||
// 映射成什么动作名由 `pluginmgr.OptionsActionName` 一处决定(与前端
|
||||
// `resolveOptionsAction` 同源):两处若分叉,声明的下拉就会去调一个宿主不认识的名字。
|
||||
func webSearchOptionsHandlers(env webSearchActionEnv) map[string]pluginmgr.ActionHandler {
|
||||
return map[string]pluginmgr.ActionHandler{
|
||||
"plugin:providers": env.listProviderOptions,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||
)
|
||||
|
||||
// 本文件钉住切片 L1/PLG-2 的**执行面接线**:内置插件 web-search 的四个动作 +
|
||||
// 下拉候选来源真的被登记到插件管理器上,并且真的能读写可变源、真发一次探针请求。
|
||||
//
|
||||
// 为什么在组合根测:处理器与可变源的**接线事实**(哪个文件、哪份默认值、哪个客户端)
|
||||
// 只在这里成立;插件管理器的单测只能证明「登记面会路由」,证明不了「搜索页点得动」。
|
||||
|
||||
// actionTestEnv 是一个接好面板中枢 + 动作处理器的组合根夹具。
|
||||
type actionTestEnv struct {
|
||||
app *app
|
||||
mgr *pluginmgr.Manager
|
||||
hub *pluginmgr.Panels
|
||||
// path 是可变源落点($DATA_DIR/config/plugins/web-search.json)。
|
||||
path string
|
||||
}
|
||||
|
||||
// newActionTestEnv 按组合根的**同一条路**接线:wirePanelHub(声明 + 动作处理器登记)。
|
||||
// extra 是额外的 .env 行,用来控制代码默认值(如 WEB_SEARCH_PROVIDER)。
|
||||
func newActionTestEnv(t *testing.T, extra ...string) *actionTestEnv {
|
||||
t.Helper()
|
||||
cfg := wsLoadConfig(t, extra...)
|
||||
b := bus.New()
|
||||
t.Cleanup(func() { _ = b.Close() })
|
||||
mgr := pluginmgr.NewManager(registry.New(), filepath.Join(cfg.DataDir, "plugins.json"), testLog())
|
||||
t.Cleanup(func() { mgr.Close(context.Background()) })
|
||||
|
||||
a := &app{cfg: cfg, log: testLog(), bus: b, plugins: mgr}
|
||||
wirePanelHub(a)
|
||||
hub := mgr.Panels()
|
||||
if hub == nil {
|
||||
t.Fatal("wirePanelHub 未把面板中枢接到插件管理器上")
|
||||
}
|
||||
return &actionTestEnv{app: a, mgr: mgr, hub: hub, path: runtimeconfig.WebSearchConfigPath(cfg.DataDir)}
|
||||
}
|
||||
|
||||
// dispatch 调一次动作(走生产同一条 Dispatch:声明 → 启用 → 处理器)。
|
||||
func (e *actionTestEnv) dispatch(t *testing.T, action string, payload any) (any, error) {
|
||||
t.Helper()
|
||||
return e.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
|
||||
PluginID: search.PanelPluginID,
|
||||
Action: action,
|
||||
Payload: payload,
|
||||
})
|
||||
}
|
||||
|
||||
// mustDispatch 调一次动作并要求成功。
|
||||
func (e *actionTestEnv) mustDispatch(t *testing.T, action string, payload any) any {
|
||||
t.Helper()
|
||||
out, err := e.dispatch(t, action, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch %s: %v", action, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// saveProvider 走保存动作(表单形状 `{values:{...}}`)。
|
||||
func (e *actionTestEnv) saveProvider(t *testing.T, values map[string]any) any {
|
||||
t.Helper()
|
||||
return e.mustDispatch(t, search.ActionSaveProvider, map[string]any{"values": values})
|
||||
}
|
||||
|
||||
// rowsOf 把列表动作的结果解成面板行。
|
||||
func (e *actionTestEnv) rowsOf(t *testing.T) []search.ProviderRow {
|
||||
t.Helper()
|
||||
out := e.mustDispatch(t, search.ActionListProviders, map[string]any{})
|
||||
m, ok := out.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("列表结果类型 = %T", out)
|
||||
}
|
||||
rows, ok := m["rows"].([]search.ProviderRow)
|
||||
if !ok {
|
||||
t.Fatalf("列表结果缺 rows: %#v", m)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// rowOf 取某一家在表格里的那一行(找不到则失败)。
|
||||
func (e *actionTestEnv) rowOf(t *testing.T, provider string) search.ProviderRow {
|
||||
t.Helper()
|
||||
for _, r := range e.rowsOf(t) {
|
||||
if r.Provider == provider {
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatalf("表格里没有 %q 这一行(rows = %+v)", provider, e.rowsOf(t))
|
||||
return search.ProviderRow{}
|
||||
}
|
||||
|
||||
// loadFile 读盘上的可变源(断言「真的落盘了」时用)。
|
||||
func (e *actionTestEnv) loadFile(t *testing.T) runtimeconfig.WebSearchFile {
|
||||
t.Helper()
|
||||
f, found, err := runtimeconfig.LoadWebSearch(e.path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWebSearch(%s): %v", e.path, err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("可变源不在盘上: %s", e.path)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// entryOf 取盘上某家的条目。
|
||||
func (e *actionTestEnv) entryOf(t *testing.T, provider string) runtimeconfig.ProviderEntry {
|
||||
t.Helper()
|
||||
for _, p := range e.loadFile(t).Providers {
|
||||
if p.Provider == provider {
|
||||
return p
|
||||
}
|
||||
}
|
||||
t.Fatalf("盘上没有 %q 条目", provider)
|
||||
return runtimeconfig.ProviderEntry{}
|
||||
}
|
||||
|
||||
// TestWirePanelHubRegistersBuiltinActions 是接线的主判据:内置插件的四个动作与
|
||||
// 下拉候选来源都进了登记面,且每一个都能真的被 Dispatch 执行(不再回 501)。
|
||||
func TestWirePanelHubRegistersBuiltinActions(t *testing.T) {
|
||||
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||
|
||||
want := append([]string(nil), search.ActionNames()...)
|
||||
want = append(want, pluginmgr.OptionsActionName("plugin:providers"))
|
||||
registered := env.hub.ActionHandlerNames(search.PanelPluginID)
|
||||
sort.Strings(want)
|
||||
if strings.Join(registered, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("已登记的处理器 = %v, want %v", registered, want)
|
||||
}
|
||||
// 每个已登记的动作都必须**已声明**(否则登记它等于登记一个永远调不到的名字)。
|
||||
for _, name := range registered {
|
||||
if _, ok := env.hub.Action(search.PanelPluginID, name); !ok {
|
||||
t.Errorf("动作 %q 已登记但没声明(永远不可达)", name)
|
||||
}
|
||||
}
|
||||
// 声明里的每一个动作都必须有处理器 —— 否则面板上会有一个点了回 501 的按钮。
|
||||
for _, name := range env.hub.DeclaredActions(search.PanelPluginID) {
|
||||
if _, err := env.dispatch(t, name, nil); errors.Is(err, pluginmgr.ErrActionUnavailable) {
|
||||
t.Errorf("声明过的动作 %q 没有处理器(会回 501)", name)
|
||||
}
|
||||
}
|
||||
// 列表动作也必须真的执行(此时可变源还没配过 ⇒ 空表不是错误)。
|
||||
if rows := env.rowsOf(t); len(rows) != 0 {
|
||||
t.Fatalf("没配过任何一家时列表应为空,实际 %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchListProvidersShowsStoredEntries 锁定列表动作读的是**可变源**
|
||||
// (而不是种子/默认值):写进盘里的条目与状态都如实出现,且不含任何凭据。
|
||||
func TestWebSearchListProvidersShowsStoredEntries(t *testing.T) {
|
||||
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||
env.saveProvider(t, map[string]any{
|
||||
"provider": search.NameBocha, "apiKey": "sk-bocha-secret",
|
||||
"baseUrl": "https://api.bocha.cn", "enabled": true,
|
||||
})
|
||||
|
||||
rows := env.rowsOf(t)
|
||||
byName := make(map[string]search.ProviderRow, len(rows))
|
||||
for _, r := range rows {
|
||||
byName[r.Provider] = r
|
||||
}
|
||||
if got := byName[search.NameBocha]; got.State != search.StateOK || !got.Enabled {
|
||||
t.Fatalf("bocha 行 = %+v, want state=ok 且启用", got)
|
||||
}
|
||||
raw, err := json.Marshal(rows)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal rows: %v", err)
|
||||
}
|
||||
if strings.Contains(string(raw), "sk-bocha-secret") {
|
||||
t.Fatalf("列表动作泄漏了凭据: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchSaveProviderHonoursSecretSentinel 锁定写侧的三态哨兵语义
|
||||
// (§5.4.2:空字符串 ≠ 不改动):
|
||||
// - apiKey 缺位 ⇒ 原值保留(面板只持有掩码,留空必须真的不改);
|
||||
// - apiKey 显式空串 ⇒ 清空;
|
||||
// - enabled / baseUrl 缺位 ⇒ 保留。
|
||||
func TestWebSearchSaveProviderHonoursSecretSentinel(t *testing.T) {
|
||||
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||
env.saveProvider(t, map[string]any{
|
||||
"provider": search.NameBocha, "apiKey": "sk-first",
|
||||
"baseUrl": "https://api.bocha.cn", "enabled": true,
|
||||
})
|
||||
|
||||
// 只改地址(apiKey / enabled 缺位)⇒ 凭据与启用态一个都不许动。
|
||||
env.saveProvider(t, map[string]any{"provider": search.NameBocha, "baseUrl": "https://mirror.bocha.cn"})
|
||||
got := env.entryOf(t, search.NameBocha)
|
||||
if got.APIKey != "sk-first" {
|
||||
t.Fatalf("apiKey 缺位时被改动了: %q(哨兵语义失效 = 一次保存静默清空凭据)", got.APIKey)
|
||||
}
|
||||
if got.BaseURL != "https://mirror.bocha.cn" || !got.IsEnabled() {
|
||||
t.Fatalf("条目 = %+v, want 新地址 + 仍启用", got)
|
||||
}
|
||||
|
||||
// 显式停用 + 显式清空凭据(前端只有勾了清除才会送出空串)。
|
||||
env.saveProvider(t, map[string]any{"provider": search.NameBocha, "apiKey": "", "enabled": false})
|
||||
got = env.entryOf(t, search.NameBocha)
|
||||
if got.APIKey != "" {
|
||||
t.Fatalf("显式空串必须清空凭据,得到 %q", got.APIKey)
|
||||
}
|
||||
if got.IsEnabled() {
|
||||
t.Fatal("显式 enabled=false 必须落盘(停用态)")
|
||||
}
|
||||
if state := env.rowOf(t, search.NameBocha).State; state != search.StateDisabled {
|
||||
t.Fatalf("停用后 state = %q, want %q", state, search.StateDisabled)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchReauthProviderReturnsMaskedValues 锁定 reauth「把该条带回表单」:
|
||||
// 返回显式 values(前端 formValuesOfResult 只认 values/formValues),
|
||||
// 且 apiKey 只给**掩码** —— 读侧永不回明文(§5.4.2)。
|
||||
func TestWebSearchReauthProviderReturnsMaskedValues(t *testing.T) {
|
||||
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||
env.saveProvider(t, map[string]any{
|
||||
"provider": search.NameBocha, "apiKey": "sk-abcdefgh",
|
||||
"baseUrl": "https://api.bocha.cn", "enabled": true,
|
||||
})
|
||||
|
||||
out := env.mustDispatch(t, search.ActionReauthProvider, map[string]any{
|
||||
"row": map[string]any{"provider": search.NameBocha, "state": search.StateOK},
|
||||
})
|
||||
m, ok := out.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("reauth 结果类型 = %T", out)
|
||||
}
|
||||
values, ok := m["values"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("reauth 结果缺 values: %#v", m)
|
||||
}
|
||||
if values["provider"] != search.NameBocha || values["baseUrl"] != "https://api.bocha.cn" {
|
||||
t.Fatalf("回填的字段 = %#v", values)
|
||||
}
|
||||
if values["enabled"] != true {
|
||||
t.Fatalf("enabled 回填 = %#v, want true", values["enabled"])
|
||||
}
|
||||
masked, _ := values["apiKey"].(string)
|
||||
if masked == "" || masked == "sk-abcdefgh" {
|
||||
t.Fatalf("apiKey 回填 = %q,必须是掩码(读侧无明文)", masked)
|
||||
}
|
||||
if !strings.HasSuffix(masked, "efgh") {
|
||||
t.Fatalf("掩码口径应与宿主的 maskToken 同源(尾 4 位),得到 %q", masked)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchTestProviderHitsRealHTTP 是探针的判据:**真发一次请求**到盘上那条
|
||||
// 配置指的地址,用真应答区分「通了」与「没通」。
|
||||
func TestWebSearchTestProviderHitsRealHTTP(t *testing.T) {
|
||||
hits := make(chan *http.Request, 1)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits <- r
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"results":[{"title":"A","url":"http://a.example","content":"alpha"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||
env.saveProvider(t, map[string]any{
|
||||
"provider": search.NameSearXNG, "baseUrl": srv.URL + "/search", "enabled": true,
|
||||
})
|
||||
|
||||
out := env.mustDispatch(t, search.ActionTestProvider, map[string]any{
|
||||
"row": map[string]any{"provider": search.NameSearXNG},
|
||||
})
|
||||
res, ok := out.(search.ProbeResult)
|
||||
if !ok {
|
||||
t.Fatalf("探针结果类型 = %T", out)
|
||||
}
|
||||
if !res.OK || res.Count != 1 {
|
||||
t.Fatalf("探针结论 = %+v, want ok 且 1 条", res)
|
||||
}
|
||||
select {
|
||||
case r := <-hits:
|
||||
if r.URL.Path != "/search" || !strings.Contains(r.URL.RawQuery, "q=cyrene") {
|
||||
t.Fatalf("上游收到的请求 = %s?%s(探针必须真的搜一次)", r.URL.Path, r.URL.RawQuery)
|
||||
}
|
||||
default:
|
||||
t.Fatal("上游一个请求都没收到 —— 探针没真发请求")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchTestProviderReportsFailureHonestly 锁定失败口径:上游拒了凭据 ⇒
|
||||
// 结构化非 2xx(码 = 探针码,文案固定不含上游原文),绝不回一句假的「已完成」。
|
||||
func TestWebSearchTestProviderReportsFailureHonestly(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"bad key sk-should-not-leak"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||
env.saveProvider(t, map[string]any{
|
||||
"provider": search.NameSearXNG, "baseUrl": srv.URL, "enabled": true,
|
||||
})
|
||||
|
||||
_, err := env.dispatch(t, search.ActionTestProvider, map[string]any{
|
||||
"row": map[string]any{"provider": search.NameSearXNG},
|
||||
})
|
||||
ae, ok := pluginmgr.AsActionError(err)
|
||||
if !ok {
|
||||
t.Fatalf("探针失败 err = %v,期望结构化 ActionError", err)
|
||||
}
|
||||
if ae.Code != search.ProbeUnauthorized {
|
||||
t.Fatalf("失败码 = %q, want %q", ae.Code, search.ProbeUnauthorized)
|
||||
}
|
||||
if ae.Status != http.StatusBadGateway {
|
||||
t.Fatalf("失败状态 = %d, want 502(上游失败)", ae.Status)
|
||||
}
|
||||
if strings.Contains(ae.Message, "sk-should-not-leak") || ae.Message == "" {
|
||||
t.Fatalf("失败文案 = %q(不得含上游原文,也不得为空)", ae.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchTestProviderNotConfiguredIsClientError 锁定「没配齐」归 400:
|
||||
// 这是调用方/配置的问题,不是上游不可用(前端据此区分该改配置还是该重试)。
|
||||
func TestWebSearchTestProviderNotConfiguredIsClientError(t *testing.T) {
|
||||
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||
// 存一条**没配齐**的(显式清空 key):探针必须在发请求**之前**就报 not_configured,
|
||||
// 绝不拿一个没有凭据的配置去戳上游。
|
||||
env.saveProvider(t, map[string]any{"provider": search.NameBocha, "apiKey": ""})
|
||||
|
||||
_, err := env.dispatch(t, search.ActionTestProvider, map[string]any{
|
||||
"row": map[string]any{"provider": search.NameBocha},
|
||||
})
|
||||
ae, ok := pluginmgr.AsActionError(err)
|
||||
if !ok || ae.Code != search.ProbeNotConfigured || ae.Status != http.StatusBadRequest {
|
||||
t.Fatalf("err = %#v, want 400 not_configured", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchActionsRejectUnknownProviderAndBadPayload 锁定动作的两类调用方错误
|
||||
// 都被**显式拒绝**(不静默存一条永远选不中的配置,也不把类型错当成空值)。
|
||||
func TestWebSearchActionsRejectUnknownProviderAndBadPayload(t *testing.T) {
|
||||
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
action string
|
||||
payload any
|
||||
wantCode string
|
||||
}{
|
||||
{"保存未登记的家", search.ActionSaveProvider,
|
||||
map[string]any{"values": map[string]any{"provider": "tavily", "apiKey": "sk-x"}}, actionCodeUnknownProvider},
|
||||
{"保存缺 provider", search.ActionSaveProvider,
|
||||
map[string]any{"values": map[string]any{"apiKey": "sk-x"}}, pluginmgr.ActionCodeInvalidPayload},
|
||||
{"保存缺 values 对象", search.ActionSaveProvider,
|
||||
map[string]any{"apiKey": "sk-x"}, pluginmgr.ActionCodeInvalidPayload},
|
||||
{"保存 enabled 类型错", search.ActionSaveProvider,
|
||||
map[string]any{"values": map[string]any{"provider": search.NameBocha, "enabled": "yes"}}, pluginmgr.ActionCodeInvalidPayload},
|
||||
{"探针未登记的家", search.ActionTestProvider,
|
||||
map[string]any{"row": map[string]any{"provider": "tavily"}}, actionCodeUnknownProvider},
|
||||
{"探针缺 provider", search.ActionTestProvider, map[string]any{}, pluginmgr.ActionCodeInvalidPayload},
|
||||
{"reauth 未登记的家", search.ActionReauthProvider,
|
||||
map[string]any{"row": map[string]any{"provider": "tavily"}}, actionCodeUnknownProvider},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := env.dispatch(t, tc.action, tc.payload)
|
||||
ae, ok := pluginmgr.AsActionError(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %v,期望结构化 ActionError", err)
|
||||
}
|
||||
if ae.Code != tc.wantCode || ae.Status != http.StatusBadRequest {
|
||||
t.Fatalf("err = %+v, want code=%q status=400", ae, tc.wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
// 被拒的保存不得在盘上留下痕迹(没写成功时文件可能根本不存在 —— 那也是「没留下」)。
|
||||
if f, found, err := runtimeconfig.LoadWebSearch(env.path); err == nil && found {
|
||||
for _, p := range f.Providers {
|
||||
if p.Provider == "tavily" {
|
||||
t.Fatal("未登记的家被写进了可变源")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchOptionsActionFeedsDeclaredOptionsFrom 锁死「声明的下拉一定拉得到候选」:
|
||||
// 声明里每个 optionsFrom 映射出的动作名都**已登记且已声明**(否则前端只能降级成文本框)。
|
||||
//
|
||||
// 候选的形状是 `{value,label}`(切片 LBL-1):这里连**显示名非空**一起钉住 —— 只有值的
|
||||
// 候选会让面板下拉与表格里全是英文 id(主人 2026-10-06 报障的那个形状)。
|
||||
func TestWebSearchOptionsActionFeedsDeclaredOptionsFrom(t *testing.T) {
|
||||
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||
|
||||
sources := declaredOptionsSources(search.AdminPanels())
|
||||
if len(sources) == 0 {
|
||||
t.Fatal("声明里一个 optionsFrom 都没有:这条判据失去了意义")
|
||||
}
|
||||
for _, src := range sources {
|
||||
name := pluginmgr.OptionsActionName(src)
|
||||
if name == "" {
|
||||
t.Fatalf("optionsFrom %q 映射不出动作名", src)
|
||||
}
|
||||
if _, ok := env.hub.Action(search.PanelPluginID, name); !ok {
|
||||
t.Errorf("候选来源 %q(动作 %s)没进声明索引", src, name)
|
||||
}
|
||||
out, err := env.dispatch(t, name, map[string]any{})
|
||||
if err != nil {
|
||||
t.Errorf("候选来源 %q 调不动: %v", src, err)
|
||||
continue
|
||||
}
|
||||
options, _ := out.(map[string]any)["options"].([]search.ProviderOption)
|
||||
if len(options) == 0 {
|
||||
t.Errorf("候选来源 %q 回了空候选(表单下拉会空着)", src)
|
||||
}
|
||||
got, want := optionValues(options), optionValues(search.ProviderOptions())
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Errorf("候选值 = %v, want %v(value 逐字等于 KnownNames())", got, want)
|
||||
}
|
||||
for _, o := range options {
|
||||
if strings.TrimSpace(o.Label) == "" {
|
||||
t.Errorf("候选来源 %q 的 %q 没有显示名(下拉里会是空白项)", src, o.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// optionValues 取出结构化候选的**值**(落盘真源),丢掉显示名。
|
||||
func optionValues(opts []search.ProviderOption) []string {
|
||||
out := make([]string, 0, len(opts))
|
||||
for _, o := range opts {
|
||||
out = append(out, o.Value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestWebSearchActionsUnwiredAnswers501 锁定降级口径:**没有配置**(测试夹具/装配未完成)
|
||||
// 时不登记任何处理器 ⇒ 已声明的动作诚实回 501(ErrActionUnavailable),
|
||||
// 绝不假装成功地回一份空列表。
|
||||
func TestWebSearchActionsUnwiredAnswers501(t *testing.T) {
|
||||
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||
b := bus.New()
|
||||
defer b.Close()
|
||||
mgr := pluginmgr.NewManager(registry.New(), filepath.Join(cfg.DataDir, "plugins.json"), testLog())
|
||||
defer mgr.Close(context.Background())
|
||||
|
||||
// cfg 为 nil ⇒ 组合根拿不到数据目录,动作登记被跳过(只告警)。
|
||||
wirePanelHub(&app{log: testLog(), bus: b, plugins: mgr})
|
||||
hub := mgr.Panels()
|
||||
if got := hub.ActionHandlerNames(search.PanelPluginID); len(got) != 0 {
|
||||
t.Fatalf("没有配置时不该登记任何处理器,实际 %v", got)
|
||||
}
|
||||
_, err := hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
|
||||
PluginID: search.PanelPluginID, Action: search.ActionListProviders,
|
||||
})
|
||||
if !errors.Is(err, pluginmgr.ErrActionUnavailable) {
|
||||
t.Fatalf("err = %v, want ErrActionUnavailable(未登记处理器必须诚实回 501)", err)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 1908 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user