Compare commits
781 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
101d02a3e9 | ||
|
|
f3e8d2aee1 | ||
|
|
fcba6fda1c | ||
|
|
5ce516ed38 | ||
|
|
0e9bd651a4 | ||
|
|
6ad7a4cc83 | ||
|
|
6f55b973e5 | ||
|
|
4674f383af | ||
|
|
e3a2b0b3d2 | ||
|
|
db548fc872 | ||
|
|
5d215c67c0 | ||
|
|
ec4d543f8e | ||
|
|
3687597136 | ||
|
|
d9f3a69d29 | ||
|
|
d9a66d1ace | ||
|
|
5679ef039c | ||
|
|
ae40af03d7 | ||
|
|
51f3f30caf | ||
|
|
0ec4aad949 | ||
|
|
e258672bcb | ||
|
|
219f5d6ce5 | ||
|
|
2a0757fb46 | ||
|
|
67193faf38 | ||
|
|
1bfc4a992a | ||
|
|
8439817c76 | ||
|
|
8cd3680c0c | ||
|
|
1c356bf321 | ||
|
|
5c7c4c28e3 | ||
|
|
1a76e8761e | ||
|
|
18d960eb7a | ||
|
|
52bfceaa3b | ||
|
|
fea47bd986 | ||
|
|
bdc328d034 | ||
|
|
b5009dd5b4 | ||
|
|
afc68d3a13 | ||
|
|
fc8898161e | ||
|
|
6572f81abb | ||
|
|
2bc6f9a997 | ||
|
|
e36def33cd | ||
|
|
5c5ca7d2ef | ||
|
|
838b931047 | ||
|
|
a6d831fc63 | ||
|
|
d21c97205e | ||
|
|
d1e1c4eeec | ||
|
|
031feda376 | ||
|
|
f53556b3ff | ||
|
|
d071e46e1f | ||
|
|
f14280e2c4 | ||
|
|
c5f4f569d6 | ||
|
|
63251ad206 | ||
|
|
89dcab8327 | ||
|
|
571cfed180 | ||
|
|
7da1e074e4 | ||
|
|
ffd11037b1 | ||
|
|
f8754ded70 | ||
|
|
3a13be297e | ||
|
|
645dfa25af | ||
|
|
07350426f2 | ||
|
|
bc10a229e3 | ||
|
|
d0257e8bcf | ||
|
|
2566b434d1 | ||
|
|
d72399ae22 | ||
|
|
604b44a254 | ||
|
|
36d87f54d5 | ||
|
|
d2d464aac3 | ||
|
|
6a736809ef | ||
|
|
f13230f7cd | ||
|
|
70dac0135c | ||
|
|
bbdacdca5c | ||
|
|
9303636dd9 | ||
|
|
e68f74ac99 | ||
|
|
d6b9cfac23 | ||
|
|
932694aec6 | ||
|
|
1df89e7a52 | ||
|
|
880350312a | ||
|
|
f70b791bf8 | ||
|
|
c98fff79c2 | ||
|
|
77b456b755 | ||
|
|
eb678d5b54 | ||
|
|
dec4b48607 | ||
|
|
a5c10d594d | ||
|
|
f328f3b843 | ||
|
|
929461ffbc | ||
|
|
50418cd47b | ||
|
|
d4b055c30b | ||
|
|
1fa740d32f | ||
|
|
fbe84d26e6 | ||
|
|
09e12e3c60 | ||
|
|
e2d33ffce4 | ||
|
|
280ab86480 | ||
|
|
d356e081ed | ||
|
|
52e1567bd1 | ||
|
|
06fd6d9ccc | ||
|
|
4651e0fad0 | ||
|
|
aa2b9d504d | ||
|
|
4683a4a0d0 | ||
|
|
e86de0aff3 | ||
|
|
92121324a0 | ||
|
|
1ccd958e23 | ||
|
|
eb95c6a341 | ||
|
|
5bde48bb6e | ||
|
|
d0f6ee2ef9 | ||
|
|
9a6caa1e78 | ||
|
|
b2fbacf847 | ||
|
|
3f838fc31a | ||
|
|
ded9b7e1c4 | ||
|
|
20ac6dfe5c | ||
|
|
0ad95cb16a | ||
|
|
33a145a669 | ||
|
|
9f269a4f1c | ||
|
|
36eb6515f6 | ||
|
|
8e546c0273 | ||
|
|
9b6bce3a0d | ||
|
|
af433de7a7 | ||
|
|
eeef360a74 | ||
|
|
e538286d9a | ||
|
|
bd8fc6a2e2 | ||
|
|
4ee80425f2 | ||
|
|
c75be8f564 | ||
|
|
f65f488635 | ||
|
|
e0f77d6ab4 | ||
|
|
e2ff00f819 | ||
|
|
d5c0838fcd | ||
|
|
8b9ad761f9 | ||
|
|
2bb0af49f2 | ||
|
|
1cf406addb | ||
|
|
ea4d381e43 | ||
|
|
2bdf5c77d4 | ||
|
|
26579ba141 | ||
|
|
3feef25737 | ||
|
|
cc45175ee5 | ||
|
|
3b614c4cd5 | ||
|
|
4e0d8da060 | ||
|
|
65e5690772 | ||
|
|
0fe59831fe | ||
|
|
9350af6fd7 | ||
|
|
22cf29d477 | ||
|
|
1ed1ce219d | ||
|
|
d184613752 | ||
|
|
b277e195fe | ||
|
|
8a74ea89e7 | ||
|
|
1fe9b76a3a | ||
|
|
db358e362b | ||
|
|
be08842642 | ||
|
|
72b4ff66f0 | ||
|
|
c86545b6a7 | ||
|
|
bbd754a496 | ||
|
|
867f2a3f81 | ||
|
|
6a17e4cc0c | ||
|
|
74ecc58afa | ||
|
|
2f0d036455 | ||
|
|
eb9614854e | ||
|
|
940c82b2da | ||
|
|
70417359e3 | ||
|
|
8e67e4aa78 | ||
|
|
4575cae9db | ||
|
|
38c0912da1 | ||
|
|
d501daafe1 | ||
|
|
7df55b9789 | ||
|
|
10c4ea24f1 | ||
|
|
60a4cb057e | ||
|
|
9806a42a26 | ||
|
|
b2771ebf69 | ||
|
|
29a31a6e26 | ||
|
|
49a2a424d5 | ||
|
|
6f37da38a6 | ||
|
|
2487de2cc0 | ||
|
|
eefa1bbad8 | ||
|
|
1f602b47ec | ||
|
|
9a371f06f5 | ||
|
|
acbd0c14f2 | ||
|
|
103a9833d5 | ||
|
|
63dee0a87c | ||
|
|
b0aed07fe0 | ||
|
|
47e91ee84b | ||
|
|
9fabd12e41 | ||
|
|
4dbc9ac3e1 | ||
|
|
d734efa8af | ||
|
|
98ed2d804b | ||
|
|
f2f7224b8d | ||
|
|
8c24b24dcd | ||
|
|
d08d96f864 | ||
|
|
8c63324ff7 | ||
|
|
9c57d36156 | ||
|
|
38df294af9 | ||
|
|
03b7714f65 | ||
|
|
62650e6a0d | ||
|
|
9d5480565f | ||
|
|
b57134bf2b | ||
|
|
be291498cf | ||
|
|
8eb3d8bdbc | ||
|
|
96182e5f51 | ||
|
|
59abbd1300 | ||
|
|
93e7ba5a6b | ||
|
|
014f16c359 | ||
|
|
26f51b7190 | ||
|
|
544d5222a1 | ||
|
|
25958139da | ||
|
|
568a913615 | ||
|
|
c707e6760b | ||
|
|
9df01c6167 | ||
|
|
f384368ee2 | ||
|
|
248cfd1248 | ||
|
|
6b04ae0254 | ||
|
|
b488a0d1b0 | ||
|
|
5d16ff7522 | ||
|
|
8bfd8b28d5 | ||
|
|
c5e8372686 | ||
|
|
5a563a45a4 | ||
|
|
a8101d98f7 | ||
|
|
0741a2ab9f | ||
|
|
ae2ed1a4e7 | ||
|
|
24d65a1efa | ||
|
|
f7f8fc6496 | ||
|
|
f35d7786e5 | ||
|
|
0a0513c9d3 | ||
|
|
24b1e6f3fc | ||
|
|
7189416969 | ||
|
|
1f07d3d0fc | ||
|
|
3780df9428 | ||
|
|
e61a405add | ||
|
|
b24b0335f7 | ||
|
|
a091be6a8e | ||
|
|
ef26d19549 | ||
|
|
8b8ff3328a | ||
|
|
dca8624454 | ||
|
|
5192ca5de5 | ||
|
|
69bf2878bc | ||
|
|
fc0152b2fc | ||
|
|
4528c6c848 | ||
|
|
27b17a8fc8 | ||
|
|
d67036db24 | ||
|
|
d625bac6d4 | ||
|
|
498b51bfc6 | ||
|
|
62adc0c00d | ||
|
|
58ad315dca | ||
|
|
3d96dc1498 | ||
|
|
520034c071 | ||
|
|
16955b712c | ||
|
|
9d22ea7ff4 | ||
|
|
360463dd8e | ||
|
|
01404ac062 | ||
|
|
6c343aff84 | ||
|
|
7d1aa2e261 | ||
|
|
3ce7844a7a | ||
|
|
ad8e10304c | ||
|
|
12a8c051fb | ||
|
|
44a6587e78 | ||
|
|
0a6f15d8d9 | ||
|
|
2800ebdcff | ||
|
|
3c457d178d | ||
|
|
c0019723d1 | ||
|
|
34329ad231 | ||
|
|
e62338d3a0 | ||
|
|
619646159c | ||
|
|
a4b56642d9 | ||
|
|
32276c81d1 | ||
|
|
86b20d362f | ||
|
|
8ce83b637c | ||
|
|
6333a06524 | ||
|
|
5663fb147b | ||
|
|
0217bf5cce | ||
|
|
da131b842d | ||
|
|
ef72384217 | ||
|
|
116a510ed3 | ||
|
|
ed24010e10 | ||
|
|
23b7c63198 | ||
|
|
7e17ec497c | ||
|
|
2d5c4b71cc | ||
|
|
4a882bec66 | ||
|
|
f48b157a8f | ||
|
|
a2d7f311be | ||
|
|
c06ec43f17 | ||
|
|
e5cf9c5910 | ||
|
|
70de09290c | ||
|
|
f109592cb0 | ||
|
|
d339200b5b | ||
|
|
0a91e3cb02 | ||
|
|
cb41075bd2 | ||
|
|
91703e3e54 | ||
|
|
396537c624 | ||
|
|
b072a6887c | ||
|
|
27e69c404a | ||
|
|
dbc9c910a8 | ||
|
|
23e9070fc5 | ||
|
|
e0257d81d5 | ||
|
|
533edbcae0 | ||
|
|
885f1fa349 | ||
|
|
970bc1d3fd | ||
|
|
061af78cde | ||
|
|
87d4136a43 | ||
|
|
ce9aec1640 | ||
|
|
1a9dba7844 | ||
|
|
06bedc8e23 | ||
|
|
63b0207604 | ||
|
|
9c69b646ff | ||
|
|
57222c70e7 | ||
|
|
14a1924796 | ||
|
|
36da37ff13 | ||
|
|
b14ea4f9f6 | ||
|
|
ff970ec844 | ||
|
|
b563484a56 | ||
|
|
89b0c8eb41 | ||
|
|
a3647570fb | ||
|
|
1011918d50 | ||
|
|
07caaec6ef | ||
|
|
1175ee363f | ||
|
|
5b923a9502 | ||
|
|
5082f426f2 | ||
|
|
537c8271db | ||
|
|
9dd6e3f338 | ||
|
|
4089972b09 | ||
|
|
498156a3e8 | ||
|
|
cd01e4d5ba | ||
|
|
96c97c5e0e | ||
|
|
ae7be6deba | ||
|
|
bd443c4862 | ||
|
|
b82954ee70 | ||
|
|
666d385c03 | ||
|
|
d39d30a213 | ||
|
|
62c56175b7 | ||
|
|
0f1b232c12 | ||
|
|
cc025aab79 | ||
|
|
236a116888 | ||
|
|
04b00065f9 | ||
|
|
e3607855b1 | ||
|
|
a72208eaf6 | ||
|
|
0a75b3f1d3 | ||
|
|
1a98f75005 | ||
|
|
095dbfd641 | ||
|
|
3a63fe479e | ||
|
|
96cb880a12 | ||
|
|
e151665131 | ||
|
|
558b1730a6 | ||
|
|
201235d807 | ||
|
|
256b3fbbdf | ||
|
|
5fa731ea4a | ||
|
|
d8e1f37e2b | ||
|
|
f42f1c69ca | ||
|
|
418d77443c | ||
|
|
13dbd818c9 | ||
|
|
85434dd03c | ||
|
|
db57c47ff3 | ||
|
|
9b628c27ab | ||
|
|
11fd0d8412 | ||
|
|
24fc9d4155 | ||
|
|
1239129ae2 | ||
|
|
880085a09e | ||
|
|
d4a3adb7b1 | ||
|
|
3daf2427f7 | ||
|
|
d41d05ea36 | ||
|
|
859602340e | ||
|
|
c3807482be | ||
|
|
2d8bccdd96 | ||
|
|
8f1f582caf | ||
|
|
a4d59b9e6c | ||
|
|
811424a87b | ||
|
|
f6e1612c7e | ||
|
|
081c4208d9 | ||
|
|
e05fc4e0e4 | ||
|
|
312a493a72 | ||
|
|
3246b263d9 | ||
|
|
bbc917a5c6 | ||
|
|
cbb4ba3f28 | ||
|
|
d527629281 | ||
|
|
77ab63361f | ||
|
|
3f484aec33 | ||
|
|
49ff8b3185 | ||
|
|
38e215e8f8 | ||
|
|
81072d34d6 | ||
|
|
e91325db25 | ||
|
|
629d4290ed | ||
|
|
28b4777b5a | ||
|
|
b6d335feaa | ||
|
|
a7e8b1ab83 | ||
|
|
c34892be44 | ||
|
|
98cd318413 | ||
|
|
94a04ddd40 | ||
|
|
765d8520d4 | ||
|
|
76e602af25 | ||
|
|
63f9b719bb | ||
|
|
f35ac3a727 | ||
|
|
0dd5d6f21c | ||
|
|
a8979f74d5 | ||
|
|
711d8bb6c0 | ||
|
|
a1c5c395e5 | ||
|
|
69570ca77c | ||
|
|
aa767d28d0 | ||
|
|
78c4f1e425 | ||
|
|
81ba420716 | ||
|
|
7f16a41a31 | ||
|
|
c68420d9aa | ||
|
|
aa78175cca | ||
|
|
da1fdca22c | ||
|
|
067d96bb30 | ||
|
|
e637965388 | ||
|
|
66fbfbaa2b | ||
|
|
877a32f49c | ||
|
|
0386dc261a | ||
|
|
17e965b52f | ||
|
|
d3a686a266 | ||
|
|
3cd38b2b31 | ||
|
|
d7071cd424 | ||
|
|
e0ad593801 | ||
|
|
75e4f8b201 | ||
|
|
352354790f | ||
|
|
5266ee26bd | ||
|
|
5c2840e2da | ||
|
|
75e6595e06 | ||
|
|
20a5f48a1f | ||
|
|
ad6e76e48e | ||
|
|
b49de92893 | ||
|
|
b1aa1cfa4d | ||
|
|
8c68ea8823 | ||
|
|
ec48c482e2 | ||
|
|
bded1cf906 | ||
|
|
7cb5547056 | ||
|
|
f3f23abd4e | ||
|
|
d6267f4d31 | ||
|
|
e7b8ab4d70 | ||
|
|
79428f93c6 | ||
|
|
a2ea15b557 | ||
|
|
692ba68e42 | ||
|
|
2484409b7a | ||
|
|
b608f8837e | ||
|
|
d5bea959a5 | ||
|
|
9a3dc10d93 | ||
|
|
25d38a467a | ||
|
|
6c5911a79f | ||
|
|
54e83fb8b6 | ||
|
|
8a1bc134fa | ||
|
|
b5fc32b18d | ||
|
|
db1240dde5 | ||
|
|
2efc1fb8e8 | ||
|
|
a9a22ee751 | ||
|
|
a512f2020e | ||
|
|
45426bdcd1 | ||
|
|
360379136b | ||
|
|
e4fec9e4e0 | ||
|
|
8cf10b152b | ||
|
|
07c25f0766 | ||
|
|
4f79b3f941 | ||
|
|
54d0ee5f6c | ||
|
|
3e1ba1b783 | ||
|
|
0a9b952d4c | ||
|
|
8864001941 | ||
|
|
7e8ed4afff | ||
|
|
215f7eff4d | ||
|
|
a4ce9ccc99 | ||
|
|
8ff3fd9442 | ||
|
|
53ce8a107b | ||
|
|
ec44a437a2 | ||
|
|
400b1721d7 | ||
|
|
fbce1093b9 | ||
|
|
c0bffa15f1 | ||
|
|
27d3f9543e | ||
|
|
51767f9d90 | ||
|
|
9d4c075e2b | ||
|
|
f5c4e110a4 | ||
|
|
4c142da3f6 | ||
|
|
3b53b3f4f6 | ||
|
|
69effc7b22 | ||
|
|
7bfba201da | ||
|
|
dc2334c5a3 | ||
|
|
bd55379886 | ||
|
|
392c315d4b | ||
|
|
25fae902d3 | ||
|
|
d6b58b9ce0 | ||
|
|
ac839e0d01 | ||
|
|
ce4e01ea92 | ||
|
|
4f7db62c58 | ||
|
|
3033fb65e3 | ||
|
|
03df7132d0 | ||
|
|
50d7d1cf88 | ||
|
|
e4688425ab | ||
|
|
9220a876bc | ||
|
|
e077d110c3 | ||
|
|
8f7bee7b34 | ||
|
|
dc43a30af7 | ||
|
|
74dee6b665 | ||
|
|
96c4102aa7 | ||
|
|
d3251fdbfd | ||
|
|
31196d42af | ||
|
|
1050c673e6 | ||
|
|
178251a5c0 | ||
|
|
21a7564afd | ||
|
|
44a544362f | ||
|
|
36830e3cd1 | ||
|
|
7ea7331f26 | ||
|
|
eb760a2158 | ||
|
|
0b96f08b3e | ||
|
|
4f1623520d | ||
|
|
505bfc6a9a | ||
|
|
1bd0341243 | ||
|
|
ccba2f5c01 | ||
|
|
45d3dc0f68 | ||
|
|
69cd0832de | ||
|
|
7b9f08c774 | ||
|
|
2810233af4 | ||
|
|
642f4536f0 | ||
|
|
f0d49b5b59 | ||
|
|
e6447ebad2 | ||
|
|
887893ecd1 | ||
|
|
75de03c99f | ||
|
|
d8ab326b73 | ||
|
|
7753e954e5 | ||
|
|
2343dc1d85 | ||
|
|
85f1017514 | ||
|
|
eb7ec5bac3 | ||
|
|
b673006b7f | ||
|
|
5a79dd0dc9 | ||
|
|
0a570ada87 | ||
|
|
53acc8e0e1 | ||
|
|
34b98285a1 | ||
|
|
e228b1414f | ||
|
|
bb445ffe9a | ||
|
|
2eb0679104 | ||
|
|
12949a2771 | ||
|
|
7b0fb246ee | ||
|
|
f86581e3e5 | ||
|
|
3c5ca2db62 | ||
|
|
c7381ee3f1 | ||
|
|
32669f4a5b | ||
|
|
c9a0e02301 | ||
|
|
bfb9bbb0bf | ||
|
|
5507dae3d7 | ||
|
|
0349df6ee4 | ||
|
|
8c36203dd4 | ||
|
|
c4d1e8c5d0 | ||
|
|
c0c0195f7f | ||
|
|
8199fa333e | ||
|
|
77769750c2 | ||
|
|
b3ad60d2c9 | ||
|
|
85d8aad0ae | ||
|
|
f1590fdb07 | ||
|
|
3776b09f4a | ||
|
|
2400e14a31 | ||
|
|
69b0a905a4 | ||
|
|
c3251ea97d | ||
|
|
924c833878 | ||
|
|
5fd7dc0c17 | ||
|
|
a4136f2da5 | ||
|
|
3c3cae89f8 | ||
|
|
8b857d9efc | ||
|
|
8d1c257ea8 | ||
|
|
6e303fbd93 | ||
|
|
61ecdaded3 | ||
|
|
09e278461c | ||
|
|
6347949463 | ||
|
|
204dc23c6b | ||
|
|
c4efe96725 | ||
|
|
6a513f49b2 | ||
|
|
db392bd532 | ||
|
|
b394efce17 | ||
|
|
28d226f5ce | ||
|
|
d8aa387c3c | ||
|
|
4ad7efe8cf | ||
|
|
57a50591ee | ||
|
|
16c58e60f4 | ||
|
|
37850a4dfd | ||
|
|
415270ff03 | ||
|
|
2a7a5ddfaf | ||
|
|
a5abe51cc5 | ||
|
|
3cc5839bf3 | ||
|
|
539501ed2b | ||
|
|
c91eaaf05f | ||
|
|
d3fea34c41 | ||
|
|
a2258139f2 | ||
|
|
1345ccccee | ||
|
|
4de4ed9a15 | ||
|
|
04ed0ff43d | ||
|
|
2beebaa6a2 | ||
|
|
0f8fec7ccd | ||
|
|
12a60faaee | ||
|
|
2acee7fc34 | ||
|
|
acc14f2f0b | ||
|
|
9948fcf1db | ||
|
|
6a1dda4082 | ||
|
|
56944cc0ab | ||
|
|
7f69155904 | ||
|
|
54181d1a07 | ||
|
|
9542639a90 | ||
|
|
bcdd7ed3f3 | ||
|
|
7a80e73eb2 | ||
|
|
78de40e015 | ||
|
|
00eb13b316 | ||
|
|
a71047bbc3 | ||
|
|
68426124c5 | ||
|
|
4c8042ea00 | ||
|
|
a6484f69a8 | ||
|
|
f13f753de8 | ||
|
|
f948baceb6 | ||
|
|
5bdeb93559 | ||
|
|
d0e08fee88 | ||
|
|
dd17a0e9b7 | ||
|
|
04401787ec | ||
|
|
09bbbfc657 | ||
|
|
88dc8bbe26 | ||
|
|
1fee123ac8 | ||
|
|
a683553699 | ||
|
|
63fb22b7ee | ||
|
|
05f09012a5 | ||
|
|
3c771c4d2c | ||
|
|
2398ec51fe | ||
|
|
4eaf4e0743 | ||
|
|
1c0d13c6d9 | ||
|
|
4c78d8a56b | ||
|
|
229680ae1e | ||
|
|
e0e642a239 | ||
|
|
e684fdd731 | ||
|
|
2a3324c201 | ||
|
|
39d42be396 | ||
|
|
2fc19a8326 | ||
|
|
7d9d7e7b66 | ||
|
|
9c44d0cf3e | ||
|
|
7552cd3e9b | ||
|
|
f316fb7502 | ||
|
|
50583f0667 | ||
|
|
26c24867e6 | ||
|
|
2562567730 | ||
|
|
2b21bb68b8 | ||
|
|
84ca4d617b | ||
|
|
9021e76708 | ||
|
|
eddf3249c1 | ||
|
|
ede1a5fc50 | ||
|
|
ed2d55f020 | ||
|
|
28354a9702 | ||
|
|
bd3ec45aa9 | ||
|
|
74a4263056 | ||
|
|
28a0f0bef9 | ||
|
|
b12a682121 | ||
|
|
bc16545794 | ||
|
|
a13a1e0b9e | ||
|
|
fc43b897c5 | ||
|
|
d6a925cf11 | ||
|
|
5468b04550 | ||
|
|
7556ea0e04 | ||
|
|
92fbf2a793 | ||
|
|
0d98116b37 | ||
|
|
31a721417e | ||
|
|
f9663d2f1d | ||
|
|
cbc3c01604 | ||
|
|
42dd2b562d | ||
|
|
6b4ff53315 | ||
|
|
ce84d1bafa | ||
|
|
afa540a222 | ||
|
|
711bb5a6c9 | ||
|
|
c677893105 | ||
|
|
eca6f5efbd | ||
|
|
068836cf6b | ||
|
|
09325f1bdf | ||
|
|
1003fa410c | ||
|
|
a2ae953620 | ||
|
|
b86ace6ce3 | ||
|
|
c357ed9b74 | ||
|
|
27c2fd6c08 | ||
|
|
0e112455ec | ||
|
|
02e6e768e6 | ||
|
|
da160d675f | ||
|
|
1e27940535 | ||
|
|
2215aced19 | ||
|
|
4947a6b0c3 | ||
|
|
0df9d4830f | ||
|
|
e0a95193d8 | ||
|
|
e3c85624d9 | ||
|
|
ed9023a431 | ||
|
|
e59fedd351 | ||
|
|
9a5435176d | ||
|
|
31281a6025 | ||
|
|
cc8cbc4d3f | ||
|
|
0e5e465ea0 | ||
|
|
a92e21553d | ||
|
|
06f46439c0 | ||
|
|
e68c1b92a4 | ||
|
|
fb19c7ea1f | ||
|
|
cb069794dd | ||
|
|
be92e59bdb | ||
|
|
f90be60e31 | ||
|
|
011034dc71 | ||
|
|
392bc5df6e | ||
|
|
fdf6ebfbe6 | ||
|
|
04678b7b6e | ||
|
|
4d68fb31d4 | ||
|
|
80b26c7c72 | ||
|
|
012ac6f149 | ||
|
|
18aca24063 | ||
|
|
a5b843d6f9 | ||
|
|
9714c1779f | ||
|
|
0126044ecb | ||
|
|
166a4c3e7b | ||
|
|
1ac1e74512 | ||
|
|
b979b4c443 | ||
|
|
c04caf3f5b | ||
|
|
799cbb7eca | ||
|
|
0d83837650 | ||
|
|
5f7564e8bb | ||
|
|
8ff5d83e14 | ||
|
|
5e899ee8fe | ||
|
|
907bb224d9 | ||
|
|
d919b584c6 | ||
|
|
4422a87de9 | ||
|
|
9e9fcb09d2 | ||
|
|
12e5de9c4e | ||
|
|
7e6fec1c85 | ||
|
|
a064542df9 | ||
|
|
ac969e4bd6 | ||
|
|
67a83368f0 | ||
|
|
a9b2a2f22f | ||
|
|
e3303c6e89 | ||
|
|
40cbd024b9 | ||
|
|
ccabdf9882 | ||
|
|
70a486ddef | ||
|
|
ab6147fba9 | ||
|
|
8aa1c9684d | ||
|
|
4d2887531d | ||
|
|
d6de7c8650 | ||
|
|
76241bc255 | ||
|
|
5b4c5b0094 | ||
|
|
027e7314f0 | ||
|
|
107c446187 | ||
|
|
01896d67f3 | ||
|
|
5a52259fd7 | ||
|
|
d71daad002 | ||
|
|
481eefaf91 | ||
|
|
534eefe09a | ||
|
|
d89639dbb3 | ||
|
|
2442fca5e5 | ||
|
|
442b0d872a | ||
|
|
3bba645364 | ||
|
|
5f014b7c4a | ||
|
|
cd598c896a | ||
|
|
58eb6e7fd5 | ||
|
|
76cdfb69e0 | ||
|
|
4622b64ca9 | ||
|
|
89891c65c8 | ||
|
|
173261c428 | ||
|
|
863dc4e938 | ||
|
|
4407c3097b | ||
|
|
71dd691ed0 | ||
|
|
9f3b2e113e | ||
|
|
e8a8fceb26 | ||
|
|
e1c2e7e3d6 | ||
|
|
c6017f461b | ||
|
|
e829fa50d5 | ||
|
|
1777cf7bfe | ||
|
|
48ba2e79e2 | ||
|
|
52e3fb70e0 | ||
|
|
c1bbdf9aeb | ||
|
|
3ca7f08b59 | ||
|
|
af76622c97 | ||
|
|
27706367b7 | ||
|
|
beb56b1a8b | ||
|
|
8d1b7a1e01 | ||
|
|
257092d107 | ||
|
|
b327103885 | ||
|
|
df9ad1fd27 | ||
|
|
2f01afd557 | ||
|
|
74fcd2e0ab | ||
|
|
4d333acbbc | ||
|
|
3d063b08a9 | ||
|
|
814e016965 | ||
|
|
0119365bd8 | ||
|
|
39066bc614 | ||
|
|
853b23cd14 | ||
|
|
cf3ccb0666 | ||
|
|
2f58724863 | ||
|
|
a3f4ad7111 | ||
|
|
0ed2981205 | ||
|
|
5762aaafba | ||
|
|
3294e54e00 | ||
|
|
2eddef3275 | ||
|
|
7bcd6623e9 | ||
|
|
82a942a2b1 | ||
|
|
805fa296c8 | ||
|
|
b8b063f325 | ||
|
|
882fc947e5 | ||
|
|
96137750a4 | ||
|
|
d10871c0e4 | ||
|
|
6d4c258d90 | ||
|
|
c312dd36ca | ||
|
|
bb595afde9 |
@@ -16,7 +16,7 @@
|
||||
# HERMES_WEBUI_PORT=8787
|
||||
|
||||
# Where to store sessions, workspaces, and other state (default: ~/.hermes/webui-mvp)
|
||||
# HERMES_WEBUI_STATE_DIR=~/.hermes/webui-mvp
|
||||
# HERMES_WEBUI_STATE_DIR=~/.hermes/webui
|
||||
|
||||
# Default workspace directory shown on first launch
|
||||
# HERMES_WEBUI_DEFAULT_WORKSPACE=~/workspace
|
||||
@@ -26,3 +26,6 @@
|
||||
|
||||
# Path to your Hermes config.yaml (for toolsets and model config)
|
||||
# HERMES_CONFIG_PATH=~/.hermes/config.yaml
|
||||
|
||||
# Display name for the assistant in the UI (default: Hermes)
|
||||
# HERMES_WEBUI_BOT_NAME=Hermes
|
||||
|
||||
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@@ -52,5 +52,6 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: HERMES_VERSION=${{ github.ref_name }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
30
.github/workflows/tests.yml
vendored
Normal file
30
.github/workflows/tests.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [master]
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.11', '3.12', '3.13']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pyyaml>=6.0 pytest pytest-timeout
|
||||
|
||||
- name: Run tests
|
||||
run: pytest tests/ -v --timeout=60
|
||||
23
.gitignore
vendored
23
.gitignore
vendored
@@ -16,12 +16,33 @@ archive/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.claude/*
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
.cursorrules
|
||||
.windsurfrules
|
||||
.aider*
|
||||
copilot-instructions.md
|
||||
|
||||
# Generated screenshots and transient artifacts
|
||||
screenshot-*.png
|
||||
full-UI.png
|
||||
|
||||
# Version file written by Docker/CI build — generated, never committed
|
||||
api/_version.py
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Local reference clones — never committed (except tracked design/UI-UX reference pages)
|
||||
docs/*
|
||||
!docs/ui-ux/
|
||||
!docs/ui-ux/**
|
||||
|
||||
# Local-only PR review harness: rendering drivers, sample bank, fixtures.
|
||||
# Used by Claude during deep reviews; never shared in the repo.
|
||||
.local-review/
|
||||
graphify-out/
|
||||
.graphify_cached.json
|
||||
.graphify_uncached.txt
|
||||
|
||||
53
AGENTS.md
53
AGENTS.md
@@ -1,53 +0,0 @@
|
||||
# Web UI MVP Instructions
|
||||
|
||||
Canonical source: <repo>/
|
||||
Symlink (for imports): <agent-dir>/webui-mvp -> <repo>
|
||||
Runtime state: ~/.hermes/webui-mvp/sessions/
|
||||
|
||||
Purpose:
|
||||
- Claude-style web UI for Hermes. Chat, workspace file browser, cron/skills/memory viewers.
|
||||
|
||||
Start server:
|
||||
cd <agent-dir>
|
||||
nohup venv/bin/python <repo>/server.py > /tmp/webui-mvp.log 2>&1 &
|
||||
# OR: <repo>/start.sh
|
||||
|
||||
Run tests:
|
||||
cd <agent-dir>
|
||||
venv/bin/python -m pytest <repo>/tests/ -v
|
||||
|
||||
Health check: curl http://127.0.0.1:8787/health
|
||||
Logs: tail -f /tmp/webui-mvp.log
|
||||
SSH tunnel from Mac: ssh -N -L 8787:127.0.0.1:8787 <user>@<your-server>
|
||||
|
||||
Living documents (always update after a sprint):
|
||||
<repo>/ROADMAP.md
|
||||
<repo>/ARCHITECTURE.md
|
||||
<repo>/TESTING.md
|
||||
|
||||
Sprint process skill: webui-sprint-loop
|
||||
|
||||
# Workspace Convention (Web UI Sessions)
|
||||
|
||||
When running as an agent invoked from the web UI, each user message is prefixed with:
|
||||
|
||||
[Workspace: /absolute/path/to/workspace]
|
||||
|
||||
This tag is the single authoritative source of the active workspace. It reflects
|
||||
whichever workspace the user has selected in the UI at the moment they sent that message.
|
||||
It updates on every message, so if the user switches workspaces mid-session, the very
|
||||
next message will carry the new path. Always use the value from the most recent tag.
|
||||
|
||||
This tag overrides any prior workspace mentioned in the system prompt, memory, or
|
||||
conversation history. Never infer or fall back to a hardcoded path like
|
||||
~/workspace when this tag is present.
|
||||
|
||||
Apply it as the default working directory for ALL file operations:
|
||||
|
||||
- write_file: resolve relative paths against this workspace
|
||||
- read_file / search_files: resolve paths relative to this workspace
|
||||
- terminal workdir: set to this path unless the user explicitly says otherwise
|
||||
- patch: resolve file paths relative to this workspace
|
||||
|
||||
If no [Workspace: ...] tag is present (e.g., CLI sessions), fall back to
|
||||
~/workspace as the default.
|
||||
@@ -7,20 +7,37 @@
|
||||
>
|
||||
> Keep this document updated as architecture changes are made.
|
||||
|
||||
> Current shipped build: `v0.50.245` (April 30, 2026).
|
||||
> Automated coverage: 3309 tests via `pytest tests/ --collect-only -q`. CI runs on Python 3.11, 3.12, and 3.13 against every PR.
|
||||
>
|
||||
> Notable architecture state as of v0.50.245: workspace panel closed/open state is preloaded via a `documentElement` dataset marker before `style.css` paints to avoid first-load flash; transcript disclosure cards animate via transitionable `max-height`/`opacity` states; thinking cards share rounded bordered card chrome with tool cards (gold palette); incremental streaming-markdown via vendored `streaming-markdown@0.2.15` (no CDN); HTTP byte-range streaming for large media; SSE-driven session sidebar with `pending_user_message` + `active_stream_id` lifecycle tracking; configurable model badges (`primary` / `fallback N`) computed in `_build_configured_model_badges()` and provider-aware in the dropdown picker.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview and Purpose
|
||||
|
||||
The Hermes Web UI is a lightweight web application that gives you a browser-based
|
||||
interface to the Hermes agent that is functionally equivalent to the CLI. It is modeled on
|
||||
the Claude-style interface: a three-panel layout with a sidebar for session management,
|
||||
a central chat area, and a right panel for workspace file browsing.
|
||||
the Claude-style interface: a sidebar for session management, a central chat area,
|
||||
and a demand-driven right panel used for workspace browsing and preview surfaces.
|
||||
The right panel is closed by default on desktop and opens only when it is actively
|
||||
being used for browsing or previewing content.
|
||||
|
||||
To prevent a visible first-paint mismatch on refresh, `static/index.html` preloads the
|
||||
saved workspace panel state into `document.documentElement.dataset.workspacePanel`
|
||||
before the main stylesheet loads. Desktop CSS honors that preload marker immediately,
|
||||
and `static/boot.js` keeps the dataset synchronized with the runtime panel state machine.
|
||||
|
||||
The design philosophy is deliberately minimal. There is no build step, no bundler, no
|
||||
frontend framework. The Python server is split into a routing shell (server.py) and
|
||||
business logic modules (api/). The frontend is seven vanilla JS modules loaded from static/.
|
||||
This makes the code easy to modify from a terminal or by an agent.
|
||||
|
||||
Hermes-level chrome is intentionally consolidated: the sidebar has no dedicated brand header.
|
||||
Instead, the footer exposes a single "Hermes WebUI" launch button that opens one tabbed
|
||||
control-center modal for global preferences, conversation import/export, and clear-conversation
|
||||
actions. The topbar remains focused on conversation context and the workspace/files toggle.
|
||||
|
||||
---
|
||||
|
||||
## 2. File Inventory
|
||||
@@ -28,7 +45,8 @@ This makes the code easy to modify from a terminal or by an agent.
|
||||
<repo>/
|
||||
server.py Thin routing shell + HTTP Handler + auth middleware. ~81 lines.
|
||||
Delegates all route handling to api/routes.py.
|
||||
start.sh Discovery script: finds agent dir, Python, starts server.
|
||||
bootstrap.py One-shot launcher: optional agent install, deps, health wait, browser open.
|
||||
start.sh Thin wrapper around bootstrap.py for shell-based startup.
|
||||
Dockerfile python:3.12-slim container image (~23 lines)
|
||||
docker-compose.yml Compose config with named volume and optional auth (~22 lines)
|
||||
.dockerignore Excludes .git, tests/, .env* from Docker builds
|
||||
@@ -39,7 +57,9 @@ This makes the code easy to modify from a terminal or by an agent.
|
||||
helpers.py HTTP helpers: j(), bad(), require(), safe_resolve(), security headers (~71 lines)
|
||||
models.py Session model + CRUD, per-session profile tracking (~137 lines)
|
||||
profiles.py Profile state management, hermes_cli wrapper (~246 lines)
|
||||
onboarding.py First-run onboarding status, real provider config writes, and readiness detection.
|
||||
routes.py All GET + POST route handlers (~1180 lines)
|
||||
startup.py Startup helpers: auto_install_agent_deps() (~50 lines)
|
||||
streaming.py SSE engine, run_agent, cancel, HERMES_HOME save/restore (~236 lines)
|
||||
upload.py Multipart parser, file upload handler (~78 lines)
|
||||
workspace.py File ops: list_dir, read_file_content, workspace helpers (~77 lines)
|
||||
@@ -48,11 +68,12 @@ This makes the code easy to modify from a terminal or by an agent.
|
||||
style.css All CSS incl. mobile responsive (~670 lines)
|
||||
ui.js DOM helpers, renderMd, tool cards, model dropdown, file tree (~977 lines)
|
||||
workspace.js File preview, file ops, loadDir, clearPreview (~185 lines)
|
||||
sessions.js Session CRUD, list rendering, search, SVG icons, overlay actions (~533 lines)
|
||||
sessions.js Session CRUD, list rendering, search, SVG icons, dropdown actions (~533 lines)
|
||||
messages.js send(), SSE event handlers, approval, transcript (~297 lines)
|
||||
panels.js Cron, skills, memory, workspace, profiles, todo, settings (~974 lines)
|
||||
commands.js Slash command registry, parser, autocomplete dropdown (~156 lines)
|
||||
boot.js Event wiring, mobile nav, voice input, boot IIFE (~338 lines)
|
||||
onboarding.js First-run wizard overlay, provider setup flow, and settings/workspace orchestration.
|
||||
boot.js Event wiring, mobile sidebar/workspace nav, voice input, boot IIFE (~338 lines)
|
||||
tests/
|
||||
conftest.py Isolated test server (port 8788, separate HERMES_HOME) (~240 lines)
|
||||
test_sprint{1-20b}.py Feature tests per sprint (21 files, 415 test functions)
|
||||
@@ -347,7 +368,7 @@ highlighting) and Mermaid.js (diagrams) from CDN, both loaded async/deferred wit
|
||||
Six JS modules loaded in order at end of <body>:
|
||||
1. ui.js (~846 lines) DOM helpers, renderMd, tool card rendering, global state
|
||||
2. workspace.js (~169 lines) File tree, preview, file operations
|
||||
3. sessions.js (~532 lines) Session CRUD, list rendering, search, SVG icons, overlay actions, project picker
|
||||
3. sessions.js (~532 lines) Session CRUD, list rendering, search, SVG icons, dropdown actions, project picker
|
||||
4. messages.js (~293 lines) send(), SSE event handlers, approval, transcript
|
||||
5. panels.js (~771 lines) Cron, skills, memory, workspace, todo, switchPanel
|
||||
6. boot.js (~175 lines) Event wiring + boot IIFE
|
||||
@@ -358,10 +379,19 @@ inherit `currentColor` for consistent theming.
|
||||
|
||||
Three-panel layout (in static/index.html):
|
||||
|
||||
<aside class="sidebar"> Left panel: session list, nav tabs, model selector
|
||||
<aside class="sidebar"> Left panel: session list, nav tabs, sidebar-footer Hermes WebUI trigger
|
||||
<main class="main"> Center: topbar, messages area, approval card, composer
|
||||
<aside class="rightpanel"> Right panel: workspace file tree and file preview
|
||||
|
||||
Composer footer layout (current):
|
||||
|
||||
left cluster attach button, mic button, per-conversation model selector
|
||||
right cluster compact circular context-usage badge, send button
|
||||
|
||||
The model selector is still the authoritative control for new-session creation
|
||||
and session updates; it was moved out of the sidebar so model choice feels scoped
|
||||
to the active conversation rather than a global app setting.
|
||||
|
||||
### 5.2 Global State
|
||||
|
||||
const S = {
|
||||
@@ -406,11 +436,19 @@ Approval:
|
||||
stopApprovalPolling clearInterval
|
||||
|
||||
UI helpers:
|
||||
setStatus(t) Updates #statusText in composer footer
|
||||
setStatus(t) Fallback helper: shows a toast for non-chat status/error messages
|
||||
setComposerStatus(t) Updates the inline composer status label for turn-scoped states
|
||||
setBusy(v) Sets S.busy, disables/enables Send button, clears status on false
|
||||
showToast(msg, ms) Bottom-center fade toast (default 2800ms)
|
||||
showConfirmDialog(o) Shared in-app confirmation modal, resolves true/false
|
||||
showPromptDialog(o) Shared in-app input modal, resolves string/null
|
||||
autoResize() Auto-resize #msg textarea up to 200px
|
||||
|
||||
Dialog policy:
|
||||
Native browser confirm()/prompt() are not used in the Web UI.
|
||||
Destructive actions use showConfirmDialog(...), then a toast on success.
|
||||
Lightweight naming flows (new file/folder/project) use showPromptDialog(...).
|
||||
|
||||
Files:
|
||||
loadDir(path) GET /api/list, rebuild #fileTree
|
||||
openFile(path) GET /api/file, show in #previewArea
|
||||
@@ -463,7 +501,7 @@ Known gaps:
|
||||
- Nested lists: single regex pass, multi-level indentation not handled
|
||||
- Mixed bold+link in same line: may produce garbled output
|
||||
|
||||
### 5.5 Model Chip Label (Fixed in Sprint 1)
|
||||
### 5.5 Model Label Resolution (Fixed in Sprint 1, reused by composer selector)
|
||||
|
||||
B3 was resolved in Sprint 1. Current code uses a MODEL_LABELS dict:
|
||||
|
||||
@@ -474,10 +512,10 @@ B3 was resolved in Sprint 1. Current code uses a MODEL_LABELS dict:
|
||||
'anthropic/claude-haiku-3-5': 'Haiku 3.5', 'google/gemini-2.5-pro': 'Gemini 2.5 Pro',
|
||||
'deepseek/deepseek-chat-v3-0324': 'DeepSeek V3', 'meta-llama/llama-4-scout': 'Llama 4 Scout',
|
||||
};
|
||||
$('modelChip').textContent = MODEL_LABELS[m] || (m.split('/').pop() || 'Unknown');
|
||||
getModelLabel(m) => MODEL_LABELS[m] || (m.split('/').pop() || 'Unknown');
|
||||
|
||||
Fallback: any unlisted model shows its short ID (after the last /) rather than a wrong label.
|
||||
To add a new model: add an entry to MODEL_LABELS and add an <option> to the <select>.
|
||||
To add a new model: add an entry to MODEL_LABELS and add an <option> to the composer footer <select>.
|
||||
|
||||
### 5.6 Session Delete Rules (from skill)
|
||||
|
||||
@@ -1095,7 +1133,7 @@ The model chip label bug is now fixed. The MODEL_LABELS object in syncTopbar():
|
||||
'deepseek/deepseek-chat-v3-0324': 'DeepSeek V3',
|
||||
'meta-llama/llama-4-scout': 'Llama 4 Scout',
|
||||
};
|
||||
$('modelChip').textContent = MODEL_LABELS[m] || (m.split('/').pop() || 'Unknown');
|
||||
getModelLabel(m) => MODEL_LABELS[m] || (m.split('/').pop() || 'Unknown');
|
||||
|
||||
Fallback: splits on '/' and uses the last segment, so any unlisted model shows its
|
||||
short identifier rather than a wrong hardcoded label.
|
||||
@@ -1586,3 +1624,19 @@ and #rightpanelResize. On mousemove: computes delta and clamps to min/max. On mo
|
||||
saves width to localStorage. Widths restored at boot via localStorage.getItem().
|
||||
CSS: .resize-handle with position:absolute, width:5px, cursor:col-resize.
|
||||
body.resizing added during drag to suppress text selection.
|
||||
|
||||
|
||||
## Workspace path trust levels
|
||||
|
||||
`api/workspace.py` has two distinct trust functions — do not collapse them:
|
||||
|
||||
**`validate_workspace_to_add(path)`** — used by `/api/workspaces/add` (explicit user registration).
|
||||
Permissive: blocks only non-existent, non-directory, and system root paths. The user is
|
||||
consciously registering an external path (e.g. `/mnt/d/Projects` in WSL), so we trust intent.
|
||||
|
||||
**`resolve_trusted_workspace(path)`** — used for actual file read/write operations inside
|
||||
an existing workspace. Strict: path must be under home, in the saved workspace list, or under
|
||||
`BOOT_DEFAULT_WORKSPACE`. Prevents path traversal and unauthorized file access.
|
||||
|
||||
The distinction matters because add uses permissive validation to avoid the circular
|
||||
dependency: you cannot get a path into the saved list if you need the saved list to add it.
|
||||
|
||||
12
BUGS.md
12
BUGS.md
@@ -10,6 +10,18 @@ This file tracks UI bugs and polish items. Fixed items are kept for reference.
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- **Two-container Docker setup: tools run in WebUI container** — In the two-container setup (hermes-agent + hermes-webui as separate containers), WebUI-initiated agent sessions run tools in the WebUI container, not the agent container. This is a known architectural constraint. Workaround: use the combined single-image approach, or initiate sessions via the CLI in the agent container. (#681)
|
||||
|
||||
- **Image-in-chat vs. saved-to-workspace mismatch** — When the agent displays an inline image (from a URL) and the user asks it to save that image, the agent issues a fresh download which may return a different file if the source URL is CDN-rotated or parameterized. The WebUI correctly renders whatever URL the agent provides. Fix requires agent-side URL caching. (#641)
|
||||
|
||||
- **MCP tools not available in WebUI sessions** — MCP servers must be configured in the active profile's config.yaml under mcp_servers:. If MCP tools are not appearing, check that the profile is correct and the MCP server process is reachable from inside the WebUI container. (#628)
|
||||
|
||||
- **os.environ race condition in concurrent sessions** — Concurrent agent sessions share process-level os.environ for TERMINAL_CWD, HERMES_SESSION_KEY, and HERMES_HOME. _ENV_LOCK serializes mutations but does not fully isolate env vars during agent execution. Upstream fix pending in hermes-agent. (#195)
|
||||
|
||||
---
|
||||
|
||||
## Fixed
|
||||
|
||||
### ~~Session title truncation / hover actions~~ -- Fixed (Sprint 16)
|
||||
|
||||
3667
CHANGELOG.md
3667
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
171
CONTRIBUTING.md
Normal file
171
CONTRIBUTING.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# Contributing to Hermes WebUI
|
||||
|
||||
Thanks for contributing.
|
||||
|
||||
Hermes WebUI is intentionally simple to work on: Python on the server, vanilla JS in the browser, no build step, no bundler, no frontend framework. The best pull requests preserve that simplicity while solving a real problem cleanly.
|
||||
|
||||
## Two Paths to a Strong Pull Request
|
||||
|
||||
### Path 1: Small, Focused Changes
|
||||
|
||||
This is the fastest path to review and merge.
|
||||
|
||||
- Fix one clear bug or add one tightly scoped improvement
|
||||
- Touch the fewest files you can
|
||||
- Avoid drive-by refactors mixed into functional changes
|
||||
- Run the relevant tests locally before opening the PR
|
||||
- Keep the PR description concise and specific
|
||||
|
||||
These are the changes that are easiest to review and safest to merge quickly.
|
||||
|
||||
### Path 2: Bigger Changes
|
||||
|
||||
If you want to change architecture, reshape a workflow, add a substantial UI feature, or alter core behavior, align on direction first.
|
||||
|
||||
- Open an issue, start a discussion, or open a draft PR early
|
||||
- Explain the problem you are solving, not just the implementation you want
|
||||
- Call out tradeoffs, migration risk, and any alternatives you considered
|
||||
- Keep the final PR easy to review by separating unrelated work
|
||||
|
||||
Large changes are welcome, but surprise rewrites are hard to review well.
|
||||
|
||||
## What We Expect in Every PR
|
||||
|
||||
### 1. One Logical Change Per PR
|
||||
|
||||
Keep each PR focused. A small related group of fixes is fine. A bug fix plus a CSS cleanup plus a refactor plus a docs rewrite is not.
|
||||
|
||||
### 2. Local Verification
|
||||
|
||||
Run the test suite locally:
|
||||
|
||||
```bash
|
||||
pytest tests/ -v --timeout=60
|
||||
```
|
||||
|
||||
CI also runs this suite on Python `3.11`, `3.12`, and `3.13`.
|
||||
|
||||
If your change affects browser behavior, also run the relevant manual checks from [TESTING.md](TESTING.md).
|
||||
|
||||
### 3. Clear PR Description
|
||||
|
||||
There is currently no PR template in this repo, so include the important sections yourself:
|
||||
|
||||
- Thinking Path
|
||||
- What Changed
|
||||
- Why It Matters
|
||||
- Verification
|
||||
- Risks / Follow-ups
|
||||
- Model Used
|
||||
|
||||
If the change is user-visible, include screenshots or a short video.
|
||||
|
||||
For UI or UX changes, before/after images are required. PRs that change the interface or interaction flow without before/after images will likely be ignored, or closed in a regular maintainer sweep without review.
|
||||
|
||||
### 4. AI Usage Disclosure
|
||||
|
||||
If AI helped produce the change, say so in the PR description.
|
||||
|
||||
Include:
|
||||
|
||||
- Provider
|
||||
- Exact model name or ID
|
||||
- Any notable mode or tool use that mattered
|
||||
|
||||
If no AI was used, write: `None — human-authored`.
|
||||
|
||||
### 5. Keep the Docs Honest
|
||||
|
||||
If your change alters behavior, architecture, testing, setup, or user-facing workflows, update the relevant docs in the same PR.
|
||||
|
||||
Common files:
|
||||
|
||||
- [README.md](README.md) for setup, usage, and contributor-facing commands
|
||||
- [ROADMAP.md](ROADMAP.md) for shipped features and sprint history
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) for implementation details and design constraints
|
||||
- [TESTING.md](TESTING.md) for manual and automated verification guidance
|
||||
- [CHANGELOG.md](CHANGELOG.md) when maintainers want release-note-ready entries
|
||||
|
||||
## Project-Specific Guidelines
|
||||
|
||||
### Preserve the Design Constraints
|
||||
|
||||
Hermes WebUI is deliberately:
|
||||
|
||||
- No build step
|
||||
- No bundler
|
||||
- No frontend framework
|
||||
- Easy to modify from a terminal
|
||||
|
||||
Do not introduce new infrastructure or dependencies unless the gain is clear and the tradeoff is justified.
|
||||
|
||||
### Match the Existing Shape of the Codebase
|
||||
|
||||
- Server logic belongs in `api/` with `server.py` staying thin
|
||||
- Frontend behavior belongs in the existing `static/*.js` modules
|
||||
- Prefer extending current patterns over introducing parallel abstractions
|
||||
- Keep changes legible to future contributors working directly from the repo in a terminal
|
||||
|
||||
### Be Careful With User-Facing Changes
|
||||
|
||||
This project is heavily UI-driven. If you change interaction flows, session behavior, workspace browsing, onboarding, or mobile layouts:
|
||||
|
||||
- test the happy path
|
||||
- test reload behavior where relevant
|
||||
- test narrow/mobile layouts where relevant
|
||||
- include before/after images in the PR
|
||||
|
||||
### Security and Safety Matter
|
||||
|
||||
This app can expose workspace contents, run agent actions, and optionally sit behind a reverse proxy or Docker deployment. Treat auth, path handling, uploads, streaming, and environment handling as high-risk areas.
|
||||
|
||||
If your PR touches security-sensitive behavior, say so explicitly in the PR description and explain how you verified it.
|
||||
|
||||
## Writing a Good PR Message
|
||||
|
||||
Start with a short Thinking Path that explains the chain from project goal to the specific fix.
|
||||
|
||||
Example:
|
||||
|
||||
> - Hermes WebUI aims for near 1:1 parity with the Hermes CLI in a browser
|
||||
> - Long-running chat turns rely on SSE streaming and session recovery
|
||||
> - Reloading during an in-flight turn can leave the UI in an inconsistent state
|
||||
> - The bug was that recovered sessions restored messages but not the live stream state
|
||||
> - This PR fixes the recovery path so in-flight turns reconnect cleanly after reload
|
||||
> - The benefit is that users can refresh or reconnect without losing visibility into active work
|
||||
|
||||
Another example:
|
||||
|
||||
> - Hermes WebUI is intentionally a simple Python + vanilla JS application
|
||||
> - The right panel is used for workspace browsing and previews
|
||||
> - On mobile, panel state changes need to be obvious and touch-friendly
|
||||
> - The existing close affordance was inconsistent with the bottom-nav flow
|
||||
> - This PR fixes the mobile panel close behavior and aligns it with the current navigation model
|
||||
> - The result is fewer dead-end UI states on phones
|
||||
|
||||
After that, cover:
|
||||
|
||||
- what you changed
|
||||
- why you changed it
|
||||
- how you verified it
|
||||
- what risks remain
|
||||
|
||||
## Review Tips
|
||||
|
||||
Want the smoothest review?
|
||||
|
||||
- Keep diffs tight
|
||||
- Name things clearly
|
||||
- Avoid unnecessary rewrites
|
||||
- Add short comments only where the code would otherwise be hard to follow
|
||||
- Respond directly to review feedback and update the PR description if the scope changes
|
||||
|
||||
## Development References
|
||||
|
||||
- [README.md](README.md)
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md)
|
||||
- [TESTING.md](TESTING.md)
|
||||
- [ROADMAP.md](ROADMAP.md)
|
||||
- [SPRINTS.md](SPRINTS.md)
|
||||
|
||||
Questions are best raised early, before a large change is finished.
|
||||
61
CONTRIBUTORS.md
Normal file
61
CONTRIBUTORS.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Contributors
|
||||
|
||||
Hermes WebUI is a community project. **66 people** have shipped code that landed in a release tag, including the long tail of folks whose work was salvaged into batch releases. This file is the canonical credit roll. Numbers are merged-PR count plus release-batch credit (a contributor whose patch was extracted into a clean PR or merged via squash gets the same credit as a standalone PR).
|
||||
|
||||
**Total contributors tracked:** 66
|
||||
**Total PRs landed:** 142
|
||||
**Last refreshed:** v0.50.245, 2026-04-30
|
||||
|
||||
Generated from `git log` + `gh api repos/.../pulls?state=closed` + the `CHANGELOG.md` attribution lines. If your name is missing or wrong, open a PR against `CONTRIBUTORS.md` — we cross-check against the changelog on each release.
|
||||
|
||||
---
|
||||
|
||||
## Top contributors (5+ merged PRs)
|
||||
|
||||
| # | Contributor | PRs | First release | Latest release |
|
||||
|---|---|---:|---|---|
|
||||
| 1 | [@franksong2702](https://github.com/franksong2702) | 22 | `v0.50.49` 2026-04-15 | `v0.50.245` 2026-04-30 |
|
||||
| 2 | [@bergeouss](https://github.com/bergeouss) | 18 | `v0.50.49` 2026-04-15 | `v0.50.240` 2026-04-30 |
|
||||
| 3 | [@aronprins](https://github.com/aronprins) | 8 | `v0.47.0` 2026-04-11 | `v0.50.77` 2026-04-17 |
|
||||
| 4 | [@iRonin](https://github.com/iRonin) | 6 | `v0.41.0` 2026-04-10 | `v0.41.0` 2026-04-10 |
|
||||
| 5 | [@24601](https://github.com/24601) | 6 | `v0.50.201` 2026-04-28 | `v0.50.201` 2026-04-28 |
|
||||
|
||||
## Sustained contributors (3–4 merged PRs)
|
||||
|
||||
| Contributor | PRs | Highlights |
|
||||
|---|---:|---|
|
||||
| [@renheqiang](https://github.com/renheqiang) | 4 | feat: add full Russian (ru-RU) localization — v0.50.93 |
|
||||
| [@KingBoyAndGirl](https://github.com/KingBoyAndGirl) | 4 | fix: trust custom provider base_url in SSRF validation; fix: fetch live models for custom provider from model.base_u |
|
||||
| [@ccqqlo](https://github.com/ccqqlo) | 3 | `v0.50.83` batch credit |
|
||||
| [@deboste](https://github.com/deboste) | 3 | fix(frontend): use URL origin for fetch/EventSource to suppo; fix(api): resolve model provider from config to prevent misr |
|
||||
| [@frap129](https://github.com/frap129) | 3 | fix(docker): Install Open SSH client; fix(docker): Install all dependencies for agent |
|
||||
|
||||
## Two-PR contributors
|
||||
|
||||
[@dso2ng](https://github.com/dso2ng), [@Michaelyklam](https://github.com/Michaelyklam), [@mmartial](https://github.com/mmartial), [@renatomott](https://github.com/renatomott), [@zichen0116](https://github.com/zichen0116), [@pavolbiely](https://github.com/pavolbiely), [@bsgdigital](https://github.com/bsgdigital), [@vansour](https://github.com/vansour), [@fecolinhares](https://github.com/fecolinhares).
|
||||
|
||||
## Single-PR contributors
|
||||
|
||||
Each of these folks landed exactly one merged change — bug fixes, locale work, doc improvements, infrastructure tweaks. Every one of them moved the project forward.
|
||||
|
||||
[@Argonaut790](https://github.com/Argonaut790), [@betamod](https://github.com/betamod), [@bschmidy10](https://github.com/bschmidy10), [@carlytwozero](https://github.com/carlytwozero), [@cloudyun888](https://github.com/cloudyun888), [@davidsben](https://github.com/davidsben), [@DavidSchuchert](https://github.com/DavidSchuchert), [@DrMaks22](https://github.com/DrMaks22), [@eba8](https://github.com/eba8), [@fxd-jason](https://github.com/fxd-jason), [@gabogabucho](https://github.com/gabogabucho), [@GiggleSamurai](https://github.com/GiggleSamurai), [@hacker2005](https://github.com/hacker2005), [@halmisen](https://github.com/halmisen), [@happy5318](https://github.com/happy5318), [@hi-friday](https://github.com/hi-friday), [@Hinotoi-agent](https://github.com/Hinotoi-agent), [@huangzt](https://github.com/huangzt), [@jeffscottward](https://github.com/jeffscottward), [@JKJameson](https://github.com/JKJameson), [@KayZz69](https://github.com/KayZz69), [@kcclaw001](https://github.com/kcclaw001), [@kevin-ho](https://github.com/kevin-ho), [@mangodxd](https://github.com/mangodxd), [@mariosam95](https://github.com/mariosam95), [@MatzAgent](https://github.com/MatzAgent), [@mbac](https://github.com/mbac), [@migueltavares](https://github.com/migueltavares), [@nickgiulioni1](https://github.com/nickgiulioni1), [@octo-patch](https://github.com/octo-patch), [@qxxaa](https://github.com/qxxaa), [@ruxme](https://github.com/ruxme), [@SaulgoodMan-C](https://github.com/SaulgoodMan-C), [@smurmann](https://github.com/smurmann), [@Stampede](https://github.com/Stampede), [@starship-s](https://github.com/starship-s), [@suinia](https://github.com/suinia), [@TaraTheStar](https://github.com/TaraTheStar), [@tgaalman](https://github.com/tgaalman), [@thadreber-web](https://github.com/thadreber-web), [@the-own-lab](https://github.com/the-own-lab), [@vcavichini](https://github.com/vcavichini), [@vCillusion](https://github.com/vCillusion), [@woaijiadanoo](https://github.com/woaijiadanoo), [@xingyue52077](https://github.com/xingyue52077), [@yunyunyunyun-yun](https://github.com/yunyunyunyun-yun), [@yzp12138](https://github.com/yzp12138).
|
||||
|
||||
---
|
||||
|
||||
## How credit is tracked
|
||||
|
||||
Most PRs in this repo land via one of three paths:
|
||||
|
||||
1. **Direct merge** — your PR is reviewed and merged on its own. Author shows up directly in `git log`.
|
||||
2. **Squash into a batch release** — your PR is merged together with several other contributor PRs into a single release commit (e.g. `release: v0.50.245 — 10-PR batch`). The squashed commit carries a `Co-authored-by: <you>` trailer plus an entry in `CHANGELOG.md` crediting you by username and PR number.
|
||||
3. **Salvaged from a larger PR** — when a PR mixes one good change with several unrelated or risky ones, we sometimes split it: the good parts ship in a clean follow-up PR, you get credit in the CHANGELOG entry, and the original PR is closed with a salvage map showing what went where.
|
||||
|
||||
All three paths count as a contribution. The number next to your name above is the total of merged PRs (path 1) plus PRs where you got attribution credit in CHANGELOG.md (paths 2 and 3).
|
||||
|
||||
## Special thanks
|
||||
|
||||
- **[@aronprins](https://github.com/aronprins)** — `v0.50.0` UI overhaul (PR #242). The CSS-only redesign that defined the design tokens, theme architecture, and three-panel layout that the rest of the app builds on. The PR didn't merge as-is — it was reshaped through `v0.50.0` — but it is the design language of the app.
|
||||
- **[@franksong2702](https://github.com/franksong2702)** — most prolific external contributor. Mobile/responsive layout, session sidebar polish, cron output preservation, streaming-session sidebar exemption, and a long tail of profile/workspace fixes.
|
||||
- **[@bergeouss](https://github.com/bergeouss)** — provider-management UI, OAuth status, two-container Docker docs, profile isolation hardening. Most of what users see when they touch Settings → Providers is bergeouss's work.
|
||||
|
||||
If you've contributed and aren't here, **open a PR**. We cross-check the CHANGELOG, but if a credit fell through (a Co-authored-by trailer that didn't make it into the changelog entry, an attribution in a comment that should be on the PR), this list is the right place to fix it.
|
||||
173
DESIGN.md
Normal file
173
DESIGN.md
Normal file
@@ -0,0 +1,173 @@
|
||||
---
|
||||
version: alpha
|
||||
name: Hermes Calm Console
|
||||
description: "A restrained agent control surface: conversational content first, tool traces as quiet metadata, minimal chrome."
|
||||
colors:
|
||||
primary: "#EAE0D5"
|
||||
secondary: "#C6AC8F"
|
||||
tertiary: "#C6AC8F"
|
||||
neutral: "#0A0908"
|
||||
surface: "#22333B"
|
||||
surfaceSubtle: "#11100E"
|
||||
borderSubtle: "#3B4A50"
|
||||
ink: "#0A0908"
|
||||
success: "#86C08B"
|
||||
warning: "#E0B15D"
|
||||
error: "#F87171"
|
||||
typography:
|
||||
body-md:
|
||||
fontFamily: "Georgia, Times New Roman, serif"
|
||||
fontSize: 15px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.68
|
||||
body-sm:
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, Segoe UI, Inter, system-ui, sans-serif"
|
||||
fontSize: 12px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.45
|
||||
user-message:
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, Segoe UI, Inter, system-ui, sans-serif"
|
||||
fontSize: 14px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.55
|
||||
mono-xs:
|
||||
fontFamily: "SF Mono, ui-monospace, monospace"
|
||||
fontSize: 11px
|
||||
fontWeight: 500
|
||||
lineHeight: 1.55
|
||||
rounded:
|
||||
sm: 4px
|
||||
md: 8px
|
||||
lg: 12px
|
||||
pill: 999px
|
||||
spacing:
|
||||
xs: 4px
|
||||
sm: 8px
|
||||
md: 12px
|
||||
lg: 16px
|
||||
components:
|
||||
app-shell:
|
||||
backgroundColor: "{colors.neutral}"
|
||||
textColor: "{colors.primary}"
|
||||
rounded: "{rounded.sm}"
|
||||
padding: 16px
|
||||
panel:
|
||||
backgroundColor: "{colors.surface}"
|
||||
textColor: "{colors.primary}"
|
||||
rounded: "{rounded.lg}"
|
||||
padding: 16px
|
||||
border-line:
|
||||
backgroundColor: "{colors.borderSubtle}"
|
||||
textColor: "{colors.primary}"
|
||||
rounded: "{rounded.sm}"
|
||||
padding: 4px
|
||||
state-success:
|
||||
backgroundColor: "{colors.success}"
|
||||
textColor: "{colors.ink}"
|
||||
rounded: "{rounded.sm}"
|
||||
padding: 4px
|
||||
state-warning:
|
||||
backgroundColor: "{colors.warning}"
|
||||
textColor: "{colors.ink}"
|
||||
rounded: "{rounded.sm}"
|
||||
padding: 4px
|
||||
state-error:
|
||||
backgroundColor: "{colors.error}"
|
||||
textColor: "{colors.ink}"
|
||||
rounded: "{rounded.sm}"
|
||||
padding: 4px
|
||||
tool-call-group:
|
||||
backgroundColor: "{colors.neutral}"
|
||||
textColor: "{colors.secondary}"
|
||||
rounded: "{rounded.md}"
|
||||
padding: 4px
|
||||
tool-card:
|
||||
backgroundColor: "{colors.surfaceSubtle}"
|
||||
textColor: "{colors.secondary}"
|
||||
rounded: "{rounded.md}"
|
||||
padding: 8px
|
||||
user-message:
|
||||
backgroundColor: "{colors.tertiary}"
|
||||
textColor: "{colors.ink}"
|
||||
rounded: "{rounded.lg}"
|
||||
padding: 12px
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Hermes WebUI should feel like a calm developer console, not a demo page assembled from colorful cards. The primary artifact is the conversation. Tool calls, thinking traces, context compaction records, token usage, and runtime status are useful, but they are transcript metadata and should sit below the visual priority of user and assistant prose.
|
||||
|
||||
The desired direction is Linear/Vercel precision with a little Claude-style conversational warmth: quiet surfaces, clear spacing, restrained accent use, and progressive disclosure for debugging detail.
|
||||
|
||||
## Colors
|
||||
|
||||
- **Primary (#EAE0D5):** main text on dark surfaces. The warm parchment should feel readable and grounded, not like bright white terminal text.
|
||||
- **Secondary/Tertiary (#C6AC8F):** metadata and restrained accent. Use sparingly for active state, focus, user bubbles, and quiet emphasis.
|
||||
- **Neutral (#0A0908):** app background and ink. This gives the WebUI depth without returning to the previous navy/gold theme.
|
||||
- **Surface (#22333B):** panels, sidebar, and stronger interactive surfaces. It should carry the structure while the conversation remains primary.
|
||||
- **Light surfaces (#EAE0D5 / #F4EEE7):** light mode uses the palette's parchment as the field and a slightly lifted derived surface for panels.
|
||||
- **Semantic colors:** success/warning/error/info are state colors only, not decorative palette choices.
|
||||
|
||||
## Typography
|
||||
|
||||
Use Claude-like split typography: assistant prose gets an editorial serif stack (Georgia as the available substitute for Anthropic Serif), while user bubbles and functional UI stay in a crisp sans stack. This keeps the bot voice calmer and more readable without making controls feel bookish. Use monospace only for code, file paths, commands, tool names, and compact metadata. Avoid making whole cards feel like terminal output unless they actually are logs.
|
||||
|
||||
Scale should stay tight: 11px metadata, 12px labels, 14px body, 16–18px headings. Do not proliferate 10px/10.5px/12.5px one-offs unless there is a real layout constraint.
|
||||
|
||||
## Layout
|
||||
|
||||
Conversation rhythm:
|
||||
|
||||
1. User message — right aligned, compact bubble.
|
||||
2. Assistant content — left aligned, prose-first, no heavy bubble.
|
||||
3. Tool/thinking/context traces — quiet disclosure rows inside the assistant turn.
|
||||
4. Raw logs/details — hidden until explicitly expanded.
|
||||
|
||||
Metadata should not break the reading flow. A turn that used ten tools should read as one assistant turn with one compact `Used 10 tools` disclosure, not ten content cards.
|
||||
|
||||
## Elevation & Depth
|
||||
|
||||
Use almost no shadows in the transcript. Shadows are reserved for popovers, dropdowns, modal dialogs, and floating controls. Cards inside chat should use either a subtle border or a subtle tint, not both aggressively.
|
||||
|
||||
## Shapes
|
||||
|
||||
- Rows/list items: `4–8px` radius.
|
||||
- Cards/panels: `8–12px` radius.
|
||||
- Pills: only true chips/badges use `999px`.
|
||||
- Avoid stacks of nested rounded rectangles. If a card contains another card, one of them is probably unnecessary.
|
||||
|
||||
## Components
|
||||
|
||||
### Tool/thinking activity group
|
||||
|
||||
Collapsed by default in settled history and during live runs. Summary line uses one disclosure for internals, e.g. `Activity: thinking + 4 tools · read_file, patch, terminal`. Expanding reveals thinking and individual tool cards together. Thinking and tools should not create separate transcript rows unless there is an error or approval state that needs attention.
|
||||
|
||||
### Tool card
|
||||
|
||||
A tool card is a debug event row, not a chat message. Show icon, name, short target/preview, and status. Arguments and result snippets stay behind expansion. Result snippets should be truncated; full logs belong behind “show more”.
|
||||
|
||||
### Thinking/context cards
|
||||
|
||||
Same visual family as tool-call metadata. They should be quieter than assistant prose and should not use bright tinted full cards unless the user expands them.
|
||||
|
||||
### Composer
|
||||
|
||||
The composer is the command surface. Keep it legible and focused: modest radius, subtle border, transparent inactive chips, no theatrical hover scaling.
|
||||
|
||||
## Do's and Don'ts
|
||||
|
||||
Do:
|
||||
|
||||
- Collapse noisy agent internals by default.
|
||||
- Use one accent color at a time.
|
||||
- Prefer neutral borders and restrained surfaces.
|
||||
- Make debug traces accessible and inspectable without making them visually dominant.
|
||||
- Add stable class/data hooks for future visual regression tests.
|
||||
|
||||
Don't:
|
||||
|
||||
- Render every tool call as a first-class chat card.
|
||||
- Mix gold, cyan, purple, orange, red, and green as decorative colors in the same viewport.
|
||||
- Add new hardcoded radius/color values when a token exists.
|
||||
- Use shadows, gradients, and hover transforms for routine controls.
|
||||
- Hide important error or approval states; those are allowed to be prominent because they require action.
|
||||
94
Dockerfile
94
Dockerfile
@@ -3,21 +3,97 @@ FROM python:3.12-slim
|
||||
LABEL maintainer="nesquena"
|
||||
LABEL description="Hermes Web UI — browser interface for Hermes Agent"
|
||||
|
||||
WORKDIR /app
|
||||
# Install system packages
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Copy source
|
||||
COPY . /app
|
||||
# Make use of apt-cacher-ng if available
|
||||
RUN if [ "A${BUILD_APT_PROXY:-}" != "A" ]; then \
|
||||
echo "Using APT proxy: ${BUILD_APT_PROXY}"; \
|
||||
printf 'Acquire::http::Proxy "%s";\n' "$BUILD_APT_PROXY" > /etc/apt/apt.conf.d/01proxy; \
|
||||
fi \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates wget gnupg \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& apt-get clean
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN apt-get update -y --fix-missing --no-install-recommends \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
apt-utils \
|
||||
locales \
|
||||
ca-certificates \
|
||||
sudo \
|
||||
curl \
|
||||
rsync \
|
||||
openssh-client \
|
||||
&& apt-get upgrade -y \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# UTF-8
|
||||
RUN localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8
|
||||
ENV LANG=en_US.utf8
|
||||
ENV LC_ALL=C
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONIOENCODING=utf-8
|
||||
|
||||
WORKDIR /apptoo
|
||||
|
||||
# Every sudo group user does not need a password
|
||||
RUN echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
|
||||
|
||||
# Create a new group for the hermeswebui and hermeswebuitoo users
|
||||
RUN groupadd -g 1024 hermeswebui \
|
||||
&& groupadd -g 1025 hermeswebuitoo
|
||||
|
||||
# The hermeswebui (resp. hermeswebuitoo) user will have UID 1024 (resp. 1025),
|
||||
# be part of the hermeswebui (resp. hermeswebuitoo) and users groups and be sudo capable (passwordless)
|
||||
RUN useradd -u 1024 -d /home/hermeswebui -g hermeswebui -s /bin/bash -m hermeswebui \
|
||||
&& usermod -G users hermeswebui \
|
||||
&& adduser hermeswebui sudo
|
||||
RUN useradd -u 1025 -d /home/hermeswebuitoo -g hermeswebuitoo -s /bin/bash -m hermeswebuitoo \
|
||||
&& usermod -G users hermeswebuitoo \
|
||||
&& adduser hermeswebuitoo sudo
|
||||
RUN chown -R hermeswebuitoo:hermeswebuitoo /apptoo
|
||||
|
||||
USER root
|
||||
|
||||
COPY --chmod=555 docker_init.bash /hermeswebui_init.bash
|
||||
|
||||
RUN touch /.within_container
|
||||
|
||||
# Remove APT proxy configuration and clean up APT downloaded files
|
||||
RUN rm -rf /var/lib/apt/lists/* /etc/apt/apt.conf.d/01proxy \
|
||||
&& apt-get clean
|
||||
|
||||
USER root
|
||||
|
||||
# Pre-install uv system-wide so the container doesn't need internet access at runtime.
|
||||
# Installing as root places uv in /usr/local/bin, available to all users.
|
||||
# The init script will skip the download when uv is already on PATH.
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh
|
||||
|
||||
USER hermeswebuitoo
|
||||
|
||||
COPY --chown=hermeswebuitoo:hermeswebuitoo . /apptoo
|
||||
|
||||
# Bake the git version tag into the image so the settings badge works even
|
||||
# when .git is not present (it is excluded by .dockerignore).
|
||||
# CI passes: --build-arg HERMES_VERSION=$(git describe --tags --always)
|
||||
# Local builds that omit the arg get "unknown" as the fallback.
|
||||
ARG HERMES_VERSION=unknown
|
||||
RUN echo "__version__ = '${HERMES_VERSION}'" > /apptoo/api/_version.py
|
||||
|
||||
# Default to binding all interfaces (required for container networking)
|
||||
ENV HERMES_WEBUI_HOST=0.0.0.0
|
||||
ENV HERMES_WEBUI_PORT=8787
|
||||
|
||||
# State directory (mount as volume for persistence)
|
||||
ENV HERMES_WEBUI_STATE_DIR=/data
|
||||
|
||||
EXPOSE 8787
|
||||
|
||||
CMD ["python", "server.py"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:8787/health || exit 1
|
||||
|
||||
CMD ["/hermeswebui_init.bash"]
|
||||
|
||||
|
||||
552
HERMES.md
552
HERMES.md
@@ -1,165 +1,176 @@
|
||||
# Why Hermes
|
||||
|
||||
Hermes is a persistent, autonomous AI agent that lives on your server. It remembers everything,
|
||||
schedules work while you sleep, and gets more capable the longer it runs. This document explains
|
||||
the mental model, why that matters, and how Hermes compares to every major AI tool available today.
|
||||
Hermes is a persistent, autonomous AI agent that runs on your server. It has layered memory that
|
||||
accumulates across sessions, a cron scheduler that fires jobs while you're offline, and a
|
||||
self-improving skills system that saves reusable procedures automatically. You reach it from a
|
||||
terminal, a browser, or a messaging app — and it's the same agent with the same history every time.
|
||||
|
||||
This document explains the mental model, how Hermes compares to other tools honestly, and where
|
||||
it is and is not the right choice.
|
||||
|
||||
---
|
||||
|
||||
## The Core Idea: Assistants Forget. Agents Don't.
|
||||
## The real problem: most tools are excellent in the moment and weak over time
|
||||
|
||||
Every time you open Claude Code, Codex, or a chat window, the tool starts from zero. It does not
|
||||
know who you are, what you worked on yesterday, how your repo is structured, or what bugs you
|
||||
already fixed. You re-explain yourself every single session. The tool is powerful in the moment
|
||||
and useless the next day.
|
||||
Memory is no longer a differentiator on its own. ChatGPT, Claude, Cursor, and GitHub Copilot all
|
||||
have some form of memory now. Anthropic, OpenAI, and Microsoft are all shipping scheduling and
|
||||
agent features. The category boundaries that existed twelve months ago are blurring fast.
|
||||
|
||||
Hermes fills that gap. It runs on your server, retains context across every session, and acts
|
||||
on your behalf whether or not you are at a keyboard.
|
||||
Hermes is not the only tool with memory or automation. It is the tool that makes those
|
||||
capabilities durable, self-hosted, cross-surface, and cumulative on your own server. The
|
||||
distinction that matters is not "has memory" vs. "has no memory" — it's whether context persists
|
||||
across sessions automatically, whether execution happens on hardware you control, whether you can
|
||||
reach the same agent identity from any device, and whether the system gets meaningfully better at
|
||||
your specific workflow over time without manual configuration.
|
||||
|
||||
```
|
||||
Assistant model: You -> [Tool] -> Answer -> Done
|
||||
(tool forgets everything when the window closes)
|
||||
Session-scoped: You -> [Tool] -> Answer -> Done
|
||||
(some tools now carry memory, but the execution is stateless)
|
||||
|
||||
Agent model: You <-> [Hermes] <-> (memory, skills, schedule, tools)
|
||||
(persistent, learns your stack, acts on your behalf, runs while you're offline)
|
||||
Persistent agent: You <-> [Hermes] <-> (memory, skills, schedule, tools, surfaces)
|
||||
(runs on your server, accumulates context, acts on your behalf offline)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The Three Pillars
|
||||
## A note on convergence
|
||||
|
||||
### 1. Memory That Compounds
|
||||
The market is converging. Chat assistants are adding task scheduling and file connectors. IDE
|
||||
tools are launching cloud agent modes. CLI tools are adding skills systems and mobile surfaces.
|
||||
The lines between "assistant," "editor," and "agent" are dissolving.
|
||||
|
||||
Hermes has layered memory that survives every session, every reboot, every model swap:
|
||||
This makes comparisons harder but also makes the question sharper: what actually matters when
|
||||
every tool is claiming some version of every feature? For Hermes, the answer is synthesis. Any
|
||||
single feature — memory, scheduling, messaging — is available somewhere else. The value is
|
||||
having all of them in one self-hosted system, running continuously, with a persistent identity
|
||||
that accumulates real knowledge of your stack over time.
|
||||
|
||||
- **User profile** -- who you are, your preferences, your communication style, things you've
|
||||
corrected Hermes on
|
||||
- **Agent memory** -- facts about your environment, your toolchain, your project conventions
|
||||
- **Skills** -- reusable procedures Hermes discovers and saves; it never has to relearn how to
|
||||
deploy your app, run your tests, or review a PR
|
||||
- **Session history** -- every past conversation is searchable; Hermes can recall what you
|
||||
worked on last Tuesday
|
||||
---
|
||||
|
||||
## The three pillars
|
||||
|
||||
### 1. Memory that compounds
|
||||
|
||||
Hermes has layered memory that survives every session, every reboot, and every model swap:
|
||||
|
||||
- User profile — who you are, your preferences, your communication style, things you've corrected Hermes on
|
||||
- Agent memory — facts about your environment, your toolchain, your project conventions
|
||||
- Skills — reusable procedures Hermes discovers and saves automatically; it never has to relearn how to deploy your app, run your tests, or review a PR
|
||||
- Session history — every past conversation is searchable; Hermes can recall what you worked on last Tuesday
|
||||
|
||||
When you correct Hermes, it remembers. When it solves a tricky problem, it saves the approach.
|
||||
When it learns your stack, that knowledge carries into every future session.
|
||||
When it learns your stack, that knowledge carries into every future session. You never configure
|
||||
this manually — it happens in the background as a side effect of normal use.
|
||||
|
||||
### 2. Autonomous Scheduling
|
||||
### 2. Autonomous scheduling
|
||||
|
||||
Hermes can run jobs without you present -- every hour, every morning, on any cron schedule.
|
||||
It fires up a fresh session, runs the task, and delivers the result to wherever you want it:
|
||||
Telegram, Discord, Slack, Signal, WhatsApp, SMS, email, and more.
|
||||
Hermes can run jobs without you present — every hour, every morning, on any cron schedule. It
|
||||
fires up a fresh session with full access to your memory and skills, runs the task, and delivers
|
||||
the result wherever you want it: Telegram, Discord, Slack, Signal, WhatsApp, SMS, email, and more.
|
||||
|
||||
Things Hermes can do while you sleep:
|
||||
|
||||
- Review new pull requests on your GitHub repo and post a full verdict comment
|
||||
- Send you a morning briefing of news, markets, or anything else you care about
|
||||
- Send a morning briefing of news, markets, or anything else you track
|
||||
- Run your test suite and alert you if something breaks
|
||||
- Watch a competitor's blog for new posts and summarize them
|
||||
- Monitor a datasource and notify you when a threshold is crossed
|
||||
|
||||
### 3. Reach It From Anywhere
|
||||
The difference from cloud-scheduled alternatives is that the job runs on your server, with your
|
||||
memory and skills, and your data never leaves your hardware.
|
||||
|
||||
### 3. Reach it from anywhere
|
||||
|
||||
Hermes runs on your server and is reachable from every surface: terminal over SSH, the web UI
|
||||
(this project), and messaging apps including Telegram, Discord, Slack, WhatsApp, Signal, and
|
||||
Matrix. Start a task from your phone, check it from the browser on your laptop, continue it in
|
||||
a terminal on a remote server. The same agent, memory, and history follow you everywhere.
|
||||
a terminal on a remote server. The same agent, memory, and history follow you across all of them.
|
||||
|
||||
---
|
||||
|
||||
## A Framework for AI Tools
|
||||
## How AI tools are layered today
|
||||
|
||||
There are four distinct categories of AI tool. Understanding the category tells you what a tool
|
||||
can and cannot do.
|
||||
The old four-category model — chat, editor, CLI, agent — is too clean. These layers are actively
|
||||
collapsing into each other. Here is a more honest picture:
|
||||
|
||||
### Category 1: Chat Assistants
|
||||
*Claude.ai, ChatGPT, Gemini*
|
||||
Chat assistants (Claude.ai, ChatGPT) now have persistent memory, task scheduling, 50+ service
|
||||
connectors, and in some cases full agent modes with computer use. They are no longer "just chat."
|
||||
|
||||
You open a window, ask something, get an answer. No persistent memory beyond the conversation,
|
||||
no ability to run code or touch files, no way to act on your behalf. Excellent for Q&A,
|
||||
drafting, and brainstorming. You re-explain your context every session.
|
||||
IDE tools (Cursor, Windsurf, Copilot) have shipped or are shipping cross-session memory,
|
||||
cloud-based background agents, and in Cursor's case a full Automations platform with Slack
|
||||
integration. Cursor v3.0 (April 2026) is explicitly agent-first.
|
||||
|
||||
### Category 2: IDE Integrations
|
||||
*GitHub Copilot, Cursor, Windsurf, Zed AI*
|
||||
CLI tools (Claude Code, Codex, OpenCode) have added hooks, skills, desktop app automations,
|
||||
and multi-surface reach. Claude Code now spans terminal, IDE, desktop, and browser. Codex has
|
||||
become a product family: CLI, IDE extension, desktop app, and Codex Cloud.
|
||||
|
||||
Deep inside your editor. Autocomplete, inline diffs, refactors -- all excellent. Windsurf was
|
||||
earliest with workspace-scoped memory (Cascade Memories); Copilot has been shipping repo-level
|
||||
memory since late 2025 and is catching up. Cursor has no native memory as of early 2026. None
|
||||
have scheduling or messaging access. Tied to one machine and one editor.
|
||||
Persistent self-hosted agents (Hermes, OpenClaw) sit at the intersection: they combine the
|
||||
tool-use power of CLI agents, the memory of chat assistants, the scheduling of automation
|
||||
platforms, and the cross-surface reach of messaging integrations — running continuously on
|
||||
hardware you own.
|
||||
|
||||
### Category 3: Agentic CLI Tools
|
||||
*Claude Code, Codex CLI, OpenCode, Aider*
|
||||
|
||||
The current frontier for most developers. Can use real tools -- run shell commands, read and
|
||||
write files, search the web, call APIs. Great for deep, multi-step tasks in a single terminal
|
||||
session. All are adding memory and scheduling features to varying degrees (see comparisons below),
|
||||
but the core model is still session-scoped: you invoke it, it works, it stops.
|
||||
|
||||
### Category 4: Persistent Autonomous Agents
|
||||
*Hermes, OpenClaw (as of early 2026)*
|
||||
|
||||
All the tool use of Category 3, plus memory that accumulates across sessions, plus always-on
|
||||
scheduling, plus multi-modal access from any device or messaging app. Gets more useful over time
|
||||
rather than resetting to zero. Hermes and OpenClaw are the two primary open-source, self-hosted
|
||||
tools in this category. OpenClaw is a gateway-centric automation platform; Hermes is a
|
||||
self-improving agent that writes and reuses its own procedures from experience.
|
||||
The question is not which category a tool belongs to. The question is which combination of
|
||||
capabilities you actually need, where that execution lives, and whether the system gets better
|
||||
at your specific context over time.
|
||||
|
||||
---
|
||||
|
||||
## How Hermes Compares
|
||||
## How Hermes compares
|
||||
|
||||
### vs. OpenClaw
|
||||
|
||||
OpenClaw is the most direct comparison to Hermes and the question most people ask first.
|
||||
Both are open-source, self-hosted, always-on agents with persistent memory, cron scheduling,
|
||||
and messaging app integration. If you're evaluating Hermes, you should evaluate OpenClaw too.
|
||||
OpenClaw is the most direct comparison and the question most people ask first. Both are
|
||||
open-source, self-hosted, always-on agents with persistent memory, cron scheduling, and messaging
|
||||
app integration. If you're evaluating Hermes, evaluate OpenClaw too.
|
||||
|
||||
OpenClaw (MIT, ~347k GitHub stars) is built around a **Gateway** control plane written in
|
||||
Node.js/TypeScript. It excels at broad personal automation: native Chrome/Chromium control for
|
||||
browser automation, the widest messaging platform support in the space (WhatsApp, Telegram,
|
||||
Signal, iMessage, LINE, WeChat, Slack, Discord, Teams, Matrix, and more), voice wake words,
|
||||
and a ClawHub skill marketplace where users share pre-built automations. The community is large
|
||||
and the ecosystem is growing fast.
|
||||
OpenClaw (MIT) is built around a Gateway control plane written in Node.js/TypeScript. It has the
|
||||
widest messaging coverage in the space — 24+ channels including WhatsApp, Telegram, Signal,
|
||||
iMessage, LINE, WeChat, Slack, Discord, Teams, Matrix, Google Chat, Feishu, Mattermost, IRC,
|
||||
Nextcloud Talk, and more. It has native Chrome/Chromium control via CDP, voice wake words on
|
||||
macOS and iOS, and a ClawHub marketplace with 10,700+ skills. The community is large (350k+
|
||||
GitHub stars, 16,900+ commits) and growing.
|
||||
|
||||
Hermes takes a different approach. It is built in Python and centers on a **self-improving
|
||||
agent loop** rather than a gateway control plane. The core difference is in how skills work:
|
||||
OpenClaw skills are primarily human-authored plugins installed from a marketplace; Hermes
|
||||
**writes and saves its own skills automatically** as part of every session. When Hermes solves
|
||||
a problem a new way, it saves the procedure and reuses it going forward without any user effort.
|
||||
Hermes is built in Python and centers on a self-improving agent loop rather than a gateway
|
||||
control plane. The core architectural difference is in skills: OpenClaw skills are primarily
|
||||
human-authored plugins installed from a marketplace. Hermes writes and saves its own skills
|
||||
automatically as part of every session. When Hermes solves a problem a new way, it saves the
|
||||
procedure and reuses it without any user effort. That's not a subtle distinction — it's the
|
||||
reason Hermes gets meaningfully better at your workflow without you maintaining a plugin library.
|
||||
|
||||
Beyond the skills architecture, there are two other practical differences worth knowing:
|
||||
Two practical differences worth knowing directly:
|
||||
|
||||
**Stability.** OpenClaw's community forums and GitHub issues document a recurring pattern of
|
||||
update-breaking regressions -- for example, Telegram integration was broken across multiple
|
||||
releases in early 2026. The unofficial WhatsApp Web protocol OpenClaw uses is known to
|
||||
disconnect and requires periodic re-pairing (this is documented in OpenClaw's own FAQ).
|
||||
Hermes has had no equivalent release breakages.
|
||||
Stability. OpenClaw's GitHub issues and community forums document recurring update-breaking
|
||||
regressions. Telegram integration was broken across multiple releases from early 2026 through
|
||||
at least April 2026. The unofficial WhatsApp Web protocol OpenClaw relies on disconnects and
|
||||
requires periodic re-pairing — this is in OpenClaw's own FAQ.
|
||||
|
||||
**Security.** ClawHub's open publishing model has been exploited repeatedly. A community audit
|
||||
identified over a thousand malicious skills in the marketplace including prompt injections and
|
||||
tool-poisoning payloads; the community-maintained awesome-openclaw-skills list tracks confirmed
|
||||
removals and flags known bad actors. Hermes has no third-party marketplace and a correspondingly
|
||||
smaller attack surface.
|
||||
Security. ClawHub's open publishing model has been exploited at scale. Three separate audits in
|
||||
early 2026 found serious problems: Koi Security (January 2026) linked 335 skills to a campaign
|
||||
called "ClawHavoc" that delivered Atomic Stealer malware on macOS; Bitdefender found roughly
|
||||
900 malicious packages representing about 20% of the ecosystem at the time; Snyk's "ToxicSkills"
|
||||
report (February 2026) found malicious skills across roughly 4,000 scanned packages. China's
|
||||
CNCERT issued a national warning about ClawHub. Hermes has no third-party marketplace and a
|
||||
correspondingly smaller attack surface.
|
||||
|
||||
**OpenClaw's genuine strengths** are worth stating plainly: it has broader messaging coverage
|
||||
(iMessage, LINE, WeChat, Teams -- platforms Hermes does not support), native browser and
|
||||
computer control via Chrome CDP, voice wake words on macOS and iOS, a larger community, and
|
||||
more third-party integrations than Hermes. If those capabilities matter most to you, OpenClaw
|
||||
is worth a serious look.
|
||||
OpenClaw's genuine strengths are worth stating plainly: broader messaging coverage (iMessage,
|
||||
LINE, WeChat, Teams, Google Chat — platforms Hermes does not support), native browser and
|
||||
computer control via Chrome CDP, voice wake words, a larger community, and more third-party
|
||||
integrations than Hermes. If those capabilities matter most, OpenClaw is worth a serious look.
|
||||
|
||||
Where Hermes is the better fit: you want an agent that self-improves from experience without
|
||||
manual plugin authoring, you work in Python and want access to the ML/data science ecosystem,
|
||||
you want a stable deployment that does not break between updates, or you want a full web chat
|
||||
UI rather than a monitoring dashboard.
|
||||
Where Hermes fits better: you want an agent that self-improves from experience without managing
|
||||
a plugin library, you work in Python and want the ML/data science ecosystem, you want a stable
|
||||
deployment that doesn't break between updates, or you want a full web chat UI rather than a
|
||||
control dashboard.
|
||||
|
||||
| | OpenClaw | Hermes |
|
||||
|---|---|---|
|
||||
| Persistent memory | Yes | Yes |
|
||||
| Scheduled jobs (cron) | Yes | Yes |
|
||||
| Messaging app access | Yes (15+ platforms, incl. iMessage/WeChat) | Yes (10+ platforms) |
|
||||
| Web UI | Gateway dashboard (monitoring only) | Full three-panel chat UI |
|
||||
| Messaging app access | Yes (24+ platforms, incl. iMessage/WeChat/LINE) | Yes (many platforms) |
|
||||
| Web UI | Chat UI + control dashboard | Full three-panel chat UI |
|
||||
| Self-hosted | Yes | Yes |
|
||||
| Open source | Yes (MIT) | Yes |
|
||||
| Self-improving skills | Partial (AI can generate skills; not the default loop) | Yes (automatic, first-class) |
|
||||
| Self-improving skills | Partial (AI can generate; not the default loop) | Yes (automatic, first-class) |
|
||||
| Browser / computer control | Yes (native Chrome CDP) | Via shell / tools |
|
||||
| Voice wake words | Yes (macOS/iOS) | No |
|
||||
| Python / ML ecosystem | No (Node.js) | Yes |
|
||||
@@ -167,209 +178,312 @@ UI rather than a monitoring dashboard.
|
||||
| Multi-profile support | Via binding-rule routing | Yes (first-class named profiles) |
|
||||
| Provider-agnostic | Yes | Yes |
|
||||
| Update reliability | Moderate (documented regressions) | High |
|
||||
| Memory inspectability | Limited | Yes (markdown files, editable) |
|
||||
| Self-hosted autonomous execution | Yes | Yes |
|
||||
|
||||
### vs. Claude Code (Anthropic)
|
||||
|
||||
Claude Code is Anthropic's official agentic CLI and one of the best tools in Category 3.
|
||||
In a single focused session it is capable -- deep code understanding, shell access, file
|
||||
editing, multi-step reasoning.
|
||||
Claude Code is Anthropic's official agentic tool and one of the strongest options for focused
|
||||
coding sessions. It has deep code understanding, shell access, file editing, and multi-step
|
||||
reasoning. It has been expanding rapidly — it now spans terminal, IDE plugin, desktop app, and
|
||||
browser surfaces — and the gap is closing in several areas.
|
||||
|
||||
Claude Code has been adding features rapidly and the gap is narrowing:
|
||||
What Claude Code has that's worth knowing:
|
||||
|
||||
- **Hooks system** -- 13 event types (SessionStart, PreToolUse, PostToolUse, Stop, etc.) with
|
||||
4 handler types (shell command, HTTP endpoint, LLM prompt, sub-agent); deterministic
|
||||
- Hooks system — 26 event types (SessionStart, PreToolUse, PostToolUse, Stop, and more) with
|
||||
4 handler types (shell command, HTTP endpoint, LLM prompt, sub-agent); gives deterministic
|
||||
non-LLM control over the agent lifecycle
|
||||
- **Plugins / Skills** -- installable via `/plugin install`, hot-reloaded from `~/.claude/skills`,
|
||||
with a marketplace; skills and slash commands unified as of v2.1.0
|
||||
- **Scheduling** -- `/loop` (session-scoped), cloud-managed cron via `claude.ai/code/scheduled`
|
||||
(Anthropic infrastructure, minimum interval applies), and desktop app automations
|
||||
- **Messaging channels** -- Telegram, Discord, iMessage, and webhooks via the Channels feature
|
||||
(research preview, v2.1.80+); deep Slack integration that triggers cloud sessions and creates PRs
|
||||
- **Claude Cowork** -- a separate product for knowledge workers; connects to 38+
|
||||
services via MCP including Slack, Gmail, Microsoft Teams, Notion, Jira, Salesforce, and more
|
||||
- **Memory** -- CLAUDE.md and MEMORY.md for project-level context; auto-memory rolling out
|
||||
- Plugins / Skills — installable via `/plugin install`, hot-reloaded from `~/.claude/skills`,
|
||||
with a marketplace; includes the official ralph-wiggum plugin (`/ralph-loop`) for
|
||||
autonomous iteration toward a completion goal (distinct from `/loop`)
|
||||
- `/loop` — a native bundled skill, available in every session without any plugin, that runs
|
||||
a prompt on a repeating schedule within an active CLI session (polling/monitoring use case);
|
||||
session-scoped, dies when the terminal closes
|
||||
- Scheduling — cloud-managed cron (Anthropic infrastructure, minimum 1-hour interval) and
|
||||
desktop app scheduled tasks (run locally while the app is open, minimum 1-minute interval,
|
||||
full local file access); no self-hosted cron
|
||||
- Messaging channels — Telegram, Discord, and iMessage via the Channels feature (research
|
||||
preview, requires Bun runtime); Slack is the most-requested addition and has not yet shipped
|
||||
- Memory — CLAUDE.md and MEMORY.md for project-level context; auto-memory since v2.1.59+
|
||||
- Claude Cowork — a separate knowledge-worker product connecting 38+ services via MCP
|
||||
including Gmail, Microsoft Teams, Notion, Jira, Salesforce, and more
|
||||
|
||||
These are real features. The key differences that remain:
|
||||
Claude Code's source was briefly and accidentally made public in March 2026 before being taken
|
||||
down. The CLI ships as minified/bundled TypeScript compiled with Bun — it is not open source.
|
||||
|
||||
- Claude Code's scheduling runs on **Anthropic's cloud** (or requires the desktop app open),
|
||||
not a self-hosted server; cloud jobs have a minimum interval and your data leaves your hardware
|
||||
- Memory is **project-file-based** (CLAUDE.md / MEMORY.md), not a knowledge graph that
|
||||
accumulates automatically across all your work; auto-memory is still rolling out
|
||||
- **Not provider-agnostic** -- routes through Bedrock or Vertex but always hits a Claude model;
|
||||
you cannot switch to GPT, Gemini, or a local model
|
||||
- **Not open source** -- proprietary; the CLI ships obfuscated JavaScript
|
||||
- Messaging channels are a **research preview** requiring Bun runtime; not yet production-grade
|
||||
Key differences that remain:
|
||||
|
||||
- Scheduling requires cloud (Anthropic infrastructure, data off your hardware, 1-hour minimum)
|
||||
or the desktop app (runs locally, but the app must stay open — not a headless server process);
|
||||
neither runs as a server daemon the way Hermes cron does
|
||||
- Memory is project-file-based (CLAUDE.md / MEMORY.md plus rolling auto-memory); it doesn't
|
||||
automatically accumulate a cross-project knowledge graph the way Hermes does
|
||||
- Not provider-agnostic — routes through Anthropic, Bedrock, Vertex, or Foundry, but always
|
||||
a Claude model; you can't switch to GPT, Gemini, or a local model
|
||||
- Messaging channels are still a research preview, not production
|
||||
|
||||
Hermes can use Claude Code as a sub-agent. For large implementation tasks, Hermes can spawn
|
||||
Claude Code to handle the heavy lifting and fold the result back into its own memory and history.
|
||||
|
||||
| | Claude Code | Hermes |
|
||||
|---|---|---|
|
||||
| Persistent memory (automatic) | Partial (CLAUDE.md / MEMORY.md, rolling out) | Yes |
|
||||
| Skills / hooks system | Yes (Hooks + Plugin/Skills marketplace) | Yes (auto-generated from experience) |
|
||||
| Persistent memory (automatic) | Partial (CLAUDE.md / MEMORY.md + auto-memory v2.1.59+) | Yes |
|
||||
| Skills / hooks system | Yes (26-event Hooks + Plugin/Skills marketplace) | Yes (auto-generated from experience) |
|
||||
| Scheduled jobs (self-hosted) | No (cloud or desktop-app only) | Yes |
|
||||
| Messaging access | Partial (Telegram/Discord/iMessage via research preview; Slack native) | Yes (10+ platforms, production) |
|
||||
| Messaging access | Partial (Telegram/Discord/iMessage research preview; Slack not yet) | Yes (many platforms, production) |
|
||||
| Cowork connectors (Slack, Gmail, etc.) | Yes (via Claude Cowork, separate product) | Via agent tool use |
|
||||
| Web UI | Yes (claude.ai/code, Anthropic-hosted) | Yes (self-hosted) |
|
||||
| Provider-agnostic | No (Claude models only, via Bedrock/Vertex) | Yes (any provider) |
|
||||
| Provider-agnostic | No (Claude models only) | Yes (any provider) |
|
||||
| Self-hosted scheduling | No | Yes |
|
||||
| Open source | No | Yes |
|
||||
| Background/cloud agent mode | Yes (cloud-scheduled) | Yes (self-hosted cron) |
|
||||
| Runs as sub-agent of Hermes | Yes | N/A |
|
||||
| Memory inspectability | Partial (CLAUDE.md readable; auto-memory less so) | Yes (markdown files) |
|
||||
|
||||
### vs. Codex CLI (OpenAI)
|
||||
|
||||
Codex CLI is OpenAI's open-source agentic terminal tool (Apache 2.0, ~73k GitHub stars). It
|
||||
supports 10+ providers including Anthropic, Google, Mistral, Groq, and local models via Ollama.
|
||||
It added persistent session memory in v0.100.0 with `codex resume`. The desktop app has an
|
||||
Automations feature for scheduled local tasks.
|
||||
Codex CLI (Apache 2.0, ~60k GitHub stars) started as a straightforward terminal tool and has
|
||||
expanded into a product family. It was rewritten from TypeScript to Rust. It now includes an IDE
|
||||
extension, a desktop app with an Automations feature, and Codex Cloud for remote execution. A
|
||||
Skills system is shared across surfaces. It supports 12+ built-in providers: OpenAI, Anthropic,
|
||||
Google/Gemini, Mistral, Groq, Ollama, OpenRouter, LM Studio, Together AI, DeepSeek, xAI,
|
||||
Azure OpenAI, and custom endpoints.
|
||||
|
||||
The CLI itself has no native scheduling (open feature request as of early 2026). Memory is
|
||||
session-history-based rather than a living knowledge graph. No messaging app access. A strong
|
||||
tool for single-session coding; Hermes adds the always-on layer on top.
|
||||
The CLI itself has no native scheduling (open feature request). Session continuity is available
|
||||
via `codex resume`. Memory is session-history-based plus AGENTS.md project context — not a
|
||||
living knowledge graph that accumulates across all your projects. No first-party messaging
|
||||
integration. The Automations feature in the desktop app covers scheduled local tasks but doesn't
|
||||
reach the cross-session, cross-surface continuity Hermes has.
|
||||
|
||||
| | Codex CLI | Hermes |
|
||||
|---|---|---|
|
||||
| Persistent memory | Partial (session history + AGENTS.md) | Yes (automatic, layered) |
|
||||
| Scheduled jobs | Partial (desktop app only; CLI has none) | Yes |
|
||||
| Scheduled jobs | Partial (desktop app Automations; CLI has none) | Yes |
|
||||
| Messaging app access | No | Yes |
|
||||
| Web UI | No | Yes (self-hosted) |
|
||||
| Provider-agnostic | Yes (10+ providers) | Yes (10+ providers) |
|
||||
| Web UI | No (CLI + desktop app) | Yes (self-hosted) |
|
||||
| Provider-agnostic | Yes (12+ providers) | Yes |
|
||||
| Self-hosted | Yes | Yes |
|
||||
| Open source | Yes (Apache 2.0) | Yes |
|
||||
| Background/cloud agent mode | Yes (Codex Cloud) | Yes (self-hosted cron) |
|
||||
| Self-improving skills | No | Yes |
|
||||
|
||||
### vs. OpenCode
|
||||
|
||||
OpenCode is an open-source TUI agentic coding assistant, provider-agnostic across 75+ providers.
|
||||
It has a WebUI embedded in its binary and an official desktop app. It uses SQLite for session
|
||||
history and AGENTS.md for project context.
|
||||
OpenCode is an open-source TUI agentic coding assistant supporting 75+ providers. It has a WebUI
|
||||
embedded in its binary, an official desktop app, SQLite session history, and AGENTS.md project
|
||||
context. It supports CLAUDE.md as a fallback for users migrating from Claude Code. There are 30+
|
||||
community plugins, and community messaging integrations exist for Telegram, Slack, Discord, and
|
||||
Microsoft Teams — though none are first-party and all require manual setup.
|
||||
|
||||
No native scheduled jobs (a community background plugin exists), no first-party messaging
|
||||
integration (community Telegram bots exist but require manual setup), and no automatic
|
||||
cross-session semantic memory. Good for interactive terminal coding sessions.
|
||||
OpenCode Go ($10/month) and OpenCode Zen (curated model service) are subscription tiers. The
|
||||
GitHub Copilot official integration launched January 2026. There is no native scheduling; a
|
||||
community background plugin exists. No automatic cross-session semantic memory.
|
||||
|
||||
| | OpenCode | Hermes |
|
||||
|---|---|---|
|
||||
| Persistent memory | Partial (session history + AGENTS.md) | Yes (automatic, layered) |
|
||||
| Scheduled jobs | No (community plugin only) | Yes |
|
||||
| Messaging app access | No (community Telegram bot only) | Yes (first-party, 10+ platforms) |
|
||||
| Messaging app access | Community integrations only (Telegram/Slack/Discord/Teams) | Yes (first-party, many platforms) |
|
||||
| Web UI | Yes (embedded + desktop app) | Yes (self-hosted) |
|
||||
| Mobile access | No | Yes |
|
||||
| Skills system | No | Yes |
|
||||
| Skills / plugins | Yes (30+ community plugins) | Yes (auto-generated, first-party) |
|
||||
| Provider-agnostic | Yes (75+ providers) | Yes |
|
||||
| Open source | Yes | Yes |
|
||||
| Self-hosted autonomous execution | No | Yes |
|
||||
|
||||
### vs. Cursor / Windsurf / Copilot
|
||||
### vs. Cursor
|
||||
|
||||
Category 2 tools -- exceptional at in-editor autocomplete, inline diffs, and code review.
|
||||
Not competing for the same job as Hermes, and they work well alongside it.
|
||||
Cursor has changed substantially. The "no memory, no scheduling, no messaging" description was
|
||||
accurate in 2024 and is wrong now.
|
||||
|
||||
Windsurf was earliest with workspace-scoped memory (Cascade Memories); Copilot has been
|
||||
shipping repo-level memory since late 2025. Cursor has no native cross-session memory as of
|
||||
early 2026. None have scheduling or messaging access.
|
||||
Memories (per-project cross-session knowledge base) shipped in beta with v1.0 in June 2025.
|
||||
Automations launched March 5, 2026 — time-based, event-based (GitHub/Linear/PagerDuty), and
|
||||
communication-based (Slack) triggers that fire background agents on cloud VMs. The web app,
|
||||
mobile agent, and Slack bot give it multi-surface reach. Cursor v3.0 (April 2, 2026) is
|
||||
explicitly agent-first with Design Mode and 30+ marketplace plugins. Cursor acquired Supermaven
|
||||
for autocomplete. As of early 2026 it's valued at $29.3B with $2B ARR. It is not a narrow editor
|
||||
tool anymore.
|
||||
|
||||
Hermes still has a different profile: it's self-hosted and server-resident, the same persistent
|
||||
identity follows you across every surface without cloud intermediation, and it works with any
|
||||
model family rather than being cloud-VM-based. For workflows that require data sovereignty,
|
||||
self-hosted scheduling, or deep Python/ML tooling on your own hardware, Cursor's cloud-agent
|
||||
architecture is a fundamental mismatch. For teams that want editor-native agents with strong
|
||||
IDE integration, Cursor's recent evolution is significant.
|
||||
|
||||
| | Cursor | Windsurf | Copilot | Hermes |
|
||||
|---|---|---|---|---|
|
||||
| In-editor autocomplete | Excellent | Excellent | Excellent | No |
|
||||
| In-editor autocomplete | Excellent (Supermaven) | Excellent (Cascade) | Excellent | No |
|
||||
| Inline diff / refactor | Yes | Yes | Yes | Via shell |
|
||||
| Cross-session memory | No | Yes (workspace) | Partial (repo, early access) | Yes |
|
||||
| Scheduled background jobs | No | No | No | Yes |
|
||||
| Messaging app / mobile | No | No | No | Yes |
|
||||
| Cross-session memory | Yes (Memories, per-project) | Yes (Cascade Memories, workspace) | Yes (Agentic Memory, repo-scoped, 28-day expiry) | Yes (automatic, persistent) |
|
||||
| Scheduled background jobs | Yes (Automations, cloud VM) | No | Via Coding Agent (issue-driven) | Yes (self-hosted cron) |
|
||||
| Messaging app / multi-surface | Yes (Slack bot, web app, mobile) | No | Via Copilot CLI / fleet | Yes (many platforms) |
|
||||
| Background/cloud agent mode | Yes (Automations on cloud VMs) | No | Yes (Coding Agent, GA Mar 2026) | Yes (self-hosted) |
|
||||
| Terminal tool use | Limited | Limited | Limited | Full |
|
||||
| Self-hosted | No | No | No | Yes |
|
||||
| Provider-agnostic | Partial | Partial | No | Yes |
|
||||
| Self-hosted autonomous execution | No | No | No | Yes |
|
||||
| Provider-agnostic | Partial | Partial | No (GitHub models) | Yes |
|
||||
| Open source | No | No | No | Yes |
|
||||
| Memory inspectability | Partial | Yes (stored locally) | Limited | Yes (markdown files) |
|
||||
|
||||
### vs. Claude.ai / ChatGPT
|
||||
### vs. Claude.ai and ChatGPT
|
||||
|
||||
Category 1. For drafting, Q&A, and brainstorming in the moment, both are excellent.
|
||||
These are no longer simple chat tools. The description of "no memory, no scheduling, no
|
||||
messaging" is inaccurate for both.
|
||||
|
||||
Claude.ai memory has been improving -- it now generates memory from chat history, not just
|
||||
user-curated entries. Claude.ai can also execute code and read/write files in a sandboxed
|
||||
environment via Artifacts. These are real capabilities, just not the same as direct filesystem
|
||||
or shell access on your own server.
|
||||
Claude Cowork (in Claude Desktop) launched scheduled tasks on February 25, 2026 — hourly,
|
||||
daily, weekly, weekdays, and on-demand. It runs in an isolated VM with file and shell access.
|
||||
Claude has 50+ service connectors as of February 2026 including Slack (launched January 26,
|
||||
2026), Gmail, Google Calendar, Google Drive, Microsoft 365, Notion, Asana, Linear, and Jira.
|
||||
Memory auto-generates from chat history, not just user-curated entries. Code execution and
|
||||
file access in Artifacts is sandboxed, not the same as shell access on your own server.
|
||||
|
||||
| | Claude.ai / ChatGPT | Hermes |
|
||||
|---|---|---|
|
||||
| Memory across conversations | Yes (improving; auto-generated from history) | Yes (deep, automatic) |
|
||||
| Runs shell commands | No | Yes |
|
||||
| Code execution | Sandboxed (Artifacts) | Yes (full shell) |
|
||||
| Reads / writes files | Sandboxed (Artifacts) | Yes (full filesystem) |
|
||||
| Schedules background jobs | No | Yes |
|
||||
| Web UI | Yes | Yes |
|
||||
| Messaging apps | No | Yes |
|
||||
| Self-hosted | No | Yes |
|
||||
| Provider-agnostic | No | Yes |
|
||||
| Open source | No | Yes |
|
||||
ChatGPT has Agent Mode (launched July 17, 2025), Scheduled Tasks (January 2025, recurring
|
||||
automated prompts), a computer-using agent, Projects, 50+ connectors including Gmail, GitHub,
|
||||
and Google Drive, dual-mode memory (auto + manual), and ChatGPT Pulse for Pro users (daily
|
||||
research briefings). It is not a passive Q&A interface.
|
||||
|
||||
Where Claude.ai and ChatGPT differ from Hermes: neither is self-hosted, neither is
|
||||
provider-agnostic, and neither gives you execution on your own hardware. Connectors and
|
||||
scheduling exist, but they run on Anthropic's or OpenAI's infrastructure. Your memory, session
|
||||
history, and agent execution live on their servers, not yours. For many use cases that's fine
|
||||
— they are capable and well-supported. For privacy-conscious users, regulated environments, or
|
||||
workflows that require persistent server-side execution on controlled hardware, it's a
|
||||
disqualifying constraint.
|
||||
|
||||
| | Claude.ai | ChatGPT | Hermes |
|
||||
|---|---|---|---|
|
||||
| Memory across conversations | Yes (auto-generated from history) | Yes (dual-mode: auto + manual) | Yes (deep, automatic) |
|
||||
| Scheduled tasks | Yes (Cowork: hourly/daily/weekly) | Yes (since Jan 2025) | Yes (any cron, self-hosted) |
|
||||
| Service connectors / messaging | Yes (50+ via Cowork) | Yes (50+ connectors) | Yes (many platforms, direct) |
|
||||
| Runs shell commands | Sandboxed (Cowork VM) | Sandboxed | Yes (full shell) |
|
||||
| Code execution | Sandboxed | Sandboxed | Yes (full shell) |
|
||||
| Reads / writes files | Sandboxed | Sandboxed | Yes (full filesystem) |
|
||||
| Web UI | Yes (Anthropic-hosted) | Yes (OpenAI-hosted) | Yes (self-hosted) |
|
||||
| Self-hosted | No | No | Yes |
|
||||
| Provider-agnostic | No | No | Yes |
|
||||
| Open source | No | No | Yes |
|
||||
| Self-hosted autonomous execution | No | No | Yes |
|
||||
| Memory inspectability | Limited | Limited | Yes (markdown files) |
|
||||
|
||||
---
|
||||
|
||||
## The Compounding Advantage
|
||||
## The compounding advantage
|
||||
|
||||
What matters most about Hermes is that it improves over time. That is the point.
|
||||
What distinguishes Hermes from most of the tools above is that it gets meaningfully better at
|
||||
your specific workflow over time without manual configuration.
|
||||
|
||||
Every time Hermes encounters a new environment, it saves facts to memory. Every time it solves
|
||||
a problem a new way, it saves the approach as a skill. Every time you correct it, it updates its
|
||||
profile of you. Every session, every scheduled job, every tool call, the agent gets more
|
||||
calibrated to you and your workflow.
|
||||
profile of you. Every session, every scheduled job, every tool call adds to a body of knowledge
|
||||
that is specific to you, stored on your hardware, and available to every future interaction.
|
||||
|
||||
A Claude Code session on day one and day one hundred are identical. A Hermes agent on day one
|
||||
and day one hundred is smarter about you -- it knows your stack, your conventions, your
|
||||
preferences, and the solutions that have worked before.
|
||||
A Claude Code session on day one and day one hundred are identical — it starts fresh. A Hermes
|
||||
agent on day one and day one hundred knows your stack, your conventions, your preferences, and
|
||||
the solutions that have worked before. That's the actual compounding.
|
||||
|
||||
---
|
||||
|
||||
## Who Hermes Is For
|
||||
## Who Hermes is for
|
||||
|
||||
**Solo developers and power users** who don't want to re-explain their stack every session and
|
||||
want an AI that actually knows their environment.
|
||||
Solo developers and power users who don't want to re-explain their stack every session and want
|
||||
an AI that actually knows their environment.
|
||||
|
||||
**Teams on a shared server** where multiple people want Claude-quality AI access without each
|
||||
paying for a separate subscription or running local tooling.
|
||||
Teams on a shared server where multiple people want capable AI access without each paying for
|
||||
a separate subscription or running separate local tooling.
|
||||
|
||||
**Automation-heavy workflows** where you want an AI running tasks on a schedule, delivering
|
||||
results to your phone, without babysitting it.
|
||||
Automation-heavy workflows where you want an AI running tasks on a schedule, delivering results
|
||||
to your phone, without babysitting it.
|
||||
|
||||
**Privacy-conscious users** who want their conversations, memory, and files on their own
|
||||
hardware.
|
||||
Privacy-conscious users who want their conversations, memory, and files on their own hardware.
|
||||
|
||||
**Multi-model users** who want to switch between OpenAI, Anthropic, Google, DeepSeek, and
|
||||
others based on cost, capability, or rate limits, without rebuilding their workflow each time.
|
||||
Multi-model users who want to switch between OpenAI, Anthropic, Google, DeepSeek, and others
|
||||
based on cost, capability, or rate limits, without rebuilding their workflow each time.
|
||||
|
||||
---
|
||||
|
||||
## Scope and Limits
|
||||
## What Hermes is not
|
||||
|
||||
**Hermes lives in the terminal, browser, and messaging apps.** For in-editor autocomplete and
|
||||
inline diffs, use Cursor or Windsurf alongside it -- they do that job better.
|
||||
Hermes is not the best in-editor autocomplete tool. Cursor and Windsurf do that job better.
|
||||
Use one alongside Hermes.
|
||||
|
||||
**You run Hermes on your own server.** That means initial setup, but your data stays on your
|
||||
It is not zero-setup. You are running a server. That means initial configuration, and it means
|
||||
you're responsible for uptime, upgrades, and backups. The tradeoff is data sovereignty and
|
||||
control; that only makes sense if you actually want it.
|
||||
|
||||
It does not make weaker models magical. Memory and skills help, but the underlying model still
|
||||
determines reasoning quality. Hermes with a weak model is a well-organized weak model.
|
||||
|
||||
It still needs guardrails, approvals, and observability for high-stakes automations. Autonomous
|
||||
execution on a schedule with shell access is powerful and requires judgment about what to
|
||||
approve. Terminal commands can require confirmation before running; use that for anything
|
||||
consequential.
|
||||
|
||||
If you need the absolute lowest-friction path to a one-off answer or a quick edit, a chat
|
||||
interface or an in-editor tool is the right call. Hermes is for continuity and autonomy, not
|
||||
minimum-friction one-shots.
|
||||
|
||||
---
|
||||
|
||||
## Scope and limits
|
||||
|
||||
Hermes lives in the terminal, browser, and messaging apps. For in-editor autocomplete and inline
|
||||
diffs, use Cursor or Windsurf — they do that job better and work well alongside Hermes.
|
||||
|
||||
You run Hermes on your own server. That means initial setup, but your data stays on your
|
||||
hardware and you control the schedule, the models, and the costs.
|
||||
|
||||
**Hermes is an orchestration and memory layer.** It makes whatever model you point it at more
|
||||
useful over time. The models do the reasoning; Hermes makes sure that reasoning accumulates into
|
||||
Hermes is an orchestration and memory layer. It makes whatever model you point at it more useful
|
||||
over time. The models do the reasoning; Hermes makes sure that reasoning accumulates into
|
||||
something durable.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
## Security and control
|
||||
|
||||
| | OpenClaw | Claude Code | Codex CLI | OpenCode | Cursor | Claude.ai | Hermes |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Persistent memory (auto) | Yes | Partial† | Partial | Partial | No | Yes (improving) | **Yes** |
|
||||
| Scheduled / background jobs | Yes | Partial‡ | Partial§ | No | No | No | **Yes (self-hosted)** |
|
||||
| Messaging app access | Yes (15+ platforms) | Partial (Telegram/Discord preview; Slack native) | No | No | No | No | **Yes (10+ platforms)** |
|
||||
| Web UI | Dashboard only | Yes (Anthropic cloud) | No | Yes | No | Yes | **Yes (self-hosted)** |
|
||||
| Skills system | Yes (marketplace) | Yes (Hooks + Plugins) | No | No | No | No | **Yes** |
|
||||
| Self-improving skills | Partial | No | No | No | No | No | **Yes** |
|
||||
| Browser / computer control | Yes (Chrome CDP) | No | No | No | No | No | Via shell |
|
||||
| Python / ML ecosystem | No (Node.js) | No | No | No | No | No | **Yes** |
|
||||
| In-editor autocomplete | No | No | No | No | Yes | No | No |
|
||||
| Orchestrates other agents | No | No | No | No | No | No | **Yes** |
|
||||
| Provider-agnostic | Yes | No (Claude only) | Yes | Yes | Partial | No | **Yes** |
|
||||
| Self-hosted | Yes | No | Yes | Yes | No | No | **Yes** |
|
||||
| Open source | Yes (MIT) | No | Yes | Yes | No | No | **Yes** |
|
||||
| Always-on / autonomous | Yes | No | No | No | No | No | **Yes** |
|
||||
Memory is stored locally on your server as readable, editable files: user profile, agent memory,
|
||||
and skills are all markdown. Session history is in SQLite on your machine. You can inspect,
|
||||
edit, or delete any of it directly.
|
||||
|
||||
† Claude Code has CLAUDE.md / MEMORY.md project context and rolling auto-memory, but not full automatic cross-session recall
|
||||
‡ Claude Code scheduling: cloud-managed (Anthropic infrastructure) or desktop-app only; no self-hosted cron
|
||||
§ Codex scheduling: desktop app Automations only; CLI has no native scheduling
|
||||
If you want external memory providers, eight are supported: Mem0, Honcho, Hindsight, RetainDB,
|
||||
ByteRover, Supermemory, Holographic, and others. These are optional and configurable.
|
||||
|
||||
Execution runs in configurable backends: local shell, Docker, SSH, Daytona, Singularity, or
|
||||
Modal. You choose what execution environment Hermes operates in and what it can reach.
|
||||
|
||||
Terminal commands can require confirmation before running. For any automation that touches
|
||||
production systems or makes external calls, enable approval controls.
|
||||
|
||||
Secrets stay on your hardware. Hermes does not phone home; it calls whatever model APIs you
|
||||
configure directly.
|
||||
|
||||
Multiple profiles give isolation between users or projects. A shared server can have separate
|
||||
profiles with separate memory, separate skills, and separate history.
|
||||
|
||||
---
|
||||
|
||||
## Quick reference
|
||||
|
||||
| | OpenClaw | Claude Code | Codex | OpenCode | Cursor | Copilot | Claude.ai | ChatGPT | Hermes |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| Persistent memory (auto) | Yes | Partial† | Partial | Partial | Yes (per-project) | Yes (repo-scoped‡) | Yes | Yes | Yes |
|
||||
| Scheduled / background jobs | Yes | Partial§ | Partial¶ | No | Yes (Automations) | Via Coding Agent | Yes (Cowork) | Yes | Yes (self-hosted) |
|
||||
| Messaging / multi-surface | Yes (24+ platforms) | Partial (preview) | No | Community only | Yes (Slack/web/mobile) | Via CLI/fleet | Yes (50+ connectors) | Yes (50+ connectors) | Yes (many platforms) |
|
||||
| Web UI | Chat UI + control dashboard | Anthropic-hosted | No | Yes | Yes + mobile | github.com | Yes (Claude Desktop) | Yes | Yes (self-hosted) |
|
||||
| Skills system | Yes (ClawHub marketplace) | Yes (Hooks + Plugins) | Partial (Skills) | Community plugins | Yes (marketplace) | No | No | No | Yes (auto-generated) |
|
||||
| Self-improving skills | Partial | No | No | No | No | No | No | No | Yes |
|
||||
| Browser / computer control | Yes (Chrome CDP) | No | No | No | No | No | No | Yes (CUA) | Via shell |
|
||||
| In-editor autocomplete | No | No | Via extension | No | Excellent | Excellent | No | No | No |
|
||||
| Orchestrates other agents | No | No | No | No | No | No | No | No | Yes |
|
||||
| Provider-agnostic | Yes | No (Claude only) | Yes | Yes | Partial | No | No | No | Yes |
|
||||
| Self-hosted | Yes | No | Yes (CLI) | Yes | No | No | No | No | Yes |
|
||||
| Self-hosted autonomous execution | Yes | No | No | No | No | No | No | No | Yes |
|
||||
| Background/cloud agent mode | Yes | Yes (cloud) | Yes (Codex Cloud) | No | Yes (cloud VMs) | Yes (Coding Agent) | Yes (Cowork VM) | Yes (Agent Mode) | Yes (self-hosted) |
|
||||
| Memory inspectability | Limited | Partial | Partial | Partial | Partial | Limited | Limited | Limited | Yes (markdown files) |
|
||||
| Open source | Yes (MIT) | No | Yes (Apache 2.0) | Yes | No | No | No | No | Yes |
|
||||
| Always-on autonomous execution | Yes | No | No | No | No | No | No | No | Yes |
|
||||
|
||||
† Claude Code: CLAUDE.md / MEMORY.md project context plus auto-memory since v2.1.59+; no automatic cross-project accumulation
|
||||
‡ Copilot Agentic Memory: public preview Jan 15, 2026; enabled by default Mar 4, 2026; repo-scoped, auto-expires after 28 days
|
||||
§ Claude Code scheduling: cloud-managed (Anthropic infrastructure) or desktop-app only; no self-hosted cron
|
||||
¶ Codex scheduling: desktop app Automations only; CLI has no native scheduling
|
||||
|
||||
410
README.md
410
README.md
@@ -1,16 +1,32 @@
|
||||
# Hermes Web UI
|
||||
|
||||
[Hermes Agent](https://hermes-agent.nousresearch.com/) is a sophisticated autonomous agent that lives on your server, accessed via a terminal or messaging apps, remembers what it learns, and gets more capable the longer it runs.
|
||||
[Hermes Agent](https://hermes-agent.nousresearch.com/) is a sophisticated autonomous agent that lives on your server, accessed via a terminal or messaging apps, that remembers what it learns and gets more capable the longer it runs.
|
||||
|
||||
Hermes WebUI is a lightweight, dark-themed web app interface in your browser for [Hermes Agent](https://hermes-agent.nousresearch.com/).
|
||||
Full parity with the CLI experience - everything you can do from a terminal,
|
||||
you can do from this UI. No build step, no framework, no bundler. Just Python
|
||||
and vanilla JS.
|
||||
|
||||
Layout: three-panel Claude-style. Left sidebar for sessions and tools,
|
||||
center for chat, right for workspace file browsing.
|
||||
Layout: three-panel. Left sidebar for sessions and navigation, center for chat,
|
||||
right for workspace file browsing. Model, profile, and workspace controls live in
|
||||
the **composer footer** — always visible while composing. A circular context ring
|
||||
shows token usage at a glance. All settings and session tools are in the
|
||||
**Hermes Control Center** (launcher at the sidebar bottom).
|
||||
|
||||
<img width="1392" alt="Hermes Web UI — three-panel layout" src="https://github.com/user-attachments/assets/79cd3c0d-3167-42ed-9434-447a742c25c3" />
|
||||
<img width="2448" height="1748" alt="Hermes Web UI — three-panel layout" src="https://github.com/user-attachments/assets/6bf8af4c-209d-441e-8b92-6515d7a0c369" />
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" align="center">
|
||||
<img width="2940" height="1848" alt="Light mode with full profile support" src="https://github.com/user-attachments/assets/4ef3a59c-7a66-4705-b4e7-cb9148fe4c47" />
|
||||
<br /><sub>Light mode with full profile support</sub>
|
||||
</td>
|
||||
<td width="50%" align="center">
|
||||
<img alt="Customize your settings, configure a password" src="https://github.com/user-attachments/assets/941f3156-21e3-41fd-bcc8-f975d5000cb8" />
|
||||
<br /><sub>Customize your settings, configure a password</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -79,20 +95,31 @@ ecosystem. See [HERMES.md](HERMES.md) for the full side-by-side.
|
||||
|
||||
## Quick start
|
||||
|
||||
First, you need to install and configure [Hermes Agent](https://hermes-agent.nousresearch.com/). Once installed:
|
||||
Run the repo bootstrap:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/nesquena/hermes-webui.git hermes-webui
|
||||
cd hermes-webui
|
||||
python3 bootstrap.py
|
||||
```
|
||||
|
||||
Or keep using the shell launcher:
|
||||
|
||||
```bash
|
||||
./start.sh
|
||||
```
|
||||
|
||||
That is it. The script will:
|
||||
The bootstrap will:
|
||||
|
||||
1. Locate your Hermes agent checkout automatically.
|
||||
2. Find (or create) a Python environment with the required dependencies.
|
||||
3. Start the server.
|
||||
4. Print the URL (and SSH tunnel command if you are on a remote machine).
|
||||
1. Detect Hermes Agent and, if missing, attempt the official installer (`curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash`).
|
||||
2. Find or create a Python environment with the WebUI dependencies.
|
||||
3. Start the web server and wait for `/health`.
|
||||
4. Open the browser unless you pass `--no-browser`.
|
||||
5. Drop you into a first-run onboarding wizard inside the WebUI.
|
||||
|
||||
> Native Windows is not supported for this bootstrap yet. Use Linux, macOS, or WSL2.
|
||||
|
||||
If provider setup is still incomplete after install, the onboarding wizard will point you to finish it with `hermes model` instead of trying to replicate the full CLI setup in-browser.
|
||||
|
||||
---
|
||||
|
||||
@@ -100,14 +127,23 @@ That is it. The script will:
|
||||
|
||||
**Pre-built images** (amd64 + arm64) are published to GHCR on every release:
|
||||
|
||||
Make sure the `HERMES_WEBUI_STATE_DIR` (by default `~/.hermes/webui-mvp`, as detailed in the `.env.example` file) folder exist with the UID/GID of the owner of the `.hermes` folder.
|
||||
The container will also mount your configured "workspace" (also from the example .env.example) as `/workspace`. adapt the location as needed.
|
||||
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/nesquena/hermes-webui:latest
|
||||
docker run -d -p 8787:8787 -v ~/.hermes:/root/.hermes ghcr.io/nesquena/hermes-webui:latest
|
||||
docker run -d \
|
||||
-e WANTED_UID=`id -u` -e WANTED_GID=`id -g` \
|
||||
-v ~/.hermes:/home/hermeswebui/.hermes -e HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui-mvp \
|
||||
-v ~/workspace:/workspace \
|
||||
-p 8787:8787 ghcr.io/nesquena/hermes-webui:latest
|
||||
```
|
||||
|
||||
Or run with Docker Compose (recommended):
|
||||
|
||||
```bash
|
||||
# Check the docker-compose.yml and make sure to adapt as needed, at minimum WANTED_UID/WANTED_GID
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
@@ -115,7 +151,11 @@ Or build locally:
|
||||
|
||||
```bash
|
||||
docker build -t hermes-webui .
|
||||
docker run -d -p 8787:8787 -v ~/.hermes:/root/.hermes hermes-webui
|
||||
docker run -d \
|
||||
-e WANTED_UID=`id -u` -e WANTED_GID=`id -g` \
|
||||
-v ~/.hermes:/home/hermeswebui/.hermes -e HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui-mvp \
|
||||
-v ~/workspace:/workspace \
|
||||
-p 8787:8787 hermes-webui
|
||||
```
|
||||
|
||||
Open http://localhost:8787 in your browser.
|
||||
@@ -123,15 +163,119 @@ Open http://localhost:8787 in your browser.
|
||||
To enable password protection:
|
||||
|
||||
```bash
|
||||
docker run -d -p 8787:8787 -e HERMES_WEBUI_PASSWORD=your-secret -v ~/.hermes:/root/.hermes ghcr.io/nesquena/hermes-webui:latest
|
||||
docker run -d \
|
||||
-e WANTED_UID=`id -u` -e WANTED_GID=`id -g` \
|
||||
-v ~/.hermes:/home/hermeswebui/.hermes -e HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui-mvp \
|
||||
-v ~/workspace:/workspace \
|
||||
-p 8787:8787 -e HERMES_WEBUI_PASSWORD=your-secret ghcr.io/nesquena/hermes-webui:latest
|
||||
```
|
||||
|
||||
Session data persists in a named volume (`hermes-data`) across restarts.
|
||||
|
||||
> **Note:** By default, Docker Compose binds to `127.0.0.1` (localhost only).
|
||||
> To expose on a network, change the port to `"8787:8787"` in `docker-compose.yml`
|
||||
> and set `HERMES_WEBUI_PASSWORD` to enable authentication.
|
||||
|
||||
### Two-container setup (Agent + WebUI)
|
||||
|
||||
If you run the Hermes Agent in its own Docker container and want the WebUI
|
||||
in a separate container:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.two-container.yml up -d
|
||||
```
|
||||
|
||||
This starts both containers with shared volumes:
|
||||
|
||||
- **`hermes-home`** — shared `~/.hermes` for config, sessions, skills, memory
|
||||
- **`hermes-agent-src`** — the agent's source code, mounted into the WebUI
|
||||
container so it can install the agent's Python dependencies at startup
|
||||
|
||||
> **Volume type:** The compose files use named Docker volumes by default.
|
||||
> If you prefer bind mounts to an existing directory (e.g. for sharing state
|
||||
> with an agent container you already run), both containers must mount the
|
||||
> same host path — the agent writes to `/root/.hermes`, the WebUI reads from
|
||||
> `/home/hermeswebui/.hermes`. See `docker-compose.two-container.yml` for
|
||||
> a bind-mount example.
|
||||
|
||||
The WebUI's init script automatically installs hermes-agent and all its
|
||||
dependencies (openai, anthropic, etc.) into its own Python environment on
|
||||
first boot. Subsequent restarts reuse the installed packages.
|
||||
|
||||
> **How it works:** The WebUI imports hermes-agent's Python modules directly
|
||||
> (not via HTTP). The shared volume makes the agent source available, and
|
||||
> the init script runs `uv pip install` to set up the dependencies. Both
|
||||
> containers share the same `~/.hermes` directory for config and state.
|
||||
|
||||
See `docker-compose.two-container.yml` for the full configuration.
|
||||
|
||||
### Running alongside hermes-dashboard (three-container setup)
|
||||
|
||||
To run the Hermes Agent, Hermes Dashboard, and the WebUI together on a
|
||||
shared volume, use the three-container Compose file:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.three-container.yml up -d
|
||||
```
|
||||
|
||||
This brings up:
|
||||
- **`hermes-agent`** — gateway API on port 8642
|
||||
- **`hermes-dashboard`** — monitoring UI on port 9119
|
||||
- **`hermes-webui`** — browser chat interface on port 8787
|
||||
|
||||
All three services share the same `hermes-home` named volume so config,
|
||||
sessions, skills, and memory are consistent across all surfaces.
|
||||
|
||||
#### Why UIDs must match
|
||||
|
||||
The `hermes-home` volume is a bind-mount in practice — all three containers
|
||||
write to the same filesystem tree under `~/.hermes`. If the containers run
|
||||
as different UIDs, whichever container creates a file first becomes its
|
||||
owner, and the others hit `PermissionError` on subsequent writes.
|
||||
|
||||
The fix is to make all containers run as **your host user's UID and GID**.
|
||||
|
||||
#### Variable name asymmetry
|
||||
|
||||
> ⚠️ **The two image families use different environment variable names** for
|
||||
> the UID/GID setting:
|
||||
>
|
||||
> | Image | Variable |
|
||||
> |---|---|
|
||||
> | `nousresearch/hermes-agent` (agent + dashboard) | `HERMES_UID` / `HERMES_GID` |
|
||||
> | `ghcr.io/nesquena/hermes-webui` | `WANTED_UID` / `WANTED_GID` |
|
||||
>
|
||||
> You must set **both pairs** when using a `.env` file.
|
||||
|
||||
#### Recommended setup
|
||||
|
||||
For a standard Linux user (UID ≥ 1000):
|
||||
|
||||
```bash
|
||||
# Create a .env file with your host UID/GID
|
||||
echo "UID=$(id -u)" >> .env
|
||||
echo "GID=$(id -g)" >> .env
|
||||
# hermes-agent / hermes-dashboard
|
||||
echo "HERMES_UID=$(id -u)" >> .env
|
||||
echo "HERMES_GID=$(id -g)" >> .env
|
||||
```
|
||||
|
||||
For NAS/Unraid deployments where a fixed service account is preferred, use
|
||||
`10000:10000` (or your NAS service UID) instead of `$(id -u)`.
|
||||
|
||||
If you get `PermissionError` on an **existing** `~/.hermes` directory, run
|
||||
the one-time ownership fix:
|
||||
|
||||
```bash
|
||||
chown -R $(id -u):$(id -g) ~/.hermes
|
||||
```
|
||||
|
||||
#### Volume mount mode
|
||||
|
||||
The dashboard container needs **read-write** access to the shared volume
|
||||
(it writes session logs and dashboard state). Do **not** add `:ro` to the
|
||||
`hermes-home` volume in `hermes-dashboard`'s `volumes:` entry.
|
||||
|
||||
See `docker-compose.three-container.yml` for the full reference configuration.
|
||||
|
||||
---
|
||||
|
||||
## What start.sh discovers automatically
|
||||
@@ -154,6 +298,7 @@ If discovery finds everything, nothing else is required.
|
||||
export HERMES_WEBUI_AGENT_DIR=/path/to/hermes-agent
|
||||
export HERMES_WEBUI_PYTHON=/path/to/python
|
||||
export HERMES_WEBUI_PORT=9000
|
||||
export HERMES_WEBUI_AUTO_INSTALL=1 # enable auto-install of agent deps (disabled by default)
|
||||
./start.sh
|
||||
```
|
||||
|
||||
@@ -202,6 +347,40 @@ are running over SSH.
|
||||
|
||||
---
|
||||
|
||||
## Accessing on your phone with Tailscale
|
||||
|
||||
[Tailscale](https://tailscale.com) is a zero-config mesh VPN built on
|
||||
WireGuard. Install it on your server and your phone, and they join the same
|
||||
private network -- no port forwarding, no SSH tunnels, no public exposure.
|
||||
|
||||
The Hermes Web UI is fully responsive with a mobile-optimized layout
|
||||
(hamburger sidebar, sidebar top tabs in the drawer, touch-friendly controls),
|
||||
so it works well as a daily-driver agent interface from your phone.
|
||||
|
||||
**Setup:**
|
||||
|
||||
1. Install [Tailscale](https://tailscale.com/download) on your server and
|
||||
your iPhone/Android.
|
||||
2. Start the WebUI listening on all interfaces with password auth enabled:
|
||||
|
||||
```bash
|
||||
HERMES_WEBUI_HOST=0.0.0.0 HERMES_WEBUI_PASSWORD=your-secret ./start.sh
|
||||
```
|
||||
|
||||
3. Open `http://<server-tailscale-ip>:8787` in your phone's browser
|
||||
(find your server's Tailscale IP in the Tailscale app or with
|
||||
`tailscale ip -4` on the server).
|
||||
|
||||
That's it. Traffic is encrypted end-to-end by WireGuard, and password auth
|
||||
protects the UI at the application level. You can add it to your home screen
|
||||
for an app-like experience.
|
||||
|
||||
> **Tip:** If using Docker, set `HERMES_WEBUI_HOST=0.0.0.0` in your
|
||||
> `docker-compose.yml` environment (already the default) and set
|
||||
> `HERMES_WEBUI_PASSWORD`.
|
||||
|
||||
---
|
||||
|
||||
## Manual launch (without start.sh)
|
||||
|
||||
If you prefer to launch the server directly:
|
||||
@@ -237,8 +416,8 @@ Or using the agent venv explicitly:
|
||||
```
|
||||
|
||||
Tests run against an isolated server on port 8788 with a separate state directory.
|
||||
Production data and real cron jobs are never touched. Current count: **424 tests**
|
||||
across 22 test files.
|
||||
Production data and real cron jobs are never touched. Current count: **3309 tests**
|
||||
across 100+ test files.
|
||||
|
||||
---
|
||||
|
||||
@@ -250,7 +429,7 @@ across 22 test files.
|
||||
- Send a message while one is processing -- it queues automatically
|
||||
- Edit any past user message inline and regenerate from that point
|
||||
- Retry the last assistant response with one click
|
||||
- Cancel a running task from the activity bar
|
||||
- Cancel a running task directly from the composer footer (Stop button next to Send)
|
||||
- Tool call cards inline -- each shows the tool name, args, and result snippet; expand/collapse all toggle for multi-tool turns
|
||||
- Subagent delegation cards -- child agent activity shown with distinct icon and indented border
|
||||
- Mermaid diagram rendering inline (flowcharts, sequence diagrams, gantt charts)
|
||||
@@ -267,6 +446,7 @@ across 22 test files.
|
||||
|
||||
### Sessions
|
||||
- Create, rename, duplicate, delete, search by title and message content
|
||||
- Session actions via `⋯` dropdown per session — pin, move to project, archive, duplicate, delete
|
||||
- Pin/star sessions to the top of the sidebar (gold indicator)
|
||||
- Archive sessions (hide without deleting, toggle to show)
|
||||
- Session projects -- named groups with colors for organizing sessions
|
||||
@@ -298,10 +478,11 @@ across 22 test files.
|
||||
- Hidden when browser doesn't support Web Speech API (Chrome, Edge, Safari)
|
||||
|
||||
### Profiles
|
||||
- Profile picker in the topbar -- purple chip with dropdown showing all profiles
|
||||
- Profile chip in the **composer footer** -- dropdown showing all profiles with gateway status and model info
|
||||
- Gateway status dots (green = running), model info, skill count per profile
|
||||
- Profiles management panel -- create, switch, and delete profiles from the sidebar
|
||||
- Clone config from active profile on create
|
||||
- Optional custom endpoint fields on create -- Base URL and API key written into the profile's `config.yaml` at creation time, so Ollama, LMStudio, and other local endpoints can be configured without editing files manually
|
||||
- Seamless switching -- no server restart; reloads config, skills, memory, cron, models
|
||||
- Per-session profile tracking (records which profile was active at creation)
|
||||
|
||||
@@ -314,17 +495,25 @@ across 22 test files.
|
||||
- 20MB POST body size limit
|
||||
- CDN resources pinned with SRI integrity hashes
|
||||
|
||||
### Themes
|
||||
- 7 built-in themes: Dark (default), Light, Slate, Solarized Dark, Monokai, Nord, OLED
|
||||
- Switch via Settings panel dropdown (instant live preview) or `/theme` command
|
||||
- Persists across reloads (server-side in settings.json + localStorage for flicker-free loading)
|
||||
- Custom themes: define a `:root[data-theme="name"]` CSS block and it works — see [THEMES.md](THEMES.md)
|
||||
|
||||
### Settings and configuration
|
||||
- Settings panel (gear icon) -- default model, default workspace, send key preference
|
||||
- **Hermes Control Center** (sidebar launcher button) -- Conversation tab (export/import/clear), Preferences tab (model, send key, theme, language, all toggles), System tab (version, password)
|
||||
- Send key: Enter (default) or Ctrl/Cmd+Enter
|
||||
- Show/hide CLI sessions toggle (enabled by default)
|
||||
- Token usage display toggle (off by default, also via `/usage` command)
|
||||
- Control Center always opens on the Conversation tab; resets on close
|
||||
- Unsaved changes guard -- discard/save prompt when closing with unpersisted changes
|
||||
- Cron completion alerts -- toast notifications and unread badge on Tasks tab
|
||||
- Background agent error alerts -- banner when a non-active session encounters an error
|
||||
|
||||
### Slash commands
|
||||
- Type `/` in the composer for autocomplete dropdown
|
||||
- Built-in: `/help`, `/clear`, `/model <name>`, `/workspace <name>`, `/new`, `/usage`
|
||||
- Built-in: `/help`, `/clear`, `/compress [focus topic]`, `/compact` (alias), `/model <name>`, `/workspace <name>`, `/new`, `/usage`, `/theme`
|
||||
- Arrow keys navigate, Tab/Enter select, Escape closes
|
||||
- Unrecognized commands pass through to the agent
|
||||
|
||||
@@ -339,10 +528,10 @@ across 22 test files.
|
||||
|
||||
### Mobile responsive
|
||||
- Hamburger sidebar -- slide-in overlay on mobile (<640px)
|
||||
- Bottom navigation bar -- 5-tab iOS-style fixed bar
|
||||
- Sidebar top tabs stay available on mobile; no fixed bottom nav stealing chat height
|
||||
- Files slide-over panel from right edge
|
||||
- Touch targets minimum 44px on all interactive elements
|
||||
- Composer positioned above bottom nav
|
||||
- Full-height chat/composer on phones without bottom-nav spacing
|
||||
- Desktop layout completely unchanged
|
||||
|
||||
---
|
||||
@@ -350,31 +539,33 @@ across 22 test files.
|
||||
## Architecture
|
||||
|
||||
```
|
||||
server.py HTTP routing shell + auth middleware (~83 lines)
|
||||
server.py HTTP routing shell + auth middleware (~154 lines)
|
||||
api/
|
||||
auth.py Optional password authentication, signed cookies (~149 lines)
|
||||
config.py Discovery, globals, model detection, reloadable config (~726 lines)
|
||||
helpers.py HTTP helpers, security headers (~71 lines)
|
||||
models.py Session model + CRUD + CLI bridge (~338 lines)
|
||||
profiles.py Profile state management, hermes_cli wrapper (~366 lines)
|
||||
routes.py All GET + POST route handlers (~1314 lines)
|
||||
streaming.py SSE engine, run_agent, cancel support (~332 lines)
|
||||
upload.py Multipart parser, file upload handler (~78 lines)
|
||||
auth.py Optional password authentication, signed cookies (~201 lines)
|
||||
config.py Discovery, globals, model detection, reloadable config (~1110 lines)
|
||||
helpers.py HTTP helpers, security headers (~175 lines)
|
||||
models.py Session model + CRUD + CLI bridge (~377 lines)
|
||||
onboarding.py First-run onboarding wizard, OAuth provider support (~507 lines)
|
||||
profiles.py Profile state management, hermes_cli wrapper (~411 lines)
|
||||
routes.py All GET + POST route handlers (~2250 lines)
|
||||
state_sync.py /insights sync — message_count to state.db (~113 lines)
|
||||
streaming.py SSE engine, run_agent, cancel support (~660 lines)
|
||||
updates.py Self-update check and release notes (~257 lines)
|
||||
upload.py Multipart parser, file upload handler (~82 lines)
|
||||
workspace.py File ops, workspace helpers, git detection (~288 lines)
|
||||
static/
|
||||
index.html HTML template (~388 lines)
|
||||
style.css All CSS incl. mobile responsive (~726 lines)
|
||||
ui.js DOM helpers, renderMd, tool cards, context indicator (~1063 lines)
|
||||
workspace.js File preview, file ops, git badge (~247 lines)
|
||||
sessions.js Session CRUD, collapsible groups, search (~589 lines)
|
||||
messages.js send(), SSE handlers, rAF throttle (~352 lines)
|
||||
panels.js Cron, skills, memory, profiles, settings (~1146 lines)
|
||||
commands.js Slash command autocomplete (~170 lines)
|
||||
boot.js Mobile nav, voice input, boot IIFE (~338 lines)
|
||||
index.html HTML template (~600 lines)
|
||||
style.css All CSS incl. mobile responsive, themes (~1050 lines)
|
||||
ui.js DOM helpers, renderMd, tool cards, context indicator (~1740 lines)
|
||||
workspace.js File preview, file ops, git badge (~286 lines)
|
||||
sessions.js Session CRUD, collapsible groups, search, reload recovery (~800 lines)
|
||||
messages.js send(), SSE handlers, live streaming, session recovery (~655 lines)
|
||||
panels.js Cron, skills, memory, profiles, settings (~1438 lines)
|
||||
commands.js Slash command autocomplete (~267 lines)
|
||||
boot.js Mobile nav, voice input, boot IIFE (~524 lines)
|
||||
tests/
|
||||
conftest.py Isolated test server (port 8788)
|
||||
test_sprint{1-23}.py 22 test files, 426 test functions
|
||||
test_regressions.py Permanent regression gate (23 tests)
|
||||
61 test files 961 test functions
|
||||
Dockerfile python:3.12-slim container image
|
||||
docker-compose.yml Compose with named volume and optional auth
|
||||
.github/workflows/ CI: multi-arch Docker build + GitHub Release on tag
|
||||
@@ -393,6 +584,139 @@ State lives outside the repo at `~/.hermes/webui-mvp/` by default
|
||||
- `TESTING.md` -- manual browser test plan and automated coverage reference
|
||||
- `CHANGELOG.md` -- release notes per sprint
|
||||
- `SPRINTS.md` -- forward sprint plan with CLI + Claude parity targets
|
||||
- `THEMES.md` -- theme system documentation, custom theme guide
|
||||
|
||||
## Contributors
|
||||
|
||||
Hermes WebUI is built with help from the open-source community. Every PR — whether merged directly or incorporated via batch release — shapes the project, and we're grateful to everyone who has taken the time to contribute.
|
||||
|
||||
**66 contributors have shipped code that landed in a release tag** as of v0.50.245. The full credit roll lives in [`CONTRIBUTORS.md`](CONTRIBUTORS.md). The highlights:
|
||||
|
||||
### Top contributors (by merged-PR count)
|
||||
|
||||
| # | Contributor | PRs | First → latest release |
|
||||
|---|---|---:|---|
|
||||
| 1 | [@franksong2702](https://github.com/franksong2702) | 22 | `v0.50.49` → `v0.50.245` |
|
||||
| 2 | [@bergeouss](https://github.com/bergeouss) | 18 | `v0.50.49` → `v0.50.240` |
|
||||
| 3 | [@aronprins](https://github.com/aronprins) | 8 | `v0.47.0` → `v0.50.77` |
|
||||
| 4 | [@iRonin](https://github.com/iRonin) | 6 | `v0.41.0` |
|
||||
| 5 | [@24601](https://github.com/24601) | 6 | `v0.50.201` |
|
||||
| 6 | [@KingBoyAndGirl](https://github.com/KingBoyAndGirl) | 4 | `v0.50.232` → `v0.50.237` |
|
||||
| 7 | [@renheqiang](https://github.com/renheqiang) | 4 | `v0.50.93` |
|
||||
| 8 | [@ccqqlo](https://github.com/ccqqlo) | 3 | `v0.50.83` → `v0.50.207` |
|
||||
| 9 | [@deboste](https://github.com/deboste) | 3 | `v0.16.1` |
|
||||
| 10 | [@frap129](https://github.com/frap129) | 3 | `v0.50.157` → `v0.50.166` |
|
||||
|
||||
See [`CONTRIBUTORS.md`](CONTRIBUTORS.md) for the full ranked list of all 66 contributors, including everyone with one or two merged PRs and the special-thanks roll for design and architectural contributions.
|
||||
|
||||
### Notable contributions
|
||||
|
||||
**[@aronprins](https://github.com/aronprins)** — v0.50.0 UI overhaul (PR #242)
|
||||
The biggest single contribution to the project: a complete UI redesign that moved model/profile/workspace controls into the composer footer, replaced the gear-icon settings panel with the Hermes Control Center (tabbed modal), removed the activity bar in favor of inline composer status, redesigned the session list with a `⋯` action dropdown, and added the workspace panel state machine. 26 commits, thoroughly designed and iterated through multiple review rounds.
|
||||
|
||||
**[@iRonin](https://github.com/iRonin)** — Security hardening sprint (PRs #196–#204)
|
||||
Six consecutive security and reliability PRs: session memory leak fix (expired token pruning), Content-Security-Policy + Permissions-Policy headers, 30-second slow-client connection timeout, optional HTTPS/TLS support via environment variables, upstream branch tracking fix for self-update, and CLI session support in the file browser API. This is the kind of focused, high-quality security work that makes a self-hosted tool trustworthy.
|
||||
|
||||
**[@DavidSchuchert](https://github.com/DavidSchuchert)** — German translation (PR #190)
|
||||
Complete German locale (`de`) covering all UI strings, settings labels, commands, and system messages — and in doing so, stress-tested the i18n system and exposed several elements that weren't yet translatable, which got fixed as part of the same PR.
|
||||
|
||||
**[@Jordan-SkyLF](https://github.com/Jordan-SkyLF)** — Live streaming, session recovery, workspace fallback (PRs #366, #367)
|
||||
Three interlocking improvements: workspace fallback resolution so the server recovers gracefully when the configured workspace is deleted or unavailable; live reasoning cards that upgrade the generic thinking spinner to a real-time reasoning display as the model thinks; and durable session state recovery via `localStorage` so in-flight tool cards, partial assistant output, and the live SSE stream all survive a full page reload or session switch.
|
||||
|
||||
### Feature contributions
|
||||
|
||||
**[@gabogabucho](https://github.com/gabogabucho)** — Spanish locale + onboarding wizard (PRs #275, #285)
|
||||
Full Spanish (`es`) locale covering all 175 UI strings, plus the one-shot bootstrap onboarding wizard that guides new users through provider setup on first launch — the feature most responsible for new users actually getting started.
|
||||
|
||||
**[@bergeouss](https://github.com/bergeouss)** — Provider management UI + gateway sync + Docker hardening (18 PRs, `v0.50.49` → `v0.50.240`)
|
||||
Real-time gateway session sync (Telegram/Discord/Slack into the WebUI sidebar via SSE), the provider management UI for adding/editing custom providers from Settings, the two-container Docker setup docs, OAuth provider status detection, profile isolation hardening (per-profile `.env` secrets), and the bulk of what users see when they touch Settings → Providers.
|
||||
|
||||
**[@ccqqlo](https://github.com/ccqqlo)** — Terminal approval UX + custom model discovery + mobile close button (PRs #224, #225, #238, #333)
|
||||
A run of focused quality-of-life improvements: terminal tool approval prompts that stay visible long enough to actually be read, restored custom model API key discovery, and the redundant mobile close button fix that had been confusing users on narrow screens.
|
||||
|
||||
**[@kevin-ho](https://github.com/kevin-ho)** — OLED theme (PR #168)
|
||||
Added the 7th built-in theme: pure black backgrounds with warm accents tuned to reduce burn-in risk. Small diff, big impact for anyone on an OLED display.
|
||||
|
||||
**[@Bobby9228](https://github.com/Bobby9228)** — Mobile Profiles button + Android Chrome fixes (PRs #253, #263, #265)
|
||||
Added the Profiles entry to the mobile navigation flow, making profile switching reachable on phones, plus a set of Android Chrome-specific fixes for the profile dropdown.
|
||||
|
||||
**[@franksong2702](https://github.com/franksong2702)** — Most prolific external contributor (22 PRs, `v0.50.49` → `v0.50.245`)
|
||||
The session title guard, breadcrumb workspace navigation, mobile workspace panel sliver fix (#1300), composer footer container queries, streaming session sidebar exemption (#1327), session sidecar repair, cron output preservation (#1295), profile default workspace persistence, and a long tail of polish across the session sidebar, mobile responsive layout, and workspace state machine.
|
||||
|
||||
**[@betamod](https://github.com/betamod)** — Security hardening (PR #171)
|
||||
A comprehensive security audit PR covering CSRF protection, SSRF guards, XSS escaping improvements, and the env race condition between concurrent agent sessions — foundational security work that shipped in v0.39.0.
|
||||
|
||||
**[@TaraTheStar](https://github.com/TaraTheStar)** — Bot name + thinking blocks + login refactor (PRs #132, #176, #181)
|
||||
Made the assistant display name configurable throughout the UI, added thinking/reasoning block display in chat, and refactored the login page to use template variables instead of inline string replacement.
|
||||
|
||||
**[@thadreber-web](https://github.com/thadreber-web)** — CLI session bridge (PR #56)
|
||||
The original CLI session bridge: reads CLI sessions from the agent's SQLite state store and surfaces them in the WebUI sidebar. This was the first bridge between the CLI and WebUI session worlds.
|
||||
|
||||
**[@deboste](https://github.com/deboste)** — Reverse proxy auth + mobile responsive layout + model routing (PRs #3, #4, #5)
|
||||
Three of the very first community PRs: fixed EventSource/fetch to use the URL origin for reverse proxy setups, corrected model provider routing from config, and added mobile responsive layout with dvh viewport fix. Early foundation work.
|
||||
|
||||
### Bug fix and security contributions
|
||||
|
||||
**[@Hinotoi-agent](https://github.com/Hinotoi-agent)** — Profile .env secret isolation (PR #351)
|
||||
Fixed API key leakage between profiles on switch — switching from a profile with `OPENAI_API_KEY` to one without it left the key in the process environment for the duration of the session, effectively leaking credentials. A subtle and important security fix.
|
||||
|
||||
**[@lawrencel1ng](https://github.com/lawrencel1ng)** — Bandit security fixes B310/B324/B110 + QuietHTTPServer (PR #354)
|
||||
Systematic bandit security scan fixes: URL scheme validation before `urlopen`, MD5 `usedforsecurity=False`, and 40+ bare `except: pass` blocks replaced with proper logging — plus `QuietHTTPServer` to stop client-disconnect log spam from SSE streams.
|
||||
|
||||
**[@lx3133584](https://github.com/lx3133584)** — CSRF fix for reverse proxy on non-standard ports (PR #360)
|
||||
Fixed CSRF rejection for deployments behind Nginx Proxy Manager or similar on non-standard ports — a real-world blocker for anyone hosting on a port other than 80/443.
|
||||
|
||||
**[@DelightRun](https://github.com/DelightRun)** — session_search fix for WebUI sessions (PR #356)
|
||||
The `session_search` tool silently returned "Session database not available" in every WebUI session. Tracked down the missing `SessionDB` injection in the streaming path and fixed it.
|
||||
|
||||
**[@shaoxianbilly](https://github.com/shaoxianbilly)** — Unicode filename downloads (PR #378)
|
||||
Fixed `UnicodeEncodeError` crashes when downloading workspace files with Chinese, Japanese, or other non-ASCII names. Implemented proper `Content-Disposition` header with RFC 5987 `filename*=UTF-8''...` encoding.
|
||||
|
||||
**[@huangzt](https://github.com/huangzt)** — Cancel interrupts agent (PR #244)
|
||||
Made the Cancel button actually interrupt the running agent and clean up UI state, rather than just hiding the button while the agent kept running.
|
||||
|
||||
**[@tgaalman](https://github.com/tgaalman)** — Thinking card fix (PR #169)
|
||||
Fixed top-level reasoning fields being missed in the thinking card display — an edge case in how Claude's extended thinking blocks surface in the API response.
|
||||
|
||||
**[@smurmann](https://github.com/smurmann)** — Custom provider routing fix (PR #189)
|
||||
Fixed model routing for slash-prefixed custom provider models, which were being misrouted in the model selector. A precise fix for a real edge case in multi-provider setups.
|
||||
|
||||
**[@jeffscottward](https://github.com/jeffscottward)** — Claude Haiku model ID fix (PR #145)
|
||||
Caught and corrected the Claude Haiku model ID (`3-5` → `4-5`) immediately after the Anthropic release — the kind of quick community catch that keeps the model dropdown accurate.
|
||||
|
||||
**[@kcclaw001](https://github.com/kcclaw001)** — Credential redaction in API responses (PR #243)
|
||||
Added credential redaction to all API response paths so API keys, tokens, and other secrets in session data or error messages are masked before reaching the browser.
|
||||
|
||||
**[@mbac](https://github.com/mbac)** — Phantom "Custom" provider group fix (PR #191)
|
||||
Removed the phantom "Custom" optgroup that appeared in the model dropdown even when no custom provider was configured — a small but consistently confusing UI noise issue.
|
||||
|
||||
**[@andrewy-wizard](https://github.com/andrewy-wizard)** — Chinese localization (PR #177)
|
||||
Added Simplified Chinese (`zh`) locale to the WebUI. One of the first non-English locales and the most-used non-English locale in the codebase.
|
||||
|
||||
**[@mmartial](https://github.com/mmartial)** — Docker UID/GID matching (PR #237)
|
||||
Added Docker support for running as an arbitrary UID/GID matching the host user, eliminating permission issues with bind-mounted volumes — essential for Docker deployments where the host user isn't UID 1000.
|
||||
|
||||
**[@vCillusion](https://github.com/vCillusion)** — pip package resolution fix (PR #76)
|
||||
Fixed agent dependency resolution to prefer packages from the venv's site-packages over the agent directory itself, preventing shadowing bugs when developing locally.
|
||||
|
||||
**[@carlytwozero](https://github.com/carlytwozero)** — API key pass-through for non-Anthropic providers (PR #78)
|
||||
Fixed `api_key` not being passed to `AIAgent` for non-Anthropic `/anthropic` providers — a quiet regression that silently broke any non-default provider.
|
||||
|
||||
**[@mangodxd](https://github.com/mangodxd)** — Type hints cleanup (PR #115)
|
||||
Added missing type hints across 10 files and corrected 9 inaccurate existing ones — the kind of maintenance work that makes the codebase easier to reason about.
|
||||
|
||||
**[@Argonaut790](https://github.com/Argonaut790)** — HTML entity decode + Traditional Chinese locale (PR #239)
|
||||
Fixed double-escaping of HTML entities in `renderMd()` — LLM output containing `<code>` was being escaped a second time, rendering as literal text instead of the intended markdown. The same PR also completed the Simplified Chinese translation (40+ missing keys) and added a full Traditional Chinese (`zh-Hant`) locale.
|
||||
|
||||
**[@indigokarasu](https://github.com/indigokarasu)** — Visual redesign proposal: icon rail + design token system + 7 themes (PR #213)
|
||||
A CSS-only redesign of the full UI — proper design tokens (`--bg-primary`, `--text-info`, spacing scale), an icon rail sidebar replacing the emoji tab strip, consistent form cards, breadcrumb nav, and 7 built-in themes as custom properties. The PR didn't merge as-is but directly shaped the design language and theme architecture that shipped in v0.50.0.
|
||||
|
||||
**[@zenc-cp](https://github.com/zenc-cp)** — Anti-hallucination guard for ReAct loop (PR #133)
|
||||
Added a streaming token buffer and post-run message scrub to `streaming.py` to detect and strip fake tool execution JSON that weaker models write inline instead of calling tools properly. A three-layer approach: ephemeral anti-hallucination prompt, live token filtering, and session history cleanup. The pattern influenced later streaming.py improvements.
|
||||
|
||||
---
|
||||
|
||||
Want to contribute? See [ARCHITECTURE.md](ARCHITECTURE.md) for the codebase layout and [TESTING.md](TESTING.md) for how to run the test suite. The best contributions are focused, well-tested, and solve a real problem — exactly what every person on this list did.
|
||||
|
||||
## Repo
|
||||
|
||||
|
||||
82
ROADMAP.md
82
ROADMAP.md
@@ -3,8 +3,8 @@
|
||||
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
|
||||
> Everything you can do from the CLI terminal, you can do from this UI.
|
||||
>
|
||||
> Last updated: v0.31.2 (April 5, 2026)
|
||||
> Tests: 424 total (424 passing, 0 failures)
|
||||
> Last updated: v0.50.245 (April 30, 2026) — 3309 tests collected
|
||||
> Tests: `pytest tests/ --collect-only -q`
|
||||
> Source: <repo>/
|
||||
|
||||
---
|
||||
@@ -32,14 +32,60 @@
|
||||
| Sprint 13 | Alerts + polish | Cron completion alerts (polling + badge), background error banner, session duplicate, browser tab title | 221 |
|
||||
| Sprint 14 | Visual polish + workspace ops | Mermaid diagrams, message timestamps, file rename, folder create, session tags, session archive | 233 |
|
||||
| Sprint 15 | Session projects + code copy | Session projects/folders, code block copy button, tool card expand/collapse toggle | 237 |
|
||||
| Sprint 16 | Session sidebar visual polish | SVG action icons, overlay hover actions, pin indicator, project border, safe HTML rendering | 289 |
|
||||
| Sprint 16 | Session sidebar visual polish | SVG action icons, session action dropdown, pin indicator, project border, safe HTML rendering | 289 |
|
||||
| Sprint 17 | Workspace polish + slash commands + settings | Breadcrumb navigation, slash command autocomplete, send key setting (#26) | 318 |
|
||||
| Sprint 18 | Thinking display + workspace tree | File preview auto-close, thinking/reasoning cards, expandable directory tree (#22) | 318 |
|
||||
| Sprint 19 | Auth + security hardening | Password auth (off by default), login page, security headers, 20MB body limit (#23) | 328 |
|
||||
| Sprint 20 | Voice input + send button | Voice input (Web Speech API), send button icon-circle with pop-in animation | 415 |
|
||||
| Sprint 21 | Mobile responsive + Docker | Hamburger sidebar, bottom nav, files slide-over, Docker support (#21, #7) | 415 |
|
||||
| Sprint 21 | Mobile responsive + Docker | Hamburger sidebar, mobile nav, files slide-over, Docker support (#21, #7) | 415 |
|
||||
| Sprint 22 | Multi-profile support | Profile picker, management panel, seamless switching, per-session tracking (#28) | 415 |
|
||||
| Sprint 23 | Agentic transparency | Token/cost display, subagent cards, skill picker in cron, skill linked files, workspace tree persistence, timestamp fixes | 424 |
|
||||
| v0.44.0 patch | Fix batch: approval card, login CSP, update diagnostics, Lucide icons | PRs #221 #225 #226 #227 #228 | 579 |
|
||||
| v0.45.0 | Custom endpoint in new profile form | Base URL + API key fields; server-side URL validation; config.yaml merge; 9 new tests (PR #233, fixes #170) | 604 |
|
||||
| v0.46.0 | Security, Docker UID/GID, model discovery, i18n, cancel fix | Credential redaction in API responses (PR #243); Docker UID/GID matching (PR #237); custom model API key discovery (PR #238); HTML entity decode + zh/zh-Hant i18n (PR #239); cancel interrupts agent (PR #244); +20 tests | 624 |
|
||||
| v0.47.0 | Dialogs, session menu, skills command, mobile fixes, mobile QA | Shared app dialogs (#251); session ⋯ menu (#252); mobile QA suite (#254); custom provider slash routing fix (#255); Android Chrome mobile fixes (#256); /skills command (#257); +21 tests | 645 |
|
||||
| v0.47.1 | Spanish locale | Full Spanish (es) locale, 175 keys, key-parity tests (#275 @gabogabucho); +3 tests | 648 |
|
||||
| v0.48.0 | Gateway session sync | Real-time Telegram/Discord/Slack sessions in sidebar via SSE + DB polling (#274 @bergeouss); +10 tests | 658 |
|
||||
| v0.48.1 | Table inline formatting | `inlineMd()` in table cells — **bold**, *italic*, `code`, links render correctly (PR #278); 0 new tests | 658 |
|
||||
| v0.48.2 | Provider mismatch warning | Toast warning + auth_mismatch error type for provider/model mismatches (#283, fixes #266); +21 tests | 679 |
|
||||
| v0.49.1 | Docker docs + mobile Profiles button | Two-container Docker compose (#291/#288); Profiles added to the mobile navigation flow with correct panel wiring and SVG sizing (#297/#265 @gabogabucho); +3 tests | 700 |
|
||||
| v0.49.0 | First-run onboarding wizard + self-update hardening | One-shot bootstrap + guided setup wizard; provider config persisted to config.yaml + .env; OpenRouter/Anthropic/OpenAI/Custom; wizard hidden after completion (#285); self-update stderr/split-ref/conflict fixes (#287); skip flaky redaction test (#289); +18 tests | 697 |
|
||||
| v0.32 | Auto-compaction handling | Compression detection, /compact command, real context window indicator | 424 |
|
||||
| v0.33 | /insights sync | Opt-in state.db sync so `hermes /insights` includes WebUI sessions | 424 |
|
||||
| v0.34 | Sprint 26 — Pluggable themes | Dark, Light, Slate, Solarized, Monokai, Nord; settings unsaved-changes guard; /theme command | 433 |
|
||||
| v0.34.1 | Theme variable polish | 30+ hardcoded dark-navy colors replaced with theme-aware CSS variables | 433 |
|
||||
| v0.34.2 | Theme text colors | 5 new per-theme typography variables (--strong, --em, --code-text, --code-inline-bg, --pre-text) | 433 |
|
||||
| v0.34.3 | Light theme final polish | 46 light-scoped selector overrides for sidebar, roles, chips, interactive elements | 433 |
|
||||
| v0.35 | Security hardening | Env race fix, random signing key, upload path traversal, PBKDF2 password hash | 433 |
|
||||
| v0.36–v0.37 | Model routing, personality config, tool card reload, duplicate model fixes | Model routing by provider prefix, personality via config.yaml, tool cards reload on page refresh | 466 |
|
||||
| v0.38.0–v0.38.6 | Model selector, custom endpoints, OLED theme, reasoning display, insights sync | Custom endpoint URL fix, OLED theme, top-level reasoning field fix, message_count sync to state.db | 466 |
|
||||
| v0.39.0 | Security hardening (Sprint 29) | CSRF, PBKDF2, rate limiting, session ID validation, SSRF, ENV_LOCK, XSS, HMAC, skills traversal, secure cookie, error sanitization, startup warning | 499 |
|
||||
| v0.40–v0.44.2 | Approval card + Lucide icons + sprint auth | Approval prompt surfaced in UI, emoji icons → Lucide SVG, login CSP inline fix, update diagnostics | 579 |
|
||||
| v0.45–v0.46 | Custom endpoints + security + i18n + cancel | Custom endpoint Base URL + API key on profile create, credential redaction (PR #243), Docker UID/GID (PR #237), HTML entity decode + zh/zh-Hant i18n, cancel interrupts agent | 624 |
|
||||
| v0.47–v0.47.1 | Dialogs + session menu + skills + mobile QA + Spanish | Shared app dialogs, session ⋯ menu, /skills command, mobile QA suite, Android Chrome fixes, Spanish locale (@gabogabucho) | 648 |
|
||||
| v0.48–v0.48.2 | Gateway session sync + table formatting + provider warnings | Real-time Telegram/Discord/Slack sessions in sidebar (@bergeouss), inlineMd() in table cells, provider/model mismatch toast | 679 |
|
||||
| v0.49–v0.49.1 | Onboarding wizard + Docker two-container | One-shot bootstrap + guided setup wizard, OpenRouter/Anthropic/OpenAI/Custom provider config, two-container Docker compose, mobile Profiles button | 700 |
|
||||
| v0.50.0 | v0.50.0 UI overhaul (Sprint 34) | Composer-centric controls, Hermes Control Center modal, workspace panel state machine, collapsible date groups, rAF streaming throttle, context ring indicator (@aronprins) | 742 |
|
||||
| v0.50.5–v0.50.10 | Think-tag edge cases + onboarding hardening + mobile fixes | MiniMax M2.5 leading-whitespace think-tag fix, skip-onboarding env var, OAuth provider path, Docker bridge networks fix, model dropdown dedup, title auto-generation fix, mobile close button | 802 |
|
||||
| v0.50.11–v0.50.12 | Chat table styles + URL autolink + profile env isolation | .msg-body table borders, plain URL auto-linking, profile .env secret isolation on switch (prevents API key leakage across profiles, @Hinotoi-agent) | 815 |
|
||||
| v0.50.13–v0.50.15 | session_search + security sweep + KaTeX math | SessionDB injection for session_search in WebUI (@DelightRun), bandit B310/B324/B110 + QuietHTTPServer (@lawrencel1ng), KaTeX math rendering with fence-before-math fix | 871 |
|
||||
| v0.50.16–v0.50.17 | CSRF reverse proxy + Docker uv pre-install | Scheme-aware CSRF port normalization for non-standard ports (@lx3133584), Docker uv pre-installed at build time as root (fixes air-gapped startup, @mmartial-pattern) | 900 |
|
||||
| v0.50.18–v0.50.19 | Workspace fallback + Unicode filenames | Cascading workspace path recovery (@Jordan-SkyLF), Unicode Content-Disposition headers with RFC 5987 filename* (@shaoxianbilly), silent auth error surfacing, stale model cleanup | 924 |
|
||||
| v0.50.20–v0.50.21 | Silent errors + live model fetching + durable streaming recovery | apperror on empty agent response, /api/models/live endpoint with SSRF guard, live reasoning cards, tool_complete SSE events, SESSION_QUEUES, localStorage reload recovery (@Jordan-SkyLF) | 961 |
|
||||
| v0.50.22–v0.50.36-local.1 | Upstream sync + minimal local patch retention | Synced to upstream `v0.50.36`; retained first-password session continuity in Settings/onboarding; removed local Assistant Reply Language enhancement; added legacy settings cleanup regression coverage | 1059 |
|
||||
| v0.50.37–v0.50.40 | Sprint 40 — rendering fixes + KaTeX CSP + MEDIA images | Think-tag edge cases, renderMd link double-linking fix, MEDIA: inline image rendering, KaTeX CSP font-src fix | 1117 |
|
||||
| v0.50.41–v0.50.43 | Sprint 41/42 — context ring, session polish, renderMd hardening | Context indicator live usage, session display fixes, renderMd bold+code stash, outer link pass ordering, _ob_stash, autolink double-link fixes (@multiple contributors) | 1150 |
|
||||
| v0.50.44 | Renderer formatting bug fixes (#486, #487) | CSS: inline code sizing in table cells; JS: markdown image syntax  → <img> in renderMd + inlineMd; _img_stash for autolink protection | 1195 |
|
||||
| v0.50.45–v0.50.100 | Upstream sync + contributor sprint | Sidebar declutter, SKIP_ONBOARDING, runtime route details, subpath mount, bug batch (light theme/panel/model cache/Docker), Docker UID/GID auto-detect, chat transcript redesign, favicon SVG+PNG+ICO, Docker UID-mismatch crash fix, auto-title markdown strip | 1777 |
|
||||
| v0.50.101–v0.50.139 | Contributor sprint wave | Custom providers, Russian locale, collapsed timestamps, IME composition fixes, model-switch toast, approval queue multi-slot, live model fetching SSRF guard, orphaned tool-message sanitization, profile polish sprint (model routing, workspace cross-profile, legacy session backfill), font-size CSS fix | 1777 |
|
||||
| v0.50.140–v0.50.147 | Bug batch + appearance | Font size setting visibly scales UI text (#843), slash command echoed as user message (#840), scroll selected item into view (#838), tasks refresh button (#835), font size toggle (#833), stale model fix (#829), session search clear on boot (#822), gateway SSE polling fallback (#635) | 1858 |
|
||||
| v0.50.148–v0.50.150 | Session index + read-path + profile | Prune stale _index.json ghost rows after session-id rotation (#847 @franksong2702), GET /api/session side-effect-free model resolution (#848 @franksong2702), profile switching cookie persist + syncTopbar fix (#849 @migueltavares) | 1858 |
|
||||
| v0.50.151 | credential_pool + Ollama Cloud | Providers added via auth store credential_pool now visible in model dropdown; Ollama Cloud support; ambient gh-cli token suppression; _apply_provider_prefix helper (#820 @starship-s) | 1898 |
|
||||
| v0.50.152 | Image rendering + auto-title | image_generate MEDIA: token renders all https:// URLs as img regardless of extension (closes #853); auto-title strips Qwen3-style plain-text thinking preambles (closes #857) | 1898 |
|
||||
| v0.50.153 | Portal model routing | Live-fetched models from portal providers (Nous, OpenCode) now get @provider: prefix so they route correctly instead of falling through to OpenRouter (closes #854) | 1898 |
|
||||
| v0.50.154 | Thinking card mirror fix | _streamDisplay() early return removed — thinking card and main response now show distinct content when provider double-emits (closes #852) | 1898 |
|
||||
| v0.50.155 | Honcho session stability | gateway_session_key=session_id passed to AIAgent so Honcho per-session strategy maintains one Honcho session per WebUI chat instead of one per turn (closes #855) | 1903 |
|
||||
| v0.50.156 | Auto-install security gate | auto_install_agent_deps() is now opt-in; set HERMES_WEBUI_AUTO_INSTALL=1 to enable; _trusted_agent_dir() checks ownership/permission bits before running pip (⚠️ breaking: default changed) | 1903 |
|
||||
|
||||
---
|
||||
|
||||
@@ -47,14 +93,14 @@
|
||||
|
||||
| Layer | Location | Status |
|
||||
|-------|----------|--------|
|
||||
| Python server | <repo>/server.py (~81 lines) + api/ modules (~3210 lines) | Thin shell + auth middleware + business logic in api/ |
|
||||
| HTML template | <repo>/static/index.html (~364 lines) | Served from disk |
|
||||
| CSS | <repo>/static/style.css (~670 lines) | Served from disk, incl. mobile responsive |
|
||||
| JavaScript | <repo>/static/{ui,workspace,sessions,messages,panels,boot,commands}.js | 7 modules, ~3610 lines total |
|
||||
| Python server | <repo>/server.py (~165 lines) + api/ modules (~5000 lines) | Thin shell + QuietHTTPServer + auth middleware + business logic in api/ |
|
||||
| HTML template | <repo>/static/index.html (~600 lines) | Served from disk |
|
||||
| CSS | <repo>/static/style.css (~1050 lines) | Served from disk, incl. mobile responsive, KaTeX, table styles |
|
||||
| JavaScript | <repo>/static/{ui,workspace,sessions,messages,panels,boot,commands,icons,i18n,login}.js | 10 modules, ~7100 lines total |
|
||||
| Docker | Dockerfile, docker-compose.yml, .dockerignore | python:3.12-slim, multi-arch (amd64+arm64) |
|
||||
| CI/CD | .github/workflows/release.yml | Auto-release + GHCR publish on tag push |
|
||||
| Runtime state | ~/.hermes/webui-mvp/sessions/ | Session JSON files |
|
||||
| Test server | Port 8788, state dir ~/.hermes/webui-mvp-test/ | Isolated, wiped per run |
|
||||
| Test server | Port 8788 (conftest.py), port 8789 (browser sanity) | Isolated, wiped per run |
|
||||
| Production server | Port 8787 | SSH tunnel from Mac |
|
||||
|
||||
---
|
||||
@@ -64,11 +110,12 @@
|
||||
### Chat and Agent
|
||||
- [x] Send messages, get SSE-streaming responses
|
||||
- [x] Switch models per session (10 models, grouped by provider)
|
||||
- [x] Composer-scoped model picker in footer (moved from sidebar to align with per-conversation model selection)
|
||||
- [x] Multi-provider API support: use any Hermes agent API provider (OpenAI, Anthropic, Google, etc.) directly, not just OpenRouter (Sprint 11)
|
||||
- [x] Custom endpoint model discovery: auto-detect models from Ollama, LM Studio, and other local LLM servers via base_url (PR #18)
|
||||
- [x] Upload files to workspace (drag-drop, click, clipboard paste)
|
||||
- [x] File tray with remove button
|
||||
- [x] Tool progress shown in activity bar above composer
|
||||
- [x] Tool progress shown inline in the conversation via live tool cards
|
||||
- [x] Approval card for dangerous commands (Allow once/session/always, Deny)
|
||||
- [x] Approval polling + SSE-pushed approval events
|
||||
- [x] INFLIGHT guard: switch sessions mid-request without losing response
|
||||
@@ -80,23 +127,25 @@
|
||||
- [x] Token/cost estimate per message (Sprint 23)
|
||||
|
||||
### Tool Visibility
|
||||
- [x] Tool progress in activity bar (moved out of composer footer)
|
||||
- [x] Tool progress in live tool cards (kept out of the composer/footer chrome)
|
||||
- [x] Approval card with all 4 choices
|
||||
- [x] Tool call cards inline (collapsed, show name/args/result)
|
||||
|
||||
### Workspace / Files
|
||||
- [x] Workspace panel defaults closed and opens only for active browsing or preview
|
||||
- [x] Browse workspace directory tree with type icons
|
||||
- [x] Preview text/code files (read-only)
|
||||
- [x] Preview markdown files (rendered, tables supported)
|
||||
- [x] Preview image files (PNG, JPG, GIF, SVG, WEBP inline)
|
||||
- [x] Edit files inline (Edit button, Enter to save, Escape to cancel)
|
||||
- [x] Create new file (+ button in panel header)
|
||||
- [x] Delete file (hover trash, confirm dialog)
|
||||
- [x] Delete file (hover trash, confirmation modal)
|
||||
- [x] File name truncation with tooltip for long names
|
||||
- [x] Right panel resizable (drag inner edge)
|
||||
- [x] Syntax highlighted code preview (Prism.js)
|
||||
- [x] Rename file (Sprint 14)
|
||||
- [x] Create folder (Sprint 14)
|
||||
- [x] Shared app modal for confirm/input flows (Sprint 33)
|
||||
|
||||
### Sessions
|
||||
- [x] Create session (+ button or Cmd/Ctrl+K)
|
||||
@@ -186,7 +235,7 @@
|
||||
- [x] Voice input via Web Speech API (Sprint 20)
|
||||
|
||||
### Mobile
|
||||
- [x] Mobile responsive layout — hamburger sidebar, bottom nav, files slide-over (Sprint 21)
|
||||
- [x] Mobile responsive layout — hamburger sidebar, sidebar tabs on phones, files slide-over (Sprint 21 + later mobile nav simplification)
|
||||
|
||||
### Profiles
|
||||
- [x] Multi-profile support — create, switch, delete profiles (Sprint 22, Issue #28)
|
||||
@@ -197,16 +246,17 @@
|
||||
- [x] Streaming performance -- rAF-throttled token rendering (Sprint 24, PR #81)
|
||||
- [x] Workspace git detection -- branch name and dirty status badge (Sprint 24, PR #82)
|
||||
- [x] Collapsible date groups -- click group headers to collapse (Sprint 24, PR #80)
|
||||
- [x] Context usage indicator -- token count and cost in composer footer (Sprint 24, PR #83)
|
||||
- [x] Context usage indicator -- compact circular badge in composer footer (Sprint 24, PR #83; refreshed April 10, 2026)
|
||||
- [ ] LLM-generated session titles -- auto-title via small model instead of first-message substring (PR #75)
|
||||
- [ ] Workspace git detection -- show branch name, dirty status in workspace header (PR #75)
|
||||
- [ ] Clarify dialog -- agent can ask clarifying questions that block until user responds (PR #75)
|
||||
- [ ] Gateway approval polling -- support blocking approvals from messaging gateway (PR #75)
|
||||
- [ ] Unified session storage -- SessionDB shared between webui and CLI (PR #75)
|
||||
- [ ] TTS playback of responses (deferred)
|
||||
- [x] Background task cancel (activity bar Cancel button)
|
||||
- [x] Background task cancel (composer footer stop button)
|
||||
- [ ] Code execution cell (deferred)
|
||||
- [ ] Desktop application (deferred)
|
||||
- [ ] Desktop application (Sprint 25, PLANNED)
|
||||
- [x] Pluggable UI themes -- Dark, Light, Slate, Solarized, Monokai, Nord (Sprint 26, v0.34)
|
||||
- [ ] Extended slash command / skill integration (deferred)
|
||||
- [ ] Virtual scroll for large lists (deferred)
|
||||
|
||||
|
||||
333
SPRINTS.md
333
SPRINTS.md
@@ -1,32 +1,29 @@
|
||||
# Hermes Web UI -- Forward Sprint Plan
|
||||
|
||||
> Current state: v0.32 | 424 tests | Daily driver ready
|
||||
> This document plans the path from here to two targets:
|
||||
> Current state: v0.50.245 | 3309 tests | Full daily driver — CLI parity achieved
|
||||
>
|
||||
> Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the
|
||||
> terminal, you can do from the browser)
|
||||
> NOTE: This file is preserved as a historical planning record. Current sprint state
|
||||
> and version history live in CHANGELOG.md and ROADMAP.md.
|
||||
>
|
||||
> Target B: 1:1 parity with Claude's reproducible features (the full Claude
|
||||
> browser UI experience, minus things only Anthropic can build)
|
||||
> Target A (CLI parity): ✅ Complete — all core tools, workspace, cron, skills,
|
||||
> memory, sessions, profiles, model routing, streaming, voice, mobile.
|
||||
>
|
||||
> Sprints are ordered by impact. Each builds on the one before.
|
||||
> Past sprint history lives in CHANGELOG.md.
|
||||
> Target B (Claude parity): ~90% — thinking display, math rendering (KaTeX),
|
||||
> tool cards, workspace preview, onboarding, settings panel all done.
|
||||
> Remaining: full subagent transparency UI, file diff viewer.
|
||||
>
|
||||
> Last meaningful update: v0.50.245 (April 30, 2026). See CHANGELOG.md for full history.
|
||||
|
||||
---
|
||||
|
||||
## Where we are now (v0.21)
|
||||
## Where we are now (v0.50.245 — updated April 2026)
|
||||
|
||||
**CLI parity: ~90% complete.** Core agent loop, all tools visible, workspace
|
||||
file ops with tree view, cron/skills/memory CRUD, session management, streaming,
|
||||
cancel, multi-provider models, custom endpoint discovery, slash commands,
|
||||
thinking/reasoning display, password auth -- all solid. Gaps are subagent
|
||||
visibility, toolset control, and code execution.
|
||||
> The sections below describe the original sprint plans (Sprints 11–17) for historical reference.
|
||||
> See ROADMAP.md for the full sprint history table (v0.36 → v0.50.245) and CHANGELOG.md for per-version release notes.
|
||||
|
||||
**Claude parity: ~70% complete.** Chat, streaming, file browser, session
|
||||
management, tool cards, syntax highlighting, model switching, projects,
|
||||
settings, Mermaid diagrams, mobile layout, breadcrumb workspace nav, slash
|
||||
commands, thinking display, auth -- all present. Gaps are artifacts, voice,
|
||||
TTS, sharing, mobile-optimized layout.
|
||||
**CLI parity: ✅ Complete** as of the v0.50.x line. Core agent loop, all tools visible, workspace file ops with tree view and git detection, cron/skills/memory CRUD, session management, streaming with rAF throttle, cancel, multi-provider models, custom endpoint discovery, slash commands (help/clear/model/workspace/new/usage/theme/compact/queue/interrupt/steer/btw/reasoning), thinking/reasoning display, password auth, multi-profile support with seamless switching, CLI session bridge (read and import from state.db), context auto-compaction handling, self-update checker, embedded workspace terminal, archive upload (zip/tar), workspace directory CRUD.
|
||||
|
||||
**Claude parity: ~95% complete.** Chat, streaming with incremental markdown (vendored streaming-markdown@0.2.15), file browser with diff/JSON/YAML/CSV/Excalidraw inline rendering, PDF/SVG/audio/video preview, session management with projects/tags, tool cards with subagent delegation, syntax highlighting, model switching with provider-aware default rehydration, Mermaid diagrams, full mobile responsive layout (container queries on composer, slide-over workspace), breadcrumb workspace nav with tree view, slash commands, thinking/reasoning display, auth with signed cookies, 8 pluggable UI themes (dark/light/system/slate/solarized/monokai/nord/Sienna/OLED), voice input (Web Speech API) and TTS playback, collapsible date groups, context ring usage indicator, token/cost display, git branch badge, Docker support with HEALTHCHECK, batch session select mode, configurable model badges, MCP server management UI, cron run-status tracking with watch mode, PWA manifest. Remaining gaps: artifacts sharing/public URLs, code execution inline cells.
|
||||
|
||||
---
|
||||
|
||||
@@ -75,7 +72,7 @@ heavy agentic work.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 12 -- Settings Panel + Reliability + Session QoL
|
||||
## Sprint 12 -- Settings Panel + Reliability + Session QoL (COMPLETED)
|
||||
|
||||
**Theme:** Persist your preferences, survive network blips, and organize sessions.
|
||||
|
||||
@@ -118,7 +115,7 @@ to keep important conversations accessible.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 13 -- Alerts, Session QoL, Polish
|
||||
## Sprint 13 -- Alerts, Session QoL, Polish (COMPLETED)
|
||||
|
||||
**Theme:** Know what Hermes is doing, and small quality-of-life wins.
|
||||
|
||||
@@ -248,7 +245,7 @@ inconsistently across platforms. These were the most common visual complaints.
|
||||
button now only appears in the hover overlay like all other actions.
|
||||
|
||||
### Track B: Features
|
||||
- **SVG action icons.** Replaced all emoji HTML entities (★, 📂, 📦, ⊕, 🗑)
|
||||
- **SVG action icons.** Replaced old symbol and emoji HTML entities
|
||||
with monochrome SVG line icons that inherit `currentColor`. Consistent
|
||||
rendering across macOS, Linux, and Windows. Icons: pin (star), folder,
|
||||
archive (box), duplicate (overlapping squares), trash (bin with lines).
|
||||
@@ -511,15 +508,14 @@ single default profile, blocking multi-persona workflows.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 23 -- Profile/Workspace/Model Coherence (COMPLETED)
|
||||
## Sprint 23 -- Agentic Transparency + Context Visibility (COMPLETED)
|
||||
|
||||
**Theme:** Make profiles, workspaces, models, and sessions coherent across
|
||||
profile switches.
|
||||
**Theme:** Surface what the agent is doing and how much context it's using.
|
||||
|
||||
**Why now:** Sprint 22 added profile switching but five coherence bugs remained:
|
||||
the model picker ignored the profile's default, workspaces were a global file,
|
||||
DEFAULT_WORKSPACE was a startup singleton, the session list showed all profiles,
|
||||
and switchToProfile() didn't refresh workspaces or sessions.
|
||||
**Why now:** Users had no visibility into tool call arguments, session token
|
||||
usage, or context window fill. Sprint 22 left five coherence bugs in the
|
||||
profile/workspace/model flow that also needed closing before the UI felt
|
||||
reliable.
|
||||
|
||||
### Track A: Bugs
|
||||
- **Model picker ignores profile on switch.** `populateModelDropdown()` skipped
|
||||
@@ -611,12 +607,9 @@ the app to others.
|
||||
CSS `contain: strict` + IntersectionObserver approach, no library needed.
|
||||
|
||||
### Track C: Code Quality
|
||||
- **SPRINTS.md + ROADMAP.md + CHANGELOG.md updated** to reflect Sprint 23
|
||||
completion (agentic transparency) and correct test counts.
|
||||
- **Remove stale Sprint 23 description** from SPRINTS.md (the "Profile/Workspace
|
||||
coherence" text is from an older plan; Sprint 23 actually shipped agentic
|
||||
transparency features).
|
||||
- **CHANGELOG entry for v0.29** covering Sprint 23 deliverables.
|
||||
- Audit and remove any remaining dead code introduced by Sprint 23 (e.g. `S.lastUsage` assignment in messages.js that nothing reads).
|
||||
- Verify tool call args render correctly in settled history cards on session reload.
|
||||
- Update test count in all docs to match actual pytest output after sprint merges.
|
||||
|
||||
**Estimated tests:** ~10 new. Target total: ~435.
|
||||
**Hermes CLI parity impact:** Low
|
||||
@@ -758,7 +751,7 @@ Both architectures in one .app. No separate downloads needed.
|
||||
- JS bridge fires when approval card appears/disappears
|
||||
|
||||
**Menu bar mode (optional, v2):**
|
||||
- A small status bar item (⚗️ icon in menu bar) that opens a compact popover
|
||||
- A small status bar item (beaker icon in menu bar) that opens a compact popover
|
||||
- Popover shows current session status, last message, quick-compose field
|
||||
- Useful for running Hermes in the background without a full window
|
||||
|
||||
@@ -897,6 +890,270 @@ genuinely differentiating for an open-source project
|
||||
|
||||
---
|
||||
|
||||
*Last updated: April 5, 2026*
|
||||
*Current version: v0.32 | 424 tests*
|
||||
## Sprint 26 -- Pluggable UI Themes (COMPLETED)
|
||||
|
||||
**Theme:** Let users choose how the app looks -- light, dark, and custom color
|
||||
schemes. One-click switching, persistent preference, zero flicker on load.
|
||||
|
||||
**Difficulty: Low-Medium.** The existing CSS is already 100% CSS-variable-driven
|
||||
off a single `:root` block. Every color, background, and accent in the entire UI
|
||||
is already a variable. Adding themes is mostly a matter of defining alternative
|
||||
`:root` overrides and wiring a picker -- not a rewrite. The main engineering
|
||||
work is flicker prevention on load and the settings UI.
|
||||
|
||||
**Estimated effort:** 1 sprint, ~2 days of implementation. 8-12 new tests.
|
||||
|
||||
---
|
||||
|
||||
### Why now
|
||||
|
||||
The UI ships only one dark theme. Contributors have asked for light mode. Power
|
||||
users want to match their terminal colorscheme. This is low-risk, high-value
|
||||
polish that makes the app feel more finished and more personal. It's also a
|
||||
good precedent-setter: once the theme system exists, community members can
|
||||
contribute new themes as a pure CSS addition with no Python changes needed.
|
||||
|
||||
---
|
||||
|
||||
### Design decisions
|
||||
|
||||
**Themes are CSS-variable overrides, not separate stylesheets.** Each theme is
|
||||
a named `:root[data-theme="name"]` block. The base stylesheet stays untouched.
|
||||
Switching themes sets `document.documentElement.dataset.theme = name` in JS.
|
||||
No FOUC (flash of unstyled content), no stylesheet swap latency.
|
||||
|
||||
**Theme preference persists server-side in `settings.json`.** Same mechanism
|
||||
as `send_key` and `show_token_usage`. The server includes `theme` in the
|
||||
`GET /api/settings` response. Boot.js reads it and applies before first paint.
|
||||
|
||||
**Flicker prevention.** A tiny inline `<script>` in `<head>` (before the
|
||||
stylesheet link) reads `localStorage.getItem('hermes-theme')` and sets
|
||||
`document.documentElement.dataset.theme` synchronously. This prevents a
|
||||
dark-flash on light-mode users during the round-trip to `/api/settings`.
|
||||
The localStorage value is kept in sync whenever the user changes themes.
|
||||
|
||||
**No third-party dependencies.** Pure CSS + vanilla JS. No theme library.
|
||||
|
||||
---
|
||||
|
||||
### Track A: Core theme system
|
||||
|
||||
**1. CSS variable blocks in `static/style.css`**
|
||||
|
||||
The existing `:root` block becomes the `dark` (default) theme. Add named
|
||||
theme blocks immediately after:
|
||||
|
||||
```css
|
||||
/* ── Default (dark) theme ── already in :root ── */
|
||||
|
||||
:root[data-theme="light"] {
|
||||
--bg: #f5f5f7;
|
||||
--sidebar: #e8e8ed;
|
||||
--border: rgba(0,0,0,0.10);
|
||||
--border2: rgba(0,0,0,0.16);
|
||||
--text: #1c1c1e;
|
||||
--muted: #6e6e80;
|
||||
--accent: #c0392b;
|
||||
--blue: #0a6dc2;
|
||||
--gold: #a07a20;
|
||||
--code-bg: #f0f0f5;
|
||||
}
|
||||
|
||||
:root[data-theme="solarized"] {
|
||||
--bg: #002b36;
|
||||
--sidebar: #073642;
|
||||
--border: rgba(255,255,255,0.08);
|
||||
--border2: rgba(255,255,255,0.13);
|
||||
--text: #839496;
|
||||
--muted: #657b83;
|
||||
--accent: #dc322f;
|
||||
--blue: #268bd2;
|
||||
--gold: #b58900;
|
||||
--code-bg: #073642;
|
||||
}
|
||||
|
||||
:root[data-theme="monokai"] {
|
||||
--bg: #272822;
|
||||
--sidebar: #1e1f1c;
|
||||
--border: rgba(255,255,255,0.07);
|
||||
--border2: rgba(255,255,255,0.12);
|
||||
--text: #f8f8f2;
|
||||
--muted: #75715e;
|
||||
--accent: #f92672;
|
||||
--blue: #66d9e8;
|
||||
--gold: #e6db74;
|
||||
--code-bg: #1e1f1c;
|
||||
}
|
||||
|
||||
:root[data-theme="nord"] {
|
||||
--bg: #2e3440;
|
||||
--sidebar: #272c36;
|
||||
--border: rgba(255,255,255,0.07);
|
||||
--border2: rgba(255,255,255,0.12);
|
||||
--text: #eceff4;
|
||||
--muted: #9099aa;
|
||||
--accent: #bf616a;
|
||||
--blue: #81a1c1;
|
||||
--gold: #ebcb8b;
|
||||
--code-bg: #272c36;
|
||||
}
|
||||
```
|
||||
|
||||
Additional theming notes:
|
||||
- `syntax-highlight` colors (Prism.js) are theme-independent (they come from the
|
||||
CDN stylesheet) -- acceptable for v1.
|
||||
- The logo gradient (`linear-gradient(145deg,#e8a030,var(--accent))`) uses
|
||||
`--accent` already so it adapts automatically.
|
||||
- Scrollbar colors and `::selection` backgrounds need explicit overrides in the
|
||||
light theme to avoid dark scrollbars on a light background.
|
||||
|
||||
**2. Flicker-prevention inline script in `static/index.html`**
|
||||
|
||||
Immediately after `<head>` opens, before the stylesheet `<link>`:
|
||||
|
||||
```html
|
||||
<script>
|
||||
(function(){
|
||||
var t=localStorage.getItem('hermes-theme');
|
||||
if(t && t!=='dark') document.documentElement.dataset.theme=t;
|
||||
})();
|
||||
</script>
|
||||
```
|
||||
|
||||
This runs synchronously before the stylesheet parses. Zero flicker.
|
||||
|
||||
**3. Theme loading in `static/boot.js`**
|
||||
|
||||
In the existing `api('/api/settings')` call, read and apply the theme:
|
||||
|
||||
```js
|
||||
const s = await api('/api/settings');
|
||||
window._sendKey = s.send_key || 'enter';
|
||||
window._showTokenUsage = !!s.show_token_usage;
|
||||
window._showCliSessions = !!s.show_cli_sessions;
|
||||
// Theme: apply server preference, update localStorage for flicker prevention
|
||||
const theme = s.theme || 'dark';
|
||||
document.documentElement.dataset.theme = theme;
|
||||
localStorage.setItem('hermes-theme', theme);
|
||||
```
|
||||
|
||||
**4. Theme setting in `api/config.py`**
|
||||
|
||||
```python
|
||||
_SETTINGS_DEFAULTS = {
|
||||
...
|
||||
'theme': 'dark', # active UI theme name
|
||||
...
|
||||
}
|
||||
_SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {'password_hash'}
|
||||
```
|
||||
|
||||
No enum constraint on `theme` -- allows user-defined theme names to work
|
||||
without server changes.
|
||||
|
||||
---
|
||||
|
||||
### Track B: Theme picker UI
|
||||
|
||||
**Settings panel addition (`static/index.html` + `static/panels.js`)**
|
||||
|
||||
A `<select>` in the Settings panel, below the send-key picker:
|
||||
|
||||
```html
|
||||
<div class="settings-field">
|
||||
<label for="settingsTheme">Theme</label>
|
||||
<select id="settingsTheme" ...>
|
||||
<option value="dark">Dark (default)</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="solarized">Solarized Dark</option>
|
||||
<option value="monokai">Monokai</option>
|
||||
<option value="nord">Nord</option>
|
||||
</select>
|
||||
</div>
|
||||
```
|
||||
|
||||
In `loadSettingsPanel()`:
|
||||
```js
|
||||
const themeSel = $('settingsTheme');
|
||||
if(themeSel) themeSel.value = settings.theme || 'dark';
|
||||
```
|
||||
|
||||
In `saveSettings()`:
|
||||
```js
|
||||
body.theme = $('settingsTheme').value;
|
||||
```
|
||||
|
||||
**Live preview on select change (no save required):**
|
||||
```js
|
||||
$('settingsTheme').addEventListener('change', e => {
|
||||
document.documentElement.dataset.theme = e.target.value;
|
||||
localStorage.setItem('hermes-theme', e.target.value);
|
||||
});
|
||||
```
|
||||
|
||||
This gives instant visual feedback as the user clicks through options.
|
||||
The full settings save then persists it server-side.
|
||||
|
||||
**`/theme` slash command (`static/commands.js`)**
|
||||
|
||||
```js
|
||||
async function cmdTheme(arg) {
|
||||
const themes = ['dark','light','solarized','monokai','nord'];
|
||||
if(!arg || !themes.includes(arg)) {
|
||||
showToast('Usage: /theme dark|light|solarized|monokai|nord');
|
||||
return;
|
||||
}
|
||||
document.documentElement.dataset.theme = arg;
|
||||
localStorage.setItem('hermes-theme', arg);
|
||||
try { await api('/api/settings', {method:'POST', body: JSON.stringify({theme: arg})}); } catch(e) {}
|
||||
showToast('Theme: ' + arg);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Track C: Tests
|
||||
|
||||
New test cases in `tests/test_sprint26.py`:
|
||||
|
||||
1. `GET /api/settings` returns `theme: 'dark'` by default
|
||||
2. `POST /api/settings` with `{theme: 'light'}` persists and round-trips
|
||||
3. `POST /api/settings` with `{theme: 'nord'}` accepts any string (no enum gate)
|
||||
4. Theme value survives server restart (reads from `settings.json`)
|
||||
5. `/theme` command fires without error for each named theme
|
||||
6. `loadSettingsPanel()` populates the select with the current theme value
|
||||
7. Settings save includes theme in the POST body
|
||||
8. `data-theme` attribute is set on `<html>` before first paint (inline script)
|
||||
|
||||
**Estimated new tests:** 8. Target total after sprint: ~443.
|
||||
|
||||
---
|
||||
|
||||
### What's out of scope
|
||||
|
||||
- **Custom color editors** (hex pickers for each variable): saves that for v2.
|
||||
The five shipped themes cover the main use cases. A custom theme can always
|
||||
be added by dropping a CSS block with no code changes.
|
||||
- **Per-session themes**: single global preference is the right call for v1.
|
||||
- **System `prefers-color-scheme` sync**: nice-to-have, low priority. The
|
||||
flicker-prevention script could be extended to read the media query if no
|
||||
explicit preference is set.
|
||||
- **Prism.js theme switching**: the code-block syntax highlighting comes from
|
||||
a CDN stylesheet. Swapping it requires a `<link>` swap and SRI re-check.
|
||||
Defer to a future sprint; the default Prism Tomorrow theme works on all
|
||||
current dark themes and is acceptable on light.
|
||||
|
||||
---
|
||||
|
||||
**Estimated tests:** 8 new. Target total: ~443.
|
||||
**Hermes CLI parity impact:** None
|
||||
**Claude parity impact:** Medium (Claude.ai has light/dark/system sync)
|
||||
**User-facing value:** High -- first thing many users ask for
|
||||
|
||||
---
|
||||
|
||||
*Last updated: April 12, 2026*
|
||||
*Current version: v0.49.1 | 700 tests*
|
||||
*Next sprint: Sprint 24 (Web Polish + Bug Fix Pass)*
|
||||
*Horizon sprint: Sprint 25 (macOS Desktop Application)*
|
||||
*Docs sweep policy: update markdown proactively during PR reviews and after significant releases*
|
||||
|
||||
227
TESTING.md
227
TESTING.md
@@ -1,15 +1,17 @@
|
||||
# Hermes Web UI: Browser Testing Plan
|
||||
|
||||
> This document is for manual browser testing by you or by a Claude browser agent.
|
||||
> It covers user-facing features of the UI through Sprint 22 (v0.24).
|
||||
> It covers user-facing features of the UI through v0.50.21 and later releases.
|
||||
> Each section is written as a step-by-step test procedure with expected outcomes.
|
||||
> A browser agent (e.g. Claude with Chrome access) can execute this plan directly.
|
||||
>
|
||||
> Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser.
|
||||
> Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}.
|
||||
>
|
||||
> Automated tests: 424 total (424 passing, 0 failures)
|
||||
> Automated coverage: 3309 tests collected via `pytest tests/ --collect-only -q`. Tests run on every PR via GitHub Actions on Python 3.11, 3.12, and 3.13. The suite covers the bootstrap/static wizard, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, CSS regression coverage for thinking/tool card animation, streaming session persistence, mobile layout breakpoints, locale parity across 9 languages, and ~700 issue/PR-pinned regression tests.
|
||||
> Run: `pytest tests/ -v --timeout=60`
|
||||
>
|
||||
> Local regression focus: verify that a previously closed workspace panel stays visually closed from first paint through boot completion on desktop refresh; there should be no brief open-then-close flash.
|
||||
|
||||
---
|
||||
|
||||
@@ -32,9 +34,11 @@ SETUP: Clear localStorage (DevTools > Application > Local Storage > delete herme
|
||||
STEPS:
|
||||
1. Navigate to http://localhost:8787
|
||||
EXPECT:
|
||||
- Dark background, Hermes logo in sidebar header
|
||||
- Dark background
|
||||
- Sidebar begins directly with the icon tab row; there is no dedicated branding header
|
||||
- Center area shows "What can I help with?" heading with suggestion buttons
|
||||
- Session list in sidebar is empty or shows existing sessions
|
||||
- Sidebar footer shows a single "Hermes WebUI" control-center button
|
||||
- No session is highlighted active
|
||||
- Send button is present but there is no input focus by default
|
||||
FAIL: Page shows error, blank white screen, or auto-creates a new session without user action.
|
||||
@@ -73,11 +77,11 @@ STEPS:
|
||||
EXPECT:
|
||||
- User message appears immediately in chat
|
||||
- Thinking dots (three animated dots) appear below
|
||||
- Status bar shows "Hermes is thinking..."
|
||||
- Send button becomes disabled (grayed out)
|
||||
- A red stop button appears in the composer footer while the turn is running
|
||||
- Within 10-30 seconds, Hermes responds with a three-word greeting
|
||||
- Thinking dots disappear
|
||||
- Send button re-enables
|
||||
- Send button re-enables and the stop button disappears
|
||||
- Session title in sidebar updates to reflect the first message
|
||||
FAIL: Message never appears, thinking dots never go away, Send button stays disabled forever.
|
||||
|
||||
@@ -144,7 +148,7 @@ FAIL: New session created, error thrown, or UI breaks.
|
||||
### T3.1: Model Dropdown Shows All Options
|
||||
SETUP: Any active session.
|
||||
STEPS:
|
||||
1. Look at the sidebar bottom: "Model" label and a dropdown
|
||||
1. Look at the composer footer: to the right of the attach/mic controls there is a model dropdown
|
||||
2. Click the dropdown to expand it
|
||||
EXPECT:
|
||||
- Provider groups visible: OpenAI, Anthropic, Other
|
||||
@@ -153,18 +157,30 @@ EXPECT:
|
||||
- Other group: Gemini 2.5 Pro, DeepSeek V3, Llama 4 Scout
|
||||
FAIL: Only 2 options visible, no groups, or missing models.
|
||||
|
||||
### T3.2: Model Chip Reflects Selection
|
||||
### T3.2: Model Dropdown Reflects Active Conversation
|
||||
SETUP: Active session.
|
||||
STEPS:
|
||||
1. Change model dropdown to "Claude Sonnet 4.6"
|
||||
EXPECT:
|
||||
- The blue chip in the topbar right updates to "Sonnet 4.6" immediately
|
||||
- NOT "GPT-5.4 Mini" (this was Bug B3, now fixed)
|
||||
- The composer footer dropdown stays on "Claude Sonnet 4.6"
|
||||
- Sending the next message uses that session model rather than an older one from another conversation
|
||||
STEPS (continued):
|
||||
2. Change model to "Gemini 2.5 Pro"
|
||||
EXPECT:
|
||||
- Chip updates to "Gemini 2.5 Pro" (not "GPT-5.4 Mini")
|
||||
FAIL: Chip shows wrong model name for any non-Sonnet selection.
|
||||
- The dropdown updates to "Gemini 2.5 Pro"
|
||||
- Switching away and back to the conversation restores the same model in the footer selector
|
||||
FAIL: Dropdown shows the wrong active model after a session switch, or sending uses a stale model.
|
||||
|
||||
### T3.3: Context Badge Shares Footer Space Cleanly
|
||||
SETUP: Active session with at least one completed response.
|
||||
STEPS:
|
||||
1. Look at the right side of the composer footer
|
||||
EXPECT:
|
||||
- A compact circular context badge appears next to the send button when usage data is available
|
||||
- The number in the center shows the used percentage
|
||||
- Hovering or focusing the badge shows a tooltip with percent used, token count, auto-compress threshold, and estimated cost when available
|
||||
- The model dropdown remains usable without overlapping the send button or pushing controls out of view
|
||||
FAIL: Linear meter still shown, tooltip missing/incomplete, controls overlap, or footer wraps in a broken way.
|
||||
|
||||
---
|
||||
|
||||
@@ -224,12 +240,48 @@ EXPECT:
|
||||
- If it was the only file, tray collapses
|
||||
FAIL: File not removed, error.
|
||||
|
||||
### T4.6: Inline Audio Attachment Editor with Variable Speed
|
||||
SETUP: Active session, an audio file ready locally (`.mp3`, `.wav`, `.m4a`, `.ogg`, or `.flac`).
|
||||
STEPS:
|
||||
1. Attach the audio file with the paperclip or drag/drop
|
||||
2. Confirm the tray shows an audio media chip, then send the message
|
||||
3. In the sent user message, press Play on the inline audio player
|
||||
4. Click 0.5×, 1.25×, 1.5×, and 2× speed buttons
|
||||
EXPECT:
|
||||
- The audio renders inline in the chat instead of only as a download/file badge
|
||||
- Native audio controls are visible and usable
|
||||
- The clicked speed button becomes active and playback speed changes immediately
|
||||
- Download/open behavior for non-media files is unchanged
|
||||
FAIL: Audio only downloads, no speed buttons appear, or speed buttons do not affect playback.
|
||||
|
||||
### T4.7: Inline Video Attachment Editor with Variable Speed
|
||||
SETUP: Active session, a video file ready locally (`.mp4`, `.mov`, `.webm`, or `.m4v`).
|
||||
STEPS:
|
||||
1. Attach and send the video file
|
||||
2. In the sent user message, play the inline video
|
||||
3. Switch among 0.75×, 1×, 1.5×, and 2× speed controls
|
||||
EXPECT:
|
||||
- The video renders inline, contained within the message width
|
||||
- Native video controls are visible and usable
|
||||
- Speed selection updates the video `playbackRate` without reloading the media
|
||||
FAIL: Video only shows a generic badge, overflows the chat column, or speed controls fail.
|
||||
|
||||
---
|
||||
|
||||
## Section 5: Workspace File Browser
|
||||
|
||||
### T5.1: File Tree Loads on Session Start
|
||||
### T5.0: Panel Is Closed By Default
|
||||
SETUP: Active session with workspace set.
|
||||
EXPECT:
|
||||
- Right workspace panel is hidden on initial load
|
||||
- Center chat column uses the freed width
|
||||
- "Files" toggle is visible in the topbar
|
||||
FAIL: Right panel starts open without any browsing or preview action.
|
||||
|
||||
### T5.1: File Tree Loads When Files Panel Is Opened
|
||||
SETUP: Active session with workspace set.
|
||||
STEPS:
|
||||
1. Click the "Files" toggle in the topbar
|
||||
EXPECT:
|
||||
- Right panel shows "WORKSPACE" header
|
||||
- File tree lists files and directories in the workspace
|
||||
@@ -263,10 +315,11 @@ STEPS:
|
||||
1. Click the X button in the panel header
|
||||
EXPECT:
|
||||
- Preview closes
|
||||
- File tree is visible again
|
||||
- If the panel auto-opened for that preview, the entire right panel closes again
|
||||
- If the panel was manually opened for browsing first, the file tree is visible again
|
||||
- Preview area is hidden
|
||||
- Reopening the same file shows fresh content (no stale cached text)
|
||||
FAIL: X button does nothing, tree does not reappear.
|
||||
FAIL: X button does nothing, panel stays stuck open, or the file tree does not reappear after manual browse mode.
|
||||
|
||||
### T5.5: Preview an Image File (Sprint 2)
|
||||
SETUP: Upload a PNG, JPG, or any image file to the workspace, OR the workspace already contains one.
|
||||
@@ -279,6 +332,33 @@ EXPECT:
|
||||
- Image maintains aspect ratio
|
||||
FAIL: Raw binary text displayed, broken image icon, error message, or nothing happens.
|
||||
|
||||
### T5.5b: Preview Audio/Video Files Inline
|
||||
SETUP: Workspace contains at least one audio file (`.mp3`, `.wav`, `.m4a`) and one video file (`.mp4`, `.mov`, `.webm`).
|
||||
STEPS:
|
||||
1. Click the audio file in the workspace file tree
|
||||
2. Play it and select 1.5× or 2× speed
|
||||
3. Close preview, then click the video file
|
||||
4. Play it and select 0.75× or 1.25× speed
|
||||
EXPECT:
|
||||
- Audio/video open in the workspace preview panel instead of downloading immediately
|
||||
- Path badge shows `audio` or `video`
|
||||
- Native media controls and the variable-speed buttons are visible
|
||||
- Video scales to the preview panel without overflowing
|
||||
FAIL: Browser downloads the media immediately, raw binary appears, or speed controls are missing/broken.
|
||||
|
||||
### T5.5c: Preview PDF Files Inline
|
||||
SETUP: Workspace contains at least one `.pdf` file.
|
||||
STEPS:
|
||||
1. Click the PDF file in the workspace file tree
|
||||
2. Use the browser/PDF viewer scroll and zoom controls if available
|
||||
3. Click "Open in browser" as a fallback
|
||||
EXPECT:
|
||||
- PDF opens in the workspace preview panel instead of downloading immediately
|
||||
- Path badge shows `pdf`
|
||||
- PDF iframe fills the preview area
|
||||
- "Open in browser" opens the same raw file endpoint in a new tab
|
||||
FAIL: Browser downloads the PDF immediately, raw binary appears, or the preview panel is blank without an open fallback.
|
||||
|
||||
### T5.6: Preview a Markdown File (Sprint 2)
|
||||
SETUP: Workspace has a .md file (or create one: upload a file named README.md with some markdown content).
|
||||
STEPS:
|
||||
@@ -377,7 +457,8 @@ FAIL: Command blocked after Allow once, card stays, error.
|
||||
### T8.1: Download Conversation as Markdown
|
||||
SETUP: A session with at least 2 messages (1 user + 1 assistant).
|
||||
STEPS:
|
||||
1. Click the "Transcript" download button in the sidebar bottom
|
||||
1. Click the "Hermes" button in the sidebar footer
|
||||
2. In the Control Center modal, click "Transcript"
|
||||
EXPECT:
|
||||
- Browser downloads a .md file named hermes-{session_id}.md
|
||||
- Opening the file shows the conversation in markdown format:
|
||||
@@ -468,6 +549,7 @@ FAIL: No log output, log shows Apache-style text instead of JSON, log file not c
|
||||
SETUP: Message is sending (thinking dots visible).
|
||||
EXPECT:
|
||||
- Send button is visually grayed out
|
||||
- Stop button is visible in the composer footer
|
||||
- Pressing Enter does NOT send another message
|
||||
- Clicking Send button does nothing
|
||||
FAIL: Multiple messages sent while one is in flight.
|
||||
@@ -831,7 +913,7 @@ FAIL: No icon ever appears, icon always visible (not hover-only).
|
||||
### T21.2: Delete a File with Confirmation
|
||||
STEPS:
|
||||
1. Hover over a file and click its trash icon
|
||||
2. A browser confirm dialog appears: "Delete [filename]?"
|
||||
2. An in-app confirmation modal appears: "Delete [filename]?"
|
||||
3. Click OK
|
||||
EXPECT:
|
||||
- Toast: "Deleted [filename]"
|
||||
@@ -842,7 +924,7 @@ FAIL: File not deleted, no confirmation dialog, error.
|
||||
### T21.3: Cancel Delete Does Nothing
|
||||
STEPS:
|
||||
1. Hover over a file and click its trash icon
|
||||
2. Click Cancel on the confirm dialog
|
||||
2. Click Cancel on the confirmation modal
|
||||
EXPECT:
|
||||
- File remains in the tree
|
||||
- No toast, no error
|
||||
@@ -851,7 +933,7 @@ FAIL: File deleted despite cancel.
|
||||
### T21.4: Create a New File
|
||||
STEPS:
|
||||
1. Click the + button in the workspace panel header
|
||||
2. A prompt dialog appears: "New file name (e.g. notes.md):"
|
||||
2. An in-app input modal appears: "New file name (e.g. notes.md):"
|
||||
3. Type "test-sprint4.md" and click OK
|
||||
EXPECT:
|
||||
- Toast: "Created test-sprint4.md"
|
||||
@@ -925,7 +1007,7 @@ FAIL: Invalid path added, no error.
|
||||
### T22.4: Remove a Workspace
|
||||
STEPS:
|
||||
1. Click the X button next to any non-default workspace
|
||||
2. Confirm the dialog
|
||||
2. Confirm the modal
|
||||
EXPECT:
|
||||
- Workspace disappears from the list
|
||||
- Toast: "Workspace removed"
|
||||
@@ -981,7 +1063,7 @@ STEPS:
|
||||
1. Hover over an assistant message
|
||||
2. Click the clipboard icon
|
||||
EXPECT:
|
||||
- Icon briefly shows a checkmark (✓) then reverts to clipboard
|
||||
- Icon briefly shows a check icon, then reverts to the copy icon
|
||||
- Paste (Cmd+V) elsewhere shows the full text of that message
|
||||
FAIL: No visual feedback, clipboard empty or wrong content.
|
||||
|
||||
@@ -994,23 +1076,23 @@ STEPS:
|
||||
1. Click any .py, .js, or .txt file in the workspace file tree
|
||||
EXPECT:
|
||||
- File content shows in read-only monospace view
|
||||
- An "✎ Edit" button is visible in the preview path bar
|
||||
- An Edit button with a pencil icon is visible in the preview path bar
|
||||
- Content is NOT editable (clicking in it does nothing)
|
||||
FAIL: Content immediately editable, no Edit button.
|
||||
|
||||
### T24.2: Edit Button Enters Edit Mode
|
||||
STEPS:
|
||||
1. Click "✎ Edit" on a code file preview
|
||||
1. Click the Edit button on a code file preview
|
||||
EXPECT:
|
||||
- Read-only view replaced by an editable textarea
|
||||
- Content of the file is pre-populated in the textarea
|
||||
- Button changes to "💾 Save"
|
||||
- Button changes to "Save" with a disk icon
|
||||
FAIL: Nothing changes, button doesn't change.
|
||||
|
||||
### T24.3: Save Writes Changes to Disk
|
||||
STEPS:
|
||||
1. In edit mode, change some text
|
||||
2. Click "💾 Save"
|
||||
2. Click the Save button
|
||||
EXPECT:
|
||||
- Read-only view returns, showing the updated content
|
||||
- Toast: "Saved"
|
||||
@@ -1022,8 +1104,8 @@ STEPS:
|
||||
1. Enter edit mode on a file
|
||||
2. Make any change (type a character)
|
||||
EXPECT:
|
||||
- Button shows "💾 Save*" (asterisk indicates unsaved changes)
|
||||
FAIL: No asterisk, button stays as "💾 Save".
|
||||
- Button shows "Save*" with the disk icon still visible (asterisk indicates unsaved changes)
|
||||
FAIL: No asterisk, button stays as "Save".
|
||||
|
||||
### T24.5: Markdown File Edit-Save Roundtrip
|
||||
STEPS:
|
||||
@@ -1070,7 +1152,7 @@ against each criterion below. A Claude browser agent can verify these with brows
|
||||
|
||||
### T25.1: Sidebar Nav Tabs are Icon-Only
|
||||
EXPECT:
|
||||
- Five icon-only tabs in the sidebar nav row: 💬 ⏱️ 📚 🧠 📁
|
||||
- Five icon-only tabs in the sidebar nav row: message, clock, book, brain, folder
|
||||
- No text labels visible by default (text removed to prevent overflow)
|
||||
- Hovering a tab shows a tooltip with the label (Chat/Tasks/Skills/Memory/Spaces)
|
||||
- Active tab has a blue underline, icon brighter blue
|
||||
@@ -1194,7 +1276,7 @@ STEPS:
|
||||
3. Click Create job
|
||||
EXPECT:
|
||||
- Form closes
|
||||
- Toast: "Job created ✓"
|
||||
- Toast: "Job created"
|
||||
- New job appears in the cron list with status "active"
|
||||
FAIL: Error shown, job not created, form stays open.
|
||||
|
||||
@@ -1225,7 +1307,8 @@ FAIL: Job created, form doesn't close.
|
||||
### T28.1: JSON Export Button Downloads File
|
||||
SETUP: Active session with at least a few messages.
|
||||
STEPS:
|
||||
1. Click the "JSON" button in the sidebar footer (next to Transcript)
|
||||
1. Click the "Hermes" button in the sidebar footer
|
||||
2. In the Control Center modal, click "JSON"
|
||||
EXPECT:
|
||||
- Browser downloads a file named hermes-{session_id}.json
|
||||
- Opening the file shows valid JSON with: session_id, title, messages array,
|
||||
@@ -1289,7 +1372,7 @@ STEPS (continued from T29.1):
|
||||
1. Change the name field to "Renamed Job"
|
||||
2. Click Save
|
||||
EXPECT:
|
||||
- Form closes, toast "Job updated ✓"
|
||||
- Form closes, toast "Job updated"
|
||||
- Job header shows new name
|
||||
FAIL: Save fails, name unchanged.
|
||||
|
||||
@@ -1297,7 +1380,7 @@ FAIL: Save fails, name unchanged.
|
||||
SETUP: A cron job you can safely delete (or a test job created for this).
|
||||
STEPS:
|
||||
1. Expand the job, click "Delete"
|
||||
2. Confirm the dialog
|
||||
2. Confirm the modal
|
||||
EXPECT:
|
||||
- Toast: "Job deleted"
|
||||
- Job disappears from the list
|
||||
@@ -1326,7 +1409,7 @@ tags: [test]
|
||||
# Test"
|
||||
2. Click Save skill
|
||||
EXPECT:
|
||||
- Toast "Skill created ✓", form closes
|
||||
- Toast "Skill created", form closes
|
||||
- Skill appears in the skills list
|
||||
FAIL: Error, skill not in list.
|
||||
|
||||
@@ -1354,7 +1437,7 @@ STEPS:
|
||||
1. In edit mode, add a line to the textarea
|
||||
2. Click Save
|
||||
EXPECT:
|
||||
- Toast "Memory saved ✓", form closes
|
||||
- Toast "Memory saved", form closes
|
||||
- Memory panel reloads showing the updated content
|
||||
FAIL: Save fails, content unchanged.
|
||||
|
||||
@@ -1467,14 +1550,16 @@ FAIL: Both messages removed, wrong message sent, crash.
|
||||
### T34.1: Clear Button Appears When Session Has Messages
|
||||
SETUP: Session with at least one message.
|
||||
EXPECT:
|
||||
- A "🗑 Clear" chip appears in the topbar right side (next to the workspace chip)
|
||||
- Button NOT visible when session has no messages / empty state
|
||||
- The "Hermes" button is visible in the sidebar footer
|
||||
- Opening the Control Center shows a "Clear" action in the Conversation section
|
||||
- The Clear action is disabled when there is no active session or no messages
|
||||
FAIL: Button always visible, never visible.
|
||||
|
||||
### T34.2: Clear Wipes Messages and Resets Title
|
||||
STEPS:
|
||||
1. Click the Clear button in the topbar
|
||||
2. Confirm the dialog
|
||||
1. Click the "Hermes" button in the sidebar footer
|
||||
2. Click "Clear" in the Conversation section
|
||||
3. Confirm the modal
|
||||
EXPECT:
|
||||
- All messages disappear from the chat area
|
||||
- Empty state ("What can I help with?") reappears
|
||||
@@ -1485,7 +1570,7 @@ FAIL: Session deleted, messages remain, title not reset.
|
||||
|
||||
### T34.3: Cancel Clear Does Nothing
|
||||
STEPS:
|
||||
1. Click Clear, then click Cancel in the confirm dialog
|
||||
1. Click Clear, then click Cancel in the confirmation modal
|
||||
EXPECT:
|
||||
- All messages still present
|
||||
- No toast, no change
|
||||
@@ -1609,7 +1694,7 @@ Each has automated API-level tests in `tests/test_sprint{N}.py`.
|
||||
- Switch model. Send a message. Verify response uses selected model.
|
||||
|
||||
### Sprint 12: Settings + Pin + Import
|
||||
- Click gear icon. Settings overlay opens.
|
||||
- Click the "Hermes WebUI" button in the sidebar footer. Control Center overlay opens with vertical section tabs on the left.
|
||||
- Change default model, save. Restart server. Verify setting persisted.
|
||||
- Pin a session (star icon in hover overlay). Verify it floats to top of list.
|
||||
- Export session as JSON. Import it back. Verify messages restored.
|
||||
@@ -1637,11 +1722,12 @@ Each has automated API-level tests in `tests/test_sprint{N}.py`.
|
||||
|
||||
### Sprint 16: Sidebar Visual Polish
|
||||
- Session titles use full sidebar width (no truncated space for hidden icons).
|
||||
- Hover a session → action buttons appear from right with gradient fade.
|
||||
- Hover a session → a dotted actions trigger appears on the right.
|
||||
- Click the dotted trigger → a dropdown opens with pin, project, archive, duplicate, and delete actions.
|
||||
- All icons are monochrome SVGs (not emoji). Consistent across platforms.
|
||||
- Pinned sessions show small gold star inline. Unpinned = no star, full title width.
|
||||
- Active session has gold highlight (not blue). Overlay gradient matches.
|
||||
- Double-click to rename → overlay hides during rename.
|
||||
- Active session has gold highlight (not blue).
|
||||
- Double-click to rename → session actions hide during rename.
|
||||
|
||||
### Sprint 17: Workspace + Slash Commands + Send Key
|
||||
- Navigate into a subdirectory. Breadcrumb bar appears with clickable segments.
|
||||
@@ -1655,6 +1741,13 @@ Each has automated API-level tests in `tests/test_sprint{N}.py`.
|
||||
- Click a directory toggle arrow (▸) → expands in-place showing children.
|
||||
- Click again (▾) → collapses. Double-click navigates into it (breadcrumb view).
|
||||
- If model returns thinking blocks (Claude extended thinking), verify collapsible gold card appears above response.
|
||||
- Verify the thinking card has a tinted background, visible border, and rounded corners like a tool card, but in the gold thinking palette.
|
||||
- Open and close a thinking card. Verify the caret rotation and the content reveal both animate smoothly instead of snapping open.
|
||||
|
||||
### UI Polish: Tool Card Disclosure Animation
|
||||
- Trigger a response with at least one completed tool call card.
|
||||
- Open and close the tool call card. Verify the caret rotates smoothly and the args/result section animates open and closed instead of appearing instantly.
|
||||
- If a turn has 2+ tool cards, use "Expand all / Collapse all" and verify the same smooth animation applies to every card in the group.
|
||||
|
||||
### Sprint 19: Auth + Security
|
||||
- No password set: everything works as normal. No login page.
|
||||
@@ -1684,12 +1777,13 @@ Each has automated API-level tests in `tests/test_sprint{N}.py`.
|
||||
- Open on mobile viewport (<640px): hamburger icon visible in topbar.
|
||||
- Tap hamburger → sidebar slides in from left with backdrop overlay.
|
||||
- Tap outside sidebar → closes. Tap a session → closes and loads session.
|
||||
- Bottom navigation bar: 5 tabs (Chat, Tasks, Skills, Memory, Spaces).
|
||||
- Tap "Tasks" in bottom nav → sidebar opens showing Tasks panel.
|
||||
- Tap "Chat" in bottom nav → sidebar closes (chat is in main area).
|
||||
- Sidebar top nav remains visible inside the mobile drawer; includes Chat/Tasks/Skills/Memory/Spaces/Profile tabs.
|
||||
- Tap "Tasks" in the drawer nav → Tasks panel opens in the sidebar drawer.
|
||||
- Tap "Chat" in the drawer nav → sidebar closes and chat is unobstructed in the main area.
|
||||
- Files button in topbar → right panel slides in from right.
|
||||
- No fixed mobile bottom nav; chat transcript and composer use the reclaimed vertical space.
|
||||
- All touch targets are at least 44px (session items, buttons, icons).
|
||||
- Desktop viewport (>640px): no hamburger, no bottom nav, no mobile elements.
|
||||
- Desktop viewport (>640px): no hamburger or mobile overlay; desktop layout unchanged.
|
||||
- Docker: `docker compose up -d` starts server on port 8787.
|
||||
- Docker: session data persists across container restarts (named volume).
|
||||
|
||||
@@ -1702,14 +1796,47 @@ Each has automated API-level tests in `tests/test_sprint{N}.py`.
|
||||
- "Use" button switches profile. Delete button removes non-default profiles.
|
||||
- "+ New profile" form: name validation (lowercase + hyphens), clone config checkbox.
|
||||
- Create profile → appears in list and dropdown.
|
||||
- Delete profile → confirm dialog. Auto-switches to default if deleting active.
|
||||
- Delete profile → confirmation modal. Auto-switches to default if deleting active.
|
||||
- Attempt switch while agent busy → blocked with toast message.
|
||||
- With hermes-agent not installed → only default profile shown, graceful fallback.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: Sprint 22 / v0.24, April 3, 2026*
|
||||
*Total automated tests: 415 (392 passing, 23 pre-existing failures)*
|
||||
*Regression gate: tests/test_regressions.py (23 tests)*
|
||||
## Slash command parity (manual checklist)
|
||||
|
||||
For each batch-1 command, run via webui slash menu AND via `hermes` CLI in the
|
||||
same `HERMES_HOME` (when applicable) and verify identical effect.
|
||||
|
||||
- [ ] `/help` — dropdown lists 25+ commands; selecting `/help` posts an assistant message listing them.
|
||||
- [ ] `/new` (and alias `/reset`) — starts fresh session.
|
||||
- [ ] `/clear` — clears current transcript display (webui-only meaning, distinct from CLI's "clear screen").
|
||||
- [ ] `/title <name>` — renames active session, topbar + sidebar update; `/title` alone shows current title.
|
||||
- [ ] `/status` — assistant message shows session_id, model, workspace, message count.
|
||||
- [ ] `/usage` — assistant message shows token counts; the "show token usage" setting is unchanged (toggle still in Settings panel).
|
||||
- [ ] `/stop` — interrupts a running stream; with no active stream toasts "No active task to stop."
|
||||
- [ ] `/retry` — removes last user+assistant exchange, refills composer with last user text, resends. Final transcript has only ONE copy of the resent message.
|
||||
- [ ] `/undo` — removes last user+assistant exchange; toast confirms; repeated until empty toasts "Nothing to undo."
|
||||
- [ ] `/model <name>` — switches model dropdown.
|
||||
- [ ] `/personality` — lists personalities; `/personality <name>` switches.
|
||||
- [ ] `/skills [query]` — lists matching skills.
|
||||
- [ ] `/theme <name>` — switches webui theme.
|
||||
- [ ] `/workspace <name>` — switches workspace.
|
||||
|
||||
Unknown / deferred:
|
||||
|
||||
- [ ] `/yolo`, `/reasoning`, `/voice`, `/branch`, `/insights`, `/debug`, `/reload`, etc. — toast "Web UI 暂未实现该命令: /<name>". MUST NOT be sent as plain text to the LLM.
|
||||
- [ ] `/compact` — toast "/compress is not available in the web UI yet — use the CLI for now." (was sending free text to LLM before this batch.)
|
||||
- [ ] Made-up command (e.g. `/fhfajl`) — fall through to send as text (existing behavior preserved for typos vs. real commands).
|
||||
|
||||
Bridged CLI sessions:
|
||||
|
||||
- [ ] Open a CLI-bridged session in webui sidebar (if `show_cli_sessions` setting enabled).
|
||||
- [ ] `/retry`, `/undo` toast "该命令仅支持 Web UI 原生会话…" and do nothing.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: v0.50.245, April 30, 2026*
|
||||
*Total automated tests collected: 3309*
|
||||
*Regression gate: tests/test_regressions.py*
|
||||
*Run: pytest tests/ -v --timeout=60*
|
||||
*Source: <repo>/*
|
||||
|
||||
145
THEMES.md
Normal file
145
THEMES.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# Hermes Web UI — Themes
|
||||
|
||||
Hermes Web UI supports pluggable color themes. Seven themes ship built-in, and
|
||||
you can create your own with pure CSS — no Python changes needed.
|
||||
|
||||
---
|
||||
|
||||
## Switching Themes
|
||||
|
||||
**Settings panel:** Click the gear icon, select a theme from the dropdown. The
|
||||
preview is instant — the UI updates as you click through options.
|
||||
|
||||
**Slash command:** Type `/theme dark` or `/theme light` in the composer.
|
||||
|
||||
**Themes persist** across page reloads and server restarts (stored in
|
||||
`settings.json` server-side, with `localStorage` for flicker-free loading).
|
||||
|
||||
---
|
||||
|
||||
## Built-in Themes
|
||||
|
||||
| Theme | Description |
|
||||
|-------|-------------|
|
||||
| **Dark** (default) | Deep navy/indigo with muted blue accents. Easy on the eyes for long sessions. |
|
||||
| **Light** | Warm off-white with dark text. High contrast for bright environments. |
|
||||
| **Slate** | Warm charcoal, lighter than Dark. Easier on the eyes for extended use. |
|
||||
| **Solarized Dark** | Ethan Schoonover's classic dark palette. Teal background, warm accents. |
|
||||
| **Monokai** | Warm dark theme inspired by the Monokai editor scheme. Green/pink accents. |
|
||||
| **Nord** | Arctic blue-gray palette from the Nord color system. Calm and minimal. |
|
||||
| **OLED** | True black (#000) backgrounds for OLED displays. Minimizes glow and burn-in risk. |
|
||||
| **Custom themes** | Any string accepted by `settings.json`, `POST /api/settings`, and `/theme` if added to the picker/command list. Pure CSS variables only. |
|
||||
|
||||
---
|
||||
|
||||
## Creating a Custom Theme
|
||||
|
||||
A theme is a CSS block that overrides the color variables. Add it to
|
||||
`static/style.css` (or a separate file that you link after the main stylesheet).
|
||||
|
||||
### Step 1: Define your theme block
|
||||
|
||||
Every color in the UI comes from these CSS variables:
|
||||
|
||||
```css
|
||||
:root[data-theme="your-theme-name"] {
|
||||
/* Core palette */
|
||||
--bg: #1a1a2e; /* Main background */
|
||||
--sidebar: #16213e; /* Sidebar background */
|
||||
--border: rgba(255,255,255,0.08); /* Subtle borders */
|
||||
--border2: rgba(255,255,255,0.14); /* Stronger borders */
|
||||
--text: #e8e8f0; /* Primary text color */
|
||||
--muted: #8888aa; /* Secondary/muted text */
|
||||
--accent: #e94560; /* Accent color (errors, warnings, delete) */
|
||||
--blue: #7cb9ff; /* Primary action color (links, active states) */
|
||||
--gold: #c9a84c; /* Secondary accent (pinned items, gold highlights) */
|
||||
--code-bg: #0d1117; /* Code block background */
|
||||
|
||||
/* Surface and chrome (required for full theme polish) */
|
||||
--surface: #1a2535; /* Dropdowns, popups, toast, approval card */
|
||||
--topbar-bg: rgba(22,33,62,.98); /* Topbar background */
|
||||
--main-bg: rgba(26,26,46,0.5); /* Main chat area background */
|
||||
--input-bg: rgba(255,255,255,.04); /* Input/button subtle backgrounds */
|
||||
--hover-bg: rgba(255,255,255,.06); /* Hover state backgrounds */
|
||||
--focus-ring: rgba(124,185,255,.35); /* Focus border color */
|
||||
--focus-glow: rgba(124,185,255,.08); /* Focus box-shadow glow */
|
||||
|
||||
/* Typography (required for readable text across themes) */
|
||||
--strong: #fff; /* Bold text in messages */
|
||||
--em: #c9c9e8; /* Italic text in messages */
|
||||
--code-text: #f0c27f; /* Inline code text color */
|
||||
--code-inline-bg: rgba(0,0,0,.35); /* Inline code background */
|
||||
--pre-text: #e2e8f0; /* Code block text color */
|
||||
}
|
||||
```
|
||||
|
||||
The **core palette** controls the overall mood. The **surface/chrome** and
|
||||
**typography** variables are part of the standard theme contract — define all
|
||||
of them for a complete theme.
|
||||
|
||||
For **light themes**, you also need `:root[data-theme="name"]` overrides
|
||||
for elements that use `rgba(255,255,255,.XX)` hover/border effects (these
|
||||
are invisible on light backgrounds). See the built-in light theme for the
|
||||
full pattern — it overrides ~45 selectors for proper dark-on-light contrast
|
||||
on hover states, borders, chips, role labels, session items, and
|
||||
interactive elements.
|
||||
|
||||
### Step 2: Add it to the theme picker (optional)
|
||||
|
||||
To make your theme appear in the Settings dropdown, add an `<option>` to the
|
||||
theme `<select>` in `static/index.html`:
|
||||
|
||||
```html
|
||||
<option value="your-theme-name">Your Theme Name</option>
|
||||
```
|
||||
|
||||
And update the `/theme` command's valid theme list in `static/commands.js`.
|
||||
|
||||
### Step 3: Test it
|
||||
|
||||
Switch to your theme via `/theme your-theme-name` or the Settings panel.
|
||||
Check these areas:
|
||||
- Sidebar session list (hover states, active state, project borders)
|
||||
- Message bubbles (user vs assistant styling)
|
||||
- Code blocks (background contrast, copy button visibility)
|
||||
- Tool cards (running indicator, expand/collapse)
|
||||
- Settings panel and login page
|
||||
- Mobile layout (hamburger sidebar, bottom nav)
|
||||
|
||||
### Tips
|
||||
|
||||
- **Light themes** need scrollbar and selection overrides, plus the full
|
||||
text/code set (`--strong`, `--em`, `--code-text`, `--code-inline-bg`,
|
||||
`--pre-text`) or they will look broken.
|
||||
- The **logo gradient** uses `--accent` automatically, so it adapts to your
|
||||
theme without extra work.
|
||||
- **Prism.js syntax highlighting** uses its own CDN stylesheet (Tomorrow theme).
|
||||
It works well on dark themes; on light themes the contrast is acceptable but
|
||||
not perfect. Custom Prism theme support is planned for a future update.
|
||||
- **No server changes needed.** The `theme` setting in `settings.json` accepts
|
||||
any string — your custom theme name will persist without code changes.
|
||||
|
||||
---
|
||||
|
||||
## How Themes Work Internally
|
||||
|
||||
1. Each theme is a `:root[data-theme="name"]` CSS block that overrides variables.
|
||||
2. Switching themes sets `document.documentElement.dataset.theme = name` in JS.
|
||||
3. A tiny inline `<script>` in `<head>` reads `localStorage` before the
|
||||
stylesheet loads — this prevents a flash of the wrong theme on page load.
|
||||
4. The theme preference is saved server-side via `POST /api/settings` and
|
||||
loaded on boot via `GET /api/settings`.
|
||||
5. The `/theme` command and Settings dropdown both update the DOM, localStorage,
|
||||
and server settings simultaneously.
|
||||
|
||||
---
|
||||
|
||||
## Contributing a Theme
|
||||
|
||||
To contribute a new built-in theme:
|
||||
|
||||
1. Add your `:root[data-theme="name"]` block to `static/style.css`
|
||||
2. Add the `<option>` to the Settings panel in `static/index.html`
|
||||
3. Add the theme name to the valid list in `cmdTheme()` in `static/commands.js`
|
||||
4. Test on desktop and mobile
|
||||
5. Open a PR — themes are pure CSS additions with no backend changes needed
|
||||
374
api/agent_sessions.py
Normal file
374
api/agent_sessions.py
Normal file
@@ -0,0 +1,374 @@
|
||||
"""Shared helpers for reading Hermes Agent sessions from state.db."""
|
||||
import logging
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
MESSAGING_SOURCES = {
|
||||
'discord',
|
||||
'slack',
|
||||
'telegram',
|
||||
'weixin',
|
||||
}
|
||||
|
||||
SOURCE_LABELS = {
|
||||
'api_server': 'API',
|
||||
'cli': 'CLI',
|
||||
'cron': 'Cron',
|
||||
'discord': 'Discord',
|
||||
'slack': 'Slack',
|
||||
'telegram': 'Telegram',
|
||||
'tool': 'Tool',
|
||||
'webui': 'WebUI',
|
||||
'weixin': 'Weixin',
|
||||
}
|
||||
|
||||
|
||||
def normalize_agent_session_source(raw_source: str | None) -> dict:
|
||||
"""Return stable source metadata for Hermes Agent session rows.
|
||||
|
||||
``sessions.source`` is an Agent-level raw value. WebUI needs a smaller,
|
||||
durable contract so routes, SSE snapshots, and future sidebar policies do
|
||||
not each reimplement raw-source checks.
|
||||
"""
|
||||
raw = str(raw_source or '').strip().lower() or 'unknown'
|
||||
|
||||
if raw == 'webui':
|
||||
session_source = 'webui'
|
||||
elif raw == 'cli':
|
||||
session_source = 'cli'
|
||||
elif raw in MESSAGING_SOURCES:
|
||||
session_source = 'messaging'
|
||||
elif raw == 'cron':
|
||||
session_source = 'cron'
|
||||
elif raw == 'tool':
|
||||
session_source = 'tool'
|
||||
elif raw == 'api_server':
|
||||
session_source = 'api'
|
||||
else:
|
||||
session_source = 'other'
|
||||
|
||||
label = SOURCE_LABELS.get(raw)
|
||||
if not label:
|
||||
label = raw.replace('_', ' ').title() if raw != 'unknown' else 'Agent'
|
||||
|
||||
return {
|
||||
'raw_source': None if raw == 'unknown' else raw,
|
||||
'session_source': session_source,
|
||||
'source_label': label,
|
||||
}
|
||||
|
||||
|
||||
def _with_normalized_source(row: dict) -> dict:
|
||||
normalized = normalize_agent_session_source(row.get('source'))
|
||||
return {**row, **normalized}
|
||||
|
||||
|
||||
def _optional_col(name: str, columns: set[str], fallback: str = "NULL") -> str:
|
||||
return f"s.{name}" if name in columns else f"{fallback} AS {name}"
|
||||
|
||||
|
||||
def _is_compression_continuation(parent: dict | None, child: dict) -> bool:
|
||||
"""Mirror Hermes Agent's compression-child guard.
|
||||
|
||||
A child is a continuation only when the parent ended because of compression
|
||||
and the child started after that compression boundary. Plain parent/child
|
||||
relationships are left alone for future subagent-tree work.
|
||||
"""
|
||||
if not parent:
|
||||
return False
|
||||
if parent.get('end_reason') != 'compression':
|
||||
return False
|
||||
ended_at = parent.get('ended_at')
|
||||
if ended_at is None:
|
||||
return False
|
||||
try:
|
||||
return float(child.get('started_at') or 0) >= float(ended_at)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _project_agent_session_rows(rows: list[dict]) -> list[dict]:
|
||||
"""Collapse compression chains into one logical sidebar row.
|
||||
|
||||
The visible conversation should still look like the original chain head
|
||||
(title and timestamps), while importing should use the latest importable
|
||||
segment so the user continues from the current compressed state.
|
||||
"""
|
||||
rows_by_id = {row['id']: row for row in rows}
|
||||
children_by_parent: dict[str, list[dict]] = {}
|
||||
continuation_child_ids = set()
|
||||
|
||||
for row in rows:
|
||||
parent_id = row.get('parent_session_id')
|
||||
if not parent_id:
|
||||
continue
|
||||
children_by_parent.setdefault(parent_id, []).append(row)
|
||||
if _is_compression_continuation(rows_by_id.get(parent_id), row):
|
||||
continuation_child_ids.add(row['id'])
|
||||
|
||||
for children in children_by_parent.values():
|
||||
children.sort(key=lambda row: row.get('started_at') or 0, reverse=True)
|
||||
|
||||
def compression_tip(row: dict) -> tuple[dict | None, int]:
|
||||
current = row
|
||||
seen = {row['id']}
|
||||
latest_importable = row if (row.get('actual_message_count') or 0) > 0 else None
|
||||
segment_count = 1
|
||||
for _ in range(len(rows_by_id) + 1):
|
||||
candidates = [
|
||||
child for child in children_by_parent.get(current['id'], [])
|
||||
if child['id'] not in seen and _is_compression_continuation(current, child)
|
||||
]
|
||||
if not candidates:
|
||||
return latest_importable, segment_count
|
||||
current = candidates[0]
|
||||
seen.add(current['id'])
|
||||
segment_count += 1
|
||||
if (current.get('actual_message_count') or 0) > 0:
|
||||
latest_importable = current
|
||||
return latest_importable, segment_count
|
||||
|
||||
projected = []
|
||||
for row in rows:
|
||||
if row['id'] in continuation_child_ids:
|
||||
continue
|
||||
|
||||
segment_count = 1
|
||||
tip = row
|
||||
if row.get('end_reason') == 'compression':
|
||||
tip, segment_count = compression_tip(row)
|
||||
if not tip or (tip.get('actual_message_count') or 0) <= 0:
|
||||
continue
|
||||
|
||||
if tip is row:
|
||||
projected.append(dict(row))
|
||||
continue
|
||||
|
||||
merged = dict(row)
|
||||
# Keep the chain head's visible identity (title, started_at), but
|
||||
# point the row at the latest importable segment for navigation AND
|
||||
# surface the tip's recency so an actively-used chain bubbles to the
|
||||
# top of the sidebar by its true last activity. Without overriding
|
||||
# last_activity, a long-lived chain whose tip is being edited NOW
|
||||
# would sort by the root's old timestamp and fall below recently
|
||||
# touched standalone sessions — exactly the inverse of what a user
|
||||
# expects from "Show agent sessions" sorted by activity.
|
||||
for key in (
|
||||
'id', 'model', 'message_count', 'actual_message_count',
|
||||
'ended_at', 'end_reason', 'last_activity',
|
||||
):
|
||||
if key in tip:
|
||||
merged[key] = tip[key]
|
||||
if not merged.get('title'):
|
||||
merged['title'] = tip.get('title')
|
||||
if not merged.get('source'):
|
||||
merged['source'] = tip.get('source')
|
||||
merged['_lineage_root_id'] = row['id']
|
||||
merged['_lineage_tip_id'] = tip['id']
|
||||
merged['_compression_segment_count'] = segment_count
|
||||
projected.append(merged)
|
||||
|
||||
projected.sort(
|
||||
key=lambda row: row.get('last_activity') or row.get('started_at') or 0,
|
||||
reverse=True,
|
||||
)
|
||||
return projected
|
||||
|
||||
|
||||
def read_importable_agent_session_rows(
|
||||
db_path: Path,
|
||||
limit: int = 200,
|
||||
log=None,
|
||||
exclude_sources: tuple[str, ...] | None = ("cron",),
|
||||
) -> list[dict]:
|
||||
"""Return non-WebUI agent sessions projected as importable conversations.
|
||||
|
||||
Hermes Agent can create rows in ``state.db.sessions`` before a session has
|
||||
any messages, and long conversations can be split into compression-linked
|
||||
rows. WebUI cannot import empty rows and should not show compression
|
||||
segments as separate conversations, so both the regular ``/api/sessions``
|
||||
path and the gateway SSE watcher use this shared projection.
|
||||
|
||||
By default, omit background/internal sources such as ``cron`` from the WebUI
|
||||
sidebar. This mirrors Hermes Agent CLI's session-list behaviour: interactive
|
||||
views should stay focused on user-facing conversations, while callers that
|
||||
need a source-specific diagnostic view can opt out by passing
|
||||
``exclude_sources=None``.
|
||||
"""
|
||||
db_path = Path(db_path)
|
||||
if not db_path.exists():
|
||||
return []
|
||||
|
||||
log = log or logger
|
||||
with sqlite3.connect(str(db_path)) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
|
||||
# Older Hermes Agent versions may not have source tracking. Without a
|
||||
# source column we cannot safely distinguish WebUI rows from agent rows.
|
||||
cur.execute("PRAGMA table_info(sessions)")
|
||||
session_cols = {row[1] for row in cur.fetchall()}
|
||||
if 'source' not in session_cols:
|
||||
log.warning(
|
||||
"agent session listing skipped: state.db at %s has no 'source' column "
|
||||
"(older hermes-agent?). Agent sessions unavailable. "
|
||||
"Upgrade hermes-agent to fix this.",
|
||||
db_path,
|
||||
)
|
||||
return []
|
||||
|
||||
parent_expr = _optional_col('parent_session_id', session_cols)
|
||||
ended_expr = _optional_col('ended_at', session_cols)
|
||||
end_reason_expr = _optional_col('end_reason', session_cols)
|
||||
|
||||
where_clauses = ["s.source IS NOT NULL", "s.source != 'webui'"]
|
||||
params: list[str] = []
|
||||
if exclude_sources:
|
||||
excluded = tuple(str(source) for source in exclude_sources if source)
|
||||
if excluded:
|
||||
placeholders = ", ".join("?" for _ in excluded)
|
||||
where_clauses.append(f"s.source NOT IN ({placeholders})")
|
||||
params.extend(excluded)
|
||||
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT s.id, s.title, s.model, s.message_count,
|
||||
s.started_at, s.source,
|
||||
{parent_expr},
|
||||
{ended_expr},
|
||||
{end_reason_expr},
|
||||
COUNT(m.id) AS actual_message_count,
|
||||
MAX(m.timestamp) AS last_activity
|
||||
FROM sessions s
|
||||
LEFT JOIN messages m ON m.session_id = s.id
|
||||
WHERE {' AND '.join(where_clauses)}
|
||||
GROUP BY s.id
|
||||
ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC
|
||||
""",
|
||||
params,
|
||||
)
|
||||
projected = _project_agent_session_rows([dict(row) for row in cur.fetchall()])
|
||||
projected = [_with_normalized_source(row) for row in projected]
|
||||
if limit is None:
|
||||
return projected
|
||||
return projected[:max(0, int(limit))]
|
||||
|
||||
|
||||
|
||||
def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[str]) -> dict[str, dict]:
|
||||
"""Return compression-lineage metadata for known WebUI sidebar sessions.
|
||||
|
||||
WebUI sessions are persisted as JSON files, but Hermes Agent also mirrors
|
||||
them into ``state.db.sessions`` for insights/session history. Compression
|
||||
and cross-surface continuation create parent chains there. ``/api/sessions``
|
||||
needs to surface that lineage to the sidebar so client-side collapse can
|
||||
group logical continuations without mutating or deleting any session files.
|
||||
|
||||
Missing DBs, old schemas, or incomplete rows degrade to an empty mapping.
|
||||
"""
|
||||
wanted = {str(sid) for sid in (session_ids or []) if sid}
|
||||
db_path = Path(db_path)
|
||||
if not wanted or not db_path.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
with sqlite3.connect(str(db_path)) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
cur.execute("PRAGMA table_info(sessions)")
|
||||
session_cols = {row[1] for row in cur.fetchall()}
|
||||
if 'parent_session_id' not in session_cols or 'end_reason' not in session_cols:
|
||||
return {}
|
||||
# Scoped fetch via PRIMARY KEY + idx_sessions_parent rather than a
|
||||
# full table scan. The sessions table grows unbounded over time
|
||||
# (1000+ rows is normal, 10000+ for power users), and this function
|
||||
# runs on every sidebar refresh — a full SELECT was ~50x slower
|
||||
# than the indexed lookup at 1000 rows and scales linearly.
|
||||
#
|
||||
# Fetch the wanted ids first, then chase parent_session_id chains
|
||||
# in batches until no new ids appear. Each batch hits PRIMARY KEY
|
||||
# so it's effectively O(N) lookups.
|
||||
#
|
||||
# IN-clause is chunked to 500 to stay under SQLITE_MAX_VARIABLE_NUMBER
|
||||
# on older sqlite (Python 3.9 ships sqlite 3.31 which defaults to 999;
|
||||
# newer Python ships sqlite 3.32+ at 32766). On a power user with
|
||||
# 2000+ sessions in the sidebar, an unchunked first hop would raise
|
||||
# `OperationalError: too many SQL variables`, get swallowed by the
|
||||
# except below, and silently disable lineage collapse forever.
|
||||
# (Opus pre-release review of v0.50.251, SHOULD-FIX 2.)
|
||||
IN_CHUNK = 500
|
||||
rows: dict[str, dict] = {}
|
||||
to_fetch = set(wanted)
|
||||
# Cap walk depth to bound worst-case query count. Real lineage
|
||||
# chains seen in production are <10 segments; anything longer is
|
||||
# almost certainly pathological data and not worth chasing.
|
||||
for _hop in range(20):
|
||||
if not to_fetch:
|
||||
break
|
||||
fetch_list = list(to_fetch)
|
||||
to_fetch = set()
|
||||
for i in range(0, len(fetch_list), IN_CHUNK):
|
||||
chunk = fetch_list[i:i + IN_CHUNK]
|
||||
placeholders = ','.join('?' * len(chunk))
|
||||
cur.execute(
|
||||
f"SELECT id, parent_session_id, end_reason FROM sessions WHERE id IN ({placeholders})",
|
||||
chunk,
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
rows[row['id']] = dict(row)
|
||||
# Queue up parents we haven't fetched yet.
|
||||
for sid in fetch_list:
|
||||
parent_id = rows.get(sid, {}).get('parent_session_id')
|
||||
if parent_id and parent_id not in rows and parent_id not in to_fetch:
|
||||
to_fetch.add(parent_id)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
metadata: dict[str, dict] = {}
|
||||
for sid in wanted:
|
||||
row = rows.get(sid)
|
||||
if not row:
|
||||
continue
|
||||
|
||||
parent_id = row.get('parent_session_id')
|
||||
# Only expose parent_session_id when:
|
||||
# 1) the parent actually exists in state.db (orphan refs would
|
||||
# otherwise leak through and the frontend would treat them as
|
||||
# sidebar grouping keys via #1358's _sessionLineageKey
|
||||
# fall-through)
|
||||
# 2) the parent's end_reason is one of {compression, cli_close} —
|
||||
# i.e. only TRUE continuations. Without this, two distinct
|
||||
# WebUI sessions sharing a `user_stop` parent would get
|
||||
# collapsed into a single sidebar row by #1358's helper
|
||||
# (it groups by parent_session_id as the third-fallback key).
|
||||
# (Opus pre-release review of v0.50.251, SHOULD-FIX 1.)
|
||||
parent_row = rows.get(parent_id) if parent_id else None
|
||||
if parent_row and parent_row.get('end_reason') in {'compression', 'cli_close'}:
|
||||
metadata.setdefault(sid, {})['parent_session_id'] = parent_id
|
||||
|
||||
root_id = sid
|
||||
current_id = sid
|
||||
segment_count = 1
|
||||
seen = {sid}
|
||||
while True:
|
||||
current = rows.get(current_id)
|
||||
parent_id = current.get('parent_session_id') if current else None
|
||||
parent = rows.get(parent_id) if parent_id else None
|
||||
if not parent or parent_id in seen:
|
||||
break
|
||||
if parent.get('end_reason') not in {'compression', 'cli_close'}:
|
||||
break
|
||||
root_id = parent_id
|
||||
current_id = parent_id
|
||||
seen.add(parent_id)
|
||||
segment_count += 1
|
||||
|
||||
if root_id != sid:
|
||||
entry = metadata.setdefault(sid, {})
|
||||
entry['_lineage_root_id'] = root_id
|
||||
entry['_compression_segment_count'] = segment_count
|
||||
|
||||
return metadata
|
||||
149
api/auth.py
149
api/auth.py
@@ -6,12 +6,17 @@ or configuring a password in the Settings panel.
|
||||
import hashlib
|
||||
import hmac
|
||||
import http.cookies
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from api.config import STATE_DIR, load_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Public paths (no auth required) ─────────────────────────────────────────
|
||||
PUBLIC_PATHS = frozenset({
|
||||
'/login', '/health', '/favicon.ico',
|
||||
@@ -21,22 +26,109 @@ PUBLIC_PATHS = frozenset({
|
||||
COOKIE_NAME = 'hermes_session'
|
||||
SESSION_TTL = 86400 # 24 hours
|
||||
|
||||
# Active sessions: token -> expiry timestamp
|
||||
_sessions = {}
|
||||
_SESSIONS_FILE = STATE_DIR / '.sessions.json'
|
||||
|
||||
|
||||
def _load_sessions() -> dict[str, float]:
|
||||
"""Load persisted sessions from STATE_DIR, pruning expired entries.
|
||||
|
||||
Returns an empty dict on any read or parse error so startup is never
|
||||
blocked by a corrupt or missing sessions file.
|
||||
"""
|
||||
try:
|
||||
if _SESSIONS_FILE.exists():
|
||||
data = json.loads(_SESSIONS_FILE.read_text(encoding='utf-8'))
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError('malformed sessions file — expected dict')
|
||||
now = time.time()
|
||||
return {t: exp for t, exp in data.items()
|
||||
if isinstance(t, str) and isinstance(exp, (int, float)) and exp > now}
|
||||
except Exception as e:
|
||||
logger.debug("Failed to load sessions file, starting fresh: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
def _save_sessions(sessions: dict[str, float]) -> None:
|
||||
"""Atomically persist sessions to STATE_DIR/.sessions.json (0600).
|
||||
|
||||
Uses a temp file + os.replace() so a crash mid-write never leaves a
|
||||
truncated file. Mirrors the same pattern as .signing_key persistence.
|
||||
"""
|
||||
try:
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=STATE_DIR, suffix='.sessions.tmp')
|
||||
try:
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||
json.dump(sessions, f)
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, _SESSIONS_FILE)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.debug("Failed to persist sessions: %s", e)
|
||||
|
||||
|
||||
# Active sessions: token -> expiry timestamp (persisted across restarts via STATE_DIR)
|
||||
_sessions = _load_sessions()
|
||||
|
||||
# ── Login rate limiter ──────────────────────────────────────────────────────
|
||||
_login_attempts = {} # ip -> [timestamp, ...]
|
||||
_LOGIN_MAX_ATTEMPTS = 5
|
||||
_LOGIN_WINDOW = 60 # seconds
|
||||
|
||||
def _check_login_rate(ip: str) -> bool:
|
||||
"""Return True if the IP is allowed to attempt login."""
|
||||
now = time.time()
|
||||
attempts = _login_attempts.get(ip, [])
|
||||
# Prune old attempts
|
||||
attempts = [t for t in attempts if now - t < _LOGIN_WINDOW]
|
||||
_login_attempts[ip] = attempts
|
||||
return len(attempts) < _LOGIN_MAX_ATTEMPTS
|
||||
|
||||
def _record_login_attempt(ip: str) -> None:
|
||||
now = time.time()
|
||||
attempts = _login_attempts.get(ip, [])
|
||||
attempts.append(now)
|
||||
_login_attempts[ip] = attempts
|
||||
|
||||
|
||||
def _signing_key():
|
||||
"""Derive a stable signing key from STATE_DIR."""
|
||||
return hashlib.sha256(str(STATE_DIR).encode()).digest()
|
||||
"""Return a random signing key, generating and persisting one on first call."""
|
||||
key_file = STATE_DIR / '.signing_key'
|
||||
try:
|
||||
if key_file.exists():
|
||||
raw = key_file.read_bytes()
|
||||
if len(raw) >= 32:
|
||||
return raw[:32]
|
||||
except Exception:
|
||||
logger.debug("Failed to read or access signing key file, using in-memory key")
|
||||
# Generate a new random key
|
||||
key = secrets.token_bytes(32)
|
||||
try:
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
key_file.write_bytes(key)
|
||||
key_file.chmod(0o600)
|
||||
except Exception:
|
||||
logger.debug("Failed to persist signing key, using in-memory key only")
|
||||
return key
|
||||
|
||||
|
||||
def _hash_password(password):
|
||||
"""SHA-256 hash with a salt derived from STATE_DIR."""
|
||||
salt = str(STATE_DIR).encode()
|
||||
return hashlib.sha256(salt + password.encode()).hexdigest()
|
||||
"""PBKDF2-SHA256 with 600k iterations (OWASP recommendation).
|
||||
Salt is the persisted random signing key, which is secret and unique per
|
||||
installation. This keeps the stored hash format a plain hex string
|
||||
(no format change to settings.json) while replacing the predictable
|
||||
STATE_DIR-derived salt from the original implementation."""
|
||||
salt = _signing_key()
|
||||
dk = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 600_000)
|
||||
return dk.hex()
|
||||
|
||||
|
||||
def get_password_hash():
|
||||
def get_password_hash() -> str | None:
|
||||
"""Return the active password hash, or None if auth is disabled.
|
||||
Priority: env var > settings.json."""
|
||||
env_pw = os.getenv('HERMES_WEBUI_PASSWORD', '').strip()
|
||||
@@ -46,12 +138,12 @@ def get_password_hash():
|
||||
return settings.get('password_hash') or None
|
||||
|
||||
|
||||
def is_auth_enabled():
|
||||
def is_auth_enabled() -> bool:
|
||||
"""True if a password is configured (env var or settings)."""
|
||||
return get_password_hash() is not None
|
||||
|
||||
|
||||
def verify_password(plain):
|
||||
def verify_password(plain) -> bool:
|
||||
"""Verify a plaintext password against the stored hash."""
|
||||
expected = get_password_hash()
|
||||
if not expected:
|
||||
@@ -59,20 +151,32 @@ def verify_password(plain):
|
||||
return hmac.compare_digest(_hash_password(plain), expected)
|
||||
|
||||
|
||||
def create_session():
|
||||
def create_session() -> str:
|
||||
"""Create a new auth session. Returns signed cookie value."""
|
||||
token = secrets.token_hex(32)
|
||||
_sessions[token] = time.time() + SESSION_TTL
|
||||
sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:16]
|
||||
_save_sessions(_sessions)
|
||||
sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:32]
|
||||
return f"{token}.{sig}"
|
||||
|
||||
|
||||
def verify_session(cookie_value):
|
||||
def _prune_expired_sessions():
|
||||
"""Remove all expired session entries to prevent unbounded memory growth."""
|
||||
now = time.time()
|
||||
expired = [t for t, exp in _sessions.items() if now > exp]
|
||||
if expired:
|
||||
for token in expired:
|
||||
_sessions.pop(token, None)
|
||||
_save_sessions(_sessions)
|
||||
|
||||
|
||||
def verify_session(cookie_value) -> bool:
|
||||
"""Verify a signed session cookie. Returns True if valid and not expired."""
|
||||
if not cookie_value or '.' not in cookie_value:
|
||||
return False
|
||||
_prune_expired_sessions() # lazy cleanup on every verification attempt
|
||||
token, sig = cookie_value.rsplit('.', 1)
|
||||
expected_sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:16]
|
||||
expected_sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:32]
|
||||
if not hmac.compare_digest(sig, expected_sig):
|
||||
return False
|
||||
expiry = _sessions.get(token)
|
||||
@@ -82,14 +186,16 @@ def verify_session(cookie_value):
|
||||
return True
|
||||
|
||||
|
||||
def invalidate_session(cookie_value):
|
||||
def invalidate_session(cookie_value) -> None:
|
||||
"""Remove a session token."""
|
||||
if cookie_value and '.' in cookie_value:
|
||||
token = cookie_value.rsplit('.', 1)[0]
|
||||
_sessions.pop(token, None)
|
||||
if token in _sessions:
|
||||
_sessions.pop(token, None)
|
||||
_save_sessions(_sessions)
|
||||
|
||||
|
||||
def parse_cookie(handler):
|
||||
def parse_cookie(handler) -> str | None:
|
||||
"""Extract the auth cookie from the request headers."""
|
||||
cookie_header = handler.headers.get('Cookie', '')
|
||||
if not cookie_header:
|
||||
@@ -103,7 +209,7 @@ def parse_cookie(handler):
|
||||
return morsel.value if morsel else None
|
||||
|
||||
|
||||
def check_auth(handler, parsed):
|
||||
def check_auth(handler, parsed) -> bool:
|
||||
"""Check if request is authorized. Returns True if OK.
|
||||
If not authorized, sends 401 (API) or 302 redirect (page) and returns False."""
|
||||
if not is_auth_enabled():
|
||||
@@ -128,7 +234,7 @@ def check_auth(handler, parsed):
|
||||
return False
|
||||
|
||||
|
||||
def set_auth_cookie(handler, cookie_value):
|
||||
def set_auth_cookie(handler, cookie_value) -> None:
|
||||
"""Set the auth cookie on the response."""
|
||||
cookie = http.cookies.SimpleCookie()
|
||||
cookie[COOKIE_NAME] = cookie_value
|
||||
@@ -136,10 +242,13 @@ def set_auth_cookie(handler, cookie_value):
|
||||
cookie[COOKIE_NAME]['samesite'] = 'Lax'
|
||||
cookie[COOKIE_NAME]['path'] = '/'
|
||||
cookie[COOKIE_NAME]['max-age'] = str(SESSION_TTL)
|
||||
# Set Secure flag when connection is HTTPS
|
||||
if getattr(handler.request, 'getpeercert', None) is not None or handler.headers.get('X-Forwarded-Proto', '') == 'https':
|
||||
cookie[COOKIE_NAME]['secure'] = True
|
||||
handler.send_header('Set-Cookie', cookie[COOKIE_NAME].OutputString())
|
||||
|
||||
|
||||
def clear_auth_cookie(handler):
|
||||
def clear_auth_cookie(handler) -> None:
|
||||
"""Clear the auth cookie on the response."""
|
||||
cookie = http.cookies.SimpleCookie()
|
||||
cookie[COOKIE_NAME] = ''
|
||||
|
||||
87
api/background.py
Normal file
87
api/background.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Background and ephemeral task tracking for /background and /btw commands."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
# parent_session_id -> list of task dicts
|
||||
_BACKGROUND_TASKS: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
# btw ephemeral session tracking: parent_sid -> {ephemeral_sid, stream_id, question}
|
||||
_BTW_TRACKING: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def track_background(parent_sid: str, bg_sid: str, stream_id: str,
|
||||
task_id: str, prompt: str) -> None:
|
||||
with _lock:
|
||||
_BACKGROUND_TASKS.setdefault(parent_sid, []).append({
|
||||
"task_id": task_id,
|
||||
"bg_session_id": bg_sid,
|
||||
"stream_id": stream_id,
|
||||
"prompt": prompt,
|
||||
"status": "running",
|
||||
"started_at": time.time(),
|
||||
"answer": None,
|
||||
"completed_at": None,
|
||||
})
|
||||
|
||||
|
||||
def track_btw(parent_sid: str, ephemeral_sid: str, stream_id: str,
|
||||
question: str) -> None:
|
||||
with _lock:
|
||||
_BTW_TRACKING[parent_sid] = {
|
||||
"ephemeral_session_id": ephemeral_sid,
|
||||
"stream_id": stream_id,
|
||||
"question": question,
|
||||
}
|
||||
|
||||
|
||||
def complete_background(parent_sid: str, task_id: str, answer: str) -> None:
|
||||
with _lock:
|
||||
for t in _BACKGROUND_TASKS.get(parent_sid, []):
|
||||
if t["task_id"] == task_id and t["status"] == "running":
|
||||
t["status"] = "done"
|
||||
t["answer"] = answer
|
||||
t["completed_at"] = time.time()
|
||||
break
|
||||
|
||||
|
||||
def get_results(parent_sid: str) -> list[dict[str, Any]]:
|
||||
"""Return completed background task results and remove only the done ones
|
||||
from tracking. Tasks still in ``status="running"`` MUST stay in the list
|
||||
so that ``complete_background()`` can still find them when the worker
|
||||
thread finishes — otherwise the first poll during a long-running task
|
||||
silently drops it and the result is lost forever.
|
||||
"""
|
||||
with _lock:
|
||||
tasks = _BACKGROUND_TASKS.get(parent_sid, [])
|
||||
done = [t for t in tasks if t["status"] == "done"]
|
||||
still_running = [t for t in tasks if t["status"] != "done"]
|
||||
if still_running:
|
||||
_BACKGROUND_TASKS[parent_sid] = still_running
|
||||
else:
|
||||
_BACKGROUND_TASKS.pop(parent_sid, None)
|
||||
return [{
|
||||
"task_id": t["task_id"],
|
||||
"prompt": t["prompt"],
|
||||
"answer": t["answer"],
|
||||
"completed_at": t["completed_at"],
|
||||
} for t in done]
|
||||
|
||||
|
||||
def get_background_tasks(parent_sid: str) -> list[dict[str, Any]]:
|
||||
"""Return all background tasks (running and done) for a parent session."""
|
||||
with _lock:
|
||||
return list(_BACKGROUND_TASKS.get(parent_sid, []))
|
||||
|
||||
|
||||
def cleanup_btw(parent_sid: str) -> dict[str, Any] | None:
|
||||
"""Remove and return btw tracking for a parent session."""
|
||||
with _lock:
|
||||
return _BTW_TRACKING.pop(parent_sid, None)
|
||||
181
api/clarify.py
Normal file
181
api/clarify.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""Clarify prompt state for the WebUI.
|
||||
|
||||
This mirrors the approval flow structure, but the response is a free-form
|
||||
clarification string instead of an approval decision.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
|
||||
DEFAULT_TIMEOUT_SECONDS = 120
|
||||
_lock = threading.Lock()
|
||||
_pending: dict[str, dict] = {}
|
||||
_gateway_queues: dict[str, list] = {}
|
||||
_gateway_notify_cbs: dict[str, object] = {}
|
||||
|
||||
# ── SSE subscriber registry ─────────────────────────────────────────────
|
||||
_clarify_sse_subscribers: dict[str, list[queue.Queue]] = {}
|
||||
|
||||
|
||||
class _ClarifyEntry:
|
||||
"""One pending clarify request inside a session."""
|
||||
|
||||
__slots__ = ("event", "data", "result")
|
||||
|
||||
def __init__(self, data: dict):
|
||||
self.event = threading.Event()
|
||||
self.data = data
|
||||
self.result: Optional[str] = None
|
||||
|
||||
|
||||
def register_gateway_notify(session_key: str, cb) -> None:
|
||||
"""Register a per-session callback for sending clarify requests to the UI."""
|
||||
with _lock:
|
||||
_gateway_notify_cbs[session_key] = cb
|
||||
|
||||
|
||||
def _clear_queue_locked(session_key: str) -> list[_ClarifyEntry]:
|
||||
entries = _gateway_queues.pop(session_key, [])
|
||||
_pending.pop(session_key, None)
|
||||
return entries
|
||||
|
||||
|
||||
def unregister_gateway_notify(session_key: str) -> None:
|
||||
"""Unregister the per-session callback and unblock any waiting clarify prompt."""
|
||||
with _lock:
|
||||
_gateway_notify_cbs.pop(session_key, None)
|
||||
entries = _clear_queue_locked(session_key)
|
||||
for entry in entries:
|
||||
entry.event.set()
|
||||
|
||||
|
||||
def clear_pending(session_key: str) -> int:
|
||||
"""Clear any pending clarify prompts for the session without removing the callback."""
|
||||
with _lock:
|
||||
entries = _clear_queue_locked(session_key)
|
||||
for entry in entries:
|
||||
entry.event.set()
|
||||
return len(entries)
|
||||
|
||||
|
||||
def _with_timeout_metadata(data: dict) -> dict:
|
||||
item = dict(data or {})
|
||||
requested_at = float(item.get("requested_at") or time.time())
|
||||
timeout_seconds = int(item.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS)
|
||||
expires_at = float(item.get("expires_at") or requested_at + timeout_seconds)
|
||||
item["requested_at"] = requested_at
|
||||
item["timeout_seconds"] = timeout_seconds
|
||||
item["expires_at"] = expires_at
|
||||
return item
|
||||
|
||||
|
||||
def _clarify_sse_notify(session_id: str, head: dict | None, total: int) -> None:
|
||||
"""Push a clarify event to all SSE subscribers for a session."""
|
||||
payload = {"pending": dict(head) if head else None, "pending_count": total}
|
||||
for q in _clarify_sse_subscribers.get(session_id, ()):
|
||||
try:
|
||||
q.put_nowait(payload)
|
||||
except queue.Full:
|
||||
pass # drop if subscriber is slow
|
||||
|
||||
|
||||
def sse_subscribe(session_id: str) -> queue.Queue:
|
||||
"""Register a bounded Queue for SSE push to a given session."""
|
||||
q: queue.Queue = queue.Queue(maxsize=16)
|
||||
with _lock:
|
||||
_clarify_sse_subscribers.setdefault(session_id, []).append(q)
|
||||
return q
|
||||
|
||||
|
||||
def sse_unsubscribe(session_id: str, q: queue.Queue) -> None:
|
||||
"""Remove a subscriber Queue; clean up empty session entries."""
|
||||
with _lock:
|
||||
subs = _clarify_sse_subscribers.get(session_id)
|
||||
if subs:
|
||||
try:
|
||||
subs.remove(q)
|
||||
except ValueError:
|
||||
pass
|
||||
if not subs:
|
||||
_clarify_sse_subscribers.pop(session_id, None)
|
||||
|
||||
|
||||
def submit_pending(session_key: str, data: dict) -> _ClarifyEntry:
|
||||
"""Queue a pending clarify request and notify the UI callback if registered."""
|
||||
data = _with_timeout_metadata(data)
|
||||
with _lock:
|
||||
gw_queue = _gateway_queues.setdefault(session_key, [])
|
||||
# De-duplicate while unresolved: if the most recent pending clarify is
|
||||
# semantically identical, reuse it instead of stacking duplicates.
|
||||
if gw_queue:
|
||||
last = gw_queue[-1]
|
||||
if (
|
||||
str(last.data.get("question", "")) == str(data.get("question", ""))
|
||||
and list(last.data.get("choices_offered") or [])
|
||||
== list(data.get("choices_offered") or [])
|
||||
):
|
||||
entry = last
|
||||
cb = _gateway_notify_cbs.get(session_key)
|
||||
# Keep _pending aligned to the oldest unresolved entry.
|
||||
_pending[session_key] = gw_queue[0].data
|
||||
if cb:
|
||||
try:
|
||||
cb(dict(entry.data))
|
||||
except Exception:
|
||||
pass
|
||||
return entry
|
||||
|
||||
entry = _ClarifyEntry(data)
|
||||
gw_queue.append(entry)
|
||||
_pending[session_key] = gw_queue[0].data
|
||||
cb = _gateway_notify_cbs.get(session_key)
|
||||
# Notify SSE subscribers from inside _lock for ordering guarantees.
|
||||
_clarify_sse_notify(session_key, dict(gw_queue[0].data), len(gw_queue))
|
||||
if cb:
|
||||
try:
|
||||
cb(data)
|
||||
except Exception:
|
||||
pass
|
||||
return entry
|
||||
|
||||
|
||||
def get_pending(session_key: str) -> dict | None:
|
||||
"""Return the oldest pending clarify request for this session, if any."""
|
||||
with _lock:
|
||||
queue = _gateway_queues.get(session_key) or []
|
||||
if queue:
|
||||
return dict(queue[0].data)
|
||||
pending = _pending.get(session_key)
|
||||
return dict(pending) if pending else None
|
||||
|
||||
|
||||
def has_pending(session_key: str) -> bool:
|
||||
with _lock:
|
||||
return bool(_gateway_queues.get(session_key))
|
||||
|
||||
|
||||
def resolve_clarify(session_key: str, response: str, resolve_all: bool = False) -> int:
|
||||
"""Resolve the oldest pending clarify request for a session."""
|
||||
with _lock:
|
||||
q = _gateway_queues.get(session_key)
|
||||
if not q:
|
||||
_pending.pop(session_key, None)
|
||||
return 0
|
||||
entries = list(q) if resolve_all else [q.pop(0)]
|
||||
if q:
|
||||
_pending[session_key] = q[0].data
|
||||
_clarify_sse_notify(session_key, dict(q[0].data), len(q))
|
||||
else:
|
||||
_clear_queue_locked(session_key)
|
||||
_clarify_sse_notify(session_key, None, 0)
|
||||
count = 0
|
||||
for entry in entries:
|
||||
entry.result = response
|
||||
entry.event.set()
|
||||
count += 1
|
||||
return count
|
||||
56
api/commands.py
Normal file
56
api/commands.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Expose hermes-agent's COMMAND_REGISTRY to the webui frontend.
|
||||
|
||||
This module is the single integration point with hermes_cli.commands.
|
||||
If hermes-agent is unavailable the endpoint degrades to an empty list
|
||||
so the frontend can still load with WEBUI_ONLY commands.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Commands that are gateway_only in the agent registry -- webui never
|
||||
# wants to expose them (sethome, restart, update etc.) even if a future
|
||||
# agent version drops the gateway_only flag. /commands is the agent's
|
||||
# own command-listing command; webui has its own /help that calls
|
||||
# cmdHelp() locally, so /commands would be redundant and confusing.
|
||||
_NEVER_EXPOSE: frozenset[str] = frozenset({
|
||||
'sethome', 'restart', 'update', 'commands',
|
||||
})
|
||||
|
||||
|
||||
def list_commands(_registry=None) -> list[dict[str, Any]]:
|
||||
"""Return COMMAND_REGISTRY entries as JSON-friendly dicts.
|
||||
|
||||
Returns empty list if hermes_cli is not installed (graceful
|
||||
degradation -- the frontend has its own fallback minimum set).
|
||||
|
||||
Args:
|
||||
_registry: Optional injected registry for testing. When None
|
||||
(production), imports COMMAND_REGISTRY from hermes_cli.
|
||||
"""
|
||||
if _registry is None:
|
||||
try:
|
||||
from hermes_cli.commands import COMMAND_REGISTRY as _registry
|
||||
except ImportError:
|
||||
logger.warning("hermes_cli.commands not importable -- /api/commands returns []")
|
||||
return []
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
for cmd in _registry:
|
||||
if cmd.gateway_only:
|
||||
continue
|
||||
if cmd.name in _NEVER_EXPOSE:
|
||||
continue
|
||||
out.append({
|
||||
'name': cmd.name,
|
||||
'description': cmd.description,
|
||||
'category': cmd.category,
|
||||
'aliases': list(cmd.aliases),
|
||||
'args_hint': cmd.args_hint,
|
||||
'subcommands': list(cmd.subcommands),
|
||||
'cli_only': bool(cmd.cli_only),
|
||||
'gateway_only': bool(cmd.gateway_only),
|
||||
})
|
||||
return out
|
||||
2527
api/config.py
2527
api/config.py
File diff suppressed because it is too large
Load Diff
230
api/gateway_watcher.py
Normal file
230
api/gateway_watcher.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""
|
||||
Hermes Web UI -- Gateway session watcher.
|
||||
|
||||
Background daemon thread that polls state.db every 5 seconds for changes
|
||||
to gateway sessions (telegram, discord, slack, etc.). When changes are
|
||||
detected, it pushes notifications to all subscribed SSE clients.
|
||||
|
||||
This enables real-time session list updates in the sidebar without
|
||||
requiring any changes to hermes-agent.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from api.config import HOME
|
||||
from api.agent_sessions import read_importable_agent_session_rows
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── State hash tracking ─────────────────────────────────────────────────────
|
||||
|
||||
def _snapshot_hash(sessions: list) -> str:
|
||||
"""Create a lightweight hash of session IDs and timestamps for change detection."""
|
||||
key = '|'.join(
|
||||
f"{s['session_id']}:{s.get('updated_at', 0)}:{s.get('message_count', 0)}"
|
||||
for s in sorted(sessions, key=lambda x: x['session_id'])
|
||||
)
|
||||
return hashlib.md5(key.encode(), usedforsecurity=False).hexdigest()
|
||||
|
||||
|
||||
# ── DB resolution (shared pattern with state_sync.py) ──────────────────────
|
||||
|
||||
def _get_state_db_path() -> Path:
|
||||
"""Resolve state.db path for the active profile."""
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
hermes_home = Path(get_active_hermes_home()).expanduser().resolve()
|
||||
except Exception:
|
||||
hermes_home = Path(os.getenv('HERMES_HOME', str(HOME / '.hermes'))).expanduser().resolve()
|
||||
return hermes_home / 'state.db'
|
||||
|
||||
|
||||
def _get_agent_sessions_from_db() -> list:
|
||||
"""Read all non-webui sessions from state.db.
|
||||
Returns list of session dicts, or empty list on any error.
|
||||
"""
|
||||
db_path = _get_state_db_path()
|
||||
if not db_path.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
sessions = []
|
||||
for row in read_importable_agent_session_rows(db_path, limit=200, log=logger):
|
||||
sessions.append({
|
||||
'session_id': row['id'],
|
||||
'title': row['title'] or 'Agent Session',
|
||||
'model': row['model'] or None,
|
||||
'message_count': row['message_count'] or row['actual_message_count'] or 0,
|
||||
'created_at': row['started_at'],
|
||||
'updated_at': row['last_activity'] or row['started_at'],
|
||||
'source': row['source'] or 'cli',
|
||||
'raw_source': row.get('raw_source'),
|
||||
'session_source': row.get('session_source'),
|
||||
'source_label': row.get('source_label'),
|
||||
})
|
||||
return sessions
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
# ── GatewayWatcher ──────────────────────────────────────────────────────────
|
||||
|
||||
class GatewayWatcher:
|
||||
"""Background thread that polls state.db for agent session changes.
|
||||
|
||||
Usage:
|
||||
watcher = GatewayWatcher()
|
||||
watcher.start()
|
||||
q = watcher.subscribe()
|
||||
# ... receive change events via q.get() ...
|
||||
watcher.unsubscribe(q)
|
||||
watcher.stop()
|
||||
"""
|
||||
|
||||
POLL_INTERVAL = 5 # seconds between polls
|
||||
SUBSCRIBER_TIMEOUT = 30 # seconds before sending keepalive comment
|
||||
|
||||
def __init__(self):
|
||||
self._subscribers: list[queue.Queue] = []
|
||||
self._sub_lock = threading.Lock()
|
||||
self._stop_event = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._last_hash: str = ''
|
||||
self._last_sessions: list = []
|
||||
|
||||
def start(self):
|
||||
"""Start the watcher daemon thread."""
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(target=self._poll_loop, daemon=True, name='gateway-watcher')
|
||||
self._thread.start()
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
"""Return True when the poll thread is running.
|
||||
|
||||
Public accessor used by ``/api/sessions/gateway/stream`` probe mode and
|
||||
the live SSE handler to detect a watcher instance whose poll thread
|
||||
died silently (e.g. uncaught exception in ``_poll_loop``). Callers
|
||||
use this to decide whether to return 503 and trigger the client-side
|
||||
polling fallback, instead of handing out an SSE connection that would
|
||||
never emit events.
|
||||
"""
|
||||
t = self._thread
|
||||
return t is not None and t.is_alive()
|
||||
|
||||
def stop(self):
|
||||
"""Stop the watcher thread."""
|
||||
self._stop_event.set()
|
||||
# Wake up any subscribers
|
||||
with self._sub_lock:
|
||||
for q in self._subscribers:
|
||||
try:
|
||||
q.put(None) # sentinel
|
||||
except Exception:
|
||||
logger.debug("Failed to send sentinel to subscriber")
|
||||
if self._thread:
|
||||
self._thread.join(timeout=3)
|
||||
self._thread = None
|
||||
|
||||
def subscribe(self) -> queue.Queue:
|
||||
"""Subscribe to change events. Returns a queue.Queue.
|
||||
Events are dicts: {'type': 'sessions_changed', 'sessions': [...]}
|
||||
A None sentinel means the watcher is stopping.
|
||||
"""
|
||||
q = queue.Queue(maxsize=10)
|
||||
with self._sub_lock:
|
||||
self._subscribers.append(q)
|
||||
return q
|
||||
|
||||
def unsubscribe(self, q: queue.Queue):
|
||||
"""Remove a subscriber queue."""
|
||||
with self._sub_lock:
|
||||
try:
|
||||
self._subscribers.remove(q)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def _notify_subscribers(self, sessions: list):
|
||||
"""Push change event to all subscribers."""
|
||||
event = {
|
||||
'type': 'sessions_changed',
|
||||
'sessions': sessions,
|
||||
}
|
||||
with self._sub_lock:
|
||||
dead = []
|
||||
for q in self._subscribers:
|
||||
try:
|
||||
q.put_nowait(event)
|
||||
except queue.Full:
|
||||
dead.append(q) # remove slow consumers
|
||||
except Exception:
|
||||
dead.append(q)
|
||||
for q in dead:
|
||||
try:
|
||||
self._subscribers.remove(q)
|
||||
except ValueError:
|
||||
pass
|
||||
# Send a None sentinel so the SSE handler unblocks, closes,
|
||||
# and lets the browser's EventSource auto-reconnect.
|
||||
try:
|
||||
q.put_nowait(None)
|
||||
except Exception:
|
||||
logger.debug("Failed to send sentinel to dead subscriber")
|
||||
|
||||
def _poll_loop(self):
|
||||
"""Main polling loop. Runs in a daemon thread."""
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
sessions = _get_agent_sessions_from_db()
|
||||
current_hash = _snapshot_hash(sessions)
|
||||
|
||||
if current_hash != self._last_hash:
|
||||
self._last_hash = current_hash
|
||||
self._last_sessions = sessions
|
||||
self._notify_subscribers(sessions)
|
||||
except Exception:
|
||||
logger.debug("Error in gateway watcher poll loop", exc_info=True)
|
||||
|
||||
# Sleep in small increments so we can stop promptly
|
||||
for _ in range(self.POLL_INTERVAL * 10):
|
||||
if self._stop_event.is_set():
|
||||
return
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
# ── Module-level singleton ─────────────────────────────────────────────────
|
||||
|
||||
_watcher: GatewayWatcher | None = None
|
||||
_watcher_lock = threading.Lock()
|
||||
|
||||
|
||||
def start_watcher():
|
||||
"""Start the global gateway watcher (idempotent)."""
|
||||
global _watcher
|
||||
with _watcher_lock:
|
||||
if _watcher is None:
|
||||
_watcher = GatewayWatcher()
|
||||
_watcher.start()
|
||||
|
||||
|
||||
def stop_watcher():
|
||||
"""Stop the global gateway watcher."""
|
||||
global _watcher
|
||||
with _watcher_lock:
|
||||
if _watcher is not None:
|
||||
_watcher.stop()
|
||||
_watcher = None
|
||||
|
||||
|
||||
def get_watcher() -> GatewayWatcher | None:
|
||||
"""Get the global watcher instance (or None if not started)."""
|
||||
with _watcher_lock:
|
||||
return _watcher
|
||||
235
api/helpers.py
235
api/helpers.py
@@ -2,22 +2,32 @@
|
||||
Hermes Web UI -- HTTP helper functions.
|
||||
"""
|
||||
import json as _json
|
||||
import re as _re
|
||||
from pathlib import Path
|
||||
from api.config import IMAGE_EXTS, MD_EXTS
|
||||
|
||||
|
||||
def require(body: dict, *fields):
|
||||
def require(body: dict, *fields) -> None:
|
||||
"""Phase D: Validate required fields. Raises ValueError with clean message."""
|
||||
missing = [f for f in fields if not body.get(f) and body.get(f) != 0]
|
||||
if missing:
|
||||
raise ValueError(f"Missing required field(s): {', '.join(missing)}")
|
||||
|
||||
|
||||
def bad(handler, msg, status=400):
|
||||
def bad(handler, msg, status: int=400):
|
||||
"""Return a clean JSON error response."""
|
||||
return j(handler, {'error': msg}, status=status)
|
||||
|
||||
|
||||
def _sanitize_error(e: Exception) -> str:
|
||||
"""Strip filesystem paths from exception messages before returning to client."""
|
||||
import re
|
||||
msg = str(e)
|
||||
# Remove absolute paths (Unix and Windows)
|
||||
msg = re.sub(r'(?:(?:/[a-zA-Z0-9_.-]+)+|(?:[A-Z]:\\[^\s]+))', '<path>', msg)
|
||||
return msg
|
||||
|
||||
|
||||
def safe_resolve(root: Path, requested: str) -> Path:
|
||||
"""Resolve a relative path inside root, raising ValueError on traversal."""
|
||||
resolved = (root / requested).resolve()
|
||||
@@ -30,21 +40,59 @@ def _security_headers(handler):
|
||||
handler.send_header('X-Content-Type-Options', 'nosniff')
|
||||
handler.send_header('X-Frame-Options', 'DENY')
|
||||
handler.send_header('Referrer-Policy', 'same-origin')
|
||||
handler.send_header(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self' https://*.cloudflareaccess.com; "
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
|
||||
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; "
|
||||
"img-src 'self' data: https: blob:; font-src 'self' data: https://cdn.jsdelivr.net https://fonts.gstatic.com; connect-src 'self'; "
|
||||
"manifest-src 'self' https://*.cloudflareaccess.com; "
|
||||
"base-uri 'self'; form-action 'self'"
|
||||
)
|
||||
handler.send_header(
|
||||
'Permissions-Policy',
|
||||
'camera=(), microphone=(self), geolocation=(), clipboard-write=(self)'
|
||||
)
|
||||
|
||||
|
||||
def j(handler, payload, status=200):
|
||||
"""Send a JSON response."""
|
||||
def _accepts_gzip(handler) -> bool:
|
||||
"""Check if the client accepts gzip encoding."""
|
||||
headers = getattr(handler, 'headers', None)
|
||||
if not headers:
|
||||
return False
|
||||
ae = headers.get('Accept-Encoding', '')
|
||||
return 'gzip' in ae
|
||||
|
||||
|
||||
def j(handler, payload, status: int=200, extra_headers: dict=None) -> None:
|
||||
"""Send a JSON response.
|
||||
|
||||
*extra_headers*: optional dict of additional headers to include
|
||||
(e.g., {'Set-Cookie': '...'}). Headers are sent before end_headers().
|
||||
"""
|
||||
body = _json.dumps(payload, ensure_ascii=False, indent=2).encode('utf-8')
|
||||
handler.send_response(status)
|
||||
handler.send_header('Content-Type', 'application/json; charset=utf-8')
|
||||
|
||||
# Gzip-compress responses over 1KB when the client accepts it.
|
||||
# Typical JSON API responses compress 70-80%, giving a big speedup
|
||||
# for large payloads (session history, message lists).
|
||||
if _accepts_gzip(handler) and len(body) > 1024:
|
||||
import gzip
|
||||
body = gzip.compress(body, compresslevel=4)
|
||||
handler.send_header('Content-Encoding', 'gzip')
|
||||
|
||||
handler.send_header('Content-Length', str(len(body)))
|
||||
handler.send_header('Cache-Control', 'no-store')
|
||||
_security_headers(handler)
|
||||
if extra_headers:
|
||||
for k, v in extra_headers.items():
|
||||
handler.send_header(k, v)
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
|
||||
|
||||
def t(handler, payload, status=200, content_type='text/plain; charset=utf-8'):
|
||||
def t(handler, payload, status: int=200, content_type: str='text/plain; charset=utf-8') -> None:
|
||||
"""Send a plain text or HTML response."""
|
||||
body = payload if isinstance(payload, bytes) else str(payload).encode('utf-8')
|
||||
handler.send_response(status)
|
||||
@@ -59,7 +107,135 @@ def t(handler, payload, status=200, content_type='text/plain; charset=utf-8'):
|
||||
MAX_BODY_BYTES = 20 * 1024 * 1024 # 20MB limit for non-upload POST bodies
|
||||
|
||||
|
||||
def read_body(handler):
|
||||
# ── Credential redaction ──────────────────────────────────────────────────────
|
||||
|
||||
def _build_redact_fn():
|
||||
"""Return a redactor backed by hermes-agent plus local fallback patterns."""
|
||||
# Minimal fallback covering the most common credential prefixes.
|
||||
# Keep this active even when hermes-agent is importable so API responses do
|
||||
# not regress if the agent redactor misses a token shape.
|
||||
_CRED_RE = _re.compile(
|
||||
r"(?<![A-Za-z0-9_-])("
|
||||
r"sk-[A-Za-z0-9_-]{10,}" # OpenAI / Anthropic / OpenRouter
|
||||
r"|ghp_[A-Za-z0-9]{10,}" # GitHub PAT (classic)
|
||||
r"|github_pat_[A-Za-z0-9_]{10,}" # GitHub PAT (fine-grained)
|
||||
r"|gho_[A-Za-z0-9]{10,}" # GitHub OAuth token
|
||||
r"|ghu_[A-Za-z0-9]{10,}" # GitHub user-to-server token
|
||||
r"|ghs_[A-Za-z0-9]{10,}" # GitHub server-to-server token
|
||||
r"|ghr_[A-Za-z0-9]{10,}" # GitHub refresh token
|
||||
r"|AKIA[A-Z0-9]{16}" # AWS Access Key ID
|
||||
r"|xox[baprs]-[A-Za-z0-9-]{10,}" # Slack tokens
|
||||
r"|hf_[A-Za-z0-9]{10,}" # HuggingFace token
|
||||
r"|SG\.[A-Za-z0-9_-]{10,}" # SendGrid API key
|
||||
r")(?![A-Za-z0-9_-])"
|
||||
)
|
||||
_AUTH_HDR_RE = _re.compile(r"(Authorization:\s*Bearer\s+)(\S+)", _re.IGNORECASE)
|
||||
_ENV_RE = _re.compile(
|
||||
r"([A-Z0-9_]{0,50}(?:API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)[A-Z0-9_]{0,50})"
|
||||
r"\s*=\s*(['\"]?)(\S+)\2"
|
||||
)
|
||||
_PRIVKEY_RE = _re.compile(
|
||||
r"-----BEGIN[A-Z ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z ]*PRIVATE KEY-----"
|
||||
)
|
||||
|
||||
def _mask(token: str) -> str:
|
||||
return f"{token[:6]}...{token[-4:]}" if len(token) >= 18 else "***"
|
||||
|
||||
def _fallback_redact(text: str) -> str:
|
||||
if not isinstance(text, str) or not text:
|
||||
return text
|
||||
text = _CRED_RE.sub(lambda m: _mask(m.group(1)), text)
|
||||
text = _AUTH_HDR_RE.sub(lambda m: m.group(1) + _mask(m.group(2)), text)
|
||||
text = _ENV_RE.sub(
|
||||
lambda m: f"{m.group(1)}={m.group(2)}{_mask(m.group(3))}{m.group(2)}", text
|
||||
)
|
||||
text = _PRIVKEY_RE.sub("[REDACTED PRIVATE KEY]", text)
|
||||
return text
|
||||
|
||||
try:
|
||||
from agent.redact import redact_sensitive_text
|
||||
except ImportError:
|
||||
return _fallback_redact
|
||||
|
||||
def _combined_redact(text: str) -> str:
|
||||
if not isinstance(text, str) or not text:
|
||||
return text
|
||||
# WebUI API responses are a hard safety boundary — pass force=True so the
|
||||
# agent's broader patterns (Stripe sk_live_, Google AIza…, JWT eyJ…, DB
|
||||
# connection strings, Telegram bot tokens) run regardless of the user's
|
||||
# HERMES_REDACT_SECRETS opt-in. The local fallback then handles the
|
||||
# common short-prefix shapes the agent omits (ghp_, sk-, hf_, AKIA).
|
||||
try:
|
||||
agent_redacted = redact_sensitive_text(text, force=True)
|
||||
except TypeError:
|
||||
# Older hermes-agent builds that predate the force kwarg.
|
||||
agent_redacted = redact_sensitive_text(text)
|
||||
return _fallback_redact(agent_redacted)
|
||||
|
||||
return _combined_redact
|
||||
|
||||
|
||||
_redact_fn_cached = _build_redact_fn()
|
||||
|
||||
|
||||
def _redact_text(text: str, *, _enabled: bool | None = None) -> str:
|
||||
"""Redact sensitive text from API responses. Respects api_redact_enabled setting.
|
||||
|
||||
The ``_enabled`` parameter is an internal optimization for callers that
|
||||
redact many strings in a single response — `redact_session_data()` reads
|
||||
the setting once and threads it through ``_redact_value`` so we avoid
|
||||
re-loading settings.json from disk per string. (Opus pre-release perf fix.)
|
||||
"""
|
||||
if not isinstance(text, str) or not text:
|
||||
return text
|
||||
if _enabled is None:
|
||||
from api.config import load_settings
|
||||
_enabled = bool(load_settings().get("api_redact_enabled", True))
|
||||
if not _enabled:
|
||||
return text
|
||||
return _redact_fn_cached(text)
|
||||
|
||||
|
||||
def _redact_value(v, *, _enabled: bool | None = None):
|
||||
"""Recursively redact credentials from strings, dicts, and lists.
|
||||
|
||||
``_enabled`` is threaded through so a single response-level redact pass
|
||||
only reads settings.json once. (Opus pre-release perf fix.)
|
||||
"""
|
||||
if isinstance(v, str):
|
||||
return _redact_text(v, _enabled=_enabled)
|
||||
if isinstance(v, dict):
|
||||
return {k: _redact_value(val, _enabled=_enabled) for k, val in v.items()}
|
||||
if isinstance(v, list):
|
||||
return [_redact_value(item, _enabled=_enabled) for item in v]
|
||||
return v
|
||||
|
||||
|
||||
def redact_session_data(session_dict: dict) -> dict:
|
||||
"""Redact credentials from message content and tool_call data before API response.
|
||||
|
||||
Applies to: messages[], tool_calls[], and title.
|
||||
The underlying session file is not modified; redaction is response-layer only.
|
||||
|
||||
Reads the ``api_redact_enabled`` setting ONCE for the entire response and
|
||||
threads it through to avoid hundreds of settings.json reads per session
|
||||
payload (a 50-message session has hundreds of nested strings). When the
|
||||
setting is disabled this is also a fast path: the recursion still walks
|
||||
but every string returns early.
|
||||
"""
|
||||
from api.config import load_settings
|
||||
_enabled = bool(load_settings().get("api_redact_enabled", True))
|
||||
result = dict(session_dict)
|
||||
if isinstance(result.get('title'), str):
|
||||
result['title'] = _redact_text(result['title'], _enabled=_enabled)
|
||||
if 'messages' in result:
|
||||
result['messages'] = _redact_value(result['messages'], _enabled=_enabled)
|
||||
if 'tool_calls' in result:
|
||||
result['tool_calls'] = _redact_value(result['tool_calls'], _enabled=_enabled)
|
||||
return result
|
||||
|
||||
|
||||
def read_body(handler) -> dict:
|
||||
"""Read and JSON-parse a POST request body (capped at 20MB)."""
|
||||
length = int(handler.headers.get('Content-Length', 0))
|
||||
if length > MAX_BODY_BYTES:
|
||||
@@ -69,3 +245,50 @@ def read_body(handler):
|
||||
return _json.loads(raw)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
# ── Profile cookie helpers (issue #798) ─────────────────────────────────────
|
||||
|
||||
PROFILE_COOKIE_NAME = 'hermes_profile'
|
||||
|
||||
|
||||
def get_profile_cookie(handler) -> str | None:
|
||||
"""Extract the hermes_profile cookie value from the request, or None."""
|
||||
cookie_header = handler.headers.get('Cookie', '')
|
||||
if not cookie_header:
|
||||
return None
|
||||
import http.cookies as _hc
|
||||
cookie = _hc.SimpleCookie()
|
||||
try:
|
||||
cookie.load(cookie_header)
|
||||
except _hc.CookieError:
|
||||
return None
|
||||
morsel = cookie.get(PROFILE_COOKIE_NAME)
|
||||
if morsel and morsel.value:
|
||||
# Validate against profile-name pattern before trusting
|
||||
from api.profiles import _PROFILE_ID_RE
|
||||
val = morsel.value
|
||||
if val == 'default' or _PROFILE_ID_RE.fullmatch(val):
|
||||
return val
|
||||
return None
|
||||
|
||||
|
||||
def build_profile_cookie(name: str) -> str:
|
||||
"""Build a Set-Cookie header value for the hermes_profile cookie.
|
||||
|
||||
Always persist the selected profile in the cookie, including 'default'.
|
||||
Clearing the cookie causes the backend to fall back to process-global
|
||||
_active_profile, which can unexpectedly switch clients back to another
|
||||
profile.
|
||||
|
||||
Set HttpOnly because the UI reads the active profile from
|
||||
/api/profile/active JSON and does not need to access this cookie via
|
||||
document.cookie.
|
||||
"""
|
||||
import http.cookies as _hc
|
||||
cookie = _hc.SimpleCookie()
|
||||
cookie[PROFILE_COOKIE_NAME] = name
|
||||
cookie[PROFILE_COOKIE_NAME]['path'] = '/'
|
||||
cookie[PROFILE_COOKIE_NAME]['httponly'] = True
|
||||
cookie[PROFILE_COOKIE_NAME]['samesite'] = 'Lax'
|
||||
return cookie[PROFILE_COOKIE_NAME].OutputString()
|
||||
|
||||
187
api/metering.py
Normal file
187
api/metering.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Hermes Web UI -- Streaming performance metering.
|
||||
|
||||
Tracks Tokens Per Second (TPS) across all active WebUI sessions, and the
|
||||
HIGH/LOW TPS values observed over the past 60 minutes. Metering data is
|
||||
emitted via SSE events so the header label can update live during a stream.
|
||||
|
||||
Architecture
|
||||
────────────
|
||||
Each streaming session is tracked independently. TPS per session is:
|
||||
|
||||
session_tps = total_tokens / (last_token_ts - first_token_ts)
|
||||
|
||||
The global tps is the average of all currently active sessions' TPS values.
|
||||
This correctly represents the system's real-time capacity regardless of how
|
||||
many sessions are running or how long each has been streaming.
|
||||
|
||||
For HIGH/LOW tracking, every stats snapshot records the current global tps
|
||||
(only when > 0 — idle periods are skipped) into a rolling 60-minute history.
|
||||
The max/min of that history gives the peak throughput observed over the past hour.
|
||||
|
||||
The ticker in streaming.py calls get_interval() — it returns 1.0 when sessions
|
||||
are actively receiving tokens so the header updates at 1 Hz, and 10.0 when idle
|
||||
so the ticker exits and no idle readings are emitted.
|
||||
|
||||
Usage from api/streaming.py
|
||||
─────────────────────────────
|
||||
from api.metering import meter
|
||||
|
||||
meter().begin_session(stream_id) # stream starts
|
||||
meter().record_token(stream_id, running_output) # per output token
|
||||
meter().record_reasoning(stream_id, running_reasoning_len) # per reasoning token
|
||||
|
||||
The SSE `metering` event payload:
|
||||
{
|
||||
"tps": 47.3, # average TPS across active sessions (real-time)
|
||||
"high": 52.1, # highest average TPS observed in the past 60 minutes
|
||||
"low": 31.4, # lowest average TPS (excl. readings < 1 tps, to ignore idle)
|
||||
"active": 1, # sessions currently streaming
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
_HOUR_SECS = 3600.0 # rolling window for HIGH/LOW tracking
|
||||
_STALE_SECS = 60.0 # consider a session inactive after this
|
||||
|
||||
|
||||
@dataclass
|
||||
class _SessionMeter:
|
||||
output_tokens: int = 0
|
||||
reasoning_tokens: int = 0
|
||||
first_token_ts: float = 0.0 # time.monotonic() of first token received
|
||||
last_token_ts: float = 0.0 # time.monotonic() of last token received
|
||||
|
||||
def total_tokens(self) -> int:
|
||||
return self.output_tokens + self.reasoning_tokens
|
||||
|
||||
def tps(self) -> float:
|
||||
if self.first_token_ts == 0.0 or self.last_token_ts <= self.first_token_ts:
|
||||
return 0.0
|
||||
return self.total_tokens() / (self.last_token_ts - self.first_token_ts)
|
||||
|
||||
|
||||
class GlobalMeter:
|
||||
"""Thread-safe global streaming meter.
|
||||
|
||||
Tracks per-session TPS, averages them for a global tps, and maintains a
|
||||
60-minute rolling history of global tps snapshots for HIGH/LOW reporting.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
'_lock',
|
||||
'_sessions', # stream_id -> _SessionMeter
|
||||
'_readings', # [(monotonic_ts, tps), ...] rolling 60-minute history
|
||||
'_window_start', # monotonic ts of current window
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._sessions: dict[str, _SessionMeter] = {}
|
||||
self._readings: list[tuple[float, float]] = []
|
||||
self._window_start: float = time.monotonic()
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
def begin_session(self, stream_id: str) -> None:
|
||||
with self._lock:
|
||||
self._sessions[stream_id] = _SessionMeter()
|
||||
|
||||
def get_interval(self) -> float:
|
||||
"""Return 1.0 when sessions are actively receiving tokens, 10.0 when idle.
|
||||
|
||||
Used by the streaming ticker to run at 1 Hz during work and exit when
|
||||
there is nothing to measure.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
# Only count sessions that have received at least one token recently.
|
||||
active_sids = {
|
||||
sid for sid, s in self._sessions.items()
|
||||
if s.first_token_ts > 0 and (now - s.last_token_ts) <= _STALE_SECS
|
||||
}
|
||||
return 1.0 if active_sids else 10.0
|
||||
|
||||
def record_token(self, stream_id: str, running_output_tokens: int) -> None:
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
s = self._sessions.get(stream_id)
|
||||
if s is None:
|
||||
return
|
||||
if s.first_token_ts == 0.0:
|
||||
s.first_token_ts = now
|
||||
s.last_token_ts = now
|
||||
s.output_tokens = running_output_tokens
|
||||
|
||||
def record_reasoning(self, stream_id: str, running_reasoning_tokens: int) -> None:
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
s = self._sessions.get(stream_id)
|
||||
if s is None:
|
||||
return
|
||||
if s.first_token_ts == 0.0:
|
||||
s.first_token_ts = now
|
||||
s.last_token_ts = now
|
||||
s.reasoning_tokens = running_reasoning_tokens
|
||||
|
||||
def end_session(self, stream_id: str, final_output_tokens: int, input_tokens: int = 0) -> None:
|
||||
with self._lock:
|
||||
self._sessions.pop(stream_id, None)
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
# Prune stale sessions
|
||||
stale = [
|
||||
sid for sid, s in self._sessions.items()
|
||||
if s.first_token_ts > 0 and (now - s.last_token_ts) > _STALE_SECS
|
||||
]
|
||||
for sid in stale:
|
||||
self._sessions.pop(sid, None)
|
||||
|
||||
# Reset window if everything went stale
|
||||
if not self._sessions:
|
||||
self._window_start = now
|
||||
|
||||
# Compute global tps: average of per-session TPS values
|
||||
active = [s for s in self._sessions.values() if s.first_token_ts > 0]
|
||||
if active:
|
||||
global_tps = sum(s.tps() for s in active) / len(active)
|
||||
else:
|
||||
global_tps = 0.0
|
||||
|
||||
# Prune readings older than 1 hour
|
||||
cutoff = now - _HOUR_SECS
|
||||
self._readings = [(ts, v) for ts, v in self._readings if ts > cutoff]
|
||||
|
||||
# Only record this snapshot for HIGH/LOW if there is active work.
|
||||
# This prevents idle periods from flooding the history and keeps
|
||||
# HIGH/LOW meaningful for the past hour of actual throughput.
|
||||
if global_tps > 0:
|
||||
self._readings.append((now, global_tps))
|
||||
|
||||
# HIGH/LOW from the past hour (skip near-zero idle readings)
|
||||
active_readings = [v for _, v in self._readings if v >= 1.0]
|
||||
high = max(active_readings) if active_readings else 0.0
|
||||
low = min(active_readings) if active_readings else 0.0
|
||||
|
||||
return {
|
||||
'tps': round(global_tps, 1),
|
||||
'high': round(high, 1),
|
||||
'low': round(low, 1),
|
||||
'active': len(self._sessions),
|
||||
}
|
||||
|
||||
|
||||
# ── Module-level singleton ─────────────────────────────────────────────────────
|
||||
|
||||
_meter = GlobalMeter()
|
||||
|
||||
|
||||
def meter() -> GlobalMeter:
|
||||
return _meter
|
||||
979
api/models.py
979
api/models.py
File diff suppressed because it is too large
Load Diff
695
api/onboarding.py
Normal file
695
api/onboarding.py
Normal file
@@ -0,0 +1,695 @@
|
||||
"""Hermes Web UI -- first-run onboarding helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from api.auth import is_auth_enabled
|
||||
from api.config import (
|
||||
DEFAULT_MODEL,
|
||||
DEFAULT_WORKSPACE,
|
||||
_FALLBACK_MODELS,
|
||||
_HERMES_FOUND,
|
||||
_PROVIDER_DISPLAY,
|
||||
_PROVIDER_MODELS,
|
||||
_get_config_path,
|
||||
get_available_models,
|
||||
get_config,
|
||||
load_settings,
|
||||
reload_config,
|
||||
save_settings,
|
||||
verify_hermes_imports,
|
||||
)
|
||||
from api.providers import _write_env_file # shared impl with _ENV_LOCK (#1164)
|
||||
from api.workspace import get_last_workspace, load_workspaces
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_SUPPORTED_PROVIDER_SETUPS = {
|
||||
# ── Easy start ──────────────────────────────────────────────────────
|
||||
"openrouter": {
|
||||
"label": "OpenRouter",
|
||||
"env_var": "OPENROUTER_API_KEY",
|
||||
"default_model": "anthropic/claude-sonnet-4.6",
|
||||
"requires_base_url": False,
|
||||
"models": [
|
||||
{"id": model["id"], "label": model["label"]} for model in _FALLBACK_MODELS
|
||||
],
|
||||
"category": "easy_start",
|
||||
"quick": True,
|
||||
},
|
||||
"anthropic": {
|
||||
"label": "Anthropic",
|
||||
"env_var": "ANTHROPIC_API_KEY",
|
||||
"default_model": "claude-sonnet-4.6",
|
||||
"requires_base_url": False,
|
||||
"models": list(_PROVIDER_MODELS.get("anthropic", [])),
|
||||
"category": "easy_start",
|
||||
},
|
||||
"openai": {
|
||||
"label": "OpenAI",
|
||||
"env_var": "OPENAI_API_KEY",
|
||||
"default_model": "gpt-4o",
|
||||
"default_base_url": "https://api.openai.com/v1",
|
||||
"requires_base_url": False,
|
||||
"models": list(_PROVIDER_MODELS.get("openai", [])),
|
||||
"category": "easy_start",
|
||||
},
|
||||
# ── Open / self-hosted ─────────────────────────────────────────────
|
||||
"ollama": {
|
||||
"label": "Ollama",
|
||||
"env_var": "OLLAMA_API_KEY",
|
||||
"default_model": "qwen3:32b",
|
||||
"default_base_url": "http://localhost:11434/v1",
|
||||
"requires_base_url": True,
|
||||
"models": [],
|
||||
"category": "self_hosted",
|
||||
},
|
||||
"lmstudio": {
|
||||
"label": "LM Studio",
|
||||
"env_var": "LMSTUDIO_API_KEY",
|
||||
"default_model": "gpt-4o-mini",
|
||||
"default_base_url": "http://localhost:1234/v1",
|
||||
"requires_base_url": True,
|
||||
"models": [],
|
||||
"category": "self_hosted",
|
||||
},
|
||||
"custom": {
|
||||
"label": "Custom OpenAI-compatible",
|
||||
"env_var": "OPENAI_API_KEY",
|
||||
"default_model": "gpt-4o-mini",
|
||||
"requires_base_url": True,
|
||||
"models": [],
|
||||
"category": "self_hosted",
|
||||
},
|
||||
# ── Specialized / extended ──────────────────────────────────────────
|
||||
"gemini": {
|
||||
"label": "Google Gemini",
|
||||
"env_var": "GOOGLE_API_KEY",
|
||||
"default_model": "gemini-3.1-pro-preview",
|
||||
"default_base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
"requires_base_url": False,
|
||||
# _PROVIDER_MODELS in api/config.py is keyed under "google" even though
|
||||
# the agent's alias map normalizes "google" → "gemini". Use the catalog
|
||||
# key here so the wizard surfaces the actual model list.
|
||||
"models": list(_PROVIDER_MODELS.get("google", [])),
|
||||
"category": "specialized",
|
||||
},
|
||||
"deepseek": {
|
||||
"label": "DeepSeek",
|
||||
"env_var": "DEEPSEEK_API_KEY",
|
||||
"default_model": "deepseek-v4-flash",
|
||||
"default_base_url": "https://api.deepseek.com",
|
||||
"requires_base_url": False,
|
||||
"models": list(_PROVIDER_MODELS.get("deepseek", [])),
|
||||
"category": "specialized",
|
||||
},
|
||||
"zai": {
|
||||
"label": "Z.AI / GLM (智谱)",
|
||||
"env_var": "GLM_API_KEY",
|
||||
"default_model": "glm-5.1",
|
||||
"default_base_url": "https://open.bigmodel.cn/api/paas/v4",
|
||||
"requires_base_url": False,
|
||||
"models": list(_PROVIDER_MODELS.get("zai", [])),
|
||||
"category": "specialized",
|
||||
},
|
||||
"nvidia": {
|
||||
"label": "NVIDIA NIM",
|
||||
"env_var": "NVIDIA_API_KEY",
|
||||
"default_model": "nvidia/llama-3.3-nemotron-super-49b-v1.5",
|
||||
"default_base_url": "https://integrate.api.nvidia.com/v1",
|
||||
"requires_base_url": False,
|
||||
"models": list(_PROVIDER_MODELS.get("nvidia", [])),
|
||||
"category": "specialized",
|
||||
},
|
||||
"mistralai": {
|
||||
"label": "Mistral",
|
||||
"env_var": "MISTRAL_API_KEY",
|
||||
"default_model": "mistral-large-latest",
|
||||
"default_base_url": "https://api.mistral.ai/v1",
|
||||
"requires_base_url": False,
|
||||
# No catalog entry for mistralai today — wizard shows a free-text input.
|
||||
"models": list(_PROVIDER_MODELS.get("mistralai", [])),
|
||||
"category": "specialized",
|
||||
},
|
||||
"x-ai": {
|
||||
"label": "xAI (Grok)",
|
||||
"env_var": "XAI_API_KEY",
|
||||
"default_model": "grok-4.20",
|
||||
"default_base_url": "https://api.x.ai/v1",
|
||||
"requires_base_url": False,
|
||||
# Agent normalizes "x-ai" → "xai"; _PROVIDER_MODELS is also keyed "xai"
|
||||
# when populated, so check both keys for forward-compatibility.
|
||||
"models": list(_PROVIDER_MODELS.get("xai", []) or _PROVIDER_MODELS.get("x-ai", [])),
|
||||
"category": "specialized",
|
||||
},
|
||||
}
|
||||
|
||||
_PROVIDER_CATEGORIES = [
|
||||
{"id": "easy_start", "label": "Easy start", "order": 0},
|
||||
{"id": "self_hosted", "label": "Open / self-hosted", "order": 1},
|
||||
{"id": "specialized", "label": "Specialized", "order": 2},
|
||||
]
|
||||
|
||||
_UNSUPPORTED_PROVIDER_NOTE = (
|
||||
"OAuth and advanced provider flows such as Nous Portal, OpenAI Codex, and GitHub "
|
||||
"Copilot are still terminal-first. Use `hermes model` for those flows."
|
||||
)
|
||||
|
||||
|
||||
def _get_active_hermes_home() -> Path:
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
|
||||
return get_active_hermes_home()
|
||||
except ImportError:
|
||||
return Path.home() / ".hermes"
|
||||
|
||||
|
||||
def _load_env_file(env_path: Path) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
if not env_path.exists():
|
||||
return values
|
||||
try:
|
||||
for raw in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
values[key.strip()] = value.strip().strip('"').strip("'")
|
||||
except Exception:
|
||||
return {}
|
||||
return values
|
||||
|
||||
|
||||
|
||||
def _load_yaml_config(config_path: Path) -> dict:
|
||||
try:
|
||||
import yaml as _yaml
|
||||
except ImportError:
|
||||
return {}
|
||||
|
||||
if not config_path.exists():
|
||||
return {}
|
||||
try:
|
||||
loaded = _yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
return loaded if isinstance(loaded, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _save_yaml_config(config_path: Path, config: dict) -> None:
|
||||
try:
|
||||
import yaml as _yaml
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("PyYAML is required to write Hermes config.yaml") from exc
|
||||
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_path.write_text(
|
||||
_yaml.safe_dump(config, sort_keys=False, allow_unicode=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_model_for_provider(provider: str, model: str) -> str:
|
||||
clean = (model or "").strip()
|
||||
if not clean:
|
||||
return ""
|
||||
if provider in {"anthropic", "openai"} and clean.startswith(provider + "/"):
|
||||
return clean.split("/", 1)[1]
|
||||
return clean
|
||||
|
||||
|
||||
def _normalize_base_url(base_url: str) -> str:
|
||||
return (base_url or "").strip().rstrip("/")
|
||||
|
||||
|
||||
def _extract_current_provider(cfg: dict) -> str:
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, dict):
|
||||
provider = str(model_cfg.get("provider") or "").strip().lower()
|
||||
if provider:
|
||||
return provider
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_current_model(cfg: dict) -> str:
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, str):
|
||||
return model_cfg.strip()
|
||||
if isinstance(model_cfg, dict):
|
||||
return str(model_cfg.get("default") or "").strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_current_base_url(cfg: dict) -> str:
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, dict):
|
||||
return _normalize_base_url(str(model_cfg.get("base_url") or ""))
|
||||
return ""
|
||||
|
||||
|
||||
def _provider_api_key_present(
|
||||
provider: str, cfg: dict, env_values: dict[str, str]
|
||||
) -> bool:
|
||||
provider = (provider or "").strip().lower()
|
||||
if not provider:
|
||||
return False
|
||||
|
||||
env_var = _SUPPORTED_PROVIDER_SETUPS.get(provider, {}).get("env_var")
|
||||
if env_var and env_values.get(env_var):
|
||||
return True
|
||||
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, dict) and str(model_cfg.get("api_key") or "").strip():
|
||||
return True
|
||||
|
||||
providers_cfg = cfg.get("providers", {})
|
||||
if isinstance(providers_cfg, dict):
|
||||
provider_cfg = providers_cfg.get(provider, {})
|
||||
if (
|
||||
isinstance(provider_cfg, dict)
|
||||
and str(provider_cfg.get("api_key") or "").strip()
|
||||
):
|
||||
return True
|
||||
if provider == "custom":
|
||||
custom_cfg = providers_cfg.get("custom", {})
|
||||
if (
|
||||
isinstance(custom_cfg, dict)
|
||||
and str(custom_cfg.get("api_key") or "").strip()
|
||||
):
|
||||
return True
|
||||
|
||||
# For providers not in _SUPPORTED_PROVIDER_SETUPS (e.g. minimax-cn, deepseek,
|
||||
# xai, etc.), ask the hermes_cli auth registry — it knows every provider's env
|
||||
# var names and can check os.environ for a valid key.
|
||||
# Exclude known OAuth/token-flow providers — those are handled separately by
|
||||
# _provider_oauth_authenticated() and should not be short-circuited here.
|
||||
_known_oauth = {"openai-codex", "copilot", "copilot-acp", "qwen-oauth", "nous"}
|
||||
if provider not in _SUPPORTED_PROVIDER_SETUPS and provider not in _known_oauth:
|
||||
try:
|
||||
from hermes_cli.auth import get_auth_status as _gas
|
||||
status = _gas(provider)
|
||||
if isinstance(status, dict) and status.get("logged_in"):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
|
||||
def _oauth_payload_has_token(payload: dict) -> bool:
|
||||
"""Return True if an auth payload contains usable token material."""
|
||||
if not isinstance(payload, dict):
|
||||
return False
|
||||
|
||||
token_fields = (
|
||||
payload,
|
||||
payload.get("tokens") if isinstance(payload.get("tokens"), dict) else {},
|
||||
)
|
||||
for candidate in token_fields:
|
||||
if not isinstance(candidate, dict):
|
||||
continue
|
||||
if any(
|
||||
str(candidate.get(key) or "").strip()
|
||||
for key in ("access_token", "refresh_token", "api_key")
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
|
||||
def _provider_oauth_authenticated(provider: str, hermes_home: "Path") -> bool:
|
||||
"""Return True if the provider has valid OAuth credentials.
|
||||
|
||||
Reads the profile-scoped auth.json directly so onboarding respects the
|
||||
requested Hermes home. Known OAuth providers may store auth either in the
|
||||
legacy providers[provider_id] singleton state or in credential_pool entries
|
||||
used by current Hermes runtime auth resolution.
|
||||
"""
|
||||
provider = (provider or "").strip().lower()
|
||||
if not provider:
|
||||
return False
|
||||
|
||||
_known_oauth_providers = {"openai-codex", "copilot", "copilot-acp", "qwen-oauth", "nous"}
|
||||
if provider not in _known_oauth_providers:
|
||||
return False
|
||||
|
||||
try:
|
||||
import json as _j
|
||||
|
||||
auth_path = hermes_home / "auth.json"
|
||||
if not auth_path.exists():
|
||||
return False
|
||||
store = _j.loads(auth_path.read_text(encoding="utf-8"))
|
||||
|
||||
providers_store = store.get("providers")
|
||||
if isinstance(providers_store, dict):
|
||||
state = providers_store.get(provider)
|
||||
if _oauth_payload_has_token(state):
|
||||
return True
|
||||
|
||||
pool_store = store.get("credential_pool")
|
||||
if isinstance(pool_store, dict):
|
||||
entries = pool_store.get(provider)
|
||||
if isinstance(entries, list):
|
||||
return any(_oauth_payload_has_token(entry) for entry in entries)
|
||||
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _status_from_runtime(cfg: dict, imports_ok: bool) -> dict:
|
||||
provider = _extract_current_provider(cfg)
|
||||
model = _extract_current_model(cfg)
|
||||
base_url = _extract_current_base_url(cfg)
|
||||
env_values = _load_env_file(_get_active_hermes_home() / ".env")
|
||||
|
||||
provider_configured = bool(provider and model)
|
||||
provider_ready = False
|
||||
|
||||
if provider_configured:
|
||||
if provider == "custom":
|
||||
provider_ready = bool(
|
||||
base_url and _provider_api_key_present(provider, cfg, env_values)
|
||||
)
|
||||
elif provider in _SUPPORTED_PROVIDER_SETUPS:
|
||||
provider_ready = _provider_api_key_present(provider, cfg, env_values)
|
||||
else:
|
||||
# Unknown provider — may be an OAuth flow (openai-codex, copilot, etc.)
|
||||
# OR an API-key provider not in the quick-setup list (minimax-cn, deepseek,
|
||||
# xai, etc.). Check both: api key presence first (covers the majority of
|
||||
# third-party providers), then OAuth auth.json.
|
||||
provider_ready = (
|
||||
_provider_api_key_present(provider, cfg, env_values)
|
||||
or _provider_oauth_authenticated(provider, _get_active_hermes_home())
|
||||
)
|
||||
|
||||
chat_ready = bool(_HERMES_FOUND and imports_ok and provider_ready)
|
||||
|
||||
if not _HERMES_FOUND or not imports_ok:
|
||||
state = "agent_unavailable"
|
||||
note = (
|
||||
"Hermes is not fully importable from the Web UI yet. Finish bootstrap or fix the "
|
||||
"agent install before provider setup will work."
|
||||
)
|
||||
elif chat_ready:
|
||||
state = "ready"
|
||||
provider_name = _PROVIDER_DISPLAY.get(
|
||||
provider, provider.title() if provider else "Hermes"
|
||||
)
|
||||
note = f"Hermes is minimally configured and ready to chat via {provider_name}."
|
||||
elif provider_configured:
|
||||
state = "provider_incomplete"
|
||||
if provider == "custom" and not base_url:
|
||||
note = (
|
||||
"Hermes has a saved provider/model selection but still needs the "
|
||||
"base URL and API key required to chat."
|
||||
)
|
||||
elif provider not in _SUPPORTED_PROVIDER_SETUPS:
|
||||
# OAuth / unsupported provider: avoid misleading "API key" wording.
|
||||
note = (
|
||||
f"Provider '{provider}' is configured but not yet authenticated. "
|
||||
"Run 'hermes auth' or 'hermes model' in a terminal to complete "
|
||||
"setup, then reload the Web UI."
|
||||
)
|
||||
else:
|
||||
note = (
|
||||
"Hermes has a saved provider/model selection but still needs the "
|
||||
"API key required to chat."
|
||||
)
|
||||
else:
|
||||
state = "needs_provider"
|
||||
note = "Hermes is installed, but you still need to choose a provider and save working credentials."
|
||||
|
||||
return {
|
||||
"provider_configured": provider_configured,
|
||||
"provider_ready": provider_ready,
|
||||
"chat_ready": chat_ready,
|
||||
"setup_state": state,
|
||||
"provider_note": note,
|
||||
"current_provider": provider or None,
|
||||
"current_model": model or None,
|
||||
"current_base_url": base_url or None,
|
||||
"env_path": str(_get_active_hermes_home() / ".env"),
|
||||
}
|
||||
|
||||
|
||||
def _build_setup_catalog(cfg: dict) -> dict:
|
||||
current_provider = _extract_current_provider(cfg) or "openrouter"
|
||||
current_model = _extract_current_model(cfg)
|
||||
current_base_url = _extract_current_base_url(cfg)
|
||||
|
||||
providers = []
|
||||
for provider_id, meta in _SUPPORTED_PROVIDER_SETUPS.items():
|
||||
providers.append(
|
||||
{
|
||||
"id": provider_id,
|
||||
"label": meta["label"],
|
||||
"env_var": meta["env_var"],
|
||||
"default_model": meta["default_model"],
|
||||
"default_base_url": meta.get("default_base_url") or "",
|
||||
"requires_base_url": bool(meta.get("requires_base_url")),
|
||||
"models": list(meta.get("models", [])),
|
||||
"category": meta.get("category", "easy_start"),
|
||||
"quick": meta.get("quick", False),
|
||||
}
|
||||
)
|
||||
|
||||
# Sort providers by category order, then alphabetically within each category.
|
||||
cat_order = {c["id"]: c["order"] for c in _PROVIDER_CATEGORIES}
|
||||
providers.sort(key=lambda p: (cat_order.get(p["category"], 99), p["label"]))
|
||||
|
||||
# Group providers by category for the frontend.
|
||||
categories = []
|
||||
for cat in sorted(_PROVIDER_CATEGORIES, key=lambda c: c["order"]):
|
||||
categories.append({
|
||||
"id": cat["id"],
|
||||
"label": cat["label"],
|
||||
"providers": [p["id"] for p in providers if p["category"] == cat["id"]],
|
||||
})
|
||||
|
||||
# Flag whether the currently-configured provider is OAuth-based (not in the
|
||||
# API-key flow). The frontend uses this to show a confirmation card instead
|
||||
# of a key input when the user has already authenticated via 'hermes auth'.
|
||||
current_is_oauth = current_provider not in _SUPPORTED_PROVIDER_SETUPS and bool(
|
||||
current_provider
|
||||
)
|
||||
|
||||
return {
|
||||
"providers": providers,
|
||||
"categories": categories,
|
||||
"unsupported_note": _UNSUPPORTED_PROVIDER_NOTE,
|
||||
"current_is_oauth": current_is_oauth,
|
||||
"current": {
|
||||
"provider": current_provider,
|
||||
"model": current_model
|
||||
or _SUPPORTED_PROVIDER_SETUPS.get(current_provider, {}).get(
|
||||
"default_model", ""
|
||||
),
|
||||
"base_url": current_base_url,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_onboarding_status() -> dict:
|
||||
settings = load_settings()
|
||||
cfg = get_config()
|
||||
imports_ok, missing, errors = verify_hermes_imports()
|
||||
runtime = _status_from_runtime(cfg, imports_ok)
|
||||
workspaces = load_workspaces()
|
||||
last_workspace = get_last_workspace()
|
||||
available_models = get_available_models()
|
||||
|
||||
# HERMES_WEBUI_SKIP_ONBOARDING=1 lets hosting providers (e.g. Agent37) ship
|
||||
# a pre-configured instance without the wizard blocking the first load.
|
||||
# This is an operator-level override and is honoured unconditionally —
|
||||
# the operator knows their deployment is configured; we must not second-guess
|
||||
# it by requiring chat_ready to also be true.
|
||||
skip_env = os.environ.get("HERMES_WEBUI_SKIP_ONBOARDING", "").strip()
|
||||
skip_requested = skip_env in {"1", "true", "yes"}
|
||||
auto_completed = skip_requested # unconditional: operator says skip, we skip
|
||||
|
||||
# Auto-complete for existing Hermes users: if config.yaml already exists
|
||||
# AND the provider is configured (or the system is chat_ready), treat onboarding
|
||||
# as done. These users configured Hermes via the CLI before the Web UI existed;
|
||||
# they must never be shown the first-run wizard — it would silently overwrite their
|
||||
# config. We use provider_configured (not chat_ready) so that users with
|
||||
# non-wizard providers (ollama-cloud, deepseek, xai, kimi, etc.) are not forced
|
||||
# through the wizard just because their provider doesn't have a detectable API key
|
||||
# — the wizard cannot represent their provider and would overwrite their config
|
||||
# with whichever wizard-supported provider they accidentally select.
|
||||
config_exists = Path(_get_config_path()).exists()
|
||||
|
||||
# For providers not in the wizard's quick-setup list (e.g. ollama-cloud, deepseek,
|
||||
# xai, kimi-k2.6), the wizard can never help — it only knows how to configure
|
||||
# openrouter/anthropic/openai/google/custom. If such a user has a configured
|
||||
# provider + model in config.yaml, showing the wizard would only confuse them
|
||||
# (or worse, let them accidentally overwrite their config with gpt-5.4-mini).
|
||||
_current_provider = str(
|
||||
(cfg.get("model", {}) or {}).get("provider", "") if isinstance(cfg.get("model"), dict)
|
||||
else ""
|
||||
).strip().lower()
|
||||
_is_non_wizard_provider = bool(
|
||||
_current_provider and _current_provider not in _SUPPORTED_PROVIDER_SETUPS
|
||||
)
|
||||
|
||||
config_auto_completed = config_exists and (
|
||||
bool(runtime.get("chat_ready"))
|
||||
or (_is_non_wizard_provider and bool(runtime.get("provider_configured")))
|
||||
)
|
||||
|
||||
# Persist the flag so it survives future transient import failures (e.g. after
|
||||
# a git branch switch in the hermes-agent repo). Without this, a CLI-configured
|
||||
# user who never ran the wizard has no onboarding_completed flag — any momentary
|
||||
# imports_ok=False during restart makes chat_ready=False, config_auto_completed=False,
|
||||
# and the wizard reappears with a broken dropdown that clobbers their config.
|
||||
#
|
||||
# Best-effort: if save_settings raises (read-only FS, disk full, permission error),
|
||||
# log and continue. The `config_auto_completed` branch of `completed=` below still
|
||||
# returns True for this request, so the user sees the correct state — only the
|
||||
# persistence-across-restart guarantee is degraded. Raising here would turn every
|
||||
# /api/onboarding/status call into a 500 until disk was writable, which is worse UX
|
||||
# than losing the next-restart protection.
|
||||
if config_auto_completed and not settings.get("onboarding_completed"):
|
||||
try:
|
||||
save_settings({"onboarding_completed": True})
|
||||
settings["onboarding_completed"] = True
|
||||
except Exception:
|
||||
logger.debug("Failed to persist onboarding_completed", exc_info=True)
|
||||
|
||||
return {
|
||||
"completed": bool(settings.get("onboarding_completed")) or auto_completed or config_auto_completed,
|
||||
"settings": {
|
||||
"default_model": settings.get("default_model") or DEFAULT_MODEL,
|
||||
"default_workspace": settings.get("default_workspace")
|
||||
or str(DEFAULT_WORKSPACE),
|
||||
"password_enabled": is_auth_enabled(),
|
||||
"bot_name": settings.get("bot_name") or "Hermes",
|
||||
},
|
||||
"system": {
|
||||
"hermes_found": bool(_HERMES_FOUND),
|
||||
"imports_ok": bool(imports_ok),
|
||||
"missing_modules": missing,
|
||||
"import_errors": errors,
|
||||
"config_path": str(_get_config_path()),
|
||||
"config_exists": Path(_get_config_path()).exists(),
|
||||
**runtime,
|
||||
},
|
||||
"setup": _build_setup_catalog(cfg),
|
||||
"workspaces": {
|
||||
"items": workspaces,
|
||||
"last": last_workspace,
|
||||
},
|
||||
"models": available_models,
|
||||
}
|
||||
|
||||
|
||||
def apply_onboarding_setup(body: dict) -> dict:
|
||||
# Hard guard: if the operator set SKIP_ONBOARDING, the wizard should never
|
||||
# have appeared. Even if the frontend somehow calls this endpoint anyway
|
||||
# (e.g. a stale JS bundle or a curious user), we must not overwrite the
|
||||
# operator's config.yaml or .env files. Just mark onboarding complete and
|
||||
# return the current status — no file writes.
|
||||
skip_env = os.environ.get("HERMES_WEBUI_SKIP_ONBOARDING", "").strip()
|
||||
if skip_env in {"1", "true", "yes"}:
|
||||
save_settings({"onboarding_completed": True})
|
||||
return get_onboarding_status()
|
||||
|
||||
provider = str(body.get("provider") or "").strip().lower()
|
||||
model = str(body.get("model") or "").strip()
|
||||
api_key = str(body.get("api_key") or "").strip()
|
||||
base_url = _normalize_base_url(str(body.get("base_url") or ""))
|
||||
|
||||
if provider not in _SUPPORTED_PROVIDER_SETUPS:
|
||||
# Unsupported providers (openai-codex, copilot, nous, etc.) are already
|
||||
# configured via the CLI. Just mark onboarding as complete and let the
|
||||
# user through — the agent is already set up, no further setup needed.
|
||||
save_settings({"onboarding_completed": True})
|
||||
return get_onboarding_status()
|
||||
if not model:
|
||||
raise ValueError("model is required")
|
||||
|
||||
provider_meta = _SUPPORTED_PROVIDER_SETUPS[provider]
|
||||
if provider_meta.get("requires_base_url"):
|
||||
if not base_url:
|
||||
raise ValueError("base_url is required for custom endpoints")
|
||||
parsed = urlparse(base_url)
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
raise ValueError("base_url must start with http:// or https://")
|
||||
|
||||
config_path = _get_config_path()
|
||||
# Guard: if config.yaml already exists and the caller did not explicitly
|
||||
# acknowledge the overwrite, refuse to proceed. The frontend must pass
|
||||
# confirm_overwrite=True after showing the user a confirmation step.
|
||||
if Path(config_path).exists() and not body.get("confirm_overwrite"):
|
||||
return {
|
||||
"error": "config_exists",
|
||||
"message": (
|
||||
"Hermes is already configured (config.yaml exists). "
|
||||
"Pass confirm_overwrite=true to overwrite it."
|
||||
),
|
||||
"requires_confirm": True,
|
||||
}
|
||||
|
||||
cfg = _load_yaml_config(config_path)
|
||||
env_path = _get_active_hermes_home() / ".env"
|
||||
env_values = _load_env_file(env_path)
|
||||
|
||||
if not api_key and not _provider_api_key_present(provider, cfg, env_values):
|
||||
raise ValueError(f"{provider_meta['env_var']} is required")
|
||||
|
||||
model_cfg = cfg.get("model", {})
|
||||
if not isinstance(model_cfg, dict):
|
||||
model_cfg = {}
|
||||
|
||||
model_cfg["provider"] = provider
|
||||
model_cfg["default"] = _normalize_model_for_provider(provider, model)
|
||||
|
||||
if provider_meta.get("requires_base_url"):
|
||||
model_cfg["base_url"] = base_url
|
||||
elif provider_meta.get("default_base_url"):
|
||||
model_cfg["base_url"] = provider_meta["default_base_url"]
|
||||
else:
|
||||
model_cfg.pop("base_url", None)
|
||||
|
||||
cfg["model"] = model_cfg
|
||||
_save_yaml_config(config_path, cfg)
|
||||
|
||||
if api_key:
|
||||
_write_env_file(env_path, {provider_meta["env_var"]: api_key})
|
||||
|
||||
# Reload the hermes_cli provider/config cache so the next streaming call
|
||||
# picks up the new key without requiring a server restart.
|
||||
try:
|
||||
from api.profiles import _reload_dotenv
|
||||
_reload_dotenv(_get_active_hermes_home())
|
||||
except Exception:
|
||||
logger.debug("Failed to reload dotenv")
|
||||
|
||||
# Belt-and-braces: set directly on os.environ AFTER _reload_dotenv so the
|
||||
# value survives even if _reload_dotenv cleared it (e.g. when _write_env_file
|
||||
# wrote to disk but the profile isolation tracking hasn't seen it yet).
|
||||
if api_key:
|
||||
os.environ[provider_meta["env_var"]] = api_key
|
||||
|
||||
try:
|
||||
# hermes_cli may cache config at import time; ask it to reload if possible.
|
||||
from hermes_cli.config import reload as _cli_reload
|
||||
_cli_reload()
|
||||
except Exception:
|
||||
logger.debug("Failed to reload hermes_cli config")
|
||||
|
||||
reload_config()
|
||||
return get_onboarding_status()
|
||||
|
||||
|
||||
def complete_onboarding() -> dict:
|
||||
save_settings({"onboarding_completed": True})
|
||||
return get_onboarding_status()
|
||||
359
api/profiles.py
359
api/profiles.py
@@ -9,12 +9,15 @@ cached paths in hermes-agent modules (skills_tool, cron/jobs) that snapshot
|
||||
HERMES_HOME at import time.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Constants (match hermes_cli.profiles upstream) ─────────────────────────
|
||||
_PROFILE_ID_RE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,63}$')
|
||||
_PROFILE_DIRS = [
|
||||
@@ -26,6 +29,13 @@ _CLONE_CONFIG_FILES = ['config.yaml', '.env', 'SOUL.md']
|
||||
# ── Module state ────────────────────────────────────────────────────────────
|
||||
_active_profile = 'default'
|
||||
_profile_lock = threading.Lock()
|
||||
_loaded_profile_env_keys: set[str] = set()
|
||||
|
||||
# Thread-local profile context: set per-request by server.py, cleared after.
|
||||
# Enables per-client profile isolation (issue #798) — each HTTP request thread
|
||||
# reads its own profile from the hermes_profile cookie instead of the
|
||||
# process-global _active_profile.
|
||||
_tls = threading.local()
|
||||
|
||||
def _resolve_base_hermes_home() -> Path:
|
||||
"""Return the BASE ~/.hermes directory — the root that contains profiles/.
|
||||
@@ -71,31 +81,164 @@ def _read_active_profile_file() -> str:
|
||||
ap_file = _DEFAULT_HERMES_HOME / 'active_profile'
|
||||
if ap_file.exists():
|
||||
try:
|
||||
name = ap_file.read_text().strip()
|
||||
name = ap_file.read_text(encoding="utf-8").strip()
|
||||
if name:
|
||||
return name
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("Failed to read active profile file")
|
||||
return 'default'
|
||||
|
||||
|
||||
# ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
def get_active_profile_name() -> str:
|
||||
"""Return the currently active profile name."""
|
||||
"""Return the currently active profile name.
|
||||
|
||||
Priority:
|
||||
1. Thread-local (set per-request from hermes_profile cookie) — issue #798
|
||||
2. Process-level default (_active_profile)
|
||||
"""
|
||||
tls_name = getattr(_tls, 'profile', None)
|
||||
if tls_name is not None:
|
||||
return tls_name
|
||||
return _active_profile
|
||||
|
||||
|
||||
def set_request_profile(name: str) -> None:
|
||||
"""Set the per-request profile context for this thread.
|
||||
|
||||
Called by server.py at the start of each request when a hermes_profile
|
||||
cookie is present. Always paired with clear_request_profile() in a
|
||||
finally block so the thread-local is released after the request.
|
||||
"""
|
||||
_tls.profile = name
|
||||
|
||||
|
||||
def clear_request_profile() -> None:
|
||||
"""Clear the per-request profile context for this thread.
|
||||
|
||||
Called by server.py in the finally block of do_GET / do_POST.
|
||||
Safe to call even if set_request_profile() was never called.
|
||||
"""
|
||||
_tls.profile = None
|
||||
|
||||
|
||||
def get_active_hermes_home() -> Path:
|
||||
"""Return the HERMES_HOME path for the currently active profile."""
|
||||
if _active_profile == 'default':
|
||||
"""Return the HERMES_HOME path for the currently active profile.
|
||||
|
||||
Uses get_active_profile_name() so per-request TLS context (issue #798)
|
||||
is respected, not just the process-level global.
|
||||
"""
|
||||
name = get_active_profile_name()
|
||||
if name == 'default':
|
||||
return _DEFAULT_HERMES_HOME
|
||||
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / _active_profile
|
||||
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name
|
||||
if profile_dir.is_dir():
|
||||
return profile_dir
|
||||
return _DEFAULT_HERMES_HOME
|
||||
|
||||
|
||||
|
||||
def get_hermes_home_for_profile(name: str) -> Path:
|
||||
"""Return the HERMES_HOME Path for *name* without mutating any process state.
|
||||
|
||||
Safe to call from per-request context (streaming, session creation) because
|
||||
it reads only the filesystem — it never touches os.environ, module-level
|
||||
cached paths, or the process-level _active_profile global.
|
||||
|
||||
Falls back to _DEFAULT_HERMES_HOME (same as 'default') when *name* is None,
|
||||
empty, 'default', or does not match the profile-name format (rejects path
|
||||
traversal such as '../../etc').
|
||||
"""
|
||||
if not name or name == 'default' or not _PROFILE_ID_RE.fullmatch(name):
|
||||
return _DEFAULT_HERMES_HOME
|
||||
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name
|
||||
return profile_dir
|
||||
|
||||
|
||||
_TERMINAL_ENV_MAPPINGS = {
|
||||
'backend': 'TERMINAL_ENV',
|
||||
'env_type': 'TERMINAL_ENV',
|
||||
'cwd': 'TERMINAL_CWD',
|
||||
'timeout': 'TERMINAL_TIMEOUT',
|
||||
'lifetime_seconds': 'TERMINAL_LIFETIME_SECONDS',
|
||||
'modal_mode': 'TERMINAL_MODAL_MODE',
|
||||
'docker_image': 'TERMINAL_DOCKER_IMAGE',
|
||||
'docker_forward_env': 'TERMINAL_DOCKER_FORWARD_ENV',
|
||||
'docker_env': 'TERMINAL_DOCKER_ENV',
|
||||
'docker_mount_cwd_to_workspace': 'TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE',
|
||||
'singularity_image': 'TERMINAL_SINGULARITY_IMAGE',
|
||||
'modal_image': 'TERMINAL_MODAL_IMAGE',
|
||||
'daytona_image': 'TERMINAL_DAYTONA_IMAGE',
|
||||
'container_cpu': 'TERMINAL_CONTAINER_CPU',
|
||||
'container_memory': 'TERMINAL_CONTAINER_MEMORY',
|
||||
'container_disk': 'TERMINAL_CONTAINER_DISK',
|
||||
'container_persistent': 'TERMINAL_CONTAINER_PERSISTENT',
|
||||
'docker_volumes': 'TERMINAL_DOCKER_VOLUMES',
|
||||
'persistent_shell': 'TERMINAL_PERSISTENT_SHELL',
|
||||
'ssh_host': 'TERMINAL_SSH_HOST',
|
||||
'ssh_user': 'TERMINAL_SSH_USER',
|
||||
'ssh_port': 'TERMINAL_SSH_PORT',
|
||||
'ssh_key': 'TERMINAL_SSH_KEY',
|
||||
'ssh_persistent': 'TERMINAL_SSH_PERSISTENT',
|
||||
'local_persistent': 'TERMINAL_LOCAL_PERSISTENT',
|
||||
}
|
||||
|
||||
|
||||
def _stringify_env_value(value) -> str:
|
||||
if isinstance(value, bool):
|
||||
return 'true' if value else 'false'
|
||||
if isinstance(value, (list, dict)):
|
||||
return json.dumps(value)
|
||||
return str(value)
|
||||
|
||||
|
||||
def get_profile_runtime_env(home: Path) -> dict[str, str]:
|
||||
"""Return env vars needed to run an agent turn for a profile home.
|
||||
|
||||
WebUI profile switching is per-client/cookie scoped, so it intentionally
|
||||
does not call ``switch_profile(..., process_wide=True)`` for every browser.
|
||||
Agent/tool code still consumes terminal backend settings through
|
||||
environment variables (matching ``hermes -p <profile>``), so streaming must
|
||||
apply the selected profile's terminal config and ``.env`` for the duration
|
||||
of that run.
|
||||
"""
|
||||
home = Path(home).expanduser()
|
||||
env: dict[str, str] = {}
|
||||
|
||||
try:
|
||||
import yaml as _yaml
|
||||
|
||||
cfg_path = home / 'config.yaml'
|
||||
cfg = _yaml.safe_load(cfg_path.read_text(encoding='utf-8')) if cfg_path.exists() else {}
|
||||
if not isinstance(cfg, dict):
|
||||
cfg = {}
|
||||
except Exception:
|
||||
cfg = {}
|
||||
|
||||
terminal_cfg = cfg.get('terminal', {}) if isinstance(cfg, dict) else {}
|
||||
if isinstance(terminal_cfg, dict):
|
||||
for key, env_key in _TERMINAL_ENV_MAPPINGS.items():
|
||||
if key in terminal_cfg and terminal_cfg[key] is not None:
|
||||
env[env_key] = _stringify_env_value(terminal_cfg[key])
|
||||
|
||||
env_path = home / '.env'
|
||||
if env_path.exists():
|
||||
try:
|
||||
for line in env_path.read_text(encoding='utf-8').splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#') and '=' in line:
|
||||
k, v = line.split('=', 1)
|
||||
k = k.strip()
|
||||
v = v.strip().strip('"').strip("'")
|
||||
if k and v:
|
||||
env[k] = v
|
||||
except Exception:
|
||||
logger.debug("Failed to read runtime env from %s", env_path)
|
||||
|
||||
return env
|
||||
|
||||
|
||||
def _set_hermes_home(home: Path):
|
||||
"""Set HERMES_HOME env var and monkey-patch cached module-level paths."""
|
||||
os.environ['HERMES_HOME'] = str(home)
|
||||
@@ -106,7 +249,7 @@ def _set_hermes_home(home: Path):
|
||||
_sk.HERMES_HOME = home
|
||||
_sk.SKILLS_DIR = home / 'skills'
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
logger.debug("Failed to patch skills_tool module")
|
||||
|
||||
# Patch cron/jobs module-level cache
|
||||
try:
|
||||
@@ -116,16 +259,29 @@ def _set_hermes_home(home: Path):
|
||||
_cj.JOBS_FILE = _cj.CRON_DIR / 'jobs.json'
|
||||
_cj.OUTPUT_DIR = _cj.CRON_DIR / 'output'
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
logger.debug("Failed to patch cron.jobs module")
|
||||
|
||||
|
||||
def _reload_dotenv(home: Path):
|
||||
"""Load .env from the profile dir into os.environ (additive)."""
|
||||
"""Load .env from the profile dir into os.environ with profile isolation.
|
||||
|
||||
Clears env vars that were loaded from the previously active profile before
|
||||
applying the current profile's .env. This prevents API keys and other
|
||||
profile-scoped secrets from leaking across profile switches.
|
||||
"""
|
||||
global _loaded_profile_env_keys
|
||||
|
||||
# Remove keys loaded from the previous profile first.
|
||||
for key in list(_loaded_profile_env_keys):
|
||||
os.environ.pop(key, None)
|
||||
_loaded_profile_env_keys = set()
|
||||
|
||||
env_path = home / '.env'
|
||||
if not env_path.exists():
|
||||
return
|
||||
try:
|
||||
for line in env_path.read_text().splitlines():
|
||||
loaded_keys: set[str] = set()
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#') and '=' in line:
|
||||
k, v = line.split('=', 1)
|
||||
@@ -133,11 +289,14 @@ def _reload_dotenv(home: Path):
|
||||
v = v.strip().strip('"').strip("'")
|
||||
if k and v:
|
||||
os.environ[k] = v
|
||||
loaded_keys.add(k)
|
||||
_loaded_profile_env_keys = loaded_keys
|
||||
except Exception:
|
||||
pass
|
||||
_loaded_profile_env_keys = set()
|
||||
logger.debug("Failed to reload dotenv from %s", env_path)
|
||||
|
||||
|
||||
def init_profile_state():
|
||||
def init_profile_state() -> None:
|
||||
"""Initialize profile state at server startup.
|
||||
|
||||
Reads ~/.hermes/active_profile, sets HERMES_HOME env var, patches
|
||||
@@ -150,12 +309,18 @@ def init_profile_state():
|
||||
_reload_dotenv(home)
|
||||
|
||||
|
||||
def switch_profile(name: str) -> dict:
|
||||
def switch_profile(name: str, *, process_wide: bool = True) -> dict:
|
||||
"""Switch the active profile.
|
||||
|
||||
Validates the profile exists, updates process state, patches module caches,
|
||||
reloads .env, and reloads config.yaml.
|
||||
|
||||
Args:
|
||||
name: Profile name to switch to.
|
||||
process_wide: If True (default), updates the process-global
|
||||
_active_profile. Set to False for per-client switches from the
|
||||
WebUI where the profile is managed via cookie + thread-local (#798).
|
||||
|
||||
Returns: {'profiles': [...], 'active': name}
|
||||
Raises ValueError if profile doesn't exist or agent is busy.
|
||||
"""
|
||||
@@ -176,29 +341,45 @@ def switch_profile(name: str) -> dict:
|
||||
if name == 'default':
|
||||
home = _DEFAULT_HERMES_HOME
|
||||
else:
|
||||
home = _DEFAULT_HERMES_HOME / 'profiles' / name
|
||||
home = _resolve_named_profile_home(name)
|
||||
if not home.is_dir():
|
||||
raise ValueError(f"Profile '{name}' does not exist.")
|
||||
|
||||
with _profile_lock:
|
||||
_active_profile = name
|
||||
_set_hermes_home(home)
|
||||
_reload_dotenv(home)
|
||||
if process_wide:
|
||||
global _active_profile
|
||||
_active_profile = name
|
||||
_set_hermes_home(home)
|
||||
_reload_dotenv(home)
|
||||
|
||||
# Write sticky default for CLI consistency
|
||||
try:
|
||||
ap_file = _DEFAULT_HERMES_HOME / 'active_profile'
|
||||
ap_file.write_text(name if name != 'default' else '')
|
||||
except Exception:
|
||||
pass
|
||||
if process_wide:
|
||||
# Write sticky default for CLI consistency
|
||||
try:
|
||||
ap_file = _DEFAULT_HERMES_HOME / 'active_profile'
|
||||
ap_file.write_text(name if name != 'default' else '', encoding='utf-8')
|
||||
except Exception:
|
||||
logger.debug("Failed to write active profile file")
|
||||
|
||||
# Reload config.yaml from the new profile
|
||||
reload_config()
|
||||
# Reload config.yaml from the new profile
|
||||
reload_config()
|
||||
|
||||
# Return profile-specific defaults so frontend can apply them
|
||||
from api.workspace import get_last_workspace
|
||||
from api.config import get_config
|
||||
cfg = get_config()
|
||||
# Return profile-specific defaults so frontend can apply them.
|
||||
# For process_wide=False (per-client switch), read the target profile's
|
||||
# config.yaml directly from disk rather than from _cfg_cache (process-global),
|
||||
# since reload_config() was intentionally skipped.
|
||||
if process_wide:
|
||||
from api.config import get_config
|
||||
cfg = get_config()
|
||||
else:
|
||||
# Direct disk read — does not touch _cfg_cache
|
||||
try:
|
||||
import yaml as _yaml
|
||||
cfg_path = home / 'config.yaml'
|
||||
cfg = _yaml.safe_load(cfg_path.read_text(encoding='utf-8')) if cfg_path.exists() else {}
|
||||
if not isinstance(cfg, dict):
|
||||
cfg = {}
|
||||
except Exception:
|
||||
cfg = {}
|
||||
model_cfg = cfg.get('model', {})
|
||||
default_model = None
|
||||
if isinstance(model_cfg, str):
|
||||
@@ -206,11 +387,57 @@ def switch_profile(name: str) -> dict:
|
||||
elif isinstance(model_cfg, dict):
|
||||
default_model = model_cfg.get('default')
|
||||
|
||||
# Read the target profile's workspace directly from *home* rather than via
|
||||
# get_last_workspace() which routes through the thread-local/process-global active
|
||||
# profile — both of which still point to the OLD profile during process_wide=False
|
||||
# switches (the Set-Cookie has been sent but hasn't been processed by a new request
|
||||
# yet). We derive workspace in priority order:
|
||||
# 1. {home}/webui_state/last_workspace.txt (previously chosen workspace for this profile)
|
||||
# 2. cfg terminal.cwd / workspace / default_workspace keys
|
||||
# 3. Boot-time DEFAULT_WORKSPACE constant
|
||||
# Use the module-level ``Path`` (imported at line 17) rather than re-importing
|
||||
# it locally — keeps the exception fallback simple and avoids a latent NameError
|
||||
# if a future refactor moves the inner imports.
|
||||
default_workspace = None
|
||||
try:
|
||||
from api.config import DEFAULT_WORKSPACE as _DW
|
||||
lw_file = home / 'webui_state' / 'last_workspace.txt'
|
||||
if lw_file.exists():
|
||||
_p = lw_file.read_text(encoding='utf-8').strip()
|
||||
if _p:
|
||||
_pp = Path(_p).expanduser()
|
||||
if _pp.is_dir():
|
||||
default_workspace = str(_pp.resolve())
|
||||
if default_workspace is None:
|
||||
for _key in ('workspace', 'default_workspace'):
|
||||
_v = cfg.get(_key)
|
||||
if _v:
|
||||
_pp = Path(str(_v)).expanduser().resolve()
|
||||
if _pp.is_dir():
|
||||
default_workspace = str(_pp)
|
||||
break
|
||||
if default_workspace is None:
|
||||
_tc = cfg.get('terminal', {})
|
||||
if isinstance(_tc, dict):
|
||||
_cwd = _tc.get('cwd', '')
|
||||
if _cwd and str(_cwd) not in ('.', ''):
|
||||
_pp = Path(str(_cwd)).expanduser().resolve()
|
||||
if _pp.is_dir():
|
||||
default_workspace = str(_pp)
|
||||
if default_workspace is None:
|
||||
default_workspace = str(_DW)
|
||||
except Exception:
|
||||
try:
|
||||
from api.config import DEFAULT_WORKSPACE as _DW2
|
||||
default_workspace = str(_DW2)
|
||||
except Exception:
|
||||
default_workspace = str(Path.home())
|
||||
|
||||
return {
|
||||
'profiles': list_profiles_api(),
|
||||
'active': name,
|
||||
'default_model': default_model,
|
||||
'default_workspace': get_last_workspace(),
|
||||
'default_workspace': default_workspace,
|
||||
}
|
||||
|
||||
|
||||
@@ -223,7 +450,7 @@ def list_profiles_api() -> list:
|
||||
# hermes_cli not available -- return just the default
|
||||
return [_default_profile_dict()]
|
||||
|
||||
active = _active_profile
|
||||
active = get_active_profile_name()
|
||||
result = []
|
||||
for p in infos:
|
||||
result.append({
|
||||
@@ -267,6 +494,24 @@ def _validate_profile_name(name: str):
|
||||
)
|
||||
|
||||
|
||||
def _profiles_root() -> Path:
|
||||
"""Return the canonical root that contains named profiles."""
|
||||
return (_DEFAULT_HERMES_HOME / 'profiles').resolve()
|
||||
|
||||
|
||||
def _resolve_named_profile_home(name: str) -> Path:
|
||||
"""Resolve a named profile to a directory under the profiles root.
|
||||
|
||||
Validates *name* as a logical profile identifier first, then resolves the
|
||||
final filesystem path and enforces containment under ~/.hermes/profiles.
|
||||
"""
|
||||
_validate_profile_name(name)
|
||||
profiles_root = _profiles_root()
|
||||
candidate = (profiles_root / name).resolve()
|
||||
candidate.relative_to(profiles_root)
|
||||
return candidate
|
||||
|
||||
|
||||
def _create_profile_fallback(name: str, clone_from: str = None,
|
||||
clone_config: bool = False) -> Path:
|
||||
"""Create a profile directory without hermes_cli (Docker/standalone fallback)."""
|
||||
@@ -294,8 +539,38 @@ def _create_profile_fallback(name: str, clone_from: str = None,
|
||||
return profile_dir
|
||||
|
||||
|
||||
def _write_endpoint_to_config(profile_dir: Path, base_url: str = None, api_key: str = None) -> None:
|
||||
"""Write custom endpoint fields into config.yaml for a profile."""
|
||||
if not base_url and not api_key:
|
||||
return
|
||||
config_path = profile_dir / 'config.yaml'
|
||||
try:
|
||||
import yaml as _yaml
|
||||
except ImportError:
|
||||
return
|
||||
cfg = {}
|
||||
if config_path.exists():
|
||||
try:
|
||||
loaded = _yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
if isinstance(loaded, dict):
|
||||
cfg = loaded
|
||||
except Exception:
|
||||
logger.debug("Failed to load config from %s", config_path)
|
||||
model_section = cfg.get('model', {})
|
||||
if not isinstance(model_section, dict):
|
||||
model_section = {}
|
||||
if base_url:
|
||||
model_section['base_url'] = base_url
|
||||
if api_key:
|
||||
model_section['api_key'] = api_key
|
||||
cfg['model'] = model_section
|
||||
config_path.write_text(_yaml.dump(cfg, default_flow_style=False, allow_unicode=True), encoding='utf-8')
|
||||
|
||||
|
||||
def create_profile_api(name: str, clone_from: str = None,
|
||||
clone_config: bool = False) -> dict:
|
||||
clone_config: bool = False,
|
||||
base_url: str = None,
|
||||
api_key: str = None) -> dict:
|
||||
"""Create a new profile. Returns the new profile info dict."""
|
||||
_validate_profile_name(name)
|
||||
# Defense-in-depth: validate clone_from here too, even though routes.py
|
||||
@@ -315,11 +590,26 @@ def create_profile_api(name: str, clone_from: str = None,
|
||||
except ImportError:
|
||||
_create_profile_fallback(name, clone_from, clone_config)
|
||||
|
||||
# Resolve the profile directory from the profile list when possible.
|
||||
# hermes_cli and the webui runtime do not always agree on the exact root,
|
||||
# so we prefer the path returned by list_profiles_api() and fall back to the
|
||||
# standard profile location only if the profile cannot be found there yet.
|
||||
profile_path = _DEFAULT_HERMES_HOME / 'profiles' / name
|
||||
for p in list_profiles_api():
|
||||
if p['name'] == name:
|
||||
try:
|
||||
profile_path = Path(p.get('path') or profile_path)
|
||||
except Exception:
|
||||
logger.debug("Failed to parse profile path")
|
||||
break
|
||||
|
||||
profile_path.mkdir(parents=True, exist_ok=True)
|
||||
_write_endpoint_to_config(profile_path, base_url=base_url, api_key=api_key)
|
||||
|
||||
# Find and return the newly created profile info.
|
||||
# When hermes_cli is not importable, list_profiles_api() also falls back
|
||||
# to the stub default-only list and won't find the new profile by name.
|
||||
# In that case, return a complete profile dict directly.
|
||||
profile_path = _DEFAULT_HERMES_HOME / 'profiles' / name
|
||||
for p in list_profiles_api():
|
||||
if p['name'] == name:
|
||||
return p
|
||||
@@ -340,6 +630,7 @@ def delete_profile_api(name: str) -> dict:
|
||||
"""Delete a profile. Switches to default first if it's the active one."""
|
||||
if name == 'default':
|
||||
raise ValueError("Cannot delete the default profile.")
|
||||
_validate_profile_name(name)
|
||||
|
||||
# If deleting the active profile, switch to default first
|
||||
if _active_profile == name:
|
||||
@@ -357,7 +648,7 @@ def delete_profile_api(name: str) -> dict:
|
||||
except ImportError:
|
||||
# Manual fallback: just remove the directory
|
||||
import shutil
|
||||
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name
|
||||
profile_dir = _resolve_named_profile_home(name)
|
||||
if profile_dir.is_dir():
|
||||
shutil.rmtree(str(profile_dir))
|
||||
else:
|
||||
|
||||
560
api/providers.py
Normal file
560
api/providers.py
Normal file
@@ -0,0 +1,560 @@
|
||||
"""Hermes Web UI -- provider management endpoints.
|
||||
|
||||
Provides CRUD operations for configuring provider API keys post-onboarding.
|
||||
Closes #586 (allow provider key update) and part of #604 (model picker
|
||||
multi-provider support).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from api.config import (
|
||||
_PROVIDER_DISPLAY,
|
||||
_PROVIDER_MODELS,
|
||||
_get_config_path,
|
||||
_save_yaml_config_file,
|
||||
get_config,
|
||||
invalidate_models_cache,
|
||||
reload_config,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# SECTION: Provider ↔ env var mapping
|
||||
|
||||
# Maps canonical provider slug → env var name for API key.
|
||||
# Providers not listed here (OAuth/token-flow providers like copilot, nous,
|
||||
# openai-codex) cannot have their keys managed from the WebUI.
|
||||
_PROVIDER_ENV_VAR: dict[str, str] = {
|
||||
"openrouter": "OPENROUTER_API_KEY",
|
||||
"anthropic": "ANTHROPIC_API_KEY",
|
||||
"openai": "OPENAI_API_KEY",
|
||||
"google": "GOOGLE_API_KEY",
|
||||
"gemini": "GEMINI_API_KEY",
|
||||
"zai": "GLM_API_KEY",
|
||||
"kimi-coding": "KIMI_API_KEY",
|
||||
"deepseek": "DEEPSEEK_API_KEY",
|
||||
"minimax": "MINIMAX_API_KEY",
|
||||
"minimax-cn": "MINIMAX_CN_API_KEY",
|
||||
"mistralai": "MISTRAL_API_KEY",
|
||||
"x-ai": "XAI_API_KEY",
|
||||
"opencode-zen": "OPENCODE_ZEN_API_KEY",
|
||||
"opencode-go": "OPENCODE_GO_API_KEY",
|
||||
# NOTE: bare "ollama" (local) deliberately omitted — local Ollama is keyless
|
||||
# by default and the runtime in hermes_cli/runtime_provider.py only consumes
|
||||
# OLLAMA_API_KEY when the base URL hostname is ollama.com (Ollama Cloud).
|
||||
# If we mapped both providers to the same env var, configuring Ollama Cloud
|
||||
# would falsely flip the local Ollama card to "API key configured" (#1410).
|
||||
# Users who genuinely run an authenticated local Ollama can still set a key
|
||||
# via providers.ollama.api_key in config.yaml — that path remains supported
|
||||
# by _provider_has_key().
|
||||
"ollama-cloud": "OLLAMA_API_KEY",
|
||||
"nvidia": "NVIDIA_API_KEY",
|
||||
}
|
||||
|
||||
# Providers that use OAuth or token flows — their credentials are managed
|
||||
# through the Hermes CLI, not via API keys. The WebUI cannot set these.
|
||||
_OAUTH_PROVIDERS = frozenset({
|
||||
"copilot",
|
||||
"copilot-acp",
|
||||
"nous",
|
||||
"openai-codex",
|
||||
"qwen-oauth",
|
||||
})
|
||||
|
||||
# SECTION: Helper functions
|
||||
|
||||
|
||||
def _get_hermes_home() -> Path:
|
||||
"""Return the active Hermes home directory."""
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
return get_active_hermes_home()
|
||||
except ImportError:
|
||||
return Path.home() / ".hermes"
|
||||
|
||||
|
||||
def _load_env_file(env_path: Path) -> dict[str, str]:
|
||||
"""Read key=value pairs from a .env file."""
|
||||
values: dict[str, str] = {}
|
||||
if not env_path.exists():
|
||||
return values
|
||||
try:
|
||||
for raw in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
values[key.strip()] = value.strip().strip('"').strip("'")
|
||||
except Exception:
|
||||
return {}
|
||||
return values
|
||||
|
||||
|
||||
def _write_env_file(env_path: Path, updates: dict[str, str | None]) -> None:
|
||||
"""Write key=value pairs to the .env file.
|
||||
|
||||
Values of ``None`` cause the key to be removed.
|
||||
|
||||
Preserves comments, blank lines, and original key order (#1164).
|
||||
New keys are appended at the end of the file with a blank-line separator.
|
||||
|
||||
Holds ``_ENV_LOCK`` from ``api.streaming`` for the entire load → modify →
|
||||
write cycle to prevent TOCTOU races between concurrent POST /api/providers
|
||||
calls (each reading the same file baseline and overwriting the other's key).
|
||||
Also serialises os.environ mutations with streaming sessions.
|
||||
"""
|
||||
from api.streaming import _ENV_LOCK
|
||||
import stat as _stat
|
||||
|
||||
with _ENV_LOCK:
|
||||
# ── Read existing lines (preserving comments and blank lines) ──
|
||||
existing_lines: list[str] = []
|
||||
if env_path.exists():
|
||||
try:
|
||||
existing_lines = env_path.read_text(encoding="utf-8").splitlines()
|
||||
except Exception:
|
||||
existing_lines = []
|
||||
|
||||
# Map each existing key to its line index so we can update in-place.
|
||||
existing_key_indices: dict[str, int] = {}
|
||||
for _i, _raw in enumerate(existing_lines):
|
||||
_stripped = _raw.strip()
|
||||
if _stripped and not _stripped.startswith("#") and "=" in _stripped:
|
||||
_existing_key_indices_key = _stripped.split("=", 1)[0].strip()
|
||||
existing_key_indices[_existing_key_indices_key] = _i
|
||||
|
||||
output_lines = list(existing_lines)
|
||||
new_keys: list[str] = []
|
||||
|
||||
for key, value in updates.items():
|
||||
if value is None:
|
||||
# Mark the line for removal (None sentinel) and clear env.
|
||||
os.environ.pop(key, None)
|
||||
if key in existing_key_indices:
|
||||
output_lines[existing_key_indices[key]] = None # type: ignore[assignment]
|
||||
continue
|
||||
clean = str(value).strip()
|
||||
if not clean:
|
||||
continue
|
||||
# Reject embedded newlines/carriage returns to prevent .env injection
|
||||
if "\n" in clean or "\r" in clean:
|
||||
raise ValueError("API key must not contain newline characters.")
|
||||
os.environ[key] = clean
|
||||
|
||||
if key in existing_key_indices:
|
||||
output_lines[existing_key_indices[key]] = f"{key}={clean}"
|
||||
else:
|
||||
new_keys.append(f"{key}={clean}")
|
||||
|
||||
# Remove deleted lines (None sentinels)
|
||||
output_lines = [l for l in output_lines if l is not None]
|
||||
|
||||
# Append new keys after a blank-line separator
|
||||
if new_keys:
|
||||
if output_lines and output_lines[-1].strip() != "":
|
||||
output_lines.append("")
|
||||
output_lines.extend(new_keys)
|
||||
|
||||
env_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = "\n".join(output_lines)
|
||||
if content:
|
||||
content += "\n"
|
||||
# Atomic write via tempfile + os.replace so cross-process readers
|
||||
# (Telegram bot, CLI) never see a half-truncated file. The shared
|
||||
# ``~/.hermes/.env`` is also written by ``hermes_cli.config.save_env_value``
|
||||
# using the same atomic pattern; matching it here closes the
|
||||
# cross-process leg of #1164 (within-process is covered by _ENV_LOCK).
|
||||
_mode = _stat.S_IRUSR | _stat.S_IWUSR # 0o600
|
||||
import tempfile as _tempfile
|
||||
_tmp_fd, _tmp_path = _tempfile.mkstemp(
|
||||
dir=str(env_path.parent), prefix=".env_", suffix=".tmp"
|
||||
)
|
||||
try:
|
||||
with os.fdopen(_tmp_fd, "w", encoding="utf-8") as _f:
|
||||
_f.write(content)
|
||||
_f.flush()
|
||||
os.fsync(_f.fileno())
|
||||
os.chmod(_tmp_path, _mode) # tighten before rename so readers see 0600
|
||||
os.replace(_tmp_path, env_path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(_tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
try:
|
||||
env_path.chmod(_mode)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _provider_has_key(provider_id: str) -> bool:
|
||||
"""Check whether a provider has a configured API key.
|
||||
|
||||
Checks (in order):
|
||||
1. ``~/.hermes/.env`` for the known env var
|
||||
2. ``os.environ`` for the known env var
|
||||
3. ``config.yaml → model.api_key`` (only if provider is the active one)
|
||||
4. ``config.yaml → providers.<id>.api_key``
|
||||
5. ``config.yaml → custom_providers[].api_key`` (for custom providers)
|
||||
"""
|
||||
env_var = _PROVIDER_ENV_VAR.get(provider_id)
|
||||
if env_var:
|
||||
env_path = _get_hermes_home() / ".env"
|
||||
env_values = _load_env_file(env_path)
|
||||
if env_values.get(env_var):
|
||||
return True
|
||||
if os.getenv(env_var):
|
||||
return True
|
||||
|
||||
cfg = get_config()
|
||||
# Check model.api_key — only match if this provider is the active one.
|
||||
# Previously this checked globally, causing all providers to show
|
||||
# "configured" when the active provider had a top-level api_key.
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, dict) and str(model_cfg.get("api_key") or "").strip():
|
||||
active_provider = model_cfg.get("provider")
|
||||
if active_provider and str(active_provider).strip().lower() == provider_id.lower():
|
||||
return True
|
||||
# Check providers.<id>.api_key
|
||||
providers_cfg = cfg.get("providers", {})
|
||||
if isinstance(providers_cfg, dict):
|
||||
provider_cfg = providers_cfg.get(provider_id, {})
|
||||
if isinstance(provider_cfg, dict) and str(provider_cfg.get("api_key") or "").strip():
|
||||
return True
|
||||
# Check custom_providers
|
||||
custom_providers = cfg.get("custom_providers", [])
|
||||
if isinstance(custom_providers, list):
|
||||
for cp in custom_providers:
|
||||
if isinstance(cp, dict):
|
||||
cp_name = (cp.get("name") or "").strip().lower().replace(" ", "-")
|
||||
if f"custom:{cp_name}" == provider_id or cp.get("name", "").strip().lower() == provider_id:
|
||||
if str(cp.get("api_key") or "").strip():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _provider_is_oauth(provider_id: str) -> bool:
|
||||
"""Check whether a provider uses OAuth/token flows (managed by CLI)."""
|
||||
return provider_id in _OAUTH_PROVIDERS
|
||||
|
||||
|
||||
# SECTION: Public API
|
||||
|
||||
|
||||
def get_providers() -> dict[str, Any]:
|
||||
"""Return a list of all known providers with their configuration status.
|
||||
|
||||
Each entry contains:
|
||||
- ``id``: canonical provider slug
|
||||
- ``display_name``: human-readable name
|
||||
- ``has_key``: whether an API key is configured
|
||||
- ``configurable``: whether the key can be set from the WebUI
|
||||
- ``key_source``: where the key was found (``env_file``, ``env_var``,
|
||||
``config_yaml``, ``oauth``, ``none``)
|
||||
- ``models``: list of known model IDs for this provider
|
||||
"""
|
||||
providers = []
|
||||
|
||||
# Collect all known provider IDs from multiple sources
|
||||
known_ids = set(_PROVIDER_DISPLAY.keys()) | set(_PROVIDER_MODELS.keys())
|
||||
|
||||
# Also detect providers from config.yaml providers section
|
||||
cfg = get_config()
|
||||
providers_cfg = cfg.get("providers", {})
|
||||
if isinstance(providers_cfg, dict):
|
||||
known_ids.update(providers_cfg.keys())
|
||||
|
||||
# Add OAuth providers even if not in _PROVIDER_DISPLAY
|
||||
known_ids.update(_OAUTH_PROVIDERS)
|
||||
|
||||
for pid in sorted(known_ids):
|
||||
display_name = _PROVIDER_DISPLAY.get(pid, pid.replace("-", " ").title())
|
||||
is_oauth = _provider_is_oauth(pid)
|
||||
has_key = _provider_has_key(pid)
|
||||
|
||||
# Determine key source
|
||||
key_source = "none"
|
||||
auth_error = None
|
||||
if is_oauth:
|
||||
key_source = "oauth"
|
||||
# Check if actually authenticated via hermes_cli.
|
||||
# IMPORTANT: do not unconditionally overwrite has_key from _provider_has_key().
|
||||
# A token in config.yaml is a valid credential even when get_auth_status()
|
||||
# returns logged_in=False (e.g. token not in the hermes credential pool,
|
||||
# or refresh token consumed by native Codex CLI / VS Code extension).
|
||||
try:
|
||||
from hermes_cli.auth import get_auth_status as _gas
|
||||
status = _gas(pid)
|
||||
if isinstance(status, dict) and status.get("logged_in"):
|
||||
has_key = True
|
||||
key_source = status.get("key_source", "oauth")
|
||||
elif has_key:
|
||||
# _provider_has_key() found a token in config.yaml — respect it
|
||||
# rather than hiding a working credential from the Settings UI.
|
||||
key_source = "config_yaml"
|
||||
auth_error = status.get("error") if isinstance(status, dict) else None
|
||||
else:
|
||||
has_key = False
|
||||
auth_error = status.get("error") if isinstance(status, dict) else None
|
||||
except Exception:
|
||||
# Import failed or auth check errored — don't override a known-good
|
||||
# key just because the hermes_cli auth module is unavailable.
|
||||
logger.debug("hermes_cli auth check failed for %s", pid, exc_info=True)
|
||||
# keep has_key from _provider_has_key()
|
||||
elif has_key:
|
||||
env_var = _PROVIDER_ENV_VAR.get(pid)
|
||||
if env_var:
|
||||
env_path = _get_hermes_home() / ".env"
|
||||
env_values = _load_env_file(env_path)
|
||||
if env_values.get(env_var):
|
||||
key_source = "env_file"
|
||||
elif os.getenv(env_var):
|
||||
key_source = "env_var"
|
||||
else:
|
||||
key_source = "config_yaml"
|
||||
else:
|
||||
key_source = "config_yaml"
|
||||
elif pid not in _PROVIDER_ENV_VAR:
|
||||
# Fallback: provider is not a known API-key provider and not in
|
||||
# the hardcoded _OAUTH_PROVIDERS set. It may be a custom or
|
||||
# newly-added OAuth provider (e.g. Anthropic connected via OAuth).
|
||||
# Check live auth status so the Providers tab agrees with the
|
||||
# model picker (#1212).
|
||||
#
|
||||
# IMPORTANT: we skip providers in _PROVIDER_ENV_VAR because they
|
||||
# are pure API-key providers — calling get_auth_status() for every
|
||||
# unconfigured API-key provider would add unnecessary latency
|
||||
# (network round-trip per provider) on the Settings page.
|
||||
# Validate pid looks like a real provider before probing
|
||||
import re as _re
|
||||
if _re.match(r'^[a-z][a-z0-9_-]{0,63}$', pid):
|
||||
try:
|
||||
from hermes_cli.auth import get_auth_status as _gas
|
||||
status = _gas(pid)
|
||||
if isinstance(status, dict) and status.get("logged_in"):
|
||||
has_key = True
|
||||
# Constrain key_source to a known-safe closed set
|
||||
_raw_ks = status.get("key_source", "")
|
||||
key_source = _raw_ks if _raw_ks in {"oauth", "env", "config", "token"} else "oauth"
|
||||
is_oauth = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
models = _PROVIDER_MODELS.get(pid, [])
|
||||
# Also include models from config.yaml providers section
|
||||
if isinstance(providers_cfg, dict):
|
||||
provider_cfg = providers_cfg.get(pid, {})
|
||||
if isinstance(provider_cfg, dict) and "models" in provider_cfg:
|
||||
cfg_models = provider_cfg["models"]
|
||||
if isinstance(cfg_models, dict):
|
||||
models = models + [{"id": k, "label": k} for k in cfg_models.keys()]
|
||||
elif isinstance(cfg_models, list):
|
||||
models = models + [{"id": k, "label": k} for k in cfg_models]
|
||||
|
||||
providers.append({
|
||||
"id": pid,
|
||||
"display_name": display_name,
|
||||
"has_key": has_key,
|
||||
"configurable": not is_oauth and pid in _PROVIDER_ENV_VAR,
|
||||
"is_oauth": is_oauth,
|
||||
"key_source": key_source,
|
||||
"auth_error": auth_error,
|
||||
"models": models,
|
||||
})
|
||||
|
||||
# Scan custom_providers from config.yaml (e.g. glmcode, timicc)
|
||||
custom_providers_cfg = cfg.get("custom_providers", [])
|
||||
if isinstance(custom_providers_cfg, list):
|
||||
for cp in custom_providers_cfg:
|
||||
if not isinstance(cp, dict) or not cp.get("name"):
|
||||
continue
|
||||
cp_name = str(cp["name"]).strip()
|
||||
cp_id = f"custom:{cp_name}"
|
||||
# Collect models from `models` list or `model` single
|
||||
cp_models = []
|
||||
if isinstance(cp.get("models"), list):
|
||||
cp_models = [{"id": str(m), "label": str(m)} for m in cp["models"]]
|
||||
elif cp.get("model"):
|
||||
cp_models = [{"id": cp["model"], "label": cp["model"]}]
|
||||
# Check for env var reference (${VAR_NAME} pattern)
|
||||
cp_api_key = str(cp.get("api_key") or "")
|
||||
cp_has_key = bool(cp_api_key.strip())
|
||||
# Replace env var reference to check actual value
|
||||
if cp_api_key.startswith("${") and cp_api_key.endswith("}"):
|
||||
env_var = cp_api_key[2:-1]
|
||||
cp_has_key = bool(os.getenv(env_var, "").strip())
|
||||
providers.append({
|
||||
"id": cp_id,
|
||||
"display_name": cp_name,
|
||||
"has_key": cp_has_key,
|
||||
"configurable": False, # custom providers managed via config.yaml
|
||||
"key_source": "config_yaml" if cp_has_key else "none",
|
||||
"models": cp_models,
|
||||
})
|
||||
|
||||
# Determine active provider
|
||||
active_provider = None
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, dict):
|
||||
active_provider = model_cfg.get("provider")
|
||||
|
||||
return {
|
||||
"providers": providers,
|
||||
"active_provider": active_provider,
|
||||
}
|
||||
|
||||
|
||||
def set_provider_key(provider_id: str, api_key: str | None) -> dict[str, Any]:
|
||||
"""Set or update the API key for a provider.
|
||||
|
||||
Writes the key to ``~/.hermes/.env`` using the standard env var name.
|
||||
If ``api_key`` is None or empty, the key is removed.
|
||||
|
||||
Returns a status dict with the operation result.
|
||||
"""
|
||||
provider_id = provider_id.strip().lower()
|
||||
|
||||
if not provider_id:
|
||||
return {"ok": False, "error": "Provider ID is required."}
|
||||
|
||||
if _provider_is_oauth(provider_id):
|
||||
return {
|
||||
"ok": False,
|
||||
"error": f"'{_PROVIDER_DISPLAY.get(provider_id, provider_id)}' uses OAuth authentication. "
|
||||
f"Use `hermes model` in the terminal to configure it.",
|
||||
}
|
||||
|
||||
env_var = _PROVIDER_ENV_VAR.get(provider_id)
|
||||
if not env_var:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": f"Cannot configure API key for '{_PROVIDER_DISPLAY.get(provider_id, provider_id)}'. "
|
||||
f"This provider does not have a known env var mapping.",
|
||||
}
|
||||
|
||||
# Validate API key format (basic sanity check)
|
||||
if api_key:
|
||||
api_key = api_key.strip()
|
||||
if "\n" in api_key or "\r" in api_key:
|
||||
return {"ok": False, "error": "API key must not contain newline characters."}
|
||||
if len(api_key) < 8:
|
||||
return {"ok": False, "error": "API key appears too short."}
|
||||
|
||||
env_path = _get_hermes_home() / ".env"
|
||||
try:
|
||||
_write_env_file(env_path, {env_var: api_key})
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to write env file for provider %s", provider_id)
|
||||
return {"ok": False, "error": f"Failed to save API key: {exc}"}
|
||||
|
||||
# Invalidate the model cache so the dropdown refreshes on next request.
|
||||
# Using invalidate_models_cache() instead of reload_config() to avoid
|
||||
# disrupting active streaming sessions that may be reading config.cfg.
|
||||
invalidate_models_cache()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"provider": provider_id,
|
||||
"display_name": _PROVIDER_DISPLAY.get(provider_id, provider_id),
|
||||
"action": "updated" if api_key else "removed",
|
||||
}
|
||||
|
||||
|
||||
def remove_provider_key(provider_id: str) -> dict[str, Any]:
|
||||
"""Remove the API key for a provider.
|
||||
|
||||
Removes the key from ``~/.hermes/.env`` (via ``set_provider_key``)
|
||||
and also cleans up ``config.yaml`` if the key is stored there
|
||||
(``providers.<id>.api_key`` or top-level ``model.api_key`` when this
|
||||
provider is the active one).
|
||||
|
||||
Returns a status dict with the operation result.
|
||||
"""
|
||||
result = set_provider_key(provider_id, None)
|
||||
|
||||
# Even if the .env removal succeeded, the key might also live in
|
||||
# config.yaml (e.g. providers.<id>.api_key or model.api_key).
|
||||
# Clean those up so _provider_has_key() returns False after removal.
|
||||
if result.get("ok"):
|
||||
_clean_provider_key_from_config(provider_id)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _clean_provider_key_from_config(provider_id: str) -> None:
|
||||
"""Remove provider API key entries from config.yaml.
|
||||
|
||||
Handles three storage locations:
|
||||
1. ``providers.<id>.api_key`` — per-provider key
|
||||
2. ``model.api_key`` — top-level key (only if provider is active)
|
||||
3. ``custom_providers[].api_key`` — custom provider entries
|
||||
|
||||
Writes back to config.yaml only if something was actually removed.
|
||||
Uses ``_cfg_lock`` to prevent TOCTOU races.
|
||||
"""
|
||||
from api.config import _cfg_lock
|
||||
|
||||
try:
|
||||
config_path = _get_config_path()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if not config_path.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
import yaml as _yaml
|
||||
|
||||
changed = False
|
||||
|
||||
with _cfg_lock:
|
||||
raw = config_path.read_text(encoding="utf-8")
|
||||
cfg = _yaml.safe_load(raw)
|
||||
if not isinstance(cfg, dict):
|
||||
return
|
||||
|
||||
# 1. Clean providers.<id>.api_key
|
||||
providers_cfg = cfg.get("providers", {})
|
||||
if isinstance(providers_cfg, dict):
|
||||
provider_cfg = providers_cfg.get(provider_id, {})
|
||||
if isinstance(provider_cfg, dict) and provider_cfg.get("api_key"):
|
||||
del provider_cfg["api_key"]
|
||||
changed = True
|
||||
|
||||
# 2. Clean model.api_key — only if this provider is the active one
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, dict) and model_cfg.get("api_key"):
|
||||
active_provider = model_cfg.get("provider")
|
||||
if active_provider and str(active_provider).strip().lower() == provider_id.lower():
|
||||
del model_cfg["api_key"]
|
||||
changed = True
|
||||
|
||||
# 3. Clean custom_providers[].api_key
|
||||
custom_providers = cfg.get("custom_providers", [])
|
||||
if isinstance(custom_providers, list):
|
||||
for cp in custom_providers:
|
||||
if isinstance(cp, dict):
|
||||
cp_name = (cp.get("name") or "").strip().lower().replace(" ", "-")
|
||||
if f"custom:{cp_name}" == provider_id or cp.get("name", "").strip().lower() == provider_id:
|
||||
if cp.get("api_key"):
|
||||
del cp["api_key"]
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
_save_yaml_config_file(config_path, cfg)
|
||||
# Sync in-memory cache and bust model TTL cache
|
||||
# MUST be called outside _cfg_lock to avoid deadlock:
|
||||
# _cfg_lock is a threading.Lock (non-reentrant) and
|
||||
# reload_config() also acquires _cfg_lock internally.
|
||||
if changed:
|
||||
reload_config()
|
||||
except Exception:
|
||||
logger.exception("Failed to clean provider key from config.yaml for %s", provider_id)
|
||||
320
api/rollback.py
Normal file
320
api/rollback.py
Normal file
@@ -0,0 +1,320 @@
|
||||
"""
|
||||
Hermes Web UI -- Filesystem checkpoint (rollback) API.
|
||||
|
||||
Provides endpoints to list, diff, and restore filesystem checkpoints
|
||||
created by the Hermes agent's CheckpointManager. Checkpoints live at
|
||||
``{hermes_home}/checkpoints/<hash>/`` as shadow git repositories.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Checkpoint identifiers are SHA-style hex hashes from the agent's
|
||||
# CheckpointManager. We only allow [A-Za-z0-9_.-]{1,64} (no '/' so the
|
||||
# value cannot be a path separator, no leading '.' so it cannot escape
|
||||
# upward via '..'/'.'). This is defense-in-depth: the workspace arg is
|
||||
# already allowlisted, but ``Path() / "../escape"`` does not normalize,
|
||||
# so without this guard a `checkpoint` value of `../<other-ws-hash>/<sha>`
|
||||
# would let any authenticated caller diff or restore from another
|
||||
# allowlisted workspace's checkpoint store. (Opus pre-release advisor.)
|
||||
_CHECKPOINT_ID_RE = re.compile(r"^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}$")
|
||||
|
||||
|
||||
def _validate_checkpoint_id(checkpoint: str) -> str:
|
||||
cid = str(checkpoint or "").strip()
|
||||
if not cid or cid in (".", "..") or not _CHECKPOINT_ID_RE.fullmatch(cid):
|
||||
raise ValueError(
|
||||
"checkpoint id must match [A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}"
|
||||
)
|
||||
return cid
|
||||
|
||||
|
||||
def _hermes_home() -> Path:
|
||||
"""Return the active Hermes home directory."""
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
return Path(get_active_hermes_home())
|
||||
except Exception:
|
||||
return Path(os.environ.get("HERMES_HOME", "~/.hermes")).expanduser()
|
||||
|
||||
|
||||
def _workspace_hash(workspace: str) -> str:
|
||||
"""Derive the checkpoint directory name from a workspace path.
|
||||
|
||||
Matches the agent's CheckpointManager._get_checkpoint_dir logic:
|
||||
SHA-256 of the canonical workspace path.
|
||||
"""
|
||||
try:
|
||||
canonical = os.path.realpath(workspace)
|
||||
except (OSError, ValueError):
|
||||
canonical = workspace
|
||||
return hashlib.sha256(canonical.encode()).hexdigest()[:12]
|
||||
|
||||
|
||||
def _checkpoint_root() -> Path:
|
||||
return _hermes_home() / "checkpoints"
|
||||
|
||||
|
||||
def _resolve_workspace(workspace: str) -> str:
|
||||
"""Validate and return the canonical workspace path.
|
||||
|
||||
Security: workspace must match a known configured workspace
|
||||
(from workspaces.json or session-attached workspaces).
|
||||
"""
|
||||
if not workspace or not isinstance(workspace, str):
|
||||
raise ValueError("workspace is required")
|
||||
# Basic path validation
|
||||
resolved = os.path.realpath(workspace)
|
||||
if not os.path.isdir(resolved):
|
||||
raise ValueError(f"Workspace does not exist: {workspace}")
|
||||
# Security: confirm workspace is in the known list
|
||||
try:
|
||||
from api.workspace import load_workspaces
|
||||
known_paths = set()
|
||||
for ws in load_workspaces():
|
||||
p = ws.get("path", "")
|
||||
if p:
|
||||
known_paths.add(os.path.realpath(p))
|
||||
if resolved not in known_paths:
|
||||
raise ValueError(f"Workspace not in configured list: {workspace}")
|
||||
except ImportError:
|
||||
logger.warning("Could not load workspace list for rollback validation")
|
||||
return resolved
|
||||
|
||||
|
||||
def _find_git() -> str:
|
||||
"""Return the path to the git binary."""
|
||||
return shutil.which("git") or "git"
|
||||
|
||||
|
||||
# ── Public API functions (called from routes.py) ────────────────────────────
|
||||
|
||||
|
||||
def list_checkpoints(workspace: str) -> dict[str, Any]:
|
||||
"""List all checkpoints for a workspace.
|
||||
|
||||
Returns a dict with:
|
||||
checkpoints: list of checkpoint objects
|
||||
workspace: resolved workspace path
|
||||
checkpoint_dir: the checkpoint directory path
|
||||
"""
|
||||
resolved = _resolve_workspace(workspace)
|
||||
ws_hash = _workspace_hash(resolved)
|
||||
ckpt_dir = _checkpoint_root() / ws_hash
|
||||
|
||||
checkpoints = []
|
||||
if not ckpt_dir.is_dir():
|
||||
return {"checkpoints": [], "workspace": resolved, "checkpoint_dir": str(ckpt_dir)}
|
||||
|
||||
# Each checkpoint is a git repo in <ckpt_dir>/<commit_hash>/
|
||||
git = _find_git()
|
||||
for entry in sorted(ckpt_dir.iterdir(), key=lambda p: p.stat().st_mtime if p.is_dir() else 0, reverse=True):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
ckpt_info = _inspect_checkpoint(entry, git)
|
||||
if ckpt_info:
|
||||
checkpoints.append(ckpt_info)
|
||||
|
||||
return {
|
||||
"checkpoints": checkpoints,
|
||||
"workspace": resolved,
|
||||
"checkpoint_dir": str(ckpt_dir),
|
||||
}
|
||||
|
||||
|
||||
def _inspect_checkpoint(ckpt_path: Path, git: str) -> dict[str, Any] | None:
|
||||
"""Extract metadata from a single checkpoint directory."""
|
||||
git_dir = ckpt_path / ".git"
|
||||
if not git_dir.is_dir():
|
||||
return None
|
||||
|
||||
name = ckpt_path.name
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[git, "-C", str(ckpt_path), "log", "--format=%H%n%s%n%aI", "-1"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.returncode != 0 or not result.stdout.strip():
|
||||
return None
|
||||
|
||||
lines = result.stdout.strip().split("\n")
|
||||
commit_hash = lines[0] if len(lines) > 0 else name
|
||||
message = lines[1] if len(lines) > 1 else "checkpoint"
|
||||
date_str = lines[2] if len(lines) > 2 else ""
|
||||
|
||||
# Parse date for display
|
||||
date_display = ""
|
||||
if date_str:
|
||||
try:
|
||||
dt = datetime.fromisoformat(date_str)
|
||||
date_display = dt.strftime("%Y-%m-%d %H:%M")
|
||||
except (ValueError, TypeError):
|
||||
date_display = date_str
|
||||
|
||||
# Count files
|
||||
files_result = subprocess.run(
|
||||
[git, "-C", str(ckpt_path), "ls-files"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
file_count = len(files_result.stdout.strip().split("\n")) if files_result.stdout.strip() else 0
|
||||
|
||||
return {
|
||||
"id": name,
|
||||
"commit": commit_hash[:12],
|
||||
"message": message,
|
||||
"date": date_str,
|
||||
"date_display": date_display,
|
||||
"files": file_count,
|
||||
"path": str(ckpt_path),
|
||||
}
|
||||
except (subprocess.TimeoutExpired, OSError) as e:
|
||||
logger.debug("Failed to inspect checkpoint %s: %s", ckpt_path, e)
|
||||
return None
|
||||
|
||||
|
||||
def get_checkpoint_diff(workspace: str, checkpoint: str) -> dict[str, Any]:
|
||||
"""Show the diff between a checkpoint and the current workspace state.
|
||||
|
||||
Returns a dict with:
|
||||
diff: unified diff text
|
||||
files_changed: list of changed file paths
|
||||
"""
|
||||
resolved = _resolve_workspace(workspace)
|
||||
checkpoint = _validate_checkpoint_id(checkpoint)
|
||||
ws_hash = _workspace_hash(resolved)
|
||||
ckpt_dir = _checkpoint_root() / ws_hash / checkpoint
|
||||
|
||||
if not ckpt_dir.is_dir():
|
||||
raise ValueError(f"Checkpoint not found: {checkpoint}")
|
||||
|
||||
git = _find_git()
|
||||
|
||||
# Get list of files in the checkpoint
|
||||
ls_result = subprocess.run(
|
||||
[git, "-C", str(ckpt_dir), "ls-files"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if ls_result.returncode != 0:
|
||||
raise ValueError("Failed to list checkpoint files")
|
||||
|
||||
ckpt_files = [f for f in ls_result.stdout.strip().split("\n") if f]
|
||||
files_changed = []
|
||||
diff_lines = []
|
||||
|
||||
for rel_path in ckpt_files:
|
||||
ckpt_file = ckpt_dir / rel_path
|
||||
ws_file = Path(resolved) / rel_path
|
||||
|
||||
if not ckpt_file.is_file():
|
||||
continue
|
||||
|
||||
# Read checkpoint version
|
||||
try:
|
||||
ckpt_content = ckpt_file.read_text(errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# Read workspace version (if exists)
|
||||
if ws_file.is_file():
|
||||
try:
|
||||
ws_content = ws_file.read_text(errors="replace")
|
||||
except OSError:
|
||||
ws_content = ""
|
||||
else:
|
||||
ws_content = None # File was deleted in workspace
|
||||
|
||||
if ws_content is None:
|
||||
# File exists in checkpoint but not in workspace (deleted)
|
||||
files_changed.append({"file": rel_path, "status": "deleted"})
|
||||
diff_lines.append(f"--- a/{rel_path}")
|
||||
diff_lines.append(f"+++ /dev/null")
|
||||
diff_lines.append("@@ -1,{lines} +0,0 @@".format(lines=len(ckpt_content.splitlines())))
|
||||
for line in ckpt_content.splitlines():
|
||||
diff_lines.append(f"-{line}")
|
||||
elif ckpt_content != ws_content:
|
||||
# File changed
|
||||
import difflib
|
||||
ckpt_lines = ckpt_content.splitlines(keepends=True)
|
||||
ws_lines = ws_content.splitlines(keepends=True)
|
||||
diff = list(difflib.unified_diff(ckpt_lines, ws_lines, fromfile=f"a/{rel_path}", tofile=f"b/{rel_path}", lineterm=""))
|
||||
if diff:
|
||||
files_changed.append({"file": rel_path, "status": "modified"})
|
||||
diff_lines.extend(diff)
|
||||
|
||||
# Check for new files in workspace that aren't in checkpoint
|
||||
# (skip for performance — diff is primarily for seeing what the checkpoint captures)
|
||||
|
||||
return {
|
||||
"checkpoint": checkpoint,
|
||||
"workspace": resolved,
|
||||
"diff": "\n".join(diff_lines) if diff_lines else "",
|
||||
"files_changed": files_changed,
|
||||
"total_changes": len(files_changed),
|
||||
}
|
||||
|
||||
|
||||
def restore_checkpoint(workspace: str, checkpoint: str) -> dict[str, Any]:
|
||||
"""Restore a checkpoint by copying files back to the workspace.
|
||||
|
||||
Only restores files that exist in the checkpoint. Does NOT delete
|
||||
files that were added after the checkpoint was created.
|
||||
|
||||
Returns a dict with:
|
||||
ok: True
|
||||
files_restored: list of restored file paths
|
||||
"""
|
||||
resolved = _resolve_workspace(workspace)
|
||||
checkpoint = _validate_checkpoint_id(checkpoint)
|
||||
ws_hash = _workspace_hash(resolved)
|
||||
ckpt_dir = _checkpoint_root() / ws_hash / checkpoint
|
||||
|
||||
if not ckpt_dir.is_dir():
|
||||
raise ValueError(f"Checkpoint not found: {checkpoint}")
|
||||
|
||||
git = _find_git()
|
||||
|
||||
# Get list of files in the checkpoint
|
||||
ls_result = subprocess.run(
|
||||
[git, "-C", str(ckpt_dir), "ls-files"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if ls_result.returncode != 0:
|
||||
raise ValueError("Failed to list checkpoint files")
|
||||
|
||||
ckpt_files = [f for f in ls_result.stdout.strip().split("\n") if f]
|
||||
restored = []
|
||||
errors = []
|
||||
|
||||
for rel_path in ckpt_files:
|
||||
ckpt_file = ckpt_dir / rel_path
|
||||
ws_file = Path(resolved) / rel_path
|
||||
|
||||
if not ckpt_file.is_file():
|
||||
continue
|
||||
|
||||
try:
|
||||
ws_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(str(ckpt_file), str(ws_file))
|
||||
restored.append(rel_path)
|
||||
except OSError as e:
|
||||
errors.append({"file": rel_path, "error": str(e)})
|
||||
logger.warning("Failed to restore %s: %s", rel_path, e)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"checkpoint": checkpoint,
|
||||
"workspace": resolved,
|
||||
"files_restored": restored,
|
||||
"files_restored_count": len(restored),
|
||||
"errors": errors,
|
||||
}
|
||||
5133
api/routes.py
5133
api/routes.py
File diff suppressed because it is too large
Load Diff
195
api/session_ops.py
Normal file
195
api/session_ops.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""Session-mutation operations for slash commands (/retry, /undo) and
|
||||
read-only aggregators (/status, /usage). Operates on the webui's own
|
||||
JSON Session store (api/models.py), not on hermes-agent's SQLite.
|
||||
|
||||
Behavior parity reference: gateway/run.py:_handle_*_command in
|
||||
the hermes-agent repo.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from api.config import LOCK, _get_session_agent_lock
|
||||
from api.models import get_session, SESSIONS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _truncate_at_last_user(messages):
|
||||
history = messages or []
|
||||
last_user_idx = None
|
||||
for i in range(len(history) - 1, -1, -1):
|
||||
if isinstance(history[i], dict) and history[i].get('role') == 'user':
|
||||
last_user_idx = i
|
||||
break
|
||||
if last_user_idx is None:
|
||||
return None
|
||||
return history[:last_user_idx]
|
||||
|
||||
|
||||
def retry_last(session_id: str) -> dict[str, Any]:
|
||||
"""Truncate the session to before the last user message, return its text.
|
||||
|
||||
Mirrors gateway/run.py:_handle_retry_command. Caller (webui frontend)
|
||||
is expected to put the returned text back in the composer and call
|
||||
send() to resume the conversation -- the agent's gateway calls its own
|
||||
_handle_message; the webui has no equivalent in-process pipeline.
|
||||
|
||||
Raises:
|
||||
KeyError: session not found
|
||||
ValueError: no user message in transcript
|
||||
"""
|
||||
# Acquire the per-session agent lock as the outermost lock so that the
|
||||
# read-modify-write of s.messages is serialised with the periodic
|
||||
# checkpoint thread, cancel_stream, and all other session writers.
|
||||
# Lock ordering: _agent_lock → LOCK → _write_session_index (LOCK).
|
||||
with _get_session_agent_lock(session_id):
|
||||
# get_session() and Session.save() both acquire the module-level LOCK
|
||||
# internally (the latter via _write_session_index()), and LOCK is a
|
||||
# non-reentrant threading.Lock — so they MUST be called outside our
|
||||
# own `with LOCK:` block to avoid self-deadlocking.
|
||||
#
|
||||
# The race we close is the read-modify-write of s.messages: two
|
||||
# concurrent /api/session/retry calls could otherwise both compute the
|
||||
# same last_user_idx from the same history and double-truncate. We
|
||||
# serialize just the in-memory mutation; persistence happens inside
|
||||
# the per-session lock so the checkpoint thread cannot race us.
|
||||
#
|
||||
# Stale-object guard: on a cache miss, two concurrent get_session()
|
||||
# calls can each load and cache a *different* Session instance for the
|
||||
# same session_id (the second store clobbers the first). Re-bind to
|
||||
# the canonical cached instance inside the lock so the mutation lands
|
||||
# on the object the next reader will see, not a stale parallel copy.
|
||||
s = get_session(session_id) # raises KeyError if missing
|
||||
with LOCK:
|
||||
s = SESSIONS.get(session_id, s)
|
||||
history = s.messages or []
|
||||
last_user_idx = None
|
||||
for i in range(len(history) - 1, -1, -1):
|
||||
if history[i].get('role') == 'user':
|
||||
last_user_idx = i
|
||||
break
|
||||
if last_user_idx is None:
|
||||
raise ValueError('No previous message to retry.')
|
||||
|
||||
last_user_text = _extract_text(history[last_user_idx].get('content', ''))
|
||||
removed_count = len(history) - last_user_idx
|
||||
s.messages = history[:last_user_idx]
|
||||
if isinstance(getattr(s, 'context_messages', None), list) and s.context_messages:
|
||||
truncated_context = _truncate_at_last_user(s.context_messages)
|
||||
if truncated_context is not None:
|
||||
s.context_messages = truncated_context
|
||||
s.save()
|
||||
return {'last_user_text': last_user_text, 'removed_count': removed_count}
|
||||
|
||||
|
||||
def undo_last(session_id: str) -> dict[str, Any]:
|
||||
"""Remove the most recent user message and everything after it.
|
||||
|
||||
Mirrors gateway/run.py:_handle_undo_command. Returns a preview of the
|
||||
removed text so the UI can confirm to the user.
|
||||
|
||||
Raises:
|
||||
KeyError: session not found
|
||||
ValueError: no user message in transcript
|
||||
"""
|
||||
# Acquire the per-session agent lock as the outermost lock so that the
|
||||
# read-modify-write of s.messages is serialised with the periodic
|
||||
# checkpoint thread, cancel_stream, and all other session writers.
|
||||
# Lock ordering: _agent_lock → LOCK → _write_session_index (LOCK).
|
||||
with _get_session_agent_lock(session_id):
|
||||
s = get_session(session_id) # acquires LOCK transiently
|
||||
with LOCK:
|
||||
# Stale-object guard — see retry_last for the rationale.
|
||||
s = SESSIONS.get(session_id, s)
|
||||
history = s.messages or []
|
||||
last_user_idx = None
|
||||
for i in range(len(history) - 1, -1, -1):
|
||||
if history[i].get('role') == 'user':
|
||||
last_user_idx = i
|
||||
break
|
||||
if last_user_idx is None:
|
||||
raise ValueError('Nothing to undo.')
|
||||
|
||||
removed_text = _extract_text(history[last_user_idx].get('content', ''))
|
||||
removed_count = len(history) - last_user_idx
|
||||
s.messages = history[:last_user_idx]
|
||||
if isinstance(getattr(s, 'context_messages', None), list) and s.context_messages:
|
||||
truncated_context = _truncate_at_last_user(s.context_messages)
|
||||
if truncated_context is not None:
|
||||
s.context_messages = truncated_context
|
||||
s.save() # outside LOCK -- save() re-acquires LOCK via _write_session_index()
|
||||
preview = (removed_text[:40] + '...') if len(removed_text) > 40 else removed_text
|
||||
return {
|
||||
'removed_count': removed_count,
|
||||
'removed_preview': preview,
|
||||
}
|
||||
|
||||
|
||||
def session_status(session_id: str) -> dict[str, Any]:
|
||||
"""Return a snapshot of session state for /status.
|
||||
|
||||
Webui equivalent of gateway/run.py:_handle_status_command. The agent's
|
||||
"agent_running" comes from `session_key in self._running_agents`; the
|
||||
webui equivalent is whether the session has an active stream
|
||||
(active_stream_id is set).
|
||||
"""
|
||||
s = get_session(session_id)
|
||||
inp = int(s.input_tokens or 0)
|
||||
out = int(s.output_tokens or 0)
|
||||
profile = getattr(s, 'profile', None) or 'default'
|
||||
try:
|
||||
from api.profiles import get_hermes_home_for_profile
|
||||
hermes_home = str(get_hermes_home_for_profile(profile))
|
||||
except Exception:
|
||||
hermes_home = ''
|
||||
return {
|
||||
'session_id': s.session_id,
|
||||
'title': s.title,
|
||||
'model': s.model,
|
||||
'profile': profile,
|
||||
'hermes_home': hermes_home,
|
||||
'workspace': s.workspace,
|
||||
'personality': s.personality,
|
||||
'message_count': len(s.messages or []),
|
||||
'created_at': s.created_at,
|
||||
'updated_at': s.updated_at,
|
||||
'agent_running': bool(getattr(s, 'active_stream_id', None)),
|
||||
'input_tokens': inp,
|
||||
'output_tokens': out,
|
||||
'total_tokens': inp + out,
|
||||
'estimated_cost': s.estimated_cost,
|
||||
}
|
||||
|
||||
|
||||
def session_usage(session_id: str) -> dict[str, Any]:
|
||||
"""Return token usage and cost for /usage.
|
||||
|
||||
Mirrors gateway/run.py:_handle_usage_command's basic counters. The
|
||||
agent shows additional fields (rate-limit headroom etc.) that depend
|
||||
on provider API responses we don't have in webui -- those are deferred.
|
||||
"""
|
||||
s = get_session(session_id)
|
||||
inp = int(s.input_tokens or 0)
|
||||
out = int(s.output_tokens or 0)
|
||||
return {
|
||||
'input_tokens': inp,
|
||||
'output_tokens': out,
|
||||
'total_tokens': inp + out,
|
||||
'estimated_cost': s.estimated_cost,
|
||||
'model': s.model,
|
||||
}
|
||||
|
||||
|
||||
def _extract_text(content: Any) -> str:
|
||||
"""Flatten message content to plain text. Agent stores either a string
|
||||
or a list of {type, text|...} parts; webui needs the user-typed text."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for p in content:
|
||||
if isinstance(p, dict) and p.get('type') == 'text':
|
||||
parts.append(p.get('text', ''))
|
||||
return ' '.join(parts)
|
||||
return str(content)
|
||||
128
api/startup.py
Normal file
128
api/startup.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Hermes Web UI -- startup helpers."""
|
||||
from __future__ import annotations
|
||||
import os, stat, subprocess, sys
|
||||
from pathlib import Path
|
||||
|
||||
# Credential files that should never be world-readable
|
||||
_SENSITIVE_FILES = (
|
||||
'.env',
|
||||
'google_token.json',
|
||||
'google_client_secret.json',
|
||||
'.signing_key',
|
||||
'auth.json',
|
||||
)
|
||||
|
||||
|
||||
def fix_credential_permissions() -> None:
|
||||
"""Ensure sensitive files in HERMES_HOME have safe permissions.
|
||||
|
||||
Respects:
|
||||
- HERMES_SKIP_CHMOD=1 → bypass entirely
|
||||
- HERMES_HOME_MODE → group bits are allowed if set by the operator,
|
||||
only world-readable/world-writable files are fixed
|
||||
"""
|
||||
if os.environ.get('HERMES_SKIP_CHMOD', '').strip() in ('1', 'true'):
|
||||
return
|
||||
|
||||
# Parse operator-declared mode to know if group bits are intentional
|
||||
declared_mode = None
|
||||
raw_mode = os.environ.get('HERMES_HOME_MODE', '').strip()
|
||||
if raw_mode:
|
||||
try:
|
||||
declared_mode = int(raw_mode, 8)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
hermes_home = Path(os.environ.get('HERMES_HOME', str(Path.home() / '.hermes')))
|
||||
if not hermes_home.is_dir():
|
||||
return
|
||||
for name in _SENSITIVE_FILES:
|
||||
fpath = hermes_home / name
|
||||
if not fpath.exists():
|
||||
continue
|
||||
try:
|
||||
current = stat.S_IMODE(fpath.stat().st_mode)
|
||||
# If operator declared a mode, allow group bits but still fix world bits
|
||||
if declared_mode is not None:
|
||||
if current & 0o007: # other bits set (world-readable/writable)
|
||||
fpath.chmod(current & ~0o007)
|
||||
print(f' [security] removed world bits on {fpath.name} ({oct(current)} -> {oct(current & ~0o007)})', flush=True)
|
||||
else:
|
||||
if current & 0o077: # group or other bits set
|
||||
fpath.chmod(0o600)
|
||||
print(f' [security] fixed permissions on {fpath.name} ({oct(current)} -> 0600)', flush=True)
|
||||
except OSError:
|
||||
pass # best-effort; don't abort startup
|
||||
|
||||
|
||||
def _agent_dir() -> Path | None:
|
||||
hermes_home = Path(os.environ.get('HERMES_HOME', str(Path.home() / '.hermes')))
|
||||
for raw in [os.environ.get('HERMES_WEBUI_AGENT_DIR', '').strip(), str(hermes_home / 'hermes-agent')]:
|
||||
if not raw:
|
||||
continue
|
||||
p = Path(raw).expanduser()
|
||||
if p.is_dir():
|
||||
return p.resolve()
|
||||
return None
|
||||
|
||||
def _trusted_agent_dir(agent_dir: Path) -> bool:
|
||||
"""Return True if agent_dir passes ownership and permission checks.
|
||||
|
||||
Validates that the directory is not world- or group-writable and,
|
||||
on POSIX systems, is owned by the current process user.
|
||||
|
||||
Intentionally does NOT enforce a canonical path (i.e. does not require
|
||||
the dir to be ~/.hermes/hermes-agent), so custom HERMES_WEBUI_AGENT_DIR
|
||||
paths work correctly when HERMES_WEBUI_AUTO_INSTALL=1 is set.
|
||||
"""
|
||||
try:
|
||||
st = agent_dir.stat()
|
||||
if stat.S_IMODE(st.st_mode) & 0o022:
|
||||
# World- or group-writable — untrusted
|
||||
return False
|
||||
if hasattr(os, 'getuid') and st.st_uid != os.getuid():
|
||||
# Not owned by current user (POSIX only; Windows fallback skips)
|
||||
return False
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def auto_install_agent_deps() -> bool:
|
||||
enabled = os.environ.get('HERMES_WEBUI_AUTO_INSTALL', '').strip().lower() in ('1', 'true', 'yes')
|
||||
if not enabled:
|
||||
print('[!!] Auto-install disabled. Set HERMES_WEBUI_AUTO_INSTALL=1 to enable.', flush=True)
|
||||
return False
|
||||
agent_dir = _agent_dir()
|
||||
if agent_dir is None:
|
||||
print('[!!] Auto-install skipped: agent directory not found.', flush=True)
|
||||
return False
|
||||
if not _trusted_agent_dir(agent_dir):
|
||||
print('[!!] Auto-install skipped: agent directory failed trust check (check ownership/permissions).', flush=True)
|
||||
return False
|
||||
req_file = agent_dir / 'requirements.txt'
|
||||
pyproject = agent_dir / 'pyproject.toml'
|
||||
if req_file.exists():
|
||||
install_args = [sys.executable, '-m', 'pip', 'install', '--quiet', '-r', str(req_file)]
|
||||
print(f' Installing from {req_file} ...', flush=True)
|
||||
elif pyproject.exists():
|
||||
install_args = [sys.executable, '-m', 'pip', 'install', '--quiet', str(agent_dir)]
|
||||
print(f' Installing from {agent_dir} (pyproject.toml) ...', flush=True)
|
||||
else:
|
||||
print('[!!] Auto-install skipped: no requirements.txt or pyproject.toml in agent dir.', flush=True)
|
||||
return False
|
||||
try:
|
||||
result = subprocess.run(install_args, capture_output=True, text=True, timeout=120)
|
||||
if result.returncode != 0:
|
||||
print(f'[!!] pip install failed (exit {result.returncode}):', flush=True)
|
||||
for line in (result.stderr or '').splitlines()[-10:]:
|
||||
print(f' {line}', flush=True)
|
||||
return False
|
||||
print('[ok] pip install completed.', flush=True)
|
||||
return True
|
||||
except subprocess.TimeoutExpired:
|
||||
print('[!!] Auto-install timed out after 120s.', flush=True)
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f'[!!] Auto-install error: {e}', flush=True)
|
||||
return False
|
||||
118
api/state_sync.py
Normal file
118
api/state_sync.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Hermes Web UI -- Optional state.db sync bridge.
|
||||
|
||||
Mirrors WebUI session metadata (token usage, title, model) into the
|
||||
hermes-agent state.db so that /insights, session lists, and cost
|
||||
tracking include WebUI activity.
|
||||
|
||||
This is opt-in via the 'sync_to_insights' setting (default: off).
|
||||
All operations are wrapped in try/except -- if state.db is unavailable,
|
||||
locked, or the schema doesn't match, the WebUI continues normally.
|
||||
|
||||
The bridge uses absolute token counts (not deltas) because the WebUI
|
||||
Session object already accumulates totals across turns. This avoids
|
||||
any double-counting risk.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_state_db():
|
||||
"""Get a SessionDB instance for the active profile's state.db.
|
||||
Returns None if hermes_state is not importable or DB is unavailable.
|
||||
Each caller is responsible for calling db.close() when done.
|
||||
"""
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
hermes_home = Path(get_active_hermes_home()).expanduser().resolve()
|
||||
except Exception:
|
||||
logger.debug("Failed to resolve hermes home, using default")
|
||||
hermes_home = Path(os.getenv('HERMES_HOME', str(Path.home() / '.hermes')))
|
||||
|
||||
db_path = hermes_home / 'state.db'
|
||||
if not db_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
return SessionDB(db_path)
|
||||
except Exception:
|
||||
logger.debug("Failed to open state.db")
|
||||
return None
|
||||
|
||||
|
||||
def sync_session_start(session_id: str, model=None) -> None:
|
||||
"""Register a WebUI session in state.db (idempotent).
|
||||
Called when a session's first message is sent.
|
||||
"""
|
||||
db = _get_state_db()
|
||||
if not db:
|
||||
return
|
||||
try:
|
||||
db.ensure_session(
|
||||
session_id=session_id,
|
||||
source='webui',
|
||||
model=model,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to sync session start to state.db")
|
||||
finally:
|
||||
try:
|
||||
db.close()
|
||||
except Exception:
|
||||
logger.debug("Failed to close state.db")
|
||||
|
||||
|
||||
def sync_session_usage(session_id: str, input_tokens: int=0, output_tokens: int=0,
|
||||
estimated_cost=None, model=None, title: str=None,
|
||||
message_count: int=None) -> None:
|
||||
"""Update token usage and title for a WebUI session in state.db.
|
||||
Called after each turn completes. Uses absolute=True to set totals
|
||||
(the WebUI Session already accumulates across turns).
|
||||
"""
|
||||
db = _get_state_db()
|
||||
if not db:
|
||||
return
|
||||
try:
|
||||
# Ensure session exists first (idempotent)
|
||||
db.ensure_session(session_id=session_id, source='webui', model=model)
|
||||
# Set absolute token counts
|
||||
db.update_token_counts(
|
||||
session_id=session_id,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
estimated_cost_usd=estimated_cost,
|
||||
model=model,
|
||||
absolute=True,
|
||||
)
|
||||
# Update title if we have one, using the public API
|
||||
if title:
|
||||
try:
|
||||
db.set_session_title(session_id, title)
|
||||
except Exception:
|
||||
logger.debug("Failed to sync session title to state.db")
|
||||
# Update message count
|
||||
if message_count is not None:
|
||||
try:
|
||||
def _set_msg_count(conn):
|
||||
conn.execute(
|
||||
"UPDATE sessions SET message_count = ? WHERE id = ?",
|
||||
(message_count, session_id),
|
||||
)
|
||||
db._execute_write(_set_msg_count)
|
||||
except Exception:
|
||||
logger.debug("Failed to sync message count to state.db")
|
||||
except Exception:
|
||||
logger.debug("Failed to sync session usage to state.db")
|
||||
finally:
|
||||
try:
|
||||
db.close()
|
||||
except Exception:
|
||||
logger.debug("Failed to close state.db")
|
||||
2826
api/streaming.py
2826
api/streaming.py
File diff suppressed because it is too large
Load Diff
248
api/terminal.py
Normal file
248
api/terminal.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""Embedded workspace terminal support for Hermes Web UI.
|
||||
|
||||
The terminal is intentionally independent from the agent execution path. It
|
||||
starts a shell with an explicit cwd/env per process and never mutates
|
||||
process-global os.environ, which avoids expanding the session-env race tracked
|
||||
in the agent execution layer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import codecs
|
||||
import fcntl
|
||||
import os
|
||||
import queue
|
||||
import select
|
||||
import shutil
|
||||
import signal
|
||||
import struct
|
||||
import subprocess
|
||||
import termios
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _set_nonblocking(fd: int) -> None:
|
||||
flags = fcntl.fcntl(fd, fcntl.F_GETFL)
|
||||
fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
||||
|
||||
|
||||
def _winsize(rows: int, cols: int) -> bytes:
|
||||
rows = max(8, min(int(rows or 24), 80))
|
||||
cols = max(20, min(int(cols or 80), 240))
|
||||
return struct.pack("HHHH", rows, cols, 0, 0)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TerminalSession:
|
||||
session_id: str
|
||||
workspace: str
|
||||
proc: subprocess.Popen
|
||||
master_fd: int
|
||||
rows: int = 24
|
||||
cols: int = 80
|
||||
output: queue.Queue = field(default_factory=lambda: queue.Queue(maxsize=2000))
|
||||
closed: threading.Event = field(default_factory=threading.Event)
|
||||
reader: threading.Thread | None = None
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
return not self.closed.is_set() and self.proc.poll() is None
|
||||
|
||||
def put_output(self, event: str, payload: dict) -> None:
|
||||
try:
|
||||
self.output.put_nowait((event, payload))
|
||||
except queue.Full:
|
||||
# Keep the terminal responsive by dropping the oldest queued chunk.
|
||||
try:
|
||||
self.output.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
self.output.put_nowait((event, payload))
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
|
||||
_TERMINALS: dict[str, TerminalSession] = {}
|
||||
_LOCK = threading.RLock()
|
||||
|
||||
|
||||
def _decode_terminal_output(decoder, data: bytes) -> str:
|
||||
"""Decode PTY bytes without stripping terminal control sequences."""
|
||||
return decoder.decode(data)
|
||||
|
||||
|
||||
def _shell_path() -> str:
|
||||
shell = os.environ.get("SHELL") or ""
|
||||
if shell and Path(shell).exists():
|
||||
return shell
|
||||
return shutil.which("zsh") or shutil.which("bash") or shutil.which("sh") or "/bin/sh"
|
||||
|
||||
|
||||
def _shell_argv(shell: str) -> list[str]:
|
||||
name = Path(shell).name
|
||||
if name in {"zsh", "bash", "sh"}:
|
||||
return [shell, "-i"]
|
||||
return [shell]
|
||||
|
||||
|
||||
def _reader_loop(term: TerminalSession) -> None:
|
||||
decoder = codecs.getincrementaldecoder("utf-8")("replace")
|
||||
try:
|
||||
while not term.closed.is_set():
|
||||
if term.proc.poll() is not None:
|
||||
break
|
||||
try:
|
||||
ready, _, _ = select.select([term.master_fd], [], [], 0.25)
|
||||
except (OSError, ValueError):
|
||||
break
|
||||
if not ready:
|
||||
continue
|
||||
try:
|
||||
data = os.read(term.master_fd, 8192)
|
||||
except OSError as exc:
|
||||
if exc.errno in (errno.EIO, errno.EBADF):
|
||||
break
|
||||
raise
|
||||
if not data:
|
||||
break
|
||||
text = _decode_terminal_output(decoder, data)
|
||||
if text:
|
||||
term.put_output("output", {"text": text})
|
||||
except Exception as exc:
|
||||
term.put_output("terminal_error", {"error": str(exc)})
|
||||
finally:
|
||||
term.closed.set()
|
||||
code = term.proc.poll()
|
||||
term.put_output("terminal_closed", {"exit_code": code})
|
||||
|
||||
|
||||
def _set_size(term: TerminalSession, rows: int, cols: int) -> None:
|
||||
term.rows = max(8, min(int(rows or term.rows or 24), 80))
|
||||
term.cols = max(20, min(int(cols or term.cols or 80), 240))
|
||||
try:
|
||||
fcntl.ioctl(term.master_fd, termios.TIOCSWINSZ, _winsize(term.rows, term.cols))
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
if term.proc.poll() is None:
|
||||
os.killpg(term.proc.pid, signal.SIGWINCH)
|
||||
except (OSError, ProcessLookupError):
|
||||
pass
|
||||
|
||||
|
||||
def start_terminal(session_id: str, workspace: Path, rows: int = 24, cols: int = 80, restart: bool = False) -> TerminalSession:
|
||||
"""Start or return the embedded terminal for a WebUI session."""
|
||||
sid = str(session_id or "").strip()
|
||||
if not sid:
|
||||
raise ValueError("session_id is required")
|
||||
cwd = str(Path(workspace).expanduser().resolve())
|
||||
if not Path(cwd).is_dir():
|
||||
raise ValueError("workspace is not a directory")
|
||||
|
||||
with _LOCK:
|
||||
current = _TERMINALS.get(sid)
|
||||
if current and current.is_alive() and not restart and current.workspace == cwd:
|
||||
_set_size(current, rows, cols)
|
||||
return current
|
||||
if current:
|
||||
close_terminal(sid)
|
||||
|
||||
master_fd, slave_fd = os.openpty()
|
||||
# Build a safe env: allowlist common shell vars, strip API keys and secrets.
|
||||
# The PTY shell is an interactive UI surface — do not leak server credentials.
|
||||
_SAFE_ENV_KEYS = {
|
||||
"PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL",
|
||||
"LC_CTYPE", "LC_MESSAGES", "LANGUAGE", "TZ", "TMPDIR", "TEMP",
|
||||
"XDG_RUNTIME_DIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME",
|
||||
}
|
||||
env = {k: v for k, v in os.environ.items() if k in _SAFE_ENV_KEYS}
|
||||
env.update(
|
||||
{
|
||||
"TERM": "xterm-256color",
|
||||
"COLORTERM": "truecolor",
|
||||
"COLUMNS": str(cols),
|
||||
"LINES": str(rows),
|
||||
"PWD": cwd,
|
||||
"HERMES_WEBUI_TERMINAL": "1",
|
||||
}
|
||||
)
|
||||
shell = _shell_path()
|
||||
proc = subprocess.Popen(
|
||||
_shell_argv(shell),
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
close_fds=True,
|
||||
start_new_session=True,
|
||||
)
|
||||
os.close(slave_fd)
|
||||
_set_nonblocking(master_fd)
|
||||
|
||||
term = TerminalSession(
|
||||
session_id=sid,
|
||||
workspace=cwd,
|
||||
proc=proc,
|
||||
master_fd=master_fd,
|
||||
rows=rows,
|
||||
cols=cols,
|
||||
)
|
||||
_set_size(term, rows, cols)
|
||||
term.reader = threading.Thread(target=_reader_loop, args=(term,), daemon=True)
|
||||
term.reader.start()
|
||||
_TERMINALS[sid] = term
|
||||
return term
|
||||
|
||||
|
||||
def get_terminal(session_id: str) -> TerminalSession | None:
|
||||
with _LOCK:
|
||||
term = _TERMINALS.get(str(session_id or ""))
|
||||
if term and term.is_alive():
|
||||
return term
|
||||
return term
|
||||
|
||||
|
||||
def write_terminal(session_id: str, data: str) -> None:
|
||||
term = get_terminal(session_id)
|
||||
if not term or not term.is_alive():
|
||||
raise KeyError("terminal not running")
|
||||
os.write(term.master_fd, str(data or "").encode("utf-8", errors="replace"))
|
||||
|
||||
|
||||
def resize_terminal(session_id: str, rows: int, cols: int) -> None:
|
||||
term = get_terminal(session_id)
|
||||
if not term:
|
||||
raise KeyError("terminal not running")
|
||||
_set_size(term, rows, cols)
|
||||
|
||||
|
||||
def close_terminal(session_id: str) -> bool:
|
||||
sid = str(session_id or "")
|
||||
with _LOCK:
|
||||
term = _TERMINALS.pop(sid, None)
|
||||
if not term:
|
||||
return False
|
||||
term.closed.set()
|
||||
try:
|
||||
if term.proc.poll() is None:
|
||||
try:
|
||||
os.killpg(term.proc.pid, signal.SIGHUP)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
term.proc.wait(timeout=1.5)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
os.killpg(term.proc.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
os.close(term.master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
return True
|
||||
431
api/updates.py
Normal file
431
api/updates.py
Normal file
@@ -0,0 +1,431 @@
|
||||
"""
|
||||
Hermes Web UI -- Self-update checker.
|
||||
|
||||
Checks if the webui and hermes-agent git repos are behind their upstream
|
||||
branches. Results are cached server-side (30-min TTL) so git fetch runs
|
||||
at most twice per hour regardless of client count.
|
||||
|
||||
Skips repos that are not git checkouts (e.g. Docker baked images where
|
||||
.git does not exist).
|
||||
"""
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from api.config import REPO_ROOT
|
||||
|
||||
# Lazy -- may be None if agent not found
|
||||
try:
|
||||
from api.config import _AGENT_DIR
|
||||
except ImportError:
|
||||
_AGENT_DIR = None
|
||||
|
||||
_update_cache = {'webui': None, 'agent': None, 'checked_at': 0}
|
||||
_cache_lock = threading.Lock()
|
||||
_check_in_progress = False
|
||||
_apply_lock = threading.Lock() # prevents concurrent stash/pull/pop on same repo
|
||||
CACHE_TTL = 1800 # 30 minutes
|
||||
|
||||
|
||||
def _run_git(args, cwd, timeout=10):
|
||||
"""Run a git command and return (useful output, ok).
|
||||
|
||||
On failure, returns stderr (or stdout as fallback) so callers can
|
||||
surface actionable git error messages instead of empty strings.
|
||||
"""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['git'] + args, cwd=str(cwd), capture_output=True,
|
||||
text=True, timeout=timeout,
|
||||
)
|
||||
stdout = r.stdout.strip()
|
||||
stderr = r.stderr.strip()
|
||||
if r.returncode == 0:
|
||||
return stdout, True
|
||||
return stderr or stdout or f"git exited with status {r.returncode}", False
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
detail = (getattr(exc, 'stderr', None) or getattr(exc, 'stdout', None) or '').strip()
|
||||
return detail or f"git {' '.join(args)} timed out after {timeout}s", False
|
||||
except FileNotFoundError:
|
||||
return 'git executable not found', False
|
||||
except OSError as exc:
|
||||
return f'git failed to start: {exc}', False
|
||||
|
||||
|
||||
def _detect_webui_version() -> str:
|
||||
"""Detect the running WebUI version from git or a baked-in fallback file.
|
||||
|
||||
Resolution order:
|
||||
1. ``git describe --tags --always --dirty`` — works in any git checkout.
|
||||
Returns the exact tag on tagged commits (e.g. ``v0.50.124``), a
|
||||
post-tag descriptor between releases (e.g. ``v0.50.124-1-ge91325d``),
|
||||
or a bare SHA when no tags exist (shallow clones, fresh forks).
|
||||
2. ``api/_version.py`` — a fallback written by the Docker / CI release
|
||||
workflow when ``.git`` is not present in the image. Expected to define
|
||||
``__version__ = 'vX.Y.Z'``.
|
||||
3. ``'unknown'`` — last resort; displayed as-is in the settings badge.
|
||||
"""
|
||||
# Timeout capped at 3s: git describe on a healthy local repo is <50ms;
|
||||
# a 10s stall on import (NFS-mounted .git, broken git binary) is unacceptable.
|
||||
out, ok = _run_git(['describe', '--tags', '--always', '--dirty'], REPO_ROOT, timeout=3)
|
||||
if ok and out:
|
||||
return out
|
||||
|
||||
# Docker / baked-image fallback: api/_version.py written by CI at build time.
|
||||
# Parse with regex rather than exec() — the file holds exactly one assignment
|
||||
# and regex is sufficient; exec() on a build artifact is an unnecessary surface.
|
||||
version_file = REPO_ROOT / 'api' / '_version.py'
|
||||
if version_file.exists():
|
||||
try:
|
||||
import re as _re
|
||||
m = _re.search(
|
||||
r"""__version__\s*=\s*['"]([^'"]+)['"]""",
|
||||
version_file.read_text(encoding='utf-8'),
|
||||
)
|
||||
if m:
|
||||
return m.group(1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return 'unknown'
|
||||
|
||||
|
||||
# Resolved once at import time — tags cannot change without a process restart.
|
||||
WEBUI_VERSION: str = _detect_webui_version()
|
||||
|
||||
|
||||
def _split_remote_ref(ref):
|
||||
"""Split 'origin/branch-name' into ('origin', 'branch-name').
|
||||
|
||||
Returns (None, ref) if ref contains no slash.
|
||||
"""
|
||||
if '/' not in ref:
|
||||
return None, ref
|
||||
remote, branch = ref.split('/', 1)
|
||||
return remote, branch
|
||||
|
||||
|
||||
def _detect_default_branch(path):
|
||||
"""Detect the remote default branch (master or main)."""
|
||||
out, ok = _run_git(['symbolic-ref', 'refs/remotes/origin/HEAD'], path)
|
||||
if ok and out:
|
||||
# refs/remotes/origin/master -> master
|
||||
return out.split('/')[-1]
|
||||
# Fallback: try master, then main
|
||||
for branch in ('master', 'main'):
|
||||
_, ok = _run_git(['rev-parse', '--verify', f'origin/{branch}'], path)
|
||||
if ok:
|
||||
return branch
|
||||
return 'master'
|
||||
|
||||
|
||||
def _check_repo(path, name):
|
||||
"""Check if a git repo is behind its upstream. Returns dict or None."""
|
||||
if path is None or not (path / '.git').exists():
|
||||
return None
|
||||
|
||||
# Fetch latest from origin (network call, cached by TTL)
|
||||
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15)
|
||||
if not fetch_ok:
|
||||
return {'name': name, 'behind': 0, 'error': 'fetch failed'}
|
||||
|
||||
# Use the current branch's upstream tracking branch, not the repo default.
|
||||
# This avoids false "N updates behind" alerts when the user is on a feature
|
||||
# branch and master/main has moved forward with unrelated commits.
|
||||
# If no upstream is set (brand-new local branch), fall back to the default branch.
|
||||
upstream, ok = _run_git(['rev-parse', '--abbrev-ref', '@{upstream}'], path)
|
||||
if ok and upstream:
|
||||
# upstream is like "origin/feat/foo" — use it directly in rev-list
|
||||
compare_ref = upstream
|
||||
else:
|
||||
branch = _detect_default_branch(path)
|
||||
compare_ref = f'origin/{branch}'
|
||||
|
||||
# Count commits behind
|
||||
out, ok = _run_git(['rev-list', '--count', f'HEAD..{compare_ref}'], path)
|
||||
behind = int(out) if ok and out.isdigit() else 0
|
||||
|
||||
# Get short SHAs for display
|
||||
current, _ = _run_git(['rev-parse', '--short', 'HEAD'], path)
|
||||
latest, _ = _run_git(['rev-parse', '--short', compare_ref], path)
|
||||
|
||||
return {
|
||||
'name': name,
|
||||
'behind': behind,
|
||||
'current_sha': current,
|
||||
'latest_sha': latest,
|
||||
'branch': compare_ref,
|
||||
}
|
||||
|
||||
|
||||
def check_for_updates(force=False):
|
||||
"""Return cached update status for webui and agent repos."""
|
||||
global _check_in_progress
|
||||
with _cache_lock:
|
||||
if not force and time.time() - _update_cache['checked_at'] < CACHE_TTL:
|
||||
return dict(_update_cache)
|
||||
if _check_in_progress:
|
||||
return dict(_update_cache) # another thread is already checking
|
||||
_check_in_progress = True
|
||||
|
||||
try:
|
||||
# Run checks outside the lock (network I/O)
|
||||
webui_info = _check_repo(REPO_ROOT, 'webui')
|
||||
agent_info = _check_repo(_AGENT_DIR, 'agent')
|
||||
|
||||
with _cache_lock:
|
||||
_update_cache['webui'] = webui_info
|
||||
_update_cache['agent'] = agent_info
|
||||
_update_cache['checked_at'] = time.time()
|
||||
return dict(_update_cache)
|
||||
finally:
|
||||
_check_in_progress = False
|
||||
|
||||
|
||||
def _schedule_restart(delay: float = 2.0) -> None:
|
||||
"""Re-exec this process after *delay* seconds.
|
||||
|
||||
Called after a successful update so that the freshly-pulled code is
|
||||
loaded on the next request, rather than running with a mix of old and
|
||||
new Python modules in sys.modules.
|
||||
|
||||
os.execv() replaces the current process image with a fresh interpreter
|
||||
running the same argv — sessions are preserved on disk, the HTTP port
|
||||
is reclaimed within the delay window, and the client's own
|
||||
``setTimeout(() => location.reload(), 2500)`` lands after the restart.
|
||||
|
||||
Coordinates with ``_apply_lock``: when the user updates both webui
|
||||
and agent, the client POSTs them sequentially. Without coordination
|
||||
the restart timer scheduled by the first update's success would fire
|
||||
while the second update's git-pull is still running, killing it mid-
|
||||
stream and leaving the second repo in an unknown partial state.
|
||||
Blocking on ``_apply_lock`` before ``os.execv`` means a pending
|
||||
second update always completes before the restart happens.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
def _do():
|
||||
import time
|
||||
time.sleep(delay)
|
||||
# Hold _apply_lock through os.execv so no new update can start between
|
||||
# the lock-release and the process replacement. Any in-flight update
|
||||
# finishes first (since it holds the lock), and then the process is
|
||||
# replaced while still holding the lock — meaning no new update can
|
||||
# sneak in during the brief TOCTOU window that existed with the
|
||||
# original acquire-release-execv sequence.
|
||||
# Threads die when execv replaces the process image, so the lock is
|
||||
# released atomically by the kernel.
|
||||
with _apply_lock:
|
||||
try:
|
||||
os.execv(sys.executable, [sys.executable] + sys.argv)
|
||||
except Exception:
|
||||
# Last-resort: if execv fails (e.g. frozen binary), just exit
|
||||
# so the process supervisor (start.sh / Docker) restarts us.
|
||||
os._exit(0)
|
||||
|
||||
threading.Thread(target=_do, daemon=True).start()
|
||||
|
||||
|
||||
def apply_force_update(target: str) -> dict:
|
||||
"""Force-reset the target repo to the latest remote HEAD.
|
||||
|
||||
Unlike apply_update() which requires a clean working tree and refuses
|
||||
merge conflicts, this discards all local modifications (checkout .) and
|
||||
resets to origin/<branch> — equivalent to what the diverged/conflict
|
||||
error messages ask the user to run manually.
|
||||
|
||||
Should only be called when apply_update() has already returned a
|
||||
response with ``conflict: True`` or ``diverged: True`` and the user
|
||||
has confirmed they want to discard local changes.
|
||||
"""
|
||||
if not _apply_lock.acquire(blocking=False):
|
||||
return {'ok': False, 'message': 'Update already in progress'}
|
||||
try:
|
||||
if target == 'webui':
|
||||
path = REPO_ROOT
|
||||
elif target == 'agent':
|
||||
path = _AGENT_DIR
|
||||
else:
|
||||
return {'ok': False, 'message': f'Unknown target: {target}'}
|
||||
|
||||
if path is None or not (path / '.git').exists():
|
||||
return {'ok': False, 'message': 'Not a git repository'}
|
||||
|
||||
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15)
|
||||
if not fetch_ok:
|
||||
return {
|
||||
'ok': False,
|
||||
'message': 'Could not reach the remote repository. Check your connection.',
|
||||
}
|
||||
|
||||
upstream, ok = _run_git(['rev-parse', '--abbrev-ref', '@{upstream}'], path)
|
||||
if ok and upstream:
|
||||
compare_ref = upstream
|
||||
else:
|
||||
branch = _detect_default_branch(path)
|
||||
compare_ref = f'origin/{branch}'
|
||||
|
||||
# Discard local modifications then reset to remote HEAD
|
||||
_run_git(['checkout', '.'], path)
|
||||
_, ok = _run_git(['reset', '--hard', compare_ref], path)
|
||||
if not ok:
|
||||
return {'ok': False, 'message': f'Force reset to {compare_ref} failed'}
|
||||
|
||||
with _cache_lock:
|
||||
_update_cache['checked_at'] = 0
|
||||
|
||||
_schedule_restart()
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'message': f'{target} force-updated to {compare_ref}',
|
||||
'target': target,
|
||||
'restart_scheduled': True,
|
||||
}
|
||||
finally:
|
||||
_apply_lock.release()
|
||||
|
||||
|
||||
def apply_update(target):
|
||||
"""Stash, pull --ff-only, pop for the given target repo."""
|
||||
if not _apply_lock.acquire(blocking=False):
|
||||
return {'ok': False, 'message': 'Update already in progress'}
|
||||
try:
|
||||
return _apply_update_inner(target)
|
||||
finally:
|
||||
_apply_lock.release()
|
||||
|
||||
|
||||
def _apply_update_inner(target):
|
||||
"""Inner implementation of apply_update, called under _apply_lock."""
|
||||
if target == 'webui':
|
||||
path = REPO_ROOT
|
||||
elif target == 'agent':
|
||||
path = _AGENT_DIR
|
||||
else:
|
||||
return {'ok': False, 'message': f'Unknown target: {target}'}
|
||||
|
||||
if path is None or not (path / '.git').exists():
|
||||
return {'ok': False, 'message': 'Not a git repository'}
|
||||
|
||||
# Use the current branch's upstream for pull, matching the behaviour
|
||||
# of _check_repo. Falls back to default branch if no upstream is set.
|
||||
upstream, ok = _run_git(['rev-parse', '--abbrev-ref', '@{upstream}'], path)
|
||||
if ok and upstream:
|
||||
compare_ref = upstream
|
||||
else:
|
||||
branch = _detect_default_branch(path)
|
||||
compare_ref = f'origin/{branch}'
|
||||
|
||||
# Fetch before attempting pull, so the remote ref is current.
|
||||
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15)
|
||||
if not fetch_ok:
|
||||
return {
|
||||
'ok': False,
|
||||
'message': (
|
||||
'Could not reach the remote repository. '
|
||||
'Check your internet connection and try again.'
|
||||
),
|
||||
}
|
||||
|
||||
# Check for dirty working tree (ignore untracked files — git stash
|
||||
# doesn't include them, so stashing on '??' alone leaves nothing to pop)
|
||||
status_out, status_ok = _run_git(
|
||||
['status', '--porcelain', '--untracked-files=no'], path
|
||||
)
|
||||
if not status_ok:
|
||||
return {'ok': False, 'message': f'Failed to inspect repo status: {status_out[:200]}'}
|
||||
# Fail early on unresolved merge conflicts
|
||||
if any(line[:2] in {'DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU'}
|
||||
for line in status_out.splitlines()):
|
||||
return {
|
||||
'ok': False,
|
||||
'message': (
|
||||
f'The local {target} repo has unresolved merge conflicts. '
|
||||
'To reset to the latest remote version run: '
|
||||
'git -C ' + str(path) + ' checkout . && '
|
||||
'git -C ' + str(path) + ' pull --ff-only'
|
||||
),
|
||||
'conflict': True,
|
||||
}
|
||||
stashed = False
|
||||
if status_out:
|
||||
_, ok = _run_git(['stash'], path)
|
||||
if not ok:
|
||||
return {'ok': False, 'message': 'Failed to stash local changes'}
|
||||
stashed = True
|
||||
|
||||
# Pull with ff-only (no merge commits).
|
||||
# Split tracking refs like 'origin/main' into separate remote + branch
|
||||
# arguments — git treats 'origin/main' as a repository name otherwise.
|
||||
remote, branch = _split_remote_ref(compare_ref)
|
||||
pull_args = ['pull', '--ff-only']
|
||||
if remote:
|
||||
pull_args.extend([remote, branch])
|
||||
else:
|
||||
pull_args.append(compare_ref)
|
||||
pull_out, pull_ok = _run_git(pull_args, path, timeout=30)
|
||||
if not pull_ok:
|
||||
if stashed:
|
||||
_run_git(['stash', 'pop'], path)
|
||||
|
||||
# Diagnose the most common failure modes and surface actionable messages.
|
||||
pull_lower = pull_out.lower()
|
||||
if 'not possible to fast-forward' in pull_lower or 'diverged' in pull_lower:
|
||||
return {
|
||||
'ok': False,
|
||||
'message': (
|
||||
f'The local {target} repo has commits that are not on the remote '
|
||||
'branch, so a fast-forward update is not possible. '
|
||||
'Run: git -C ' + str(path) + ' fetch origin && '
|
||||
'git -C ' + str(path) + ' reset --hard ' + compare_ref
|
||||
),
|
||||
'diverged': True,
|
||||
}
|
||||
if 'does not track' in pull_lower or 'no tracking information' in pull_lower:
|
||||
return {
|
||||
'ok': False,
|
||||
'message': (
|
||||
f'The local {target} branch has no upstream tracking branch configured. '
|
||||
'Run: git -C ' + str(path) + ' branch --set-upstream-to=' + compare_ref
|
||||
),
|
||||
}
|
||||
# Generic fallback — include the raw git output for debugging.
|
||||
detail = pull_out.strip()[:300] if pull_out.strip() else '(no output from git)'
|
||||
return {'ok': False, 'message': f'Pull failed: {detail}'}
|
||||
|
||||
# Pop stash if we stashed
|
||||
if stashed:
|
||||
_, pop_ok = _run_git(['stash', 'pop'], path)
|
||||
if not pop_ok:
|
||||
return {
|
||||
'ok': False,
|
||||
'message': 'Updated but stash pop failed -- manual merge needed',
|
||||
'stash_conflict': True,
|
||||
}
|
||||
|
||||
# Invalidate cache
|
||||
with _cache_lock:
|
||||
_update_cache['checked_at'] = 0
|
||||
|
||||
# Schedule a self-restart so the updated code is loaded fresh. A plain
|
||||
# git pull leaves stale Python modules in sys.modules — agent imports that
|
||||
# reference new symbols (functions, classes) added in the update will fail
|
||||
# on the next request with AttributeError / ImportError. os.execv() re-
|
||||
# execs the same interpreter with the same argv, picking up the new code
|
||||
# cleanly without requiring the user to restart manually.
|
||||
#
|
||||
# The 2 s delay gives the HTTP response time to flush to the client before
|
||||
# the process replaces itself. The client already does
|
||||
# setTimeout(() => location.reload(), 1500) on success, so the page reload
|
||||
# and the restart land at roughly the same time.
|
||||
_schedule_restart()
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'message': f'{target} updated successfully',
|
||||
'target': target,
|
||||
'restart_scheduled': True,
|
||||
}
|
||||
218
api/upload.py
218
api/upload.py
@@ -1,8 +1,10 @@
|
||||
"""
|
||||
Hermes Web UI -- File upload: multipart parser and upload handler.
|
||||
"""
|
||||
import mimetypes
|
||||
import re as _re
|
||||
import email.parser
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from api.config import MAX_UPLOAD_BYTES
|
||||
@@ -11,7 +13,7 @@ from api.models import get_session
|
||||
from api.workspace import safe_resolve_ws
|
||||
|
||||
|
||||
def parse_multipart(rfile, content_type, content_length):
|
||||
def parse_multipart(rfile, content_type, content_length) -> tuple:
|
||||
import re as _re, email.parser as _ep
|
||||
m = _re.search(r'boundary=([^;\s]+)', content_type)
|
||||
if not m:
|
||||
@@ -50,8 +52,15 @@ def parse_multipart(rfile, content_type, content_length):
|
||||
return fields, files
|
||||
|
||||
|
||||
def _sanitize_upload_name(filename: str) -> str:
|
||||
safe_name = _re.sub(r'[^\w.\-]', '_', Path(filename).name)[:200]
|
||||
if not safe_name or safe_name.strip('.') == '':
|
||||
raise ValueError('Invalid filename')
|
||||
return safe_name
|
||||
|
||||
|
||||
def handle_upload(handler):
|
||||
import re as _re, traceback as _tb
|
||||
import traceback as _tb
|
||||
try:
|
||||
content_type = handler.headers.get('Content-Type', '')
|
||||
content_length = int(handler.headers.get('Content-Length', 0) or 0)
|
||||
@@ -69,10 +78,207 @@ def handle_upload(handler):
|
||||
except KeyError:
|
||||
return j(handler, {'error': 'Session not found'}, status=404)
|
||||
workspace = Path(s.workspace)
|
||||
safe_name = _re.sub(r'[^\w.\-]', '_', Path(filename).name)[:200]
|
||||
dest = workspace / safe_name
|
||||
safe_name = _sanitize_upload_name(filename)
|
||||
dest = safe_resolve_ws(workspace, safe_name)
|
||||
dest.write_bytes(file_bytes)
|
||||
return j(handler, {'filename': safe_name, 'path': str(dest), 'size': dest.stat().st_size})
|
||||
except Exception as e:
|
||||
mime = mimetypes.guess_type(safe_name)[0] or 'application/octet-stream'
|
||||
return j(handler, {
|
||||
'filename': safe_name,
|
||||
'path': str(dest),
|
||||
'size': dest.stat().st_size,
|
||||
'mime': mime,
|
||||
'is_image': mime.startswith('image/'),
|
||||
})
|
||||
except ValueError as e:
|
||||
return j(handler, {'error': str(e)}, status=400)
|
||||
except Exception:
|
||||
print('[webui] upload error: ' + _tb.format_exc(), flush=True)
|
||||
return j(handler, {'error': 'Upload failed'}, status=500)
|
||||
|
||||
|
||||
# Maximum total extracted bytes — guards against zip/tar bombs.
|
||||
# Set to 10x the upload limit; a legitimate archive rarely exceeds 3-4x.
|
||||
_MAX_EXTRACTED_BYTES = 10 * 20 * 1024 * 1024 # 200 MB
|
||||
|
||||
|
||||
def extract_archive(file_bytes: bytes, filename: str, workspace: Path):
|
||||
"""Extract a zip or tar archive into the workspace.
|
||||
|
||||
Returns a dict with ``extracted`` (int), ``files`` (list[str]).
|
||||
Raises ValueError on zip-slip or unsupported format.
|
||||
"""
|
||||
import zipfile, tarfile, io, os, shutil
|
||||
|
||||
name = Path(filename).name
|
||||
stem = Path(filename).stem # strip .zip / .tar.gz etc.
|
||||
|
||||
if name.lower().endswith(('.zip',)):
|
||||
_mode = 'zip'
|
||||
elif name.lower().endswith(('.tar', '.tar.gz', '.tgz', '.tar.bz2', '.tbz2', '.tar.xz', '.txz')):
|
||||
_mode = 'tar'
|
||||
else:
|
||||
raise ValueError(f'Unsupported archive format: {filename}')
|
||||
|
||||
# Determine destination directory — use archive stem as folder name
|
||||
dest_dir = safe_resolve_ws(workspace, stem)
|
||||
# Avoid overwriting existing files by appending a suffix
|
||||
if dest_dir.exists():
|
||||
import string, random
|
||||
while dest_dir.exists():
|
||||
suffix = ''.join(random.choices(string.digits, k=3))
|
||||
dest_dir = dest_dir.with_name(stem + '_' + suffix)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
extracted_files = []
|
||||
total_extracted = 0
|
||||
|
||||
try:
|
||||
if _mode == 'zip':
|
||||
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
|
||||
for member in zf.infolist():
|
||||
# Skip directories
|
||||
if member.is_dir():
|
||||
continue
|
||||
# Zip-slip protection
|
||||
member_path = (dest_dir / member.filename).resolve()
|
||||
if not member_path.is_relative_to(dest_dir.resolve()):
|
||||
raise ValueError(f'Zip-slip blocked: {member.filename}')
|
||||
# Zip-bomb protection: track actual extracted bytes (not declared file_size)
|
||||
if total_extracted > _MAX_EXTRACTED_BYTES:
|
||||
raise ValueError(
|
||||
f'Extraction too large ({total_extracted // (1024*1024)} MB > '
|
||||
f'{_MAX_EXTRACTED_BYTES // (1024*1024)} MB limit). '
|
||||
f'Possible zip bomb.'
|
||||
)
|
||||
member_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zf.open(member) as src, open(member_path, 'wb') as dst:
|
||||
_chunk_size = 65536
|
||||
while True:
|
||||
chunk = src.read(_chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
total_extracted += len(chunk)
|
||||
if total_extracted > _MAX_EXTRACTED_BYTES:
|
||||
raise ValueError(
|
||||
f'Extraction too large (> '
|
||||
f'{_MAX_EXTRACTED_BYTES // (1024*1024)} MB limit). '
|
||||
f'Possible zip bomb.'
|
||||
)
|
||||
dst.write(chunk)
|
||||
extracted_files.append(str(member_path.relative_to(workspace.resolve())))
|
||||
|
||||
elif _mode == 'tar':
|
||||
with tarfile.open(fileobj=io.BytesIO(file_bytes)) as tf:
|
||||
for member in tf.getmembers():
|
||||
if not member.isfile():
|
||||
continue
|
||||
# Tar-slip protection
|
||||
member_path = (dest_dir / member.name).resolve()
|
||||
if not member_path.is_relative_to(dest_dir.resolve()):
|
||||
raise ValueError(f'Tar-slip blocked: {member.name}')
|
||||
# Tar-bomb protection: track actual extracted bytes (not declared size)
|
||||
if total_extracted > _MAX_EXTRACTED_BYTES:
|
||||
raise ValueError(
|
||||
f'Extraction too large ({total_extracted // (1024*1024)} MB > '
|
||||
f'{_MAX_EXTRACTED_BYTES // (1024*1024)} MB limit). '
|
||||
f'Possible zip bomb.'
|
||||
)
|
||||
member_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
src_obj = tf.extractfile(member)
|
||||
if src_obj:
|
||||
with src_obj as src, open(member_path, 'wb') as dst:
|
||||
_chunk_size = 65536
|
||||
while True:
|
||||
chunk = src.read(_chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
total_extracted += len(chunk)
|
||||
if total_extracted > _MAX_EXTRACTED_BYTES:
|
||||
raise ValueError(
|
||||
f'Extraction too large (> '
|
||||
f'{_MAX_EXTRACTED_BYTES // (1024*1024)} MB limit). '
|
||||
f'Possible zip bomb.'
|
||||
)
|
||||
dst.write(chunk)
|
||||
extracted_files.append(str(member_path.relative_to(workspace.resolve())))
|
||||
except Exception:
|
||||
# Clean up partially-extracted directory to avoid orphaned folders
|
||||
try:
|
||||
shutil.rmtree(dest_dir, ignore_errors=True)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
return {'extracted': len(extracted_files), 'files': extracted_files, 'dest': str(dest_dir)}
|
||||
|
||||
|
||||
def handle_upload_extract(handler):
|
||||
"""Handle archive upload and extraction."""
|
||||
import traceback as _tb
|
||||
try:
|
||||
content_type = handler.headers.get('Content-Type', '')
|
||||
content_length = int(handler.headers.get('Content-Length', 0) or 0)
|
||||
if content_length > MAX_UPLOAD_BYTES:
|
||||
return j(handler, {'error': f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)'}, status=413)
|
||||
fields, files = parse_multipart(handler.rfile, content_type, content_length)
|
||||
session_id = fields.get('session_id', '')
|
||||
if 'file' not in files:
|
||||
return j(handler, {'error': 'No file field in request'}, status=400)
|
||||
filename, file_bytes = files['file']
|
||||
if not filename:
|
||||
return j(handler, {'error': 'No filename in upload'}, status=400)
|
||||
try:
|
||||
s = get_session(session_id)
|
||||
except KeyError:
|
||||
return j(handler, {'error': 'Session not found'}, status=404)
|
||||
workspace = Path(s.workspace)
|
||||
result = extract_archive(file_bytes, filename, workspace)
|
||||
return j(handler, {'ok': True, **result})
|
||||
except ValueError as e:
|
||||
return j(handler, {'error': str(e)}, status=400)
|
||||
except Exception:
|
||||
print('[webui] upload extract error: ' + _tb.format_exc(), flush=True)
|
||||
return j(handler, {'error': 'Archive extraction failed'}, status=500)
|
||||
|
||||
|
||||
def handle_transcribe(handler):
|
||||
import traceback as _tb
|
||||
temp_path = None
|
||||
try:
|
||||
content_type = handler.headers.get('Content-Type', '')
|
||||
content_length = int(handler.headers.get('Content-Length', 0) or 0)
|
||||
if content_length > MAX_UPLOAD_BYTES:
|
||||
return j(handler, {'error': f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)'}, status=413)
|
||||
fields, files = parse_multipart(handler.rfile, content_type, content_length)
|
||||
if 'file' not in files:
|
||||
return j(handler, {'error': 'No file field in request'}, status=400)
|
||||
filename, file_bytes = files['file']
|
||||
if not filename:
|
||||
return j(handler, {'error': 'No filename in upload'}, status=400)
|
||||
safe_name = _sanitize_upload_name(filename)
|
||||
suffix = Path(safe_name).suffix or '.webm'
|
||||
with tempfile.NamedTemporaryFile(prefix='webui-stt-', suffix=suffix, delete=False) as tmp:
|
||||
temp_path = tmp.name
|
||||
tmp.write(file_bytes)
|
||||
try:
|
||||
from tools.transcription_tools import transcribe_audio
|
||||
except ImportError:
|
||||
return j(handler, {'error': 'Speech-to-text is unavailable on this server'}, status=503)
|
||||
result = transcribe_audio(temp_path)
|
||||
if not result.get('success'):
|
||||
msg = str(result.get('error') or 'Transcription failed')
|
||||
status = 503 if 'unavailable' in msg.lower() or 'not configured' in msg.lower() else 400
|
||||
return j(handler, {'error': msg}, status=status)
|
||||
transcript = str(result.get('transcript') or '').strip()
|
||||
return j(handler, {'ok': True, 'transcript': transcript})
|
||||
except ValueError as e:
|
||||
return j(handler, {'error': str(e)}, status=400)
|
||||
except Exception:
|
||||
print('[webui] transcribe error: ' + _tb.format_exc(), flush=True)
|
||||
return j(handler, {'error': 'Transcription failed'}, status=500)
|
||||
finally:
|
||||
if temp_path:
|
||||
try:
|
||||
Path(temp_path).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
546
api/workspace.py
546
api/workspace.py
@@ -8,10 +8,14 @@ profile has its own workspace configuration. State files live at
|
||||
paths are used as fallback when no profile module is available.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import concurrent.futures
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from api.config import (
|
||||
WORKSPACES_FILE as _GLOBAL_WS_FILE,
|
||||
LAST_WORKSPACE_FILE as _GLOBAL_LW_FILE,
|
||||
@@ -37,7 +41,7 @@ def _profile_state_dir() -> Path:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
except ImportError:
|
||||
pass
|
||||
logger.debug("Failed to import profiles module, using global state dir")
|
||||
return _GLOBAL_WS_FILE.parent
|
||||
|
||||
|
||||
@@ -80,7 +84,7 @@ def _profile_default_workspace() -> str:
|
||||
if p.is_dir():
|
||||
return str(p)
|
||||
except (ImportError, Exception):
|
||||
pass
|
||||
logger.debug("Failed to load profile default workspace config")
|
||||
return str(_BOOT_DEFAULT_WORKSPACE)
|
||||
|
||||
|
||||
@@ -89,7 +93,6 @@ def _profile_default_workspace() -> str:
|
||||
def _clean_workspace_list(workspaces: list) -> list:
|
||||
"""Sanitize a workspace list:
|
||||
- Remove entries whose paths no longer exist on disk.
|
||||
- Remove entries that look like test artifacts (webui-mvp-test, test-workspace).
|
||||
- Remove entries whose paths live inside another profile's directory
|
||||
(e.g. ~/.hermes/profiles/X/... should not appear on a different profile).
|
||||
- Rename any entry whose name is literally 'default' to 'Home' (avoids
|
||||
@@ -102,18 +105,24 @@ def _clean_workspace_list(workspaces: list) -> list:
|
||||
path = w.get('path', '')
|
||||
name = w.get('name', '')
|
||||
p = Path(path).resolve() if path else Path('/')
|
||||
# Skip test artifacts
|
||||
if 'test-workspace' in path or 'webui-mvp-test' in path:
|
||||
continue
|
||||
# Skip paths that no longer exist
|
||||
if not p.is_dir():
|
||||
continue
|
||||
# Skip paths inside a named profile's directory (cross-profile leak)
|
||||
# Skip paths inside a DIFFERENT profile's directory (cross-profile leak).
|
||||
# Allow paths inside the CURRENT profile's own directory (e.g. test workspaces
|
||||
# created under ~/.hermes/profiles/webui/webui-mvp-test/).
|
||||
try:
|
||||
p.relative_to(hermes_profiles)
|
||||
continue # it IS under profiles/ — remove it
|
||||
# p is under ~/.hermes/profiles/ — only skip if it's under a DIFFERENT profile
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
own_profile_dir = get_active_hermes_home().resolve()
|
||||
p.relative_to(own_profile_dir)
|
||||
# p is under our own profile dir — keep it
|
||||
except (ValueError, Exception):
|
||||
continue # under profiles/ but not our own — cross-profile leak, skip
|
||||
except ValueError:
|
||||
pass
|
||||
pass # not under profiles/ at all — keep it
|
||||
# Rename confusing 'default' label to 'Home'
|
||||
if name.lower() == 'default':
|
||||
name = 'Home'
|
||||
@@ -156,10 +165,10 @@ def load_workspaces() -> list:
|
||||
json.dumps(cleaned, ensure_ascii=False, indent=2), encoding='utf-8'
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("Failed to persist cleaned workspace list")
|
||||
return cleaned or [{'path': _profile_default_workspace(), 'name': 'Home'}]
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("Failed to load workspaces from %s", ws_file)
|
||||
# No profile-local file yet.
|
||||
# For the DEFAULT profile: migrate from the legacy global file (one-time cleanup).
|
||||
# For NAMED profiles: always start clean with just their own workspace.
|
||||
@@ -176,7 +185,7 @@ def load_workspaces() -> list:
|
||||
return [{'path': _profile_default_workspace(), 'name': 'Home'}]
|
||||
|
||||
|
||||
def save_workspaces(workspaces: list):
|
||||
def save_workspaces(workspaces: list) -> None:
|
||||
ws_file = _workspaces_file()
|
||||
ws_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
ws_file.write_text(json.dumps(workspaces, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
@@ -190,7 +199,7 @@ def get_last_workspace() -> str:
|
||||
if p and Path(p).is_dir():
|
||||
return p
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("Failed to read last workspace from %s", lw_file)
|
||||
# Fallback: try global file
|
||||
if _GLOBAL_LW_FILE.exists():
|
||||
try:
|
||||
@@ -198,44 +207,490 @@ def get_last_workspace() -> str:
|
||||
if p and Path(p).is_dir():
|
||||
return p
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("Failed to read global last workspace")
|
||||
return _profile_default_workspace()
|
||||
|
||||
|
||||
def set_last_workspace(path: str):
|
||||
def set_last_workspace(path: str) -> None:
|
||||
try:
|
||||
lw_file = _last_workspace_file()
|
||||
lw_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
lw_file.write_text(str(path), encoding='utf-8')
|
||||
except Exception:
|
||||
logger.debug("Failed to set last workspace")
|
||||
|
||||
|
||||
def _safe_resolve(p: Path) -> Path:
|
||||
"""Path.resolve() that never raises — falls back to the input path on error."""
|
||||
try:
|
||||
return p.resolve()
|
||||
except (OSError, RuntimeError):
|
||||
return p
|
||||
|
||||
|
||||
# Per-user temp directories that sit nominally under a "system" prefix but are
|
||||
# actually user-writable scratch space. Workspaces registered here (e.g. by
|
||||
# pytest's ``tmp_path_factory`` on macOS, which uses ``/var/folders/<hash>/T/``)
|
||||
# must remain accepted even though their parent (``/var``) is blocked. These
|
||||
# carve-outs apply to BOTH workspace registration and runtime file ops so a
|
||||
# symlink target inside the carve-out is also reachable.
|
||||
_USER_TMP_PREFIXES: tuple[Path, ...] = (
|
||||
Path('/var/folders'), # macOS per-user tmp (literal form)
|
||||
Path('/private/var/folders'), # macOS per-user tmp (resolved form)
|
||||
Path('/var/tmp'), # Linux/macOS system-wide tmp (user-writable)
|
||||
Path('/private/var/tmp'), # macOS resolved form
|
||||
)
|
||||
|
||||
|
||||
def _workspace_blocked_roots() -> tuple[Path, ...]:
|
||||
"""System roots that must never be accepted as workspace candidates.
|
||||
|
||||
Returns both the literal path and its symlink-resolved canonical form,
|
||||
deduped. This matters on macOS where ``/etc``, ``/var``, and ``/tmp``
|
||||
are symlinks to ``/private/etc`` etc. Without the resolved forms,
|
||||
callers that pass a ``.resolve()``-d candidate (every caller does)
|
||||
would compare ``/private/etc`` against literal ``Path('/etc')`` and the
|
||||
``relative_to`` check would miss — letting ``/etc`` through as a
|
||||
registered workspace on macOS.
|
||||
|
||||
Carve-outs for legitimate user-tmp paths nominally under these roots
|
||||
(e.g. ``/var/folders/.../T/`` on macOS) are handled by
|
||||
:func:`_is_blocked_system_path`, not by exclusion from this list.
|
||||
"""
|
||||
_raw = (
|
||||
# Linux / macOS
|
||||
'/etc',
|
||||
'/usr',
|
||||
'/var',
|
||||
'/bin',
|
||||
'/sbin',
|
||||
'/boot',
|
||||
'/proc',
|
||||
'/sys',
|
||||
'/dev',
|
||||
'/lib',
|
||||
'/lib64',
|
||||
'/opt/homebrew',
|
||||
'/System',
|
||||
'/Library',
|
||||
)
|
||||
_seen: set[Path] = set()
|
||||
_out: list[Path] = []
|
||||
for _p in _raw:
|
||||
for _form in (Path(_p), _safe_resolve(Path(_p))):
|
||||
if _form not in _seen:
|
||||
_seen.add(_form)
|
||||
_out.append(_form)
|
||||
return tuple(_out)
|
||||
|
||||
|
||||
def _is_blocked_system_path(candidate: Path) -> bool:
|
||||
"""Return True if *candidate* falls under a blocked system root.
|
||||
|
||||
Honours :data:`_USER_TMP_PREFIXES` carve-outs so per-user tmp directories
|
||||
nominally under ``/var`` (``/var/folders`` on macOS, ``/var/tmp`` on
|
||||
Linux/macOS) remain valid workspace candidates and reachable file targets.
|
||||
"""
|
||||
for tmp in _USER_TMP_PREFIXES:
|
||||
if _is_within(candidate, tmp):
|
||||
return False
|
||||
for blocked in _workspace_blocked_roots():
|
||||
if _is_within(candidate, blocked):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _workspace_blocked_resolved_subtrees() -> tuple[Path, ...]:
|
||||
roots = list(_workspace_blocked_roots()) + [Path('/private/etc')]
|
||||
resolved: list[Path] = []
|
||||
for root in roots:
|
||||
try:
|
||||
p = root.expanduser().resolve()
|
||||
except Exception:
|
||||
p = root
|
||||
if p not in resolved:
|
||||
resolved.append(p)
|
||||
return tuple(resolved)
|
||||
|
||||
|
||||
def _workspace_blocked_exact_roots() -> tuple[Path, ...]:
|
||||
roots = [Path('/'), Path('/private/var')]
|
||||
for root in _workspace_blocked_roots():
|
||||
try:
|
||||
roots.append(root.expanduser().resolve())
|
||||
except Exception:
|
||||
roots.append(root)
|
||||
unique: list[Path] = []
|
||||
for root in roots:
|
||||
if root not in unique:
|
||||
unique.append(root)
|
||||
return tuple(unique)
|
||||
|
||||
|
||||
def _is_blocked_workspace_path(candidate: Path, raw_path: str | Path | None = None) -> bool:
|
||||
"""Return True when candidate points at a known OS/system directory.
|
||||
|
||||
Compare both the original spelling and the resolved path. This closes the
|
||||
macOS /etc -> /private/etc bypass without globally banning temporary pytest
|
||||
paths under /private/var/folders.
|
||||
"""
|
||||
raw = None
|
||||
if raw_path not in (None, ""):
|
||||
try:
|
||||
raw = Path(raw_path).expanduser()
|
||||
except Exception:
|
||||
raw = None
|
||||
|
||||
exact = _workspace_blocked_exact_roots()
|
||||
if candidate in exact or (raw is not None and raw in _workspace_blocked_roots()):
|
||||
return True
|
||||
|
||||
for tmp in _USER_TMP_PREFIXES:
|
||||
if _is_within(candidate, tmp) or (raw is not None and _is_within(raw, tmp)):
|
||||
return False
|
||||
|
||||
# Raw paths under literal roots (e.g. /etc/ssh, /var/db) are always blocked.
|
||||
if raw is not None:
|
||||
for blocked in _workspace_blocked_roots():
|
||||
if _is_within(raw, blocked):
|
||||
return True
|
||||
|
||||
# Resolved subtree checks catch symlink aliases such as /private/etc. The
|
||||
# macOS temp root /private/var/folders is intentionally allowed for pytest
|
||||
# and per-user temporary workspaces; other direct /private/var system data
|
||||
# such as /private/var/db and /private/var/log remains blocked.
|
||||
allowed_private_var = (Path('/private/var/folders'), Path('/private/var/tmp'))
|
||||
for blocked in _workspace_blocked_resolved_subtrees():
|
||||
if blocked == Path('/private/var'):
|
||||
if candidate == blocked:
|
||||
return True
|
||||
if any(_is_within(candidate, allowed) for allowed in allowed_private_var):
|
||||
continue
|
||||
if _is_within(candidate, blocked):
|
||||
return True
|
||||
continue
|
||||
if _is_within(candidate, blocked):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_within(path: Path, root: Path) -> bool:
|
||||
try:
|
||||
path.relative_to(root)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _trusted_workspace_roots() -> list[Path]:
|
||||
roots: list[Path] = []
|
||||
|
||||
def add(candidate: str | Path | None) -> None:
|
||||
if candidate in (None, ""):
|
||||
return
|
||||
try:
|
||||
p = Path(candidate).expanduser().resolve()
|
||||
except Exception:
|
||||
return
|
||||
if not p.exists() or not p.is_dir():
|
||||
return
|
||||
if _is_blocked_workspace_path(p, candidate):
|
||||
return
|
||||
if p not in roots:
|
||||
roots.append(p)
|
||||
|
||||
add(Path.home())
|
||||
add(_BOOT_DEFAULT_WORKSPACE)
|
||||
for w in load_workspaces():
|
||||
add(w.get("path"))
|
||||
roots.sort(key=lambda p: len(str(p)))
|
||||
return roots
|
||||
|
||||
|
||||
def list_workspace_suggestions(prefix: str = "", limit: int = 12) -> list[str]:
|
||||
"""Return workspace path suggestions under trusted roots only.
|
||||
|
||||
Suggestions are limited to directories under one of:
|
||||
- Path.home()
|
||||
- the boot default workspace
|
||||
- already-saved workspace roots
|
||||
|
||||
Arbitrary system prefixes return an empty list rather than an error so the
|
||||
UI can safely autocomplete while the user types.
|
||||
"""
|
||||
roots = _trusted_workspace_roots()
|
||||
if not roots:
|
||||
return []
|
||||
|
||||
raw = (prefix or "").strip()
|
||||
if not raw:
|
||||
return [str(p) for p in roots[:limit]]
|
||||
|
||||
if raw.startswith("~"):
|
||||
target = Path(raw).expanduser()
|
||||
elif Path(raw).is_absolute():
|
||||
target = Path(raw)
|
||||
else:
|
||||
target = Path.home() / raw
|
||||
|
||||
normalized = str(target)
|
||||
normalized_lower = normalized.lower()
|
||||
suggestions: list[str] = []
|
||||
|
||||
def add(path: Path) -> None:
|
||||
value = str(path)
|
||||
if value not in suggestions:
|
||||
suggestions.append(value)
|
||||
|
||||
# If the user is typing a partial trusted root like /Users/xuef..., suggest
|
||||
# the matching trusted roots without scanning arbitrary system parents.
|
||||
for root in roots:
|
||||
if str(root).lower().startswith(normalized_lower):
|
||||
add(root)
|
||||
|
||||
in_root = [
|
||||
root
|
||||
for root in roots
|
||||
if normalized == str(root) or normalized.startswith(str(root) + os.sep)
|
||||
]
|
||||
if not in_root:
|
||||
return suggestions[:limit]
|
||||
|
||||
anchor_root = max(in_root, key=lambda p: len(str(p)))
|
||||
ends_with_sep = raw.endswith(os.sep) or raw.endswith('/')
|
||||
parent = target if ends_with_sep else target.parent
|
||||
leaf = '' if ends_with_sep else target.name
|
||||
show_hidden = leaf.startswith('.')
|
||||
|
||||
try:
|
||||
parent_resolved = parent.expanduser().resolve()
|
||||
except Exception:
|
||||
return suggestions[:limit]
|
||||
|
||||
if not parent_resolved.exists() or not parent_resolved.is_dir():
|
||||
return suggestions[:limit]
|
||||
if not _is_within(parent_resolved, anchor_root):
|
||||
return suggestions[:limit]
|
||||
|
||||
leaf_lower = leaf.lower()
|
||||
try:
|
||||
children = sorted(parent_resolved.iterdir(), key=lambda p: p.name.lower())
|
||||
except OSError:
|
||||
return suggestions[:limit]
|
||||
|
||||
for child in children:
|
||||
if not child.is_dir():
|
||||
continue
|
||||
if child.name.startswith('.') and not show_hidden:
|
||||
continue
|
||||
if leaf_lower and not child.name.lower().startswith(leaf_lower):
|
||||
continue
|
||||
add(child.resolve())
|
||||
if len(suggestions) >= limit:
|
||||
break
|
||||
return suggestions[:limit]
|
||||
|
||||
|
||||
def resolve_trusted_workspace(path: str | Path | None = None) -> Path:
|
||||
"""Resolve and validate a workspace path.
|
||||
|
||||
A path is trusted if it satisfies at least one of:
|
||||
(A) It is under the user's home directory (Path.home()).
|
||||
Works cross-platform: ~/... on Linux/macOS, C:\\Users\\... on Windows.
|
||||
(B) It is already in the profile's saved workspace list.
|
||||
This covers self-hosted deployments where workspaces live outside home
|
||||
(e.g. /data/projects, /opt/workspace) — once a workspace is saved by
|
||||
an admin, it can be reused without re-validation.
|
||||
|
||||
Additionally enforced regardless of (A)/(B):
|
||||
1. The path must exist.
|
||||
2. The path must be a directory.
|
||||
3. The path must not be a known system root (/etc, /usr, /var, /bin, /sbin,
|
||||
/boot, /proc, /sys, /dev, /root on Linux/macOS; Windows system dirs).
|
||||
This prevents even admin-saved workspaces from pointing at OS internals.
|
||||
|
||||
None/empty path falls back to the boot-time DEFAULT_WORKSPACE, which is always
|
||||
trusted (it was validated at server startup).
|
||||
"""
|
||||
if path in (None, ""):
|
||||
return Path(_BOOT_DEFAULT_WORKSPACE).expanduser().resolve()
|
||||
|
||||
candidate = Path(path).expanduser().resolve()
|
||||
|
||||
if not candidate.exists():
|
||||
raise ValueError(f"Path does not exist: {candidate}")
|
||||
if not candidate.is_dir():
|
||||
raise ValueError(f"Path is not a directory: {candidate}")
|
||||
|
||||
# (A) Trusted if under the user's home directory — cross-platform via Path.home()
|
||||
# Must be checked before system roots to allow symlinks like /var/home.
|
||||
_home = Path.home().resolve()
|
||||
if _home != Path("/"):
|
||||
try:
|
||||
candidate.relative_to(_home)
|
||||
return candidate
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Block known system roots and their children.
|
||||
if _is_blocked_workspace_path(candidate, path):
|
||||
raise ValueError(f"Path points to a system directory: {candidate}")
|
||||
|
||||
# (B) Trusted if already in the saved workspace list — covers non-home installs
|
||||
try:
|
||||
saved = load_workspaces()
|
||||
saved_paths = {Path(w["path"]).resolve() for w in saved if w.get("path")}
|
||||
if candidate in saved_paths:
|
||||
return candidate
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# (C) Trusted if it is equal to or under the boot-time DEFAULT_WORKSPACE.
|
||||
# In Docker deployments HERMES_WEBUI_DEFAULT_WORKSPACE is often set to a
|
||||
# volume mount outside the user's home (e.g. /data/workspace). That path
|
||||
# was already validated at server startup, so any sub-path of it is safe
|
||||
# without requiring the user to add it to the workspace list manually.
|
||||
try:
|
||||
boot_default = Path(_BOOT_DEFAULT_WORKSPACE).expanduser().resolve()
|
||||
candidate.relative_to(boot_default)
|
||||
return candidate
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
raise ValueError(
|
||||
f"Path is outside the user home directory, not in the saved workspace "
|
||||
f"list, and not under the default workspace: {candidate}. "
|
||||
f"Add it via Settings → Workspaces first."
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
def validate_workspace_to_add(path: str) -> Path:
|
||||
"""Validate a path for *adding* to the workspace list (less restrictive than resolve_trusted_workspace).
|
||||
|
||||
When a user explicitly adds a new workspace path, we trust their intent — they
|
||||
have console or filesystem access to that path and are consciously registering it.
|
||||
We only block: non-existent paths, non-directories, and known system roots.
|
||||
|
||||
The stricter ``resolve_trusted_workspace`` is used when *using* an existing workspace
|
||||
(file reads/writes) to prevent path traversal after the list is built.
|
||||
"""
|
||||
candidate = Path(path).expanduser().resolve()
|
||||
|
||||
if not candidate.exists():
|
||||
raise ValueError(f"Path does not exist: {candidate}")
|
||||
if not candidate.is_dir():
|
||||
raise ValueError(f"Path is not a directory: {candidate}")
|
||||
|
||||
# Home directory is always trusted regardless of where it lives on disk
|
||||
# (e.g. /var/home/... on systemd-homed Fedora/RHEL).
|
||||
_home = Path.home().resolve()
|
||||
if _home != Path("/") and _is_within(candidate, _home):
|
||||
return candidate
|
||||
|
||||
# Block known system roots and their immediate children.
|
||||
if _is_blocked_workspace_path(candidate, path):
|
||||
raise ValueError(f"Path points to a system directory: {candidate}")
|
||||
|
||||
return candidate
|
||||
|
||||
def safe_resolve_ws(root: Path, requested: str) -> Path:
|
||||
"""Resolve a relative path inside a workspace root, raising ValueError on traversal."""
|
||||
resolved = (root / requested).resolve()
|
||||
resolved.relative_to(root.resolve())
|
||||
"""Resolve a relative path inside a workspace root, raising ValueError on traversal.
|
||||
|
||||
Symlinks whose *unresolved* path is within the workspace root are allowed —
|
||||
the user placed them there intentionally. Only raw ``..`` traversal outside
|
||||
the root is blocked.
|
||||
"""
|
||||
import os
|
||||
unresolved = root / requested
|
||||
resolved = unresolved.resolve()
|
||||
# Fast path: resolved path is inside root (covers most cases)
|
||||
try:
|
||||
resolved.relative_to(root.resolve())
|
||||
return resolved
|
||||
except ValueError:
|
||||
pass
|
||||
# Symlink path: normalize '..' (without following symlinks) and check
|
||||
# os.path.normpath collapses '..' but does NOT follow symlinks.
|
||||
norm = Path(os.path.normpath(str(unresolved)))
|
||||
try:
|
||||
norm.relative_to(root)
|
||||
except ValueError:
|
||||
raise ValueError(f"Path traversal blocked: {requested}")
|
||||
# Symlink points outside workspace root — additionally block system directories.
|
||||
# Even if the user placed the symlink intentionally, prevent reads from
|
||||
# /etc, /proc, /sys, /dev and other blocked roots (LLM agents can call
|
||||
# read_file_content via tool calls, not just human users).
|
||||
if _is_blocked_system_path(resolved):
|
||||
raise ValueError(f"Path traversal blocked (system dir): {requested}")
|
||||
return resolved
|
||||
|
||||
|
||||
def list_dir(workspace: Path, rel='.'):
|
||||
def list_dir(workspace: Path, rel: str='.'):
|
||||
target = safe_resolve_ws(workspace, rel)
|
||||
if not target.is_dir():
|
||||
raise FileNotFoundError(f"Not a directory: {rel}")
|
||||
ws_resolved = workspace.resolve()
|
||||
entries = []
|
||||
for item in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
|
||||
entries.append({
|
||||
'name': item.name,
|
||||
'path': str(item.relative_to(workspace)),
|
||||
'type': 'dir' if item.is_dir() else 'file',
|
||||
'size': item.stat().st_size if item.is_file() else None,
|
||||
})
|
||||
for item in sorted(target.iterdir(), key=lambda p: (not p.is_symlink(), p.is_file(), p.name.lower())):
|
||||
if item.is_symlink():
|
||||
# Resolve the symlink target and check if it stays within workspace
|
||||
try:
|
||||
link_target = item.resolve()
|
||||
except OSError:
|
||||
continue
|
||||
# Cycle detection: skip if symlink points back to current dir,
|
||||
# workspace root, or any ancestor of current dir.
|
||||
# This must run REGARDLESS of whether target is inside workspace.
|
||||
if (link_target == target.resolve() or link_target == target
|
||||
or link_target == ws_resolved):
|
||||
continue
|
||||
try:
|
||||
target.resolve().relative_to(link_target)
|
||||
# target is under link_target — link_target is an ancestor → cycle
|
||||
continue
|
||||
except ValueError:
|
||||
pass
|
||||
# Block symlinks that resolve to system directories.
|
||||
if _is_blocked_system_path(link_target):
|
||||
continue
|
||||
is_dir = link_target.is_dir()
|
||||
# Keep the display path relative to workspace (don't follow the link)
|
||||
display_path = str(Path(item.name))
|
||||
if rel and rel != '.':
|
||||
display_path = rel + '/' + display_path
|
||||
entry = {
|
||||
'name': item.name,
|
||||
'path': display_path,
|
||||
'type': 'symlink',
|
||||
'target': str(link_target),
|
||||
'is_dir': is_dir,
|
||||
}
|
||||
if not is_dir:
|
||||
try:
|
||||
entry['size'] = link_target.stat().st_size
|
||||
except OSError:
|
||||
entry['size'] = None
|
||||
entries.append(entry)
|
||||
else:
|
||||
# Use rel-based path so entries under symlink targets (outside
|
||||
# the workspace root) still get a valid workspace-relative path.
|
||||
entry_path = item.name
|
||||
if rel and rel != '.':
|
||||
entry_path = rel + '/' + item.name
|
||||
entries.append({
|
||||
'name': item.name,
|
||||
'path': entry_path,
|
||||
'type': 'dir' if item.is_dir() else 'file',
|
||||
'size': item.stat().st_size if item.is_file() else None,
|
||||
})
|
||||
if len(entries) >= 200:
|
||||
break
|
||||
return entries
|
||||
|
||||
|
||||
def read_file_content(workspace: Path, rel: str):
|
||||
def read_file_content(workspace: Path, rel: str) -> dict:
|
||||
target = safe_resolve_ws(workspace, rel)
|
||||
if not target.is_file():
|
||||
raise FileNotFoundError(f"Not a file: {rel}")
|
||||
@@ -267,22 +722,35 @@ def git_info_for_workspace(workspace: Path) -> dict:
|
||||
branch = _run_git(['rev-parse', '--abbrev-ref', 'HEAD'], workspace)
|
||||
if branch is None:
|
||||
return None
|
||||
# Status counts
|
||||
status_out = _run_git(['status', '--porcelain'], workspace) or ''
|
||||
lines = [l for l in status_out.splitlines() if l]
|
||||
# git status --porcelain: XY format where X=index, Y=worktree
|
||||
modified = sum(1 for l in lines if len(l) >= 2 and (l[0] in 'MAR' or l[1] in 'MAR'))
|
||||
untracked = sum(1 for l in lines if l.startswith('??'))
|
||||
dirty = len(lines)
|
||||
# Ahead/behind
|
||||
ahead = _run_git(['rev-list', '--count', '@{u}..HEAD'], workspace)
|
||||
behind = _run_git(['rev-list', '--count', 'HEAD..@{u}'], workspace)
|
||||
# Run the remaining git commands in parallel via threads — they are
|
||||
# independent subprocess calls and together can take 50-200ms when run
|
||||
# serially. Threading is safe here because each call blocks only on the
|
||||
# subprocess pipe, not on the GIL.
|
||||
def _ahead():
|
||||
r = _run_git(['rev-list', '--count', '@{u}..HEAD'], workspace)
|
||||
return int(r) if r and r.isdigit() else 0
|
||||
def _behind():
|
||||
r = _run_git(['rev-list', '--count', 'HEAD..@{u}'], workspace)
|
||||
return int(r) if r and r.isdigit() else 0
|
||||
def _status():
|
||||
out = _run_git(['status', '--porcelain'], workspace) or ''
|
||||
lines = [l for l in out.splitlines() if l]
|
||||
modified = sum(1 for l in lines if len(l) >= 2 and (l[0] in 'MAR' or l[1] in 'MAR'))
|
||||
untracked = sum(1 for l in lines if l.startswith('??'))
|
||||
return len(lines), modified, untracked
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:
|
||||
f_status = pool.submit(_status)
|
||||
f_ahead = pool.submit(_ahead)
|
||||
f_behind = pool.submit(_behind)
|
||||
dirty, modified, untracked = f_status.result()
|
||||
ahead = f_ahead.result()
|
||||
behind = f_behind.result()
|
||||
return {
|
||||
'branch': branch,
|
||||
'dirty': dirty,
|
||||
'modified': modified,
|
||||
'untracked': untracked,
|
||||
'ahead': int(ahead) if ahead and ahead.isdigit() else 0,
|
||||
'behind': int(behind) if behind and behind.isdigit() else 0,
|
||||
'ahead': ahead,
|
||||
'behind': behind,
|
||||
'is_git': True,
|
||||
}
|
||||
|
||||
276
bootstrap.py
Normal file
276
bootstrap.py
Normal file
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env python3
|
||||
"""One-shot bootstrap launcher for Hermes Web UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import venv
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
INSTALLER_URL = "https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh"
|
||||
REPO_ROOT = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def _load_repo_dotenv() -> None:
|
||||
"""Load REPO_ROOT/.env into os.environ.
|
||||
|
||||
Mirrors what start.sh does via ``set -a; source .env`` so that running
|
||||
``python3 bootstrap.py`` directly behaves identically to ``./start.sh``.
|
||||
Variables are set unconditionally (matching shell source semantics), so a
|
||||
value in .env overrides one already present in the shell environment.
|
||||
To keep a CLI-supplied value, unset it from .env or launch via start.sh
|
||||
and override there.
|
||||
|
||||
Only loads the webui repo .env — not ~/.hermes/.env, which the server
|
||||
loads independently at startup for provider credentials.
|
||||
|
||||
Note: does not handle the ``export FOO=bar`` prefix — strip ``export``
|
||||
from .env values if copy-pasting from a shell rc file.
|
||||
"""
|
||||
env_path = REPO_ROOT / ".env"
|
||||
if not env_path.exists():
|
||||
return
|
||||
try:
|
||||
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
k = k.strip()
|
||||
# Strip optional 'export' prefix (common in copy-pasted shell snippets)
|
||||
if k.startswith("export "):
|
||||
k = k[7:].strip()
|
||||
v = v.strip().strip('"').strip("'")
|
||||
if k:
|
||||
os.environ[k] = v
|
||||
except Exception as exc:
|
||||
import sys as _sys
|
||||
print(f"[bootstrap] Warning: could not load .env — {exc}", file=_sys.stderr)
|
||||
|
||||
|
||||
# Side effect: loads REPO_ROOT/.env into os.environ on import.
|
||||
# Must run before DEFAULT_HOST / DEFAULT_PORT so os.getenv() picks up
|
||||
# values from .env even when bootstrap.py is invoked directly (not via start.sh).
|
||||
_load_repo_dotenv()
|
||||
|
||||
DEFAULT_HOST = os.getenv("HERMES_WEBUI_HOST", "127.0.0.1")
|
||||
DEFAULT_PORT = int(os.getenv("HERMES_WEBUI_PORT", "8787"))
|
||||
# Set HERMES_WEBUI_SKIP_ONBOARDING=1 to bypass the first-run wizard when
|
||||
# the environment is already fully configured (e.g. managed hosting).
|
||||
|
||||
|
||||
def info(msg: str) -> None:
|
||||
print(f"[bootstrap] {msg}", flush=True)
|
||||
|
||||
|
||||
def is_wsl() -> bool:
|
||||
if platform.system() != "Linux":
|
||||
return False
|
||||
release = platform.release().lower()
|
||||
return (
|
||||
"microsoft" in release or "wsl" in release or bool(os.getenv("WSL_DISTRO_NAME"))
|
||||
)
|
||||
|
||||
|
||||
def ensure_supported_platform() -> None:
|
||||
if platform.system() == "Windows" and not is_wsl():
|
||||
raise RuntimeError(
|
||||
"Native Windows is not supported for this bootstrap yet. "
|
||||
"Please run it from Linux, macOS, or inside WSL2."
|
||||
)
|
||||
|
||||
|
||||
def discover_agent_dir() -> Path | None:
|
||||
home = Path(os.getenv("HERMES_HOME", str(Path.home() / ".hermes"))).expanduser()
|
||||
candidates = [
|
||||
os.getenv("HERMES_WEBUI_AGENT_DIR", ""),
|
||||
str(home / "hermes-agent"),
|
||||
str(REPO_ROOT.parent / "hermes-agent"),
|
||||
str(Path.home() / ".hermes" / "hermes-agent"),
|
||||
str(Path.home() / "hermes-agent"),
|
||||
]
|
||||
for raw in candidates:
|
||||
if not raw:
|
||||
continue
|
||||
candidate = Path(raw).expanduser().resolve()
|
||||
if candidate.exists() and (candidate / "run_agent.py").exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def discover_launcher_python(agent_dir: Path | None) -> str:
|
||||
env_python = os.getenv("HERMES_WEBUI_PYTHON")
|
||||
if env_python:
|
||||
return env_python
|
||||
if agent_dir:
|
||||
for rel in ("venv/bin/python", "venv/Scripts/python.exe", ".venv/bin/python", ".venv/Scripts/python.exe"):
|
||||
candidate = agent_dir / rel
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
for rel in (".venv/bin/python", ".venv/Scripts/python.exe"):
|
||||
candidate = REPO_ROOT / rel
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
return shutil.which("python3") or shutil.which("python") or sys.executable
|
||||
|
||||
|
||||
def ensure_python_has_webui_deps(python_exe: str) -> str:
|
||||
check = subprocess.run(
|
||||
[python_exe, "-c", "import yaml"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if check.returncode == 0:
|
||||
return python_exe
|
||||
|
||||
venv_dir = REPO_ROOT / ".venv"
|
||||
venv_python = venv_dir / (
|
||||
"Scripts/python.exe" if platform.system() == "Windows" else "bin/python"
|
||||
)
|
||||
if not venv_python.exists():
|
||||
info(f"Creating local virtualenv at {venv_dir}")
|
||||
venv.EnvBuilder(with_pip=True).create(venv_dir)
|
||||
|
||||
info("Installing WebUI dependencies into local virtualenv")
|
||||
subprocess.run(
|
||||
[str(venv_python), "-m", "pip", "install", "--quiet", "--upgrade", "pip"],
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
str(venv_python),
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--quiet",
|
||||
"-r",
|
||||
str(REPO_ROOT / "requirements.txt"),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
return str(venv_python)
|
||||
|
||||
|
||||
def hermes_command_exists() -> bool:
|
||||
return shutil.which("hermes") is not None
|
||||
|
||||
|
||||
def install_hermes_agent() -> None:
|
||||
info(f"Hermes Agent not found. Attempting install via {INSTALLER_URL}")
|
||||
subprocess.run(
|
||||
["/bin/bash", "-lc", f"curl -fsSL {INSTALLER_URL} | bash"], check=True
|
||||
)
|
||||
|
||||
|
||||
def wait_for_health(url: str, timeout: float = 25.0) -> bool:
|
||||
deadline = time.time() + timeout
|
||||
# Validate URL scheme to prevent file:// and other dangerous schemes
|
||||
if not url.startswith(("http://", "https://")):
|
||||
raise ValueError(f"Invalid health check URL: {url}")
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=2) as response: # nosec B310
|
||||
if b'"status": "ok"' in response.read():
|
||||
return True
|
||||
except Exception:
|
||||
time.sleep(0.4)
|
||||
return False
|
||||
|
||||
|
||||
def open_browser(url: str) -> None:
|
||||
try:
|
||||
webbrowser.open(url)
|
||||
except Exception as exc:
|
||||
info(f"Could not open browser automatically: {exc}")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Bootstrap Hermes Web UI onboarding.")
|
||||
parser.add_argument("port", nargs="?", type=int, default=DEFAULT_PORT)
|
||||
parser.add_argument("--host", default=DEFAULT_HOST)
|
||||
parser.add_argument(
|
||||
"--no-browser",
|
||||
action="store_true",
|
||||
help="Do not open a browser tab automatically.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-agent-install",
|
||||
action="store_true",
|
||||
help="Fail instead of attempting the official Hermes installer.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
ensure_supported_platform()
|
||||
|
||||
agent_dir = discover_agent_dir()
|
||||
if not agent_dir and not hermes_command_exists():
|
||||
if args.skip_agent_install:
|
||||
raise RuntimeError(
|
||||
"Hermes Agent was not found and auto-install was disabled."
|
||||
)
|
||||
install_hermes_agent()
|
||||
agent_dir = discover_agent_dir()
|
||||
|
||||
python_exe = ensure_python_has_webui_deps(discover_launcher_python(agent_dir))
|
||||
state_dir = Path(
|
||||
os.getenv("HERMES_WEBUI_STATE_DIR", str(Path.home() / ".hermes" / "webui"))
|
||||
).expanduser()
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_path = state_dir / f"bootstrap-{args.port}.log"
|
||||
|
||||
env = os.environ.copy()
|
||||
env["HERMES_WEBUI_HOST"] = args.host
|
||||
env["HERMES_WEBUI_PORT"] = str(args.port)
|
||||
env.setdefault("HERMES_WEBUI_STATE_DIR", str(state_dir))
|
||||
if agent_dir:
|
||||
env["HERMES_WEBUI_AGENT_DIR"] = str(agent_dir)
|
||||
|
||||
info(f"Starting Hermes Web UI on http://{args.host}:{args.port}")
|
||||
with log_path.open("ab") as log_file:
|
||||
proc = subprocess.Popen(
|
||||
[python_exe, str(REPO_ROOT / "server.py")],
|
||||
cwd=str(agent_dir or REPO_ROOT),
|
||||
env=env,
|
||||
stdout=log_file,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
health_url = f"http://{args.host}:{args.port}/health"
|
||||
if not wait_for_health(health_url):
|
||||
raise RuntimeError(
|
||||
f"Web UI did not become healthy at {health_url}. "
|
||||
f"Check the log at {log_path}. Server PID: {proc.pid}"
|
||||
)
|
||||
|
||||
app_url = (
|
||||
f"http://localhost:{args.port}"
|
||||
if args.host in ("127.0.0.1", "localhost")
|
||||
else f"http://{args.host}:{args.port}"
|
||||
)
|
||||
info(f"Web UI is ready: {app_url}")
|
||||
info(f"Log file: {log_path}")
|
||||
if not args.no_browser:
|
||||
open_browser(app_url)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as exc:
|
||||
print(f"[bootstrap] ERROR: {exc}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
121
docker-compose.three-container.yml
Normal file
121
docker-compose.three-container.yml
Normal file
@@ -0,0 +1,121 @@
|
||||
# Three-container Docker Compose: Hermes Agent + Dashboard + WebUI
|
||||
#
|
||||
# This extends the two-container setup with the Hermes Dashboard for
|
||||
# monitoring agent activity, sessions, and resource usage.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.three-container.yml up -d
|
||||
#
|
||||
# Services:
|
||||
# hermes-agent — gateway API on port 8642 (CLI, Telegram, cron, tools)
|
||||
# hermes-dashboard — monitoring dashboard on port 9119
|
||||
# hermes-webui — browser chat interface on port 8787
|
||||
#
|
||||
# All three share the same hermes-home volume so config, sessions,
|
||||
# skills, and memory are consistent across all surfaces.
|
||||
#
|
||||
# NOTE ON VOLUMES:
|
||||
# This file uses named Docker volumes (hermes-home, hermes-agent-src) which
|
||||
# work out of the box. If you prefer bind mounts (e.g. to an existing directory),
|
||||
# see the two-container compose file for a bind-mount example.
|
||||
# When using bind mounts, ALL containers must mount the same host path.
|
||||
|
||||
services:
|
||||
hermes-agent:
|
||||
image: nousresearch/hermes-agent:latest
|
||||
container_name: hermes-agent
|
||||
command: gateway run
|
||||
ports:
|
||||
- "127.0.0.1:8642:8642"
|
||||
volumes:
|
||||
# Persist config, state, sessions, skills, memory across restarts
|
||||
- hermes-home:/home/hermes/.hermes
|
||||
# Expose agent source so the WebUI can install dependencies from it
|
||||
- hermes-agent-src:/opt/hermes
|
||||
environment:
|
||||
- HERMES_HOME=/home/hermes/.hermes
|
||||
- HERMES_UID=${HERMES_UID:-10000}
|
||||
- HERMES_GID=${HERMES_GID:-10000}
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 4G
|
||||
cpus: "2.0"
|
||||
networks:
|
||||
- hermes-net
|
||||
|
||||
hermes-dashboard:
|
||||
image: nousresearch/hermes-agent:latest
|
||||
container_name: hermes-dashboard
|
||||
command: dashboard --host 0.0.0.0 --insecure
|
||||
ports:
|
||||
- "127.0.0.1:9119:9119"
|
||||
volumes:
|
||||
- hermes-home:/home/hermes/.hermes
|
||||
environment:
|
||||
- HERMES_HOME=/home/hermes/.hermes
|
||||
- HERMES_UID=${HERMES_UID:-10000}
|
||||
- HERMES_GID=${HERMES_GID:-10000}
|
||||
# Dashboard connects to the gateway for health/session data
|
||||
- GATEWAY_HEALTH_URL=http://hermes-agent:8642
|
||||
depends_on:
|
||||
- hermes-agent
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: "0.5"
|
||||
networks:
|
||||
- hermes-net
|
||||
|
||||
hermes-webui:
|
||||
image: ghcr.io/nesquena/hermes-webui:latest
|
||||
container_name: hermes-webui
|
||||
depends_on:
|
||||
- hermes-agent
|
||||
ports:
|
||||
# Expose on localhost only. Remove 127.0.0.1: to expose on all interfaces
|
||||
# (set HERMES_WEBUI_PASSWORD if doing so).
|
||||
- "127.0.0.1:8787:8787"
|
||||
volumes:
|
||||
# Same hermes home as the agent — shares config, sessions, state
|
||||
- hermes-home:/home/hermeswebui/.hermes
|
||||
# Agent source mounted where docker_init.bash expects it.
|
||||
# At startup the init script runs:
|
||||
# uv pip install /home/hermeswebui/.hermes/hermes-agent
|
||||
# which installs the agent and all its Python dependencies.
|
||||
- hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent
|
||||
# Workspace directory — browse and edit files from the WebUI.
|
||||
# Adapt the host path to your project directory.
|
||||
- ${HERMES_WORKSPACE:-~/workspace}:/workspace
|
||||
environment:
|
||||
- HERMES_WEBUI_HOST=0.0.0.0
|
||||
- HERMES_WEBUI_PORT=8787
|
||||
- HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui
|
||||
# Match your host user's UID/GID for correct file permissions.
|
||||
# Run `id -u` and `id -g` to find your values.
|
||||
# On macOS, UIDs start at 501 (not 1000) — set these in a .env file:
|
||||
# echo "UID=$(id -u)" >> .env && echo "GID=$(id -g)" >> .env
|
||||
- WANTED_UID=${UID:-1000}
|
||||
- WANTED_GID=${GID:-1000}
|
||||
# NOTE: When using bind-mount volumes shared across containers, ALL containers
|
||||
# that write to the same host directory must run as the same UID/GID.
|
||||
# If hermes-agent initialises the state dir as root (UID 0), hermes-webui
|
||||
# will get a PermissionError accessing those paths — including a crash on every
|
||||
# HTTP request if the auth signing-key file is unreadable. Either set WANTED_UID
|
||||
# to match the agent container's UID, or use a named Docker volume (preferred).
|
||||
# Optional: set a password for remote access
|
||||
# - HERMES_WEBUI_PASSWORD=your-secret-password
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- hermes-net
|
||||
|
||||
networks:
|
||||
hermes-net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
hermes-home:
|
||||
hermes-agent-src:
|
||||
95
docker-compose.two-container.yml
Normal file
95
docker-compose.two-container.yml
Normal file
@@ -0,0 +1,95 @@
|
||||
# Two-container Docker Compose: Hermes Agent + Hermes WebUI
|
||||
#
|
||||
# This runs the agent and web UI in separate containers connected via
|
||||
# shared volumes. The WebUI installs the agent's Python dependencies
|
||||
# at startup from the shared agent source volume.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.two-container.yml up -d
|
||||
#
|
||||
# The agent container runs the gateway (CLI, Telegram, cron, etc.).
|
||||
# The WebUI container serves the browser interface on port 8787.
|
||||
# Both share ~/.hermes for config, sessions, and state.
|
||||
#
|
||||
# NOTE ON VOLUMES:
|
||||
# This file uses named Docker volumes (hermes-home, hermes-agent-src) which
|
||||
# work out of the box. If you prefer bind mounts (e.g. to an existing directory),
|
||||
# replace the named volumes at the bottom. Example for hermes-agent-src:
|
||||
#
|
||||
# hermes-agent-src:
|
||||
# driver: local
|
||||
# driver_opts:
|
||||
# type: none
|
||||
# o: bind
|
||||
# device: /opt/hermes-agent
|
||||
#
|
||||
# When using bind mounts, BOTH containers must mount the same host path.
|
||||
# The agent exposes source at /opt/hermes, the WebUI reads it from
|
||||
# /home/hermeswebui/.hermes/hermes-agent — as long as both point to the
|
||||
# same host directory, the paths align correctly.
|
||||
|
||||
services:
|
||||
hermes-agent:
|
||||
image: nousresearch/hermes-agent:latest
|
||||
container_name: hermes-agent
|
||||
command: gateway run
|
||||
ports:
|
||||
# Gateway API — exposed on localhost only.
|
||||
# Other containers on hermes-net reach it via http://hermes-agent:8642.
|
||||
# Remove 127.0.0.1: to expose on the host network (e.g. for remote clients).
|
||||
- "127.0.0.1:8642:8642"
|
||||
volumes:
|
||||
# Persist config, state, sessions, skills, memory across restarts
|
||||
- hermes-home:/home/hermes/.hermes
|
||||
# Expose agent source so the WebUI can install dependencies from it
|
||||
- hermes-agent-src:/opt/hermes
|
||||
environment:
|
||||
- HERMES_HOME=/home/hermes/.hermes
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- hermes-net
|
||||
|
||||
hermes-webui:
|
||||
image: ghcr.io/nesquena/hermes-webui:latest
|
||||
container_name: hermes-webui
|
||||
depends_on:
|
||||
- hermes-agent
|
||||
ports:
|
||||
- "127.0.0.1:8787:8787"
|
||||
volumes:
|
||||
# Same hermes home as the agent — shares config, sessions, state
|
||||
- hermes-home:/home/hermeswebui/.hermes
|
||||
# Agent source mounted where docker_init.bash expects it.
|
||||
# At startup the init script runs:
|
||||
# uv pip install /home/hermeswebui/.hermes/hermes-agent
|
||||
# which installs the agent and all its Python dependencies.
|
||||
- hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent
|
||||
# Workspace directory — browse and edit files from the WebUI.
|
||||
# Adapt the host path to your project directory.
|
||||
# Override with: HERMES_WORKSPACE=/your/path docker compose up
|
||||
- ${HERMES_WORKSPACE:-~/workspace}:/workspace
|
||||
environment:
|
||||
- HERMES_WEBUI_HOST=0.0.0.0
|
||||
- HERMES_WEBUI_PORT=8787
|
||||
- HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui
|
||||
# Match your host user's UID/GID for correct file permissions.
|
||||
# In two-container setups the WebUI auto-detects UID/GID from the shared
|
||||
# hermes-home volume, but you can override explicitly if needed (#668):
|
||||
# Run `id -u` and `id -g` to find your values.
|
||||
# On macOS, UIDs start at 501 — set these in a .env file:
|
||||
# echo "UID=$(id -u)" >> .env && echo "GID=$(id -g)" >> .env
|
||||
- WANTED_UID=${UID:-1000}
|
||||
- WANTED_GID=${GID:-1000}
|
||||
# Optional: set a password for remote access
|
||||
# - HERMES_WEBUI_PASSWORD=***
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- hermes-net
|
||||
|
||||
networks:
|
||||
hermes-net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
hermes-home:
|
||||
hermes-agent-src:
|
||||
@@ -4,19 +4,34 @@ services:
|
||||
hermes-webui:
|
||||
build: .
|
||||
ports:
|
||||
# select only one; use 127.0.0.1 version to expose to localhost only
|
||||
- "127.0.0.1:8787:8787"
|
||||
# - "8787:8787"
|
||||
volumes:
|
||||
# Persist session data, settings, and projects across restarts
|
||||
- hermes-data:/data
|
||||
# Mount hermes home for agent features and profile management
|
||||
- ${HERMES_HOME:-${HOME}/.hermes}:/root/.hermes
|
||||
# Mount your Hermes home directory into the container.
|
||||
# The default (${HOME}/.hermes) works on both macOS (/Users/<you>/.hermes)
|
||||
# and Linux (/home/<you>/.hermes) — no change needed for standard installs.
|
||||
# Only set HERMES_HOME explicitly if your .hermes lives somewhere non-standard.
|
||||
# macOS note: set UID and GID below to match your user ID (run `id -u` and `id -g`).
|
||||
- ${HERMES_HOME:-${HOME}/.hermes}:/home/hermeswebui/.hermes
|
||||
# Your workspace directory shown on first launch (adapt if yours is different, the container will use the mounted /workspace)
|
||||
- ${HERMES_WORKSPACE:-${HOME}/workspace}:/workspace
|
||||
environment:
|
||||
# Set to your host user ID: run `id -u` and `id -g` to find them.
|
||||
# On macOS, UIDs start at 501 (not 1000), so set UID and GID in a .env file:
|
||||
# echo "UID=$(id -u)" >> .env
|
||||
# echo "GID=$(id -g)" >> .env
|
||||
# Without this, the container may not be able to read your mounted files.
|
||||
- WANTED_UID=${UID:-1000}
|
||||
- WANTED_GID=${GID:-1000}
|
||||
# Required: bind address and port
|
||||
- HERMES_WEBUI_HOST=0.0.0.0
|
||||
- HERMES_WEBUI_PORT=8787
|
||||
- HERMES_WEBUI_STATE_DIR=/data
|
||||
# Where to store sessions, workspaces, and other state (default: ~/.hermes/webui)
|
||||
- HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui
|
||||
# Default workspace directory shown on first launch
|
||||
# - HERMES_WEBUI_DEFAULT_WORKSPACE=/workspace
|
||||
# Optional: set a password for remote access
|
||||
# - HERMES_WEBUI_PASSWORD=your-secret-password
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
hermes-data:
|
||||
|
||||
345
docker_init.bash
Normal file
345
docker_init.bash
Normal file
@@ -0,0 +1,345 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
error_exit() {
|
||||
echo -n "!! ERROR: "
|
||||
echo $*
|
||||
echo "!! Exiting script (ID: $$)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
ok_exit() {
|
||||
echo $*
|
||||
echo "++ Exiting script (ID: $$)"
|
||||
exit 0
|
||||
}
|
||||
|
||||
## Environment variables loaded when passing environment variables from user to user
|
||||
# Ignore list: variables to ignore when loading environment variables from user to user
|
||||
export ENV_IGNORELIST="HOME PWD USER SHLVL TERM OLDPWD SHELL _ SUDO_COMMAND HOSTNAME LOGNAME MAIL SUDO_GID SUDO_UID SUDO_USER CHECK_NV_CUDNN_VERSION VIRTUAL_ENV VIRTUAL_ENV_PROMPT ENV_IGNORELIST ENV_OBFUSCATE_PART"
|
||||
# Obfuscate part: part of the key to obfuscate when loading environment variables from user to user, ex: HF_TOKEN, ...
|
||||
export ENV_OBFUSCATE_PART="TOKEN API KEY"
|
||||
|
||||
# Check for ENV_IGNORELIST and ENV_OBFUSCATE_PART
|
||||
if [ -z "${ENV_IGNORELIST+x}" ]; then error_exit "ENV_IGNORELIST not set"; fi
|
||||
if [ -z "${ENV_OBFUSCATE_PART+x}" ]; then error_exit "ENV_OBFUSCATE_PART not set"; fi
|
||||
|
||||
whoami=`whoami`
|
||||
script_dir=$(dirname $0)
|
||||
script_name=$(basename $0)
|
||||
echo ""; echo ""
|
||||
echo "======================================"
|
||||
echo "=================== Starting script (ID: $$)"
|
||||
echo "== Running ${script_name} in ${script_dir} as ${whoami}"
|
||||
script_fullname=$0
|
||||
echo " - script_fullname: ${script_fullname}"
|
||||
ignore_value="VALUE_TO_IGNORE"
|
||||
|
||||
# everyone can read our files by default
|
||||
umask 0022
|
||||
|
||||
# Write a world-writeable file (preferably inside /tmp -- ie within the container)
|
||||
write_worldtmpfile() {
|
||||
tmpfile=$1
|
||||
if [ -z "${tmpfile}" ]; then error_exit "write_worldfile: missing argument"; fi
|
||||
if [ -f $tmpfile ]; then rm -f $tmpfile; fi
|
||||
echo -n $2 > ${tmpfile}
|
||||
chmod 777 ${tmpfile}
|
||||
}
|
||||
|
||||
itdir=/tmp/hermeswebui_init
|
||||
if [ ! -d $itdir ]; then mkdir $itdir; chmod 777 $itdir; fi
|
||||
if [ ! -d $itdir ]; then error_exit "Failed to create $itdir"; fi
|
||||
|
||||
# Set user and group id
|
||||
# logic: if not set and file exists, use file value, else use default. Create file for persistence when the container is re-run
|
||||
# reasoning: needed when using docker compose as the file will exist in the stopped container, and changing the value from environment variables or configuration file must be propagated from hermeswebuitoo to hermeswebuitoo transition (those values are the only ones loaded before the environment variables dump file are loaded)
|
||||
it=$itdir/hermeswebui_user_uid
|
||||
if [ -z "${WANTED_UID+x}" ]; then
|
||||
if [ -f $it ]; then WANTED_UID=$(cat $it); fi
|
||||
fi
|
||||
# Auto-detect from mounted volumes if still unset (#569, #668).
|
||||
# On macOS, host UIDs start at 501. Using the wrong UID means the container
|
||||
# user cannot read the bind-mounted files, making the workspace appear empty.
|
||||
# In two-container setups (hermes-agent + hermes-webui), the shared hermes-home
|
||||
# volume may be owned by the agent container's UID — detect from there first.
|
||||
if [ -z "${WANTED_UID+x}" ] || [ "${WANTED_UID}" = "1024" ]; then
|
||||
# Priority 1: hermes-home shared volume — covers two-container Zeabur/Compose setups (#668)
|
||||
for _probe_dir in "/home/hermeswebui/.hermes" "$HERMES_HOME" "/opt/data"; do
|
||||
if [ -d "$_probe_dir" ]; then
|
||||
_detected_uid=$(stat -c '%u' "$_probe_dir" 2>/dev/null || echo "")
|
||||
if [ -n "$_detected_uid" ] && [ "$_detected_uid" != "0" ]; then
|
||||
echo "-- Auto-detected UID: $_detected_uid (from $_probe_dir)"
|
||||
WANTED_UID=$_detected_uid
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ -z "${WANTED_UID+x}" ] || [ "${WANTED_UID}" = "1024" ]; then
|
||||
# Priority 2: /workspace bind-mount — the standard single-container mount point
|
||||
if [ -d "/workspace" ]; then
|
||||
_detected_uid=$(stat -c '%u' "/workspace" 2>/dev/null || echo "")
|
||||
if [ -n "$_detected_uid" ] && [ "$_detected_uid" != "0" ]; then
|
||||
echo "-- Auto-detected workspace UID: $_detected_uid (from /workspace)"
|
||||
WANTED_UID=$_detected_uid
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
WANTED_UID=${WANTED_UID:-1024}
|
||||
write_worldtmpfile $it "$WANTED_UID"
|
||||
echo "-- WANTED_UID: \"${WANTED_UID}\""
|
||||
|
||||
it=$itdir/hermeswebui_user_gid
|
||||
if [ -z "${WANTED_GID+x}" ]; then
|
||||
if [ -f $it ]; then WANTED_GID=$(cat $it); fi
|
||||
fi
|
||||
# Auto-detect GID from mounted volumes to match (#569, #668)
|
||||
if [ -z "${WANTED_GID+x}" ] || [ "${WANTED_GID}" = "1024" ]; then
|
||||
# Priority 1: hermes-home shared volume
|
||||
for _probe_dir in "/home/hermeswebui/.hermes" "$HERMES_HOME" "/opt/data"; do
|
||||
if [ -d "$_probe_dir" ]; then
|
||||
_detected_gid=$(stat -c '%g' "$_probe_dir" 2>/dev/null || echo "")
|
||||
if [ -n "$_detected_gid" ] && [ "$_detected_gid" != "0" ]; then
|
||||
echo "-- Auto-detected GID: $_detected_gid (from $_probe_dir)"
|
||||
WANTED_GID=$_detected_gid
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ -z "${WANTED_GID+x}" ] || [ "${WANTED_GID}" = "1024" ]; then
|
||||
# Priority 2: /workspace bind-mount
|
||||
if [ -d "/workspace" ]; then
|
||||
_detected_gid=$(stat -c '%g' "/workspace" 2>/dev/null || echo "")
|
||||
if [ -n "$_detected_gid" ] && [ "$_detected_gid" != "0" ]; then
|
||||
echo "-- Auto-detected workspace GID: $_detected_gid (from /workspace)"
|
||||
WANTED_GID=$_detected_gid
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
WANTED_GID=${WANTED_GID:-1024}
|
||||
write_worldtmpfile $it "$WANTED_GID"
|
||||
echo "-- WANTED_GID: \"${WANTED_GID}\""
|
||||
|
||||
echo "== Most Environment variables set"
|
||||
|
||||
# Check user id and group id
|
||||
new_gid=`id -g`
|
||||
new_uid=`id -u`
|
||||
echo "== user ($whoami)"
|
||||
echo " uid: $new_uid / WANTED_UID: $WANTED_UID"
|
||||
echo " gid: $new_gid / WANTED_GID: $WANTED_GID"
|
||||
|
||||
save_env() {
|
||||
tosave=$1
|
||||
echo "-- Saving environment variables to $tosave"
|
||||
env | sort > "$tosave"
|
||||
}
|
||||
|
||||
load_env() {
|
||||
tocheck=$1
|
||||
overwrite_if_different=$2
|
||||
ignore_list="${ENV_IGNORELIST}"
|
||||
obfuscate_part="${ENV_OBFUSCATE_PART}"
|
||||
if [ -f "$tocheck" ]; then
|
||||
echo "-- Loading environment variables from $tocheck (overwrite existing: $overwrite_if_different) (ignorelist: $ignore_list) (obfuscate: $obfuscate_part)"
|
||||
while IFS='=' read -r key value; do
|
||||
doit=false
|
||||
# checking if the key is in the ignorelist
|
||||
for i in $ignore_list; do
|
||||
if [[ "A$key" == "A$i" ]]; then doit=ignore; break; fi
|
||||
done
|
||||
if [[ "A$doit" == "Aignore" ]]; then continue; fi
|
||||
rvalue=$value
|
||||
# checking if part of the key is in the obfuscate list
|
||||
doobs=false
|
||||
for i in $obfuscate_part; do
|
||||
if [[ "A$key" == *"$i"* ]]; then doobs=obfuscate; break; fi
|
||||
done
|
||||
if [[ "A$doobs" == "Aobfuscate" ]]; then rvalue="**OBFUSCATED**"; fi
|
||||
|
||||
if [ -z "${!key}" ]; then
|
||||
echo " ++ Setting environment variable $key [$rvalue]"
|
||||
doit=true
|
||||
elif [ "A$overwrite_if_different" == "Atrue" ]; then
|
||||
cvalue="${!key}"
|
||||
if [[ "A${doobs}" == "Aobfuscate" ]]; then cvalue="**OBFUSCATED**"; fi
|
||||
if [[ "A${!key}" != "A${value}" ]]; then
|
||||
echo " @@ Overwriting environment variable $key [$cvalue] -> [$rvalue]"
|
||||
doit=true
|
||||
else
|
||||
echo " == Environment variable $key [$rvalue] already set and value is unchanged"
|
||||
fi
|
||||
fi
|
||||
if [[ "A$doit" == "Atrue" ]]; then
|
||||
export "$key=$value"
|
||||
fi
|
||||
done < "$tocheck"
|
||||
fi
|
||||
}
|
||||
|
||||
# hermeswebuitoo is a specfiic user not existing by default on ubuntu, we can check its whomai
|
||||
if [ "A${whoami}" == "Ahermeswebuitoo" ]; then
|
||||
echo "-- Running as hermeswebuitoo, will switch hermeswebui to the desired UID/GID"
|
||||
# The script is started as hermeswebuitoo -- UID/GID 1025/1025
|
||||
|
||||
# We are altering the UID/GID of the hermeswebui user to the desired ones and restarting as that user
|
||||
# using usermod for the already create hermeswebui user, knowing it is not already in use
|
||||
# per usermod manual: "You must make certain that the named user is not executing any processes when this command is being executed"
|
||||
sudo groupmod -o -g ${WANTED_GID} hermeswebui || error_exit "Failed to set GID of hermeswebui user"
|
||||
sudo usermod -o -u ${WANTED_UID} hermeswebui || error_exit "Failed to set UID of hermeswebui user"
|
||||
sudo chown -R ${WANTED_UID}:${WANTED_GID} /home/hermeswebui || error_exit "Failed to set owner of /home/hermeswebui"
|
||||
save_env /tmp/hermeswebuitoo_env.txt
|
||||
# restart the script as hermeswebui set with the correct UID/GID this time
|
||||
echo "-- Restarting as hermeswebui user with UID ${WANTED_UID} GID ${WANTED_GID}"
|
||||
sudo su hermeswebui $script_fullname || error_exit "subscript failed"
|
||||
ok_exit "Clean exit"
|
||||
fi
|
||||
|
||||
# If we are here, the script is started as another user than hermeswebuitoo
|
||||
# because the whoami value for the hermeswebui user can be any existing user, we can not check against it
|
||||
# instead we check if the UID/GID are the expected ones
|
||||
if [ "$WANTED_GID" != "$new_gid" ]; then error_exit "hermeswebui MUST be running as UID ${WANTED_UID} GID ${WANTED_GID}, current UID ${new_uid} GID ${new_gid}"; fi
|
||||
if [ "$WANTED_UID" != "$new_uid" ]; then error_exit "hermeswebui MUST be running as UID ${WANTED_UID} GID ${WANTED_GID}, current UID ${new_uid} GID ${new_gid}"; fi
|
||||
|
||||
########## 'hermeswebui' specific section below
|
||||
|
||||
# We are therefore running as hermeswebui
|
||||
echo ""; echo "== Running as hermeswebui"
|
||||
|
||||
# Load environment variables one by one if they do not exist from /tmp/hermeswebuitoo_env.txt
|
||||
it=/tmp/hermeswebuitoo_env.txt
|
||||
if [ -f $it ]; then
|
||||
echo "-- Loading not already set environment variables from $it"
|
||||
load_env $it true
|
||||
fi
|
||||
|
||||
##
|
||||
echo ""; echo "-- Making sure /app is owned by the hermeswebui user to avoid permission issues when running the server "
|
||||
sudo mkdir -p /app || error_exit "Failed to create /app directory"
|
||||
sudo chown hermeswebui:hermeswebui /app || error_exit "Failed to set owner of /app to hermeswebui user"
|
||||
sudo rsync -av --chown=hermeswebui:hermeswebui /apptoo/ /app/ || error_exit "Failed to sync /apptoo to /app with correct ownership"
|
||||
it=/app/.testfile; touch $it || error_exit "Failed to verify /app directory"
|
||||
rm -f $it || error_exit "Failed to delete test file in /app"
|
||||
|
||||
######## Environment variables (consume AFTER the load_env)
|
||||
|
||||
echo ""; echo "== Checking required environment variables for hermes-webui"
|
||||
|
||||
echo ""; echo "-- HERMES_WEBUI_VERSION: Where to store sessions, workspaces, and other state (default: ~/.hermes/webui-mvp)"
|
||||
if [ -z "${HERMES_WEBUI_STATE_DIR+x}" ]; then error_exit "HERMES_WEBUI_STATE_DIR not set"; fi;
|
||||
echo "-- HERMES_WEBUI_STATE_DIR: $HERMES_WEBUI_STATE_DIR"
|
||||
if [ ! -d "$HERMES_WEBUI_STATE_DIR" ]; then mkdir -p $HERMES_WEBUI_STATE_DIR || error_exit "Failed to create state directory at $HERMES_WEBUI_STATE_DIR"; fi
|
||||
if [ ! -d "$HERMES_WEBUI_STATE_DIR" ]; then error_exit "HERMES_WEBUI_STATE_DIR directory does not exist at $HERMES_WEBUI_STATE_DIR"; fi
|
||||
it="$HERMES_WEBUI_STATE_DIR/.testfile"; touch $it || error_exit "Failed to verify state directory at $HERMES_WEBUI_STATE_DIR"
|
||||
rm -f $it || error_exit "Failed to delete test file in $HERMES_WEBUI_STATE_DIR"
|
||||
|
||||
echo ""; echo "-- HERMES_WEBUI_DEFAULT_WORKSPACE: Default workspace directory shown on first launch"
|
||||
if [ -z "${HERMES_WEBUI_DEFAULT_WORKSPACE+x}" ]; then echo "HERMES_WEBUI_DEFAULT_WORKSPACE not set, setting to /workspace"; export HERMES_WEBUI_DEFAULT_WORKSPACE="/workspace"; fi;
|
||||
echo "-- HERMES_WEBUI_DEFAULT_WORKSPACE: $HERMES_WEBUI_DEFAULT_WORKSPACE"
|
||||
# Use sudo for mkdir — Docker may auto-create bind-mount directories as root (#357).
|
||||
# Skip mkdir if the directory already exists (e.g. a read-only mount — #670).
|
||||
if [ ! -d "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]; then
|
||||
sudo mkdir -p "$HERMES_WEBUI_DEFAULT_WORKSPACE" || error_exit "Failed to create default workspace at $HERMES_WEBUI_DEFAULT_WORKSPACE"
|
||||
fi
|
||||
if [ ! -d "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]; then error_exit "HERMES_WEBUI_DEFAULT_WORKSPACE directory does not exist at $HERMES_WEBUI_DEFAULT_WORKSPACE"; fi
|
||||
# Only chown and write-test if the workspace is writable. Read-only bind-mounts
|
||||
# (:ro) are valid — the workspace is used for browsing, not writing by the server.
|
||||
if [ -w "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]; then
|
||||
sudo chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE" || echo "!! WARNING: Could not chown $HERMES_WEBUI_DEFAULT_WORKSPACE (continuing)"
|
||||
it="$HERMES_WEBUI_DEFAULT_WORKSPACE/.testfile"; touch $it && rm -f $it || echo "!! WARNING: Could not write to $HERMES_WEBUI_DEFAULT_WORKSPACE (continuing)"
|
||||
else
|
||||
echo "-- HERMES_WEBUI_DEFAULT_WORKSPACE is read-only — skipping chown/write check (read-only workspace is supported)"
|
||||
fi
|
||||
|
||||
echo ""; echo "==================="
|
||||
echo ""; echo "== Installing uv and creating a new virtual environment for hermes-webui"
|
||||
|
||||
export PATH="/home/hermeswebui/.local/bin/:$PATH"
|
||||
if command -v uv &>/dev/null; then
|
||||
echo "-- uv already installed ($(uv --version)), skipping download"
|
||||
else
|
||||
echo "-- uv not found, downloading..."
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh || error_exit "Failed to install uv — check network connectivity"
|
||||
fi
|
||||
export UV_PROJECT_ENVIRONMENT=venv
|
||||
|
||||
export UV_CACHE_DIR=/uv_cache
|
||||
sudo mkdir -p ${UV_CACHE_DIR} || error_exit "Failed to create /uv_cache directory"
|
||||
sudo chown hermeswebui:hermeswebui ${UV_CACHE_DIR} || error_exit "Failed to set owner of ${UV_CACHE_DIR} to hermeswebui user"
|
||||
|
||||
cd /app
|
||||
if [ -f /app/venv/bin/python3 ]; then
|
||||
echo ""; echo "== Existing virtual environment found — reusing (fast restart)"
|
||||
else
|
||||
echo ""; echo "== Creating new virtual environment"
|
||||
uv venv venv
|
||||
fi
|
||||
export VIRTUAL_ENV=/app/venv
|
||||
test -d /app/venv
|
||||
test -f /app/venv/bin/activate
|
||||
|
||||
echo "";echo "== Activating hermes webui's virtual environment"
|
||||
source /app/venv/bin/activate || error_exit "Failed to activate hermeswebui virtual environment"
|
||||
test -x /app/venv/bin/python3
|
||||
|
||||
ensure_hindsight_client_docker_dependency() {
|
||||
# Keep this outside the .deps_installed fast-restart guard so existing
|
||||
# two-container Docker venvs self-heal after this dependency was added.
|
||||
_hindsight_client_requirement="hindsight-client>=0.4.22"
|
||||
echo ""; echo "== Checking Hindsight memory provider dependency"
|
||||
if uv pip show hindsight-client >/dev/null 2>&1; then
|
||||
echo "-- hindsight-client already installed"
|
||||
else
|
||||
echo "-- Installing ${_hindsight_client_requirement} for Hindsight memory provider support"
|
||||
uv pip install "${_hindsight_client_requirement}" --trusted-host pypi.org --trusted-host files.pythonhosted.org || error_exit "Failed to install hindsight-client"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ -f /app/venv/.deps_installed ]; then
|
||||
echo ""; echo "== Dependencies already installed — skipping (fast restart)"
|
||||
else
|
||||
echo ""; echo "== Installing hermes-webui dependencies"
|
||||
uv pip install -r requirements.txt --trusted-host pypi.org --trusted-host files.pythonhosted.org
|
||||
uv pip install -U pip setuptools --trusted-host pypi.org --trusted-host files.pythonhosted.org
|
||||
test -x /app/venv/bin/pip
|
||||
|
||||
echo ""; echo "== Adding hermes-agent's pyproject.toml base dependencies to the virtual environment"
|
||||
_agent_paths=(
|
||||
"/home/hermeswebui/.hermes/hermes-agent"
|
||||
"/opt/hermes"
|
||||
)
|
||||
_agent_src=""
|
||||
for _p in "${_agent_paths[@]}"; do
|
||||
if [ -d "$_p" ] && [ -f "$_p/pyproject.toml" ]; then
|
||||
_agent_src="$_p"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -n "$_agent_src" ]; then
|
||||
uv pip install "$_agent_src[all]" --trusted-host pypi.org --trusted-host files.pythonhosted.org || error_exit "Failed to install hermes-agent's requirements"
|
||||
else
|
||||
echo ""
|
||||
echo "!! WARNING: hermes-agent source not found."
|
||||
echo "!! Looked in: ${_agent_paths[0]}"
|
||||
echo "!! ${_agent_paths[1]}"
|
||||
echo "!! The WebUI will start with reduced functionality (no model auto-detection,"
|
||||
echo "!! no personality routing, no CLI session imports)."
|
||||
echo "!! To fix: mount the agent source volume into the container:"
|
||||
echo "!! -v /path/to/hermes-agent:/home/hermeswebui/.hermes/hermes-agent"
|
||||
echo "!! Or see the two-container compose example:"
|
||||
echo "!! https://github.com/nesquena/hermes-webui/blob/master/docker-compose.two-container.yml"
|
||||
echo ""
|
||||
fi
|
||||
touch /app/venv/.deps_installed
|
||||
fi
|
||||
|
||||
ensure_hindsight_client_docker_dependency
|
||||
|
||||
echo ""; echo "== Running hermes-webui"
|
||||
cd /app; python server.py || error_exit "hermes-webui failed or exited with an error"
|
||||
|
||||
# we should never be here because the server should be running indefinitely, but if we are, we exit safely
|
||||
ok_exit "Clean exit"
|
||||
23
docs/ISSUES.md
Normal file
23
docs/ISSUES.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Upstream Issues — Root Cause Analysis
|
||||
|
||||
## #1256: Browser tools fail with "Playwright not installed"
|
||||
|
||||
### Root Cause
|
||||
The check lives in **hermes-agent** (upstream), not hermes-webui:
|
||||
|
||||
```
|
||||
hermes-agent/tools/browser_tool.py → check_browser_requirements()
|
||||
```
|
||||
|
||||
`check_browser_requirements()` does not recognize CDP (Chrome DevTools Protocol) mode — it only looks for a local Playwright/Puppeteer install. When the agent runs in CDP mode (connecting to an existing browser), the check still fails.
|
||||
|
||||
### WebUI side
|
||||
The WebUI already passes `CLI_TOOLSETS` correctly per-request. The `enabled_toolsets` field in the cron/chat config is dynamic and works as intended.
|
||||
|
||||
### Fix required
|
||||
The fix must happen in `hermes-agent/tools/browser_tool.py`:
|
||||
- `check_browser_requirements()` should skip the Playwright check when CDP mode is configured
|
||||
- Or add a `BROWSER_MODE=cdp` env var that bypasses the local browser requirement
|
||||
|
||||
### Workaround
|
||||
Use `CLOUD_BROWSER=true` or configure `browser.base_url` to point to a remote CDP endpoint. This bypasses the local Playwright requirement.
|
||||
BIN
docs/pr-assets/restore-top-titlebar-after.png
Normal file
BIN
docs/pr-assets/restore-top-titlebar-after.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 174 KiB |
BIN
docs/pr-assets/restore-top-titlebar-before.png
Normal file
BIN
docs/pr-assets/restore-top-titlebar-before.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
838
docs/ui-ux/index.html
Normal file
838
docs/ui-ux/index.html
Normal file
@@ -0,0 +1,838 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="slate">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Hermes WebUI — Messages UI Inventory</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<!-- Real app stylesheet -->
|
||||
<link rel="stylesheet" href="../../static/style.css">
|
||||
<!-- Prism (same theme the app pulls at runtime) -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css">
|
||||
<!-- KaTeX -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
||||
<style>
|
||||
/* Showcase scaffold — styles only for the doc chrome. Everything inside
|
||||
.messages uses the real app CSS unchanged. */
|
||||
/* Real app CSS makes <body> a fixed-height flex shell. Undo that so this
|
||||
doc page can scroll normally with a stacked header + main. */
|
||||
body{display:block !important;height:auto !important;min-height:100vh;overflow:auto !important;}
|
||||
.doc-main{display:block;}
|
||||
.doc-header{position:sticky;top:0;z-index:50;background:var(--topbar-bg);backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:14px 24px;display:flex;flex-wrap:wrap;align-items:center;gap:14px;}
|
||||
.doc-title{font-size:16px;font-weight:700;letter-spacing:-.01em;color:var(--text);}
|
||||
.doc-title small{display:block;font-size:11px;font-weight:500;color:var(--muted);margin-top:3px;}
|
||||
.doc-toggles{display:flex;flex-wrap:wrap;gap:6px;margin-left:auto;}
|
||||
.doc-toggles button{font:inherit;font-size:11px;padding:5px 10px;border-radius:7px;border:1px solid var(--border2);background:var(--input-bg);color:var(--muted);cursor:pointer;}
|
||||
.doc-toggles button.on{background:rgba(124,185,255,.12);border-color:rgba(124,185,255,.4);color:var(--blue);}
|
||||
.doc-main{max-width:1100px;margin:0 auto;padding:24px 24px 120px;}
|
||||
.doc-section{margin:40px 0 8px;padding-top:20px;border-top:1px dashed var(--border);}
|
||||
.doc-section:first-of-type{border-top:none;padding-top:0;margin-top:0;}
|
||||
.doc-kicker{font-size:10px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:var(--blue);}
|
||||
.doc-h{font-size:18px;font-weight:700;color:var(--text);margin:4px 0 4px;}
|
||||
.doc-note{font-size:12px;color:var(--muted);line-height:1.55;max-width:760px;margin-bottom:10px;}
|
||||
.doc-card{position:relative;background:var(--main-bg);border:1px solid var(--border);border-radius:12px;padding:4px 6px;margin:12px 0;}
|
||||
.doc-label{position:absolute;top:-9px;left:12px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;padding:2px 8px;background:var(--bg);color:var(--muted);border:1px solid var(--border);border-radius:999px;}
|
||||
/* Force-show hover-only affordances inside explicitly flagged demos */
|
||||
.force-show .msg-actions,
|
||||
.force-show .msg-time,
|
||||
.force-show .msg-foot{opacity:1 !important;}
|
||||
/* Chat demo container mimics the app's .messages scroll wrapper but not fullscreen */
|
||||
.messages.doc-messages{overflow:visible;display:block;}
|
||||
.messages-inner.doc-inner{padding:14px 16px;}
|
||||
/* Make the in-page demos of approval/clarify cards visible without JS */
|
||||
.approval-card.doc-visible,
|
||||
.clarify-card.doc-visible{display:block;}
|
||||
.reconnect-banner.doc-visible{display:flex;align-items:center;justify-content:space-between;gap:12px;background:rgba(201,168,76,.12);border:1px solid rgba(201,168,76,.3);color:var(--gold);padding:8px 14px;border-radius:8px;font-size:12px;}
|
||||
.reconnect-banner.doc-visible .reconnect-btn{background:none;border:1px solid rgba(201,168,76,.35);color:var(--gold);padding:4px 10px;border-radius:6px;font-size:11px;cursor:pointer;}
|
||||
.bg-error-banner.doc-visible{border-radius:8px;}
|
||||
/* Two-up grid for short comparisons */
|
||||
.doc-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:12px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="doc-header">
|
||||
<div class="doc-title">Hermes WebUI — Messages UI Inventory<small>Every message-area element & combination, wired to the real <code>static/style.css</code>. · <a href="./two-stage-proposal.html" style="color:var(--blue);text-decoration:none;">Two-stage proposal (#536) →</a></small></div>
|
||||
<div class="doc-toggles">
|
||||
<strong style="font-size:10px;color:var(--muted);letter-spacing:.08em;text-transform:uppercase;align-self:center;margin-right:4px;">Theme</strong>
|
||||
<button data-theme-btn="default">Default</button>
|
||||
<button data-theme-btn="slate" class="on">Slate</button>
|
||||
<button data-theme-btn="light">Light</button>
|
||||
<button data-theme-btn="solarized">Solarized</button>
|
||||
<button data-theme-btn="monokai">Monokai</button>
|
||||
<button data-theme-btn="nord">Nord</button>
|
||||
<button data-theme-btn="oled">OLED</button>
|
||||
<span style="width:1px;height:18px;background:var(--border);margin:0 4px;align-self:center;"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="doc-main">
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">1 · Empty state</div>
|
||||
<h2 class="doc-h">First load / no messages</h2>
|
||||
<p class="doc-note">Renders inside <code>#messages</code> when <code>S.messages</code> is empty. Logo + title + subtitle + 3 suggestion buttons.</p>
|
||||
<div class="doc-card"><span class="doc-label">.empty-state</span>
|
||||
<div class="messages doc-messages">
|
||||
<div class="empty-state" style="min-height:340px;flex:0 0 auto;">
|
||||
<div class="empty-logo">H</div>
|
||||
<h2>What can I help with?</h2>
|
||||
<p>Ask anything, run commands, explore files, or manage your scheduled tasks.</p>
|
||||
<div class="suggestion-grid">
|
||||
<button class="suggestion">📁 What files are in this workspace?</button>
|
||||
<button class="suggestion">📅 What's on my schedule today?</button>
|
||||
<button class="suggestion">🗺️ Help me plan a small project.</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">2 · User messages</div>
|
||||
<h2 class="doc-h">Right-aligned bubble, attachments, and edit mode</h2>
|
||||
<p class="doc-note">User rows have no avatar/label — the right-edge alignment and tinted bubble identify the sender. Timestamp + edit/copy live in a <code>.msg-foot</code> below the bubble, revealed on hover (forced visible here).</p>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.msg-row[data-role="user"] — plain</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row force-show" data-role="user" data-raw-text="How do I run the dev server and point it at a specific workspace path?">
|
||||
<div class="msg-body"><p>How do I run the dev server and point it at a specific workspace path?</p></div>
|
||||
<div class="msg-foot">
|
||||
<span class="msg-time" title="Thu, Apr 16 2026, 10:42 AM">10:42</span>
|
||||
<span class="msg-actions">
|
||||
<button class="msg-action-btn" title="Edit"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
|
||||
<button class="msg-copy-btn msg-action-btn" title="Copy"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.msg-files — attachments above body (right-aligned)</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row" data-role="user">
|
||||
<div class="msg-files">
|
||||
<span class="msg-file-badge">📎 architecture-notes.pdf</span>
|
||||
<span class="msg-file-badge">📎 Q1-forecast.xlsx</span>
|
||||
<span class="msg-file-badge">📎 meeting.docx</span>
|
||||
<span class="msg-file-badge">📎 screenshot.png</span>
|
||||
</div>
|
||||
<div class="msg-body"><p>Please review these docs and summarise the key decisions.</p></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.msg-edit-area + .msg-edit-bar — edit mode</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row" data-role="user" data-editing="1">
|
||||
<textarea class="msg-edit-area">How do I run the dev server and point it at a specific workspace path — and can I do it without docker?</textarea>
|
||||
<div class="msg-edit-bar">
|
||||
<button class="msg-edit-send">Send edit</button>
|
||||
<button class="msg-edit-cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">3 · Assistant — markdown basics</div>
|
||||
<h2 class="doc-h">Paragraphs, emphasis, lists, blockquote, hr, links</h2>
|
||||
<p class="doc-note">Assistant output is a single <code>.msg-row.assistant-turn</code> that holds one role header + an <code>.assistant-turn-blocks</code> column of one-or-more <code>.assistant-segment</code> children. Each segment may contain a <code>.thinking-card</code>, a <code>.msg-body</code>, and its own <code>.msg-foot</code> (copy / regen). This lets a turn stream reasoning → text → tool calls → more text without repeating the Hermes avatar each time.</p>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.msg-body — rich prose</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row assistant-turn force-show" data-role="assistant">
|
||||
<div class="msg-role assistant" title="Thu, Apr 16 2026, 10:42 AM">
|
||||
<span class="role-icon assistant">H</span>
|
||||
<span>Hermes</span>
|
||||
</div>
|
||||
<div class="assistant-turn-blocks">
|
||||
<div class="assistant-segment" data-raw-text="Running the dev server...">
|
||||
<div class="msg-body">
|
||||
<h1>Running the dev server</h1>
|
||||
<p>You can start Hermes with the built-in launcher. The <strong>simplest path</strong> is <em>no docker, no proxy</em> — the CLI handles everything.</p>
|
||||
<h2>Prerequisites</h2>
|
||||
<ul>
|
||||
<li>Node <code>>= 18</code></li>
|
||||
<li>A workspace directory you own
|
||||
<ul>
|
||||
<li>Read/write permissions</li>
|
||||
<li>No existing <code>.hermes</code> folder</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>An API key set via <code>HERMES_API_KEY</code></li>
|
||||
</ul>
|
||||
<h2>Steps</h2>
|
||||
<ol>
|
||||
<li>Clone the repo</li>
|
||||
<li>Run <code>npm install</code></li>
|
||||
<li>Start with <code>npm run dev -- --workspace ~/code</code></li>
|
||||
</ol>
|
||||
<blockquote>Tip: the <code>--workspace</code> flag accepts absolute or <code>~</code>-prefixed paths. Relative paths are resolved against the CWD.</blockquote>
|
||||
<hr>
|
||||
<p>For full setup options see the <a href="#">configuration guide</a>.</p>
|
||||
</div>
|
||||
<div class="msg-foot">
|
||||
<span class="msg-actions">
|
||||
<button class="msg-copy-btn msg-action-btn" title="Copy"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
|
||||
<button class="msg-action-btn" title="Regenerate"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg></button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.msg-body table</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row assistant-turn" data-role="assistant">
|
||||
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
|
||||
<div class="assistant-turn-blocks">
|
||||
<div class="assistant-segment">
|
||||
<div class="msg-body">
|
||||
<p>Model comparison:</p>
|
||||
<table>
|
||||
<thead><tr><th>Model</th><th>Context</th><th>Good for</th><th>Cost / 1M in</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Opus 4.6</td><td>1M</td><td>Deep reasoning, long code</td><td><code>$15.00</code></td></tr>
|
||||
<tr><td>Sonnet 4.6</td><td>1M</td><td>Daily driver, agents</td><td><code>$3.00</code></td></tr>
|
||||
<tr><td>Haiku 4.5</td><td>200k</td><td>Fast tasks, tool loops</td><td><code>$0.80</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">4 · Code blocks</div>
|
||||
<h2 class="doc-h">Plain, with header, with copy button, multi-language</h2>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">pre + code (no header)</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row assistant-turn" data-role="assistant">
|
||||
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
|
||||
<div class="assistant-turn-blocks"><div class="assistant-segment">
|
||||
<div class="msg-body">
|
||||
<pre><code class="language-bash">npm install
|
||||
npm run dev -- --workspace ~/code</code></pre>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.pre-header + pre + .code-copy-btn</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row assistant-turn" data-role="assistant">
|
||||
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
|
||||
<div class="assistant-turn-blocks"><div class="assistant-segment">
|
||||
<div class="msg-body">
|
||||
<div style="position:relative;">
|
||||
<div class="pre-header">typescript <button class="code-copy-btn" style="margin-left:auto;">Copy</button></div>
|
||||
<pre><code class="language-typescript">export async function startServer(opts: ServerOptions) {
|
||||
const port = opts.port ?? 3000;
|
||||
const app = createApp();
|
||||
app.listen(port, () => {
|
||||
console.log(`Hermes listening on :${port}`);
|
||||
});
|
||||
return app;
|
||||
}</code></pre>
|
||||
</div>
|
||||
<div style="position:relative;margin-top:14px;">
|
||||
<div class="pre-header">python <button class="code-copy-btn" style="margin-left:auto;">Copy</button></div>
|
||||
<pre><code class="language-python">from hermes import Agent
|
||||
|
||||
def main() -> None:
|
||||
agent = Agent(model="claude-opus-4-6")
|
||||
reply = agent.run("Summarise today's commits")
|
||||
print(reply)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()</code></pre>
|
||||
</div>
|
||||
<div style="position:relative;margin-top:14px;">
|
||||
<div class="pre-header">json <button class="code-copy-btn" style="margin-left:auto;">Copy</button></div>
|
||||
<pre><code class="language-json">{
|
||||
"model": "claude-sonnet-4-6",
|
||||
"stream": true,
|
||||
"tools": ["bash", "edit_file", "search"]
|
||||
}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">5 · Inline media</div>
|
||||
<h2 class="doc-h">Images (default & zoomed) and downloadable links</h2>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.msg-media-img (default + .msg-media-img--full)</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row assistant-turn" data-role="assistant">
|
||||
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
|
||||
<div class="assistant-turn-blocks"><div class="assistant-segment">
|
||||
<div class="msg-body">
|
||||
<p>Here's the screenshot you asked for (click to zoom):</p>
|
||||
<img class="msg-media-img" alt="demo" src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='640' height='360'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' x2='1'%3E%3Cstop offset='0' stop-color='%237cb9ff'/%3E%3Cstop offset='1' stop-color='%23c9a84c'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23g)' width='640' height='360'/%3E%3Ctext x='50%25' y='50%25' font-family='system-ui' font-size='28' fill='white' text-anchor='middle' dominant-baseline='middle'%3E.msg-media-img (480×400 cap)%3C/text%3E%3C/svg%3E">
|
||||
<p style="margin-top:10px;">And the full-width variant:</p>
|
||||
<img class="msg-media-img msg-media-img--full" alt="demo-full" src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1280' height='320'%3E%3Crect fill='%231e2023' width='1280' height='320'/%3E%3Ctext x='50%25' y='50%25' font-family='system-ui' font-size='28' fill='%2382aaff' text-anchor='middle' dominant-baseline='middle'%3E.msg-media-img--full (unbounded)%3C/text%3E%3C/svg%3E">
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.msg-media-link — non-image downloads</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row assistant-turn" data-role="assistant">
|
||||
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
|
||||
<div class="assistant-turn-blocks"><div class="assistant-segment">
|
||||
<div class="msg-body">
|
||||
<p>I saved the generated files:</p>
|
||||
<p><a class="msg-media-link" href="#">📎 report-2026-Q1.pdf</a> <a class="msg-media-link" href="#">📎 revenue.csv</a> <a class="msg-media-link" href="#">📎 diagram.svg</a></p>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">6 · Math & diagrams</div>
|
||||
<h2 class="doc-h">KaTeX inline / block & Mermaid block</h2>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.katex-inline + .katex-block</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row assistant-turn" data-role="assistant">
|
||||
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
|
||||
<div class="assistant-turn-blocks"><div class="assistant-segment">
|
||||
<div class="msg-body">
|
||||
<p>Inline math: <span class="katex-inline" data-math-inline>\(E = mc^2\)</span> and the quadratic formula below:</p>
|
||||
<div class="katex-block" data-math-block>$$x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$$</div>
|
||||
<p>A tidier form: <span class="katex-inline" data-math-inline>\(\sum_{i=1}^{n} i = \frac{n(n+1)}{2}\)</span>.</p>
|
||||
<div class="katex-block" data-math-block>$$\int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi}$$</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.mermaid-block (pre-render placeholder)</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row assistant-turn" data-role="assistant">
|
||||
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
|
||||
<div class="assistant-turn-blocks"><div class="assistant-segment">
|
||||
<div class="msg-body">
|
||||
<p>The request flow:</p>
|
||||
<div class="mermaid-block"><pre style="margin:0;background:none;border:none;padding:0;color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;font-size:12px;">graph LR
|
||||
U[User] --> C[Composer]
|
||||
C --> API[/api/chat/]
|
||||
API --> M((Model))
|
||||
M --> T{tool?}
|
||||
T -- yes --> X[Tool Runner]
|
||||
T -- no --> R[Reply]
|
||||
X --> R
|
||||
R --> U</pre></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">7 · Thinking / reasoning</div>
|
||||
<h2 class="doc-h">Bordered panel (collapsed / open, animated), live loader, streaming cursor</h2>
|
||||
<p class="doc-note">Thinking cards are rendered at the top of an <code>.assistant-segment</code>. They're now bordered gold-tinted panels (no more left-rule-only look) and expand/collapse with a <code>max-height</code> + opacity transition. Click the header in either example below to see the animation live.</p>
|
||||
|
||||
<div class="doc-grid">
|
||||
<div class="doc-card"><span class="doc-label">.thinking-card (collapsed, inside .assistant-segment)</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner" style="padding-top:8px;">
|
||||
<div class="msg-row assistant-turn" data-role="assistant">
|
||||
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
|
||||
<div class="assistant-turn-blocks"><div class="assistant-segment">
|
||||
<div class="thinking-card">
|
||||
<div class="thinking-card-header">
|
||||
<span class="thinking-card-icon">💡</span>
|
||||
<span class="thinking-card-label">Thought for 4.3s</span>
|
||||
<span class="thinking-card-toggle">▶</span>
|
||||
</div>
|
||||
<div class="thinking-card-body"><pre>The user asked about the dev server...</pre></div>
|
||||
</div>
|
||||
<div class="msg-body"><p>Here's the shortest path…</p></div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.thinking-card.open (animated — max-height + opacity)</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner" style="padding-top:8px;">
|
||||
<div class="msg-row assistant-turn" data-role="assistant">
|
||||
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
|
||||
<div class="assistant-turn-blocks"><div class="assistant-segment">
|
||||
<div class="thinking-card open">
|
||||
<div class="thinking-card-header">
|
||||
<span class="thinking-card-icon">💡</span>
|
||||
<span class="thinking-card-label">Thought for 4.3s</span>
|
||||
<span class="thinking-card-toggle">▶</span>
|
||||
</div>
|
||||
<div class="thinking-card-body"><pre>The user is asking about launching the dev server.
|
||||
Options: npm script, docker, or the bundled CLI.
|
||||
The CLI is the simplest — no container runtime needed.
|
||||
I should show the exact commands and the --workspace flag,
|
||||
then mention the env var for the API key at the end.</pre></div>
|
||||
</div>
|
||||
<div class="msg-body"><p>Here's the shortest path…</p></div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.thinking — live 3-dot loader (pre-reasoning)</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row assistant-turn" data-role="assistant">
|
||||
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
|
||||
<div class="assistant-turn-blocks"><div class="assistant-segment" data-live-assistant="1">
|
||||
<div class="thinking">Thinking <span class="dot"></span><span class="dot"></span><span class="dot"></span></div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">[data-live-assistant="1"] — streaming cursor at end of last child</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row assistant-turn" data-role="assistant" id="liveAssistantTurn">
|
||||
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
|
||||
<div class="assistant-turn-blocks"><div class="assistant-segment" data-live-assistant="1">
|
||||
<div class="msg-body"><p>Sure — the simplest way is to run <code>npm run dev</code>. The CLI will pick up the default</p></div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">8 · Tool cards</div>
|
||||
<h2 class="doc-h">Running, done, expanded, subagent, error, multi-card toggle</h2>
|
||||
<p class="doc-note">Tool cards sit in <code>.tool-card-row</code> wrappers (no longer nested under <code>.msg-row</code>). The details panel now animates open/closed via <code>max-height</code> + opacity — click any header below to see the transition.</p>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.tool-card.tool-card-running (collapsed, pulsing dot)</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card tool-card-running">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-running-dot"></span>
|
||||
<span class="tool-card-icon">⚡</span>
|
||||
<span class="tool-card-name">bash</span>
|
||||
<span class="tool-card-preview">npm run build</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.tool-card — done, collapsed</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-icon">📄</span>
|
||||
<span class="tool-card-name">read_file</span>
|
||||
<span class="tool-card-preview">static/style.css · 1155 lines</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.tool-card.open — args table + result snippet + Show more (animated detail)</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card open">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-icon">⚡</span>
|
||||
<span class="tool-card-name">bash</span>
|
||||
<span class="tool-card-preview">grep -rn "msg-role" static/ · exit 0 · 380ms</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
<div class="tool-card-detail">
|
||||
<div class="tool-card-args">
|
||||
<div><span class="tool-arg-key">command:</span> <span class="tool-arg-val">grep -rn "msg-role" static/</span></div>
|
||||
<div><span class="tool-arg-key">cwd:</span> <span class="tool-arg-val">/Users/aron/hermes-webui</span></div>
|
||||
<div><span class="tool-arg-key">timeout:</span> <span class="tool-arg-val">30000</span></div>
|
||||
</div>
|
||||
<div class="tool-card-result">
|
||||
<pre>static/style.css:430: .msg-role{font-size:12px;font-weight:500...}
|
||||
static/style.css:431: .msg-role.user{color:rgba(124,185,255,0.65);}
|
||||
static/style.css:432: .msg-role.assistant{color:rgba(201,168,76,0.6);}
|
||||
static/ui.js:1141: const roleEl = el('div', 'msg-role ' + role);</pre>
|
||||
<button class="tool-card-more">Show more (+142 lines)</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.tool-card.tool-card-subagent — delegated work</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card tool-card-subagent">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-icon">🤖</span>
|
||||
<span class="tool-card-name">Subagent</span>
|
||||
<span class="tool-card-preview">Explore · Map chat messages UI elements</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card tool-card-subagent">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-icon">🤖</span>
|
||||
<span class="tool-card-name">Delegate task</span>
|
||||
<span class="tool-card-preview">Plan · Propose redesign variants</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.tool-card (error snippet)</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card open">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-icon">⚡</span>
|
||||
<span class="tool-card-name">bash</span>
|
||||
<span class="tool-card-preview">npm run typecheck · exit 1 · 2.3s</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
<div class="tool-card-detail">
|
||||
<div class="tool-card-args">
|
||||
<div><span class="tool-arg-key">command:</span> <span class="tool-arg-val">npm run typecheck</span></div>
|
||||
</div>
|
||||
<div class="tool-card-result">
|
||||
<pre style="color:#fca5a5;">src/server.ts:42:7 - error TS2345: Argument of type 'string | undefined'
|
||||
is not assignable to parameter of type 'number'.
|
||||
|
||||
42 app.listen(opts.port, () => {
|
||||
~~~~~~~~~</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.tool-cards-toggle — Expand/Collapse All (≥2 cards)</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="tool-cards-toggle">
|
||||
<button>Expand all (3)</button>
|
||||
<button>Collapse all</button>
|
||||
</div>
|
||||
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon">📄</span><span class="tool-card-name">read_file</span><span class="tool-card-preview">package.json</span><span class="tool-card-toggle">▶</span></div></div></div>
|
||||
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon">🔎</span><span class="tool-card-name">grep</span><span class="tool-card-preview">"listen" in src/</span><span class="tool-card-toggle">▶</span></div></div></div>
|
||||
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon">⚡</span><span class="tool-card-name">bash</span><span class="tool-card-preview">npm run typecheck · exit 0 · 4.1s</span><span class="tool-card-toggle">▶</span></div></div></div>
|
||||
</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">9 · Meta affordances</div>
|
||||
<h2 class="doc-h">Role timestamp tooltip, footer action toolbar, token-usage badge</h2>
|
||||
<p class="doc-note">Assistant timestamps live on the <code>.msg-role</code> <code>title</code> attribute (hover for full date). Copy/regen buttons sit in the per-segment <code>.msg-foot</code>, 45% opacity at rest, full on turn hover. The <code>.msg-usage</code> badge is always visible at the bottom of the turn.</p>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">Full hover state — .msg-foot actions + .msg-usage</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row assistant-turn force-show" data-role="assistant">
|
||||
<div class="msg-role assistant" title="Thu, Apr 16 2026, 10:42 AM">
|
||||
<span class="role-icon assistant">H</span>
|
||||
<span>Hermes</span>
|
||||
</div>
|
||||
<div class="assistant-turn-blocks"><div class="assistant-segment">
|
||||
<div class="msg-body"><p>Built and type-checked successfully — server is running on <code>:3000</code>.</p></div>
|
||||
<div class="msg-foot">
|
||||
<span class="msg-actions">
|
||||
<button class="msg-copy-btn msg-action-btn" title="Copy"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
|
||||
<button class="msg-action-btn" title="Regenerate"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg></button>
|
||||
</span>
|
||||
</div>
|
||||
</div></div>
|
||||
<div class="msg-usage">3.2K in · 481 out · ~$0.012</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">10 · Full composition</div>
|
||||
<h2 class="doc-h">User turn → assistant turn (segment 1: thinking + body + tool cards) → usage</h2>
|
||||
<p class="doc-note">A realistic turn: one role header up top, then the segment hosting a thinking card plus the first body; tool cards follow as siblings of the turn inside <code>.messages-inner</code>; the usage badge closes the turn.</p>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">All-in-one turn</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row force-show" data-role="user">
|
||||
<div class="msg-files"><span class="msg-file-badge">📎 server.ts</span></div>
|
||||
<div class="msg-body"><p>The build fails — can you type-check and explain?</p></div>
|
||||
<div class="msg-foot">
|
||||
<span class="msg-time">10:40</span>
|
||||
<span class="msg-actions">
|
||||
<button class="msg-action-btn" title="Edit">✎</button>
|
||||
<button class="msg-copy-btn msg-action-btn" title="Copy">⎘</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="msg-row assistant-turn force-show" data-role="assistant">
|
||||
<div class="msg-role assistant" title="Thu, Apr 16 2026, 10:42 AM">
|
||||
<span class="role-icon assistant">H</span><span>Hermes</span>
|
||||
</div>
|
||||
<div class="assistant-turn-blocks">
|
||||
<div class="assistant-segment">
|
||||
<div class="thinking-card open">
|
||||
<div class="thinking-card-header"><span class="thinking-card-icon">💡</span><span class="thinking-card-label">Thought for 2.1s</span><span class="thinking-card-toggle">▶</span></div>
|
||||
<div class="thinking-card-body"><pre>Attached server.ts — probably typing issue.
|
||||
Run typecheck to confirm, then patch.</pre></div>
|
||||
</div>
|
||||
<div class="msg-body">
|
||||
<p>The build fails because <code>opts.port</code> can be <code>undefined</code>. Two fixes below — pick the one that matches your intent.</p>
|
||||
<h3>Option A — require the port</h3>
|
||||
<pre><code class="language-typescript">export function startServer(opts: { port: number }) {
|
||||
app.listen(opts.port);
|
||||
}</code></pre>
|
||||
<h3>Option B — default to 3000</h3>
|
||||
<pre><code class="language-typescript">export function startServer(opts: { port?: number } = {}) {
|
||||
const port = opts.port ?? 3000;
|
||||
app.listen(port);
|
||||
}</code></pre>
|
||||
<p>I ran the checks below to confirm.</p>
|
||||
</div>
|
||||
<div class="msg-foot">
|
||||
<span class="msg-actions">
|
||||
<button class="msg-copy-btn msg-action-btn" title="Copy">⎘</button>
|
||||
<button class="msg-action-btn" title="Regenerate">↻</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="msg-usage">11.4K in · 612 out · ~$0.049</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-cards-toggle">
|
||||
<button>Expand all (3)</button><button>Collapse all</button>
|
||||
</div>
|
||||
<div class="tool-card-row"><div class="tool-card open">
|
||||
<div class="tool-card-header"><span class="tool-card-icon">📄</span><span class="tool-card-name">read_file</span><span class="tool-card-preview">src/server.ts · 58 lines</span><span class="tool-card-toggle">▶</span></div>
|
||||
<div class="tool-card-detail">
|
||||
<div class="tool-card-args"><div><span class="tool-arg-key">path:</span> <span class="tool-arg-val">src/server.ts</span></div></div>
|
||||
<div class="tool-card-result"><pre>export function startServer(opts: ServerOptions) {
|
||||
app.listen(opts.port, () => { ... });
|
||||
}</pre></div>
|
||||
</div>
|
||||
</div></div>
|
||||
<div class="tool-card-row"><div class="tool-card">
|
||||
<div class="tool-card-header"><span class="tool-card-icon">⚡</span><span class="tool-card-name">bash</span><span class="tool-card-preview">npm run typecheck · exit 1 · 2.3s</span><span class="tool-card-toggle">▶</span></div>
|
||||
</div></div>
|
||||
<div class="tool-card-row"><div class="tool-card">
|
||||
<div class="tool-card-header"><span class="tool-card-icon">✏️</span><span class="tool-card-name">edit_file</span><span class="tool-card-preview">src/server.ts +1 / -1</span><span class="tool-card-toggle">▶</span></div>
|
||||
</div></div>
|
||||
</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">12 · System / inline notes</div>
|
||||
<h2 class="doc-h">Compression, cancellation, errors — rendered as italicised assistant messages</h2>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">Italic system notices (still italic — info, not errors)</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row assistant-turn" data-role="assistant">
|
||||
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
|
||||
<div class="assistant-turn-blocks">
|
||||
<div class="assistant-segment"><div class="msg-body"><p><em>[Context was auto-compressed to continue the conversation]</em></p></div></div>
|
||||
<div class="assistant-segment"><div class="msg-body"><p><em>Task cancelled.</em></p></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.assistant-segment[data-error="1"] — real error card, red accent, no italic</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row assistant-turn" data-role="assistant">
|
||||
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
|
||||
<div class="assistant-turn-blocks">
|
||||
<div class="assistant-segment" data-error="1"><div class="msg-body"><p><strong>Error:</strong> Connection lost. Your last message was saved — refresh to continue.</p></div></div>
|
||||
<div class="assistant-segment" data-error="1"><div class="msg-body"><p><strong>Error:</strong> Upstream rate-limited (429). Retrying in 30s…</p></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">12b · Turn boundaries & date separators</div>
|
||||
<h2 class="doc-h">Right-alignment separates user turns · day-change separator</h2>
|
||||
<p class="doc-note">The dashed divider before each user turn was removed — the right-edge bubble alignment is its own visual break, so only a small vertical gap (10px top margin) remains between turns. Day changes still get a centred <code>.msg-date-sep</code>.</p>
|
||||
<div class="doc-card"><span class="doc-label">.msg-date-sep — Today / Yesterday / weekday / date</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-date-sep">Yesterday</div>
|
||||
<div class="msg-row" data-role="user"><div class="msg-body"><p>Can you summarise the PR I opened earlier?</p></div></div>
|
||||
<div class="msg-row assistant-turn" data-role="assistant">
|
||||
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
|
||||
<div class="assistant-turn-blocks"><div class="assistant-segment"><div class="msg-body"><p>Yes — three files changed, net +42 / -18. Main change is the new rail variable…</p></div></div></div>
|
||||
</div>
|
||||
<div class="msg-date-sep">Today</div>
|
||||
<div class="msg-row" data-role="user"><div class="msg-body"><p>Did CI pass overnight?</p></div></div>
|
||||
<div class="msg-row assistant-turn" data-role="assistant">
|
||||
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
|
||||
<div class="assistant-turn-blocks"><div class="assistant-segment"><div class="msg-body"><p>All green — three jobs, 4m 12s total. Here's the breakdown:</p></div></div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">13 · Overlay cards (adjacent to transcript)</div>
|
||||
<h2 class="doc-h">Approval & Clarify cards + reconnect banner</h2>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.approval-card — 4 button variants (once / session / always / deny)</span>
|
||||
<div class="approval-card doc-visible">
|
||||
<div class="approval-inner">
|
||||
<div class="approval-header">⚠ Approval required</div>
|
||||
<div class="approval-desc" style="font-size:12px;color:var(--muted);margin-bottom:8px;">The agent wants to run a shell command in <code>/Users/aron/hermes-webui</code>.</div>
|
||||
<div class="approval-cmd">rm -rf node_modules && npm install</div>
|
||||
<div class="approval-btns">
|
||||
<button class="approval-btn once">✓ <span class="approval-btn-label">Allow once</span><kbd class="approval-kbd">↵</kbd></button>
|
||||
<button class="approval-btn session">🔒 <span class="approval-btn-label">Allow session</span></button>
|
||||
<button class="approval-btn always">★ <span class="approval-btn-label">Always allow</span></button>
|
||||
<button class="approval-btn deny">✕ <span class="approval-btn-label">Deny</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.clarify-card — choice buttons + free-text fallback</span>
|
||||
<div class="clarify-card doc-visible">
|
||||
<div class="clarify-inner">
|
||||
<div class="clarify-header">? Clarification needed</div>
|
||||
<div class="clarify-question">Which environment should I deploy this to?</div>
|
||||
<div class="clarify-choices">
|
||||
<button class="clarify-choice"><span class="clarify-choice-badge">A</span><span class="clarify-choice-text">Staging — safe sandbox, auto-teardown nightly</span></button>
|
||||
<button class="clarify-choice"><span class="clarify-choice-badge">B</span><span class="clarify-choice-text">Production EU — customer-facing, requires change ticket</span></button>
|
||||
<button class="clarify-choice"><span class="clarify-choice-badge">C</span><span class="clarify-choice-text">Production US — same caveats as EU</span></button>
|
||||
<button class="clarify-choice other"><span class="clarify-choice-badge other">✎</span><span class="clarify-choice-text">Other — I'll type it below</span></button>
|
||||
</div>
|
||||
<div class="clarify-response">
|
||||
<input class="clarify-input" type="text" placeholder="Type your response…">
|
||||
<button class="clarify-submit">Send</button>
|
||||
</div>
|
||||
<div class="clarify-hint">Pick a choice, or type your own answer below.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">Reconnect / mid-stream recovery banner</span>
|
||||
<div class="reconnect-banner doc-visible">
|
||||
<span>⚠ A response may have been in progress when you last left. Reload messages?</span>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button class="reconnect-btn">Dismiss</button>
|
||||
<button class="reconnect-btn">↻ Reload</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-error-banner doc-visible" style="margin-top:8px;">
|
||||
<span>⚠ Agent run exited with non-zero status (code 1). Check the logs.</span>
|
||||
<button class="reconnect-btn">Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">14 · Structure & data-attribute cheat sheet</div>
|
||||
<h2 class="doc-h">Wrappers and state markers produced by <code>renderMessages()</code></h2>
|
||||
|
||||
<div class="doc-card" style="padding:14px 18px;">
|
||||
<h3 style="font-size:13px;color:var(--text);margin:0 0 8px;">Wrappers</h3>
|
||||
<ul style="color:var(--muted);font-size:12px;line-height:1.9;list-style:disc;padding-left:20px;">
|
||||
<li><code>.msg-row[data-role="user"]</code> — one user turn (right-aligned bubble, 60% max-width)</li>
|
||||
<li><code>.msg-row.assistant-turn[data-role="assistant"]</code> — one assistant turn; contains <strong>one</strong> <code>.msg-role</code> and <strong>one</strong> <code>.assistant-turn-blocks</code></li>
|
||||
<li><code>.assistant-turn-blocks</code> — flex-column holder for segments</li>
|
||||
<li><code>.assistant-segment</code> — a single logical chunk inside a turn: optional <code>.thinking-card</code> + optional <code>.msg-body</code> + optional <code>.msg-foot</code></li>
|
||||
<li><code>.assistant-segment-anchor</code> — hidden segment kept as a DOM anchor for tool cards when the model emitted no text</li>
|
||||
<li><code>.tool-card-row</code> — per-tool-card wrapper, sibling of the turn inside <code>.messages-inner</code></li>
|
||||
<li><code>.msg-foot</code> — per-segment (or per-user-row) footer holding <code>.msg-time</code> + <code>.msg-actions</code></li>
|
||||
</ul>
|
||||
<h3 style="font-size:13px;color:var(--text);margin:14px 0 8px;">Data attributes & IDs</h3>
|
||||
<ul style="color:var(--muted);font-size:12px;line-height:1.9;list-style:disc;padding-left:20px;">
|
||||
<li><code>data-role="user|assistant"</code> — role marker on the row</li>
|
||||
<li><code>data-msgIdx="N"</code> — index into <code>S.messages</code>; on user rows <em>and</em> assistant segments</li>
|
||||
<li><code>data-raw-text="…"</code> — plain-text source for copy (now lives on <code>.assistant-segment</code> for assistant output)</li>
|
||||
<li><code>data-live-assistant="1"</code> — the segment that's currently streaming</li>
|
||||
<li><code>data-editing="1"</code> — row is in edit mode</li>
|
||||
<li><code>data-error="1"</code> — error state; applies to <code>.msg-row</code> (user) or <code>.assistant-segment</code></li>
|
||||
<li><code>id="liveAssistantTurn"</code> — on the turn that contains the streaming segment</li>
|
||||
<li><code>.tool-card-row[data-live-tid="…"]</code> — live tool-call card (removed when the turn settles)</li>
|
||||
<li><code>data-mermaid-id</code>, <code>data-katex</code>, <code>data-rendered</code> — block rendering state</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- Prism autoloader for real syntax highlighting -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
|
||||
<!-- KaTeX auto-render -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
|
||||
onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'\\[',right:'\\]',display:true},{left:'\\(',right:'\\)',display:false},{left:'$',right:'$',display:false}],throwOnError:false});"></script>
|
||||
|
||||
<script>
|
||||
// Theme picker
|
||||
document.querySelectorAll('[data-theme-btn]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const t = btn.dataset.themeBtn;
|
||||
if (t === 'default') document.documentElement.removeAttribute('data-theme');
|
||||
else document.documentElement.setAttribute('data-theme', t);
|
||||
document.querySelectorAll('[data-theme-btn]').forEach(b => b.classList.toggle('on', b === btn));
|
||||
});
|
||||
});
|
||||
// Bubble-layout toggle
|
||||
|
||||
// Thinking / tool-card click-to-toggle (so the demo feels live)
|
||||
document.querySelectorAll('.thinking-card-header, .tool-card-header').forEach(h => {
|
||||
h.addEventListener('click', () => h.parentElement.classList.toggle('open'));
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
742
docs/ui-ux/two-stage-proposal.html
Normal file
742
docs/ui-ux/two-stage-proposal.html
Normal file
@@ -0,0 +1,742 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="slate">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Hermes WebUI — Two-Stage Chat Proposal (Issue #536)</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<link rel="stylesheet" href="../../static/style.css">
|
||||
<style>
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
Doc-chrome scaffold (same pattern as index.html) — real app CSS
|
||||
is used unchanged inside .messages / .msg-row. New proposed
|
||||
elements are prefixed .p2s- so nothing collides with the app.
|
||||
────────────────────────────────────────────────────────────── */
|
||||
body{display:block !important;height:auto !important;min-height:100vh;overflow:auto !important;}
|
||||
.doc-header{position:sticky;top:0;z-index:50;background:var(--topbar-bg);backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:14px 24px;display:flex;flex-wrap:wrap;align-items:center;gap:14px;}
|
||||
.doc-title{font-size:16px;font-weight:700;letter-spacing:-.01em;color:var(--text);}
|
||||
.doc-title small{display:block;font-size:11px;font-weight:500;color:var(--muted);margin-top:3px;}
|
||||
.doc-title a{color:var(--blue);text-decoration:none;}
|
||||
.doc-toggles{display:flex;flex-wrap:wrap;gap:6px;margin-left:auto;}
|
||||
.doc-toggles button{font:inherit;font-size:11px;padding:5px 10px;border-radius:7px;border:1px solid var(--border2);background:var(--input-bg);color:var(--muted);cursor:pointer;}
|
||||
.doc-toggles button.on{background:rgba(124,185,255,.12);border-color:rgba(124,185,255,.4);color:var(--blue);}
|
||||
.doc-main{max-width:1180px;margin:0 auto;padding:24px 24px 120px;}
|
||||
.doc-section{margin:48px 0 8px;padding-top:22px;border-top:1px dashed var(--border);}
|
||||
.doc-section:first-of-type{border-top:none;padding-top:0;margin-top:0;}
|
||||
.doc-kicker{font-size:10px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:var(--blue);}
|
||||
.doc-h{font-size:20px;font-weight:700;color:var(--text);margin:4px 0 6px;letter-spacing:-.01em;}
|
||||
.doc-note{font-size:12.5px;color:var(--muted);line-height:1.6;max-width:780px;margin-bottom:14px;}
|
||||
.doc-note code{color:var(--text);background:rgba(255,255,255,.05);padding:1px 5px;border-radius:4px;font-size:11.5px;}
|
||||
.doc-card{position:relative;background:var(--main-bg);border:1px solid var(--border);border-radius:14px;padding:6px 8px;margin:14px 0;}
|
||||
.doc-label{position:absolute;top:-9px;left:14px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;padding:2px 9px;background:var(--bg);color:var(--muted);border:1px solid var(--border);border-radius:999px;}
|
||||
.doc-label.current{color:var(--muted);}
|
||||
.doc-label.proposed{color:var(--gold);border-color:rgba(201,168,76,.35);background:var(--bg);}
|
||||
.force-show .msg-actions,.force-show .msg-time,.force-show .msg-foot{opacity:1 !important;}
|
||||
.messages.doc-messages{overflow:visible;display:block;}
|
||||
.messages-inner.doc-inner{padding:14px 16px;}
|
||||
.approval-card.doc-visible,.clarify-card.doc-visible{display:block;}
|
||||
.doc-grid-2{display:grid;grid-template-columns:repeat(auto-fit,minmax(440px,1fr));gap:14px;}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
Proposed two-stage elements (prefix .p2s-)
|
||||
|
||||
The proposal introduces one container (.p2s-stage1) that wraps
|
||||
the execution history (thinking + tool cards) and one visual
|
||||
treatment (.p2s-answer) for the final-answer segment. The same
|
||||
DOM can be rendered in three modes:
|
||||
|
||||
.p2s-stage1.is-live → Working timer + expanded history
|
||||
.p2s-stage1.is-settled → Collapsed to one-line summary
|
||||
.p2s-stage1.is-settled.is-open → expanded on demand
|
||||
|
||||
Everything else (thinking-card, tool-card-row, msg-body) is the
|
||||
existing app CSS unchanged.
|
||||
────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Worklog bar — the header of Stage 1.
|
||||
Aligns with every other rail child via --msg-rail / --msg-max. */
|
||||
.p2s-worklog{
|
||||
display:flex;align-items:center;gap:10px;
|
||||
margin:4px 0 6px var(--msg-rail);
|
||||
max-width:var(--msg-max);
|
||||
padding:8px 12px;
|
||||
border:1px solid var(--border);
|
||||
border-radius:10px;
|
||||
background:rgba(255,255,255,.025);
|
||||
font-size:12px;color:var(--muted);
|
||||
cursor:pointer;user-select:none;
|
||||
transition:border-color .15s,background .15s;
|
||||
}
|
||||
.p2s-worklog:hover{border-color:var(--border2);background:rgba(255,255,255,.04);}
|
||||
.p2s-worklog-dot{
|
||||
width:8px;height:8px;border-radius:50%;background:var(--gold);flex-shrink:0;
|
||||
box-shadow:0 0 0 0 rgba(201,168,76,.4);
|
||||
}
|
||||
.p2s-stage1.is-live .p2s-worklog-dot{
|
||||
animation:p2sPulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.p2s-stage1.is-settled .p2s-worklog-dot{
|
||||
background:var(--muted);opacity:.6;
|
||||
}
|
||||
@keyframes p2sPulse{
|
||||
0%,100%{box-shadow:0 0 0 0 rgba(201,168,76,.45);}
|
||||
50%{box-shadow:0 0 0 6px rgba(201,168,76,0);}
|
||||
}
|
||||
.p2s-worklog-label{color:var(--text);font-weight:500;}
|
||||
.p2s-worklog-stats{margin-left:auto;display:flex;gap:12px;color:var(--muted);font-size:11.5px;}
|
||||
.p2s-worklog-stats b{color:var(--text);font-weight:600;}
|
||||
.p2s-worklog-caret{
|
||||
display:inline-block;width:14px;height:14px;line-height:14px;text-align:center;
|
||||
color:var(--muted);font-size:10px;transition:transform .2s;
|
||||
margin-left:6px;
|
||||
}
|
||||
.p2s-stage1.is-live .p2s-worklog-caret{display:none;}
|
||||
.p2s-stage1.is-settled.is-open .p2s-worklog-caret{transform:rotate(90deg);}
|
||||
|
||||
/* Stage 1 body — holds thinking + tool cards + round separators. */
|
||||
.p2s-stage1-body{
|
||||
overflow:hidden;
|
||||
transition:max-height .35s ease,opacity .25s ease;
|
||||
}
|
||||
.p2s-stage1.is-live .p2s-stage1-body,
|
||||
.p2s-stage1.is-settled.is-open .p2s-stage1-body{
|
||||
max-height:2000px;opacity:1;
|
||||
}
|
||||
.p2s-stage1.is-settled:not(.is-open) .p2s-stage1-body{
|
||||
max-height:0;opacity:0;pointer-events:none;
|
||||
}
|
||||
|
||||
/* Round separator — shown inside Stage 1 between execution rounds. */
|
||||
.p2s-round-sep{
|
||||
display:flex;align-items:center;gap:10px;
|
||||
margin:10px 0 6px var(--msg-rail);
|
||||
max-width:var(--msg-max);
|
||||
color:var(--muted);
|
||||
font-size:10.5px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;
|
||||
}
|
||||
.p2s-round-sep::before,.p2s-round-sep::after{
|
||||
content:"";flex:1;height:1px;background:var(--border);
|
||||
}
|
||||
|
||||
/* Stage 1 → Stage 2 transition divider. */
|
||||
.p2s-transition{
|
||||
margin:14px 0 10px var(--msg-rail);
|
||||
max-width:var(--msg-max);
|
||||
height:1px;
|
||||
background:linear-gradient(
|
||||
to right,transparent,var(--border) 20%,var(--border) 80%,transparent
|
||||
);
|
||||
}
|
||||
|
||||
/* Stage 2 — the final answer wrapper.
|
||||
|
||||
Design intent: nothing loud. A small "Answer" kicker in gold,
|
||||
slightly taller line-height, the existing .msg-body styling,
|
||||
and a gentle top breathing-space. The user arrives at this
|
||||
block and it *feels* like a conclusion, not another tool row.
|
||||
*/
|
||||
.p2s-answer{margin-top:8px;}
|
||||
.p2s-answer-kicker{
|
||||
margin:0 0 4px var(--msg-rail);
|
||||
max-width:var(--msg-max);
|
||||
font-size:10px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;
|
||||
color:var(--gold);opacity:.8;
|
||||
}
|
||||
.p2s-answer .msg-body{
|
||||
font-size:14.5px;line-height:1.78;
|
||||
}
|
||||
|
||||
/* Clarify slot — placed at the transition rather than inline. */
|
||||
.p2s-clarify-slot{
|
||||
margin:12px 0 4px var(--msg-rail);
|
||||
max-width:var(--msg-max);
|
||||
}
|
||||
.p2s-clarify-slot .clarify-card{margin:0;}
|
||||
|
||||
/* Comparison-grid accents. */
|
||||
.doc-compare-caption{
|
||||
font-size:11px;color:var(--muted);text-align:center;padding:6px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="doc-header">
|
||||
<div class="doc-title">
|
||||
Two-Stage Chat UX — Proposal for <a href="https://github.com/nesquena/hermes-webui/issues/536" target="_blank">issue #536</a>
|
||||
<small>Companion to <a href="./index.html">index.html</a> — shows <em>Working → Final answer</em> as a distinct two-phase interaction model.</small>
|
||||
</div>
|
||||
<div class="doc-toggles">
|
||||
<strong style="font-size:10px;color:var(--muted);letter-spacing:.08em;text-transform:uppercase;align-self:center;margin-right:4px;">Theme</strong>
|
||||
<button data-theme-btn="default">Default</button>
|
||||
<button data-theme-btn="slate" class="on">Slate</button>
|
||||
<button data-theme-btn="light">Light</button>
|
||||
<button data-theme-btn="solarized">Solarized</button>
|
||||
<button data-theme-btn="monokai">Monokai</button>
|
||||
<button data-theme-btn="nord">Nord</button>
|
||||
<button data-theme-btn="oled">OLED</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="doc-main">
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">0 · The model</div>
|
||||
<h2 class="doc-h">One turn, two stages</h2>
|
||||
<p class="doc-note">
|
||||
Today an assistant turn is a flat stream: thinking card → tool cards → answer, all stacked
|
||||
inline with equal visual weight. The proposal wraps the execution history in a
|
||||
<code>.p2s-stage1</code> container with a <em>worklog bar</em> as its header, and marks the
|
||||
final answer as <code>.p2s-answer</code>. The same DOM renders three ways:
|
||||
</p>
|
||||
<ul class="doc-note" style="padding-left:18px;list-style:disc;">
|
||||
<li><b>Live</b> — worklog shows <em>Working… 0:42 · 2 tools</em> with a pulsing dot; history is fully visible.</li>
|
||||
<li><b>Settled</b> — worklog collapses to a single line (<em>Worked 1:42 · 4 tools · 2 thinking</em>); final answer sits below as the calm conclusion.</li>
|
||||
<li><b>Settled + opened</b> — user clicks the worklog to re-expand the history for audit.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">1 · Current vs proposed — settled turn</div>
|
||||
<h2 class="doc-h">Side-by-side comparison</h2>
|
||||
<p class="doc-note">
|
||||
Same turn, same tool calls, same answer. Left is what #587 ships today. Right is the
|
||||
proposal: execution history collapses to a one-line summary; the final answer stands alone
|
||||
with a small <em>Answer</em> kicker.
|
||||
</p>
|
||||
|
||||
<div class="doc-grid-2">
|
||||
|
||||
<!-- CURRENT ──────────────────────────────────────────────── -->
|
||||
<div class="doc-card"><span class="doc-label current">Current (PR #587)</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row" data-role="user">
|
||||
<div class="msg-body"><p>Does our dev server pick up the workspace from an env var or a flag?</p></div>
|
||||
</div>
|
||||
<div class="msg-row assistant-turn" data-role="assistant">
|
||||
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
|
||||
<div class="assistant-turn-blocks"><div class="assistant-segment">
|
||||
<div class="thinking-card open">
|
||||
<div class="thinking-card-header">
|
||||
<span class="thinking-card-icon">💡</span>
|
||||
<span class="thinking-card-label">Thought for 3.1s</span>
|
||||
<span class="thinking-card-toggle">▶</span>
|
||||
</div>
|
||||
<div class="thinking-card-body"><pre>Check how the CLI resolves workspace:
|
||||
grep for HERMES_WORKSPACE and --workspace
|
||||
inspect argv vs env precedence.</pre></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card open">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-icon">⚡</span>
|
||||
<span class="tool-card-name">bash</span>
|
||||
<span class="tool-card-preview">grep -rn "HERMES_WORKSPACE" . · exit 0</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
<div class="tool-card-detail">
|
||||
<div class="tool-card-result"><pre>cli/main.py:14:WORKSPACE_ENV = "HERMES_WORKSPACE"
|
||||
cli/main.py:92: ws = os.getenv(WORKSPACE_ENV) or args.workspace</pre></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-icon">📄</span>
|
||||
<span class="tool-card-name">read_file</span>
|
||||
<span class="tool-card-preview">cli/main.py · 148 lines</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="msg-row assistant-turn" data-role="assistant">
|
||||
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
|
||||
<div class="assistant-turn-blocks"><div class="assistant-segment">
|
||||
<div class="msg-body">
|
||||
<p>Both work, but <strong>env wins</strong>. The CLI reads
|
||||
<code>HERMES_WORKSPACE</code> first and only falls back to the
|
||||
<code>--workspace</code> flag if the env var is unset.</p>
|
||||
<p>So in practice:</p>
|
||||
<ul>
|
||||
<li>CI / daemons → set the env var.</li>
|
||||
<li>Ad-hoc runs → pass <code>--workspace</code>.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
<div class="doc-compare-caption">Everything stacks equally — the answer is just the next block.</div>
|
||||
</div>
|
||||
|
||||
<!-- PROPOSED ─────────────────────────────────────────────── -->
|
||||
<div class="doc-card"><span class="doc-label proposed">Proposed — two-stage, settled</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row" data-role="user">
|
||||
<div class="msg-body"><p>Does our dev server pick up the workspace from an env var or a flag?</p></div>
|
||||
</div>
|
||||
<div class="msg-row assistant-turn" data-role="assistant">
|
||||
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
|
||||
<div class="assistant-turn-blocks"><div class="assistant-segment">
|
||||
|
||||
<!-- Stage 1 — settled, collapsed to summary (click to expand) -->
|
||||
<div class="p2s-stage1 is-settled" data-p2s-toggle>
|
||||
<div class="p2s-worklog">
|
||||
<span class="p2s-worklog-dot"></span>
|
||||
<span class="p2s-worklog-label">Worked for 0:08</span>
|
||||
<span class="p2s-worklog-stats">
|
||||
<span><b>2</b> tools</span>
|
||||
<span><b>1</b> thinking round</span>
|
||||
</span>
|
||||
<span class="p2s-worklog-caret">▶</span>
|
||||
</div>
|
||||
<div class="p2s-stage1-body">
|
||||
<div class="thinking-card open">
|
||||
<div class="thinking-card-header">
|
||||
<span class="thinking-card-icon">💡</span>
|
||||
<span class="thinking-card-label">Thought for 3.1s</span>
|
||||
<span class="thinking-card-toggle">▶</span>
|
||||
</div>
|
||||
<div class="thinking-card-body"><pre>Check how the CLI resolves workspace:
|
||||
grep for HERMES_WORKSPACE and --workspace
|
||||
inspect argv vs env precedence.</pre></div>
|
||||
</div>
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-icon">⚡</span>
|
||||
<span class="tool-card-name">bash</span>
|
||||
<span class="tool-card-preview">grep -rn "HERMES_WORKSPACE" . · exit 0</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-icon">📄</span>
|
||||
<span class="tool-card-name">read_file</span>
|
||||
<span class="tool-card-preview">cli/main.py · 148 lines</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stage 2 — the final answer -->
|
||||
<div class="p2s-transition"></div>
|
||||
<div class="p2s-answer">
|
||||
<div class="p2s-answer-kicker">Answer</div>
|
||||
<div class="msg-body">
|
||||
<p>Both work, but <strong>env wins</strong>. The CLI reads
|
||||
<code>HERMES_WORKSPACE</code> first and only falls back to the
|
||||
<code>--workspace</code> flag if the env var is unset.</p>
|
||||
<p>So in practice:</p>
|
||||
<ul>
|
||||
<li>CI / daemons → set the env var.</li>
|
||||
<li>Ad-hoc runs → pass <code>--workspace</code>.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
<div class="doc-compare-caption">Click the worklog bar to expand the execution history.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">2 · Stage 1 · Live run</div>
|
||||
<h2 class="doc-h">Working timer + live execution history</h2>
|
||||
<p class="doc-note">
|
||||
The worklog bar at the top is the anchor for the whole active run: pulsing dot, elapsed
|
||||
timer that ticks every second, and live counts that increment as tool cards resolve.
|
||||
Thinking cards and tool cards render inside <code>.p2s-stage1-body</code> exactly as today.
|
||||
A <em>Round N</em> separator is inserted when the agent starts a new reasoning/tool cycle.
|
||||
</p>
|
||||
|
||||
<div class="doc-card"><span class="doc-label proposed">.p2s-stage1.is-live — Round 1 done, Round 2 running</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row assistant-turn" data-role="assistant">
|
||||
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
|
||||
<div class="assistant-turn-blocks"><div class="assistant-segment" data-live-assistant="1">
|
||||
|
||||
<div class="p2s-stage1 is-live">
|
||||
<div class="p2s-worklog">
|
||||
<span class="p2s-worklog-dot"></span>
|
||||
<span class="p2s-worklog-label">Working… <span id="p2sTimer">0:42</span></span>
|
||||
<span class="p2s-worklog-stats">
|
||||
<span><b>3</b> tools</span>
|
||||
<span><b>2</b> thinking</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="p2s-stage1-body">
|
||||
|
||||
<div class="thinking-card open">
|
||||
<div class="thinking-card-header">
|
||||
<span class="thinking-card-icon">💡</span>
|
||||
<span class="thinking-card-label">Thought for 2.4s</span>
|
||||
<span class="thinking-card-toggle">▶</span>
|
||||
</div>
|
||||
<div class="thinking-card-body"><pre>Need to map the streaming code path first,
|
||||
then check the persistence layer.</pre></div>
|
||||
</div>
|
||||
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-icon">📄</span>
|
||||
<span class="tool-card-name">read_file</span>
|
||||
<span class="tool-card-preview">api/streaming.py · 612 lines</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-icon">⚡</span>
|
||||
<span class="tool-card-name">bash</span>
|
||||
<span class="tool-card-preview">grep -rn "tool_call_id" api/ · exit 0 · 88ms</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p2s-round-sep">Round 2</div>
|
||||
|
||||
<div class="thinking-card">
|
||||
<div class="thinking-card-header">
|
||||
<span class="thinking-card-icon">💡</span>
|
||||
<span class="thinking-card-label">Thought for 1.8s</span>
|
||||
<span class="thinking-card-toggle">▶</span>
|
||||
</div>
|
||||
<div class="thinking-card-body"><pre>Streaming looks fine — drill into how
|
||||
tool_calls get attached before save.</pre></div>
|
||||
</div>
|
||||
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card tool-card-running">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-running-dot"></span>
|
||||
<span class="tool-card-icon">⚡</span>
|
||||
<span class="tool-card-name">bash</span>
|
||||
<span class="tool-card-preview">pytest tests/test_tool_call_persistence.py -q</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">3 · Approve vs Clarify — placement</div>
|
||||
<h2 class="doc-h">Approvals stay in Stage 1; Clarify moves to the transition</h2>
|
||||
<p class="doc-note">
|
||||
Per the issue: <em>approvals are part of doing the work</em> (they gate a single tool),
|
||||
<em>clarifications stabilise the answer path</em> (they precede the conclusion). The
|
||||
proposal keeps <code>.approval-card</code> inline among tool cards, and places
|
||||
<code>.clarify-card</code> at the Stage 1 → Stage 2 seam, above the final answer.
|
||||
</p>
|
||||
|
||||
<div class="doc-grid-2">
|
||||
|
||||
<!-- Approve inline in Stage 1 -->
|
||||
<div class="doc-card"><span class="doc-label proposed">Approve card — inline in Stage 1</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row assistant-turn" data-role="assistant">
|
||||
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
|
||||
<div class="assistant-turn-blocks"><div class="assistant-segment" data-live-assistant="1">
|
||||
|
||||
<div class="p2s-stage1 is-live">
|
||||
<div class="p2s-worklog">
|
||||
<span class="p2s-worklog-dot"></span>
|
||||
<span class="p2s-worklog-label">Working… 0:18</span>
|
||||
<span class="p2s-worklog-stats"><span><b>1</b> tool</span></span>
|
||||
</div>
|
||||
<div class="p2s-stage1-body">
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-icon">⚡</span>
|
||||
<span class="tool-card-name">bash</span>
|
||||
<span class="tool-card-preview">ls -la ~/.hermes/sessions · exit 0</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="approval-card doc-visible">
|
||||
<div class="approval-card-header">
|
||||
<span class="approval-card-icon">🔐</span>
|
||||
<span class="approval-card-title">Approve command</span>
|
||||
</div>
|
||||
<div class="approval-card-body">
|
||||
<p class="approval-card-desc">Hermes wants to run a potentially destructive command:</p>
|
||||
<pre class="approval-card-cmd">rm -rf ~/.hermes/sessions/*.json.bak</pre>
|
||||
</div>
|
||||
<div class="approval-card-actions">
|
||||
<button class="approval-btn approve">Approve</button>
|
||||
<button class="approval-btn deny">Deny</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
<div class="doc-compare-caption">Permission gate sits next to the tools it gates.</div>
|
||||
</div>
|
||||
|
||||
<!-- Clarify at transition -->
|
||||
<div class="doc-card"><span class="doc-label proposed">Clarify card — Stage 1 → Stage 2 transition</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row assistant-turn" data-role="assistant">
|
||||
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
|
||||
<div class="assistant-turn-blocks"><div class="assistant-segment">
|
||||
|
||||
<div class="p2s-stage1 is-settled" data-p2s-toggle>
|
||||
<div class="p2s-worklog">
|
||||
<span class="p2s-worklog-dot"></span>
|
||||
<span class="p2s-worklog-label">Worked for 0:12</span>
|
||||
<span class="p2s-worklog-stats"><span><b>2</b> tools</span></span>
|
||||
<span class="p2s-worklog-caret">▶</span>
|
||||
</div>
|
||||
<div class="p2s-stage1-body">
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-icon">📄</span>
|
||||
<span class="tool-card-name">read_file</span>
|
||||
<span class="tool-card-preview">package.json · 48 lines</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-icon">⚡</span>
|
||||
<span class="tool-card-name">bash</span>
|
||||
<span class="tool-card-preview">ls src/ · exit 0</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p2s-transition"></div>
|
||||
<div class="p2s-clarify-slot">
|
||||
<div class="clarify-card doc-visible">
|
||||
<div class="clarify-card-header">
|
||||
<span class="clarify-card-icon">❓</span>
|
||||
<span class="clarify-card-title">One quick question before I answer</span>
|
||||
</div>
|
||||
<div class="clarify-card-body">
|
||||
<p>I can wire the dev server either as an <strong>npm script</strong> in the
|
||||
existing <code>package.json</code>, or as a standalone <strong>CLI
|
||||
entry-point</strong>. Which would you prefer?</p>
|
||||
</div>
|
||||
<div class="clarify-card-actions">
|
||||
<button class="clarify-opt">npm script</button>
|
||||
<button class="clarify-opt">CLI entry-point</button>
|
||||
<button class="clarify-opt">Let Hermes pick</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
<div class="doc-compare-caption">Stage 1 is already settled; the answer is paused on clarification.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">4 · Stage 2 · Calm conclusion</div>
|
||||
<h2 class="doc-h">What the "Answer" stage looks like on its own</h2>
|
||||
<p class="doc-note">
|
||||
Three small choices distinguish Stage 2 from a regular text block:
|
||||
(1) a thin horizontal divider above it, (2) a tiny gold <em>Answer</em> kicker aligned to
|
||||
the text rail, (3) a slightly taller line-height. No heavy borders, no boxed treatment —
|
||||
the emphasis comes from <em>what is missing around it</em>, not ornament.
|
||||
</p>
|
||||
|
||||
<div class="doc-card"><span class="doc-label proposed">.p2s-answer (Stage 1 collapsed above)</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row assistant-turn" data-role="assistant">
|
||||
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
|
||||
<div class="assistant-turn-blocks"><div class="assistant-segment">
|
||||
|
||||
<div class="p2s-stage1 is-settled" data-p2s-toggle>
|
||||
<div class="p2s-worklog">
|
||||
<span class="p2s-worklog-dot"></span>
|
||||
<span class="p2s-worklog-label">Worked for 1:42</span>
|
||||
<span class="p2s-worklog-stats">
|
||||
<span><b>4</b> tools</span>
|
||||
<span><b>2</b> thinking</span>
|
||||
<span><b>1</b> approval</span>
|
||||
</span>
|
||||
<span class="p2s-worklog-caret">▶</span>
|
||||
</div>
|
||||
<div class="p2s-stage1-body">
|
||||
<div class="thinking-card"><div class="thinking-card-header"><span class="thinking-card-icon">💡</span><span class="thinking-card-label">Thought for 2.4s</span><span class="thinking-card-toggle">▶</span></div></div>
|
||||
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon">📄</span><span class="tool-card-name">read_file</span><span class="tool-card-preview">api/streaming.py</span><span class="tool-card-toggle">▶</span></div></div></div>
|
||||
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon">⚡</span><span class="tool-card-name">bash</span><span class="tool-card-preview">grep -rn "tool_call_id" api/</span><span class="tool-card-toggle">▶</span></div></div></div>
|
||||
<div class="p2s-round-sep">Round 2</div>
|
||||
<div class="thinking-card"><div class="thinking-card-header"><span class="thinking-card-icon">💡</span><span class="thinking-card-label">Thought for 1.8s</span><span class="thinking-card-toggle">▶</span></div></div>
|
||||
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon">⚡</span><span class="tool-card-name">bash</span><span class="tool-card-preview">pytest -q · exit 0 · 2.4s</span><span class="tool-card-toggle">▶</span></div></div></div>
|
||||
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon">✍️</span><span class="tool-card-name">edit_file</span><span class="tool-card-preview">api/streaming.py · +12 −3</span><span class="tool-card-toggle">▶</span></div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p2s-transition"></div>
|
||||
<div class="p2s-answer">
|
||||
<div class="p2s-answer-kicker">Answer</div>
|
||||
<div class="msg-body">
|
||||
<p>Tool-call persistence was breaking because <code>session.tool_calls</code> was
|
||||
written <em>after</em> <code>s.save()</code> in <code>api/streaming.py</code>.
|
||||
I moved the attach step above the save, and added a fallback that reconstructs
|
||||
ordering from live tool-progress events when <code>tool_call_id</code> is absent
|
||||
on older sessions.</p>
|
||||
<p>Net result:</p>
|
||||
<ul>
|
||||
<li>Reloading mid-stream now preserves every tool card with args + output snippet.</li>
|
||||
<li>Last-turn reasoning survives reload.</li>
|
||||
<li>No schema migration needed — old sessions degrade gracefully.</li>
|
||||
</ul>
|
||||
<p>Covered by the new regression in <code>tests/test_tool_call_persistence.py</code>.</p>
|
||||
</div>
|
||||
<div class="msg-foot" style="opacity:1;padding-left:var(--msg-rail);">
|
||||
<span class="msg-time">11:42 AM · 2,481 tokens · 1.42s</span>
|
||||
<span class="msg-actions">
|
||||
<button class="msg-act" title="Copy">⧉</button>
|
||||
<button class="msg-act" title="Regenerate">↻</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">5 · Open-question answers (picked defaults)</div>
|
||||
<h2 class="doc-h">What this proposal commits to</h2>
|
||||
<div class="doc-card" style="padding:16px 20px;">
|
||||
<ul style="color:var(--muted);font-size:13px;line-height:1.85;list-style:disc;padding-left:22px;margin:0;">
|
||||
<li><b style="color:var(--text);">Stage 1 on settle →</b> <em>partial</em> collapse to a
|
||||
single worklog bar with counts. Click to re-expand. No "nuke to black box", no "keep
|
||||
everything open forever".</li>
|
||||
<li><b style="color:var(--text);">Final answer placement →</b> sits <em>beneath</em> Stage 1,
|
||||
not replacing it. Visual distinction comes from the divider + kicker + spacing, not from
|
||||
a two-panel layout.</li>
|
||||
<li><b style="color:var(--text);">Clarify placement →</b> at the Stage 1 → Stage 2 seam.
|
||||
Approvals stay inline with tools.</li>
|
||||
<li><b style="color:var(--text);">Timer →</b> lives on Stage 1 only. Stops when the agent
|
||||
emits the first Stage 2 token; final label becomes "Worked for N:NN".</li>
|
||||
<li><b style="color:var(--text);">Signal for "answer has started" →</b> first assistant
|
||||
text delta after all tool calls have resolved and no new <code>tool_use</code> is pending
|
||||
in the current round. Already present in the SSE stream per maintainer comment.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">6 · DOM cheat-sheet</div>
|
||||
<h2 class="doc-h">What changes vs index.html</h2>
|
||||
<div class="doc-card" style="padding:14px 18px;">
|
||||
<h3 style="font-size:13px;color:var(--text);margin:0 0 8px;">New wrappers</h3>
|
||||
<ul style="color:var(--muted);font-size:12px;line-height:1.9;list-style:disc;padding-left:20px;">
|
||||
<li><code>.p2s-stage1[is-live|is-settled][is-open]</code> — wraps the execution history inside an <code>.assistant-segment</code>.</li>
|
||||
<li><code>.p2s-worklog</code> — header of Stage 1. Pulsing dot + label + counts + caret. Clickable when settled.</li>
|
||||
<li><code>.p2s-stage1-body</code> — holds <code>.thinking-card</code> + <code>.tool-card-row</code> + <code>.p2s-round-sep</code>. Animated via <code>max-height</code>.</li>
|
||||
<li><code>.p2s-round-sep</code> — inline horizontal separator between tool/reasoning rounds.</li>
|
||||
<li><code>.p2s-transition</code> — thin gradient divider between Stage 1 and Stage 2.</li>
|
||||
<li><code>.p2s-answer</code> — wraps the final <code>.msg-body</code> + <code>.msg-foot</code>.</li>
|
||||
<li><code>.p2s-answer-kicker</code> — small gold <em>Answer</em> label.</li>
|
||||
<li><code>.p2s-clarify-slot</code> — placement slot for <code>.clarify-card</code> at the Stage 1/2 seam.</li>
|
||||
</ul>
|
||||
<h3 style="font-size:13px;color:var(--text);margin:14px 0 8px;">Unchanged</h3>
|
||||
<ul style="color:var(--muted);font-size:12px;line-height:1.9;list-style:disc;padding-left:20px;">
|
||||
<li><code>.thinking-card</code>, <code>.tool-card</code>, <code>.approval-card</code>, <code>.clarify-card</code>, <code>.msg-body</code>, <code>.msg-foot</code> — all existing app CSS and existing markup.</li>
|
||||
<li><code>.assistant-turn-blocks</code> and <code>.assistant-segment</code> remain the top-level wrappers.</li>
|
||||
<li>Tool cards still live as <code>.tool-card-row</code> siblings — now nested <em>inside</em> <code>.p2s-stage1-body</code> rather than as direct children of <code>.messages-inner</code>.</li>
|
||||
</ul>
|
||||
<h3 style="font-size:13px;color:var(--text);margin:14px 0 8px;">Implementation notes</h3>
|
||||
<ul style="color:var(--muted);font-size:12px;line-height:1.9;list-style:disc;padding-left:20px;">
|
||||
<li>Renderer in <code>static/messages.js</code> wraps an assistant turn's non-final blocks in <code>.p2s-stage1-body</code> and appends the <code>.p2s-worklog</code> header once; toggles <code>is-live</code>/<code>is-settled</code> based on <code>data-live-assistant</code>.</li>
|
||||
<li><code>static/boot.js</code> SSE handler ticks the timer while <code>is-live</code>, increments counts on each <code>tool_use</code>, and flips the class when the first Stage 2 delta arrives.</li>
|
||||
<li>Persistence: no schema change needed — the worklog summary can be derived on reload from the existing persisted tool-call list + thinking rounds.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Theme picker (matches index.html)
|
||||
document.querySelectorAll('[data-theme-btn]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const t = btn.dataset.themeBtn;
|
||||
if (t === 'default') document.documentElement.removeAttribute('data-theme');
|
||||
else document.documentElement.setAttribute('data-theme', t);
|
||||
document.querySelectorAll('[data-theme-btn]').forEach(b => b.classList.toggle('on', b === btn));
|
||||
});
|
||||
});
|
||||
|
||||
// Existing thinking/tool cards click-to-toggle.
|
||||
document.querySelectorAll('.thinking-card-header, .tool-card-header').forEach(h => {
|
||||
h.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
h.parentElement.classList.toggle('open');
|
||||
});
|
||||
});
|
||||
|
||||
// Click the worklog bar on a settled Stage 1 to expand/collapse the history.
|
||||
document.querySelectorAll('.p2s-stage1[data-p2s-toggle] .p2s-worklog').forEach(bar => {
|
||||
bar.addEventListener('click', () => {
|
||||
const stage = bar.closest('.p2s-stage1');
|
||||
if (!stage.classList.contains('is-settled')) return;
|
||||
stage.classList.toggle('is-open');
|
||||
});
|
||||
});
|
||||
|
||||
// Live timer demo in section 2 — ticks so the page feels alive.
|
||||
(function(){
|
||||
const el = document.getElementById('p2sTimer');
|
||||
if (!el) return;
|
||||
let [m, s] = el.textContent.split(':').map(Number);
|
||||
setInterval(() => {
|
||||
s = (s + 1) % 60;
|
||||
if (s === 0) m += 1;
|
||||
el.textContent = m + ':' + String(s).padStart(2,'0');
|
||||
}, 1000);
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
139
server.py
139
server.py
@@ -3,22 +3,56 @@ Hermes Web UI -- Main server entry point.
|
||||
Thin routing shell: imports Handler, delegates to api/routes.py, runs server.
|
||||
All business logic lives in api/*.
|
||||
"""
|
||||
import logging
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from api.auth import check_auth
|
||||
from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE
|
||||
from api.helpers import j
|
||||
from api.helpers import j, get_profile_cookie
|
||||
from api.profiles import set_request_profile, clear_request_profile
|
||||
from api.routes import handle_get, handle_post
|
||||
from api.startup import auto_install_agent_deps, fix_credential_permissions
|
||||
from api.updates import WEBUI_VERSION
|
||||
|
||||
|
||||
class QuietHTTPServer(ThreadingHTTPServer):
|
||||
"""Custom HTTP server that silently handles common network errors."""
|
||||
daemon_threads = True
|
||||
request_queue_size = 64
|
||||
|
||||
def handle_error(self, request, client_address):
|
||||
"""Override to suppress logging for common client disconnect errors."""
|
||||
exc_type, exc_value, _ = sys.exc_info()
|
||||
|
||||
# Silently ignore common connection errors caused by client disconnects
|
||||
if exc_type in (ConnectionResetError, BrokenPipeError, ConnectionAbortedError, TimeoutError):
|
||||
return
|
||||
|
||||
# Also handle socket errors that indicate client disconnect
|
||||
if issubclass(exc_type, OSError):
|
||||
# errno 54 is Connection reset by peer on macOS/BSD
|
||||
# errno 104 is Connection reset by peer on Linux
|
||||
if getattr(exc_value, 'errno', None) in (32, 54, 104, 110): # EPIPE, ECONNRESET, ETIMEDOUT
|
||||
return
|
||||
|
||||
# For other errors, use default logging
|
||||
super().handle_error(request, client_address)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = 'HermesWebUI/0.2'
|
||||
timeout = 30 # seconds — kills idle/incomplete connections to prevent thread exhaustion
|
||||
_ver_suffix = WEBUI_VERSION.removeprefix('v')
|
||||
server_version = ('HermesWebUI/' + _ver_suffix) if _ver_suffix != 'unknown' else 'HermesWebUI'
|
||||
def log_message(self, fmt, *args): pass # suppress default Apache-style log
|
||||
|
||||
def log_request(self, code='-', size='-'):
|
||||
def log_request(self, code: str='-', size: str='-') -> None:
|
||||
"""Structured JSON logs for each request."""
|
||||
import json as _json
|
||||
duration_ms = round((time.time() - getattr(self, '_req_t0', time.time())) * 1000, 1)
|
||||
@@ -31,8 +65,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
})
|
||||
print(f'[webui] {record}', flush=True)
|
||||
|
||||
def do_GET(self):
|
||||
def do_GET(self) -> None:
|
||||
self._req_t0 = time.time()
|
||||
# Per-request profile context from cookie (issue #798)
|
||||
cookie_profile = get_profile_cookie(self)
|
||||
if cookie_profile:
|
||||
set_request_profile(cookie_profile)
|
||||
try:
|
||||
parsed = urlparse(self.path)
|
||||
if not check_auth(self, parsed): return
|
||||
@@ -42,9 +80,15 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except Exception as e:
|
||||
print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True)
|
||||
return j(self, {'error': 'Internal server error'}, status=500)
|
||||
finally:
|
||||
clear_request_profile()
|
||||
|
||||
def do_POST(self):
|
||||
def do_POST(self) -> None:
|
||||
self._req_t0 = time.time()
|
||||
# Per-request profile context from cookie (issue #798)
|
||||
cookie_profile = get_profile_cookie(self)
|
||||
if cookie_profile:
|
||||
set_request_profile(cookie_profile)
|
||||
try:
|
||||
parsed = urlparse(self.path)
|
||||
if not check_auth(self, parsed): return
|
||||
@@ -54,30 +98,101 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except Exception as e:
|
||||
print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True)
|
||||
return j(self, {'error': 'Internal server error'}, status=500)
|
||||
finally:
|
||||
clear_request_profile()
|
||||
|
||||
|
||||
def main():
|
||||
def main() -> None:
|
||||
from api.config import print_startup_config, verify_hermes_imports, _HERMES_FOUND
|
||||
|
||||
print_startup_config()
|
||||
|
||||
# Fix sensitive file permissions before doing anything else
|
||||
fix_credential_permissions()
|
||||
|
||||
within_container = False
|
||||
# Check for the "/.within_container" file to determine if we're running inside a container; this file is created in the Dockerfile
|
||||
try:
|
||||
with open('/.within_container', 'r') as f:
|
||||
within_container = True
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
if within_container:
|
||||
print('[ok] Running within container.', flush=True)
|
||||
|
||||
# Security: warn if binding non-loopback without authentication
|
||||
from api.auth import is_auth_enabled
|
||||
if HOST not in ('127.0.0.1', '::1', 'localhost') and not is_auth_enabled():
|
||||
print(f'[!!] WARNING: Binding to {HOST} with NO PASSWORD SET.', flush=True)
|
||||
print(f' Anyone on the network can access your filesystem and agent.', flush=True)
|
||||
print(f' Set a password via Settings or HERMES_WEBUI_PASSWORD env var.', flush=True)
|
||||
print(f' To suppress: bind to 127.0.0.1 or set a password.', flush=True)
|
||||
if within_container:
|
||||
print(f' Note: You are running within a container, must bind to 0.0.0.0 to publish the port.', flush=True)
|
||||
elif not is_auth_enabled():
|
||||
print(f' [tip] No password set. Any process on this machine can read sessions', flush=True)
|
||||
print(f' and memory via the local API. Set HERMES_WEBUI_PASSWORD to', flush=True)
|
||||
print(f' enable authentication.', flush=True)
|
||||
|
||||
ok, missing, errors = verify_hermes_imports()
|
||||
if not ok and _HERMES_FOUND:
|
||||
print(f'[!!] Warning: Hermes agent found but missing modules: {missing}', flush=True)
|
||||
for mod, err in errors.items():
|
||||
print(f' {mod}: {err}', flush=True)
|
||||
print(' Agent features may not work correctly.', flush=True)
|
||||
print(' Attempting to install missing dependencies from agent requirements.txt...', flush=True)
|
||||
auto_install_agent_deps()
|
||||
ok, missing, errors = verify_hermes_imports()
|
||||
if not ok:
|
||||
print(f'[!!] Still missing after install attempt: {missing}', flush=True)
|
||||
for mod, err in errors.items():
|
||||
print(f' {mod}: {err}', flush=True)
|
||||
print(' Agent features may not work correctly.', flush=True)
|
||||
else:
|
||||
print('[ok] Agent dependencies installed successfully.', flush=True)
|
||||
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
||||
DEFAULT_WORKSPACE.mkdir(parents=True, exist_ok=True)
|
||||
httpd = ThreadingHTTPServer((HOST, PORT), Handler)
|
||||
print(f' Hermes Web UI listening on http://{HOST}:{PORT}', flush=True)
|
||||
if HOST == '127.0.0.1':
|
||||
|
||||
# Start the gateway session watcher for real-time SSE updates
|
||||
try:
|
||||
from api.gateway_watcher import start_watcher
|
||||
start_watcher()
|
||||
except Exception as e:
|
||||
print(f'[!!] WARNING: Gateway watcher failed to start: {e}', flush=True)
|
||||
|
||||
httpd = QuietHTTPServer((HOST, PORT), Handler)
|
||||
|
||||
# ── TLS/HTTPS setup (optional) ─────────────────────────────────────────
|
||||
from api.config import TLS_ENABLED, TLS_CERT, TLS_KEY
|
||||
scheme = 'https' if TLS_ENABLED else 'http'
|
||||
if TLS_ENABLED:
|
||||
try:
|
||||
import ssl
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
ctx.load_cert_chain(TLS_CERT, TLS_KEY)
|
||||
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
|
||||
print(f' TLS enabled: cert={TLS_CERT}, key={TLS_KEY}', flush=True)
|
||||
except Exception as e:
|
||||
print(f'[!!] WARNING: TLS setup failed ({e}), falling back to HTTP', flush=True)
|
||||
scheme = 'http'
|
||||
|
||||
print(f' Hermes Web UI listening on {scheme}://{HOST}:{PORT}', flush=True)
|
||||
if HOST == '127.0.0.1' or within_container:
|
||||
print(f' Remote access: ssh -N -L {PORT}:127.0.0.1:{PORT} <user>@<your-server>', flush=True)
|
||||
print(f' Then open: http://localhost:{PORT}', flush=True)
|
||||
print(f' Then open: {scheme}://localhost:{PORT}', flush=True)
|
||||
print('', flush=True)
|
||||
httpd.serve_forever()
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
finally:
|
||||
# Stop the gateway watcher on shutdown
|
||||
try:
|
||||
from api.gateway_watcher import stop_watcher
|
||||
stop_watcher()
|
||||
except Exception:
|
||||
logger.debug("Failed to stop gateway watcher during shutdown")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
265
start.sh
265
start.sh
@@ -1,260 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# Hermes Web UI -- portable bootstrap
|
||||
# Usage: ./start.sh [port]
|
||||
#
|
||||
# One-command startup. Discovers your Hermes install, sets up
|
||||
# a local virtualenv if needed, installs dependencies, then
|
||||
# launches the server and prints everything you need to know.
|
||||
#
|
||||
# Override any step with environment variables:
|
||||
# HERMES_WEBUI_AGENT_DIR path to hermes-agent checkout
|
||||
# HERMES_WEBUI_PYTHON python executable to use
|
||||
# HERMES_WEBUI_PORT port to listen on (default: 8787)
|
||||
# HERMES_WEBUI_HOST bind address (default: 127.0.0.1)
|
||||
# HERMES_HOME override ~/.hermes base
|
||||
# HERMES_WEBUI_STATE_DIR override state directory
|
||||
# ============================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Load .env if present (machine-local overrides, not committed) ─────────────
|
||||
_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
if [[ -f "${_SCRIPT_DIR}/.env" ]]; then
|
||||
set -a
|
||||
# shellcheck source=/dev/null
|
||||
source "${_SCRIPT_DIR}/.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# ── Colours ──────────────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||
ok() { echo -e "${GREEN}[ok]${RESET} $*"; }
|
||||
warn() { echo -e "${YELLOW}[!!]${RESET} $*"; }
|
||||
die() { echo -e "${RED}[XX]${RESET} $*" >&2; exit 1; }
|
||||
info() { echo -e "${CYAN}[--]${RESET} $*"; }
|
||||
hdr() { echo -e "\n${BOLD}$*${RESET}"; }
|
||||
|
||||
# ── Resolve repo root (the directory this script lives in) ───────────────────
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
info "Repo root: ${REPO_ROOT}"
|
||||
|
||||
# ── Port ─────────────────────────────────────────────────────────────────────
|
||||
PORT="${1:-${HERMES_WEBUI_PORT:-8787}}"
|
||||
export HERMES_WEBUI_PORT="${PORT}"
|
||||
|
||||
# ── Python discovery ─────────────────────────────────────────────────────────
|
||||
hdr "Discovering Python..."
|
||||
|
||||
_find_python() {
|
||||
# 1. Explicit env var
|
||||
if [[ -n "${HERMES_WEBUI_PYTHON:-}" ]]; then
|
||||
echo "${HERMES_WEBUI_PYTHON}"; return
|
||||
fi
|
||||
|
||||
# 2. Agent venv (discovered below -- call again after agent dir found)
|
||||
# (handled after agent dir discovery)
|
||||
|
||||
# 3. Local .venv in repo
|
||||
if [[ -x "${REPO_ROOT}/.venv/bin/python" ]]; then
|
||||
echo "${REPO_ROOT}/.venv/bin/python"; return
|
||||
fi
|
||||
|
||||
# 4. System python3
|
||||
if command -v python3 &>/dev/null; then
|
||||
echo "$(command -v python3)"; return
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
PYTHON="$(_find_python)"
|
||||
|
||||
# ── Hermes agent discovery ────────────────────────────────────────────────────
|
||||
hdr "Discovering Hermes agent..."
|
||||
|
||||
HERMES_HOME="${HERMES_HOME:-${HOME}/.hermes}"
|
||||
AGENT_DIR=""
|
||||
|
||||
_find_agent() {
|
||||
local candidates=(
|
||||
"${HERMES_WEBUI_AGENT_DIR:-}"
|
||||
"${HERMES_HOME}/hermes-agent"
|
||||
"${REPO_ROOT}/../hermes-agent"
|
||||
"${HOME}/.hermes/hermes-agent"
|
||||
"${HOME}/hermes-agent"
|
||||
)
|
||||
|
||||
for d in "${candidates[@]}"; do
|
||||
[[ -z "$d" ]] && continue
|
||||
d="$(cd "${d}" 2>/dev/null && pwd || true)"
|
||||
if [[ -n "$d" && -f "${d}/run_agent.py" ]]; then
|
||||
echo "$d"; return
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
}
|
||||
|
||||
AGENT_DIR="$(_find_agent)"
|
||||
|
||||
if [[ -n "${AGENT_DIR}" ]]; then
|
||||
ok "Hermes agent: ${AGENT_DIR}"
|
||||
export HERMES_WEBUI_AGENT_DIR="${AGENT_DIR}"
|
||||
|
||||
# Now that we have agent dir, prefer its venv if we don't already have a python
|
||||
if [[ -z "${HERMES_WEBUI_PYTHON:-}" && -x "${AGENT_DIR}/venv/bin/python" ]]; then
|
||||
PYTHON="${AGENT_DIR}/venv/bin/python"
|
||||
fi
|
||||
else
|
||||
warn "Hermes agent not found. Agent features will not work."
|
||||
warn "Fix with: export HERMES_WEBUI_AGENT_DIR=/path/to/hermes-agent"
|
||||
if [[ -f "${REPO_ROOT}/.env" ]]; then
|
||||
set -a
|
||||
# shellcheck source=/dev/null
|
||||
source "${REPO_ROOT}/.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
if [[ -n "${PYTHON}" ]]; then
|
||||
ok "Python: ${PYTHON} ($(${PYTHON} --version 2>&1))"
|
||||
else
|
||||
warn "No Python found. Attempting to install..."
|
||||
if command -v apt-get &>/dev/null; then
|
||||
sudo apt-get install -y python3 python3-venv python3-pip
|
||||
elif command -v brew &>/dev/null; then
|
||||
brew install python3
|
||||
else
|
||||
die "Could not find or install Python. Please install Python 3.8+ and re-run."
|
||||
fi
|
||||
PYTHON="${HERMES_WEBUI_PYTHON:-}"
|
||||
if [[ -z "${PYTHON}" ]]; then
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
PYTHON="$(command -v python3)"
|
||||
ok "Python installed: ${PYTHON}"
|
||||
elif command -v python >/dev/null 2>&1; then
|
||||
PYTHON="$(command -v python)"
|
||||
else
|
||||
echo "[XX] Python 3 is required to run bootstrap.py" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Minimum Python version check ─────────────────────────────────────────────
|
||||
PY_VER="$(${PYTHON} -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
|
||||
PY_MAJOR="$(echo "${PY_VER}" | cut -d. -f1)"
|
||||
PY_MINOR="$(echo "${PY_VER}" | cut -d. -f2)"
|
||||
if [[ "${PY_MAJOR}" -lt 3 || ( "${PY_MAJOR}" -eq 3 && "${PY_MINOR}" -lt 8 ) ]]; then
|
||||
die "Python 3.8+ required. Found: ${PY_VER}"
|
||||
fi
|
||||
|
||||
# ── Dependency check / local venv setup ──────────────────────────────────────
|
||||
hdr "Checking dependencies..."
|
||||
|
||||
VENV_NEEDED=false
|
||||
VENV_PATH="${REPO_ROOT}/.venv"
|
||||
|
||||
# If the chosen python is already the agent venv, its deps are already installed.
|
||||
# If it is a system python, check if we can import the webui deps, create a local
|
||||
# .venv if not.
|
||||
_check_deps() {
|
||||
"${PYTHON}" -c "import yaml" 2>/dev/null
|
||||
}
|
||||
|
||||
if ! _check_deps; then
|
||||
info "PyYAML not found in ${PYTHON}. Creating local .venv..."
|
||||
|
||||
if [[ ! -d "${VENV_PATH}" ]]; then
|
||||
"${PYTHON}" -m venv "${VENV_PATH}" || die "Failed to create virtualenv at ${VENV_PATH}"
|
||||
fi
|
||||
|
||||
VENV_PY="${VENV_PATH}/bin/python"
|
||||
"${VENV_PY}" -m pip install --quiet --upgrade pip
|
||||
|
||||
if [[ -f "${REPO_ROOT}/requirements.txt" ]]; then
|
||||
info "Installing from requirements.txt..."
|
||||
"${VENV_PY}" -m pip install --quiet -r "${REPO_ROOT}/requirements.txt"
|
||||
else
|
||||
info "Installing minimal deps (pyyaml)..."
|
||||
"${VENV_PY}" -m pip install --quiet pyyaml
|
||||
fi
|
||||
|
||||
PYTHON="${VENV_PY}"
|
||||
ok "Local venv ready: ${VENV_PATH}"
|
||||
else
|
||||
ok "Dependencies satisfied."
|
||||
fi
|
||||
|
||||
# ── Kill any stale instance on the same port ─────────────────────────────────
|
||||
hdr "Checking for existing instances..."
|
||||
|
||||
EXISTING=$(lsof -ti tcp:"${PORT}" 2>/dev/null || true)
|
||||
if [[ -n "${EXISTING}" ]]; then
|
||||
warn "Killing existing process on port ${PORT} (PID ${EXISTING})"
|
||||
kill "${EXISTING}" 2>/dev/null || true
|
||||
sleep 0.5
|
||||
fi
|
||||
|
||||
# Also kill any server.py process from this repo
|
||||
pkill -f "${REPO_ROOT}/server.py" 2>/dev/null || true
|
||||
|
||||
# ── Set up working directory for Hermes imports ───────────────────────────────
|
||||
# server.py / api/config.py inject agent dir into sys.path at import time,
|
||||
# but we also cd into the agent dir so relative imports in run_agent work.
|
||||
if [[ -n "${AGENT_DIR}" ]]; then
|
||||
WORKDIR="${AGENT_DIR}"
|
||||
else
|
||||
WORKDIR="${REPO_ROOT}"
|
||||
fi
|
||||
|
||||
# ── Launch ───────────────────────────────────────────────────────────────────
|
||||
hdr "Starting Hermes Web UI..."
|
||||
|
||||
LOG="/tmp/hermes-webui-${PORT}.log"
|
||||
export HERMES_WEBUI_HOST="${HERMES_WEBUI_HOST:-127.0.0.1}"
|
||||
export HERMES_WEBUI_STATE_DIR="${HERMES_WEBUI_STATE_DIR:-${HERMES_HOME}/webui}"
|
||||
|
||||
nohup "${PYTHON}" "${REPO_ROOT}/server.py" \
|
||||
> "${LOG}" 2>&1 &
|
||||
PID=$!
|
||||
|
||||
echo -e "\n${CYAN} PID ${PID} starting...${RESET}"
|
||||
sleep 1.5
|
||||
|
||||
# ── Health check ─────────────────────────────────────────────────────────────
|
||||
HEALTH_URL="http://${HERMES_WEBUI_HOST:-127.0.0.1}:${PORT}/health"
|
||||
MAX_WAIT=15
|
||||
ELAPSED=0
|
||||
while [[ $ELAPSED -lt $MAX_WAIT ]]; do
|
||||
if curl -sf "${HEALTH_URL}" | grep -q '"status"' 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
ELAPSED=$((ELAPSED + 1))
|
||||
done
|
||||
|
||||
if ! curl -sf "${HEALTH_URL}" | grep -q '"status"' 2>/dev/null; then
|
||||
warn "Health check did not pass within ${MAX_WAIT}s. Check log:"
|
||||
tail -20 "${LOG}"
|
||||
echo ""
|
||||
warn "Server may still be starting. Try: curl ${HEALTH_URL}"
|
||||
else
|
||||
ok "Server is healthy."
|
||||
fi
|
||||
|
||||
# ── Print access instructions ─────────────────────────────────────────────────
|
||||
BIND_HOST="${HERMES_WEBUI_HOST:-127.0.0.1}"
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}========================================${RESET}"
|
||||
echo -e "${GREEN} Hermes Web UI is running${RESET}"
|
||||
echo -e "${BOLD}========================================${RESET}"
|
||||
echo ""
|
||||
|
||||
if [[ "${BIND_HOST}" == "127.0.0.1" || "${BIND_HOST}" == "localhost" ]]; then
|
||||
# Server is bound to loopback -- detect if we are on a remote machine
|
||||
# by checking if $SSH_CLIENT or $SSH_TTY is set
|
||||
if [[ -n "${SSH_CLIENT:-}" || -n "${SSH_TTY:-}" ]]; then
|
||||
SERVER_IP="$(hostname -I 2>/dev/null | awk '{print $1}' || echo "<your-server-ip>")"
|
||||
echo -e " You are on a remote machine. To access from your local browser:"
|
||||
echo ""
|
||||
echo -e " ${CYAN}ssh -N -L ${PORT}:127.0.0.1:${PORT} \$(whoami)@${SERVER_IP}${RESET}"
|
||||
echo ""
|
||||
echo -e " Then open: ${BOLD}http://localhost:${PORT}${RESET}"
|
||||
else
|
||||
echo -e " Open: ${BOLD}http://localhost:${PORT}${RESET}"
|
||||
fi
|
||||
else
|
||||
echo -e " Open: ${BOLD}http://${BIND_HOST}:${PORT}${RESET}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e " Log: ${LOG}"
|
||||
echo -e " PID: ${PID}"
|
||||
echo ""
|
||||
exec "${PYTHON}" "${REPO_ROOT}/bootstrap.py" --no-browser "$@"
|
||||
|
||||
1189
static/boot.js
1189
static/boot.js
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
BIN
static/favicon-32.png
Normal file
BIN
static/favicon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
BIN
static/favicon.ico
Normal file
BIN
static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
20
static/favicon.svg
Normal file
20
static/favicon.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="12" fill="#1a1a1a"/>
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#F5C542;stop-opacity:1"/>
|
||||
<stop offset="100%" style="stop-color:#D4961C;stop-opacity:1"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="30" y="10" width="4" height="46" rx="2" fill="url(#g)"/>
|
||||
<path d="M30 18 C24 14, 14 14, 10 18 C14 16, 22 16, 28 20" fill="#F5C542" opacity="0.9"/>
|
||||
<path d="M30 22 C26 19, 18 19, 14 22 C18 20, 24 20, 28 24" fill="#D4961C" opacity="0.8"/>
|
||||
<path d="M34 18 C40 14, 50 14, 54 18 C50 16, 42 16, 36 20" fill="#F5C542" opacity="0.9"/>
|
||||
<path d="M34 22 C38 19, 46 19, 50 22 C46 20, 40 20, 36 24" fill="#D4961C" opacity="0.8"/>
|
||||
<path d="M32 48 C22 44, 20 38, 26 34 C20 36, 18 42, 24 46 C18 40, 22 30, 30 28 C24 32, 22 38, 28 42"
|
||||
fill="none" stroke="#F5C542" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<path d="M32 48 C42 44, 44 38, 38 34 C44 36, 46 42, 40 46 C46 40, 42 30, 34 28 C40 32, 42 38, 36 42"
|
||||
fill="none" stroke="#D4961C" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="10" r="4" fill="#F5C542"/>
|
||||
<circle cx="32" cy="10" r="2" fill="#FFF8E1" opacity="0.7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
6553
static/i18n.js
Normal file
6553
static/i18n.js
Normal file
File diff suppressed because it is too large
Load Diff
80
static/icons.js
Normal file
80
static/icons.js
Normal file
@@ -0,0 +1,80 @@
|
||||
// ── Lucide icon library (self-hosted SVG paths, no CDN dependency) ──────────
|
||||
// All icons are 24×24 viewBox, stroke-based, currentColor.
|
||||
// Usage: li('folder') → returns a ready-to-embed SVG string
|
||||
// The returned SVG uses display:inline-block + vertical-align so it sits
|
||||
// neatly beside text in both HTML templates and innerHTML assignments.
|
||||
|
||||
const LI_PATHS = {
|
||||
// Navigation tabs
|
||||
'message-square': '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
||||
'calendar': '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
|
||||
'layers': '<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>',
|
||||
'lightbulb': '<path d="M12 2a7 7 0 0 1 7 7c0 2.5-1.3 4.7-3.2 6H8.2C6.3 13.7 5 11.5 5 9a7 7 0 0 1 7-7z"/><line x1="9" y1="17" x2="15" y2="17"/><line x1="10" y1="20" x2="14" y2="20"/>',
|
||||
'folder': '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
|
||||
'list-todo': '<rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>',
|
||||
// Editing / actions
|
||||
'pencil': '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>',
|
||||
'save': '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>',
|
||||
'chevron-down': '<polyline points="6 9 12 15 18 9"/>',
|
||||
'chevron-right': '<polyline points="9 18 15 12 9 6"/>',
|
||||
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
|
||||
'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
|
||||
'braces': '<path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/>',
|
||||
'trash-2': '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/>',
|
||||
'settings': '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
|
||||
'alert-triangle': '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||
'refresh-cw': '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
|
||||
'undo': '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11"/>',
|
||||
'check': '<polyline points="20 6 9 17 4 12"/>',
|
||||
'lock': '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
'star': '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
|
||||
'x': '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
|
||||
'square': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>',
|
||||
'plus': '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
|
||||
'arrow-up': '<line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/>',
|
||||
'arrow-right': '<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>',
|
||||
'loader': '<line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"/><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"/>',
|
||||
'pause': '<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>',
|
||||
// Tool icons
|
||||
'terminal': '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>',
|
||||
'file-text': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>',
|
||||
'file-pen': '<path d="M12 22h6a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v10"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10.4 19.4 14 16l-4-1 .4 4.4z"/><path d="m14 16 1.5-1.5a2.12 2.12 0 0 1 3 3L17 19"/>',
|
||||
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
|
||||
'globe': '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
|
||||
'play': '<polygon points="5 3 19 12 5 21 5 3"/>',
|
||||
'wrench': '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>',
|
||||
'brain': '<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2z"/><path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2z"/>',
|
||||
'book-open': '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>',
|
||||
'grip-vertical': '<circle cx="9" cy="5" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="19" r="1"/>',
|
||||
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
||||
'bot': '<rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16"/><line x1="16" y1="16" x2="16" y2="16"/>',
|
||||
'eye': '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
|
||||
'shuffle': '<polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/>',
|
||||
'paperclip': '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.82-2.82l8.48-8.48"/>',
|
||||
'copy': '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
|
||||
'rotate-ccw': '<path d="M3 2v6h6"/><path d="M3 8a9 9 0 1 0 2.64-4.36L3 8"/>',
|
||||
'user': '<path d="M20 21a8 8 0 0 0-16 0"/><circle cx="12" cy="7" r="4"/>',
|
||||
// File-type icons
|
||||
'image': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>',
|
||||
'file-code': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><polyline points="10 13 8 15 10 17"/><polyline points="14 13 16 15 14 17"/>',
|
||||
'zap': '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
|
||||
// Suggestion buttons
|
||||
'clipboard-list': '<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="9" y1="16" x2="12" y2="16"/>',
|
||||
'map': '<polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/><line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/>',
|
||||
'git-branch': '<line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a Lucide SVG string for the given icon name.
|
||||
* @param {string} name – key in LI_PATHS (e.g. 'folder', 'trash-2')
|
||||
* @param {number} size – width/height in px (default 16)
|
||||
* @returns {string} SVG element string ready for innerHTML
|
||||
*/
|
||||
function li(name, size = 16) {
|
||||
const p = LI_PATHS[name];
|
||||
if (!p) { console.warn('li(): unknown icon', name); return ''; }
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" `
|
||||
+ `stroke="currentColor" stroke-width="2" stroke-linecap="round" `
|
||||
+ `stroke-linejoin="round" aria-hidden="true" `
|
||||
+ `style="display:inline-block;vertical-align:-0.15em;flex-shrink:0">${p}</svg>`;
|
||||
}
|
||||
1180
static/index.html
1180
static/index.html
File diff suppressed because it is too large
Load Diff
69
static/login.js
Normal file
69
static/login.js
Normal file
@@ -0,0 +1,69 @@
|
||||
/* Login page — external script, no inline handlers.
|
||||
* Loaded by the /login route. Reads data attributes from the form for
|
||||
* i18n strings so the server does not need to inject JS literals.
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var form = document.getElementById('login-form');
|
||||
var input = document.getElementById('pw');
|
||||
|
||||
if (!form || !input) return;
|
||||
|
||||
var invalidPw = form.getAttribute('data-invalid-pw') || 'Invalid password';
|
||||
var connFailed = form.getAttribute('data-conn-failed') || 'Connection failed';
|
||||
|
||||
function showErr(msg) {
|
||||
var err = document.getElementById('err');
|
||||
if (err) { err.textContent = msg; err.style.display = 'block'; }
|
||||
}
|
||||
|
||||
function hideErr() {
|
||||
var err = document.getElementById('err');
|
||||
if (err) { err.style.display = 'none'; }
|
||||
}
|
||||
|
||||
// Return the ?next= redirect path if present and safe, otherwise './'
|
||||
// Guards against open-redirect: rejects protocol-relative (//evil.com),
|
||||
// absolute URLs, backslash variants, and control characters.
|
||||
function _safeNextPath() {
|
||||
try {
|
||||
var raw = new URL(window.location.href).searchParams.get('next');
|
||||
if (!raw) return './';
|
||||
if (raw.charAt(0) !== '/') return './'; // must be path-absolute
|
||||
if (raw.charAt(1) === '/' || raw.charAt(1) === '\\') return './'; // reject // and \\
|
||||
if (/[\x00-\x1f\x7f\s]/.test(raw)) return './'; // reject control chars / whitespace
|
||||
return raw;
|
||||
} catch (_) { return './'; }
|
||||
}
|
||||
|
||||
async function doLogin(e) {
|
||||
e.preventDefault();
|
||||
var pw = input.value;
|
||||
hideErr();
|
||||
try {
|
||||
var res = await fetch('api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: pw }),
|
||||
credentials: 'include',
|
||||
});
|
||||
var data = {};
|
||||
try { data = await res.json(); } catch (_) {}
|
||||
if (res.ok && data.ok) {
|
||||
window.location.href = _safeNextPath();
|
||||
} else {
|
||||
showErr(data.error || invalidPw);
|
||||
}
|
||||
} catch (ex) {
|
||||
showErr(connFailed);
|
||||
}
|
||||
}
|
||||
|
||||
form.addEventListener('submit', doLogin);
|
||||
|
||||
input.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
doLogin(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
23
static/manifest.json
Normal file
23
static/manifest.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "Hermes",
|
||||
"short_name": "Hermes",
|
||||
"description": "Hermes AI Agent Web UI",
|
||||
"start_url": "./",
|
||||
"display": "standalone",
|
||||
"background_color": "#1a1a1a",
|
||||
"theme_color": "#1a1a1a",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "static/favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "static/favicon-32.png",
|
||||
"sizes": "32x32",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
1790
static/messages.js
1790
static/messages.js
File diff suppressed because it is too large
Load Diff
411
static/onboarding.js
Normal file
411
static/onboarding.js
Normal file
@@ -0,0 +1,411 @@
|
||||
const ONBOARDING={status:null,step:0,steps:['system','setup','workspace','password','finish'],form:{provider:'openrouter',workspace:'',model:'',password:'',apiKey:'',baseUrl:''},active:false};
|
||||
|
||||
function _getOnboardingSetupProviders(){
|
||||
return (((ONBOARDING.status||{}).setup||{}).providers)||[];
|
||||
}
|
||||
|
||||
function _getOnboardingSetupProvider(id){
|
||||
return _getOnboardingSetupProviders().find(p=>p.id===id)||null;
|
||||
}
|
||||
|
||||
function _getOnboardingSetupCategories(){
|
||||
return (((ONBOARDING.status||{}).setup||{}).categories)||[];
|
||||
}
|
||||
|
||||
/** Render the provider <select> with <optgroup> per category. */
|
||||
function _renderProviderSelectOptions(selectedId){
|
||||
const providers=_getOnboardingSetupProviders();
|
||||
const categories=_getOnboardingSetupCategories();
|
||||
const provMap={};
|
||||
providers.forEach(p=>{provMap[p.id]=p;});
|
||||
if(!categories.length){
|
||||
// Fallback: flat list when no categories are available.
|
||||
return providers.map(p=>`<option value="${esc(p.id)}">${esc(p.label)}${p.quick?' — '+esc(t('onboarding_quick_setup_badge')):''}</option>`).join('');
|
||||
}
|
||||
return categories.map(cat=>{
|
||||
const opts=cat.providers.map(pid=>{
|
||||
const p=provMap[pid];
|
||||
if(!p)return '';
|
||||
return `<option value="${esc(p.id)}"${p.id===selectedId?' selected':''}>${esc(p.label)}${p.quick?' — '+esc(t('onboarding_quick_setup_badge')):''}</option>`;
|
||||
}).join('');
|
||||
return `<optgroup label="${esc(t('provider_category_'+cat.id)||cat.label)}">${opts}</optgroup>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function _getOnboardingCurrentSetup(){
|
||||
return (((ONBOARDING.status||{}).setup||{}).current)||{};
|
||||
}
|
||||
|
||||
function _onboardingStepMeta(key){
|
||||
return ({
|
||||
system:{title:t('onboarding_step_system_title'),desc:t('onboarding_step_system_desc')},
|
||||
setup:{title:t('onboarding_step_setup_title'),desc:t('onboarding_step_setup_desc')},
|
||||
workspace:{title:t('onboarding_step_workspace_title'),desc:t('onboarding_step_workspace_desc')},
|
||||
password:{title:t('onboarding_step_password_title'),desc:t('onboarding_step_password_desc')},
|
||||
finish:{title:t('onboarding_step_finish_title'),desc:t('onboarding_step_finish_desc')}
|
||||
})[key];
|
||||
}
|
||||
|
||||
function _renderOnboardingSteps(){
|
||||
const wrap=$('onboardingSteps');
|
||||
if(!wrap)return;
|
||||
wrap.innerHTML='';
|
||||
ONBOARDING.steps.forEach((key,idx)=>{
|
||||
const meta=_onboardingStepMeta(key);
|
||||
const item=document.createElement('div');
|
||||
item.className='onboarding-step'+(idx===ONBOARDING.step?' active':idx<ONBOARDING.step?' done':'');
|
||||
item.innerHTML=`<div class="onboarding-step-index">${idx+1}</div><div><div class="onboarding-step-title">${meta.title}</div><div class="onboarding-step-desc">${meta.desc}</div></div>`;
|
||||
wrap.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function _setOnboardingNotice(msg,kind='info'){
|
||||
const el=$('onboardingNotice');
|
||||
if(!el)return;
|
||||
if(!msg){el.style.display='none';el.textContent='';el.className='onboarding-status';return;}
|
||||
el.style.display='block';
|
||||
el.className='onboarding-status '+kind;
|
||||
el.textContent=msg;
|
||||
}
|
||||
|
||||
function _getOnboardingWorkspaceChoices(){
|
||||
const items=((ONBOARDING.status||{}).workspaces||{}).items||[];
|
||||
return items.length?items:[{name:'Home',path:ONBOARDING.form.workspace||''}];
|
||||
}
|
||||
|
||||
function _getOnboardingProviderModelChoices(){
|
||||
const provider=_getOnboardingSetupProvider(ONBOARDING.form.provider);
|
||||
return provider?(provider.models||[]):[];
|
||||
}
|
||||
|
||||
function _getOnboardingSelectedModel(){
|
||||
return ONBOARDING.form.model||'';
|
||||
}
|
||||
|
||||
function _renderOnboardingModelField(){
|
||||
const choices=_getOnboardingProviderModelChoices();
|
||||
if(ONBOARDING.form.provider==='custom'){
|
||||
return `<label class="onboarding-field"><span>${t('onboarding_model_label')}</span><input id="onboardingModelInput" value="${esc(_getOnboardingSelectedModel())}" placeholder="${t('onboarding_custom_model_placeholder')}" oninput="ONBOARDING.form.model=this.value"></label><p class="onboarding-copy">${t('onboarding_custom_model_help')}</p>`;
|
||||
}
|
||||
const options=choices.map(m=>`<option value="${esc(m.id)}">${esc(m.label)}</option>`).join('');
|
||||
return `<label class="onboarding-field"><span>${t('onboarding_model_label')}</span><select id="onboardingModelSelect" onchange="ONBOARDING.form.model=this.value">${options}</select></label><p class="onboarding-copy">${t('onboarding_workspace_help')}</p>`;
|
||||
}
|
||||
|
||||
function _providerStatusLabel(system){
|
||||
if(system.chat_ready) return t('onboarding_check_provider_ready');
|
||||
if(system.provider_configured) return t('onboarding_check_provider_partial');
|
||||
return t('onboarding_check_provider_pending');
|
||||
}
|
||||
|
||||
function _renderOnboardingBody(){
|
||||
const body=$('onboardingBody');
|
||||
if(!body||!ONBOARDING.status)return;
|
||||
const key=ONBOARDING.steps[ONBOARDING.step];
|
||||
const system=ONBOARDING.status.system||{};
|
||||
const settings=ONBOARDING.status.settings||{};
|
||||
const setup=ONBOARDING.status.setup||{};
|
||||
const nextBtn=$('onboardingNextBtn');
|
||||
const backBtn=$('onboardingBackBtn');
|
||||
if(backBtn) backBtn.style.display=ONBOARDING.step>0?'':'none';
|
||||
if(nextBtn) nextBtn.textContent=key==='finish'?t('onboarding_open'):t('onboarding_continue');
|
||||
|
||||
if(key==='system'){
|
||||
const hermesOk=system.hermes_found&&system.imports_ok;
|
||||
const setupOk=!!system.chat_ready;
|
||||
_setOnboardingNotice(system.provider_note|| (setupOk?t('onboarding_notice_system_ready'):t('onboarding_notice_system_unavailable')),setupOk?'success':(hermesOk?'info':'warn'));
|
||||
body.innerHTML=`
|
||||
<div class="onboarding-panel-grid">
|
||||
<div class="onboarding-check ${hermesOk?'ok':'warn'}"><strong>${t('onboarding_check_agent')}</strong><span>${hermesOk?t('onboarding_check_agent_ready'):t('onboarding_check_agent_missing')}</span></div>
|
||||
<div class="onboarding-check ${(setupOk?'ok':system.provider_configured?'warn':'muted')}"><strong>${t('onboarding_check_provider')}</strong><span>${_providerStatusLabel(system)}</span></div>
|
||||
<div class="onboarding-check ${(settings.password_enabled?'ok':'muted')}"><strong>${t('onboarding_check_password')}</strong><span>${settings.password_enabled?t('onboarding_check_password_enabled'):t('onboarding_check_password_disabled')}</span></div>
|
||||
</div>
|
||||
<div class="onboarding-copy">
|
||||
<p><strong>${t('onboarding_config_file')}</strong> ${esc(system.config_path||t('onboarding_unknown'))}</p>
|
||||
<p><strong>${t('onboarding_env_file')}</strong> ${esc(system.env_path||t('onboarding_unknown'))}</p>
|
||||
<p>${esc(system.provider_note||'')}</p>
|
||||
${system.current_provider?`<p><strong>${t('onboarding_current_provider')}</strong> ${esc(system.current_provider)}${system.current_model?` — ${esc(system.current_model)}`:''}</p>`:''}
|
||||
${system.current_base_url?`<p><strong>${t('onboarding_base_url_label')}</strong> ${esc(system.current_base_url)}</p>`:''}
|
||||
${system.missing_modules&&system.missing_modules.length?`<p><strong>${t('onboarding_missing_imports')}</strong> ${esc(system.missing_modules.join(', '))}</p>`:''}
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if(key==='setup'){
|
||||
const selectedId=ONBOARDING.form.provider;
|
||||
const groupedOptions=_renderProviderSelectOptions(selectedId);
|
||||
const provider=_getOnboardingSetupProvider(selectedId)||_getOnboardingSetupProviders()[0]||null;
|
||||
const showBaseUrl=provider&&provider.requires_base_url;
|
||||
const keyHelp=provider?`${t('onboarding_api_key_help_prefix')} ${esc(provider.env_var)}.`:'';
|
||||
|
||||
// OAuth provider path: configured via CLI, no API key input needed.
|
||||
const currentIsOauth=!!(ONBOARDING.status.setup||{}).current_is_oauth;
|
||||
const currentProviderName=((ONBOARDING.status.setup||{}).current||{}).provider||'';
|
||||
if(currentIsOauth){
|
||||
const isReady=!!(ONBOARDING.status.system||{}).chat_ready;
|
||||
const providerLabel=esc(currentProviderName);
|
||||
if(isReady){
|
||||
_setOnboardingNotice(t('onboarding_notice_setup_already_ready'),'success');
|
||||
body.innerHTML=`
|
||||
<div class="onboarding-oauth-card onboarding-oauth-ready">
|
||||
<div class="onboarding-oauth-icon">✓</div>
|
||||
<div>
|
||||
<strong>${t('onboarding_oauth_provider_ready_title')}</strong>
|
||||
<p>${t('onboarding_oauth_provider_ready_body').replace('{provider}',providerLabel)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="onboarding-copy" style="margin-top:20px">${t('onboarding_oauth_switch_hint')}</p>
|
||||
<label class="onboarding-field">
|
||||
<span>${t('onboarding_provider_label')}</span>
|
||||
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${groupedOptions}</select>
|
||||
</label>
|
||||
<label class="onboarding-field" id="onboardingApiKeyField">
|
||||
<span>${t('onboarding_api_key_label')}</span>
|
||||
<input id="onboardingApiKeyInput" type="password" value="${esc(ONBOARDING.form.apiKey||'')}" placeholder="${t('onboarding_api_key_placeholder')}" oninput="ONBOARDING.form.apiKey=this.value">
|
||||
</label>
|
||||
${showBaseUrl?`<label class="onboarding-field"><span>${t('onboarding_base_url_label')}</span><input id="onboardingBaseUrlInput" value="${esc(ONBOARDING.form.baseUrl||'')}" placeholder="${t('onboarding_base_url_placeholder')}" oninput="ONBOARDING.form.baseUrl=this.value"></label>`:''}
|
||||
<p class="onboarding-copy">${keyHelp}</p>`;
|
||||
} else {
|
||||
_setOnboardingNotice(t('onboarding_notice_setup_required'),'warn');
|
||||
body.innerHTML=`
|
||||
<div class="onboarding-oauth-card onboarding-oauth-pending">
|
||||
<div class="onboarding-oauth-icon">⚠</div>
|
||||
<div>
|
||||
<strong>${t('onboarding_oauth_provider_not_ready_title')}</strong>
|
||||
<p>${t('onboarding_oauth_provider_not_ready_body').replace('{provider}',providerLabel)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="onboarding-copy" style="margin-top:20px">${t('onboarding_oauth_switch_hint')}</p>
|
||||
<label class="onboarding-field">
|
||||
<span>${t('onboarding_provider_label')}</span>
|
||||
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${groupedOptions}</select>
|
||||
</label>
|
||||
<label class="onboarding-field" id="onboardingApiKeyField">
|
||||
<span>${t('onboarding_api_key_label')}</span>
|
||||
<input id="onboardingApiKeyInput" type="password" value="${esc(ONBOARDING.form.apiKey||'')}" placeholder="${t('onboarding_api_key_placeholder')}" oninput="ONBOARDING.form.apiKey=this.value">
|
||||
</label>
|
||||
${showBaseUrl?`<label class="onboarding-field"><span>${t('onboarding_base_url_label')}</span><input id="onboardingBaseUrlInput" value="${esc(ONBOARDING.form.baseUrl||'')}" placeholder="${t('onboarding_base_url_placeholder')}" oninput="ONBOARDING.form.baseUrl=this.value"></label>`:''}
|
||||
<p class="onboarding-copy">${keyHelp}</p>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_setOnboardingNotice(system.chat_ready?t('onboarding_notice_setup_already_ready'):t('onboarding_notice_setup_required'),system.chat_ready?'success':'info');
|
||||
body.innerHTML=`
|
||||
<label class="onboarding-field">
|
||||
<span>${t('onboarding_provider_label')}</span>
|
||||
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${groupedOptions}</select>
|
||||
</label>
|
||||
<label class="onboarding-field">
|
||||
<span>${t('onboarding_api_key_label')}</span>
|
||||
<input id="onboardingApiKeyInput" type="password" value="${esc(ONBOARDING.form.apiKey||'')}" placeholder="${t('onboarding_api_key_placeholder')}" oninput="ONBOARDING.form.apiKey=this.value">
|
||||
</label>
|
||||
${showBaseUrl?`<label class="onboarding-field"><span>${t('onboarding_base_url_label')}</span><input id="onboardingBaseUrlInput" value="${esc(ONBOARDING.form.baseUrl||'')}" placeholder="${t('onboarding_base_url_placeholder')}" oninput="ONBOARDING.form.baseUrl=this.value"></label>`:''}
|
||||
<p class="onboarding-copy">${keyHelp}</p>
|
||||
${showBaseUrl?`<p class="onboarding-copy">${t('onboarding_base_url_help')}</p>`:''}
|
||||
<p class="onboarding-copy">${esc(setup.unsupported_note||'')||''}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if(key==='workspace'){
|
||||
const workspaceOptions=_getOnboardingWorkspaceChoices().map(ws=>`<option value="${esc(ws.path)}">${esc(ws.name||ws.path)} — ${esc(ws.path)}</option>`).join('');
|
||||
_setOnboardingNotice(t('onboarding_notice_workspace'), 'info');
|
||||
body.innerHTML=`
|
||||
<label class="onboarding-field">
|
||||
<span>${t('onboarding_workspace_label')}</span>
|
||||
<select id="onboardingWorkspaceSelect" onchange="syncOnboardingWorkspaceSelect(this.value)">${workspaceOptions}</select>
|
||||
</label>
|
||||
<label class="onboarding-field">
|
||||
<span>${t('onboarding_workspace_or_path')}</span>
|
||||
<input id="onboardingWorkspaceInput" value="${esc(ONBOARDING.form.workspace||'')}" placeholder="${t('onboarding_workspace_placeholder')}" oninput="ONBOARDING.form.workspace=this.value">
|
||||
</label>
|
||||
${_renderOnboardingModelField()}`;
|
||||
const wsSel=$('onboardingWorkspaceSelect');
|
||||
if(wsSel && ONBOARDING.form.workspace) wsSel.value=ONBOARDING.form.workspace;
|
||||
const modelSel=$('onboardingModelSelect');
|
||||
if(modelSel && ONBOARDING.form.model) modelSel.value=ONBOARDING.form.model;
|
||||
return;
|
||||
}
|
||||
|
||||
if(key==='password'){
|
||||
_setOnboardingNotice(settings.password_enabled?t('onboarding_notice_password_enabled'):t('onboarding_notice_password_recommended'), settings.password_enabled?'success':'info');
|
||||
body.innerHTML=`
|
||||
<label class="onboarding-field">
|
||||
<span>${t('onboarding_password_label')}</span>
|
||||
<input id="onboardingPasswordInput" type="password" value="${esc(ONBOARDING.form.password||'')}" placeholder="${t('onboarding_password_placeholder')}" oninput="ONBOARDING.form.password=this.value">
|
||||
</label>
|
||||
<p class="onboarding-copy">${t('onboarding_password_help')}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const provider=_getOnboardingSetupProvider(ONBOARDING.form.provider);
|
||||
_setOnboardingNotice(t('onboarding_notice_finish'), 'success');
|
||||
body.innerHTML=`
|
||||
<div class="onboarding-summary">
|
||||
<div><strong>${t('onboarding_provider_label')}</strong><span>${esc((provider&&provider.label)||ONBOARDING.form.provider||t('onboarding_not_set'))}</span></div>
|
||||
<div><strong>${t('onboarding_model_label')}</strong><span>${esc(_getOnboardingSelectedModel()||t('onboarding_not_set'))}</span></div>
|
||||
<div><strong>${t('onboarding_workspace_label')}</strong><span>${esc(ONBOARDING.form.workspace||t('onboarding_not_set'))}</span></div>
|
||||
<div><strong>${t('onboarding_check_password')}</strong><span>${t(_getOnboardingPasswordSummaryKey(settings))}</span></div>
|
||||
</div>
|
||||
${ONBOARDING.form.baseUrl?`<p class="onboarding-copy"><strong>${t('onboarding_base_url_label')}</strong> ${esc(ONBOARDING.form.baseUrl)}</p>`:''}
|
||||
<p class="onboarding-copy">${t('onboarding_finish_help')}</p>`;
|
||||
}
|
||||
|
||||
function _getOnboardingPasswordSummaryKey(settings){
|
||||
const hasExistingPassword=!!(settings&&settings.password_enabled);
|
||||
const hasNewPassword=!!((ONBOARDING.form.password||'').trim());
|
||||
if(hasNewPassword) return hasExistingPassword?'onboarding_password_will_replace':'onboarding_password_will_enable';
|
||||
return hasExistingPassword?'onboarding_password_keep_existing':'onboarding_password_remains_disabled';
|
||||
}
|
||||
|
||||
function syncOnboardingWorkspaceSelect(value){
|
||||
ONBOARDING.form.workspace=value;
|
||||
const input=$('onboardingWorkspaceInput');
|
||||
if(input) input.value=value;
|
||||
}
|
||||
|
||||
function syncOnboardingProvider(value){
|
||||
const provider=_getOnboardingSetupProvider(value);
|
||||
ONBOARDING.form.provider=value;
|
||||
if(provider){
|
||||
if(!ONBOARDING.form.model || !_getOnboardingProviderModelChoices().some(m=>m.id===ONBOARDING.form.model) || value==='custom'){
|
||||
ONBOARDING.form.model=provider.default_model||'';
|
||||
}
|
||||
if(provider.requires_base_url){
|
||||
ONBOARDING.form.baseUrl=ONBOARDING.form.baseUrl||provider.default_base_url||'';
|
||||
}else{
|
||||
ONBOARDING.form.baseUrl=provider.default_base_url||'';
|
||||
}
|
||||
}
|
||||
_renderOnboardingBody();
|
||||
}
|
||||
|
||||
async function loadOnboardingWizard(){
|
||||
try{
|
||||
const status=await api('/api/onboarding/status');
|
||||
ONBOARDING.status=status;
|
||||
const current=((status.setup||{}).current)||{};
|
||||
ONBOARDING.form.provider=current.provider||'openrouter';
|
||||
ONBOARDING.form.workspace=(status.workspaces&&status.workspaces.last)||status.settings.default_workspace||'';
|
||||
ONBOARDING.form.model=status.settings.default_model||current.model||'';
|
||||
ONBOARDING.form.password='';
|
||||
ONBOARDING.form.apiKey='';
|
||||
ONBOARDING.form.baseUrl=current.base_url||'';
|
||||
ONBOARDING.active=!status.completed;
|
||||
if(!ONBOARDING.active) return false;
|
||||
$('onboardingOverlay').style.display='flex';
|
||||
_renderOnboardingSteps();
|
||||
_renderOnboardingBody();
|
||||
return true;
|
||||
}catch(e){
|
||||
console.warn('onboarding status failed',e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function prevOnboardingStep(){
|
||||
if(ONBOARDING.step===0)return;
|
||||
ONBOARDING.step--;
|
||||
_renderOnboardingSteps();
|
||||
_renderOnboardingBody();
|
||||
}
|
||||
|
||||
async function _saveOnboardingProviderSetup(){
|
||||
const provider=(ONBOARDING.form.provider||'').trim();
|
||||
const model=(ONBOARDING.form.model||'').trim();
|
||||
const apiKey=(ONBOARDING.form.apiKey||'').trim();
|
||||
const baseUrl=(ONBOARDING.form.baseUrl||'').trim();
|
||||
const current=_getOnboardingCurrentSetup();
|
||||
const isUnchanged=current.provider===provider&&((current.model||'')===model)&&((current.base_url||'')===baseUrl);
|
||||
// Skip the POST when nothing changed. We also skip when the provider is
|
||||
// unsupported/OAuth-based and already working — chat_ready may be false for
|
||||
// providers not in the quick-setup list (e.g. minimax-cn) even though they are
|
||||
// fully configured. Posting in that case would either be a no-op (the server
|
||||
// just marks complete for unsupported providers) or could silently overwrite
|
||||
// config.yaml if the user accidentally changed the provider dropdown.
|
||||
const currentIsOauth=!!(ONBOARDING.status&&ONBOARDING.status.setup&&ONBOARDING.status.setup.current_is_oauth);
|
||||
if(isUnchanged && !apiKey && ((ONBOARDING.status.system||{}).chat_ready || currentIsOauth)) return;
|
||||
const body={provider,model};
|
||||
if(apiKey) body.api_key=apiKey;
|
||||
if(baseUrl) body.base_url=baseUrl;
|
||||
const status=await api('/api/onboarding/setup',{method:'POST',body:JSON.stringify(body)});
|
||||
ONBOARDING.status=status;
|
||||
}
|
||||
|
||||
async function _saveOnboardingDefaults(){
|
||||
const workspace=(ONBOARDING.form.workspace||'').trim();
|
||||
const model=(ONBOARDING.form.model||'').trim();
|
||||
const password=(ONBOARDING.form.password||'').trim();
|
||||
if(!workspace) throw new Error(t('onboarding_error_choose_workspace'));
|
||||
if(!model) throw new Error(t('onboarding_error_choose_model'));
|
||||
const known=_getOnboardingWorkspaceChoices().some(ws=>ws.path===workspace);
|
||||
if(!known){
|
||||
await api('/api/workspaces/add',{method:'POST',body:JSON.stringify({path:workspace})});
|
||||
}
|
||||
// Model persisted by /api/onboarding/setup — no /api/default-model call needed here
|
||||
const body={default_workspace:workspace};
|
||||
if(password) body._set_password=password;
|
||||
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
|
||||
if(ONBOARDING.status){
|
||||
ONBOARDING.status.settings={...(ONBOARDING.status.settings||{}),password_enabled:!!saved.auth_enabled};
|
||||
}
|
||||
localStorage.setItem('hermes-webui-model',model);
|
||||
if($('modelSelect')) _applyModelToDropdown(model,$('modelSelect'));
|
||||
}
|
||||
|
||||
async function _finishOnboarding(){
|
||||
await _saveOnboardingProviderSetup();
|
||||
await _saveOnboardingDefaults();
|
||||
const done=await api('/api/onboarding/complete',{method:'POST',body:'{}'});
|
||||
ONBOARDING.status=done;
|
||||
ONBOARDING.active=false;
|
||||
$('onboardingOverlay').style.display='none';
|
||||
showToast(t('onboarding_complete'));
|
||||
await loadWorkspaceList();
|
||||
if(typeof renderSessionList==='function') await renderSessionList();
|
||||
if(!S.session && typeof newSession==='function'){
|
||||
await newSession(true);
|
||||
await renderSessionList();
|
||||
}
|
||||
}
|
||||
|
||||
async function skipOnboarding(){
|
||||
try{
|
||||
// Mark onboarding completed server-side without changing any config
|
||||
await api('/api/onboarding/complete',{method:'POST',body:'{}'});
|
||||
ONBOARDING.active=false;
|
||||
$('onboardingOverlay').style.display='none';
|
||||
showToast(t('onboarding_skipped')||'Setup skipped');
|
||||
}catch(e){
|
||||
_setOnboardingNotice((e.message||String(e)),'warn');
|
||||
}
|
||||
}
|
||||
|
||||
async function nextOnboardingStep(){
|
||||
try{
|
||||
if(ONBOARDING.steps[ONBOARDING.step]==='setup'){
|
||||
ONBOARDING.form.provider=(($('onboardingProviderSelect')||{}).value||ONBOARDING.form.provider||'').trim();
|
||||
ONBOARDING.form.apiKey=(($('onboardingApiKeyInput')||{}).value||'').trim();
|
||||
ONBOARDING.form.baseUrl=(($('onboardingBaseUrlInput')||{}).value||ONBOARDING.form.baseUrl||'').trim();
|
||||
if(!ONBOARDING.form.provider) throw new Error(t('onboarding_error_provider_required'));
|
||||
if(ONBOARDING.form.provider==='custom' && !ONBOARDING.form.baseUrl) throw new Error(t('onboarding_error_base_url_required'));
|
||||
}
|
||||
if(ONBOARDING.steps[ONBOARDING.step]==='workspace'){
|
||||
ONBOARDING.form.workspace=(($('onboardingWorkspaceInput')||{}).value||ONBOARDING.form.workspace||'').trim();
|
||||
ONBOARDING.form.model=(($('onboardingModelInput')||{}).value||($('onboardingModelSelect')||{}).value||ONBOARDING.form.model||'').trim();
|
||||
if(!ONBOARDING.form.workspace) throw new Error(t('onboarding_error_workspace_required'));
|
||||
if(!ONBOARDING.form.model) throw new Error(t('onboarding_error_model_required'));
|
||||
}
|
||||
if(ONBOARDING.steps[ONBOARDING.step]==='password'){
|
||||
ONBOARDING.form.password=(($('onboardingPasswordInput')||{}).value||'').trim();
|
||||
}
|
||||
if(ONBOARDING.step===ONBOARDING.steps.length-1){
|
||||
await _finishOnboarding();
|
||||
return;
|
||||
}
|
||||
ONBOARDING.step++;
|
||||
_renderOnboardingSteps();
|
||||
_renderOnboardingBody();
|
||||
}catch(e){
|
||||
_setOnboardingNotice(e.message||String(e),'warn');
|
||||
}
|
||||
}
|
||||
3767
static/panels.js
3767
static/panels.js
File diff suppressed because it is too large
Load Diff
1851
static/sessions.js
1851
static/sessions.js
File diff suppressed because it is too large
Load Diff
2749
static/style.css
2749
static/style.css
File diff suppressed because it is too large
Load Diff
115
static/sw.js
Normal file
115
static/sw.js
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Hermes WebUI Service Worker
|
||||
* Minimal PWA service worker — enables "Add to Home Screen".
|
||||
* No offline caching of API responses (the UI requires a live backend).
|
||||
* Caches only static shell assets so the app shell loads fast on repeat visits.
|
||||
*/
|
||||
|
||||
// Cache version is injected by the server at request time (routes.py /sw.js handler).
|
||||
// Bumps automatically whenever the git commit changes — no manual edits needed.
|
||||
const CACHE_NAME = 'hermes-shell-__CACHE_VERSION__';
|
||||
|
||||
// Static assets that form the app shell
|
||||
const SHELL_ASSETS = [
|
||||
'./',
|
||||
'./static/style.css',
|
||||
'./static/boot.js',
|
||||
'./static/ui.js',
|
||||
'./static/messages.js',
|
||||
'./static/sessions.js',
|
||||
'./static/panels.js',
|
||||
'./static/commands.js',
|
||||
'./static/icons.js',
|
||||
'./static/i18n.js',
|
||||
'./static/workspace.js',
|
||||
'./static/terminal.js',
|
||||
'./static/onboarding.js',
|
||||
'./static/favicon.svg',
|
||||
'./static/favicon-32.png',
|
||||
'./manifest.json',
|
||||
];
|
||||
|
||||
// Install: pre-cache the app shell
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
return cache.addAll(SHELL_ASSETS).catch((err) => {
|
||||
// Non-fatal: if any asset fails, still activate
|
||||
console.warn('[sw] Shell pre-cache partial failure:', err);
|
||||
});
|
||||
})
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate: clean up old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(
|
||||
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
|
||||
)
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Fetch strategy:
|
||||
// - API calls (/api/*, /stream) → always network (never cache)
|
||||
// - Shell assets → cache-first with network fallback
|
||||
// - Everything else → network-first, fall back to offline page
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Never intercept cross-origin requests
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// Never intercept the service worker script itself. Returning a cached sw.js
|
||||
// prevents the browser from seeing a new cache version after local patches.
|
||||
if (url.pathname.endsWith('/sw.js')) return;
|
||||
|
||||
// API and streaming endpoints — always go to network.
|
||||
// The WebUI may be mounted under a subpath such as /hermes/, so API
|
||||
// requests can look like /hermes/api/sessions rather than /api/sessions.
|
||||
if (
|
||||
url.pathname.startsWith('/api/') ||
|
||||
url.pathname.includes('/api/') ||
|
||||
url.pathname.includes('/stream') ||
|
||||
url.pathname.startsWith('/health') ||
|
||||
url.pathname.includes('/health')
|
||||
) {
|
||||
return; // let browser handle normally
|
||||
}
|
||||
|
||||
// Shell assets: cache-first
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) => {
|
||||
if (cached) return cached;
|
||||
return fetch(event.request).then((response) => {
|
||||
// Cache successful GET responses for shell assets
|
||||
if (
|
||||
event.request.method === 'GET' &&
|
||||
response.status === 200
|
||||
) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
||||
}
|
||||
return response;
|
||||
}).catch(() => {
|
||||
// Offline fallback for navigation requests.
|
||||
// Note: caches.match() returns a Promise (always truthy in a `||` check),
|
||||
// so we must await/then to unwrap it — otherwise the `new Response(...)`
|
||||
// branch is dead code and the browser falls back to its default offline page.
|
||||
if (event.request.mode === 'navigate') {
|
||||
return caches.match('./').then((cached) => cached || new Response(
|
||||
'<html><body style="font-family:sans-serif;padding:2rem;background:#1a1a1a;color:#ccc">' +
|
||||
'<h2>You are offline</h2>' +
|
||||
'<p>Hermes requires a server connection. Please check your network and try again.</p>' +
|
||||
'</body></html>',
|
||||
{ headers: { 'Content-Type': 'text/html' } }
|
||||
));
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
632
static/terminal.js
Normal file
632
static/terminal.js
Normal file
@@ -0,0 +1,632 @@
|
||||
const TERMINAL_UI={
|
||||
open:false,
|
||||
collapsed:false,
|
||||
sessionId:null,
|
||||
workspace:null,
|
||||
source:null,
|
||||
term:null,
|
||||
fitAddon:null,
|
||||
resizeObserver:null,
|
||||
resizeTimer:null,
|
||||
closeTimer:null,
|
||||
typedLine:'',
|
||||
height:null,
|
||||
resizeHandleReady:false,
|
||||
resizing:false,
|
||||
resizeStartY:0,
|
||||
resizeStartHeight:0,
|
||||
};
|
||||
|
||||
const TERMINAL_HEIGHT_DEFAULT=260;
|
||||
const TERMINAL_HEIGHT_MIN=180;
|
||||
const TERMINAL_HEIGHT_MAX=520;
|
||||
const TERMINAL_MOBILE_HEIGHT_DEFAULT=190;
|
||||
const TERMINAL_MOBILE_HEIGHT_MIN=140;
|
||||
const TERMINAL_MOBILE_HEIGHT_MAX=300;
|
||||
|
||||
function _terminalEls(){
|
||||
return {
|
||||
panel:$('composerTerminalPanel'),
|
||||
inner:$('composerTerminalPanel')&&$('composerTerminalPanel').querySelector('.composer-terminal-inner'),
|
||||
dock:$('composerTerminalDock'),
|
||||
viewport:$('terminalViewport'),
|
||||
surface:$('terminalSurface'),
|
||||
toggle:$('btnTerminalToggle'),
|
||||
workspace:$('terminalWorkspaceLabel'),
|
||||
dockWorkspace:$('terminalDockWorkspaceLabel'),
|
||||
handle:$('terminalResizeHandle'),
|
||||
};
|
||||
}
|
||||
|
||||
function _terminalSessionId(){
|
||||
return S.session&&S.session.session_id;
|
||||
}
|
||||
|
||||
function _terminalWorkspaceName(){
|
||||
const ws=S.session&&S.session.workspace;
|
||||
if(!ws)return '';
|
||||
const parts=String(ws).split(/[\\/]+/).filter(Boolean);
|
||||
return parts[parts.length-1]||ws;
|
||||
}
|
||||
|
||||
function _isTerminalCloseCommand(value){
|
||||
return ['exit','quit','logout','close'].includes(String(value||'').trim().toLowerCase());
|
||||
}
|
||||
|
||||
function _trackTerminalInput(data){
|
||||
if(data==='\r'||data==='\n'){
|
||||
const command=TERMINAL_UI.typedLine;
|
||||
TERMINAL_UI.typedLine='';
|
||||
return command;
|
||||
}
|
||||
if(data==='\u0003'){
|
||||
TERMINAL_UI.typedLine='';
|
||||
return null;
|
||||
}
|
||||
if(data==='\u007f'||data==='\b'){
|
||||
TERMINAL_UI.typedLine=TERMINAL_UI.typedLine.slice(0,-1);
|
||||
return null;
|
||||
}
|
||||
if(data.length===1&&data>=' '){
|
||||
TERMINAL_UI.typedLine+=data;
|
||||
}else if(data.length>1&&/^[\x20-\x7e]+$/.test(data)){
|
||||
TERMINAL_UI.typedLine+=data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _terminalCssVar(name,fallback){
|
||||
const value=getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
return value||fallback;
|
||||
}
|
||||
|
||||
function _terminalTheme(){
|
||||
const isDark=document.documentElement.classList.contains('dark');
|
||||
const background=_terminalCssVar('--code-bg',isDark?'#1A1A2E':'#F5F0E5');
|
||||
const foreground=_terminalCssVar('--pre-text',_terminalCssVar('--text',isDark?'#E2E8F0':'#1A1610'));
|
||||
const muted=_terminalCssVar('--muted',isDark?'#C0C0C0':'#5C5344');
|
||||
const accent=_terminalCssVar('--accent-text',_terminalCssVar('--accent',isDark?'#FFD700':'#8B6508'));
|
||||
const error=_terminalCssVar('--error',isDark?'#EF5350':'#C62828');
|
||||
const success=_terminalCssVar('--success',isDark?'#4CAF50':'#3D8B40');
|
||||
const warning=_terminalCssVar('--warning',isDark?'#FFA726':'#E68A00');
|
||||
const info=_terminalCssVar('--info',isDark?'#4DD0E1':'#0288A8');
|
||||
return {
|
||||
background,
|
||||
foreground,
|
||||
cursor:accent,
|
||||
selectionBackground:_terminalCssVar('--accent-bg-strong',isDark?'rgba(255,215,0,.18)':'rgba(184,134,11,.18)'),
|
||||
black:isDark?'#0D0D1A':'#1A1610',
|
||||
red:error,
|
||||
green:success,
|
||||
yellow:warning,
|
||||
blue:info,
|
||||
magenta:accent,
|
||||
cyan:info,
|
||||
white:foreground,
|
||||
brightBlack:muted,
|
||||
brightRed:error,
|
||||
brightGreen:success,
|
||||
brightYellow:accent,
|
||||
brightBlue:info,
|
||||
brightMagenta:accent,
|
||||
brightCyan:info,
|
||||
brightWhite:isDark?'#FFFFFF':'#0F0D08',
|
||||
};
|
||||
}
|
||||
|
||||
function syncComposerTerminalTheme(){
|
||||
if(TERMINAL_UI.term)TERMINAL_UI.term.options.theme=_terminalTheme();
|
||||
}
|
||||
|
||||
function _xtermReady(){
|
||||
return typeof window.Terminal==='function';
|
||||
}
|
||||
|
||||
function _ensureXterm(){
|
||||
const {surface}= _terminalEls();
|
||||
if(!surface)return null;
|
||||
if(TERMINAL_UI.term)return TERMINAL_UI.term;
|
||||
if(!_xtermReady()){
|
||||
surface.textContent='Terminal library failed to load. Check network access to cdn.jsdelivr.net.';
|
||||
return null;
|
||||
}
|
||||
const term=new window.Terminal({
|
||||
cursorBlink:true,
|
||||
fontSize:13,
|
||||
fontFamily:'Menlo, Monaco, Consolas, "Liberation Mono", monospace',
|
||||
scrollback:1000,
|
||||
convertEol:false,
|
||||
theme:_terminalTheme(),
|
||||
});
|
||||
let fitAddon=null;
|
||||
if(window.FitAddon&&typeof window.FitAddon.FitAddon==='function'){
|
||||
fitAddon=new window.FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
}
|
||||
if(window.WebLinksAddon&&typeof window.WebLinksAddon.WebLinksAddon==='function'){
|
||||
term.loadAddon(new window.WebLinksAddon.WebLinksAddon());
|
||||
}
|
||||
term.open(surface);
|
||||
term.onData(data=>{
|
||||
const completedCommand=_trackTerminalInput(data);
|
||||
if(completedCommand!==null&&_isTerminalCloseCommand(completedCommand)){
|
||||
closeComposerTerminal();
|
||||
return;
|
||||
}
|
||||
const sid=TERMINAL_UI.sessionId||_terminalSessionId();
|
||||
if(!sid)return;
|
||||
api('/api/terminal/input',{method:'POST',body:JSON.stringify({
|
||||
session_id:sid,
|
||||
data,
|
||||
})}).catch(e=>showToast(t('terminal_input_failed')+e.message,2600,'error'));
|
||||
});
|
||||
TERMINAL_UI.term=term;
|
||||
TERMINAL_UI.fitAddon=fitAddon;
|
||||
_fitTerminal();
|
||||
return term;
|
||||
}
|
||||
|
||||
function _terminalDimensions(){
|
||||
const term=TERMINAL_UI.term;
|
||||
if(term&&term.cols&&term.rows)return {rows:term.rows,cols:term.cols};
|
||||
return {rows:18,cols:80};
|
||||
}
|
||||
|
||||
function _terminalHeightBounds(){
|
||||
const mobile=window.matchMedia&&window.matchMedia('(max-width: 700px)').matches;
|
||||
const min=mobile?TERMINAL_MOBILE_HEIGHT_MIN:TERMINAL_HEIGHT_MIN;
|
||||
const maxByViewport=Math.floor(window.innerHeight*(mobile?0.44:0.5));
|
||||
const hardMax=mobile?TERMINAL_MOBILE_HEIGHT_MAX:TERMINAL_HEIGHT_MAX;
|
||||
return {
|
||||
min,
|
||||
max:Math.max(min,Math.min(hardMax,maxByViewport)),
|
||||
defaultHeight:mobile?TERMINAL_MOBILE_HEIGHT_DEFAULT:TERMINAL_HEIGHT_DEFAULT,
|
||||
};
|
||||
}
|
||||
|
||||
function _clampTerminalHeight(height){
|
||||
const bounds=_terminalHeightBounds();
|
||||
const n=Number(height);
|
||||
const fallback=TERMINAL_UI.height||bounds.defaultHeight;
|
||||
return Math.max(bounds.min,Math.min(bounds.max,Number.isFinite(n)?n:fallback));
|
||||
}
|
||||
|
||||
function _applyTerminalHeight(height){
|
||||
const {inner,handle}= _terminalEls();
|
||||
const next=_clampTerminalHeight(height);
|
||||
TERMINAL_UI.height=next;
|
||||
if(inner)inner.style.setProperty('--composer-terminal-height',next+'px');
|
||||
if(handle){
|
||||
const bounds=_terminalHeightBounds();
|
||||
handle.setAttribute('aria-valuemin',String(bounds.min));
|
||||
handle.setAttribute('aria-valuemax',String(bounds.max));
|
||||
handle.setAttribute('aria-valuenow',String(next));
|
||||
}
|
||||
if(TERMINAL_UI.open&&!TERMINAL_UI.collapsed){
|
||||
_fitTerminal();
|
||||
_syncTerminalTranscriptSpace(true);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function _resetTerminalHeightForViewport(){
|
||||
const bounds=_terminalHeightBounds();
|
||||
_applyTerminalHeight(TERMINAL_UI.height||bounds.defaultHeight);
|
||||
}
|
||||
|
||||
function _startTerminalHeightResize(ev){
|
||||
if(ev.pointerType==='touch')return;
|
||||
const {inner,handle}= _terminalEls();
|
||||
if(!inner||!handle)return;
|
||||
ev.preventDefault();
|
||||
TERMINAL_UI.resizing=true;
|
||||
TERMINAL_UI.resizeStartY=ev.clientY;
|
||||
TERMINAL_UI.resizeStartHeight=TERMINAL_UI.height||inner.getBoundingClientRect().height||_terminalHeightBounds().defaultHeight;
|
||||
inner.classList.add('is-resizing');
|
||||
try{handle.setPointerCapture(ev.pointerId);}catch(_){}
|
||||
}
|
||||
|
||||
function _moveTerminalHeightResize(ev){
|
||||
if(!TERMINAL_UI.resizing)return;
|
||||
ev.preventDefault();
|
||||
_applyTerminalHeight(TERMINAL_UI.resizeStartHeight+(TERMINAL_UI.resizeStartY-ev.clientY));
|
||||
}
|
||||
|
||||
function _endTerminalHeightResize(ev){
|
||||
if(!TERMINAL_UI.resizing)return;
|
||||
TERMINAL_UI.resizing=false;
|
||||
const {inner,handle}= _terminalEls();
|
||||
if(inner)inner.classList.remove('is-resizing');
|
||||
if(handle&&ev&&ev.pointerId!==undefined)try{handle.releasePointerCapture(ev.pointerId);}catch(_){}
|
||||
_fitTerminal();
|
||||
}
|
||||
|
||||
function _handleTerminalResizeKey(ev){
|
||||
let delta=0;
|
||||
if(ev.key==='ArrowUp')delta=16;
|
||||
else if(ev.key==='ArrowDown')delta=-16;
|
||||
else if(ev.key==='PageUp')delta=64;
|
||||
else if(ev.key==='PageDown')delta=-64;
|
||||
else if(ev.key==='Home'){
|
||||
ev.preventDefault();
|
||||
return _applyTerminalHeight(_terminalHeightBounds().min);
|
||||
}
|
||||
else if(ev.key==='End'){
|
||||
ev.preventDefault();
|
||||
return _applyTerminalHeight(_terminalHeightBounds().max);
|
||||
}
|
||||
else return;
|
||||
ev.preventDefault();
|
||||
_applyTerminalHeight((TERMINAL_UI.height||_terminalHeightBounds().defaultHeight)+delta);
|
||||
}
|
||||
|
||||
function _initTerminalResizeHandle(){
|
||||
if(TERMINAL_UI.resizeHandleReady)return;
|
||||
const {handle}= _terminalEls();
|
||||
if(!handle)return;
|
||||
TERMINAL_UI.resizeHandleReady=true;
|
||||
handle.addEventListener('pointerdown',_startTerminalHeightResize);
|
||||
handle.addEventListener('pointermove',_moveTerminalHeightResize);
|
||||
handle.addEventListener('pointerup',_endTerminalHeightResize);
|
||||
handle.addEventListener('pointercancel',_endTerminalHeightResize);
|
||||
handle.addEventListener('keydown',_handleTerminalResizeKey);
|
||||
}
|
||||
|
||||
function _terminalMessagesEl(){
|
||||
return document.getElementById('messages');
|
||||
}
|
||||
|
||||
function _terminalIsMessagesNearBottom(el){
|
||||
if(!el)return false;
|
||||
return el.scrollHeight-el.scrollTop-el.clientHeight<150;
|
||||
}
|
||||
|
||||
function _syncTerminalTranscriptSpace(open,opts){
|
||||
opts=opts||{};
|
||||
const messages=_terminalMessagesEl();
|
||||
if(!messages)return;
|
||||
const wasNearBottom=_terminalIsMessagesNearBottom(messages);
|
||||
if(!open){
|
||||
messages.classList.remove('terminal-open');
|
||||
messages.classList.remove('terminal-collapsed');
|
||||
messages.classList.remove('terminal-expanding-from-dock');
|
||||
messages.style.removeProperty('--terminal-card-height');
|
||||
messages.style.removeProperty('--terminal-dock-height');
|
||||
if(wasNearBottom&&typeof scrollToBottom==='function')requestAnimationFrame(scrollToBottom);
|
||||
return;
|
||||
}
|
||||
if(open==='collapsed'){
|
||||
messages.classList.remove('terminal-open');
|
||||
messages.classList.add('terminal-collapsed');
|
||||
}else{
|
||||
messages.classList.add('terminal-open');
|
||||
messages.classList.remove('terminal-collapsed');
|
||||
}
|
||||
const measure=()=>{
|
||||
if(!TERMINAL_UI.open)return;
|
||||
const {panel,inner,dock}= _terminalEls();
|
||||
const target=open==='collapsed'?(dock||panel):(inner||panel);
|
||||
const h=target&&target.getBoundingClientRect().height;
|
||||
if(h>0){
|
||||
if(open==='collapsed')messages.style.setProperty('--terminal-dock-height',Math.ceil(h+24)+'px');
|
||||
else messages.style.setProperty('--terminal-card-height',Math.ceil(h+24)+'px');
|
||||
}
|
||||
if(wasNearBottom&&typeof scrollToBottom==='function')scrollToBottom();
|
||||
};
|
||||
if(opts.immediate)measure();
|
||||
requestAnimationFrame(measure);
|
||||
setTimeout(measure,420);
|
||||
}
|
||||
|
||||
function _fitTerminal(){
|
||||
const term=TERMINAL_UI.term;
|
||||
if(!term)return;
|
||||
if(TERMINAL_UI.collapsed)return;
|
||||
try{
|
||||
if(TERMINAL_UI.fitAddon)TERMINAL_UI.fitAddon.fit();
|
||||
}catch(_){}
|
||||
_syncTerminalTranscriptSpace(true);
|
||||
_scheduleTerminalResize();
|
||||
}
|
||||
|
||||
function _setTerminalChromeState(state){
|
||||
const {panel,inner,dock,workspace,dockWorkspace}= _terminalEls();
|
||||
const composerWrap=$('composerWrap');
|
||||
if(!panel)return;
|
||||
const collapsed=state==='collapsed';
|
||||
const expanded=state==='expanded';
|
||||
if(composerWrap)composerWrap.classList.toggle('terminal-dock-visible',collapsed);
|
||||
panel.hidden=!(collapsed||expanded);
|
||||
panel.classList.toggle('is-open',expanded);
|
||||
panel.classList.toggle('is-collapsed',collapsed);
|
||||
if(inner)inner.setAttribute('aria-hidden',collapsed?'true':'false');
|
||||
if(dock)dock.hidden=!collapsed;
|
||||
const label=_terminalWorkspaceName();
|
||||
if(workspace)workspace.textContent=label;
|
||||
if(dockWorkspace)dockWorkspace.textContent=label;
|
||||
}
|
||||
|
||||
function syncTerminalButton(){
|
||||
const {toggle}= _terminalEls();
|
||||
const currentSid=_terminalSessionId();
|
||||
const currentWorkspace=S.session&&S.session.workspace;
|
||||
if(TERMINAL_UI.open&&TERMINAL_UI.sessionId&&(currentSid!==TERMINAL_UI.sessionId||currentWorkspace!==TERMINAL_UI.workspace)){
|
||||
closeComposerTerminal(TERMINAL_UI.sessionId);
|
||||
}
|
||||
if(!toggle)return;
|
||||
const hasWorkspace=!!(S.session&&S.session.workspace);
|
||||
toggle.disabled=!hasWorkspace;
|
||||
toggle.classList.toggle('active',TERMINAL_UI.open);
|
||||
toggle.setAttribute('aria-pressed',TERMINAL_UI.open?'true':'false');
|
||||
toggle.title=hasWorkspace?(TERMINAL_UI.collapsed?t('terminal_expand'):t('terminal_open_title')):t('terminal_no_workspace_title');
|
||||
toggle.setAttribute('aria-label',toggle.title);
|
||||
}
|
||||
|
||||
function focusComposerTerminalInput(){
|
||||
if(TERMINAL_UI.term)TERMINAL_UI.term.focus();
|
||||
}
|
||||
|
||||
function _connectTerminalOutput(){
|
||||
const sid=_terminalSessionId();
|
||||
if(!sid)return;
|
||||
if(TERMINAL_UI.source){
|
||||
try{TERMINAL_UI.source.close();}catch(_){}
|
||||
TERMINAL_UI.source=null;
|
||||
}
|
||||
const url=new URL('api/terminal/output',document.baseURI||location.href);
|
||||
url.searchParams.set('session_id',sid);
|
||||
const source=new EventSource(url.href,{withCredentials:true});
|
||||
TERMINAL_UI.source=source;
|
||||
source.addEventListener('output',ev=>{
|
||||
if(TERMINAL_UI.source!==source)return;
|
||||
let text='';
|
||||
try{text=(JSON.parse(ev.data)||{}).text||'';}
|
||||
catch(_){text=ev.data||'';}
|
||||
if(TERMINAL_UI.term&&text)TERMINAL_UI.term.write(text);
|
||||
});
|
||||
source.addEventListener('terminal_closed',()=>{
|
||||
if(TERMINAL_UI.source!==source)return;
|
||||
if(TERMINAL_UI.term)TERMINAL_UI.term.writeln('\r\n[terminal closed]\r\n');
|
||||
try{source.close();}catch(_){}
|
||||
TERMINAL_UI.source=null;
|
||||
setTimeout(()=>closeComposerTerminal(null,{skipApi:true}),260);
|
||||
});
|
||||
source.addEventListener('terminal_error',ev=>{
|
||||
if(TERMINAL_UI.source!==source)return;
|
||||
let msg=t('terminal_error');
|
||||
try{msg=(JSON.parse(ev.data)||{}).error||msg;}catch(_){}
|
||||
if(TERMINAL_UI.term)TERMINAL_UI.term.writeln('\r\n[terminal error] '+msg+'\r\n');
|
||||
try{source.close();}catch(_){}
|
||||
TERMINAL_UI.source=null;
|
||||
});
|
||||
}
|
||||
|
||||
async function _startComposerTerminal(restart=false){
|
||||
const sid=_terminalSessionId();
|
||||
if(!sid||!(S.session&&S.session.workspace)){
|
||||
showToast(t('terminal_no_workspace_title'),2600,'warning');
|
||||
syncTerminalButton();
|
||||
return;
|
||||
}
|
||||
const term=_ensureXterm();
|
||||
if(!term)return;
|
||||
_fitTerminal();
|
||||
const dims=_terminalDimensions();
|
||||
await api('/api/terminal/start',{method:'POST',body:JSON.stringify({
|
||||
session_id:sid,
|
||||
rows:dims.rows,
|
||||
cols:dims.cols,
|
||||
restart:!!restart,
|
||||
})});
|
||||
TERMINAL_UI.sessionId=sid;
|
||||
TERMINAL_UI.workspace=S.session&&S.session.workspace||null;
|
||||
TERMINAL_UI.typedLine='';
|
||||
_connectTerminalOutput();
|
||||
_resizeComposerTerminal();
|
||||
}
|
||||
|
||||
async function toggleComposerTerminal(force){
|
||||
const next=typeof force==='boolean'?force:!TERMINAL_UI.open;
|
||||
if(next){
|
||||
if(TERMINAL_UI.open){
|
||||
if(TERMINAL_UI.collapsed)expandComposerTerminal();
|
||||
else focusComposerTerminalInput();
|
||||
return;
|
||||
}
|
||||
const {panel,inner}= _terminalEls();
|
||||
const messages=_terminalMessagesEl();
|
||||
if(!panel)return;
|
||||
clearTimeout(TERMINAL_UI.closeTimer);
|
||||
_initTerminalResizeHandle();
|
||||
_resetTerminalHeightForViewport();
|
||||
if(messages)messages.classList.add('terminal-expanding-from-dock');
|
||||
_setTerminalChromeState('expanded');
|
||||
TERMINAL_UI.open=true;
|
||||
TERMINAL_UI.collapsed=false;
|
||||
_syncTerminalTranscriptSpace(true,{immediate:true});
|
||||
if(messages)void messages.offsetHeight;
|
||||
requestAnimationFrame(()=>{
|
||||
panel.classList.add('is-open');
|
||||
window.setTimeout(_fitTerminal,80);
|
||||
setTimeout(()=>{
|
||||
if(messages)messages.classList.remove('terminal-expanding-from-dock');
|
||||
},120);
|
||||
});
|
||||
syncTerminalButton();
|
||||
if(!TERMINAL_UI.resizeObserver&&window.ResizeObserver){
|
||||
TERMINAL_UI.resizeObserver=new ResizeObserver(()=>_fitTerminal());
|
||||
TERMINAL_UI.resizeObserver.observe(inner||panel);
|
||||
}
|
||||
try{
|
||||
await _startComposerTerminal(false);
|
||||
focusComposerTerminalInput();
|
||||
}catch(e){
|
||||
showToast(t('terminal_start_failed')+e.message,3200,'error');
|
||||
}
|
||||
}else{
|
||||
await closeComposerTerminal();
|
||||
}
|
||||
}
|
||||
|
||||
function collapseComposerTerminal(){
|
||||
if(!TERMINAL_UI.open||TERMINAL_UI.collapsed)return;
|
||||
TERMINAL_UI.collapsed=true;
|
||||
_setTerminalChromeState('collapsed');
|
||||
_syncTerminalTranscriptSpace('collapsed');
|
||||
syncTerminalButton();
|
||||
}
|
||||
|
||||
function expandComposerTerminal(){
|
||||
if(!TERMINAL_UI.open)return;
|
||||
const {panel}= _terminalEls();
|
||||
const messages=_terminalMessagesEl();
|
||||
TERMINAL_UI.collapsed=false;
|
||||
clearTimeout(TERMINAL_UI.closeTimer);
|
||||
if(panel)panel.classList.add('is-expanding-from-dock');
|
||||
if(messages)messages.classList.add('terminal-expanding-from-dock');
|
||||
_syncTerminalTranscriptSpace(true,{immediate:true});
|
||||
if(messages)void messages.offsetHeight;
|
||||
_setTerminalChromeState('expanded');
|
||||
_resetTerminalHeightForViewport();
|
||||
requestAnimationFrame(()=>{
|
||||
_fitTerminal();
|
||||
focusComposerTerminalInput();
|
||||
setTimeout(()=>{
|
||||
if(panel)panel.classList.remove('is-expanding-from-dock');
|
||||
if(messages)messages.classList.remove('terminal-expanding-from-dock');
|
||||
},120);
|
||||
});
|
||||
syncTerminalButton();
|
||||
}
|
||||
|
||||
function _disposeXterm(){
|
||||
if(TERMINAL_UI.term){
|
||||
try{TERMINAL_UI.term.dispose();}catch(_){}
|
||||
}
|
||||
TERMINAL_UI.term=null;
|
||||
TERMINAL_UI.fitAddon=null;
|
||||
TERMINAL_UI.typedLine='';
|
||||
const {surface}= _terminalEls();
|
||||
if(surface)surface.textContent='';
|
||||
}
|
||||
|
||||
async function closeComposerTerminal(sessionId,opts){
|
||||
opts=opts||{};
|
||||
const sid=sessionId||TERMINAL_UI.sessionId||_terminalSessionId();
|
||||
if(TERMINAL_UI.source){
|
||||
try{TERMINAL_UI.source.close();}catch(_){}
|
||||
TERMINAL_UI.source=null;
|
||||
}
|
||||
if(sid&&!opts.skipApi){
|
||||
api('/api/terminal/close',{method:'POST',body:JSON.stringify({session_id:sid})}).catch(()=>{});
|
||||
}
|
||||
const {panel}= _terminalEls();
|
||||
if(panel){
|
||||
panel.classList.remove('is-open','is-collapsed','is-expanding-from-dock');
|
||||
_syncTerminalTranscriptSpace(false);
|
||||
clearTimeout(TERMINAL_UI.closeTimer);
|
||||
TERMINAL_UI.closeTimer=setTimeout(()=>{
|
||||
if(!TERMINAL_UI.open)panel.hidden=true;
|
||||
_disposeXterm();
|
||||
},280);
|
||||
}else{
|
||||
_syncTerminalTranscriptSpace(false);
|
||||
_disposeXterm();
|
||||
}
|
||||
TERMINAL_UI.open=false;
|
||||
TERMINAL_UI.collapsed=false;
|
||||
const composerWrap=$('composerWrap');
|
||||
if(composerWrap)composerWrap.classList.remove('terminal-dock-visible');
|
||||
TERMINAL_UI.sessionId=null;
|
||||
TERMINAL_UI.workspace=null;
|
||||
syncTerminalButton();
|
||||
}
|
||||
|
||||
async function restartComposerTerminal(){
|
||||
if(!TERMINAL_UI.open||TERMINAL_UI.collapsed)return;
|
||||
if(TERMINAL_UI.source){
|
||||
try{TERMINAL_UI.source.close();}catch(_){}
|
||||
TERMINAL_UI.source=null;
|
||||
}
|
||||
if(TERMINAL_UI.term)TERMINAL_UI.term.reset();
|
||||
try{await _startComposerTerminal(true);}
|
||||
catch(e){showToast(t('terminal_start_failed')+e.message,3200,'error');}
|
||||
}
|
||||
|
||||
function clearComposerTerminal(){
|
||||
if(TERMINAL_UI.term)TERMINAL_UI.term.clear();
|
||||
}
|
||||
|
||||
function _terminalBufferText(){
|
||||
const term=TERMINAL_UI.term;
|
||||
if(!term||!term.buffer)return '';
|
||||
const buffer=term.buffer.active;
|
||||
const lines=[];
|
||||
for(let i=0;i<buffer.length;i++){
|
||||
const line=buffer.getLine(i);
|
||||
if(line)lines.push(line.translateToString(true));
|
||||
}
|
||||
return lines.join('\n').trim();
|
||||
}
|
||||
|
||||
async function copyComposerTerminalOutput(){
|
||||
try{
|
||||
const selection=TERMINAL_UI.term&&TERMINAL_UI.term.getSelection?TERMINAL_UI.term.getSelection():'';
|
||||
await navigator.clipboard.writeText(selection||_terminalBufferText());
|
||||
showToast(t('copied'));
|
||||
}catch(e){
|
||||
showToast(t('terminal_copy_failed')+e.message,2600,'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitComposerTerminalInput(ev){
|
||||
if(ev)ev.preventDefault();
|
||||
}
|
||||
|
||||
function _scheduleTerminalResize(){
|
||||
clearTimeout(TERMINAL_UI.resizeTimer);
|
||||
TERMINAL_UI.resizeTimer=setTimeout(_resizeComposerTerminal,120);
|
||||
}
|
||||
|
||||
async function _resizeComposerTerminal(){
|
||||
if(!TERMINAL_UI.open||TERMINAL_UI.collapsed)return;
|
||||
const sid=TERMINAL_UI.sessionId||_terminalSessionId();
|
||||
if(!sid)return;
|
||||
const dims=_terminalDimensions();
|
||||
try{
|
||||
await api('/api/terminal/resize',{method:'POST',body:JSON.stringify({
|
||||
session_id:sid,
|
||||
rows:dims.rows,
|
||||
cols:dims.cols,
|
||||
})});
|
||||
}catch(_){}
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload',()=>{
|
||||
if(TERMINAL_UI.source)try{TERMINAL_UI.source.close();}catch(_){}
|
||||
if(TERMINAL_UI.sessionId){
|
||||
const url=new URL('api/terminal/close',document.baseURI||location.href).href;
|
||||
const body=JSON.stringify({session_id:TERMINAL_UI.sessionId});
|
||||
try{
|
||||
navigator.sendBeacon(url,new Blob([body],{type:'application/json'}));
|
||||
}catch(_){
|
||||
try{fetch(url,{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body,keepalive:true});}catch(__){}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('resize',()=>{
|
||||
if(!TERMINAL_UI.open)return;
|
||||
if(TERMINAL_UI.collapsed){
|
||||
_syncTerminalTranscriptSpace('collapsed');
|
||||
return;
|
||||
}
|
||||
_resetTerminalHeightForViewport();
|
||||
});
|
||||
|
||||
if(window.MutationObserver){
|
||||
new MutationObserver(syncComposerTerminalTheme).observe(document.documentElement,{
|
||||
attributes:true,
|
||||
attributeFilter:['class','data-skin'],
|
||||
});
|
||||
}
|
||||
4653
static/ui.js
4653
static/ui.js
File diff suppressed because it is too large
Load Diff
29
static/vendor/smd.min.js
vendored
Normal file
29
static/vendor/smd.min.js
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
var D=2,C=3,h=4,b=5,B=6,U=7,G=8,S=9,x=10,m=11,H=12,K=13,M=14,Q=15,w=16,q=17,W=18,P=19,Y=20,y=21,F=22,$=23,v=24,X=25,j=26,z=27,J=28,V=29,Z=30,p=31;var I=1,k=2,L=4,T=8,f=16;function ee(e){switch(e){case I:return"href";case k:return"src";case L:return"class";case T:return"checked";case f:return"start"}}var ne=e=>{switch(e){case 1:return 3;case 2:return 4;case 3:return 5;case 4:return 6;case 5:return 7;default:return 8}},te=ne;var O=24;function ae(e){let c=new Uint32Array(O);return c[0]=1,{renderer:e,text:"",pending:"",tokens:c,len:0,token:1,fence_end:0,blockquote_idx:0,hr_char:"",hr_chars:0,fence_start:0,spaces:new Uint8Array(O),indent:"",indent_len:0,table_state:0}}function ce(e){e.pending.length>0&&o(e,`
|
||||
`)}function a(e){e.text.length!==0&&(e.renderer.add_text(e.renderer.data,e.text),e.text="")}function _(e){e.len-=1,e.token=e.tokens[e.len],e.renderer.end_token(e.renderer.data)}function i(e,c){(e.tokens[e.len]===24||e.tokens[e.len]===23)&&c!==25&&_(e),e.len+=1,e.tokens[e.len]=c,e.token=c,e.renderer.add_token(e.renderer.data,c)}function re(e,c,n){for(;n<=e.len;){if(e.tokens[n]===c)return n;n+=1}return-1}function l(e,c){for(e.fence_start=0;e.len>c;)_(e)}function u(e,c){let n=0;for(let t=0;t<=e.len&&(c-=e.spaces[t],!(c<0));t+=1)switch(e.tokens[t]){case 9:case 10:case 20:case 25:n=t;break}for(;e.len>n;)_(e);return c}function A(e,c){let n=-1,t=-1;for(let s=e.blockquote_idx+1;s<=e.len;s+=1)if(e.tokens[s]===25){if(e.indent_len<e.spaces[s]){t=-1;break}t=s}else e.tokens[s]===c&&(n=s);return t===-1?n===-1?(l(e,e.blockquote_idx),i(e,c),!0):(l(e,n),!1):(l(e,t),i(e,c),!0)}function g(e,c){i(e,25),e.spaces[e.len]=e.indent_len+c,E(e),e.token=103}function E(e){e.indent="",e.indent_len=0,e.pending=""}function N(e){switch(e){case 48:case 49:case 50:case 51:case 52:case 53:case 54:case 55:case 56:case 57:return!0;default:return!1}}function ie(e){switch(e){case 32:case 58:case 59:case 41:case 44:case 33:case 46:case 63:case 93:case 10:return!0;default:return!1}}function se(e){return N(e)||ie(e)}function o(e,c){for(let n of c){if(e.token===101){switch(n){case" ":e.indent_len+=1;continue;case" ":e.indent_len+=4;continue}let s=u(e,e.indent_len);e.indent_len=0,e.token=e.tokens[e.len],s>0&&o(e," ".repeat(s))}let t=e.pending+n;switch(e.token){case 21:case 1:case 20:case 24:case 23:switch(e.pending[0]){case void 0:e.pending=n;continue;case" ":e.pending=n,e.indent+=" ",e.indent_len+=1;continue;case" ":e.pending=n,e.indent+=" ",e.indent_len+=4;continue;case`
|
||||
`:if(e.tokens[e.len]===25&&e.token===21){_(e),E(e),e.pending=n;continue}l(e,e.blockquote_idx),E(e),e.blockquote_idx=0,e.fence_start=0,e.pending=n;continue;case"#":switch(n){case"#":if(e.pending.length<6){e.pending=t;continue}break;case" ":u(e,e.indent_len),i(e,te(e.pending.length)),E(e);continue}break;case">":{let r=re(e,20,e.blockquote_idx+1);r===-1?(l(e,e.blockquote_idx),e.blockquote_idx+=1,e.fence_start=0,i(e,20)):e.blockquote_idx=r,E(e),e.pending=n;continue}case"-":case"*":case"_":if(e.hr_chars===0&&(e.hr_chars=1,e.hr_char=e.pending),e.hr_chars>0){switch(n){case e.hr_char:e.hr_chars+=1,e.pending=t;continue;case" ":e.pending=t;continue;case`
|
||||
`:if(e.hr_chars<3)break;u(e,e.indent_len),e.renderer.add_token(e.renderer.data,22),e.renderer.end_token(e.renderer.data),E(e),e.hr_chars=0;continue}e.hr_chars=0}if(e.pending[0]!=="_"&&e.pending[1]===" "){A(e,23),g(e,2),o(e,t.slice(2));continue}break;case"`":if(e.pending.length<3){if(n==="`"){e.pending=t,e.fence_start=t.length;continue}e.fence_start=0;break}switch(n){case"`":e.pending.length===e.fence_start?(e.pending=t,e.fence_start=t.length):(i(e,2),E(e),e.fence_start=0,o(e,t));continue;case`
|
||||
`:{u(e,e.indent_len),i(e,10),e.pending.length>e.fence_start&&e.renderer.set_attr(e.renderer.data,L,e.pending.slice(e.fence_start)),E(e),e.token=101;continue}default:e.pending=t;continue}case"+":if(n!==" ")break;A(e,23),g(e,2);continue;case"0":case"1":case"2":case"3":case"4":case"5":case"6":case"7":case"8":case"9":if(e.pending[e.pending.length-1]==="."){if(n!==" ")break;A(e,24)&&e.pending!=="1."&&e.renderer.set_attr(e.renderer.data,f,e.pending.slice(0,-1)),g(e,e.pending.length+1);continue}else{let r=n.charCodeAt(0);if(r===46||N(r)){e.pending=t;continue}}break;case"|":l(e,e.blockquote_idx),i(e,27),i(e,28),e.pending="",o(e,n);continue}let s=t;if(e.token===21)e.token=e.tokens[e.len],e.renderer.add_token(e.renderer.data,21),e.renderer.end_token(e.renderer.data);else if(e.indent_len>=4){let r=0;for(;r<4;r+=1)if(e.indent[r]===" "){r=r+1;break}s=e.indent.slice(r)+t,i(e,9)}else i(e,2);E(e),o(e,s);continue;case 27:if(e.table_state===1)switch(n){case"-":case" ":case"|":case":":e.pending=t;continue;case`
|
||||
`:e.table_state=2,e.pending="";continue;default:_(e),e.table_state=0;break}else switch(e.pending){case"|":i(e,28),e.pending="",o(e,n);continue;case`
|
||||
`:_(e),e.pending="",e.table_state=0,o(e,n);continue}break;case 28:switch(e.pending){case"":break;case"|":i(e,29),_(e),e.pending="",o(e,n);continue;case`
|
||||
`:_(e),e.table_state=Math.min(e.table_state+1,2),e.pending="",o(e,n);continue;default:i(e,29),o(e,n);continue}break;case 29:if(e.pending==="|"){a(e),_(e),e.pending="",o(e,n);continue}break;case 9:switch(t){case`
|
||||
`:case`
|
||||
`:case`
|
||||
`:case`
|
||||
`:case`
|
||||
`:e.text+=`
|
||||
`,e.pending="";continue;case`
|
||||
`:case`
|
||||
`:case`
|
||||
`:case`
|
||||
`:e.pending=t;continue;default:e.pending.length!==0?(a(e),_(e),e.pending=n):e.text+=n;continue}case 10:switch(n){case"`":e.pending=t;continue;case`
|
||||
`:if(t.length===e.fence_start+e.fence_end+1){a(e),_(e),e.pending="",e.fence_start=0,e.fence_end=0,e.token=101;continue}e.token=101;break;case" ":if(e.pending[0]===`
|
||||
`){e.pending=t,e.fence_end+=1;continue}break}e.text+=e.pending,e.pending=n,e.fence_end=1;continue;case 11:switch(n){case"`":t.length===e.fence_start+ +(e.pending[0]===" ")?(a(e),_(e),e.pending="",e.fence_start=0):e.pending=t;continue;case`
|
||||
`:e.text+=e.pending,e.pending="",e.token=21,e.blockquote_idx=0,a(e);continue;case" ":e.text+=e.pending,e.pending=n;continue;default:e.text+=t,e.pending="";continue}case 103:switch(e.pending.length){case 0:if(n!=="[")break;e.pending=t;continue;case 1:if(n!==" "&&n!=="x")break;e.pending=t;continue;case 2:if(n!=="]")break;e.pending=t;continue;case 3:if(n!==" ")break;e.renderer.add_token(e.renderer.data,26),e.pending[1]==="x"&&e.renderer.set_attr(e.renderer.data,T,""),e.renderer.end_token(e.renderer.data),e.pending=" ";continue}e.token=e.tokens[e.len],e.pending="",o(e,t);continue;case 14:case 15:{let r="*",d=12;if(e.token===15&&(r="_",d=13),r===e.pending){if(a(e),r===n){_(e),e.pending="";continue}i(e,d),e.pending=n;continue}break}case 12:case 13:{let r="*",d=14;switch(e.token===13&&(r="_",d=15),e.pending){case r:r===n?e.tokens[e.len-1]===d?e.pending=t:(a(e),i(e,d),e.pending=""):(a(e),_(e),e.pending=n);continue;case r+r:let R=e.token;a(e),_(e),_(e),r!==n?(i(e,R),e.pending=n):e.pending="";continue}break}case 16:if(t==="~~"){a(e),_(e),e.pending="";continue}break;case 105:n===`
|
||||
`?(a(e),i(e,30),e.pending=""):(e.token=e.tokens[e.len],e.pending[0]==="\\"?e.text+="[":e.text+="$$",e.pending="",o(e,n));continue;case 30:if(t==="\\]"||t==="$$"){a(e),_(e),e.pending="";continue}break;case 31:if(t==="\\)"||e.pending[0]==="$"){a(e),_(e),n===")"?e.pending="":e.pending=n;continue}break;case 102:t==="http://"||t==="https://"?(a(e),i(e,18),e.pending=t,e.text=t):"http:/"[e.pending.length]===n||"https:/"[e.pending.length]===n?e.pending=t:(e.token=e.tokens[e.len],o(e,n));continue;case 17:case 19:if(e.pending==="]"){a(e),n==="("?e.pending=t:(_(e),e.pending=n);continue}if(e.pending[0]==="]"&&e.pending[1]==="("){if(n===")"){let r=e.token===17?I:k,d=e.pending.slice(2);e.renderer.set_attr(e.renderer.data,r,d),_(e),e.pending=""}else e.pending+=n;continue}break;case 18:n===" "||n===`
|
||||
`||n==="\\"?(e.renderer.set_attr(e.renderer.data,I,e.pending),a(e),_(e),e.pending=n):(e.text+=n,e.pending=t);continue;case 104:if(t.startsWith("<br")){if(t.length===3||n===" "||n==="/"&&(t.length===4||e.pending[e.pending.length-1]===" ")){e.pending=t;continue}if(n===">"){a(e),e.token=e.tokens[e.len],e.renderer.add_token(e.renderer.data,21),e.renderer.end_token(e.renderer.data),e.pending="";continue}}e.token=e.tokens[e.len],e.text+="<",e.pending=e.pending.slice(1),o(e,n);continue}switch(e.pending[0]){case"\\":if(e.token===19||e.token===30||e.token===31)break;switch(n){case"(":a(e),i(e,31),e.pending="";continue;case"[":e.token=105,e.pending=t;continue;case`
|
||||
`:e.pending=n;continue;default:let s=n.charCodeAt(0);e.pending="",e.text+=N(s)||s>=65&&s<=90||s>=97&&s<=122?t:n;continue}case`
|
||||
`:switch(e.token){case 19:case 30:case 31:break;case 3:case 4:case 5:case 6:case 7:case 8:a(e),l(e,e.blockquote_idx),e.blockquote_idx=0,e.pending=n;continue;default:a(e),e.pending=n,e.token=21,e.blockquote_idx=0;continue}break;case"<":if(e.token!==19&&e.token!==30&&e.token!==31){a(e),e.pending=t,e.token=104;continue}break;case"`":if(e.token===19)break;n==="`"?(e.fence_start+=1,e.pending=t):(e.fence_start+=1,a(e),i(e,11),e.text=n===" "||n===`
|
||||
`?"":n,e.pending="");continue;case"_":case"*":{if(e.token===19||e.token===30||e.token===31||e.token===14)break;let s=12,r=14,d=e.pending[0];if(d==="_"&&(s=13,r=15),e.pending.length===1){if(d===n){e.pending=t;continue}if(n!==" "&&n!==`
|
||||
`){a(e),i(e,s),e.pending=n;continue}}else{if(d===n){a(e),i(e,r),i(e,s),e.pending="";continue}if(n!==" "&&n!==`
|
||||
`){a(e),i(e,r),e.pending=n;continue}}break}case"~":if(e.token!==19&&e.token!==16){if(e.pending==="~"){if(n==="~"){e.pending=t;continue}}else if(n!==" "&&n!==`
|
||||
`){a(e),i(e,16),e.pending=n;continue}}break;case"$":if(e.token!==19&&e.token!==16&&e.pending==="$")if(n==="$"){e.token=105,e.pending=t;continue}else{if(se(n.charCodeAt(0)))break;a(e),i(e,31),e.pending=n;continue}break;case"[":if(e.token!==19&&e.token!==17&&e.token!==30&&e.token!==31&&n!=="]"){a(e),i(e,17),e.pending=n;continue}break;case"!":if(e.token!==19&&n==="["){a(e),i(e,19),e.pending="";continue}break;case" ":if(e.pending.length===1&&n===" ")continue;break}if(e.token!==19&&e.token!==17&&e.token!==30&&e.token!==31&&n==="h"&&(e.pending===" "||e.pending==="")){e.text+=e.pending,e.pending=n,e.token=102;continue}e.text+=e.pending,e.pending=n}a(e)}function _e(e){return{add_token:oe,end_token:de,add_text:Ee,set_attr:le,data:{nodes:[e,,,,,],index:0}}}function oe(e,c){let n=e.nodes[e.index],t;switch(c){case 1:return;case 20:t=document.createElement("blockquote");break;case 2:t=document.createElement("p");break;case 21:t=document.createElement("br");break;case 22:t=document.createElement("hr");break;case 3:t=document.createElement("h1");break;case 4:t=document.createElement("h2");break;case 5:t=document.createElement("h3");break;case 6:t=document.createElement("h4");break;case 7:t=document.createElement("h5");break;case 8:t=document.createElement("h6");break;case 12:case 13:t=document.createElement("em");break;case 14:case 15:t=document.createElement("strong");break;case 16:t=document.createElement("s");break;case 11:t=document.createElement("code");break;case 18:case 17:t=document.createElement("a");break;case 19:t=document.createElement("img");break;case 23:t=document.createElement("ul");break;case 24:t=document.createElement("ol");break;case 25:t=document.createElement("li");break;case 26:let s=t=document.createElement("input");s.type="checkbox",s.disabled=!0;break;case 9:case 10:n=n.appendChild(document.createElement("pre")),t=document.createElement("code");break;case 27:t=document.createElement("table");break;case 28:switch(n.children.length){case 0:n=n.appendChild(document.createElement("thead"));break;case 1:n=n.appendChild(document.createElement("tbody"));break;default:n=n.children[1]}t=document.createElement("tr");break;case 29:t=document.createElement(n.parentElement?.tagName==="THEAD"?"th":"td");break;case 30:t=document.createElement("equation-block");break;case 31:t=document.createElement("equation-inline");break}e.nodes[++e.index]=n.appendChild(t)}function de(e){e.index-=1}function Ee(e,c){e.nodes[e.index].appendChild(document.createTextNode(c))}function le(e,c,n){e.nodes[e.index].setAttribute(ee(c),n)}export{Y as BLOCKQUOTE,j as CHECKBOX,T as CHECKED,S as CODE_BLOCK,x as CODE_FENCE,m as CODE_INLINE,Z as EQUATION_BLOCK,p as EQUATION_INLINE,C as HEADING_1,h as HEADING_2,b as HEADING_3,B as HEADING_4,U as HEADING_5,G as HEADING_6,I as HREF,P as IMAGE,H as ITALIC_AST,K as ITALIC_UND,L as LANG,y as LINE_BREAK,q as LINK,X as LIST_ITEM,v as LIST_ORDERED,$ as LIST_UNORDERED,D as PARAGRAPH,W as RAW_URL,F as RULE,k as SRC,f as START,w as STRIKE,M as STRONG_AST,Q as STRONG_UND,z as TABLE,V as TABLE_CELL,J as TABLE_ROW,_e as default_renderer,ae as parser,ce as parser_end,o as parser_write};
|
||||
@@ -1,15 +1,43 @@
|
||||
async function api(path,opts={}){
|
||||
const url=new URL(path,location.origin);
|
||||
const res=await fetch(url.href,{credentials:'include',headers:{'Content-Type':'application/json'},...opts});
|
||||
if(!res.ok){
|
||||
const text=await res.text();
|
||||
// Parse JSON error body and surface the human-readable message,
|
||||
// rather than showing raw JSON like {"error":"Profile 'x' does not exist."}
|
||||
try{const j=JSON.parse(text);throw new Error(j.error||j.message||text);}
|
||||
catch(e){if(e instanceof SyntaxError)throw new Error(text);throw e;}
|
||||
// Strip leading slash so URL resolves relative to location.href (supports subpath mounts)
|
||||
const rel = path.startsWith('/') ? path.slice(1) : path;
|
||||
const url=new URL(rel,document.baseURI||location.href);
|
||||
// Retry up to 2 times on network errors (e.g. stale keep-alive after long idle).
|
||||
// Server errors (4xx/5xx) are NOT retried — only connection failures.
|
||||
let lastErr;
|
||||
for(let attempt=0;attempt<3;attempt++){
|
||||
try{
|
||||
const res=await fetch(url.href,{credentials:'include',headers:{'Content-Type':'application/json'},...opts});
|
||||
if(!res.ok){
|
||||
// 401 means the auth session expired. Redirect to /login so the user can
|
||||
// re-authenticate. This is especially important for iOS PWA (standalone mode)
|
||||
// where a server-side 302 → /login opens in Safari instead of within the PWA.
|
||||
if(res.status===401){window.location.href='/login?next='+encodeURIComponent(window.location.pathname+window.location.search);return;}
|
||||
const text=await res.text();
|
||||
// Parse JSON error body and surface the human-readable message,
|
||||
// rather than showing raw JSON like {"error":"Profile 'x' does not exist."}
|
||||
let message=text;
|
||||
try{const j=JSON.parse(text);message=j.error||j.message||text;}catch(e){}
|
||||
// Attach the raw HTTP context so callers can branch on status (404 stale-session
|
||||
// cleanup, 401 redirect, 503 retry, etc.) without re-parsing the message string.
|
||||
const err=new Error(message);
|
||||
err.status=res.status;
|
||||
err.statusText=res.statusText;
|
||||
err.body=text;
|
||||
throw err;
|
||||
}
|
||||
const ct=res.headers.get('content-type')||'';
|
||||
return ct.includes('application/json')?res.json():res.text();
|
||||
}catch(e){
|
||||
lastErr=e;
|
||||
// Only retry on network errors (TypeError from fetch), not on HTTP errors
|
||||
// that were already thrown above. Re-throw 401 redirects immediately.
|
||||
if(e.message&&/401/.test(e.message)) throw e;
|
||||
if(attempt<2 && e instanceof TypeError) continue;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
const ct=res.headers.get('content-type')||'';
|
||||
return ct.includes('application/json')?res.json():res.text();
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
// Persist/restore expanded directory state per workspace in localStorage
|
||||
@@ -41,20 +69,23 @@ async function loadDir(path){
|
||||
const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
|
||||
S.entries=data.entries||[];renderBreadcrumb();renderFileTree();
|
||||
// Pre-fetch contents of restored expanded dirs so they render without a second click
|
||||
// (parallelized — avoids serial waterfall when multiple dirs are expanded)
|
||||
if(!path||path==='.'){
|
||||
for(const dirPath of (S._expandedDirs||[])){
|
||||
if(!S._dirCache[dirPath]){
|
||||
try{
|
||||
const dc=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(dirPath)}`);
|
||||
S._dirCache[dirPath]=dc.entries||[];
|
||||
}catch(e2){S._dirCache[dirPath]=[];}
|
||||
}
|
||||
const expanded=S._expandedDirs||new Set();
|
||||
const pending=[...expanded].filter(dirPath=>!S._dirCache[dirPath]);
|
||||
if(pending.length){
|
||||
const results=await Promise.all(pending.map(dirPath=>
|
||||
api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(dirPath)}`)
|
||||
.then(dc=>({dirPath,entries:dc.entries||[]}))
|
||||
.catch(()=>({dirPath,entries:[]}))
|
||||
));
|
||||
for(const {dirPath,entries} of results) S._dirCache[dirPath]=entries;
|
||||
}
|
||||
if(S._expandedDirs&&S._expandedDirs.size>0)renderFileTree();
|
||||
if(expanded.size>0)renderFileTree();
|
||||
}
|
||||
if(typeof clearPreview==='function'){
|
||||
if(typeof _previewDirty!=='undefined'&&_previewDirty){
|
||||
if(confirm('You have unsaved changes in the preview. Discard and navigate?'))clearPreview();
|
||||
showConfirmDialog({title:t('unsaved_confirm'),message:'',confirmLabel:'Discard',danger:true,focusCancel:true}).then(ok=>{if(ok)clearPreview();});
|
||||
}else{
|
||||
clearPreview();
|
||||
}
|
||||
@@ -95,11 +126,14 @@ function navigateUp(){
|
||||
// File extension sets for preview routing (must match server-side sets)
|
||||
const IMAGE_EXTS = new Set(['.png','.jpg','.jpeg','.gif','.svg','.webp','.ico','.bmp']);
|
||||
const MD_EXTS = new Set(['.md','.markdown','.mdown']);
|
||||
const HTML_EXTS = new Set(['.html','.htm']);
|
||||
const PDF_EXTS = new Set(['.pdf']);
|
||||
const AUDIO_EXTS = new Set(['.mp3','.wav','.m4a','.aac','.ogg','.oga','.opus','.flac']);
|
||||
const VIDEO_EXTS = new Set(['.mp4','.mov','.m4v','.webm','.ogv','.avi','.mkv']);
|
||||
// Binary formats that should download rather than preview
|
||||
const DOWNLOAD_EXTS = new Set([
|
||||
'.docx','.doc','.xlsx','.xls','.pptx','.ppt','.odt','.ods','.odp',
|
||||
'.pdf','.zip','.tar','.gz','.bz2','.7z','.rar',
|
||||
'.mp3','.mp4','.wav','.m4a','.ogg','.flac','.mov','.avi','.mkv','.webm',
|
||||
'.zip','.tar','.gz','.bz2','.7z','.rar',
|
||||
'.exe','.dmg','.pkg','.deb','.rpm',
|
||||
'.woff','.woff2','.ttf','.otf','.eot',
|
||||
'.bin','.dat','.db','.sqlite','.pyc','.class','.so','.dylib','.dll',
|
||||
@@ -108,21 +142,27 @@ const DOWNLOAD_EXTS = new Set([
|
||||
function fileExt(p){ const i=p.lastIndexOf('.'); return i>=0?p.slice(i).toLowerCase():''; }
|
||||
|
||||
let _previewCurrentPath = ''; // relative path of currently previewed file
|
||||
let _previewCurrentMode = ''; // 'code' | 'md' | 'image'
|
||||
let _previewCurrentMode = ''; // 'code' | 'md' | 'image' | 'html' | 'pdf' | 'audio' | 'video'
|
||||
let _previewDirty = false; // true when edits are unsaved
|
||||
|
||||
function showPreview(mode){
|
||||
// mode: 'code' | 'image' | 'md'
|
||||
// mode: 'code' | 'image' | 'md' | 'html' | 'pdf' | 'audio' | 'video'
|
||||
$('previewCode').style.display = mode==='code' ? '' : 'none';
|
||||
$('previewImgWrap').style.display = mode==='image' ? '' : 'none';
|
||||
const mediaWrap=$('previewMediaWrap'); if(mediaWrap) mediaWrap.style.display = (mode==='audio'||mode==='video') ? '' : 'none';
|
||||
const pdfWrap=$('previewPdfWrap'); if(pdfWrap) pdfWrap.style.display = mode==='pdf' ? '' : 'none';
|
||||
$('previewMd').style.display = mode==='md' ? '' : 'none';
|
||||
$('previewHtmlWrap').style.display = mode==='html' ? '' : 'none';
|
||||
$('previewEditArea').style.display = 'none'; // start in read-only
|
||||
const badge=$('previewBadge');
|
||||
badge.className='preview-badge '+mode;
|
||||
badge.textContent = mode==='image'?'image':mode==='md'?'md':fileExt($('previewPathText').textContent)||'text';
|
||||
badge.textContent = mode==='image'?'image':mode==='audio'?'audio':mode==='video'?'video':mode==='pdf'?'pdf':mode==='md'?'md':mode==='html'?'html':fileExt($('previewPathText').textContent)||'text';
|
||||
_previewCurrentMode = mode;
|
||||
_previewDirty = false;
|
||||
updateEditBtn();
|
||||
// Show "Open in browser" button for iframe-backed document previews
|
||||
const openBtn=$('btnOpenInBrowser');
|
||||
if(openBtn) openBtn.style.display = (mode==='html'||mode==='pdf')?'inline-flex':'none';
|
||||
}
|
||||
|
||||
function updateEditBtn(){
|
||||
@@ -131,8 +171,8 @@ function updateEditBtn(){
|
||||
const editable = _previewCurrentMode==='code'||_previewCurrentMode==='md';
|
||||
btn.style.display = editable?'':'none';
|
||||
const editing = $('previewEditArea').style.display!=='none';
|
||||
btn.innerHTML = editing ? '💾 Save' : '✎ Edit';
|
||||
btn.title = editing ? 'Save changes' : 'Edit this file';
|
||||
btn.innerHTML = editing ? `💾 ${t('save')}` : `✎ ${t('edit')}`;
|
||||
btn.title = editing ? t('save_title') : t('edit_title');
|
||||
btn.style.color = editing ? 'var(--blue)' : '';
|
||||
if(_previewDirty) btn.innerHTML = '💾 Save*';
|
||||
}
|
||||
@@ -150,12 +190,12 @@ async function toggleEditMode(){
|
||||
_previewDirty=false;
|
||||
// Update read-only views
|
||||
if(_previewCurrentMode==='code') $('previewCode').textContent=content;
|
||||
else $('previewMd').innerHTML=renderMd(content);
|
||||
else { $('previewMd').innerHTML=renderMd(content); requestAnimationFrame(()=>{if(typeof renderKatexBlocks==='function')renderKatexBlocks();}); }
|
||||
$('previewEditArea').style.display='none';
|
||||
if(_previewCurrentMode==='code') $('previewCode').style.display='';
|
||||
else $('previewMd').style.display='';
|
||||
showToast('Saved');
|
||||
}catch(e){setStatus('Save failed: '+e.message);}
|
||||
showToast(t('saved'));
|
||||
}catch(e){setStatus(t('save_failed')+e.message);}
|
||||
}else{
|
||||
// Enter edit mode: populate textarea with current content
|
||||
const currentText = _previewCurrentMode==='code'
|
||||
@@ -200,13 +240,34 @@ async function openFile(path){
|
||||
$('fileTree').style.display='none';
|
||||
|
||||
_previewCurrentPath = path;
|
||||
renderFileBreadcrumb(path);
|
||||
if(IMAGE_EXTS.has(ext)){
|
||||
// Image: load via raw endpoint, show as <img>
|
||||
showPreview('image');
|
||||
const url=`/api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`;
|
||||
const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`;
|
||||
$('previewImg').alt=path;
|
||||
$('previewImg').src=url;
|
||||
$('previewImg').onerror=()=>setStatus('Could not load image');
|
||||
$('previewImg').onerror=()=>setStatus(t('image_load_failed'));
|
||||
} else if(AUDIO_EXTS.has(ext)||VIDEO_EXTS.has(ext)){
|
||||
const mode=VIDEO_EXTS.has(ext)?'video':'audio';
|
||||
showPreview(mode);
|
||||
const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&inline=1`;
|
||||
const wrap=$('previewMediaWrap');
|
||||
if(wrap){
|
||||
wrap.innerHTML=(typeof _mediaPlayerHtml==='function')
|
||||
? _mediaPlayerHtml(mode,url,path.split('/').pop()||path)
|
||||
: `<${mode} src="${url.replace(/"/g,'%22')}" controls preload="metadata"></${mode}>`;
|
||||
if(typeof _applyMediaPlaybackPreferences==='function') _applyMediaPlaybackPreferences(wrap);
|
||||
}
|
||||
} else if(PDF_EXTS.has(ext)){
|
||||
showPreview('pdf');
|
||||
const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&inline=1`;
|
||||
const frame=$('previewPdfFrame');
|
||||
if(frame){
|
||||
frame.src=''; // clear first to avoid stale content
|
||||
frame.src=url;
|
||||
frame.title=`PDF preview: ${path.split('/').pop()||path}`;
|
||||
}
|
||||
} else if(MD_EXTS.has(ext)){
|
||||
// Markdown: fetch text, render with renderMd, display as formatted HTML
|
||||
try{
|
||||
@@ -214,7 +275,24 @@ async function openFile(path){
|
||||
showPreview('md');
|
||||
_previewRawContent = data.content;
|
||||
$('previewMd').innerHTML=renderMd(data.content);
|
||||
}catch(e){setStatus('Could not open file');}
|
||||
requestAnimationFrame(()=>{if(typeof renderKatexBlocks==='function')renderKatexBlocks();});
|
||||
}catch(e){setStatus(t('file_open_failed'));}
|
||||
} else if(HTML_EXTS.has(ext)){
|
||||
// HTML: render in sandboxed iframe via raw endpoint.
|
||||
// SECURITY TRADEOFF: We use sandbox="allow-scripts" which lets inline JS run
|
||||
// but prevents access to the parent frame (origin isolation). This is a
|
||||
// deliberate choice — the user is previewing their own workspace files, so
|
||||
// blocking scripts entirely would break most HTML documents. The sandbox
|
||||
// still prevents the preview from navigating the parent, accessing cookies,
|
||||
// or reading other origin data. If a stricter mode is needed, remove
|
||||
// allow-scripts (or add sandbox="") to disable all JS execution.
|
||||
showPreview('html');
|
||||
const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&inline=1`;
|
||||
const iframe=$('previewHtmlIframe');
|
||||
if(iframe){
|
||||
iframe.src=''; // clear first to avoid stale content
|
||||
iframe.src=url;
|
||||
}
|
||||
} else {
|
||||
// Plain code / text -- but fall back to download if server signals binary
|
||||
try{
|
||||
@@ -236,12 +314,56 @@ async function openFile(path){
|
||||
function downloadFile(path){
|
||||
if(!S.session)return;
|
||||
// Trigger browser download via the raw file endpoint with content-disposition attachment
|
||||
const url=`/api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&download=1`;
|
||||
const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&download=1`;
|
||||
const filename=path.split('/').pop();
|
||||
const a=document.createElement('a');
|
||||
a.href=url;a.download=filename;
|
||||
document.body.appendChild(a);a.click();
|
||||
setTimeout(()=>document.body.removeChild(a),100);
|
||||
showToast(`Downloading ${filename}\u2026`,2000);
|
||||
showToast(t('downloading',filename),2000);
|
||||
}
|
||||
|
||||
|
||||
// ── Render breadcrumb for file preview mode ──────────────────────────────────
|
||||
function renderFileBreadcrumb(filePath) {
|
||||
const bar = $('breadcrumbBar');
|
||||
if (!bar) return;
|
||||
bar.style.display = 'flex';
|
||||
const upBtn = $('btnUpDir');
|
||||
if (upBtn) upBtn.style.display = '';
|
||||
|
||||
bar.innerHTML = '';
|
||||
// Root
|
||||
const root = document.createElement('span');
|
||||
root.className = 'breadcrumb-seg breadcrumb-link';
|
||||
root.textContent = '~';
|
||||
root.onclick = () => { clearPreview(); loadDir('.'); };
|
||||
bar.appendChild(root);
|
||||
|
||||
const parts = filePath.split('/');
|
||||
let accumulated = '';
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const sep = document.createElement('span');
|
||||
sep.className = 'breadcrumb-sep';
|
||||
sep.textContent = '/';
|
||||
bar.appendChild(sep);
|
||||
|
||||
accumulated += (accumulated ? '/' : '') + parts[i];
|
||||
const seg = document.createElement('span');
|
||||
seg.textContent = parts[i];
|
||||
if (i < parts.length - 1) {
|
||||
seg.className = 'breadcrumb-seg breadcrumb-link';
|
||||
const target = accumulated;
|
||||
seg.onclick = () => { clearPreview(); loadDir(target); };
|
||||
} else {
|
||||
seg.className = 'breadcrumb-seg breadcrumb-current';
|
||||
}
|
||||
bar.appendChild(seg);
|
||||
}
|
||||
}
|
||||
|
||||
function openInBrowser(){
|
||||
if(!_previewCurrentPath||!S.session) return;
|
||||
const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(_previewCurrentPath)}`;
|
||||
window.open(url,'_blank');
|
||||
}
|
||||
|
||||
46
tests/_pytest_port.py
Normal file
46
tests/_pytest_port.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Shared test server constants for use in individual test files.
|
||||
|
||||
Instead of hardcoding ``BASE = "http://127.0.0.1:8788"`` in every test file,
|
||||
import from here so the port and state dir are always consistent with
|
||||
what conftest.py computed for this worktree.
|
||||
|
||||
Usage::
|
||||
|
||||
from tests._pytest_port import BASE
|
||||
|
||||
conftest.py publishes ``HERMES_WEBUI_TEST_PORT`` and
|
||||
``HERMES_WEBUI_TEST_STATE_DIR`` to ``os.environ`` at module level
|
||||
(before any test file is imported), so this module always reads the
|
||||
correct values. The auto-derivation fallback matches conftest's logic
|
||||
exactly, so standalone imports also work correctly.
|
||||
"""
|
||||
import hashlib
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
def _auto_test_port(repo_root: pathlib.Path) -> int:
|
||||
h = int(hashlib.md5(str(repo_root).encode()).hexdigest(), 16)
|
||||
return 20000 + (h % 10000)
|
||||
|
||||
def _auto_state_dir_name(repo_root: pathlib.Path) -> str:
|
||||
h = hashlib.md5(str(repo_root).encode()).hexdigest()[:8]
|
||||
return f"webui-test-{h}"
|
||||
|
||||
_TESTS_DIR = pathlib.Path(__file__).parent.resolve()
|
||||
_REPO_ROOT = _TESTS_DIR.parent.resolve()
|
||||
_HERMES_HOME = pathlib.Path(os.getenv('HERMES_HOME',
|
||||
str(pathlib.Path.home() / '.hermes')))
|
||||
|
||||
TEST_PORT = int(os.environ.get('HERMES_WEBUI_TEST_PORT',
|
||||
str(_auto_test_port(_REPO_ROOT))))
|
||||
BASE = f"http://127.0.0.1:{TEST_PORT}"
|
||||
|
||||
TEST_STATE_DIR = pathlib.Path(os.environ.get(
|
||||
'HERMES_WEBUI_TEST_STATE_DIR',
|
||||
str(_HERMES_HOME / _auto_state_dir_name(_REPO_ROOT))
|
||||
))
|
||||
|
||||
# Default model injected by conftest — tests that mutate the default model
|
||||
# must restore to this value so later tests see a consistent baseline.
|
||||
TEST_DEFAULT_MODEL = os.environ.get('HERMES_WEBUI_DEFAULT_MODEL', 'openai/gpt-5.4-mini')
|
||||
@@ -31,14 +31,45 @@ HOME = pathlib.Path.home()
|
||||
HERMES_HOME = pathlib.Path(os.getenv('HERMES_HOME', str(HOME / '.hermes')))
|
||||
|
||||
# ── Test server config ────────────────────────────────────────────────────
|
||||
TEST_PORT = int(os.getenv('HERMES_WEBUI_TEST_PORT', '8788'))
|
||||
# Port and state dir auto-derive from the repo path when no env var is set,
|
||||
# giving every worktree its own isolated port (8800-8899) and state directory.
|
||||
# Override with HERMES_WEBUI_TEST_PORT / HERMES_WEBUI_TEST_STATE_DIR to pin.
|
||||
|
||||
def _auto_test_port(repo_root) -> int:
|
||||
"""Map repo path to a unique port in 20000-29999 (10k range = near-zero collisions).
|
||||
Far from system port ranges and Linux ephemeral ports (32768+).
|
||||
Override with HERMES_WEBUI_TEST_PORT to use a specific port."""
|
||||
import hashlib
|
||||
h = int(hashlib.md5(str(repo_root).encode()).hexdigest(), 16)
|
||||
return 20000 + (h % 10000)
|
||||
|
||||
def _auto_state_dir_name(repo_root) -> str:
|
||||
import hashlib
|
||||
h = hashlib.md5(str(repo_root).encode()).hexdigest()[:8]
|
||||
return f"webui-test-{h}"
|
||||
|
||||
TEST_PORT = int(os.getenv('HERMES_WEBUI_TEST_PORT',
|
||||
str(_auto_test_port(REPO_ROOT))))
|
||||
TEST_BASE = f"http://127.0.0.1:{TEST_PORT}"
|
||||
TEST_STATE_DIR = pathlib.Path(os.getenv(
|
||||
'HERMES_WEBUI_TEST_STATE_DIR',
|
||||
str(HERMES_HOME / 'webui-mvp-test')
|
||||
str(HERMES_HOME / _auto_state_dir_name(REPO_ROOT))
|
||||
))
|
||||
TEST_WORKSPACE = TEST_STATE_DIR / 'test-workspace'
|
||||
|
||||
# Publish at module level so api.config, _pytest_port.py, and any test module
|
||||
# importing stateful API code during collection see the isolated test paths.
|
||||
#
|
||||
# Direct assignment is intentional for production-risk paths: tests that import
|
||||
# api.config/api.models in the pytest process must never inherit the real
|
||||
# ~/.hermes state tree before the server subprocess fixture starts.
|
||||
os.environ['HERMES_WEBUI_TEST_PORT'] = str(TEST_PORT)
|
||||
os.environ['HERMES_WEBUI_TEST_STATE_DIR'] = str(TEST_STATE_DIR)
|
||||
os.environ['HERMES_WEBUI_STATE_DIR'] = str(TEST_STATE_DIR)
|
||||
os.environ['HERMES_WEBUI_DEFAULT_WORKSPACE'] = str(TEST_WORKSPACE)
|
||||
os.environ['HERMES_HOME'] = str(TEST_STATE_DIR)
|
||||
os.environ['HERMES_BASE_HOME'] = str(TEST_STATE_DIR)
|
||||
|
||||
# ── Server script: always relative to repo root ───────────────────────────
|
||||
SERVER_SCRIPT = REPO_ROOT / 'server.py'
|
||||
if not SERVER_SCRIPT.exists():
|
||||
@@ -153,6 +184,8 @@ def pytest_collection_modifyitems(config, items):
|
||||
# Agent backend (need running AIAgent)
|
||||
'test_chat_stream_opens_successfully',
|
||||
'test_approval_submit_and_respond',
|
||||
# Security redaction (flaky — session state varies across test ordering)
|
||||
'test_api_sessions_list_redacts_titles',
|
||||
# Workspace path (macOS /tmp -> /private/tmp symlink)
|
||||
'test_new_session_inherits_workspace',
|
||||
'test_workspace_add_valid',
|
||||
@@ -170,7 +203,7 @@ def pytest_collection_modifyitems(config, items):
|
||||
skipped += 1
|
||||
|
||||
if skipped:
|
||||
print(f"\n⚠️ hermes-agent not found — {skipped} agent-dependent tests will be skipped\n")
|
||||
print(f"\nWARNING: hermes-agent not found; {skipped} agent-dependent tests will be skipped\n")
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
@@ -210,6 +243,18 @@ def test_server():
|
||||
Start an isolated test server on TEST_PORT with a clean state directory.
|
||||
Paths are discovered dynamically -- no hardcoded absolute path assumptions.
|
||||
"""
|
||||
# Kill any leftover process on the test port before starting.
|
||||
# Stale servers from QA harness runs or prior test sessions cause
|
||||
# conftest to think the server is already up, producing false failures.
|
||||
try:
|
||||
import subprocess as _sp
|
||||
_sp.run(['fuser', '-k', f'{TEST_PORT}/tcp'],
|
||||
capture_output=True, timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
import time as _time
|
||||
_time.sleep(0.5) # brief pause to let the port release
|
||||
|
||||
# Clean slate
|
||||
if TEST_STATE_DIR.exists():
|
||||
shutil.rmtree(TEST_STATE_DIR)
|
||||
@@ -226,7 +271,25 @@ def test_server():
|
||||
# Isolated cron state
|
||||
(TEST_STATE_DIR / 'cron').mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Expose TEST_STATE_DIR to the test process itself so that tests which write
|
||||
# directly to state.db (e.g. test_gateway_sync.py) always use the same path
|
||||
# as the server. Other test files (test_auth_sessions.py) may override
|
||||
# HERMES_WEBUI_STATE_DIR for their own purposes, but HERMES_WEBUI_TEST_STATE_DIR
|
||||
# is reserved for this mapping and is never overridden by individual test files.
|
||||
# Export both port and state-dir as env vars so individual test files
|
||||
# can read them without importing conftest (avoids circular imports).
|
||||
os.environ.setdefault('HERMES_WEBUI_TEST_PORT', str(TEST_PORT))
|
||||
# os.environ already set at module level above; no-op here.
|
||||
|
||||
env = os.environ.copy()
|
||||
# Strip real provider keys so test subprocess never inherits production credentials.
|
||||
# The test server uses a mock/isolated config — no real API calls are made.
|
||||
for _k in list(env):
|
||||
if any(_k.startswith(p) for p in (
|
||||
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY',
|
||||
'GOOGLE_API_KEY', 'DEEPSEEK_API_KEY',
|
||||
)):
|
||||
del env[_k]
|
||||
env.update({
|
||||
"HERMES_WEBUI_PORT": str(TEST_PORT),
|
||||
"HERMES_WEBUI_HOST": "127.0.0.1",
|
||||
@@ -234,6 +297,14 @@ def test_server():
|
||||
"HERMES_WEBUI_DEFAULT_WORKSPACE": str(TEST_WORKSPACE),
|
||||
"HERMES_WEBUI_DEFAULT_MODEL": "openai/gpt-5.4-mini",
|
||||
"HERMES_HOME": str(TEST_STATE_DIR),
|
||||
# Belt-and-suspenders: HERMES_BASE_HOME hard-locks _DEFAULT_HERMES_HOME
|
||||
# in api/profiles.py to the test state dir regardless of profile switching
|
||||
# or any os.environ mutation that happens inside the server process.
|
||||
# Without this, a profile switch or active_profile file in the real
|
||||
# ~/.hermes can redirect _get_active_hermes_home() out of the sandbox,
|
||||
# causing onboarding writes (config.yaml, .env) to land in the production
|
||||
# ~/.hermes/profiles/webui/ and overwrite real API keys.
|
||||
"HERMES_BASE_HOME": str(TEST_STATE_DIR),
|
||||
})
|
||||
|
||||
# Pass agent dir if discovered so server.py doesn't have to re-discover
|
||||
@@ -279,6 +350,33 @@ def base_url():
|
||||
return TEST_BASE
|
||||
|
||||
|
||||
# ── Per-test model cache invalidation ────────────────────────────────────────
|
||||
# The TTL cache for get_available_models() persists across tests within the
|
||||
# same process. Tests that modify cfg in-memory won't trigger the mtime path,
|
||||
# so the cache must be explicitly invalidated after each test that exercises
|
||||
# provider/model detection.
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _invalidate_models_cache_after_test():
|
||||
"""Force the TTL cache to be cleared before and after every test.
|
||||
|
||||
This prevents state bleed where a test that calls get_available_models()
|
||||
populates the cache with a particular config, and the next test sees stale
|
||||
results even though it has mutated _cfg_cache in-memory.
|
||||
"""
|
||||
try:
|
||||
from api.config import invalidate_models_cache
|
||||
invalidate_models_cache()
|
||||
except Exception:
|
||||
pass
|
||||
yield
|
||||
try:
|
||||
from api.config import invalidate_models_cache
|
||||
invalidate_models_cache()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── Per-test session cleanup ──────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
138
tests/test_1003_appearance_autosave.py
Normal file
138
tests/test_1003_appearance_autosave.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""Regression checks for Issue #1003: Appearance settings autosave.
|
||||
|
||||
Focus:
|
||||
- Theme/Skin/Font size should autosave immediately and only show inline status.
|
||||
- Appearance changes should not participate in the global unsaved-changes guard.
|
||||
- Full-save flow should still include font_size.
|
||||
- /api/settings should accept appearance-only payloads and preserve untouched fields.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from tests._pytest_port import BASE
|
||||
|
||||
|
||||
BOOT_JS = (Path(__file__).parent.parent / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
PANELS_JS = (Path(__file__).parent.parent / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
INDEX_HTML = (Path(__file__).parent.parent / "static" / "index.html").read_text(encoding="utf-8")
|
||||
I18N_JS = (Path(__file__).parent.parent / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _function_block(src: str, name: str) -> str:
|
||||
marker = re.search(rf"(^|\n)(?:async\s+)?function\s+{re.escape(name)}\(", src)
|
||||
assert marker is not None, f"{name}() not found"
|
||||
start = marker.start()
|
||||
next_marker = re.search(r"\n(?:function\s+\w+\(|async\s+function\s+\w+\()", src[start + 1 :])
|
||||
if next_marker:
|
||||
end = start + 1 + next_marker.start()
|
||||
else:
|
||||
end = len(src)
|
||||
return src[start:end]
|
||||
|
||||
|
||||
def _post(path, body):
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
BASE + path,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status
|
||||
except urllib.error.HTTPError as e:
|
||||
payload = e.read()
|
||||
return json.loads(payload or b"{}"), e.code
|
||||
|
||||
|
||||
def _get(path):
|
||||
with urllib.request.urlopen(BASE + path, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status
|
||||
|
||||
|
||||
def test_appearance_pickers_schedule_autosave_and_do_not_mark_dirty():
|
||||
for fn in ("_pickTheme", "_pickSkin", "_pickFontSize"):
|
||||
block = _function_block(BOOT_JS, fn)
|
||||
assert "_scheduleAppearanceAutosave()" in block, (
|
||||
f"{fn}() should invoke _scheduleAppearanceAutosave()"
|
||||
)
|
||||
assert "_markSettingsDirty()" not in block, (
|
||||
f"{fn}() should not call _markSettingsDirty()"
|
||||
)
|
||||
|
||||
|
||||
def test_appearance_revert_preview_no_longer_rolls_back_theme_skin_font():
|
||||
block = _function_block(PANELS_JS, "_revertSettingsPreview")
|
||||
assert "hermes-theme" not in block
|
||||
assert "hermes-skin" not in block
|
||||
assert "hermes-font-size" not in block
|
||||
assert "_markSettingsDirty()" not in block
|
||||
|
||||
|
||||
def test_appearance_autosave_payload_is_theme_skin_font_only():
|
||||
block = _function_block(PANELS_JS, "_appearancePayloadFromUi")
|
||||
assert "theme:" in block
|
||||
assert "skin:" in block
|
||||
assert "font_size:" in block
|
||||
assert "language:" not in block
|
||||
assert "workspace" not in block
|
||||
assert "show_token_usage" not in block
|
||||
status_block = _function_block(PANELS_JS, "_setAppearanceAutosaveStatus")
|
||||
assert "settings_autosave_saving" in status_block
|
||||
assert "settings_autosave_saved" in status_block
|
||||
assert "settings_autosave_failed" in status_block
|
||||
assert "settings_autosave_retry" in status_block
|
||||
|
||||
|
||||
def test_appearance_autosave_status_line_and_i18n_keys_exist():
|
||||
assert 'id="settingsAppearanceAutosaveStatus"' in INDEX_HTML
|
||||
required_keys = [
|
||||
"settings_autosave_saving",
|
||||
"settings_autosave_saved",
|
||||
"settings_autosave_failed",
|
||||
"settings_autosave_retry",
|
||||
]
|
||||
for key in required_keys:
|
||||
assert I18N_JS.count(f"{key}:") >= 8, (
|
||||
f"{key} must be defined in all LOCALES blocks (found {I18N_JS.count(f'{key}:')})"
|
||||
)
|
||||
|
||||
|
||||
def test_full_save_settings_still_includes_font_size():
|
||||
block = _function_block(PANELS_JS, "saveSettings")
|
||||
compact = block.replace(" ", "")
|
||||
assert "body.theme=theme;" in compact
|
||||
assert "body.skin=skin;" in compact
|
||||
assert "body.font_size=fontSize;" in compact
|
||||
|
||||
|
||||
def test_settings_api_accepts_appearance_only_payload_without_overwriting_other_fields():
|
||||
original, status = _get("/api/settings")
|
||||
assert status == 200
|
||||
snapshot = {
|
||||
"theme": original.get("theme"),
|
||||
"skin": original.get("skin"),
|
||||
"font_size": original.get("font_size", "default"),
|
||||
"show_token_usage": original.get("show_token_usage"),
|
||||
"show_cli_sessions": original.get("show_cli_sessions"),
|
||||
"check_for_updates": original.get("check_for_updates"),
|
||||
}
|
||||
try:
|
||||
d, status = _post("/api/settings", {"theme": "system", "skin": "charizard", "font_size": "large"})
|
||||
assert status == 200
|
||||
assert d.get("theme") == "system"
|
||||
assert d.get("skin") == "charizard"
|
||||
assert d.get("font_size") == "large"
|
||||
reloaded, _ = _get("/api/settings")
|
||||
assert reloaded.get("show_token_usage") == snapshot["show_token_usage"]
|
||||
assert reloaded.get("show_cli_sessions") == snapshot["show_cli_sessions"]
|
||||
assert reloaded.get("check_for_updates") == snapshot["check_for_updates"]
|
||||
finally:
|
||||
_post("/api/settings", {
|
||||
"theme": snapshot["theme"],
|
||||
"skin": snapshot["skin"],
|
||||
"font_size": snapshot["font_size"],
|
||||
})
|
||||
178
tests/test_1003_preferences_autosave.py
Normal file
178
tests/test_1003_preferences_autosave.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""Regression checks for Issue #1003 Phase 2: Preferences settings autosave (PR #1369).
|
||||
|
||||
Mirrors the structure of test_1003_appearance_autosave.py to verify the
|
||||
preferences-panel autosave pattern is wired correctly:
|
||||
|
||||
- All 13 preference fields use _schedulePreferencesAutosave (not _markSettingsDirty)
|
||||
- Password field MUST still call _markSettingsDirty (security: never autosave)
|
||||
- _preferencesPayloadFromUi covers all 13 fields
|
||||
- _setPreferencesAutosaveStatus uses the shared i18n keys
|
||||
- Status div exists in static/index.html
|
||||
- _autosavePreferencesSettings clears the dirty flag and hides the unsaved bar
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
PANELS_JS = (Path(__file__).parent.parent / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
INDEX_HTML = (Path(__file__).parent.parent / "static" / "index.html").read_text(encoding="utf-8")
|
||||
I18N_JS = (Path(__file__).parent.parent / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _function_block(src: str, name: str) -> str:
|
||||
marker = re.search(rf"(^|\n)(?:async\s+)?function\s+{re.escape(name)}\(", src)
|
||||
assert marker is not None, f"{name}() not found"
|
||||
start = marker.start()
|
||||
next_marker = re.search(r"\n(?:function\s+\w+\(|async\s+function\s+\w+\()", src[start + 1:])
|
||||
end = start + 1 + next_marker.start() if next_marker else len(src)
|
||||
return src[start:end]
|
||||
|
||||
|
||||
def _load_settings_panel_block() -> str:
|
||||
return _function_block(PANELS_JS, "loadSettingsPanel")
|
||||
|
||||
|
||||
# ── Field-by-field autosave wiring ───────────────────────────────────────
|
||||
|
||||
PREFERENCE_FIELDS_AUTOSAVE = [
|
||||
# (DOM id, field name in _preferencesPayloadFromUi)
|
||||
("settingsSendKey", "send_key"),
|
||||
("settingsLanguage", "language"),
|
||||
("settingsShowTokenUsage", "show_token_usage"),
|
||||
("settingsSimplifiedToolCalling", "simplified_tool_calling"),
|
||||
("settingsShowCliSessions", "show_cli_sessions"),
|
||||
("settingsSyncInsights", "sync_to_insights"),
|
||||
("settingsCheckUpdates", "check_for_updates"),
|
||||
("settingsSoundEnabled", "sound_enabled"),
|
||||
("settingsNotificationsEnabled", "notifications_enabled"),
|
||||
("settingsSidebarDensity", "sidebar_density"),
|
||||
("settingsAutoTitleRefresh", "auto_title_refresh_every"),
|
||||
("settingsBusyInputMode", "busy_input_mode"),
|
||||
("settingsBotName", "bot_name"),
|
||||
]
|
||||
|
||||
|
||||
def test_all_13_preference_fields_have_autosave_payload_entries():
|
||||
"""_preferencesPayloadFromUi must include all 13 preference fields."""
|
||||
block = _function_block(PANELS_JS, "_preferencesPayloadFromUi")
|
||||
for dom_id, field in PREFERENCE_FIELDS_AUTOSAVE:
|
||||
assert f"$('{dom_id}')" in block, \
|
||||
f"_preferencesPayloadFromUi missing reference to {dom_id}"
|
||||
assert f"payload.{field}=" in block, \
|
||||
f"_preferencesPayloadFromUi missing payload assignment for {field}"
|
||||
|
||||
|
||||
def test_preference_fields_use_schedule_autosave_not_mark_dirty():
|
||||
"""All 12 listener attachments (excluding bot_name's debounce wrapper) must
|
||||
use _schedulePreferencesAutosave. bot_name uses a wrapper but still
|
||||
eventually calls _schedulePreferencesAutosave."""
|
||||
panel = _load_settings_panel_block()
|
||||
# Each field should have at least one addEventListener call wired to the autosave
|
||||
# path. We check that for each non-password/non-model field, the dirty marker
|
||||
# has been replaced.
|
||||
for dom_id, _field in PREFERENCE_FIELDS_AUTOSAVE:
|
||||
if dom_id == "settingsBotName":
|
||||
# Bot name uses a 500ms wrapper that calls _schedulePreferencesAutosave
|
||||
# via setTimeout. The wrapper itself is in the loadSettingsPanel block.
|
||||
assert "_schedulePreferencesAutosave" in panel, \
|
||||
"_schedulePreferencesAutosave must be referenced for bot_name flow"
|
||||
continue
|
||||
# For other fields: search the field's block for the addEventListener call
|
||||
# and verify it points to _schedulePreferencesAutosave.
|
||||
# We use a context window around the dom_id to find the listener.
|
||||
idx = panel.find(f"$('{dom_id}')")
|
||||
assert idx != -1, f"{dom_id} not loaded in loadSettingsPanel"
|
||||
# Window of next ~600 chars covers the .addEventListener call
|
||||
window = panel[idx:idx + 600]
|
||||
assert "addEventListener" in window, f"{dom_id} has no addEventListener"
|
||||
assert "_schedulePreferencesAutosave" in window, \
|
||||
f"{dom_id} listener should call _schedulePreferencesAutosave (Phase 2 #1003)"
|
||||
assert "_markSettingsDirty" not in window, \
|
||||
f"{dom_id} should not call _markSettingsDirty (Phase 2 autosaves it)"
|
||||
|
||||
|
||||
def test_password_still_uses_mark_dirty():
|
||||
"""SECURITY INVARIANT: password field must NEVER autosave; it must still
|
||||
call _markSettingsDirty so user explicitly clicks Save Settings."""
|
||||
panel = _load_settings_panel_block()
|
||||
idx = panel.find("$('settingsPassword')")
|
||||
assert idx != -1, "settingsPassword field not loaded"
|
||||
window = panel[idx:idx + 400]
|
||||
assert "_markSettingsDirty" in window, \
|
||||
"Password field MUST call _markSettingsDirty (security: never autosave passwords)"
|
||||
assert "_schedulePreferencesAutosave" not in window, \
|
||||
"Password field MUST NOT call _schedulePreferencesAutosave (security)"
|
||||
|
||||
|
||||
def test_autosave_clears_dirty_flag_and_hides_unsaved_bar():
|
||||
"""_autosavePreferencesSettings must clear the dirty flag and hide the
|
||||
unsaved-changes bar on success — but ONLY when password and model are
|
||||
not pending. Q1 from Opus pre-release review of v0.50.250."""
|
||||
block = _function_block(PANELS_JS, "_autosavePreferencesSettings")
|
||||
# Must check pwField/modelSel state before clearing dirty + hiding bar
|
||||
assert "settingsPassword" in block, (
|
||||
"_autosavePreferencesSettings must check the password field before "
|
||||
"clearing _settingsDirty (Opus SHOULD-FIX Q1: autosave was clobbering "
|
||||
"pending password edits)"
|
||||
)
|
||||
assert "settingsModel" in block, (
|
||||
"_autosavePreferencesSettings must check the model selector before "
|
||||
"clearing _settingsDirty (autosave was clobbering pending model changes)"
|
||||
)
|
||||
assert "_settingsHermesDefaultModelOnOpen" in block, (
|
||||
"_autosavePreferencesSettings must compare the model selector value "
|
||||
"against the on-open snapshot to detect a pending change"
|
||||
)
|
||||
# The clear-and-hide block must be conditional, not unconditional
|
||||
compact = block.replace(" ", "").replace("\n", "")
|
||||
assert "if(!pwDirty&&!modelDirty)" in compact or "if(pwDirty||modelDirty)" in compact, (
|
||||
"_autosavePreferencesSettings must guard the dirty-clear and bar-hide "
|
||||
"with a condition that defers when a manual field has pending edits"
|
||||
)
|
||||
|
||||
|
||||
def test_status_div_exists_in_index_html():
|
||||
"""The status div must be present in index.html for status feedback."""
|
||||
assert 'id="settingsPreferencesAutosaveStatus"' in INDEX_HTML
|
||||
|
||||
|
||||
def test_set_status_uses_shared_i18n_keys():
|
||||
"""_setPreferencesAutosaveStatus must use the shared i18n keys from Phase 1."""
|
||||
block = _function_block(PANELS_JS, "_setPreferencesAutosaveStatus")
|
||||
for key in [
|
||||
"settings_autosave_saving",
|
||||
"settings_autosave_saved",
|
||||
"settings_autosave_failed",
|
||||
"settings_autosave_retry",
|
||||
]:
|
||||
assert key in block, f"_setPreferencesAutosaveStatus must use '{key}'"
|
||||
|
||||
|
||||
def test_retry_function_exists_and_falls_back_gracefully():
|
||||
"""_retryPreferencesAutosave must exist and use the saved retry payload (or
|
||||
rebuild from UI if unavailable)."""
|
||||
block = _function_block(PANELS_JS, "_retryPreferencesAutosave")
|
||||
assert "_settingsPreferencesAutosaveRetryPayload" in block, \
|
||||
"Retry must reference the stored payload"
|
||||
assert "_preferencesPayloadFromUi" in block, \
|
||||
"Retry must fall back to rebuilding from UI when no stored payload"
|
||||
assert "_autosavePreferencesSettings" in block, \
|
||||
"Retry must invoke _autosavePreferencesSettings"
|
||||
|
||||
|
||||
def test_debounce_cancels_pending_timer_on_rapid_input():
|
||||
"""_schedulePreferencesAutosave must clear any in-flight timer before
|
||||
setting a new one — otherwise rapid changes queue up multiple POSTs."""
|
||||
block = _function_block(PANELS_JS, "_schedulePreferencesAutosave")
|
||||
assert "clearTimeout(_settingsPreferencesAutosaveTimer)" in block, \
|
||||
"_schedulePreferencesAutosave must clearTimeout the prior timer"
|
||||
assert "350" in block, \
|
||||
"_schedulePreferencesAutosave must use 350ms debounce (matching Phase 1)"
|
||||
|
||||
|
||||
def test_phase1_appearance_autosave_still_passes():
|
||||
"""Sanity: Phase 2 must not break Phase 1's pattern. The Appearance autosave
|
||||
functions and i18n keys must still exist."""
|
||||
assert "function _appearancePayloadFromUi" in PANELS_JS
|
||||
assert "function _autosaveAppearanceSettings" in PANELS_JS
|
||||
assert "function _scheduleAppearanceAutosave" in PANELS_JS
|
||||
assert 'id="settingsAppearanceAutosaveStatus"' in INDEX_HTML
|
||||
112
tests/test_1038_pwa_auth_redirect.py
Normal file
112
tests/test_1038_pwa_auth_redirect.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
Tests for issue #1038 — iOS PWA auth-expiry redirect.
|
||||
|
||||
When a 401 is returned by any API endpoint, the client-side JS should redirect
|
||||
to /login rather than showing a raw error toast. On iOS PWA standalone mode a
|
||||
server-side 302→/login breaks out of the PWA shell into Safari, so the fix is
|
||||
client-side: workspace.js api() intercepts 401 before throwing and calls
|
||||
window.location.href = '/login'.
|
||||
|
||||
These are static regression tests that verify the JS source contains the
|
||||
correct guard patterns.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent.parent
|
||||
|
||||
|
||||
def _workspace_js() -> str:
|
||||
return (ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _ui_js() -> str:
|
||||
return (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestPWAAuthRedirect:
|
||||
def test_workspace_js_has_401_redirect(self):
|
||||
"""api() in workspace.js must redirect to /login on 401."""
|
||||
src = _workspace_js()
|
||||
# Guard must appear inside the !res.ok block, before throwing
|
||||
assert "res.status===401" in src, \
|
||||
"workspace.js api() must check res.status===401"
|
||||
assert "window.location.href='/login" in src or 'window.location.href="/login' in src, \
|
||||
"workspace.js api() must redirect to /login on 401"
|
||||
|
||||
def test_workspace_js_401_before_throw(self):
|
||||
"""The 401 redirect must come before any error throw."""
|
||||
src = _workspace_js()
|
||||
idx_401 = src.find("res.status===401")
|
||||
# api() may throw via `throw new Error(...)` or via the structured
|
||||
# `const err=new Error(...); ... throw err;` pattern that attaches HTTP
|
||||
# context for callers. Either is fine — what matters is the 401 redirect
|
||||
# short-circuits before the generic throw.
|
||||
idx_throw = src.find("throw new Error")
|
||||
if idx_throw == -1:
|
||||
idx_throw = src.find("throw err")
|
||||
assert idx_401 != -1, "401 guard not found in workspace.js"
|
||||
assert idx_throw != -1, "no error throw found in workspace.js"
|
||||
assert idx_401 < idx_throw, \
|
||||
"401 redirect must appear before the generic throw in workspace.js"
|
||||
|
||||
def test_ui_js_has_redirect_helper(self):
|
||||
"""ui.js must define _redirectIfUnauth helper."""
|
||||
src = _ui_js()
|
||||
assert "_redirectIfUnauth" in src, \
|
||||
"ui.js must define _redirectIfUnauth helper function"
|
||||
|
||||
def test_ui_js_models_fetch_uses_redirect(self):
|
||||
"""populateModelDropdown() must call _redirectIfUnauth on the api/models response."""
|
||||
src = _ui_js()
|
||||
# The helper must be called after the api/models fetch
|
||||
assert "_redirectIfUnauth(_modelsRes)" in src, \
|
||||
"populateModelDropdown() must check 401 on api/models fetch"
|
||||
|
||||
def test_ui_js_live_models_fetch_uses_redirect(self):
|
||||
"""loadLiveModels() must call _redirectIfUnauth on the api/models/live response."""
|
||||
src = _ui_js()
|
||||
assert "_redirectIfUnauth(_liveRes)" in src, \
|
||||
"loadLiveModels() must check 401 on api/models/live fetch"
|
||||
|
||||
def test_ui_js_upload_fetch_uses_redirect(self):
|
||||
"""File upload must call _redirectIfUnauth on the api/upload response."""
|
||||
src = _ui_js()
|
||||
assert "_redirectIfUnauth(res)" in src, \
|
||||
"upload fetch must call _redirectIfUnauth"
|
||||
|
||||
|
||||
class TestLoginJsSafeNextPath:
|
||||
"""login.js _safeNextPath() must honor ?next= but reject open-redirect payloads."""
|
||||
|
||||
@staticmethod
|
||||
def _login_js():
|
||||
return (Path(__file__).parent.parent / "static" / "login.js").read_text(encoding="utf-8")
|
||||
|
||||
def test_safe_next_path_function_exists(self):
|
||||
"""login.js must define _safeNextPath() to honor the ?next= redirect."""
|
||||
assert "_safeNextPath" in self._login_js(), (
|
||||
"login.js must define _safeNextPath() to use the ?next= redirect after login"
|
||||
)
|
||||
|
||||
def test_login_uses_safe_next_path(self):
|
||||
"""doLogin success handler must redirect to _safeNextPath(), not hardcoded './'."""
|
||||
src = self._login_js()
|
||||
assert "_safeNextPath()" in src, (
|
||||
"doLogin must call _safeNextPath() instead of hardcoding './'"
|
||||
)
|
||||
|
||||
def test_safe_next_path_rejects_protocol_relative(self):
|
||||
"""_safeNextPath guard must reject '//' prefix (protocol-relative open-redirect)."""
|
||||
src = self._login_js()
|
||||
assert "charAt(1) === '/'" in src or "startsWith('//')" in src, (
|
||||
"_safeNextPath must reject protocol-relative paths like //evil.com"
|
||||
)
|
||||
|
||||
def test_safe_next_path_rejects_non_path_absolute(self):
|
||||
"""_safeNextPath guard must require path starts with '/'."""
|
||||
src = self._login_js()
|
||||
assert "charAt(0) !== '/'" in src or "startsWith('/')" in src, (
|
||||
"_safeNextPath must reject non-path-absolute inputs (e.g. 'http://...')"
|
||||
)
|
||||
51
tests/test_1044_mermaid_csp_font.py
Normal file
51
tests/test_1044_mermaid_csp_font.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Tests for issue #1044 — Mermaid CSP font violation.
|
||||
|
||||
Mermaid's built-in themes inject an @import for Google Fonts (Manrope) at
|
||||
render time, which is blocked by the CSP's style-src directive. Fix: pass
|
||||
fontFamily:'inherit' in themeVariables so Mermaid never requests an external
|
||||
font URL.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent.parent
|
||||
|
||||
|
||||
def _ui_js() -> str:
|
||||
return (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestMermaidCSPFont:
|
||||
def test_mermaid_init_has_font_family_inherit(self):
|
||||
"""themeVariables in mermaid.initialize() must set fontFamily to 'inherit'."""
|
||||
src = _ui_js()
|
||||
assert "fontFamily:'inherit'" in src, (
|
||||
"mermaid.initialize() themeVariables must set fontFamily:'inherit' "
|
||||
"to suppress the Google Fonts (Manrope) import that violates CSP"
|
||||
)
|
||||
|
||||
def test_mermaid_init_no_google_fonts_url(self):
|
||||
"""ui.js must not contain a hardcoded fonts.googleapis.com URL."""
|
||||
src = _ui_js()
|
||||
assert "fonts.googleapis.com" not in src, (
|
||||
"ui.js must not reference fonts.googleapis.com — use fontFamily:'inherit'"
|
||||
)
|
||||
|
||||
def test_mermaid_font_family_inside_theme_variables_block(self):
|
||||
"""fontFamily:'inherit' must be inside the themeVariables block of mermaid.initialize()."""
|
||||
src = _ui_js()
|
||||
init_idx = src.find("mermaid.initialize(")
|
||||
assert init_idx != -1, "mermaid.initialize() call not found in ui.js"
|
||||
# Find the themeVariables block after the initialize call
|
||||
tv_idx = src.find("themeVariables", init_idx)
|
||||
assert tv_idx != -1, "themeVariables not found inside mermaid.initialize()"
|
||||
font_idx = src.find("fontFamily:'inherit'", tv_idx)
|
||||
assert font_idx != -1, (
|
||||
"fontFamily:'inherit' must appear inside themeVariables in mermaid.initialize()"
|
||||
)
|
||||
# The closing brace of themeVariables should come after fontFamily
|
||||
close_brace = src.find("})", tv_idx)
|
||||
assert font_idx < close_brace, (
|
||||
"fontFamily:'inherit' must be inside the themeVariables block (before })"
|
||||
)
|
||||
116
tests/test_1045_bfcache_layout_restore.py
Normal file
116
tests/test_1045_bfcache_layout_restore.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Tests for issue #1045 — bfcache layout broken on tab restore.
|
||||
|
||||
When the browser restores a page from bfcache (event.persisted === true),
|
||||
the async boot IIFE does not re-run. The existing pageshow handler (added for
|
||||
#822) only cleared the session search field and re-rendered the session list.
|
||||
This left the rail, topbar, workspace panel, and resize handles in the stale
|
||||
bfcache DOM state, producing a broken layout.
|
||||
|
||||
Fix: extend the pageshow handler to also call syncTopbar, syncWorkspacePanelState,
|
||||
_initResizePanels, and startGatewaySSE — all guarded so missing helpers degrade.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent.parent
|
||||
|
||||
|
||||
def _boot_js() -> str:
|
||||
return (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestBfcacheLayoutRestore:
|
||||
def test_pageshow_calls_sync_topbar(self):
|
||||
"""pageshow handler must call syncTopbar() on bfcache restore."""
|
||||
src = _boot_js()
|
||||
# Find the pageshow listener block
|
||||
ps_idx = src.find("window.addEventListener('pageshow'")
|
||||
assert ps_idx != -1, "pageshow listener not found in boot.js"
|
||||
handler_body = src[ps_idx:ps_idx + 1600]
|
||||
assert "syncTopbar" in handler_body, (
|
||||
"pageshow handler must call syncTopbar() to restore topbar state after bfcache"
|
||||
)
|
||||
|
||||
def test_pageshow_calls_sync_workspace_panel_state(self):
|
||||
"""pageshow handler must call syncWorkspacePanelState()."""
|
||||
src = _boot_js()
|
||||
ps_idx = src.find("window.addEventListener('pageshow'")
|
||||
handler_body = src[ps_idx:ps_idx + 1600]
|
||||
assert "syncWorkspacePanelState" in handler_body, (
|
||||
"pageshow handler must call syncWorkspacePanelState() on bfcache restore"
|
||||
)
|
||||
|
||||
|
||||
def test_pageshow_calls_start_gateway_sse(self):
|
||||
"""pageshow handler must call startGatewaySSE() to reconnect the dead SSE connection."""
|
||||
src = _boot_js()
|
||||
ps_idx = src.find("window.addEventListener('pageshow'")
|
||||
handler_body = src[ps_idx:ps_idx + 1600]
|
||||
assert "startGatewaySSE" in handler_body, (
|
||||
"pageshow handler must restart gateway SSE (bfcache-persisted connections are dead)"
|
||||
)
|
||||
|
||||
def test_pageshow_still_clears_session_search(self):
|
||||
"""pageshow handler must still clear #sessionSearch (original #822 fix preserved)."""
|
||||
src = _boot_js()
|
||||
ps_idx = src.find("window.addEventListener('pageshow'")
|
||||
handler_body = src[ps_idx:ps_idx + 1600]
|
||||
assert "sessionSearch" in handler_body, (
|
||||
"pageshow handler must still clear #sessionSearch (regression: #822 fix must be preserved)"
|
||||
)
|
||||
|
||||
def test_pageshow_still_calls_render_session_list_from_cache(self):
|
||||
"""pageshow handler must still call renderSessionListFromCache()."""
|
||||
src = _boot_js()
|
||||
ps_idx = src.find("window.addEventListener('pageshow'")
|
||||
handler_body = src[ps_idx:ps_idx + 1600]
|
||||
assert "renderSessionListFromCache" in handler_body, (
|
||||
"pageshow handler must still call renderSessionListFromCache() (regression: #822 fix)"
|
||||
)
|
||||
|
||||
def test_pageshow_does_not_call_init_resize_panels(self):
|
||||
"""pageshow handler must NOT call _initResizePanels() — bfcache
|
||||
preserves event listeners so re-attaching them stacks duplicates."""
|
||||
src = _boot_js()
|
||||
ps_idx = src.find("window.addEventListener('pageshow'")
|
||||
handler_body = src[ps_idx:ps_idx + 1600]
|
||||
assert "_initResizePanels" not in handler_body, (
|
||||
"pageshow handler must not call _initResizePanels() — it stacks "
|
||||
"duplicate mousedown listeners on every bfcache restore"
|
||||
)
|
||||
|
||||
def test_new_calls_are_guarded_with_typeof(self):
|
||||
"""New calls in the pageshow handler must be typeof-guarded for safe degradation."""
|
||||
src = _boot_js()
|
||||
ps_idx = src.find("window.addEventListener('pageshow'")
|
||||
handler_body = src[ps_idx:ps_idx + 1600]
|
||||
# Each of the new calls must be guarded
|
||||
for fn in ("syncTopbar", "syncWorkspacePanelState", "startGatewaySSE",
|
||||
"closeModelDropdown", "closeReasoningDropdown", "closeWsDropdown", "closeProfileDropdown"):
|
||||
assert f"typeof {fn} === 'function'" in handler_body, (
|
||||
f"{fn}() call in pageshow handler must be guarded with typeof === 'function'"
|
||||
)
|
||||
|
||||
def test_pageshow_closes_all_dropdowns(self):
|
||||
"""pageshow handler must close all known dropdowns to reset frozen bfcache popover state."""
|
||||
src = _boot_js()
|
||||
ps_idx = src.find("window.addEventListener('pageshow'")
|
||||
handler_body = src[ps_idx:ps_idx + 1600]
|
||||
for fn in ("closeModelDropdown", "closeReasoningDropdown", "closeWsDropdown", "closeProfileDropdown"):
|
||||
assert fn in handler_body, (
|
||||
f"pageshow handler must call {fn}() to dismiss any dropdown left open by bfcache"
|
||||
)
|
||||
|
||||
def test_dropdowns_closed_before_layout_sync(self):
|
||||
"""Dropdown closes must come before layout sync calls (clean state first)."""
|
||||
src = _boot_js()
|
||||
ps_idx = src.find("window.addEventListener('pageshow'")
|
||||
handler_body = src[ps_idx:ps_idx + 1600]
|
||||
close_idx = handler_body.find("closeModelDropdown")
|
||||
sync_idx = handler_body.find("syncTopbar")
|
||||
assert close_idx != -1 and sync_idx != -1, "Both close and sync calls must be present"
|
||||
assert close_idx < sync_idx, (
|
||||
"Dropdown close calls must appear before layout sync calls in the pageshow handler"
|
||||
)
|
||||
|
||||
382
tests/test_1058_adaptive_title_refresh.py
Normal file
382
tests/test_1058_adaptive_title_refresh.py
Normal file
@@ -0,0 +1,382 @@
|
||||
"""Tests for adaptive session title refresh helpers (PR #1058).
|
||||
|
||||
Covers all five new functions added to api/streaming.py:
|
||||
- _count_exchanges
|
||||
- _latest_exchange_snippets
|
||||
- _get_title_refresh_interval
|
||||
- _run_background_title_refresh
|
||||
- _maybe_schedule_title_refresh
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import threading
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure the project root is on sys.path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
from api.streaming import (
|
||||
_count_exchanges,
|
||||
_latest_exchange_snippets,
|
||||
_get_title_refresh_interval,
|
||||
_run_background_title_refresh,
|
||||
_maybe_schedule_title_refresh,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_auth_sessions():
|
||||
"""Snapshot and restore api.auth._sessions around each test.
|
||||
|
||||
Importing api.streaming can trigger api.config.load_settings() which may
|
||||
call into api.auth and create a real session token. Without this fixture,
|
||||
that stale token leaks into test_auth_session_persistence.py tests (which
|
||||
assume _sessions starts empty) when our file runs first alphabetically.
|
||||
"""
|
||||
import api.auth as _auth
|
||||
snapshot = dict(_auth._sessions)
|
||||
yield
|
||||
_auth._sessions.clear()
|
||||
_auth._sessions.update(snapshot)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _user_msg(text):
|
||||
return {'role': 'user', 'content': text}
|
||||
|
||||
|
||||
def _asst_msg(text, tool_calls=None):
|
||||
msg = {'role': 'assistant', 'content': text}
|
||||
if tool_calls is not None:
|
||||
msg['tool_calls'] = tool_calls
|
||||
return msg
|
||||
|
||||
|
||||
def _tool_only_asst():
|
||||
"""Assistant message that only has tool_calls, no real text."""
|
||||
return {'role': 'assistant', 'content': '', 'tool_calls': [{'id': 't1', 'type': 'function'}]}
|
||||
|
||||
|
||||
def _make_session(title='My Title', llm_title_generated=True, messages=None, session_id='sid1'):
|
||||
s = MagicMock()
|
||||
s.title = title
|
||||
s.llm_title_generated = llm_title_generated
|
||||
s.messages = messages or []
|
||||
s.session_id = session_id
|
||||
s.save = MagicMock()
|
||||
return s
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _count_exchanges
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCountExchanges:
|
||||
def test_empty_messages_returns_zero(self):
|
||||
assert _count_exchanges([]) == 0
|
||||
|
||||
def test_none_messages_returns_zero(self):
|
||||
assert _count_exchanges(None) == 0
|
||||
|
||||
def test_counts_only_user_messages(self):
|
||||
msgs = [_user_msg('hello'), _asst_msg('hi'), _user_msg('world')]
|
||||
assert _count_exchanges(msgs) == 2
|
||||
|
||||
def test_skips_empty_user_messages(self):
|
||||
msgs = [_user_msg(''), _user_msg(' '), _user_msg('real question')]
|
||||
assert _count_exchanges(msgs) == 1
|
||||
|
||||
def test_counts_list_content_user_messages(self):
|
||||
msgs = [
|
||||
{'role': 'user', 'content': [{'type': 'text', 'text': 'list question'}]},
|
||||
]
|
||||
assert _count_exchanges(msgs) == 1
|
||||
|
||||
def test_skips_empty_list_content(self):
|
||||
msgs = [
|
||||
{'role': 'user', 'content': [{'type': 'text', 'text': ' '}]},
|
||||
]
|
||||
assert _count_exchanges(msgs) == 0
|
||||
|
||||
def test_ignores_non_user_roles(self):
|
||||
msgs = [_asst_msg('response'), {'role': 'system', 'content': 'system prompt'}]
|
||||
assert _count_exchanges(msgs) == 0
|
||||
|
||||
def test_ignores_non_dict_entries(self):
|
||||
msgs = ['not a dict', _user_msg('real'), None]
|
||||
assert _count_exchanges(msgs) == 1
|
||||
|
||||
def test_five_exchanges(self):
|
||||
msgs = []
|
||||
for i in range(5):
|
||||
msgs.append(_user_msg(f'question {i}'))
|
||||
msgs.append(_asst_msg(f'answer {i}'))
|
||||
assert _count_exchanges(msgs) == 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _latest_exchange_snippets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLatestExchangeSnippets:
|
||||
def test_empty_returns_empty_strings(self):
|
||||
u, a = _latest_exchange_snippets([])
|
||||
assert u == '' and a == ''
|
||||
|
||||
def test_none_returns_empty_strings(self):
|
||||
u, a = _latest_exchange_snippets(None)
|
||||
assert u == '' and a == ''
|
||||
|
||||
def test_basic_pair(self):
|
||||
msgs = [_user_msg('first q'), _asst_msg('first a'),
|
||||
_user_msg('second q'), _asst_msg('second a')]
|
||||
u, a = _latest_exchange_snippets(msgs)
|
||||
assert u == 'second q'
|
||||
assert a == 'second a'
|
||||
|
||||
def test_returns_latest_not_first(self):
|
||||
msgs = [_user_msg('old q'), _asst_msg('old a'),
|
||||
_user_msg('new q'), _asst_msg('new a')]
|
||||
u, a = _latest_exchange_snippets(msgs)
|
||||
assert u == 'new q'
|
||||
|
||||
def test_skips_tool_call_only_assistant(self):
|
||||
"""An assistant msg with tool_calls and no real text should be skipped."""
|
||||
msgs = [_user_msg('q'), _asst_msg('real answer'),
|
||||
_user_msg('q2'), _tool_only_asst()]
|
||||
u, a = _latest_exchange_snippets(msgs)
|
||||
# _tool_only_asst should be skipped; fall back to previous real assistant
|
||||
assert a == 'real answer'
|
||||
assert u == 'q2'
|
||||
|
||||
def test_truncates_long_content(self):
|
||||
long_text = 'x' * 600
|
||||
msgs = [_user_msg(long_text), _asst_msg(long_text)]
|
||||
u, a = _latest_exchange_snippets(msgs)
|
||||
assert len(u) == 500
|
||||
assert len(a) == 500
|
||||
|
||||
def test_no_assistant_message(self):
|
||||
msgs = [_user_msg('q')]
|
||||
u, a = _latest_exchange_snippets(msgs)
|
||||
assert u == 'q'
|
||||
assert a == ''
|
||||
|
||||
def test_no_user_message(self):
|
||||
msgs = [_asst_msg('a')]
|
||||
u, a = _latest_exchange_snippets(msgs)
|
||||
assert u == ''
|
||||
assert a == 'a'
|
||||
|
||||
def test_ignores_non_dict_entries(self):
|
||||
msgs = ['noise', _user_msg('q'), None, _asst_msg('a')]
|
||||
u, a = _latest_exchange_snippets(msgs)
|
||||
assert u == 'q'
|
||||
assert a == 'a'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _get_title_refresh_interval
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetTitleRefreshInterval:
|
||||
def test_returns_int_for_valid_setting(self):
|
||||
# _get_title_refresh_interval does a local import: `from api.config import load_settings`
|
||||
# so patch the source module, not api.streaming
|
||||
with patch('api.config.load_settings', return_value={'auto_title_refresh_every': '5'}):
|
||||
assert _get_title_refresh_interval() == 5
|
||||
|
||||
def test_returns_zero_for_off_setting(self):
|
||||
with patch('api.config.load_settings', return_value={'auto_title_refresh_every': '0'}):
|
||||
assert _get_title_refresh_interval() == 0
|
||||
|
||||
def test_returns_zero_when_key_absent(self):
|
||||
with patch('api.config.load_settings', return_value={}):
|
||||
assert _get_title_refresh_interval() == 0
|
||||
|
||||
def test_returns_zero_on_exception(self):
|
||||
with patch('api.config.load_settings', side_effect=Exception('boom')):
|
||||
assert _get_title_refresh_interval() == 0
|
||||
|
||||
def test_valid_values_10_and_20(self):
|
||||
for val in ('10', '20'):
|
||||
with patch('api.config.load_settings', return_value={'auto_title_refresh_every': val}):
|
||||
assert _get_title_refresh_interval() == int(val)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _run_background_title_refresh
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRunBackgroundTitleRefresh:
|
||||
def _make_put_event(self):
|
||||
events = []
|
||||
def put(name, data):
|
||||
events.append((name, data))
|
||||
return put, events
|
||||
|
||||
def _make_session_obj(self, title='Old Title'):
|
||||
s = MagicMock()
|
||||
s.title = title
|
||||
s.llm_title_generated = True
|
||||
s.save = MagicMock()
|
||||
return s
|
||||
|
||||
def test_skips_when_title_changed_before_call(self):
|
||||
"""If the title has changed (manual rename) since the refresh was scheduled, skip."""
|
||||
put, events = self._make_put_event()
|
||||
with patch('api.streaming.get_session') as mock_get, \
|
||||
patch('api.streaming.SESSIONS', {}), \
|
||||
patch('api.streaming.LOCK', threading.Lock()):
|
||||
s = self._make_session_obj(title='Different Title')
|
||||
mock_get.return_value = s
|
||||
_run_background_title_refresh(
|
||||
'sid', 'user', 'asst', 'Old Title', put, agent=None
|
||||
)
|
||||
# No 'title' event should have been emitted
|
||||
assert not any(name == 'title' for name, _ in events)
|
||||
|
||||
def test_skips_if_session_not_found(self):
|
||||
put, events = self._make_put_event()
|
||||
with patch('api.streaming.get_session', side_effect=KeyError('not found')):
|
||||
_run_background_title_refresh('sid', 'u', 'a', 'title', put)
|
||||
assert events == []
|
||||
|
||||
def test_skips_when_title_is_untitled(self):
|
||||
put, events = self._make_put_event()
|
||||
with patch('api.streaming.get_session') as mock_get:
|
||||
s = self._make_session_obj(title='Untitled')
|
||||
mock_get.return_value = s
|
||||
_run_background_title_refresh('sid', 'u', 'a', 'Untitled', put)
|
||||
assert not any(name == 'title' for name, _ in events)
|
||||
|
||||
def test_skips_same_title(self):
|
||||
"""If the LLM generates a title identical to the current one, no event is emitted."""
|
||||
put, events = self._make_put_event()
|
||||
with patch('api.streaming.get_session') as mock_get, \
|
||||
patch('api.streaming._aux_title_configured', return_value=True), \
|
||||
patch('api.streaming._generate_llm_session_title_via_aux',
|
||||
return_value=('Old Title', 'llm_ok', 'raw')), \
|
||||
patch('api.streaming.SESSIONS', {}), \
|
||||
patch('api.streaming.LOCK', threading.Lock()):
|
||||
s = self._make_session_obj(title='Old Title')
|
||||
mock_get.return_value = s
|
||||
_run_background_title_refresh('sid', 'u', 'a', 'Old Title', put)
|
||||
assert not any(name == 'title' for name, _ in events)
|
||||
|
||||
def test_emits_title_event_on_new_title(self):
|
||||
put, events = self._make_put_event()
|
||||
s = self._make_session_obj(title='Old Title')
|
||||
# Use a real dict for SESSIONS so .get() works, pre-populated with our session
|
||||
fake_sessions = {'sid': s}
|
||||
with patch('api.streaming.get_session', return_value=s), \
|
||||
patch('api.streaming._aux_title_configured', return_value=True), \
|
||||
patch('api.streaming._generate_llm_session_title_via_aux',
|
||||
return_value=('New Refreshed Title', 'llm_ok', 'raw')), \
|
||||
patch('api.streaming.SESSIONS', fake_sessions), \
|
||||
patch('api.streaming.LOCK', threading.Lock()):
|
||||
_run_background_title_refresh('sid', 'u', 'a', 'Old Title', put)
|
||||
title_events = [(n, d) for n, d in events if n == 'title']
|
||||
assert len(title_events) == 1
|
||||
assert title_events[0][1]['title'] == 'New Refreshed Title'
|
||||
|
||||
def test_exceptions_are_silently_swallowed(self):
|
||||
"""Any unexpected error inside must not propagate — it's a background daemon."""
|
||||
put, events = self._make_put_event()
|
||||
with patch('api.streaming.get_session', side_effect=RuntimeError('oops')):
|
||||
# Should not raise
|
||||
_run_background_title_refresh('sid', 'u', 'a', 'title', put)
|
||||
assert events == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _maybe_schedule_title_refresh
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMaybeScheduleTitleRefresh:
|
||||
def _noop_put(self, name, data):
|
||||
pass
|
||||
|
||||
def test_does_nothing_when_disabled(self):
|
||||
with patch('api.streaming._get_title_refresh_interval', return_value=0):
|
||||
spawned = []
|
||||
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
|
||||
session = _make_session(messages=[_user_msg('q'), _asst_msg('a')] * 5)
|
||||
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
||||
assert spawned == []
|
||||
|
||||
def test_does_nothing_when_title_is_empty(self):
|
||||
with patch('api.streaming._get_title_refresh_interval', return_value=5):
|
||||
spawned = []
|
||||
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
|
||||
session = _make_session(title='', messages=[_user_msg('q'), _asst_msg('a')] * 5)
|
||||
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
||||
assert spawned == []
|
||||
|
||||
def test_does_nothing_for_untitled(self):
|
||||
with patch('api.streaming._get_title_refresh_interval', return_value=5):
|
||||
spawned = []
|
||||
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
|
||||
session = _make_session(title='Untitled', messages=[_user_msg('q'), _asst_msg('a')] * 5)
|
||||
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
||||
assert spawned == []
|
||||
|
||||
def test_does_nothing_when_title_not_llm_generated(self):
|
||||
with patch('api.streaming._get_title_refresh_interval', return_value=5):
|
||||
spawned = []
|
||||
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
|
||||
session = _make_session(llm_title_generated=False,
|
||||
messages=[_user_msg('q'), _asst_msg('a')] * 5)
|
||||
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
||||
assert spawned == []
|
||||
|
||||
def test_does_nothing_when_exchange_count_not_at_interval(self):
|
||||
"""Refresh only fires when exchange_count % interval == 0 (and > 0)."""
|
||||
with patch('api.streaming._get_title_refresh_interval', return_value=5):
|
||||
spawned = []
|
||||
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
|
||||
# 4 exchanges — not a multiple of 5
|
||||
session = _make_session(messages=[_user_msg('q'), _asst_msg('a')] * 4)
|
||||
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
||||
assert spawned == []
|
||||
|
||||
def test_spawns_thread_at_exact_interval(self):
|
||||
"""Refresh fires when exchange_count == refresh_interval."""
|
||||
with patch('api.streaming._get_title_refresh_interval', return_value=5):
|
||||
spawned = []
|
||||
with patch('threading.Thread') as mock_thread_cls:
|
||||
mock_thread = MagicMock()
|
||||
mock_thread_cls.return_value = mock_thread
|
||||
# 5 user messages = 5 exchanges
|
||||
session = _make_session(messages=[_user_msg('q'), _asst_msg('a')] * 5)
|
||||
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
||||
assert mock_thread_cls.called
|
||||
assert mock_thread.start.called
|
||||
|
||||
def test_spawns_thread_at_multiple_of_interval(self):
|
||||
"""Refresh fires at 10 exchanges when interval is 5."""
|
||||
with patch('api.streaming._get_title_refresh_interval', return_value=5):
|
||||
with patch('threading.Thread') as mock_thread_cls:
|
||||
mock_thread = MagicMock()
|
||||
mock_thread_cls.return_value = mock_thread
|
||||
# 10 exchanges
|
||||
session = _make_session(messages=[_user_msg('q'), _asst_msg('a')] * 10)
|
||||
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
||||
assert mock_thread_cls.called
|
||||
|
||||
def test_does_nothing_when_no_exchange_content(self):
|
||||
"""Even at interval, if both snippets are empty, don't spawn."""
|
||||
with patch('api.streaming._get_title_refresh_interval', return_value=5), \
|
||||
patch('api.streaming._latest_exchange_snippets', return_value=('', '')):
|
||||
spawned = []
|
||||
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
|
||||
session = _make_session(messages=[_user_msg('q'), _asst_msg('a')] * 5)
|
||||
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
||||
assert spawned == []
|
||||
84
tests/test_1059_settings_picker_active_state.py
Normal file
84
tests/test_1059_settings_picker_active_state.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Regression tests for settings picker active-state highlighting.
|
||||
|
||||
The theme, skin, and font-size pickers in the Appearance settings tab must show
|
||||
the currently-selected option with a visible accent border. This was broken because
|
||||
the CSS rule used !important on border-color:var(--border) which overrode the inline
|
||||
style that _syncThemePicker/etc. set. Fixed by moving to .active CSS class + !important
|
||||
override on the active state.
|
||||
|
||||
Issue: #1059 (settings picker active state)
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
BOOT_JS = (Path(__file__).parent.parent / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (Path(__file__).parent.parent / "static" / "style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestSettingsPickerActiveState:
|
||||
"""The selected picker card must be visually distinct via the .active class."""
|
||||
|
||||
def test_theme_picker_uses_active_class(self):
|
||||
"""_syncThemePicker must toggle .active class, not set inline borderColor."""
|
||||
idx = BOOT_JS.find("function _syncThemePicker(")
|
||||
assert idx >= 0, "_syncThemePicker function not found in boot.js"
|
||||
body = BOOT_JS[idx:idx + 300]
|
||||
assert "classList.toggle" in body, (
|
||||
"_syncThemePicker must use classList.toggle('active', ...) — "
|
||||
"inline style.borderColor is overridden by !important CSS rules"
|
||||
)
|
||||
# Confirm no accent/border2 color values set inline (clearing with '' is OK)
|
||||
assert "var(--accent)" not in body and "var(--border2)" not in body, (
|
||||
"_syncThemePicker must not set var(--accent) or var(--border2) inline — "
|
||||
"those are overridden by !important CSS rules"
|
||||
)
|
||||
|
||||
def test_font_size_picker_uses_active_class(self):
|
||||
"""_syncFontSizePicker must toggle .active class."""
|
||||
idx = BOOT_JS.find("function _syncFontSizePicker(")
|
||||
assert idx >= 0, "_syncFontSizePicker function not found in boot.js"
|
||||
body = BOOT_JS[idx:idx + 300]
|
||||
assert "classList.toggle" in body, (
|
||||
"_syncFontSizePicker must use classList.toggle('active', ...)"
|
||||
)
|
||||
assert "var(--accent)" not in body and "var(--border2)" not in body, (
|
||||
"_syncFontSizePicker must not set var(--accent) or var(--border2) inline"
|
||||
)
|
||||
|
||||
def test_skin_picker_uses_active_class(self):
|
||||
"""_syncSkinPicker must toggle .active class."""
|
||||
idx = BOOT_JS.find("function _syncSkinPicker(")
|
||||
assert idx >= 0, "_syncSkinPicker function not found in boot.js"
|
||||
body = BOOT_JS[idx:idx + 300]
|
||||
assert "classList.toggle" in body, (
|
||||
"_syncSkinPicker must use classList.toggle('active', ...)"
|
||||
)
|
||||
assert "var(--accent)" not in body and "var(--border2)" not in body, (
|
||||
"_syncSkinPicker must not set var(--accent) or var(--border2) inline"
|
||||
)
|
||||
|
||||
def test_css_active_rule_beats_base_rule(self):
|
||||
"""CSS must have a .active rule with !important that overrides the base border-color rule."""
|
||||
assert ".theme-pick-btn.active" in STYLE_CSS, (
|
||||
"style.css must have a .theme-pick-btn.active rule"
|
||||
)
|
||||
assert ".font-size-pick-btn.active" in STYLE_CSS, (
|
||||
"style.css must have a .font-size-pick-btn.active rule"
|
||||
)
|
||||
assert ".skin-pick-btn.active" in STYLE_CSS, (
|
||||
"style.css must have a .skin-pick-btn.active rule"
|
||||
)
|
||||
# The active rule must use !important to beat the base !important rule
|
||||
idx = STYLE_CSS.find(".theme-pick-btn.active")
|
||||
rule = STYLE_CSS[idx:idx + 200]
|
||||
assert "!important" in rule, (
|
||||
".theme-pick-btn.active must use !important to override "
|
||||
"the base border-color:var(--border)!important rule"
|
||||
)
|
||||
|
||||
def test_active_rule_uses_accent_color(self):
|
||||
"""The .active rule must apply the accent color to make selection visible."""
|
||||
idx = STYLE_CSS.find(".theme-pick-btn.active")
|
||||
rule = STYLE_CSS[idx:idx + 200]
|
||||
assert "var(--accent)" in rule, (
|
||||
".theme-pick-btn.active must set border-color to var(--accent)"
|
||||
)
|
||||
375
tests/test_1062_busy_input_modes.py
Normal file
375
tests/test_1062_busy_input_modes.py
Normal file
@@ -0,0 +1,375 @@
|
||||
"""Regression tests for busy_input_mode (PR #1062, closes #720).
|
||||
|
||||
Pins the wiring for the three modes (queue / interrupt / steer):
|
||||
- The setting key + default + enum validation in api/config.py
|
||||
- Three slash commands registered in static/commands.js
|
||||
- send()'s busy branch reads window._busyInputMode and dispatches
|
||||
- Boot initializes window._busyInputMode from settings
|
||||
- 17 new i18n keys present in all 6 locale blocks
|
||||
|
||||
Issue: #720 (configurable busy-input behaviour)
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent.parent
|
||||
CONFIG_PY = (ROOT / "api" / "config.py").read_text(encoding="utf-8")
|
||||
COMMANDS_JS = (ROOT / "static" / "commands.js").read_text(encoding="utf-8")
|
||||
MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
|
||||
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ── Backend: setting registration + enum validation ─────────────────────
|
||||
|
||||
class TestBusyInputModeSetting:
|
||||
"""The new setting key must be registered with a default and enum validator."""
|
||||
|
||||
def test_default_is_queue(self):
|
||||
"""Default value preserves existing queue behaviour for users who don't touch the setting."""
|
||||
assert '"busy_input_mode": "queue"' in CONFIG_PY, (
|
||||
"_DEFAULT_SETTINGS must include busy_input_mode='queue' so existing users see no change"
|
||||
)
|
||||
|
||||
def test_enum_validator_present(self):
|
||||
"""_SETTINGS_ENUM_KEYS must validate busy_input_mode against {queue, interrupt, steer}."""
|
||||
# Find the entry inside the enum dict (a set literal as the value)
|
||||
idx = CONFIG_PY.find('"busy_input_mode": {')
|
||||
assert idx >= 0, "busy_input_mode entry missing from _SETTINGS_ENUM_KEYS"
|
||||
block = CONFIG_PY[idx:idx + 200]
|
||||
assert '"queue"' in block and '"interrupt"' in block and '"steer"' in block, (
|
||||
"busy_input_mode enum must contain {queue, interrupt, steer}"
|
||||
)
|
||||
|
||||
|
||||
# ── Frontend: slash commands ─────────────────────────────────────────────
|
||||
|
||||
class TestSlashCommandRegistration:
|
||||
"""The three new slash commands must be registered in COMMANDS array."""
|
||||
|
||||
def test_queue_command_registered(self):
|
||||
assert "name:'queue'" in COMMANDS_JS and "fn:cmdQueue" in COMMANDS_JS
|
||||
|
||||
def test_interrupt_command_registered(self):
|
||||
assert "name:'interrupt'" in COMMANDS_JS and "fn:cmdInterrupt" in COMMANDS_JS
|
||||
|
||||
def test_steer_command_registered(self):
|
||||
assert "name:'steer'" in COMMANDS_JS and "fn:cmdSteer" in COMMANDS_JS
|
||||
|
||||
def test_all_three_busy_commands_are_no_echo(self):
|
||||
"""All three busy commands must set noEcho:true so the slash invocation
|
||||
is not echoed as a visible user bubble. Without noEcho, /queue causes a
|
||||
double-bubble: the raw slash text appears, then the queued message appears
|
||||
again when the drain fires.
|
||||
"""
|
||||
for name in ("queue", "interrupt", "steer"):
|
||||
idx = COMMANDS_JS.find(f"name:'{name}'")
|
||||
assert idx >= 0, f"{name} not registered"
|
||||
block = COMMANDS_JS[idx:idx + 250]
|
||||
assert "noEcho:true" in block, (
|
||||
f"/{name} registration must set noEcho:true — "
|
||||
"without it the command text is echoed as a user bubble, causing duplicates"
|
||||
)
|
||||
|
||||
|
||||
class TestSlashCommandHandlers:
|
||||
"""The three handler functions must guard properly and call cancelStream where appropriate."""
|
||||
|
||||
def test_cmd_queue_handles_idle_state(self):
|
||||
"""/queue when idle now sends the message normally instead of showing an
|
||||
error toast. The if(!S.busy) guard must still exist — it routes to the
|
||||
idle-send path rather than the queue path."""
|
||||
idx = COMMANDS_JS.find("async function cmdQueue(")
|
||||
assert idx >= 0
|
||||
body = COMMANDS_JS[idx:idx + 600]
|
||||
assert "if(!S.busy)" in body, "/queue must have an if(!S.busy) guard that routes to send()"
|
||||
|
||||
def test_cmd_interrupt_calls_cancel_stream(self):
|
||||
idx = COMMANDS_JS.find("async function cmdInterrupt(")
|
||||
assert idx >= 0
|
||||
body = COMMANDS_JS[idx:idx + 1300] # expanded: idle-fallback block added before the busy path
|
||||
assert "queueSessionMessage" in body, "/interrupt must queue the new message before cancelling"
|
||||
assert "cancelStream" in body, "/interrupt must call cancelStream() so the drain re-sends"
|
||||
|
||||
def test_cmd_steer_delegates_to_try_steer(self):
|
||||
"""/steer delegates to _trySteer which calls /api/chat/steer with
|
||||
a queue+cancel fallback. The fallback path is exercised by tests
|
||||
in test_real_steer.py — this test just pins the delegation."""
|
||||
idx = COMMANDS_JS.find("async function cmdSteer(")
|
||||
assert idx >= 0
|
||||
body = COMMANDS_JS[idx:idx + 800]
|
||||
# cmdSteer now delegates to _trySteer; the fallback (queueSessionMessage
|
||||
# + cancelStream) lives inside _trySteer.
|
||||
assert "_trySteer" in body, "cmdSteer must call _trySteer to use the real /api/chat/steer endpoint"
|
||||
# The shared helper must contain the fallback path
|
||||
helper_idx = COMMANDS_JS.find("async function _trySteer(")
|
||||
assert helper_idx >= 0, "_trySteer helper must exist"
|
||||
helper_body = COMMANDS_JS[helper_idx:helper_idx + 1500]
|
||||
assert "queueSessionMessage" in helper_body
|
||||
assert "cancelStream" in helper_body
|
||||
# Toast should differ from interrupt to signal it's the steer path
|
||||
assert "cmd_steer_fallback" in helper_body or "steer_fallback" in helper_body
|
||||
|
||||
|
||||
# ── send() busy branch ───────────────────────────────────────────────────
|
||||
|
||||
def test_slash_commands_clear_pending_files(self):
|
||||
"""All three busy command handlers must clear S.pendingFiles (directly
|
||||
or via _trySteer) after enqueuing, so staged files are not duplicated.
|
||||
|
||||
cmdQueue and cmdInterrupt call queueSessionMessage themselves and clear
|
||||
S.pendingFiles directly. cmdSteer delegates to _trySteer. The fallback/interrupt path clears
|
||||
S.pendingFiles inside _trySteer; the success path returns early and
|
||||
send() handles the post-await clear. Either way files are not
|
||||
duplicated — we verify by checking _trySteer body for the clearing.
|
||||
"""
|
||||
# cmdQueue and cmdInterrupt clear pendingFiles directly
|
||||
for fn_name in ("cmdQueue", "cmdInterrupt"):
|
||||
idx = COMMANDS_JS.find(f"function {fn_name}(")
|
||||
assert idx >= 0, f"{fn_name} not found"
|
||||
body = COMMANDS_JS[idx:idx + 800]
|
||||
assert "S.pendingFiles=[]" in body, (
|
||||
f"{fn_name} must clear S.pendingFiles after queueSessionMessage"
|
||||
)
|
||||
assert "renderTray()" in body, (
|
||||
f"{fn_name} must call renderTray() after clearing pendingFiles"
|
||||
)
|
||||
# cmdSteer delegates to _trySteer; that helper clears pendingFiles
|
||||
idx_try = COMMANDS_JS.find("function _trySteer(")
|
||||
assert idx_try >= 0, "_trySteer not found"
|
||||
try_body = COMMANDS_JS[idx_try:idx_try + 1200]
|
||||
assert "S.pendingFiles=[]" in try_body, (
|
||||
"_trySteer must clear S.pendingFiles in its fallback path — "
|
||||
"without this, files are lost on steer→interrupt fallback"
|
||||
)
|
||||
assert "renderTray()" in try_body, (
|
||||
"_trySteer must call renderTray() after clearing pendingFiles"
|
||||
)
|
||||
|
||||
|
||||
class TestBusySendButton:
|
||||
"""The composer send button must remain usable for busy-input actions."""
|
||||
|
||||
def test_update_send_btn_uses_single_primary_action_button(self):
|
||||
idx = UI_JS.find("function updateSendBtn()")
|
||||
assert idx >= 0, "updateSendBtn() not found"
|
||||
body = UI_JS[idx:UI_JS.find("function setBusy", idx)]
|
||||
assert "getComposerPrimaryAction()" in body, (
|
||||
"updateSendBtn must derive icon/color/enabled state from one composer-primary action helper"
|
||||
)
|
||||
assert "btn.dataset.action=action" in body, (
|
||||
"btnSend should expose its current action for CSS, tests, and accessibility"
|
||||
)
|
||||
assert "btn.classList.toggle('stop',action==='stop')" in body, (
|
||||
"busy/no-draft state should turn the single primary button into the red stop action"
|
||||
)
|
||||
assert "btn.style.display=''" in body, (
|
||||
"the single primary button should remain visible while busy; it becomes Stop when there is no draft"
|
||||
)
|
||||
|
||||
def test_composer_primary_action_accounts_for_all_busy_input_modes(self):
|
||||
idx = UI_JS.find("function getComposerPrimaryAction()")
|
||||
assert idx >= 0, "getComposerPrimaryAction() not found"
|
||||
body = UI_JS[idx:UI_JS.find("function _setComposerPrimaryButtonIcon", idx)]
|
||||
assert "return 'stop'" in body, "busy/no-draft + active stream must map to stop"
|
||||
assert "return 'queue'" in body, "queue mode and unavailable steer/interrupt fallbacks must map to queue"
|
||||
assert "return 'interrupt'" in body, "interrupt mode with an active stream must map to interrupt"
|
||||
assert "return 'steer'" in body, "steer mode with active stream support must map to steer"
|
||||
assert "window._busyInputMode||'queue'" in body, "helper must respect the Busy input mode setting"
|
||||
assert "_getExplicitBusyCommandAction(msg&&msg.value)" in body, (
|
||||
"explicit /queue, /interrupt, and /steer drafts must override the Busy input mode for button visuals"
|
||||
)
|
||||
|
||||
def test_explicit_busy_commands_override_button_visual_action(self):
|
||||
idx = UI_JS.find("function _getExplicitBusyCommandAction(")
|
||||
assert idx >= 0, "_getExplicitBusyCommandAction() not found"
|
||||
body = UI_JS[idx:UI_JS.find("function getComposerPrimaryAction", idx)]
|
||||
assert "name==='queue'" in body and "return 'queue'" in body, (
|
||||
"typing /queue <message> should show the queue/list-end button even in another busy mode"
|
||||
)
|
||||
assert "name==='steer'" in body and "return 'steer'" in body, (
|
||||
"typing /steer <message> should show the steer/compass button even when the global mode is queue"
|
||||
)
|
||||
assert "name==='interrupt'" in body and "return 'interrupt'" in body, (
|
||||
"typing /interrupt <message> should show the interrupt/skip-forward button even in another busy mode"
|
||||
)
|
||||
assert "if(!args) return null" in body, (
|
||||
"partial slash commands without a payload should not override the primary button while the user is still typing"
|
||||
)
|
||||
|
||||
def test_send_button_click_uses_primary_action_handler(self):
|
||||
assert "function handleComposerPrimaryAction()" in UI_JS, (
|
||||
"btnSend click should route through a primary action handler so Stop can cancel instead of sending"
|
||||
)
|
||||
assert "handleComposerPrimaryAction" in BOOT_JS, (
|
||||
"boot.js should wire btnSend to handleComposerPrimaryAction(), not directly to send()"
|
||||
)
|
||||
|
||||
|
||||
class TestSendBusyBranchDispatch:
|
||||
"""send()'s busy block must read window._busyInputMode and branch accordingly."""
|
||||
|
||||
def test_send_reads_busy_input_mode(self):
|
||||
# The send() function should read window._busyInputMode in the busy block
|
||||
send_idx = MESSAGES_JS.find("async function send(")
|
||||
assert send_idx >= 0
|
||||
# Look in the first ~3000 chars of send() for the busy mode read
|
||||
send_body = MESSAGES_JS[send_idx:send_idx + 3000]
|
||||
assert "_busyInputMode" in send_body, (
|
||||
"send() must read window._busyInputMode in the S.busy branch"
|
||||
)
|
||||
|
||||
def test_send_calls_cancel_stream_on_interrupt(self):
|
||||
send_idx = MESSAGES_JS.find("async function send(")
|
||||
send_body = MESSAGES_JS[send_idx:send_idx + 3000]
|
||||
# The interrupt branch must call cancelStream
|
||||
assert "cancelStream" in send_body
|
||||
# And queue before cancel (otherwise the drain has nothing to pick up)
|
||||
# Verify the order textually: queueSessionMessage appears before cancelStream
|
||||
# within the busy block's interrupt branch
|
||||
cancel_idx = send_body.find("cancelStream")
|
||||
queue_idx = send_body.find("queueSessionMessage")
|
||||
assert queue_idx >= 0 and cancel_idx >= 0
|
||||
assert queue_idx < cancel_idx, (
|
||||
"queueSessionMessage must run before cancelStream so the drain "
|
||||
"after setBusy(false) picks up the queued message"
|
||||
)
|
||||
|
||||
|
||||
def test_slash_commands_intercepted_before_busymode_routing(self):
|
||||
"""The three busy-control slash commands (/steer /interrupt /queue) must be
|
||||
intercepted at the TOP of the busy block — before the busyMode routing — so
|
||||
they execute immediately while the agent is running.
|
||||
|
||||
Without this intercept, typing /steer while busy queues the text as a plain
|
||||
message. When it drains after the turn ends there is no active stream, so
|
||||
cmdSteer says "No active task to stop." and the steer is lost entirely.
|
||||
"""
|
||||
send_idx = MESSAGES_JS.find("async function send(")
|
||||
assert send_idx >= 0, "send() not found"
|
||||
# Look in the first 500 chars of the busy block for the intercept
|
||||
busy_start = MESSAGES_JS.find("S.busy||compressionRunning", send_idx)
|
||||
assert busy_start >= 0, "busy block not found"
|
||||
# The intercept must appear BEFORE the busyMode assignment
|
||||
intercept_idx = MESSAGES_JS.find("'steer','interrupt','queue'", busy_start)
|
||||
busymode_idx = MESSAGES_JS.find("_busyInputMode||'queue'", busy_start)
|
||||
assert intercept_idx >= 0, (
|
||||
"send() must intercept /steer /interrupt /queue before the busyMode "
|
||||
"routing block — otherwise they queue instead of executing immediately"
|
||||
)
|
||||
assert intercept_idx < busymode_idx, (
|
||||
"The slash-command intercept must come BEFORE the busyMode routing "
|
||||
"so /steer executes while the agent is running, not after the turn ends"
|
||||
)
|
||||
|
||||
def test_steer_intercept_calls_handler_directly(self):
|
||||
"""The busy-intercept must dispatch via _bc.fn(_pc.args), not queue the text."""
|
||||
send_idx = MESSAGES_JS.find("async function send(")
|
||||
busy_start = MESSAGES_JS.find("S.busy||compressionRunning", send_idx)
|
||||
intercept_idx = MESSAGES_JS.find("'steer','interrupt','queue'", busy_start)
|
||||
assert intercept_idx >= 0
|
||||
# Get the intercept block (up to the next busyMode assignment)
|
||||
busymode_idx = MESSAGES_JS.find("_busyInputMode||'queue'", busy_start)
|
||||
intercept_block = MESSAGES_JS[intercept_idx:busymode_idx]
|
||||
assert "_bc.fn(_pc.args)" in intercept_block, (
|
||||
"The intercept must call the command handler directly via _bc.fn(_pc.args)"
|
||||
)
|
||||
assert "return;" in intercept_block, (
|
||||
"The intercept must return after dispatching so send() does not also queue"
|
||||
)
|
||||
|
||||
def test_steer_intercept_clears_input_before_await(self):
|
||||
"""The intercept must clear $('msg').value BEFORE awaiting the handler.
|
||||
|
||||
Without the sync clear, the input field still shows '/steer foo' after
|
||||
the steer fires. If the user presses Enter again (a common reflex while
|
||||
waiting for the toast), send() re-runs and either re-fires the command
|
||||
or — once the turn ended — drops a confusing 'No active task to stop.'
|
||||
"""
|
||||
send_idx = MESSAGES_JS.find("async function send(")
|
||||
busy_start = MESSAGES_JS.find("S.busy||compressionRunning", send_idx)
|
||||
intercept_idx = MESSAGES_JS.find("'steer','interrupt','queue'", busy_start)
|
||||
busymode_idx = MESSAGES_JS.find("_busyInputMode||'queue'", busy_start)
|
||||
intercept_block = MESSAGES_JS[intercept_idx:busymode_idx]
|
||||
clear_idx = intercept_block.find("$('msg').value=''")
|
||||
await_idx = intercept_block.find("await _bc.fn")
|
||||
assert clear_idx >= 0, (
|
||||
"The intercept must clear $('msg').value (so the field doesn't keep "
|
||||
"showing /steer foo after the command fires)"
|
||||
)
|
||||
assert await_idx >= 0, "await _bc.fn(...) must be present in the intercept"
|
||||
assert clear_idx < await_idx, (
|
||||
"$('msg').value='' must be cleared BEFORE awaiting the handler — "
|
||||
"otherwise a reflexive Enter press during the await re-fires the command"
|
||||
)
|
||||
|
||||
|
||||
# ── Boot init + settings panel wiring ───────────────────────────────────
|
||||
|
||||
class TestBootAndPanelsWiring:
|
||||
def test_boot_init_default_path(self):
|
||||
"""Boot success path initialises window._busyInputMode from settings."""
|
||||
assert "window._busyInputMode=(s.busy_input_mode||'queue')" in BOOT_JS
|
||||
|
||||
def test_boot_init_fallback_path(self):
|
||||
"""Boot fallback path (settings load failed) initialises to safe default."""
|
||||
# The fallback should set window._busyInputMode='queue'
|
||||
assert "window._busyInputMode='queue'" in BOOT_JS
|
||||
|
||||
def test_panels_load_save_apply(self):
|
||||
assert "settingsBusyInputMode" in PANELS_JS, "panels.js must load the setting"
|
||||
assert "body.busy_input_mode" in PANELS_JS, "saveSettings must include busy_input_mode in body"
|
||||
assert "window._busyInputMode=body.busy_input_mode" in PANELS_JS, (
|
||||
"_applySavedSettingsUi must propagate busy_input_mode to the global"
|
||||
)
|
||||
|
||||
def test_index_html_dropdown_has_three_options(self):
|
||||
idx = INDEX_HTML.find('id="settingsBusyInputMode"')
|
||||
assert idx >= 0
|
||||
block = INDEX_HTML[idx:idx + 800]
|
||||
assert 'value="queue"' in block
|
||||
assert 'value="interrupt"' in block
|
||||
assert 'value="steer"' in block
|
||||
|
||||
|
||||
# ── i18n locale coverage ─────────────────────────────────────────────────
|
||||
|
||||
class TestI18nKeys:
|
||||
"""All 17 new keys must appear in each of the 6 locale blocks."""
|
||||
|
||||
REQUIRED_KEYS = [
|
||||
"cmd_queue",
|
||||
"cmd_interrupt",
|
||||
"cmd_steer",
|
||||
"cmd_queue_no_msg",
|
||||
"cmd_queue_not_busy",
|
||||
"cmd_queue_confirm",
|
||||
"cmd_interrupt_no_msg",
|
||||
"cmd_interrupt_confirm",
|
||||
"cmd_steer_no_msg",
|
||||
"cmd_steer_fallback",
|
||||
"busy_steer_fallback",
|
||||
"busy_interrupt_confirm",
|
||||
"settings_label_busy_input_mode",
|
||||
"settings_desc_busy_input_mode",
|
||||
"settings_busy_input_mode_queue",
|
||||
"settings_busy_input_mode_interrupt",
|
||||
"settings_busy_input_mode_steer",
|
||||
]
|
||||
|
||||
def test_each_key_appears_at_least_six_times(self):
|
||||
"""Each key should appear once per locale (en, ru, es, de, zh, zh-Hant) = 6 occurrences minimum."""
|
||||
for key in self.REQUIRED_KEYS:
|
||||
count = I18N_JS.count(f"{key}:")
|
||||
assert count >= 6, (
|
||||
f"i18n key {key!r} appears {count} times; expected ≥6 (one per locale block)"
|
||||
)
|
||||
|
||||
def test_key_count_total(self):
|
||||
"""17 keys × 6 locales = 102 minimum occurrences across the file."""
|
||||
total = sum(I18N_JS.count(f"{key}:") for key in self.REQUIRED_KEYS)
|
||||
assert total >= 17 * 6, (
|
||||
f"Total i18n occurrences = {total}; expected ≥ {17*6}"
|
||||
)
|
||||
89
tests/test_1079_cron_session_project.py
Normal file
89
tests/test_1079_cron_session_project.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Tests for #1079: Auto-assign cron job sessions to dedicated 'Cron Jobs' project."""
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import TEST_STATE_DIR, _post, TEST_BASE
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("test_server")
|
||||
|
||||
|
||||
def _get_projects(base_url):
|
||||
"""Fetch the project list from the API."""
|
||||
import urllib.request
|
||||
with urllib.request.urlopen(base_url + "/api/projects", timeout=5) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
def test_ensure_cron_project_creates_project():
|
||||
"""ensure_cron_project() should create a 'Cron Jobs' project if none exists."""
|
||||
from api.models import ensure_cron_project, load_projects, save_projects
|
||||
|
||||
# Remove any existing Cron Jobs project to test creation
|
||||
projects = load_projects()
|
||||
original = [p for p in projects if p.get('name') != 'Cron Jobs']
|
||||
save_projects(original)
|
||||
|
||||
pid = ensure_cron_project()
|
||||
|
||||
# Should now exist
|
||||
projects = load_projects()
|
||||
cron_projects = [p for p in projects if p.get('name') == 'Cron Jobs']
|
||||
assert len(cron_projects) == 1
|
||||
assert cron_projects[0]['project_id'] == pid
|
||||
assert cron_projects[0]['color'] == '#6366f1'
|
||||
assert len(pid) == 12
|
||||
|
||||
# Restore
|
||||
save_projects(projects)
|
||||
|
||||
|
||||
def test_ensure_cron_project_idempotent():
|
||||
"""Calling ensure_cron_project() twice should return the same ID."""
|
||||
from api.models import ensure_cron_project, load_projects, save_projects
|
||||
|
||||
projects = load_projects()
|
||||
save_projects([p for p in projects if p.get('name') != 'Cron Jobs'])
|
||||
|
||||
pid1 = ensure_cron_project()
|
||||
pid2 = ensure_cron_project()
|
||||
assert pid1 == pid2
|
||||
|
||||
|
||||
def test_is_cron_session():
|
||||
"""is_cron_session should detect cron sessions by source_tag or ID prefix."""
|
||||
from api.models import is_cron_session
|
||||
|
||||
# By source_tag
|
||||
assert is_cron_session("any_id", source_tag="cron") is True
|
||||
assert is_cron_session("any_id", source_tag="cli") is False
|
||||
|
||||
# By session ID prefix
|
||||
assert is_cron_session("cron_abc123") is True
|
||||
assert is_cron_session("cron_") is True
|
||||
assert is_cron_session("regular_session") is False
|
||||
assert is_cron_session(None) is False
|
||||
assert is_cron_session("") is False
|
||||
|
||||
|
||||
def test_cron_jobs_project_i18n_key_exists():
|
||||
"""All 8 locales must have the cron_jobs_project i18n key."""
|
||||
i18n_path = pathlib.Path(__file__).resolve().parent.parent / "static" / "i18n.js"
|
||||
content = i18n_path.read_text(encoding="utf-8")
|
||||
|
||||
# Count occurrences of cron_jobs_project
|
||||
count = content.count("cron_jobs_project:")
|
||||
assert count == 8, f"Expected 8 locale entries for cron_jobs_project, found {count}"
|
||||
|
||||
|
||||
def test_cron_session_gets_project_id_in_cli_list():
|
||||
"""get_cli_sessions() should assign project_id for cron sessions."""
|
||||
from api.models import get_cli_sessions
|
||||
# Just verify the function is callable and returns a list
|
||||
# The actual project assignment is tested indirectly via integration
|
||||
sessions = get_cli_sessions()
|
||||
assert isinstance(sessions, list)
|
||||
123
tests/test_1325_user_fenced_code.py
Normal file
123
tests/test_1325_user_fenced_code.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Tests for issue #1325 — fenced code blocks in user message bubbles."""
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
UI_JS = os.path.join(os.path.dirname(__file__), '..', 'static', 'ui.js')
|
||||
|
||||
|
||||
def _extract_js_functions():
|
||||
"""Extract esc and _renderUserFencedBlocks from ui.js by line numbers."""
|
||||
lines = open(UI_JS).read().split('\n')
|
||||
# esc is on line 52 (0-indexed: 51)
|
||||
esc_def = lines[51]
|
||||
# _renderUserFencedBlocks starts at line 61 (0-indexed: 60)
|
||||
# Find the end by matching closing brace at column 0
|
||||
fn_lines = []
|
||||
i = 60 # 0-indexed
|
||||
depth = 0
|
||||
while i < len(lines):
|
||||
fn_lines.append(lines[i])
|
||||
depth += lines[i].count('{') - lines[i].count('}')
|
||||
if depth <= 0:
|
||||
break
|
||||
i += 1
|
||||
fn_def = '\n'.join(fn_lines)
|
||||
return esc_def, fn_def
|
||||
|
||||
|
||||
def _run_user_render(text_input):
|
||||
"""Return the HTML output of _renderUserFencedBlocks for the given input text."""
|
||||
import json
|
||||
esc_def, fn_def = _extract_js_functions()
|
||||
js_code = esc_def + '\n' + fn_def + '\n'
|
||||
js_code += 'var input = JSON.parse(process.argv[2]);\n'
|
||||
js_code += 'process.stdout.write(_renderUserFencedBlocks(input));\n'
|
||||
tf = tempfile.NamedTemporaryFile(mode='w', suffix='.js', delete=False, encoding='utf-8')
|
||||
tf.write(js_code)
|
||||
tf.close()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['node', tf.name, json.dumps(text_input)],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"node error: {result.stderr}")
|
||||
return result.stdout
|
||||
finally:
|
||||
os.unlink(tf.name)
|
||||
|
||||
|
||||
class TestUserFencedBlocks:
|
||||
"""Fenced code blocks in user messages should render as <pre><code>."""
|
||||
|
||||
def test_simple_fenced_block(self):
|
||||
out = _run_user_render("hello\n```python\nprint(1)\n```\nworld")
|
||||
assert '<pre><code class="language-python">' in out
|
||||
assert 'print(1)' in out
|
||||
# Newlines around the fenced block become <br> (same as original plain-text path)
|
||||
assert 'hello<br>' in out
|
||||
assert '<br>world' in out
|
||||
|
||||
def test_fenced_block_escaped_html(self):
|
||||
"""HTML in code blocks should be escaped."""
|
||||
out = _run_user_render("```html\n<div>hi</div>\n```")
|
||||
assert '<div>' in out
|
||||
# No raw <div> in code content
|
||||
assert '<div>' not in out.replace('<div>', '').replace('>', '')
|
||||
|
||||
def test_plain_text_not_interpreted_as_markdown(self):
|
||||
"""Bold/italic/links in non-fenced text should stay escaped."""
|
||||
out = _run_user_render("**bold** and *italic* and <script>alert(1)</script>")
|
||||
assert '**bold**' in out
|
||||
assert '*italic*' in out
|
||||
assert '<script>' in out
|
||||
assert '<strong>' not in out
|
||||
|
||||
def test_language_header_shown(self):
|
||||
out = _run_user_render("```javascript\nconst x = 1;\n```")
|
||||
assert 'class="pre-header"' in out
|
||||
assert 'javascript' in out
|
||||
|
||||
def test_no_language_no_header(self):
|
||||
out = _run_user_render("```\nsome code\n```")
|
||||
assert 'class="pre-header"' not in out
|
||||
assert '<pre><code>' in out
|
||||
assert 'some code' in out
|
||||
|
||||
def test_diff_block_colored(self):
|
||||
out = _run_user_render("```diff\n+added\n-removed\n```")
|
||||
assert 'diff-block' in out
|
||||
assert 'diff-plus' in out
|
||||
assert 'diff-minus' in out
|
||||
|
||||
def test_multiple_fenced_blocks(self):
|
||||
out = _run_user_render("first\n```python\n1\n```\nmiddle\n```js\n2\n```\nlast")
|
||||
assert 'language-python' in out
|
||||
assert 'language-js' in out
|
||||
assert 'first<br>' in out
|
||||
assert '<br>last' in out
|
||||
|
||||
def test_fenced_block_with_ampersand(self):
|
||||
out = _run_user_render("```python\nx & y\n```")
|
||||
assert 'x & y' in out
|
||||
|
||||
def test_empty_code_block(self):
|
||||
out = _run_user_render("```\n```")
|
||||
assert '<pre><code>' in out
|
||||
|
||||
def test_special_chars_outside_blocks_escaped(self):
|
||||
out = _run_user_render("a < b > c & d")
|
||||
assert 'a < b > c & d' in out
|
||||
|
||||
def test_links_not_rendered_in_plain_text(self):
|
||||
"""URLs in plain text should NOT become clickable links."""
|
||||
out = _run_user_render("Check https://example.com for details")
|
||||
assert '<a ' not in out
|
||||
assert 'https://example.com' in out
|
||||
|
||||
def test_inline_backticks_not_touched(self):
|
||||
"""Inline backticks (single backtick, not fenced block) should remain escaped as text."""
|
||||
out = _run_user_render("use `var x = 1` here")
|
||||
assert '`var x = 1`' in out
|
||||
assert '<code>' not in out
|
||||
273
tests/test_465_session_branching.py
Normal file
273
tests/test_465_session_branching.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""Tests for issue #465 — session branching (/branch).
|
||||
|
||||
Verifies:
|
||||
1. Backend endpoint POST /api/session/branch exists in routes.py
|
||||
2. Session model supports parent_session_id field
|
||||
3. Frontend /branch slash command is registered
|
||||
4. forkFromMessage function exists in commands.js
|
||||
5. Fork button (git-branch icon) is rendered in ui.js message actions
|
||||
6. Parent session indicator (⑂) is rendered in sessions.js sidebar
|
||||
7. i18n keys exist for all branch-related strings
|
||||
8. git-branch icon exists in icons.js
|
||||
"""
|
||||
import re
|
||||
|
||||
|
||||
# ── Backend ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_branch_endpoint_exists():
|
||||
"""Verify the POST /api/session/branch route handler exists."""
|
||||
with open('api/routes.py') as f:
|
||||
src = f.read()
|
||||
assert '"POST /api/session/branch"' in src or '"/api/session/branch"' in src, \
|
||||
"Missing /api/session/branch route"
|
||||
|
||||
|
||||
def test_branch_endpoint_validates_session_id():
|
||||
"""Verify the branch endpoint requires session_id."""
|
||||
with open('api/routes.py') as f:
|
||||
src = f.read()
|
||||
# Find the branch block
|
||||
branch_match = re.search(
|
||||
r'parsed\.path == "/api/session/branch"(.*?)(?=\n if parsed\.path|$)',
|
||||
src, re.DOTALL
|
||||
)
|
||||
assert branch_match, "Could not find /api/session/branch handler block"
|
||||
block = branch_match.group(1)
|
||||
assert 'require(body, "session_id")' in block, \
|
||||
"Branch handler should validate session_id"
|
||||
|
||||
|
||||
def test_branch_endpoint_returns_new_session_id():
|
||||
"""Verify the branch endpoint returns session_id and title."""
|
||||
with open('api/routes.py') as f:
|
||||
src = f.read()
|
||||
branch_match = re.search(
|
||||
r'parsed\.path == "/api/session/branch"(.*?)(?=\n if parsed\.path|$)',
|
||||
src, re.DOTALL
|
||||
)
|
||||
assert branch_match
|
||||
block = branch_match.group(1)
|
||||
assert '"session_id"' in block, "Branch handler should return session_id"
|
||||
assert '"title"' in block, "Branch handler should return title"
|
||||
assert '"parent_session_id"' in block, \
|
||||
"Branch handler should return parent_session_id"
|
||||
|
||||
|
||||
def test_branch_creates_session_with_parent():
|
||||
"""Verify the branch creates a Session with parent_session_id set."""
|
||||
with open('api/routes.py') as f:
|
||||
src = f.read()
|
||||
branch_match = re.search(
|
||||
r'parsed\.path == "/api/session/branch"(.*?)(?=\n if parsed\.path|$)',
|
||||
src, re.DOTALL
|
||||
)
|
||||
assert branch_match
|
||||
block = branch_match.group(1)
|
||||
assert 'parent_session_id=source.session_id' in block, \
|
||||
"Branch handler should set parent_session_id to source session"
|
||||
|
||||
|
||||
def test_branch_keep_count_support():
|
||||
"""Verify the branch endpoint supports keep_count parameter."""
|
||||
with open('api/routes.py') as f:
|
||||
src = f.read()
|
||||
branch_match = re.search(
|
||||
r'parsed\.path == "/api/session/branch"(.*?)(?=\n if parsed\.path|$)',
|
||||
src, re.DOTALL
|
||||
)
|
||||
assert branch_match
|
||||
block = branch_match.group(1)
|
||||
assert 'keep_count' in block, "Branch handler should support keep_count"
|
||||
assert 'forked_messages = source_messages[:keep_count]' in block, \
|
||||
"Branch handler should slice messages by keep_count"
|
||||
|
||||
|
||||
def test_branch_auto_title():
|
||||
"""Verify fork title defaults to '<original> (fork)'."""
|
||||
with open('api/routes.py') as f:
|
||||
src = f.read()
|
||||
branch_match = re.search(
|
||||
r'parsed\.path == "/api/session/branch"(.*?)(?=\n if parsed\.path|$)',
|
||||
src, re.DOTALL
|
||||
)
|
||||
assert branch_match
|
||||
block = branch_match.group(1)
|
||||
assert '(fork)' in block, "Branch handler should auto-title as '(fork)'"
|
||||
|
||||
|
||||
# ── Session model ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_session_model_parent_session_id():
|
||||
"""Verify Session model supports parent_session_id."""
|
||||
with open('api/models.py') as f:
|
||||
src = f.read()
|
||||
assert 'parent_session_id' in src, "Session model should have parent_session_id"
|
||||
# Check __init__ parameter
|
||||
assert 'parent_session_id: str=None' in src, \
|
||||
"Session.__init__ should accept parent_session_id parameter"
|
||||
# Check it's set on self
|
||||
assert 'self.parent_session_id = parent_session_id' in src, \
|
||||
"Session.__init__ should assign parent_session_id"
|
||||
|
||||
|
||||
def test_session_compact_includes_parent():
|
||||
"""Verify compact() includes parent_session_id."""
|
||||
with open('api/models.py') as f:
|
||||
src = f.read()
|
||||
# Use simpler search - find the compact method and check for parent_session_id after it
|
||||
compact_def_match = re.search(r"def compact\(self", src)
|
||||
assert compact_def_match, "Could not find compact() method"
|
||||
# Check the next 1000 chars after def compact for parent_session_id
|
||||
snippet = src[compact_def_match.start():compact_def_match.start() + 1500]
|
||||
assert "'parent_session_id'" in snippet, \
|
||||
"compact() should include parent_session_id"
|
||||
|
||||
|
||||
def test_session_metadata_fields_includes_parent():
|
||||
"""Verify parent_session_id is in METADATA_FIELDS for persistence."""
|
||||
with open('api/models.py') as f:
|
||||
src = f.read()
|
||||
assert "'parent_session_id'" in src, \
|
||||
"METADATA_FIELDS should include parent_session_id"
|
||||
|
||||
|
||||
# ── Frontend: slash command ────────────────────────────────────────────────────
|
||||
|
||||
def test_branch_slash_command_registered():
|
||||
"""Verify /branch is registered as a slash command."""
|
||||
with open('static/commands.js') as f:
|
||||
src = f.read()
|
||||
assert "name:'branch'" in src, "/branch should be registered as a command"
|
||||
assert 'cmdBranch' in src, "cmdBranch handler should be defined"
|
||||
|
||||
|
||||
def test_cmdBranch_function_exists():
|
||||
"""Verify cmdBranch function is defined."""
|
||||
with open('static/commands.js') as f:
|
||||
src = f.read()
|
||||
assert 'async function cmdBranch(' in src, \
|
||||
"cmdBranch should be an async function"
|
||||
|
||||
|
||||
def test_cmdBranch_calls_branch_endpoint():
|
||||
"""Verify cmdBranch calls the /api/session/branch endpoint."""
|
||||
with open('static/commands.js') as f:
|
||||
src = f.read()
|
||||
branch_fn = re.search(r'async function cmdBranch\(.*?\n\}', src, re.DOTALL)
|
||||
assert branch_fn, "Could not find cmdBranch function"
|
||||
block = branch_fn.group(0)
|
||||
assert "'/api/session/branch'" in block, \
|
||||
"cmdBranch should call /api/session/branch"
|
||||
|
||||
|
||||
def test_cmdBranch_switches_session():
|
||||
"""Verify cmdBranch calls loadSession after branching."""
|
||||
with open('static/commands.js') as f:
|
||||
src = f.read()
|
||||
branch_fn = re.search(r'async function cmdBranch\(.*?\n\}', src, re.DOTALL)
|
||||
assert branch_fn
|
||||
block = branch_fn.group(0)
|
||||
assert 'loadSession(' in block, \
|
||||
"cmdBranch should switch to the new session via loadSession"
|
||||
|
||||
|
||||
# ── Frontend: forkFromMessage ─────────────────────────────────────────────────
|
||||
|
||||
def test_forkFromMessage_function_exists():
|
||||
"""Verify forkFromMessage function exists."""
|
||||
with open('static/commands.js') as f:
|
||||
src = f.read()
|
||||
assert 'async function forkFromMessage(' in src, \
|
||||
"forkFromMessage should be defined"
|
||||
|
||||
|
||||
def test_forkFromMessage_passes_keep_count():
|
||||
"""Verify forkFromMessage passes keep_count to the endpoint."""
|
||||
with open('static/commands.js') as f:
|
||||
src = f.read()
|
||||
fn = re.search(r'async function forkFromMessage\(.*?\n\}', src, re.DOTALL)
|
||||
assert fn
|
||||
block = fn.group(0)
|
||||
assert 'keep_count' in block, \
|
||||
"forkFromMessage should pass keep_count to /api/session/branch"
|
||||
|
||||
|
||||
# ── Frontend: fork button in messages ──────────────────────────────────────────
|
||||
|
||||
def test_fork_button_rendered_in_ui():
|
||||
"""Verify fork button is rendered in message actions."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
assert "forkBtn" in src, "forkBtn variable should exist in ui.js"
|
||||
assert "fork_from_here" in src, \
|
||||
"fork_from_here i18n key should be referenced for tooltip"
|
||||
assert "forkFromMessage(" in src, \
|
||||
"forkFromMessage should be called from the button"
|
||||
|
||||
|
||||
def test_fork_button_in_message_actions():
|
||||
"""Verify fork button is included in the msg-actions span."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
# The footHtml template should include forkBtn
|
||||
assert '${forkBtn}' in src, \
|
||||
"forkBtn should be included in message actions template"
|
||||
|
||||
|
||||
# ── Frontend: sidebar parent indicator ────────────────────────────────────────
|
||||
|
||||
def test_sidebar_parent_indicator():
|
||||
"""Verify parent session indicator is rendered in session list."""
|
||||
with open('static/sessions.js') as f:
|
||||
src = f.read()
|
||||
assert 'parent_session_id' in src, \
|
||||
"sessions.js should check parent_session_id"
|
||||
assert 'session-branch-indicator' in src, \
|
||||
"Should have session-branch-indicator class"
|
||||
assert '\\u2482' in src, \
|
||||
"Should use ⑂ character for parent indicator"
|
||||
|
||||
|
||||
def test_parent_indicator_clickable():
|
||||
"""Verify parent indicator navigates to parent session on click."""
|
||||
with open('static/sessions.js') as f:
|
||||
src = f.read()
|
||||
# Find the parent indicator block
|
||||
parent_block = re.search(
|
||||
r'branch-indicator[\s\S]*?parent_session_id[\s\S]*?titleRow\.appendChild',
|
||||
src
|
||||
)
|
||||
assert parent_block, "Could not find parent indicator block"
|
||||
block = parent_block.group(0)
|
||||
assert 'loadSession(' in block, \
|
||||
"Parent indicator should call loadSession on click"
|
||||
|
||||
|
||||
# ── Frontend: i18n keys ────────────────────────────────────────────────────────
|
||||
|
||||
def test_i18n_branch_keys():
|
||||
"""Verify all branch-related i18n keys exist in English locale."""
|
||||
with open('static/i18n.js') as f:
|
||||
src = f.read()
|
||||
required_keys = [
|
||||
'cmd_branch',
|
||||
'cmd_branch_usage',
|
||||
'branch_forked',
|
||||
'branch_failed',
|
||||
'fork_from_here',
|
||||
'forked_from',
|
||||
]
|
||||
for key in required_keys:
|
||||
assert f"{key}:" in src or f"{key} :" in src, \
|
||||
f"Missing i18n key: {key}"
|
||||
|
||||
|
||||
# ── Frontend: icon ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_git_branch_icon_exists():
|
||||
"""Verify git-branch icon is defined in icons.js."""
|
||||
with open('static/icons.js') as f:
|
||||
src = f.read()
|
||||
assert "'git-branch'" in src, \
|
||||
"git-branch icon should be defined in LI_PATHS"
|
||||
249
tests/test_499_tts_playback.py
Normal file
249
tests/test_499_tts_playback.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
Tests for #499: TTS playback of agent responses via Web Speech API.
|
||||
|
||||
Verifies that TTS utility functions, speaker button rendering, and
|
||||
settings controls are present in the WebUI codebase.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
|
||||
STATIC_DIR = os.path.join(os.path.dirname(__file__), '..', 'static')
|
||||
|
||||
|
||||
def _read(filename):
|
||||
return open(os.path.join(STATIC_DIR, filename), encoding='utf-8').read()
|
||||
|
||||
|
||||
class TestTtsUtilityFunctions:
|
||||
"""TTS core functions exist in ui.js."""
|
||||
|
||||
def test_strip_for_tts_exists(self):
|
||||
src = _read('ui.js')
|
||||
assert 'function _stripForTTS(' in src, \
|
||||
"_stripForTTS function not found in ui.js"
|
||||
|
||||
def test_speak_message_exists(self):
|
||||
src = _read('ui.js')
|
||||
assert 'function speakMessage(' in src, \
|
||||
"speakMessage function not found in ui.js"
|
||||
|
||||
def test_stop_tts_exists(self):
|
||||
src = _read('ui.js')
|
||||
assert 'function stopTTS(' in src, \
|
||||
"stopTTS function not found in ui.js"
|
||||
|
||||
def test_auto_read_exists(self):
|
||||
src = _read('ui.js')
|
||||
assert 'function autoReadLastAssistant(' in src, \
|
||||
"autoReadLastAssistant function not found in ui.js"
|
||||
|
||||
def test_strip_code_blocks(self):
|
||||
"""_stripForTTS must remove ``` code blocks."""
|
||||
src = _read('ui.js')
|
||||
assert re.search(r'_stripForTTS.*```', src, re.DOTALL), \
|
||||
"_stripForTTS must handle fenced code blocks"
|
||||
|
||||
def test_strip_media_paths(self):
|
||||
"""_stripForTTS must replace MEDIA: paths."""
|
||||
src = _read('ui.js')
|
||||
assert 'MEDIA:' in src and 'a file' in src, \
|
||||
"_stripForTTS must replace MEDIA: paths"
|
||||
|
||||
def test_uses_speech_synthesis(self):
|
||||
"""speakMessage must use window.speechSynthesis."""
|
||||
src = _read('ui.js')
|
||||
assert 'SpeechSynthesisUtterance' in src, \
|
||||
"speakMessage must create SpeechSynthesisUtterance"
|
||||
assert 'speechSynthesis.speak' in src, \
|
||||
"speakMessage must call speechSynthesis.speak"
|
||||
|
||||
|
||||
class TestTtsSpeakerButton:
|
||||
"""Speaker button is rendered on assistant messages."""
|
||||
|
||||
def test_tts_button_rendered(self):
|
||||
"""ttsBtn must be generated for non-user messages."""
|
||||
src = _read('ui.js')
|
||||
assert 'msg-tts-btn' in src, \
|
||||
"TTS button class not found in ui.js"
|
||||
|
||||
def test_tts_button_not_on_user_messages(self):
|
||||
"""ttsBtn must only be added for non-user (assistant) messages."""
|
||||
src = _read('ui.js')
|
||||
# Find the ttsBtn definition — it should have !isUser guard
|
||||
tts_line = [l for l in src.splitlines() if 'msg-tts-btn' in l][0]
|
||||
assert '!isUser' in tts_line or 'isUser' in tts_line, \
|
||||
"TTS button should have user-check guard"
|
||||
|
||||
def test_tts_button_in_footer(self):
|
||||
"""ttsBtn must be included in the msg-actions span."""
|
||||
src = _read('ui.js')
|
||||
# The footHtml line should include ttsBtn
|
||||
foot_lines = [l for l in src.splitlines() if 'footHtml' in l and 'msg-actions' in l]
|
||||
assert any('ttsBtn' in l for l in foot_lines), \
|
||||
"ttsBtn not included in footHtml msg-actions"
|
||||
|
||||
def test_tts_button_uses_volume_icon(self):
|
||||
"""Speaker button should use volume-2 icon."""
|
||||
src = _read('ui.js')
|
||||
tts_line = [l for l in src.splitlines() if 'msg-tts-btn' in l][0]
|
||||
assert 'volume-2' in tts_line, \
|
||||
"TTS button should use volume-2 icon"
|
||||
|
||||
|
||||
class TestTtsSettings:
|
||||
"""TTS settings controls exist in the HTML and are wired in panels.js."""
|
||||
|
||||
def test_tts_enabled_checkbox(self):
|
||||
src = _read('index.html')
|
||||
assert 'settingsTtsEnabled' in src, \
|
||||
"TTS enabled checkbox not found in index.html"
|
||||
|
||||
def test_tts_auto_read_checkbox(self):
|
||||
src = _read('index.html')
|
||||
assert 'settingsTtsAutoRead' in src, \
|
||||
"TTS auto-read checkbox not found in index.html"
|
||||
|
||||
def test_tts_voice_selector(self):
|
||||
src = _read('index.html')
|
||||
assert 'settingsTtsVoice' in src, \
|
||||
"TTS voice selector not found in index.html"
|
||||
|
||||
def test_tts_rate_slider(self):
|
||||
src = _read('index.html')
|
||||
assert 'settingsTtsRate' in src, \
|
||||
"TTS rate slider not found in index.html"
|
||||
|
||||
def test_tts_pitch_slider(self):
|
||||
src = _read('index.html')
|
||||
assert 'settingsTtsPitch' in src, \
|
||||
"TTS pitch slider not found in index.html"
|
||||
|
||||
def test_tts_settings_wired_in_panels(self):
|
||||
"""TTS settings must be initialized in loadSettingsPanel."""
|
||||
src = _read('panels.js')
|
||||
assert 'settingsTtsEnabled' in src, \
|
||||
"TTS enabled setting not wired in panels.js"
|
||||
assert '_applyTtsEnabled' in src, \
|
||||
"_applyTtsEnabled not called in panels.js"
|
||||
|
||||
def test_apply_tts_enabled_function(self):
|
||||
"""_applyTtsEnabled must toggle msg-tts-btn display."""
|
||||
src = _read('panels.js')
|
||||
assert 'function _applyTtsEnabled(' in src, \
|
||||
"_applyTtsEnabled function not found in panels.js"
|
||||
|
||||
|
||||
class TestTtsI18n:
|
||||
"""TTS i18n keys exist in the English locale."""
|
||||
|
||||
def test_tts_listen_key(self):
|
||||
src = _read('i18n.js')
|
||||
assert "tts_listen:" in src, \
|
||||
"tts_listen key not found in i18n.js"
|
||||
|
||||
def test_tts_not_supported_key(self):
|
||||
src = _read('i18n.js')
|
||||
assert "tts_not_supported:" in src, \
|
||||
"tts_not_supported key not found in i18n.js"
|
||||
|
||||
def test_tts_settings_keys(self):
|
||||
src = _read('i18n.js')
|
||||
for key in ['settings_label_tts', 'settings_label_tts_auto_read',
|
||||
'settings_label_tts_voice', 'settings_label_tts_rate',
|
||||
'settings_label_tts_pitch']:
|
||||
assert f"{key}:" in src, f"{key} not found in i18n.js"
|
||||
|
||||
|
||||
class TestTtsAutoRead:
|
||||
"""Auto-read is triggered after SSE done event."""
|
||||
|
||||
def test_auto_read_called_in_messages(self):
|
||||
src = _read('messages.js')
|
||||
assert 'autoReadLastAssistant' in src, \
|
||||
"autoReadLastAssistant not called in messages.js"
|
||||
|
||||
def test_tts_pause_on_composer_focus(self):
|
||||
"""Speech should pause when user focuses the composer."""
|
||||
src = _read('messages.js')
|
||||
assert 'speechSynthesis.pause' in src, \
|
||||
"speechSynthesis.pause not called in messages.js"
|
||||
assert 'speechSynthesis.resume' in src, \
|
||||
"speechSynthesis.resume not called in messages.js"
|
||||
|
||||
|
||||
class TestTtsBoot:
|
||||
"""TTS enabled state is applied on page load."""
|
||||
|
||||
def test_apply_tts_on_boot(self):
|
||||
src = _read('boot.js')
|
||||
assert '_applyTtsEnabled' in src, \
|
||||
"_applyTtsEnabled not called in boot.js"
|
||||
|
||||
|
||||
class TestTtsStyles:
|
||||
"""TTS CSS styles exist."""
|
||||
|
||||
def test_tts_button_hidden_default(self):
|
||||
src = _read('style.css')
|
||||
assert '.msg-tts-btn' in src, \
|
||||
".msg-tts-btn CSS class not found in style.css"
|
||||
|
||||
def test_tts_pulse_animation(self):
|
||||
src = _read('style.css')
|
||||
assert 'tts-pulse' in src, \
|
||||
"tts-pulse animation not found in style.css"
|
||||
|
||||
|
||||
class TestIssue1409TtsToggleBodyClass:
|
||||
"""Regression: #1409 — TTS toggle had no effect because of CSS specificity collision.
|
||||
|
||||
Original bug: ``_applyTtsEnabled`` set ``btn.style.display=enabled?'':'none'``.
|
||||
The empty-string branch removes the inline override, after which the
|
||||
``.msg-tts-btn { display:none; }`` rule from style.css applies — so both
|
||||
"enabled" and "disabled" states left the button hidden.
|
||||
|
||||
Fix: toggle a body-level class (``body.tts-enabled``) and gate the speaker
|
||||
icon on a compound selector ``body.tts-enabled .msg-tts-btn``. This bypasses
|
||||
the inline-style cascade collision and survives ``renderMd()`` re-renders.
|
||||
"""
|
||||
|
||||
def test_apply_tts_enabled_uses_body_class(self):
|
||||
"""_applyTtsEnabled must toggle the document body's `tts-enabled` class."""
|
||||
src = _read('panels.js')
|
||||
# The new shape: toggle body class instead of writing inline display
|
||||
assert "document.body.classList.toggle('tts-enabled'" in src, (
|
||||
"_applyTtsEnabled must toggle the body.tts-enabled class — see #1409. "
|
||||
"Reverting to inline `style.display` will silently break the toggle "
|
||||
"again because of the .msg-action-btn / .msg-tts-btn cascade."
|
||||
)
|
||||
|
||||
def test_apply_tts_enabled_does_not_use_inline_display(self):
|
||||
"""_applyTtsEnabled must NOT set inline `style.display` on .msg-tts-btn."""
|
||||
src = _read('panels.js')
|
||||
# Find the function body and check it doesn't set inline display
|
||||
# on individual buttons (the broken pattern).
|
||||
m = re.search(
|
||||
r'function _applyTtsEnabled\([^)]*\)\s*\{(?P<body>[^}]*)\}',
|
||||
src,
|
||||
)
|
||||
assert m, "_applyTtsEnabled function body not found in panels.js"
|
||||
body = m.group('body')
|
||||
assert '.style.display' not in body, (
|
||||
"_applyTtsEnabled body must not set inline style.display — that's "
|
||||
"the #1409 bug. Use body.classList.toggle('tts-enabled') instead."
|
||||
)
|
||||
|
||||
def test_body_class_selector_in_css(self):
|
||||
"""style.css must show .msg-tts-btn only when body.tts-enabled is set."""
|
||||
src = _read('style.css')
|
||||
assert 'body.tts-enabled .msg-tts-btn' in src, (
|
||||
"Missing `body.tts-enabled .msg-tts-btn` selector in style.css — "
|
||||
"without this rule the body class has no visual effect (#1409)."
|
||||
)
|
||||
# The default-hidden rule must still be present (so no body class = no icon).
|
||||
assert '.msg-tts-btn{display:none;}' in src or \
|
||||
re.search(r'\.msg-tts-btn\s*\{[^}]*display\s*:\s*none', src), (
|
||||
"Default `.msg-tts-btn{display:none;}` rule must remain so the "
|
||||
"icon is hidden by default (#1409)."
|
||||
)
|
||||
108
tests/test_745_code_block_newlines.py
Normal file
108
tests/test_745_code_block_newlines.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
Tests for #745: code blocks losing newlines when not preceded by double blank line.
|
||||
|
||||
Root cause: the paragraph-splitter in renderMd() replaced \n with <br> inside
|
||||
<pre><code> blocks when they were not separated by a double newline from surrounding
|
||||
text. The fix stashes <pre> blocks (and pre-header divs, mermaid, katex) before
|
||||
the paragraph split and restores them afterwards.
|
||||
"""
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
|
||||
UI_JS = os.path.join(os.path.dirname(__file__), '..', 'static', 'ui.js')
|
||||
|
||||
|
||||
def get_ui_js():
|
||||
return open(UI_JS, encoding='utf-8').read()
|
||||
|
||||
|
||||
class TestCodeBlockNewlinePreservation:
|
||||
|
||||
def test_pre_stash_present(self):
|
||||
"""The _pre_stash variable must exist in ui.js."""
|
||||
src = get_ui_js()
|
||||
assert '_pre_stash' in src, "_pre_stash not found in ui.js"
|
||||
|
||||
def test_pre_stash_token_E_used(self):
|
||||
"""Stash token \\x00E must be used for pre-block stashing."""
|
||||
src = get_ui_js()
|
||||
assert r'\x00E' in src, r"\x00E stash token not found in ui.js"
|
||||
|
||||
def test_stash_before_paragraph_split(self):
|
||||
"""_pre_stash must be populated BEFORE the parts=s.split line."""
|
||||
src = get_ui_js()
|
||||
pre_stash_pos = src.index('_pre_stash=[]')
|
||||
split_pos = src.index('const parts=s.split(/\\n{2,}/)')
|
||||
assert pre_stash_pos < split_pos, \
|
||||
"_pre_stash must be initialised before the paragraph split"
|
||||
|
||||
def test_restore_after_paragraph_split(self):
|
||||
"""_pre_stash restore must happen AFTER the paragraph map/join line."""
|
||||
src = get_ui_js()
|
||||
restore_pos = src.index('_pre_stash[+i]')
|
||||
split_pos = src.index("}).join('\\n');", src.index('const parts=s.split'))
|
||||
assert restore_pos > split_pos, \
|
||||
"_pre_stash must be restored after the paragraph split/join"
|
||||
|
||||
def test_paragraph_split_bypasses_stash_tokens(self):
|
||||
"""The paragraph map must bypass lines that start with \\x00E (pre stash).
|
||||
Also accepts a character class like \\x00[EQ] when other stash tokens
|
||||
share the same bypass (e.g. \\x00Q for blockquote stash)."""
|
||||
src = get_ui_js()
|
||||
# The map line must check for \x00E in its bypass condition
|
||||
map_line = next(
|
||||
l for l in src.splitlines()
|
||||
if 'parts.map' in l and '<br>' in l
|
||||
)
|
||||
assert r'\x00E' in map_line or r'\x00[E' in map_line, (
|
||||
r"paragraph map must bypass \x00E stash tokens (literally or as "
|
||||
r"part of a character class like \x00[EQ])"
|
||||
)
|
||||
|
||||
def test_pre_regex_covers_pre_header_div(self):
|
||||
"""The stash regex must match <div class=\"pre-header\"> before <pre>."""
|
||||
src = get_ui_js()
|
||||
# Find the replacement regex used to populate _pre_stash
|
||||
stash_block_idx = src.index('_pre_stash=[]')
|
||||
stash_block = src[stash_block_idx:stash_block_idx + 400]
|
||||
assert 'pre-header' in stash_block, \
|
||||
"pre-stash regex must match <div class=\"pre-header\"> wrappers"
|
||||
|
||||
def test_mermaid_covered_by_stash(self):
|
||||
"""The stash regex must also cover mermaid-block divs."""
|
||||
src = get_ui_js()
|
||||
stash_block_idx = src.index('_pre_stash=[]')
|
||||
stash_block = src[stash_block_idx:stash_block_idx + 400]
|
||||
assert 'mermaid-block' in stash_block, \
|
||||
"pre-stash regex must cover mermaid-block divs"
|
||||
|
||||
def test_katex_covered_by_stash(self):
|
||||
"""The stash regex must also cover katex-block divs."""
|
||||
src = get_ui_js()
|
||||
stash_block_idx = src.index('_pre_stash=[]')
|
||||
stash_block = src[stash_block_idx:stash_block_idx + 400]
|
||||
assert 'katex-block' in stash_block, \
|
||||
"pre-stash regex must cover katex-block divs"
|
||||
|
||||
def test_js_syntax_valid(self):
|
||||
"""ui.js must pass node --check after the fix."""
|
||||
result = subprocess.run(
|
||||
['node', '--check', UI_JS],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0, \
|
||||
f"node --check failed:\n{result.stderr}"
|
||||
|
||||
def test_stash_token_e_not_used_elsewhere(self):
|
||||
"""\\x00E must only appear in the pre-stash section (not reused)."""
|
||||
src = get_ui_js()
|
||||
occurrences = [
|
||||
i for i in range(len(src))
|
||||
if src[i:i+4] == r'\x00' and i + 4 < len(src) and src[i+4] == 'E'
|
||||
]
|
||||
# Allow 2 occurrences: the push token and the restore regex
|
||||
# (may be 3 if there's also a comment mentioning it)
|
||||
assert len(occurrences) >= 2, \
|
||||
r"Expected at least 2 uses of \x00E (push + restore)"
|
||||
107
tests/test_779_html_preview.py
Normal file
107
tests/test_779_html_preview.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Tests for inline HTML preview in workspace panel (issue #779)."""
|
||||
import pytest
|
||||
|
||||
|
||||
def _get_routes_content():
|
||||
return open("api/routes.py", encoding="utf-8").read()
|
||||
|
||||
|
||||
def _get_workspace_js():
|
||||
return open("static/workspace.js", encoding="utf-8").read()
|
||||
|
||||
|
||||
def _get_index_html():
|
||||
return open("static/index.html", encoding="utf-8").read()
|
||||
|
||||
|
||||
def test_inline_preview_param_in_file_raw():
|
||||
"""?inline=1 must bypass Content-Disposition: attachment for text/html."""
|
||||
content = _get_routes_content()
|
||||
assert "inline_preview" in content, (
|
||||
"_handle_file_raw must read the inline query parameter"
|
||||
)
|
||||
assert "html_inline_ok" in content, (
|
||||
"_handle_file_raw must allow HTML inline when inline_preview=True"
|
||||
)
|
||||
|
||||
|
||||
def test_iframe_uses_inline_param():
|
||||
"""workspace.js must pass &inline=1 when setting the preview iframe src."""
|
||||
content = _get_workspace_js()
|
||||
assert "inline=1" in content, (
|
||||
"workspace.js must pass ?inline=1 to api/file/raw for the HTML preview iframe"
|
||||
)
|
||||
|
||||
|
||||
def test_html_preview_iframe_exists_in_html():
|
||||
"""The previewHtmlIframe element must be present in index.html."""
|
||||
content = _get_index_html()
|
||||
assert "previewHtmlIframe" in content, (
|
||||
"index.html must contain the previewHtmlIframe element"
|
||||
)
|
||||
|
||||
|
||||
def test_html_exts_defined_in_workspace_js():
|
||||
"""HTML_EXTS set must include .html and .htm."""
|
||||
content = _get_workspace_js()
|
||||
assert "HTML_EXTS" in content, "workspace.js must define HTML_EXTS"
|
||||
assert "'.html'" in content or '".html"' in content, "HTML_EXTS must include .html"
|
||||
assert "'.htm'" in content or '".htm"' in content, "HTML_EXTS must include .htm"
|
||||
|
||||
|
||||
def test_sandbox_allows_scripts_only():
|
||||
"""iframe sandbox must not include allow-same-origin (XSS risk)."""
|
||||
content = _get_index_html()
|
||||
# Find the sandbox attribute value
|
||||
import re
|
||||
sandboxes = re.findall(r'sandbox="([^"]*)"', content)
|
||||
preview_sandboxes = [s for s in sandboxes if "allow" in s]
|
||||
for sb in preview_sandboxes:
|
||||
assert "allow-same-origin" not in sb, (
|
||||
"HTML preview iframe must not have allow-same-origin (would expose parent cookies)"
|
||||
)
|
||||
|
||||
|
||||
def test_mime_map_includes_html_and_htm():
|
||||
"""MIME_MAP must map .html/.htm to text/html — without this, _handle_file_raw
|
||||
falls back to application/octet-stream and browsers refuse to render the
|
||||
response inside the preview iframe (issue #779 follow-up: PR #1070)."""
|
||||
from api.config import MIME_MAP
|
||||
assert MIME_MAP.get(".html") == "text/html", (
|
||||
"MIME_MAP['.html'] must be 'text/html' for the workspace HTML preview iframe"
|
||||
)
|
||||
assert MIME_MAP.get(".htm") == "text/html", (
|
||||
"MIME_MAP['.htm'] must be 'text/html' for the workspace HTML preview iframe"
|
||||
)
|
||||
|
||||
|
||||
def test_inline_html_response_sets_csp_sandbox():
|
||||
"""Defense-in-depth: ?inline=1 HTML responses must set Content-Security-Policy:
|
||||
sandbox so the same origin isolation applies even when the URL is opened
|
||||
directly in a top-level tab (not just inside the workspace panel iframe).
|
||||
|
||||
Without this, a user tricked into clicking a chat link like
|
||||
/api/file/raw?path=evil.html&inline=1 would render the HTML in the WebUI's
|
||||
origin without any sandbox, giving the page full access to cookies and
|
||||
localStorage. The CSP sandbox directive (no allow-same-origin) downgrades
|
||||
the document to a unique opaque origin server-side.
|
||||
"""
|
||||
content = _get_routes_content()
|
||||
# Find the html_inline_ok block in _handle_file_raw
|
||||
idx = content.find("html_inline_ok")
|
||||
assert idx != -1, "html_inline_ok block not found"
|
||||
block = content[idx:idx + 2500]
|
||||
assert "Content-Security-Policy" in block, (
|
||||
"_handle_file_raw must set Content-Security-Policy header on inline HTML responses"
|
||||
)
|
||||
assert "sandbox" in block, (
|
||||
"CSP must include the sandbox directive"
|
||||
)
|
||||
# Must NOT have allow-same-origin in the sandbox directive
|
||||
csp_sections = [line for line in block.splitlines() if "sandbox" in line and "Policy" in line]
|
||||
for line in csp_sections:
|
||||
# The line setting the CSP header — make sure it doesn't grant same-origin
|
||||
if "send_header" in line:
|
||||
assert "allow-same-origin" not in line, (
|
||||
"CSP sandbox must NOT include allow-same-origin — that would defeat the isolation"
|
||||
)
|
||||
92
tests/test_886_ordered_list_numbering.py
Normal file
92
tests/test_886_ordered_list_numbering.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Tests for #886: ordered list items always rendered as "1." regardless of position.
|
||||
|
||||
Root cause: when LLMs output numbered lists with blank lines between items,
|
||||
the paragraph-splitter in renderMd() splits the markdown into one chunk per item,
|
||||
so the ordered-list regex wraps each item in its own <ol>. Each <ol> restarts
|
||||
at 1, producing "1. 1. 1." instead of "1. 2. 3.".
|
||||
|
||||
Fix: emit value="N" on every <li> so the correct ordinal is preserved even when
|
||||
items end up in separate <ol> containers after the paragraph split.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
|
||||
UI_JS = os.path.join(os.path.dirname(__file__), '..', 'static', 'ui.js')
|
||||
|
||||
|
||||
def get_ui_js():
|
||||
return open(UI_JS, encoding='utf-8').read()
|
||||
|
||||
|
||||
class TestOrderedListNumbering:
|
||||
|
||||
def test_li_value_attr_present_in_ordered_list_block(self):
|
||||
"""The ordered-list renderer must emit value= on each <li>."""
|
||||
src = get_ui_js()
|
||||
# Locate the ordered-list replace block
|
||||
ol_idx = src.find('s=s.replace(/((?:^(?: )?\\d+\\. .+\\n?)+)/gm')
|
||||
assert ol_idx != -1, "Ordered-list replace block not found in ui.js"
|
||||
# Extract a window large enough to cover the whole closure (~400 chars)
|
||||
ol_block = src[ol_idx:ol_idx + 500]
|
||||
assert 'value=' in ol_block, (
|
||||
"Ordered-list block must emit value= attribute on <li> elements to "
|
||||
"preserve numbering when items are separated by blank lines (#886)"
|
||||
)
|
||||
|
||||
def test_li_value_uses_parsed_number(self):
|
||||
"""The value= must be derived from parseInt of the captured digit, not hardcoded."""
|
||||
src = get_ui_js()
|
||||
ol_idx = src.find('s=s.replace(/((?:^(?: )?\\d+\\. .+\\n?)+)/gm')
|
||||
assert ol_idx != -1, "Ordered-list replace block not found in ui.js"
|
||||
ol_block = src[ol_idx:ol_idx + 500]
|
||||
assert 'parseInt' in ol_block, (
|
||||
"Ordered-list block should use parseInt() to parse the list number (#886)"
|
||||
)
|
||||
|
||||
def test_numMatch_variable_present(self):
|
||||
"""The numMatch variable (or equivalent digit capture) must exist in the OL block."""
|
||||
src = get_ui_js()
|
||||
ol_idx = src.find('s=s.replace(/((?:^(?: )?\\d+\\. .+\\n?)+)/gm')
|
||||
assert ol_idx != -1, "Ordered-list replace block not found in ui.js"
|
||||
ol_block = src[ol_idx:ol_idx + 500]
|
||||
# Either numMatch or a similar digit-capture variable
|
||||
assert 'numMatch' in ol_block or re.search(r'match\(/.*\\d', ol_block), (
|
||||
"Ordered-list block should capture the list item number with a regex match (#886)"
|
||||
)
|
||||
|
||||
def test_valAttr_or_value_template_present(self):
|
||||
"""The <li> template must include the value attribute conditionally or unconditionally."""
|
||||
src = get_ui_js()
|
||||
ol_idx = src.find('s=s.replace(/((?:^(?: )?\\d+\\. .+\\n?)+)/gm')
|
||||
assert ol_idx != -1, "Ordered-list replace block not found in ui.js"
|
||||
ol_block = src[ol_idx:ol_idx + 500]
|
||||
# Either a valAttr variable or an inline value= in the template
|
||||
has_val_attr = 'valAttr' in ol_block
|
||||
has_inline_value = re.search(r'<li.*value=', ol_block)
|
||||
assert has_val_attr or has_inline_value, (
|
||||
"Ordered-list block must have value= on <li> (via valAttr var or inline) (#886)"
|
||||
)
|
||||
|
||||
def test_ordered_list_comment_references_issue(self):
|
||||
"""A comment near the OL fix should reference the issue (#886) or the symptom."""
|
||||
src = get_ui_js()
|
||||
ol_idx = src.find('s=s.replace(/((?:^(?: )?\\d+\\. .+\\n?)+)/gm')
|
||||
assert ol_idx != -1, "Ordered-list replace block not found in ui.js"
|
||||
# Look at the 300 chars BEFORE the replace line for an explanatory comment
|
||||
context = src[max(0, ol_idx - 300):ol_idx]
|
||||
has_comment = '#886' in context or '1. 1. 1.' in context or 'blank lines' in context.lower()
|
||||
assert has_comment, (
|
||||
"Expected a comment near the OL fix explaining the blank-line issue (#886)"
|
||||
)
|
||||
|
||||
def test_list_without_blank_lines_unaffected(self):
|
||||
"""A compact list (no blank lines) should still produce one <ol> with sequential items."""
|
||||
src = get_ui_js()
|
||||
# Structural check: the regex still captures multi-line blocks (\\n? allows groups)
|
||||
ol_idx = src.find('s=s.replace(/((?:^(?: )?\\d+\\. .+\\n?)+)/gm')
|
||||
assert ol_idx != -1, "Ordered-list replace block not found"
|
||||
# The \\n? quantifier that allows grouping must still be present
|
||||
assert '\\n?' in src[ol_idx:ol_idx + 80], (
|
||||
"The \\\\n? in the ordered-list regex was removed — compact lists may break"
|
||||
)
|
||||
25
tests/test_app_titlebar_restore.py
Normal file
25
tests/test_app_titlebar_restore.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_app_titlebar_no_longer_contains_tps_chip():
|
||||
assert 'id="tpsStat"' not in INDEX_HTML
|
||||
|
||||
|
||||
def test_app_titlebar_returns_to_centered_desktop_layout():
|
||||
assert ".app-titlebar{display:flex;align-items:center;justify-content:center;" in STYLE_CSS
|
||||
assert ".app-titlebar-inner{display:flex;align-items:center;gap:8px;min-width:0;max-width:100%;justify-content:center;}" in STYLE_CSS
|
||||
|
||||
|
||||
def test_app_titlebar_subtitle_shows_message_count_again():
|
||||
assert "subText = t('n_messages', vis.length);" in PANELS_JS
|
||||
|
||||
|
||||
def test_queue_updates_do_not_hijack_app_titlebar_subtitle():
|
||||
assert "_syncQueueTitlebar" not in UI_JS
|
||||
52
tests/test_approval_card_layering.py
Normal file
52
tests/test_approval_card_layering.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
Regression test for PR #1071: approval card must render above the queue flyout.
|
||||
|
||||
Both `.approval-card` and `.queue-card` are siblings inside `.composer-flyout`
|
||||
and share the same absolute positioning slot just above the composer. When
|
||||
both are visible at the same time (queue flyout open + tool approval card
|
||||
sliding up) the approval card MUST win the stacking order so its security-
|
||||
relevant Allow / Deny buttons stay clickable.
|
||||
|
||||
The old CSS had `.queue-card { z-index: 2 }` and no z-index on
|
||||
`.approval-card.visible`, so the queue card painted on top and blocked the
|
||||
approval buttons. The fix raises `.approval-card.visible` to z-index 3.
|
||||
|
||||
This test pins the invariant: approval-card.visible z-index must be strictly
|
||||
greater than queue-card z-index.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
CSS = Path("static/style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _z_index_of(selector_regex: str) -> int | None:
|
||||
m = re.search(selector_regex + r"\s*\{[^}]*z-index:(\d+)", CSS)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def test_approval_card_visible_outranks_queue_card():
|
||||
queue_z = _z_index_of(r"\.queue-card")
|
||||
approval_visible_z = _z_index_of(r"\.approval-card\.visible")
|
||||
assert queue_z is not None, ".queue-card must declare a z-index"
|
||||
assert approval_visible_z is not None, (
|
||||
".approval-card.visible must declare a z-index — without it, the approval "
|
||||
"buttons get covered by the queue flyout (PR #1071)"
|
||||
)
|
||||
assert approval_visible_z > queue_z, (
|
||||
f".approval-card.visible z-index ({approval_visible_z}) must be strictly "
|
||||
f"greater than .queue-card z-index ({queue_z}) so approval buttons "
|
||||
f"remain clickable when both flyouts are open."
|
||||
)
|
||||
|
||||
|
||||
def test_approval_card_visible_outranks_terminal_card():
|
||||
terminal_z = _z_index_of(r"\.composer-terminal-panel")
|
||||
approval_visible_z = _z_index_of(r"\.approval-card\.visible")
|
||||
assert terminal_z is not None, ".composer-terminal-panel must declare a z-index"
|
||||
assert approval_visible_z is not None
|
||||
assert approval_visible_z > terminal_z, (
|
||||
f".approval-card.visible z-index ({approval_visible_z}) must stay above "
|
||||
f".composer-terminal-panel z-index ({terminal_z}) so approval controls "
|
||||
f"remain clickable when the terminal flyout is open."
|
||||
)
|
||||
188
tests/test_approval_queue.py
Normal file
188
tests/test_approval_queue.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""Tests for approval queue multi-entry support (issue #527).
|
||||
|
||||
Previously _pending[sid] held one entry, so simultaneous approvals overwrote
|
||||
each other. This PR changes submit_pending() to append to a list and adds
|
||||
approval_id so /api/approval/respond can target a specific entry.
|
||||
"""
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
ROUTES_SRC = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8")
|
||||
MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
INDEX_HTML = (REPO_ROOT / "static" / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static-analysis: Python routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_submit_pending_appends_to_list():
|
||||
"""submit_pending() must append to a list, not overwrite."""
|
||||
# The new wrapper must contain a queue append (list mutation pattern)
|
||||
assert "queue_list.append(entry)" in ROUTES_SRC or "queue.append(entry)" in ROUTES_SRC, \
|
||||
"submit_pending() must append entry to a list queue, not overwrite _pending[sid]"
|
||||
|
||||
|
||||
def test_submit_pending_adds_approval_id():
|
||||
"""Each queued entry must get a unique approval_id."""
|
||||
assert "approval_id" in ROUTES_SRC and "uuid.uuid4().hex" in ROUTES_SRC, \
|
||||
"submit_pending() must assign a uuid4 approval_id to each queued entry"
|
||||
|
||||
|
||||
def test_handle_approval_pending_returns_count():
|
||||
"""_handle_approval_pending must return pending_count in its response."""
|
||||
assert '"pending_count"' in ROUTES_SRC, \
|
||||
"_handle_approval_pending must include pending_count in the JSON response"
|
||||
|
||||
|
||||
def test_handle_approval_respond_pops_by_approval_id():
|
||||
"""_handle_approval_respond must target entry by approval_id."""
|
||||
assert 'approval_id = body.get("approval_id"' in ROUTES_SRC, \
|
||||
"_handle_approval_respond must read approval_id from request body"
|
||||
assert 'entry.get("approval_id") == approval_id' in ROUTES_SRC, \
|
||||
"_handle_approval_respond must find and pop the matching entry by approval_id"
|
||||
|
||||
|
||||
def test_handle_approval_respond_fallback_to_oldest():
|
||||
"""When no approval_id is given, fall back to popping the oldest entry (FIFO)."""
|
||||
# The fallback path: queue.pop(0) when approval_id is empty
|
||||
assert "queue.pop(0)" in ROUTES_SRC, \
|
||||
"_handle_approval_respond must fall back to popping the oldest entry when approval_id is absent"
|
||||
|
||||
|
||||
def test_backward_compat_legacy_dict_value():
|
||||
"""The respond handler must tolerate a legacy single-dict value in _pending."""
|
||||
assert "Legacy single-dict value" in ROUTES_SRC or \
|
||||
"# Legacy single-dict" in ROUTES_SRC or \
|
||||
"elif queue:" in ROUTES_SRC, \
|
||||
"respond handler must handle legacy single-dict _pending values for backward compatibility"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static-analysis: JavaScript frontend
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_respond_sends_approval_id():
|
||||
"""respondApproval() must include approval_id in the POST body."""
|
||||
assert "approval_id: approvalId" in MESSAGES_JS, \
|
||||
"respondApproval() must send approval_id in the POST body to /api/approval/respond"
|
||||
|
||||
|
||||
def test_show_approval_card_accepts_count():
|
||||
"""showApprovalCard must accept a pendingCount parameter."""
|
||||
assert re.search(r"function showApprovalCard\(pending,\s*pendingCount\)", MESSAGES_JS), \
|
||||
"showApprovalCard() must accept a pendingCount argument"
|
||||
|
||||
|
||||
def test_show_approval_card_renders_counter():
|
||||
"""showApprovalCard must display a '1 of N pending' counter when N > 1."""
|
||||
assert '"1 of " + pendingCount + " pending"' in MESSAGES_JS or \
|
||||
"'1 of ' + pendingCount + ' pending'" in MESSAGES_JS, \
|
||||
"showApprovalCard() must render '1 of N pending' counter for multiple queued approvals"
|
||||
|
||||
|
||||
def test_approval_current_id_tracked():
|
||||
"""_approvalCurrentId must be set and cleared around each approval."""
|
||||
assert "_approvalCurrentId" in MESSAGES_JS, \
|
||||
"_approvalCurrentId must track the approval_id of the currently displayed card"
|
||||
assert "_approvalCurrentId = pending.approval_id" in MESSAGES_JS or \
|
||||
"_approvalCurrentId = pending.approval_id || null" in MESSAGES_JS, \
|
||||
"_approvalCurrentId must be assigned from pending.approval_id"
|
||||
# Must be nulled on respond
|
||||
assert "_approvalCurrentId = null" in MESSAGES_JS, \
|
||||
"_approvalCurrentId must be cleared when respondApproval() is called"
|
||||
|
||||
|
||||
def test_polling_passes_count_to_show():
|
||||
"""The poll loop must pass pending_count to showApprovalCard."""
|
||||
assert "showApprovalCard(data.pending, data.pending_count" in MESSAGES_JS, \
|
||||
"Poll loop must pass data.pending_count to showApprovalCard"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTML: counter element present
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_approval_counter_element_exists():
|
||||
"""index.html must contain an approvalCounter element."""
|
||||
assert 'id="approvalCounter"' in INDEX_HTML, \
|
||||
"index.html must contain an element with id='approvalCounter' for the '1 of N' display"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Functional: multiple entries behave correctly (via routes module directly)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_multiple_approvals_both_surfaced():
|
||||
"""Two submit_pending calls must produce two queued entries, not one."""
|
||||
import threading
|
||||
from api import routes as r
|
||||
|
||||
# Reset state
|
||||
sid = "test-multi-approval-sid"
|
||||
with r._lock:
|
||||
r._pending.pop(sid, None)
|
||||
|
||||
r.submit_pending(sid, {"command": "cmd1", "pattern_key": "p1", "pattern_keys": ["p1"], "description": "d1"})
|
||||
r.submit_pending(sid, {"command": "cmd2", "pattern_key": "p2", "pattern_keys": ["p2"], "description": "d2"})
|
||||
|
||||
with r._lock:
|
||||
queue = r._pending.get(sid)
|
||||
|
||||
assert isinstance(queue, list), "After two submit_pending calls, _pending[sid] must be a list"
|
||||
assert len(queue) == 2, f"Expected 2 queued entries, got {len(queue)}"
|
||||
assert queue[0]["command"] == "cmd1"
|
||||
assert queue[1]["command"] == "cmd2"
|
||||
assert queue[0].get("approval_id"), "First entry must have an approval_id"
|
||||
assert queue[1].get("approval_id"), "Second entry must have an approval_id"
|
||||
assert queue[0]["approval_id"] != queue[1]["approval_id"], "Each entry must have a unique approval_id"
|
||||
|
||||
# Cleanup
|
||||
with r._lock:
|
||||
r._pending.pop(sid, None)
|
||||
|
||||
|
||||
def test_respond_by_approval_id_pops_correct_entry():
|
||||
"""Responding with approval_id must remove only the targeted entry."""
|
||||
from api import routes as r
|
||||
|
||||
sid = "test-respond-by-id-sid"
|
||||
with r._lock:
|
||||
r._pending.pop(sid, None)
|
||||
|
||||
r.submit_pending(sid, {"command": "cmd1", "pattern_key": "p1", "pattern_keys": ["p1"], "description": "d1"})
|
||||
r.submit_pending(sid, {"command": "cmd2", "pattern_key": "p2", "pattern_keys": ["p2"], "description": "d2"})
|
||||
|
||||
with r._lock:
|
||||
queue = r._pending.get(sid, [])
|
||||
aid2 = queue[1]["approval_id"] if len(queue) > 1 else None
|
||||
|
||||
assert aid2, "Second entry must have an approval_id"
|
||||
|
||||
# Respond to the SECOND entry by its approval_id
|
||||
# We call the handler internals directly (no HTTP)
|
||||
with r._lock:
|
||||
queue = r._pending.get(sid, [])
|
||||
popped = None
|
||||
for i, entry in enumerate(queue):
|
||||
if entry.get("approval_id") == aid2:
|
||||
popped = queue.pop(i)
|
||||
break
|
||||
|
||||
assert popped is not None, "Should have found and popped entry by approval_id"
|
||||
assert popped["command"] == "cmd2", "Popped the wrong entry"
|
||||
|
||||
with r._lock:
|
||||
remaining = r._pending.get(sid, [])
|
||||
|
||||
assert len(remaining) == 1, "One entry should remain after popping the second"
|
||||
assert remaining[0]["command"] == "cmd1", "The remaining entry should be cmd1"
|
||||
|
||||
# Cleanup
|
||||
with r._lock:
|
||||
r._pending.pop(sid, None)
|
||||
487
tests/test_approval_sse.py
Normal file
487
tests/test_approval_sse.py
Normal file
@@ -0,0 +1,487 @@
|
||||
"""Tests for the approval SSE (Server-Sent Events) long-connection implementation.
|
||||
|
||||
Verifies:
|
||||
- SSE subscribe/unsubscribe/notify lifecycle
|
||||
- Initial snapshot delivery on connect
|
||||
- Instant push when submit_pending() fires
|
||||
- Client disconnect triggers unsubscribe cleanup
|
||||
- Multiple concurrent subscribers per session
|
||||
- Queue overflow (slow subscriber) drops silently
|
||||
- Cross-session isolation (notify only reaches matching subscribers)
|
||||
- Frontend EventSource / fallback polling patterns
|
||||
"""
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import queue
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
ROUTES_SRC = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8")
|
||||
MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 1. Static-analysis tests (no server needed)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestSSEStaticAnalysis:
|
||||
"""Verify the SSE infrastructure exists and is wired correctly in routes.py."""
|
||||
|
||||
def test_sse_route_registered(self):
|
||||
"""The /api/approval/stream route must be registered."""
|
||||
assert '"/api/approval/stream"' in ROUTES_SRC, \
|
||||
"Route /api/approval/stream must be registered in the URL dispatch"
|
||||
|
||||
def test_sse_handler_function_exists(self):
|
||||
"""_handle_approval_sse_stream handler must exist."""
|
||||
assert "def _handle_approval_sse_stream(" in ROUTES_SRC, \
|
||||
"_handle_approval_sse_stream handler function must exist"
|
||||
|
||||
def test_subscribe_function_exists(self):
|
||||
"""_approval_sse_subscribe must exist and use a Queue."""
|
||||
assert "def _approval_sse_subscribe(" in ROUTES_SRC, \
|
||||
"_approval_sse_subscribe must be defined"
|
||||
|
||||
def test_unsubscribe_function_exists(self):
|
||||
"""_approval_sse_unsubscribe must exist and clean up empty lists."""
|
||||
assert "def _approval_sse_unsubscribe(" in ROUTES_SRC, \
|
||||
"_approval_sse_unsubscribe must be defined"
|
||||
|
||||
def test_notify_function_exists(self):
|
||||
"""_approval_sse_notify must exist and push to subscriber queues."""
|
||||
assert "def _approval_sse_notify(" in ROUTES_SRC, \
|
||||
"_approval_sse_notify must be defined"
|
||||
|
||||
def test_sse_subscribers_dict_exists(self):
|
||||
"""Module-level _approval_sse_subscribers dict must exist."""
|
||||
assert "_approval_sse_subscribers" in ROUTES_SRC, \
|
||||
"_approval_sse_subscribers module-level dict must exist"
|
||||
|
||||
def test_sse_content_type(self):
|
||||
"""SSE handler must set text/event-stream content type."""
|
||||
assert "text/event-stream" in ROUTES_SRC, \
|
||||
"SSE handler must set Content-Type to text/event-stream"
|
||||
|
||||
def test_sse_keepalive(self):
|
||||
"""SSE handler must send keepalive comments to prevent proxy timeout."""
|
||||
assert "keepalive" in ROUTES_SRC, \
|
||||
"SSE handler must send keepalive comments"
|
||||
|
||||
def test_sse_cache_control(self):
|
||||
"""SSE handler must set Cache-Control: no-cache."""
|
||||
assert "no-cache" in ROUTES_SRC, \
|
||||
"SSE handler must set Cache-Control: no-cache"
|
||||
|
||||
def test_sse_initial_snapshot(self):
|
||||
"""SSE handler must send initial snapshot on connect."""
|
||||
assert "'initial'" in ROUTES_SRC, \
|
||||
"SSE handler must send an 'initial' event with snapshot data"
|
||||
|
||||
def test_sse_approval_event(self):
|
||||
"""SSE handler must send 'approval' events on push."""
|
||||
assert "'approval'" in ROUTES_SRC, \
|
||||
"SSE handler must send 'approval' events when pushing notifications"
|
||||
|
||||
def test_notify_called_from_submit_pending(self):
|
||||
"""submit_pending must call _approval_sse_notify_locked."""
|
||||
# Pinned to the inner-lock variant: must run inside the same `with _lock:`
|
||||
# block as the queue mutation so two parallel submit_pending calls can't
|
||||
# deliver out-of-order with stale pending_count. Tracks the v0.50.248
|
||||
# MUST-FIX A fix.
|
||||
assert "_approval_sse_notify_locked(session_key, head, total)" in ROUTES_SRC, \
|
||||
("submit_pending() must call _approval_sse_notify_locked(session_key, head, total) "
|
||||
"from inside the `with _lock:` block — not the unlocked _approval_sse_notify wrapper, "
|
||||
"and head must be queue_list[0] (the head, not the just-appended entry).")
|
||||
|
||||
def test_unsubscribe_in_finally(self):
|
||||
"""SSE handler must unsubscribe in a finally block."""
|
||||
# Find the finally block that calls _approval_sse_unsubscribe
|
||||
assert re.search(r"finally:.*\n.*_approval_sse_unsubscribe\(", ROUTES_SRC, re.DOTALL), \
|
||||
"SSE handler must call _approval_sse_unsubscribe in a finally block"
|
||||
|
||||
def test_client_disconnect_handled(self):
|
||||
"""SSE handler must catch client disconnect errors."""
|
||||
assert "_CLIENT_DISCONNECT_ERRORS" in ROUTES_SRC, \
|
||||
"SSE handler must catch client disconnect errors"
|
||||
|
||||
def test_subscriber_queue_maxsize(self):
|
||||
"""Subscriber queues must have a bounded maxsize to prevent memory leaks."""
|
||||
assert "queue.Queue(maxsize=" in ROUTES_SRC, \
|
||||
"Subscriber queues must have maxsize set to prevent unbounded memory growth"
|
||||
|
||||
def test_notify_drops_on_full(self):
|
||||
"""_approval_sse_notify must silently drop events when subscriber is slow."""
|
||||
# The queue.Full exception handler
|
||||
assert "queue.Full" in ROUTES_SRC, \
|
||||
"_approval_sse_notify must handle queue.Full to drop events for slow subscribers"
|
||||
|
||||
def test_subscribe_uses_shared_lock(self):
|
||||
"""subscribe/unsubscribe/notify must all use the same _lock."""
|
||||
# All three functions must use _lock
|
||||
for func in ["_approval_sse_subscribe", "_approval_sse_unsubscribe", "_approval_sse_notify"]:
|
||||
# Find the function and verify it uses "with _lock"
|
||||
func_start = ROUTES_SRC.find(f"def {func}(")
|
||||
assert func_start != -1, f"{func} must exist"
|
||||
# Find the next function definition after this one
|
||||
next_func = ROUTES_SRC.find("\ndef ", func_start + 1)
|
||||
func_body = ROUTES_SRC[func_start:next_func] if next_func != -1 else ROUTES_SRC[func_start:]
|
||||
assert "with _lock:" in func_body, \
|
||||
f"{func} must use 'with _lock:' for thread safety"
|
||||
|
||||
def test_unsubscribe_cleans_empty_session(self):
|
||||
"""Unsubscribe must remove empty session keys from the dict."""
|
||||
assert "_approval_sse_subscribers.pop(session_id, None)" in ROUTES_SRC, \
|
||||
"_approval_sse_unsubscribe must pop session_id when subscriber list is empty"
|
||||
|
||||
|
||||
class TestFrontendSSEImplementation:
|
||||
"""Verify the frontend JavaScript SSE implementation."""
|
||||
|
||||
def test_eventsource_used(self):
|
||||
"""Frontend must use EventSource for SSE connection."""
|
||||
assert "new EventSource(" in MESSAGES_JS, \
|
||||
"startApprovalPolling must create an EventSource for SSE"
|
||||
|
||||
def test_sse_url_matches_backend(self):
|
||||
"""Frontend SSE URL must match backend /api/approval/stream route."""
|
||||
assert "/api/approval/stream" in MESSAGES_JS, \
|
||||
"EventSource must connect to /api/approval/stream"
|
||||
|
||||
def test_initial_event_listener(self):
|
||||
"""Frontend must listen for 'initial' SSE events."""
|
||||
assert "'initial'" in MESSAGES_JS or '"initial"' in MESSAGES_JS, \
|
||||
"Frontend must addEventListener for 'initial' SSE events"
|
||||
|
||||
def test_approval_event_listener(self):
|
||||
"""Frontend must listen for 'approval' SSE events."""
|
||||
assert "'approval'" in MESSAGES_JS or '"approval"' in MESSAGES_JS, \
|
||||
"Frontend must addEventListener for 'approval' SSE events"
|
||||
|
||||
def test_onerror_fallback_to_polling(self):
|
||||
"""onerror must fall back to HTTP polling."""
|
||||
assert "_startApprovalFallbackPoll" in MESSAGES_JS, \
|
||||
"SSE onerror handler must call _startApprovalFallbackPoll"
|
||||
|
||||
def test_fallback_poll_interval(self):
|
||||
"""Fallback polling interval must match v0.50.247's 1500ms cadence."""
|
||||
assert "1500" in MESSAGES_JS, \
|
||||
"Fallback polling interval must be 1500ms to match degraded-mode parity with v0.50.247"
|
||||
|
||||
def test_fallback_closes_eventsource(self):
|
||||
"""onerror handler must close the EventSource before falling back."""
|
||||
# The onerror handler should call es.close()
|
||||
assert "es.close()" in MESSAGES_JS, \
|
||||
"onerror handler must close the EventSource before falling back"
|
||||
|
||||
def test_stop_closes_eventsource(self):
|
||||
"""stopApprovalPolling must close EventSource."""
|
||||
assert "_approvalEventSource.close()" in MESSAGES_JS, \
|
||||
"stopApprovalPolling must close _approvalEventSource"
|
||||
|
||||
def test_health_timer_cleanup(self):
|
||||
"""stopApprovalPolling must clear the SSE health timer."""
|
||||
assert "_approvalSSEHealthTimer" in MESSAGES_JS, \
|
||||
"SSE health timer must be tracked and cleared in stopApprovalPolling"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 2. Unit tests (in-process, no HTTP server)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestSSESubscribeUnsubscribe:
|
||||
"""Test the subscribe/unsubscribe lifecycle."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Clean SSE subscriber state before each test."""
|
||||
from api import routes as r
|
||||
with r._lock:
|
||||
r._approval_sse_subscribers.clear()
|
||||
|
||||
def teardown_method(self):
|
||||
"""Clean up after each test."""
|
||||
from api import routes as r
|
||||
with r._lock:
|
||||
r._approval_sse_subscribers.clear()
|
||||
|
||||
def test_subscribe_returns_queue(self):
|
||||
"""_approval_sse_subscribe must return a Queue."""
|
||||
from api import routes as r
|
||||
sid = f"sse-test-{uuid.uuid4().hex[:8]}"
|
||||
q = r._approval_sse_subscribe(sid)
|
||||
assert isinstance(q, queue.Queue), "subscribe must return a queue.Queue"
|
||||
# Cleanup
|
||||
r._approval_sse_unsubscribe(sid, q)
|
||||
|
||||
def test_subscribe_registers_subscriber(self):
|
||||
"""After subscribe, the queue must appear in _approval_sse_subscribers."""
|
||||
from api import routes as r
|
||||
sid = f"sse-reg-{uuid.uuid4().hex[:8]}"
|
||||
q = r._approval_sse_subscribe(sid)
|
||||
try:
|
||||
with r._lock:
|
||||
subs = r._approval_sse_subscribers.get(sid, [])
|
||||
assert q in subs, "Subscribed queue must be in the subscribers list"
|
||||
finally:
|
||||
r._approval_sse_unsubscribe(sid, q)
|
||||
|
||||
def test_unsubscribe_removes_queue(self):
|
||||
"""After unsubscribe, the queue must not be in the subscribers list."""
|
||||
from api import routes as r
|
||||
sid = f"sse-unsub-{uuid.uuid4().hex[:8]}"
|
||||
q = r._approval_sse_subscribe(sid)
|
||||
r._approval_sse_unsubscribe(sid, q)
|
||||
with r._lock:
|
||||
subs = r._approval_sse_subscribers.get(sid, [])
|
||||
assert q not in subs, "Unsubscribed queue must not be in the list"
|
||||
|
||||
def test_unsubscribe_removes_empty_session_key(self):
|
||||
"""When the last subscriber is removed, the session key must be cleaned up."""
|
||||
from api import routes as r
|
||||
sid = f"sse-empty-{uuid.uuid4().hex[:8]}"
|
||||
q = r._approval_sse_subscribe(sid)
|
||||
r._approval_sse_unsubscribe(sid, q)
|
||||
with r._lock:
|
||||
assert sid not in r._approval_sse_subscribers, \
|
||||
"Session key must be removed when subscriber list is empty"
|
||||
|
||||
def test_unsubscribe_idempotent(self):
|
||||
"""Unsubscribing twice must not raise."""
|
||||
from api import routes as r
|
||||
sid = f"sse-idem-{uuid.uuid4().hex[:8]}"
|
||||
q = r._approval_sse_subscribe(sid)
|
||||
r._approval_sse_unsubscribe(sid, q)
|
||||
r._approval_sse_unsubscribe(sid, q) # should not raise
|
||||
|
||||
def test_unsubscribe_unknown_queue_noop(self):
|
||||
"""Unsubscribing a queue that was never subscribed must not crash."""
|
||||
from api import routes as r
|
||||
sid = f"sse-noop-{uuid.uuid4().hex[:8]}"
|
||||
q = queue.Queue()
|
||||
r._approval_sse_unsubscribe(sid, q) # should not raise
|
||||
|
||||
|
||||
class TestSSENotify:
|
||||
"""Test the notification mechanism."""
|
||||
|
||||
def setup_method(self):
|
||||
from api import routes as r
|
||||
with r._lock:
|
||||
r._approval_sse_subscribers.clear()
|
||||
|
||||
def teardown_method(self):
|
||||
from api import routes as r
|
||||
with r._lock:
|
||||
r._approval_sse_subscribers.clear()
|
||||
|
||||
def test_notify_delivers_payload(self):
|
||||
"""_approval_sse_notify must put the payload on subscriber queues."""
|
||||
from api import routes as r
|
||||
sid = f"sse-notify-{uuid.uuid4().hex[:8]}"
|
||||
q = r._approval_sse_subscribe(sid)
|
||||
try:
|
||||
entry = {"command": "rm -rf /tmp/test", "pattern_key": "delete"}
|
||||
r._approval_sse_notify(sid, entry, 1)
|
||||
payload = q.get(timeout=1)
|
||||
assert payload["pending"]["command"] == "rm -rf /tmp/test"
|
||||
assert payload["pending_count"] == 1
|
||||
finally:
|
||||
r._approval_sse_unsubscribe(sid, q)
|
||||
|
||||
def test_notify_multiple_subscribers(self):
|
||||
"""All subscribers for a session must receive the notification."""
|
||||
from api import routes as r
|
||||
sid = f"sse-multi-{uuid.uuid4().hex[:8]}"
|
||||
q1 = r._approval_sse_subscribe(sid)
|
||||
q2 = r._approval_sse_subscribe(sid)
|
||||
q3 = r._approval_sse_subscribe(sid)
|
||||
try:
|
||||
entry = {"command": "test-cmd"}
|
||||
r._approval_sse_notify(sid, entry, 2)
|
||||
for q in [q1, q2, q3]:
|
||||
payload = q.get(timeout=1)
|
||||
assert payload["pending"]["command"] == "test-cmd"
|
||||
assert payload["pending_count"] == 2
|
||||
finally:
|
||||
for q in [q1, q2, q3]:
|
||||
r._approval_sse_unsubscribe(sid, q)
|
||||
|
||||
def test_notify_cross_session_isolation(self):
|
||||
"""Notify for session A must NOT deliver to session B subscribers."""
|
||||
from api import routes as r
|
||||
sid_a = f"sse-iso-a-{uuid.uuid4().hex[:8]}"
|
||||
sid_b = f"sse-iso-b-{uuid.uuid4().hex[:8]}"
|
||||
qa = r._approval_sse_subscribe(sid_a)
|
||||
qb = r._approval_sse_subscribe(sid_b)
|
||||
try:
|
||||
entry = {"command": "only-for-a"}
|
||||
r._approval_sse_notify(sid_a, entry, 1)
|
||||
# qa should have the event
|
||||
payload = qa.get(timeout=1)
|
||||
assert payload["pending"]["command"] == "only-for-a"
|
||||
# qb should be empty
|
||||
assert qb.empty(), "Session B subscriber must not receive session A events"
|
||||
finally:
|
||||
r._approval_sse_unsubscribe(sid_a, qa)
|
||||
r._approval_sse_unsubscribe(sid_b, qb)
|
||||
|
||||
def test_notify_no_subscribers_is_noop(self):
|
||||
"""Notifying a session with no subscribers must not raise."""
|
||||
from api import routes as r
|
||||
sid = f"sse-nosub-{uuid.uuid4().hex[:8]}"
|
||||
r._approval_sse_notify(sid, {"command": "test"}, 1) # should not raise
|
||||
|
||||
def test_notify_drops_on_full_queue(self):
|
||||
"""When subscriber queue is full, events must be silently dropped."""
|
||||
from api import routes as r
|
||||
sid = f"sse-full-{uuid.uuid4().hex[:8]}"
|
||||
q = r._approval_sse_subscribe(sid)
|
||||
try:
|
||||
# Fill the queue (maxsize=16)
|
||||
for i in range(20):
|
||||
r._approval_sse_notify(sid, {"command": f"cmd-{i}"}, i + 1)
|
||||
# Queue should have at most 16 items
|
||||
assert q.qsize() <= 16, "Queue must not exceed maxsize"
|
||||
assert q.qsize() > 0, "Queue should have some items"
|
||||
finally:
|
||||
r._approval_sse_unsubscribe(sid, q)
|
||||
|
||||
|
||||
class TestSSENotifyFromSubmitPending:
|
||||
"""Test that submit_pending triggers SSE notifications."""
|
||||
|
||||
def setup_method(self):
|
||||
from api import routes as r
|
||||
with r._lock:
|
||||
r._approval_sse_subscribers.clear()
|
||||
r._pending.clear()
|
||||
|
||||
def teardown_method(self):
|
||||
from api import routes as r
|
||||
with r._lock:
|
||||
r._approval_sse_subscribers.clear()
|
||||
r._pending.clear()
|
||||
|
||||
def test_submit_pending_notifies_sse_subscriber(self):
|
||||
"""submit_pending must push an SSE event to subscribers."""
|
||||
from api import routes as r
|
||||
sid = f"sse-submit-{uuid.uuid4().hex[:8]}"
|
||||
q = r._approval_sse_subscribe(sid)
|
||||
try:
|
||||
r.submit_pending(sid, {
|
||||
"command": "rm -rf /tmp/test",
|
||||
"pattern_key": "recursive delete",
|
||||
"pattern_keys": ["recursive delete"],
|
||||
"description": "recursive delete",
|
||||
})
|
||||
payload = q.get(timeout=1)
|
||||
assert payload["pending"]["command"] == "rm -rf /tmp/test"
|
||||
assert payload["pending_count"] == 1
|
||||
finally:
|
||||
r._approval_sse_unsubscribe(sid, q)
|
||||
|
||||
def test_submit_pending_delivers_count(self):
|
||||
"""Multiple submit_pending calls must report correct pending_count."""
|
||||
from api import routes as r
|
||||
sid = f"sse-count-{uuid.uuid4().hex[:8]}"
|
||||
q = r._approval_sse_subscribe(sid)
|
||||
try:
|
||||
for i in range(3):
|
||||
r.submit_pending(sid, {
|
||||
"command": f"cmd-{i}",
|
||||
"pattern_key": f"p{i}",
|
||||
"pattern_keys": [f"p{i}"],
|
||||
"description": f"d{i}",
|
||||
})
|
||||
for expected_count in [1, 2, 3]:
|
||||
payload = q.get(timeout=1)
|
||||
assert payload["pending_count"] == expected_count, \
|
||||
f"Expected pending_count={expected_count}, got {payload['pending_count']}"
|
||||
finally:
|
||||
r._approval_sse_unsubscribe(sid, q)
|
||||
|
||||
|
||||
class TestSSEConcurrency:
|
||||
"""Test thread safety of SSE subscribe/unsubscribe/notify."""
|
||||
|
||||
def setup_method(self):
|
||||
from api import routes as r
|
||||
with r._lock:
|
||||
r._approval_sse_subscribers.clear()
|
||||
r._pending.clear()
|
||||
|
||||
def teardown_method(self):
|
||||
from api import routes as r
|
||||
with r._lock:
|
||||
r._approval_sse_subscribers.clear()
|
||||
r._pending.clear()
|
||||
|
||||
def test_concurrent_subscribe_unsubscribe(self):
|
||||
"""Concurrent subscribe/unsubscribe must not corrupt state."""
|
||||
from api import routes as r
|
||||
sid = f"sse-conc-{uuid.uuid4().hex[:8]}"
|
||||
errors = []
|
||||
queues = []
|
||||
|
||||
def worker():
|
||||
try:
|
||||
for _ in range(50):
|
||||
q = r._approval_sse_subscribe(sid)
|
||||
queues.append(q)
|
||||
r._approval_sse_unsubscribe(sid, q)
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=worker) for _ in range(4)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=10)
|
||||
|
||||
assert not errors, f"Concurrent subscribe/unsubscribe errors: {errors}"
|
||||
# After all threads finish, no queues should remain
|
||||
with r._lock:
|
||||
subs = r._approval_sse_subscribers.get(sid, [])
|
||||
assert len(subs) == 0, "All subscribers should be cleaned up"
|
||||
|
||||
def test_concurrent_notify_while_subscribing(self):
|
||||
"""Notify while new subscribers are joining must not deadlock or crash."""
|
||||
from api import routes as r
|
||||
sid = f"sse-notsub-{uuid.uuid4().hex[:8]}"
|
||||
errors = []
|
||||
|
||||
def notifier():
|
||||
try:
|
||||
for i in range(100):
|
||||
r._approval_sse_notify(sid, {"command": f"cmd-{i}"}, 1)
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
def subscriber():
|
||||
try:
|
||||
for _ in range(50):
|
||||
q = r._approval_sse_subscribe(sid)
|
||||
time.sleep(0.001)
|
||||
r._approval_sse_unsubscribe(sid, q)
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=notifier),
|
||||
threading.Thread(target=subscriber),
|
||||
threading.Thread(target=subscriber),
|
||||
]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=15)
|
||||
|
||||
assert not errors, f"Concurrent notify/subscribe errors: {errors}"
|
||||
with r._lock:
|
||||
r._approval_sse_subscribers.clear()
|
||||
288
tests/test_approval_unblock.py
Normal file
288
tests/test_approval_unblock.py
Normal file
@@ -0,0 +1,288 @@
|
||||
"""
|
||||
Tests for fix/approval-stuck-thinking:
|
||||
Verify that /api/approval/respond correctly unblocks gateway approval queues
|
||||
and that the approval module exports the symbols streaming.py and routes.py
|
||||
need to prevent the UI getting stuck in "Thinking…" during dangerous commands.
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import uuid
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
|
||||
import pytest
|
||||
|
||||
# Import approval internals — shared module-level state within this process.
|
||||
# The HTTP tests use the test server (port 8788, separate process).
|
||||
# The unit tests operate directly on the module.
|
||||
try:
|
||||
from tools.approval import (
|
||||
register_gateway_notify,
|
||||
unregister_gateway_notify,
|
||||
resolve_gateway_approval,
|
||||
_gateway_queues,
|
||||
_gateway_notify_cbs,
|
||||
_lock,
|
||||
_ApprovalEntry,
|
||||
submit_pending,
|
||||
)
|
||||
# has_pending and pop_pending were removed from tools.approval when the
|
||||
# agent renamed has_pending -> has_blocking_approval (gateway queue check)
|
||||
# and removed the polling-mode pop_pending. Routes now check _pending
|
||||
# directly. These symbols are no longer part of the public API.
|
||||
APPROVAL_AVAILABLE = True
|
||||
except ImportError:
|
||||
APPROVAL_AVAILABLE = False
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not APPROVAL_AVAILABLE,
|
||||
reason="tools.approval not available in this environment"
|
||||
)
|
||||
|
||||
from tests._pytest_port import BASE
|
||||
|
||||
|
||||
def get(path):
|
||||
url = BASE + path
|
||||
with urllib.request.urlopen(url, timeout=10) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
def post(path, body=None):
|
||||
url = BASE + path
|
||||
data = json.dumps(body or {}).encode()
|
||||
req = urllib.request.Request(url, data=data,
|
||||
headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status
|
||||
except urllib.error.HTTPError as e:
|
||||
return json.loads(e.read()), e.code
|
||||
|
||||
|
||||
# ── Unit tests (in-process, no HTTP server needed) ──────────────────────────
|
||||
|
||||
class TestGatewayApprovalUnblocking:
|
||||
"""Unit tests for the gateway queue unblocking mechanism."""
|
||||
|
||||
def test_resolve_gateway_approval_sets_event(self):
|
||||
"""resolve_gateway_approval() must set the entry's event and store the result."""
|
||||
sid = f"unit-resolve-{uuid.uuid4().hex[:8]}"
|
||||
data = {"command": "rm -rf /tmp/x", "description": "recursive delete"}
|
||||
entry = _ApprovalEntry(data)
|
||||
with _lock:
|
||||
_gateway_queues.setdefault(sid, []).append(entry)
|
||||
|
||||
resolved = resolve_gateway_approval(sid, "once", resolve_all=False)
|
||||
assert resolved == 1
|
||||
assert entry.event.is_set()
|
||||
assert entry.result == "once"
|
||||
|
||||
# Queue should be cleaned up
|
||||
with _lock:
|
||||
assert sid not in _gateway_queues
|
||||
|
||||
def test_resolve_gateway_approval_deny(self):
|
||||
"""Deny choice is propagated correctly."""
|
||||
sid = f"unit-deny-{uuid.uuid4().hex[:8]}"
|
||||
entry = _ApprovalEntry({"command": "pkill -9 x", "description": "force kill"})
|
||||
with _lock:
|
||||
_gateway_queues.setdefault(sid, []).append(entry)
|
||||
|
||||
resolve_gateway_approval(sid, "deny")
|
||||
assert entry.result == "deny"
|
||||
|
||||
def test_resolve_gateway_approval_no_queue_is_harmless(self):
|
||||
"""resolve_gateway_approval with no queue entry returns 0, no crash."""
|
||||
sid = f"unit-no-queue-{uuid.uuid4().hex[:8]}"
|
||||
result = resolve_gateway_approval(sid, "once")
|
||||
assert result == 0
|
||||
|
||||
def test_resolve_all_unblocks_multiple_entries(self):
|
||||
"""resolve_all=True unblocks every pending entry in the queue."""
|
||||
sid = f"unit-resolve-all-{uuid.uuid4().hex[:8]}"
|
||||
entries = [_ApprovalEntry({"command": f"cmd{i}"}) for i in range(3)]
|
||||
with _lock:
|
||||
_gateway_queues[sid] = list(entries)
|
||||
|
||||
resolved = resolve_gateway_approval(sid, "session", resolve_all=True)
|
||||
assert resolved == 3
|
||||
for e in entries:
|
||||
assert e.event.is_set()
|
||||
assert e.result == "session"
|
||||
|
||||
def test_register_and_fire_notify_cb(self):
|
||||
"""register_gateway_notify stores the cb; calling it delivers approval data."""
|
||||
sid = f"unit-notify-{uuid.uuid4().hex[:8]}"
|
||||
fired = []
|
||||
register_gateway_notify(sid, lambda d: fired.append(d))
|
||||
|
||||
with _lock:
|
||||
cb = _gateway_notify_cbs.get(sid)
|
||||
assert cb is not None
|
||||
|
||||
data = {"command": "test", "description": "test"}
|
||||
cb(data)
|
||||
assert fired == [data]
|
||||
|
||||
unregister_gateway_notify(sid)
|
||||
|
||||
def test_unregister_clears_cb_and_signals_entries(self):
|
||||
"""unregister_gateway_notify removes cb and unblocks any queued entries."""
|
||||
sid = f"unit-unreg-{uuid.uuid4().hex[:8]}"
|
||||
register_gateway_notify(sid, lambda d: None)
|
||||
|
||||
entry = _ApprovalEntry({"command": "x"})
|
||||
with _lock:
|
||||
_gateway_queues.setdefault(sid, []).append(entry)
|
||||
|
||||
unregister_gateway_notify(sid)
|
||||
|
||||
assert entry.event.is_set(), "unregister should signal blocked entries"
|
||||
with _lock:
|
||||
assert sid not in _gateway_notify_cbs
|
||||
assert sid not in _gateway_queues
|
||||
|
||||
def test_streaming_approval_integration(self):
|
||||
"""
|
||||
End-to-end unit simulation of the streaming.py fix:
|
||||
1. streaming.py registers notify_cb
|
||||
2. check_all_command_guards fires notify_cb (pushing approval SSE)
|
||||
3. User responds — resolve_gateway_approval unblocks agent thread
|
||||
4. Agent thread sees choice and continues
|
||||
"""
|
||||
sid = f"unit-e2e-{uuid.uuid4().hex[:8]}"
|
||||
approval_events_sent = []
|
||||
|
||||
# Step 1: streaming.py registers the notify callback
|
||||
def _approval_notify_cb(approval_data):
|
||||
approval_events_sent.append(approval_data) # would be put('approval', ...)
|
||||
register_gateway_notify(sid, _approval_notify_cb)
|
||||
|
||||
# Step 2: check_all_command_guards fires the callback and queues an entry
|
||||
approval_data = {
|
||||
"command": "rm -rf /tmp/test",
|
||||
"pattern_key": "recursive delete",
|
||||
"pattern_keys": ["recursive delete"],
|
||||
"description": "recursive delete",
|
||||
}
|
||||
entry = _ApprovalEntry(approval_data)
|
||||
with _lock:
|
||||
_gateway_queues.setdefault(sid, []).append(entry)
|
||||
# notify_cb fires synchronously (gateway notifies user)
|
||||
with _lock:
|
||||
cb = _gateway_notify_cbs.get(sid)
|
||||
cb(approval_data)
|
||||
|
||||
assert len(approval_events_sent) == 1, "approval SSE event should have been queued"
|
||||
|
||||
# Step 3: user responds via /api/approval/respond → resolve_gateway_approval
|
||||
resolved = resolve_gateway_approval(sid, "once")
|
||||
assert resolved == 1
|
||||
|
||||
# Step 4: agent thread is unblocked with the correct choice
|
||||
assert entry.event.is_set()
|
||||
assert entry.result == "once"
|
||||
|
||||
# Cleanup
|
||||
unregister_gateway_notify(sid)
|
||||
|
||||
|
||||
# ── Symbol existence tests ───────────────────────────────────────────────────
|
||||
|
||||
class TestApprovalModuleExports:
|
||||
"""Verify the module exports all symbols that streaming.py and routes.py need."""
|
||||
|
||||
def test_register_gateway_notify_exported(self):
|
||||
import tools.approval as ap
|
||||
assert hasattr(ap, "register_gateway_notify"), \
|
||||
"tools.approval must export register_gateway_notify"
|
||||
|
||||
def test_unregister_gateway_notify_exported(self):
|
||||
import tools.approval as ap
|
||||
assert hasattr(ap, "unregister_gateway_notify"), \
|
||||
"tools.approval must export unregister_gateway_notify"
|
||||
|
||||
def test_resolve_gateway_approval_exported(self):
|
||||
import tools.approval as ap
|
||||
assert hasattr(ap, "resolve_gateway_approval"), \
|
||||
"tools.approval must export resolve_gateway_approval"
|
||||
|
||||
def test_approval_entry_exported(self):
|
||||
import tools.approval as ap
|
||||
assert hasattr(ap, "_ApprovalEntry"), \
|
||||
"tools.approval must export _ApprovalEntry"
|
||||
|
||||
|
||||
# ── HTTP regression tests (test server, port 8788) ───────────────────────────
|
||||
|
||||
class TestApprovalHTTPEndpoints:
|
||||
"""
|
||||
Regression tests for /api/approval/respond against the live test server.
|
||||
These verify that the HTTP layer behaves correctly — they don't rely on
|
||||
in-process module state shared with the server subprocess.
|
||||
"""
|
||||
|
||||
def test_respond_returns_ok_no_pending(self):
|
||||
"""respond with no pending entry returns ok (no crash, no 500)."""
|
||||
sid = f"http-no-pending-{uuid.uuid4().hex[:8]}"
|
||||
result, status = post("/api/approval/respond", {
|
||||
"session_id": sid,
|
||||
"choice": "deny",
|
||||
})
|
||||
assert status == 200
|
||||
assert result["ok"] is True
|
||||
|
||||
def test_respond_clears_injected_pending(self):
|
||||
"""Inject a pending entry, respond, verify it's cleared."""
|
||||
sid = f"http-clear-{uuid.uuid4().hex[:8]}"
|
||||
cmd = "rm -rf /tmp/testdir"
|
||||
|
||||
inject = get(f"/api/approval/inject_test?session_id={urllib.parse.quote(sid)}"
|
||||
f"&pattern_key=recursive+delete&command={urllib.parse.quote(cmd)}")
|
||||
assert inject["ok"] is True
|
||||
|
||||
data = get(f"/api/approval/pending?session_id={urllib.parse.quote(sid)}")
|
||||
assert data["pending"] is not None
|
||||
|
||||
result, status = post("/api/approval/respond", {
|
||||
"session_id": sid,
|
||||
"choice": "deny",
|
||||
})
|
||||
assert status == 200
|
||||
assert result["ok"] is True
|
||||
|
||||
data2 = get(f"/api/approval/pending?session_id={urllib.parse.quote(sid)}")
|
||||
assert data2["pending"] is None, "pending should be cleared after respond"
|
||||
|
||||
def test_respond_rejects_invalid_choice(self):
|
||||
"""respond with an unknown choice returns 400."""
|
||||
result, status = post("/api/approval/respond", {
|
||||
"session_id": "some-session",
|
||||
"choice": "INVALID",
|
||||
})
|
||||
assert status == 400
|
||||
|
||||
def test_respond_requires_session_id(self):
|
||||
"""respond without session_id returns 400."""
|
||||
result, status = post("/api/approval/respond", {"choice": "deny"})
|
||||
assert status == 400
|
||||
|
||||
def test_respond_session_choice_clears_pending(self):
|
||||
"""Inject pending, respond with 'session', verify cleared."""
|
||||
sid = f"http-session-{uuid.uuid4().hex[:8]}"
|
||||
inject = get(f"/api/approval/inject_test?session_id={urllib.parse.quote(sid)}"
|
||||
f"&pattern_key=force+kill+processes&command=pkill+-9+something")
|
||||
assert inject["ok"] is True
|
||||
|
||||
result, status = post("/api/approval/respond", {
|
||||
"session_id": sid,
|
||||
"choice": "session",
|
||||
})
|
||||
assert status == 200
|
||||
assert result["choice"] == "session"
|
||||
|
||||
data = get(f"/api/approval/pending?session_id={urllib.parse.quote(sid)}")
|
||||
assert data["pending"] is None
|
||||
107
tests/test_auth_session_persistence.py
Normal file
107
tests/test_auth_session_persistence.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Regression tests: auth sessions persist across process restarts.
|
||||
|
||||
_sessions is an in-memory dict. Without persistence, any restart (launchd,
|
||||
systemd, container) invalidates all active browser sessions and floods clients
|
||||
with 401s until they clear cookies. The HMAC signing key already persists to
|
||||
STATE_DIR; this PR persists the session table using the same pattern.
|
||||
"""
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
# Isolate state dir so tests never touch real sessions
|
||||
_TEST_STATE = Path(tempfile.mkdtemp())
|
||||
os.environ["HERMES_WEBUI_STATE_DIR"] = str(_TEST_STATE)
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
import api.auth as auth
|
||||
|
||||
|
||||
class TestSessionPersistence(unittest.TestCase):
|
||||
"""Sessions survive a simulated process restart (module reload)."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
auth._sessions.clear()
|
||||
sessions_file = _TEST_STATE / '.sessions.json'
|
||||
if sessions_file.exists():
|
||||
sessions_file.unlink()
|
||||
|
||||
def _simulate_restart(self) -> None:
|
||||
"""Reload auth module to simulate a fresh process start.
|
||||
|
||||
api.auth does `from api.config import STATE_DIR` at module level, so
|
||||
`_SESSIONS_FILE` is computed from api.config.STATE_DIR at reload time.
|
||||
We temporarily override api.config.STATE_DIR so the reload uses the
|
||||
test state dir without reloading api.config itself (which would
|
||||
invalidate imported references like STREAM_PARTIAL_TEXT in other tests).
|
||||
"""
|
||||
import api.config as _config
|
||||
_saved = _config.STATE_DIR
|
||||
_config.STATE_DIR = _TEST_STATE
|
||||
try:
|
||||
importlib.reload(auth)
|
||||
finally:
|
||||
_config.STATE_DIR = _saved
|
||||
|
||||
def test_session_survives_restart(self) -> None:
|
||||
"""A session created before restart should still verify after reload."""
|
||||
cookie = auth.create_session()
|
||||
self.assertTrue(auth.verify_session(cookie))
|
||||
self._simulate_restart()
|
||||
self.assertTrue(auth.verify_session(cookie),
|
||||
"Session must survive process restart via persisted .sessions.json")
|
||||
|
||||
def test_invalidated_session_does_not_survive_restart(self) -> None:
|
||||
"""Invalidating a session must be reflected after reload."""
|
||||
cookie = auth.create_session()
|
||||
auth.invalidate_session(cookie)
|
||||
self._simulate_restart()
|
||||
self.assertFalse(auth.verify_session(cookie),
|
||||
"Invalidated session must not be reinstated after restart")
|
||||
|
||||
def test_expired_sessions_pruned_on_load(self) -> None:
|
||||
"""Sessions that expire between restarts must not be loaded."""
|
||||
sessions_file = _TEST_STATE / '.sessions.json'
|
||||
# Write a sessions file with one expired and one valid entry
|
||||
now = time.time()
|
||||
sessions_file.write_text(json.dumps({
|
||||
"expired_token": now - 10,
|
||||
"valid_token": now + 3600,
|
||||
}))
|
||||
self._simulate_restart()
|
||||
self.assertNotIn("expired_token", auth._sessions)
|
||||
self.assertIn("valid_token", auth._sessions)
|
||||
|
||||
def test_sessions_file_permissions(self) -> None:
|
||||
"""Sessions file must be owner-read-only (0600)."""
|
||||
auth.create_session()
|
||||
sessions_file = _TEST_STATE / '.sessions.json'
|
||||
self.assertTrue(sessions_file.exists(), ".sessions.json was not created")
|
||||
mode = oct(sessions_file.stat().st_mode & 0o777)
|
||||
self.assertEqual(mode, oct(0o600),
|
||||
f".sessions.json permissions {mode} — expected 0o600")
|
||||
|
||||
def test_malformed_sessions_file_starts_fresh(self) -> None:
|
||||
"""A corrupt sessions file must not crash auth — start with empty dict."""
|
||||
sessions_file = _TEST_STATE / '.sessions.json'
|
||||
sessions_file.write_text("not valid json {{{{")
|
||||
self._simulate_restart()
|
||||
self.assertEqual(auth._sessions, {},
|
||||
"Corrupt sessions file must result in empty session dict")
|
||||
|
||||
def test_sessions_file_wrong_type_starts_fresh(self) -> None:
|
||||
"""A sessions file containing a non-dict must be ignored."""
|
||||
sessions_file = _TEST_STATE / '.sessions.json'
|
||||
sessions_file.write_text(json.dumps(["list", "not", "dict"]))
|
||||
self._simulate_restart()
|
||||
self.assertEqual(auth._sessions, {})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
134
tests/test_auth_sessions.py
Normal file
134
tests/test_auth_sessions.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Tests for auth session lifecycle — session creation, verification, expiry,
|
||||
and lazy pruning of expired entries.
|
||||
"""
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
# Isolate state dir so we don't touch real sessions
|
||||
_TEST_STATE = Path(tempfile.mkdtemp())
|
||||
os.environ["HERMES_WEBUI_STATE_DIR"] = str(_TEST_STATE)
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
import importlib
|
||||
|
||||
# Force re-import of auth module so it picks up our TEST_STATE_DIR
|
||||
auth = importlib.import_module("api.auth")
|
||||
|
||||
|
||||
class TestSessionPruning(unittest.TestCase):
|
||||
"""Verify expired session cleanup works correctly."""
|
||||
|
||||
def setUp(self):
|
||||
# Clear any leftover sessions from other tests
|
||||
auth._sessions.clear()
|
||||
|
||||
def test_session_created_valid(self):
|
||||
"""A fresh session token should verify as valid."""
|
||||
token = auth.create_session()
|
||||
self.assertTrue(auth.verify_session(token))
|
||||
|
||||
def test_expired_session_pruned(self):
|
||||
"""Manually inserting an expired entry should be pruned on next verify_session call."""
|
||||
# Insert sessions that have already expired
|
||||
auth._sessions["fake_token"] = time.time() - 100
|
||||
auth._sessions["another_fake"] = time.time() - 50
|
||||
# Insert one valid session (far future)
|
||||
auth._sessions["good_token"] = time.time() + 3600
|
||||
|
||||
# _sessions has 3 entries, 2 expired
|
||||
self.assertEqual(len(auth._sessions), 3)
|
||||
|
||||
# Call verify_session — this triggers _prune_expired_sessions()
|
||||
# Cookie format is token.signature, so we need a dot to pass the early check
|
||||
auth.verify_session("fake_token.fake_sig")
|
||||
|
||||
# After verification, only the valid session should remain
|
||||
self.assertEqual(len(auth._sessions), 1)
|
||||
self.assertIn("good_token", auth._sessions)
|
||||
self.assertNotIn("fake_token", auth._sessions)
|
||||
self.assertNotIn("another_fake", auth._sessions)
|
||||
|
||||
def test_prune_does_not_remove_valid_sessions(self):
|
||||
"""_prune_expired_sessions should never remove sessions that are still active."""
|
||||
auth._sessions["active_1"] = time.time() + 86400 # 24 hours from now
|
||||
auth._sessions["active_2"] = time.time() + 7200 # 2 hours from now
|
||||
auth._sessions["expired_1"] = time.time() - 10
|
||||
|
||||
auth._prune_expired_sessions()
|
||||
|
||||
self.assertEqual(len(auth._sessions), 2)
|
||||
self.assertIn("active_1", auth._sessions)
|
||||
self.assertIn("active_2", auth._sessions)
|
||||
self.assertNotIn("expired_1", auth._sessions)
|
||||
|
||||
def test_verify_session_prunes_before_verification(self):
|
||||
"""verify_session should prune expired entries before checking the target token.
|
||||
|
||||
This ensures that _prune_expired_sessions() is called at the very top
|
||||
of verify_session(), so cleanup happens on every auth check.
|
||||
"""
|
||||
auth._sessions["expired_for_test"] = time.time() - 999
|
||||
|
||||
# verify_session with an invalid cookie triggers the full path:
|
||||
# _prune_expired_sessions -> signature check -> return False
|
||||
result = auth.verify_session("nonexistent.bad_sig")
|
||||
self.assertFalse(result)
|
||||
|
||||
# The expired entry should have been cleaned up
|
||||
self.assertNotIn("expired_for_test", auth._sessions)
|
||||
|
||||
def test_prune_handles_empty_dict(self):
|
||||
"""_prune_expired_sessions should be safe on an empty dict."""
|
||||
auth._sessions.clear()
|
||||
auth._prune_expired_sessions()
|
||||
self.assertEqual(len(auth._sessions), 0)
|
||||
|
||||
def test_session_ttl_is_24_hours(self):
|
||||
"""Newly created sessions should have the expected 24-hour TTL."""
|
||||
auth._sessions.clear()
|
||||
token_hex = auth.create_session().split(".")[0]
|
||||
# The _sessions dict stores token -> expiry_time
|
||||
# We can check the expiry is approximately SESSION_TTL seconds from now
|
||||
# by looking up the raw entry via the token
|
||||
from api.auth import _sessions, SESSION_TTL
|
||||
# find our entry
|
||||
for t, exp in _sessions.items():
|
||||
if t == token_hex:
|
||||
# expiry should be within 5 seconds of now + SESSION_TTL
|
||||
expected = time.time() + SESSION_TTL
|
||||
self.assertAlmostEqual(exp, expected, delta=5)
|
||||
break
|
||||
else:
|
||||
self.fail("Session token not found in _sessions")
|
||||
|
||||
|
||||
class TestSessionInvalidation(unittest.TestCase):
|
||||
"""Test session logout / invalidation."""
|
||||
|
||||
def setUp(self):
|
||||
auth._sessions.clear()
|
||||
|
||||
def test_invalidate_session_removes_token(self):
|
||||
"""Calling invalidate_session should remove the token from _sessions."""
|
||||
token = auth.create_session()
|
||||
self.assertTrue(auth.verify_session(token))
|
||||
|
||||
auth.invalidate_session(token)
|
||||
# Token should be gone
|
||||
self.assertFalse(auth.verify_session(token))
|
||||
|
||||
def test_invalidate_unknown_token_is_safe(self):
|
||||
"""Invalidating a non-existent token should not raise."""
|
||||
auth._sessions.clear()
|
||||
auth.invalidate_session("nonexistent_token")
|
||||
# Should not raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
168
tests/test_auto_compression_card.py
Normal file
168
tests/test_auto_compression_card.py
Normal file
@@ -0,0 +1,168 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _read(relpath: str) -> str:
|
||||
return (ROOT / relpath).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _compressed_listener_block() -> str:
|
||||
src = _read("static/messages.js")
|
||||
start = src.find("source.addEventListener('compressed'")
|
||||
assert start != -1, "compressed SSE listener not found"
|
||||
end = src.find("source.addEventListener('metering'", start)
|
||||
assert end != -1, "metering listener after compressed SSE listener not found"
|
||||
return src[start:end]
|
||||
|
||||
|
||||
def test_auto_compression_sse_uses_transient_card_not_fake_message():
|
||||
"""Auto compression must not inject display-only text into S.messages."""
|
||||
src = _read("static/messages.js")
|
||||
block = _compressed_listener_block()
|
||||
|
||||
assert "*[Context was auto-compressed to continue the conversation]*" not in src
|
||||
assert "S.messages.push" not in block
|
||||
assert "setCompressionUi" in block
|
||||
assert "phase:'done'" in block
|
||||
assert "automatic:true" in block
|
||||
assert "_setCompressionSessionLock" in block
|
||||
|
||||
|
||||
def test_auto_compression_sse_keeps_inactive_and_malformed_paths_safe():
|
||||
block = _compressed_listener_block()
|
||||
|
||||
guard = "if(!S.session||S.session.session_id!==activeSid) return;"
|
||||
assert guard in block
|
||||
assert block.index(guard) < block.index("setCompressionUi")
|
||||
assert "try{ d=JSON.parse(e.data||'{}')||{}; }catch(_){ d={}; }" in block
|
||||
|
||||
|
||||
def test_auto_compression_card_reuses_compression_card_renderer():
|
||||
src = _read("static/ui.js")
|
||||
start = src.find("function _autoCompressionCardsHtml")
|
||||
assert start != -1, "auto compression card helper not found"
|
||||
end = src.find("function _compressionCardsNode", start)
|
||||
assert end != -1, "compression cards node helper not found after auto helper"
|
||||
helper = src[start:end]
|
||||
|
||||
assert "if(state.automatic) return _autoCompressionCardsHtml(state);" in src
|
||||
assert "tool-card-row compression-card-row" in helper
|
||||
assert "tool-card-compress-complete tool-card-compress-auto" in helper
|
||||
assert "auto_compress_label" in helper
|
||||
|
||||
|
||||
def test_auto_compression_card_survives_compression_session_rotation():
|
||||
src = _read("static/messages.js")
|
||||
|
||||
assert "window._compressionUi.sessionId===activeSid" in src
|
||||
assert "sessionId:d.session.session_id" in src
|
||||
|
||||
|
||||
def test_preserved_task_list_marker_is_detected_case_insensitively():
|
||||
src = _read("static/ui.js")
|
||||
marker = "[your active task list was preserved across context compression]"
|
||||
start = src.find("function _isPreservedCompressionTaskListMessage")
|
||||
assert start != -1, "preserved task list detector not found"
|
||||
end = src.find("function _preservedCompressionTaskListPreview", start)
|
||||
assert end != -1, "preserved task list preview helper not found after detector"
|
||||
detector = src[start:end]
|
||||
|
||||
assert "m.role!=='user'" in detector
|
||||
assert marker.strip("[]") in detector.lower()
|
||||
assert ".test(text)" in detector
|
||||
assert "/i.test" in detector
|
||||
|
||||
|
||||
def test_context_compaction_marker_is_detected_across_roles():
|
||||
src = _read("static/ui.js")
|
||||
start = src.find("function _isContextCompactionMessage")
|
||||
assert start != -1, "context compaction detector not found"
|
||||
end = src.find("function _isPreservedCompressionTaskListMessage", start)
|
||||
assert end != -1, "preserved task list detector not found after context detector"
|
||||
detector = src[start:end]
|
||||
|
||||
assert "m.role==='tool'" in detector
|
||||
assert "m.role!=='assistant'" not in detector
|
||||
assert "[context compaction" in detector.lower()
|
||||
assert "context compaction" in detector.lower()
|
||||
|
||||
|
||||
def test_context_compaction_branch_precedes_user_bubble_branch():
|
||||
src = _read("static/ui.js")
|
||||
loop_start = src.find("for(let vi=0;vi<visWithIdx.length;vi++)")
|
||||
assert loop_start != -1, "message render loop not found"
|
||||
loop_end = src.find("if(!currentAssistantTurn)", loop_start)
|
||||
assert loop_end != -1, "assistant render branch not found after context branch"
|
||||
render_prefix = src[loop_start:loop_end]
|
||||
|
||||
context_idx = render_prefix.find("if(_isContextCompactionMessage(m))")
|
||||
user_idx = render_prefix.find("if(isUser)")
|
||||
assert context_idx != -1, "context compaction render branch not found"
|
||||
assert user_idx != -1, "normal user bubble render branch not found"
|
||||
assert context_idx < user_idx
|
||||
assert "_contextCompactionMessageHtml(m, tsTitle, preservedForThisCard)" in render_prefix
|
||||
|
||||
|
||||
def test_preserved_task_list_skips_normal_visible_message_path():
|
||||
src = _read("static/ui.js")
|
||||
|
||||
visible_filter_start = src.find("const vis=S.messages.filter")
|
||||
assert visible_filter_start != -1, "visible message filter not found"
|
||||
visible_filter_end = src.find("$('emptyState')", visible_filter_start)
|
||||
assert visible_filter_end != -1, "empty state update after visible filter not found"
|
||||
visible_filter = src[visible_filter_start:visible_filter_end]
|
||||
assert "if(_isContextCompactionMessage(m)) return false;" in visible_filter
|
||||
assert "if(_isPreservedCompressionTaskListMessage(m)) return false;" in visible_filter
|
||||
|
||||
vis_idx_start = src.find("for(const m of S.messages)", visible_filter_end)
|
||||
assert vis_idx_start != -1, "raw message index loop not found"
|
||||
vis_idx_end = src.find("let lastUserRawIdx", vis_idx_start)
|
||||
assert vis_idx_end != -1, "last user index lookup after raw message loop not found"
|
||||
vis_idx_loop = src[vis_idx_start:vis_idx_end]
|
||||
assert "if(_isPreservedCompressionTaskListMessage(m))" in vis_idx_loop
|
||||
assert "preservedCompressionRawIdxs.push(rawIdx)" in vis_idx_loop
|
||||
assert "continue;" in vis_idx_loop
|
||||
|
||||
|
||||
def test_preserved_task_list_renders_through_compression_card_path():
|
||||
src = _read("static/ui.js")
|
||||
start = src.find("function _preservedCompressionTaskListCardHtml")
|
||||
assert start != -1, "preserved task list card helper not found"
|
||||
end = src.find("function _preservedCompressionTaskListCardsHtml", start)
|
||||
assert end != -1, "preserved task list card list helper not found"
|
||||
helper = src[start:end]
|
||||
|
||||
assert "_compressionStatusCardHtml" in helper
|
||||
assert "preserved_task_list_label" in helper
|
||||
assert "tool-card-compress-reference" in helper
|
||||
assert "data-compression-card=\"1\"" in helper
|
||||
assert "li('list-todo',13)" in helper
|
||||
assert "_contextCompactionMessageHtml(m, tsTitle, preservedForThisCard)" in src
|
||||
|
||||
|
||||
def test_preserved_task_list_attaches_once_per_render():
|
||||
src = _read("static/ui.js")
|
||||
|
||||
assert "function _latestPreservedCompressionTaskListMessages" in src
|
||||
assert ".reverse().find(m=>_isPreservedCompressionTaskListMessage(m))" in src
|
||||
assert "const preservedCompressionTaskMessages=_latestPreservedCompressionTaskListMessages(S.messages);" in src
|
||||
assert "S.messages.filter(m=>_isPreservedCompressionTaskListMessage(m))" not in src
|
||||
assert "let preservedCompressionTaskCardsAttached=!!referenceNode;" in src
|
||||
assert "const preservedForThisCard=preservedCompressionTaskCardsAttached?[]:preservedCompressionTaskMessages;" in src
|
||||
assert "if(preservedForThisCard.length) preservedCompressionTaskCardsAttached=true;" in src
|
||||
assert "(!preservedCompressionTaskCardsAttached&&(!referenceMessage||compressionState)&&preservedCompressionTaskMessages.length)" in src
|
||||
|
||||
|
||||
def test_preserved_task_list_rendering_does_not_mutate_history():
|
||||
src = _read("static/ui.js")
|
||||
start = src.find("function _isPreservedCompressionTaskListMessage")
|
||||
assert start != -1, "preserved task list detector not found"
|
||||
end = src.find("function _isSameLocalDay", start)
|
||||
assert end != -1, "end of preserved task list render helpers not found"
|
||||
preserved_helpers = src[start:end]
|
||||
|
||||
assert "S.messages" not in preserved_helpers
|
||||
assert ".splice(" not in preserved_helpers
|
||||
assert "delete " not in preserved_helpers
|
||||
148
tests/test_background_tasks.py
Normal file
148
tests/test_background_tasks.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Regression tests for the /background task tracker.
|
||||
|
||||
Covers two bugs caught in review of PR #932:
|
||||
|
||||
1. `get_results()` was calling `_BACKGROUND_TASKS.pop(parent_sid, [])`, which
|
||||
removed EVERY task (including still-running ones) on the first poll. Once
|
||||
popped, `complete_background()` could no longer find the task to mark done,
|
||||
so the final answer was silently lost.
|
||||
|
||||
2. The `_handle_background` worker thread called `_run_agent_streaming` but
|
||||
never invoked `complete_background()` after it returned. With no completion
|
||||
hook, every background task stayed in `status="running"` forever —
|
||||
`get_results()` filtered them out of its "done" list, and the user never
|
||||
saw the result.
|
||||
|
||||
These two bugs together made the `/background` command completely
|
||||
non-functional as originally shipped. The fix in api/background.py +
|
||||
api/routes.py wires the completion hook and keeps running tasks in the
|
||||
tracker until they resolve.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
# Ensure the repo root is importable without relying on CWD.
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
|
||||
class TestGetResultsKeepsRunningTasks(unittest.TestCase):
|
||||
"""get_results() MUST NOT drop still-running tasks from _BACKGROUND_TASKS."""
|
||||
|
||||
def setUp(self):
|
||||
import api.background as bg
|
||||
bg._BACKGROUND_TASKS.clear()
|
||||
self.bg = bg
|
||||
|
||||
def test_running_tasks_survive_get_results_call(self):
|
||||
"""A running task must remain in the tracker so complete_background()
|
||||
can still find it after the first poll returns."""
|
||||
parent = "parent-session-1"
|
||||
self.bg.track_background(
|
||||
parent_sid=parent, bg_sid="bg-a", stream_id="s-a",
|
||||
task_id="task-a", prompt="long task",
|
||||
)
|
||||
|
||||
# First poll: task is still running, no done results to return
|
||||
results = self.bg.get_results(parent)
|
||||
self.assertEqual(results, [], "no done tasks yet — nothing to return")
|
||||
|
||||
# The running task MUST still be tracked — otherwise the worker
|
||||
# thread's complete_background call cannot find it.
|
||||
remaining = self.bg.get_background_tasks(parent)
|
||||
self.assertEqual(len(remaining), 1, (
|
||||
"get_results dropped the still-running task — subsequent "
|
||||
"complete_background() calls will silently no-op and the "
|
||||
"result will be lost forever"
|
||||
))
|
||||
self.assertEqual(remaining[0]["status"], "running")
|
||||
self.assertEqual(remaining[0]["task_id"], "task-a")
|
||||
|
||||
def test_done_tasks_are_returned_and_removed(self):
|
||||
"""Done tasks are returned and popped; running tasks stay."""
|
||||
parent = "parent-session-2"
|
||||
self.bg.track_background(parent, "bg-done", "s-d", "task-done", "p1")
|
||||
self.bg.track_background(parent, "bg-run", "s-r", "task-run", "p2")
|
||||
self.bg.complete_background(parent, "task-done", "42")
|
||||
|
||||
results = self.bg.get_results(parent)
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0]["task_id"], "task-done")
|
||||
self.assertEqual(results[0]["answer"], "42")
|
||||
|
||||
# Done one is gone; running one is still tracked
|
||||
remaining = self.bg.get_background_tasks(parent)
|
||||
self.assertEqual(len(remaining), 1)
|
||||
self.assertEqual(remaining[0]["task_id"], "task-run")
|
||||
self.assertEqual(remaining[0]["status"], "running")
|
||||
|
||||
def test_complete_after_poll_still_reaches_tracker(self):
|
||||
"""Regression for the original bug: poll → complete → poll must surface
|
||||
the result. Before the fix, the first poll popped the running task and
|
||||
complete_background()'s loop iterated over an empty list."""
|
||||
parent = "parent-session-3"
|
||||
self.bg.track_background(parent, "bg-x", "s-x", "task-x", "slow task")
|
||||
|
||||
# Frontend polls before the task finishes
|
||||
first = self.bg.get_results(parent)
|
||||
self.assertEqual(first, [])
|
||||
|
||||
# Worker thread finishes and calls complete_background
|
||||
self.bg.complete_background(parent, "task-x", "answer!")
|
||||
|
||||
# Next poll must surface the answer
|
||||
second = self.bg.get_results(parent)
|
||||
self.assertEqual(len(second), 1)
|
||||
self.assertEqual(second[0]["task_id"], "task-x")
|
||||
self.assertEqual(second[0]["answer"], "answer!")
|
||||
|
||||
def test_empty_parent_is_cleaned_up(self):
|
||||
"""When all tasks are done and returned, the parent key is removed from the dict."""
|
||||
parent = "parent-session-4"
|
||||
self.bg.track_background(parent, "bg-1", "s-1", "task-1", "p")
|
||||
self.bg.complete_background(parent, "task-1", "ok")
|
||||
self.bg.get_results(parent)
|
||||
self.assertNotIn(parent, self.bg._BACKGROUND_TASKS)
|
||||
|
||||
|
||||
class TestBackgroundCompletionHookWiring(unittest.TestCase):
|
||||
"""Static check: the _handle_background worker thread must call
|
||||
complete_background() after _run_agent_streaming returns. Without this,
|
||||
running tasks stay forever-running and the user never sees the result.
|
||||
"""
|
||||
|
||||
def test_run_bg_and_notify_calls_complete_background(self):
|
||||
"""_handle_background must wrap _run_agent_streaming in a function
|
||||
that subsequently invokes complete_background(parent_sid, task_id, answer)."""
|
||||
routes_src = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8")
|
||||
# Locate the _handle_background function
|
||||
idx = routes_src.find("def _handle_background(")
|
||||
self.assertGreater(idx, -1, "_handle_background() not found in routes.py")
|
||||
# Take a generous window around the function body
|
||||
end = routes_src.find("\ndef ", idx + 1)
|
||||
body = routes_src[idx:end if end > 0 else idx + 3000]
|
||||
|
||||
self.assertIn("complete_background", body, (
|
||||
"_handle_background worker must call complete_background() after "
|
||||
"_run_agent_streaming returns — otherwise the tracker never "
|
||||
"transitions the task to status='done' and /api/background/status "
|
||||
"returns nothing forever. See api/background.py:complete_background."
|
||||
))
|
||||
# Must extract the last assistant message content from the bg session
|
||||
self.assertIn("_run_agent_streaming", body)
|
||||
self.assertIn("Session.load", body, (
|
||||
"_run_bg_and_notify must reload the bg session to extract the "
|
||||
"final assistant reply so complete_background gets an actual answer"
|
||||
))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
247
tests/test_batch_fixes.py
Normal file
247
tests/test_batch_fixes.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""Tests for the batch of fixes from PRs #506-#521 (v0.50.47).
|
||||
|
||||
Covers:
|
||||
- /root workspace unblocking (#510/#521)
|
||||
- Attached-files split guard (#521)
|
||||
- custom_providers model visibility (#515/#519)
|
||||
- Cron skill cache invalidation (#507/#508)
|
||||
- System (auto) theme (#504/#506/#509/#514)
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
REPO = pathlib.Path(__file__).parent.parent
|
||||
|
||||
|
||||
def read(rel):
|
||||
return (REPO / rel).read_text()
|
||||
|
||||
|
||||
# ── Group A: /root workspace ──────────────────────────────────────────────────
|
||||
|
||||
class TestRootWorkspaceUnblocked:
|
||||
|
||||
def test_root_not_in_blocked_system_roots(self):
|
||||
src = read("api/workspace.py")
|
||||
assert "Path('/root')" not in src, (
|
||||
"/root must not be in _BLOCKED_SYSTEM_ROOTS — "
|
||||
"breaks deployments where Hermes runs as root"
|
||||
)
|
||||
|
||||
def test_etc_still_blocked(self):
|
||||
"""Sanity: other dangerous paths remain blocked.
|
||||
|
||||
After the macOS symlink fix, blocked roots are listed as bare strings
|
||||
in a tuple and ``_workspace_blocked_roots()`` materialises both the
|
||||
literal and resolved-canonical Path forms. Assert the source still
|
||||
names ``/etc`` and ``/proc`` as blocked roots.
|
||||
"""
|
||||
src = read("api/workspace.py")
|
||||
assert "'/etc'" in src or 'Path("/etc")' in src or "Path('/etc')" in src
|
||||
assert "'/proc'" in src or 'Path("/proc")' in src or "Path('/proc')" in src
|
||||
|
||||
def test_split_guard_present(self):
|
||||
src = read("api/streaming.py")
|
||||
assert "'\\n\\n[Attached files:' in msg_text" in src, (
|
||||
"base_text split must guard against missing '[Attached files:' "
|
||||
"to avoid empty-string on plain messages"
|
||||
)
|
||||
|
||||
|
||||
# ── Group B: custom_providers visibility ─────────────────────────────────────
|
||||
|
||||
class TestCustomProvidersVisibility:
|
||||
|
||||
def test_has_custom_providers_variable_present(self):
|
||||
src = read("api/config.py")
|
||||
assert "_has_custom_providers" in src, (
|
||||
"_has_custom_providers variable must exist in get_available_models()"
|
||||
)
|
||||
|
||||
def test_discard_custom_conditional_on_no_custom_providers(self):
|
||||
src = read("api/config.py")
|
||||
assert "not _has_custom_providers" in src, (
|
||||
"detected_providers.discard('custom') must be gated on "
|
||||
"'not _has_custom_providers'"
|
||||
)
|
||||
|
||||
def test_custom_providers_isinstance_check(self):
|
||||
src = read("api/config.py")
|
||||
assert "isinstance(_custom_providers_cfg, list)" in src, (
|
||||
"_has_custom_providers must check isinstance(..., list)"
|
||||
)
|
||||
|
||||
|
||||
# ── Group C: cron skill cache ─────────────────────────────────────────────────
|
||||
|
||||
class TestCronSkillCacheInvalidation:
|
||||
|
||||
def _panels_src(self):
|
||||
return read("static/panels.js")
|
||||
|
||||
def test_cache_busted_on_form_open(self):
|
||||
src = self._panels_src()
|
||||
# toggleCronForm should set cache to null unconditionally
|
||||
# openCronCreate() opens the task create form (renamed from toggleCronForm
|
||||
# in the main-view refactor). It must null the skills cache before fetching.
|
||||
m = re.search(
|
||||
r'function openCronCreate\(\)\{.*?_cronSkillsCache\s*=\s*null',
|
||||
src, re.DOTALL
|
||||
)
|
||||
assert m, (
|
||||
"openCronCreate must unconditionally null _cronSkillsCache "
|
||||
"before fetching skills"
|
||||
)
|
||||
|
||||
def test_cache_not_guarded_by_if_on_open(self):
|
||||
src = self._panels_src()
|
||||
# openCronCreate must not gate the fetch behind an if(!_cronSkillsCache) guard.
|
||||
m = re.search(
|
||||
r'function openCronCreate\(\)\{.*?\}',
|
||||
src, re.DOTALL
|
||||
)
|
||||
assert m, "openCronCreate definition not found"
|
||||
assert "if(!_cronSkillsCache)" not in m.group(0), (
|
||||
"openCronCreate should not use 'if(!_cronSkillsCache)' guard — "
|
||||
"cache must always be busted on open"
|
||||
)
|
||||
|
||||
def test_cache_busted_on_skill_save(self):
|
||||
src = self._panels_src()
|
||||
# saveSkillForm() is the handler invoked on skill save (renamed from
|
||||
# submitSkillSave in the main-view refactor; the old name still aliases it).
|
||||
m = re.search(
|
||||
r'async function saveSkillForm\(\).*?_skillsData\s*=\s*null.*?_cronSkillsCache\s*=\s*null',
|
||||
src, re.DOTALL
|
||||
)
|
||||
assert m, (
|
||||
"_cronSkillsCache must be set to null in saveSkillForm() "
|
||||
"right after _skillsData = null"
|
||||
)
|
||||
|
||||
|
||||
# ── Group D: System (auto) theme ──────────────────────────────────────────────
|
||||
|
||||
class TestSystemTheme:
|
||||
|
||||
def test_apply_theme_helper_in_boot_js(self):
|
||||
src = read("static/boot.js")
|
||||
assert "function _applyTheme(" in src, (
|
||||
"_applyTheme helper function must be defined in boot.js"
|
||||
)
|
||||
|
||||
def test_apply_theme_resolves_system(self):
|
||||
src = read("static/boot.js")
|
||||
assert "normalized.theme==='system'" in src or "=== 'system'" in src, (
|
||||
"_applyTheme must branch on 'system' to resolve via matchMedia"
|
||||
)
|
||||
|
||||
def test_apply_theme_uses_matchmedia(self):
|
||||
src = read("static/boot.js")
|
||||
assert "prefers-color-scheme" in src, (
|
||||
"_applyTheme must use matchMedia('(prefers-color-scheme:dark)')"
|
||||
)
|
||||
|
||||
def test_load_settings_calls_apply_theme(self):
|
||||
src = read("static/boot.js")
|
||||
assert "_applyTheme(appearance.theme)" in src, (
|
||||
"loadSettings must call _applyTheme() instead of direct data-theme assignment"
|
||||
)
|
||||
|
||||
def test_system_option_in_theme_picker(self):
|
||||
html = read("static/index.html")
|
||||
assert "_pickTheme('system')" in html, (
|
||||
"Theme picker must include a system theme button"
|
||||
)
|
||||
assert ">System<" in html, (
|
||||
"Theme picker must show 'System' label"
|
||||
)
|
||||
|
||||
def test_theme_picker_uses_pick_theme(self):
|
||||
html = read("static/index.html")
|
||||
assert "_pickTheme(" in html, (
|
||||
"Theme buttons must call _pickTheme()"
|
||||
)
|
||||
|
||||
def test_flicker_script_resolves_system(self):
|
||||
html = read("static/index.html")
|
||||
# The head flicker-prevention IIFE must handle 'system'
|
||||
assert "==='system'" in html or "=== 'system'" in html, (
|
||||
"Flicker-prevention head script must resolve 'system' before setting data-theme"
|
||||
)
|
||||
assert "legacy={slate:['dark','slate']" in html, (
|
||||
"Flicker-prevention head script must normalize legacy theme names on first paint"
|
||||
)
|
||||
|
||||
def test_system_in_commands_themes_list(self):
|
||||
src = read("static/commands.js")
|
||||
assert "'system'" in src, (
|
||||
"/theme command must include 'system' in the valid themes array"
|
||||
)
|
||||
|
||||
def test_commands_uses_apply_theme(self):
|
||||
src = read("static/commands.js")
|
||||
assert "_applyTheme(appearance.theme)" in src, (
|
||||
"cmdTheme must call _applyTheme() with the normalized canonical theme"
|
||||
)
|
||||
|
||||
def test_commands_accept_legacy_theme_aliases(self):
|
||||
src = read("static/commands.js")
|
||||
assert "const legacyThemes=Object.keys(_LEGACY_THEME_MAP||{});" in src, (
|
||||
"cmdTheme must accept legacy theme aliases and map them onto canonical appearance values"
|
||||
)
|
||||
|
||||
def test_panels_reverts_via_apply_theme(self):
|
||||
src = read("static/panels.js")
|
||||
block = re.search(r"function _revertSettingsPreview\(\)\{.*?\n\}", src, re.DOTALL)
|
||||
assert block, "_revertSettingsPreview() should be present"
|
||||
assert "_applyTheme(" not in block.group(0), (
|
||||
"_revertSettingsPreview must no longer call _applyTheme() since Appearance now autosaves"
|
||||
)
|
||||
|
||||
def test_system_theme_apply_path_uses_apply_theme(self):
|
||||
src = read("static/boot.js")
|
||||
assert "_applyTheme(appearance.theme)" in src, (
|
||||
"System theme still must be activated through _applyTheme() in boot/theme application"
|
||||
)
|
||||
|
||||
def test_panels_saves_system_string_not_resolved(self):
|
||||
src = read("static/panels.js")
|
||||
assert "localStorage.getItem('hermes-theme')" in src, (
|
||||
"_settingsThemeOnOpen must read from localStorage to preserve "
|
||||
"the 'system' string, not the resolved 'dark'/'light'"
|
||||
)
|
||||
|
||||
def test_i18n_cmd_theme_includes_system_english(self):
|
||||
src = read("static/i18n.js")
|
||||
assert "system/dark/light" in src, (
|
||||
"English cmd_theme i18n key must include 'system' in the theme list"
|
||||
)
|
||||
|
||||
def test_i18n_cmd_theme_all_locales(self):
|
||||
src = read("static/i18n.js")
|
||||
count = src.count("system/dark/light")
|
||||
assert count >= 5, (
|
||||
f"cmd_theme description should mention 'system' in all 5 locales; "
|
||||
f"found {count}"
|
||||
)
|
||||
|
||||
def test_theme_listener_cleanup_uses_stable_handler(self):
|
||||
src = read("static/boot.js")
|
||||
assert "_systemThemeMq&&_onSystemThemeChange" in src, (
|
||||
"_applyTheme must track the active OS-theme listener so it can be removed cleanly"
|
||||
)
|
||||
assert "removeEventListener('change',_onSystemThemeChange)" in src, (
|
||||
"_applyTheme must remove the previous OS-theme listener before adding a new one"
|
||||
)
|
||||
|
||||
def test_panels_hydrates_appearance_before_models_fetch(self):
|
||||
src = read("static/panels.js")
|
||||
skin_idx = src.index("const skinVal=(settings.skin||'default').toLowerCase();")
|
||||
# models is now declared as let models=null before the try block
|
||||
models_idx = src.index("models=await api('/api/models');")
|
||||
assert skin_idx < models_idx, (
|
||||
"loadSettingsPanel must hydrate theme/skin before awaiting /api/models, "
|
||||
"otherwise a slow model fetch can clobber an in-progress skin selection"
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user