Compare commits
629 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
e0d52f39dc | ||
|
|
4a6769ec08 | ||
|
|
2797e5189b | ||
|
|
429a0ea228 | ||
|
|
2e7ce0a341 | ||
|
|
181641db6b | ||
|
|
fdc7d281a3 | ||
|
|
5a17df2573 | ||
|
|
1e6746c66b | ||
|
|
74dd613b1d | ||
|
|
fffdc34fdb | ||
|
|
c1db709ef3 | ||
|
|
4b55f08961 | ||
|
|
e184eb5ff5 | ||
|
|
b60c4fd498 | ||
|
|
a2243f4c4f | ||
|
|
ac5929918c | ||
|
|
9ceb3773f8 | ||
|
|
516062bd41 | ||
|
|
d8e6079a2c | ||
|
|
c0769c50a2 | ||
|
|
42590fceb3 | ||
|
|
84b6dde078 | ||
|
|
e2d24f57ac | ||
|
|
cc6709c9d5 | ||
|
|
6c54eda462 | ||
|
|
1a773597ac | ||
|
|
dc6be230ce | ||
|
|
123207e0a6 | ||
|
|
d05e15e612 | ||
|
|
2b92fe0aa9 | ||
|
|
dac58c8162 | ||
|
|
6a4b20f3f2 | ||
|
|
90b5ad8d99 | ||
|
|
57a4f573f6 | ||
|
|
c1320b4712 | ||
|
|
d3b693524f | ||
|
|
66f95e08c2 | ||
|
|
1a4d56c215 | ||
|
|
b2c2f32584 | ||
|
|
15fde033c3 | ||
|
|
10a1e57c9b | ||
|
|
4f10080501 | ||
|
|
f8ea02c14d | ||
|
|
122fe955b6 | ||
|
|
017d7f1eca | ||
|
|
cabda6b77a | ||
|
|
33fca2383c | ||
|
|
be951a4d1d | ||
|
|
bba9a236c3 | ||
|
|
846565484b | ||
|
|
1a579ef9cf | ||
|
|
1605d65226 | ||
|
|
3d4d7f2b53 | ||
|
|
2fb2ddeaaa | ||
|
|
b1d687ba22 | ||
|
|
c1dcd73502 | ||
|
|
df06c1cdca | ||
|
|
2c0f6e80b6 | ||
|
|
4a4af209ad | ||
|
|
279690e4c1 | ||
|
|
2766314e81 | ||
|
|
9d69408610 | ||
|
|
4a3b9571f1 | ||
|
|
c488031fe3 | ||
|
|
94b080fa1e | ||
|
|
e6663596ce | ||
|
|
16553be59d | ||
|
|
6a61f36280 | ||
|
|
b03ddf78c9 | ||
|
|
5c9edfc7bf | ||
|
|
733957cea1 | ||
|
|
e61382ef71 | ||
|
|
da43a6a09a | ||
|
|
4eae6c98f9 | ||
|
|
c71439d8ab | ||
|
|
ad755e49e5 | ||
|
|
f75e17c912 | ||
|
|
3d8cf85ef2 | ||
|
|
d4ab01c152 | ||
|
|
c778c1eb0c | ||
|
|
ca01845643 | ||
|
|
30529e0002 | ||
|
|
7ef203cd41 | ||
|
|
3520fa5643 | ||
|
|
0480bbf34c | ||
|
|
28ac04da7d | ||
|
|
f21b088a14 | ||
|
|
4bec7c082e | ||
|
|
571a5a40f1 | ||
|
|
d2b27f6f1e | ||
|
|
af73a5d8fd | ||
|
|
a92c251ef8 | ||
|
|
574cd2cf70 | ||
|
|
d278563e00 | ||
|
|
8cd07d3774 | ||
|
|
959c386d8d | ||
|
|
690f04bff0 | ||
|
|
f5c9f218c4 | ||
|
|
dcb21dfd37 | ||
|
|
59a92e03d8 | ||
|
|
df3de7a543 | ||
|
|
46fdf3513f | ||
|
|
efb7293ae8 | ||
|
|
44aa538b7c | ||
|
|
1b1cd124f6 | ||
|
|
3f9d1da0e2 | ||
|
|
9363d967ed | ||
|
|
2dda99082f | ||
|
|
51bcf8fead | ||
|
|
56526ce502 | ||
|
|
e0a1ab8e03 | ||
|
|
d88419ccfb | ||
|
|
3c95502979 | ||
|
|
66bd84accb | ||
|
|
b8b62722ec | ||
|
|
1c6db07c2b | ||
|
|
d0aef93372 | ||
|
|
67324cc3bc | ||
|
|
e7e09f217a | ||
|
|
0c00dae15a | ||
|
|
685aabab38 | ||
|
|
0f2bd537f1 | ||
|
|
856f5c21e1 | ||
|
|
f3ae8305dc | ||
|
|
a92fff75a3 | ||
|
|
0be7ccde4c | ||
|
|
fcd155be55 | ||
|
|
0c601a9cc8 | ||
|
|
4b326dbdf0 | ||
|
|
fd993c5513 | ||
|
|
d2bcd2b2f7 | ||
|
|
5e4645ee05 | ||
|
|
ae1faa7252 | ||
|
|
6c7fb4ee44 | ||
|
|
e59eb8bb5b | ||
|
|
ca06fd5533 | ||
|
|
7ddd896b36 | ||
|
|
f109910b58 | ||
|
|
b784fff104 | ||
|
|
d9293c6097 | ||
|
|
669412cbc9 | ||
|
|
c1a9324f35 | ||
|
|
cbf82898cb | ||
|
|
fb916d1f7e | ||
|
|
9452f56821 | ||
|
|
95645d651e | ||
|
|
2f281cbbd7 | ||
|
|
537337a158 | ||
|
|
8075442200 | ||
|
|
ebdd955578 | ||
|
|
1a4793848e | ||
|
|
8ed206657c | ||
|
|
06e1f11070 | ||
|
|
089dd7e3de | ||
|
|
0875dddbff | ||
|
|
85557381ec | ||
|
|
4a1bf20b67 | ||
|
|
ceb091d6b1 | ||
|
|
f9f56da484 | ||
|
|
b0da4db0d2 | ||
|
|
d899115d26 | ||
|
|
241357595d | ||
|
|
1375ce0634 | ||
|
|
0a39a64387 | ||
|
|
f6e58ef2ad | ||
|
|
2864c2b691 | ||
|
|
96547f68a3 | ||
|
|
a9ae0b0a83 |
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.pytest_cache
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
tests/
|
||||
.env*
|
||||
@@ -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
|
||||
|
||||
57
.github/workflows/release.yml
vendored
Normal file
57
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
name: Release & Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # required: create GitHub Release
|
||||
packages: write # required: push to ghcr.io
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Create GitHub Release from tag with auto-generated notes
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
|
||||
# Set up multi-arch build (QEMU + Buildx)
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Log in to GitHub Container Registry
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Extract tags from the git ref (supports vX.Y and vX.Y.Z formats)
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=match,pattern=v(\d+\.\d+(?:\.\d+)?),group=1
|
||||
type=raw,value=latest
|
||||
|
||||
# Build and push multi-arch image (amd64 + arm64)
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
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
|
||||
16
.gitignore
vendored
16
.gitignore
vendored
@@ -16,12 +16,26 @@ 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/**
|
||||
|
||||
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.
|
||||
207
ARCHITECTURE.md
207
ARCHITECTURE.md
@@ -7,57 +7,89 @@
|
||||
>
|
||||
> Keep this document updated as architecture changes are made.
|
||||
|
||||
> Current shipped build: `v0.50.36-local.1` (April 16, 2026).
|
||||
> Baseline: upstream `nesquena/hermes-webui` `v0.50.36`.
|
||||
> Intentional local delta: first-time password enablement from Settings immediately issues a `hermes_session` cookie so the current browser remains signed in. The previous `Assistant Reply Language` customization has been removed, legacy `assistant_language` settings are filtered out on load/save, the workspace panel closed/open state is preloaded via a `documentElement` dataset marker before `style.css` paints to avoid a first-load desktop flash, transcript disclosure cards now animate caret rotation and body expansion with transitionable `max-height`/`opacity` states instead of `display:none/block`, and thinking cards now share the same rounded bordered card chrome as tool cards while keeping their gold palette.
|
||||
> Automated coverage: 1353 tests collected (`pytest tests/ --collect-only -q`).
|
||||
|
||||
---
|
||||
|
||||
## 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 six vanilla JS modules loaded from static/.
|
||||
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.
|
||||
|
||||
For the current local build, the codebase is intentionally as close to upstream as possible:
|
||||
the app now tracks upstream `v0.50.36`, keeps the password-session continuity patch in the
|
||||
settings/onboarding flow, and does not carry forward the prior reply-language preference
|
||||
feature.
|
||||
|
||||
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
|
||||
|
||||
<repo>/
|
||||
server.py Thin routing shell + HTTP Handler. ~76 lines. Pure Python.
|
||||
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
|
||||
api/
|
||||
__init__.py Package marker
|
||||
routes.py All GET + POST route handlers (~802 lines)
|
||||
config.py Shared configuration, constants, global state, model discovery (~453 lines)
|
||||
helpers.py HTTP helpers: j(), bad(), require(), safe_resolve() (~57 lines)
|
||||
models.py Session model + CRUD (~114 lines)
|
||||
auth.py Optional password authentication, signed cookies (~149 lines)
|
||||
config.py Discovery, globals, model detection, reloadable config (~701 lines)
|
||||
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)
|
||||
upload.py Multipart parser, file upload handler (~77 lines)
|
||||
streaming.py SSE engine, run_agent integration, cancel support (~218 lines)
|
||||
static/
|
||||
index.html HTML template (served from disk)
|
||||
style.css All CSS
|
||||
ui.js DOM helpers, renderMd, tool cards, model dropdown (~671 lines)
|
||||
workspace.js File tree, preview, file ops (~168 lines)
|
||||
sessions.js Session CRUD, list rendering, search (~206 lines)
|
||||
messages.js send(), SSE event handlers, approval, transcript (~310 lines)
|
||||
panels.js Cron, skills, memory, workspace, todo, switchPanel (~600 lines)
|
||||
boot.js Event wiring + boot IIFE (~154 lines)
|
||||
index.html HTML template (~364 lines)
|
||||
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, 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)
|
||||
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_sprint1-11.py Feature tests per sprint (13 files)
|
||||
test_regressions.py Permanent regression gate
|
||||
test_sprint{1-20b}.py Feature tests per sprint (21 files, 415 test functions)
|
||||
test_regressions.py Permanent regression gate (23 tests)
|
||||
AGENTS.md Instruction file for agents working in this directory.
|
||||
ROADMAP.md Feature and product roadmap document.
|
||||
SPRINTS.md Forward sprint plan with CLI + Claude parity targets.
|
||||
ARCHITECTURE.md THIS FILE.
|
||||
TESTING.md Manual browser test plan and automated coverage reference.
|
||||
CHANGELOG.md Release notes per sprint.
|
||||
PORTABILITY.md Portability design spec for download-and-run installs.
|
||||
BUGS.md Bug backlog and fixed items tracker.
|
||||
requirements.txt Python dependencies.
|
||||
.env.example Sample environment variable overrides.
|
||||
|
||||
@@ -67,7 +99,8 @@ State directory (runtime data, separate from source):
|
||||
sessions/ One JSON file per session: {session_id}.json
|
||||
workspaces.json Registered workspaces list
|
||||
last_workspace.txt Last-used workspace path
|
||||
settings.json (future) User settings
|
||||
settings.json User settings (default model, workspace, send key, password hash)
|
||||
projects.json Session project groups (name, color, id)
|
||||
|
||||
Log file:
|
||||
|
||||
@@ -91,6 +124,8 @@ Environment variables controlling behavior:
|
||||
HERMES_WEBUI_STATE_DIR Where sessions/ folder lives
|
||||
HERMES_CONFIG_PATH Path to ~/.hermes/config.yaml
|
||||
HERMES_WEBUI_DEFAULT_MODEL Default LLM model string
|
||||
HERMES_WEBUI_PASSWORD Optional: enable password auth (off by default)
|
||||
HERMES_HOME Base directory for Hermes state (~/.hermes by default)
|
||||
|
||||
Test isolation environment variables (set by conftest.py):
|
||||
|
||||
@@ -109,6 +144,8 @@ Per-request environment variables (set by chat handler, restored after):
|
||||
HERMES_EXEC_ASK Set to "1" to enable approval gate for dangerous commands.
|
||||
HERMES_SESSION_KEY Set to session_id. The approval tool keys pending entries
|
||||
by this value, enabling per-session approval state.
|
||||
HERMES_HOME Set to the active profile's directory before running agent.
|
||||
Saved and restored around each agent run.
|
||||
|
||||
WARNING: These env vars are process-global. Two concurrent chat requests will clobber
|
||||
each other. This is safe only for single-user, single-concurrent-request use.
|
||||
@@ -151,10 +188,14 @@ Session is a plain Python class (not a dataclass, not SQLAlchemy):
|
||||
session_id hex string, 12 chars (uuid4().hex[:12])
|
||||
title string, auto-set from first user message
|
||||
workspace absolute path string, resolved at creation
|
||||
model OpenRouter model ID string (e.g. "anthropic/claude-sonnet-4.6")
|
||||
model model ID string (e.g. "anthropic/claude-sonnet-4.6")
|
||||
messages list of OpenAI-format message dicts
|
||||
created_at float Unix timestamp
|
||||
updated_at float Unix timestamp, updated on every save()
|
||||
pinned bool, default False (Sprint 12)
|
||||
archived bool, default False (Sprint 14)
|
||||
project_id string or null, FK to projects.json (Sprint 15)
|
||||
tool_calls list of tool call dicts (Sprint 10)
|
||||
|
||||
Key methods:
|
||||
path (property) Returns SESSION_DIR/{session_id}.json
|
||||
@@ -326,23 +367,36 @@ read_file_content(workspace, rel):
|
||||
### 5.1 Structure
|
||||
|
||||
The frontend is served from static/ as separate files: one HTML template, one CSS file,
|
||||
and six JavaScript modules (~2,025 lines total). External dependency: Prism.js from CDN
|
||||
(syntax highlighting, loaded async/deferred).
|
||||
and six JavaScript modules (~2,786 lines total). External dependencies: Prism.js (syntax
|
||||
highlighting) and Mermaid.js (diagrams) from CDN, both loaded async/deferred with SRI hashes.
|
||||
|
||||
Six JS modules loaded in order at end of <body>:
|
||||
1. ui.js (~589 lines) DOM helpers, renderMd, tool card rendering, global state
|
||||
2. workspace.js (~168 lines) File tree, preview, file operations
|
||||
3. sessions.js (~206 lines) Session CRUD, list rendering, search
|
||||
4. messages.js (~310 lines) send(), SSE event handlers, approval, transcript
|
||||
5. panels.js (~600 lines) Cron, skills, memory, workspace, todo, switchPanel
|
||||
6. boot.js (~152 lines) Event wiring + boot IIFE
|
||||
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, 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
|
||||
|
||||
sessions.js defines an `ICONS` constant at module level with hardcoded SVG strings for all
|
||||
session action buttons (pin, unpin, folder, archive, unarchive, duplicate, trash). All icons
|
||||
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 = {
|
||||
@@ -387,11 +441,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
|
||||
@@ -406,27 +468,45 @@ Boot IIFE:
|
||||
|
||||
### 5.4 Markdown Renderer (renderMd)
|
||||
|
||||
A hand-rolled regex chain. Processes in this order:
|
||||
1. Code blocks (``` lang ... ```) -> <pre><code> with language header
|
||||
2. Inline code (`...`) -> <code>
|
||||
3. Bold+italic (***..***) -> <strong><em>
|
||||
4. Bold (**...**) -> <strong>
|
||||
5. Italic (*...*) -> <em>
|
||||
6. Headings (# ## ###) -> <h1> <h2> <h3>
|
||||
7. Horizontal rules (---+) -> <hr>
|
||||
8. Blockquotes (> ...) -> <blockquote>
|
||||
9. Unordered lists (- or * or + at line start) -> <ul><li>
|
||||
10. Ordered lists (N. at line start) -> <ol><li>
|
||||
11. Links ([text](https://...)) -> <a href target=_blank>
|
||||
12. Paragraph wrapping: remaining double-newline-separated blocks -> <p>
|
||||
A hand-rolled regex chain with HTML safety. Processes in this order:
|
||||
|
||||
Pre-pass (v0.18.1):
|
||||
0a. Stash fenced code blocks and backtick spans (fence_stash array)
|
||||
0b. Convert safe HTML tags to markdown equivalents:
|
||||
<strong>/<b> -> **text**, <em>/<i> -> *text*, <code> -> `text`, <br> -> newline
|
||||
0c. Restore stashed code blocks
|
||||
|
||||
Pipeline:
|
||||
1. Mermaid blocks (```mermaid ... ```) -> <div class="mermaid-block">
|
||||
2. Code blocks (``` lang ... ```) -> <pre><code> with language header
|
||||
3. Inline code (`...`) -> <code>
|
||||
4. Bold+italic (***..***) -> <strong><em>
|
||||
5. Bold (**...**) -> <strong>
|
||||
6. Italic (*...*) -> <em>
|
||||
7. Headings (# ## ###) -> <h1> <h2> <h3> (uses inlineMd() for content)
|
||||
8. Horizontal rules (---+) -> <hr>
|
||||
9. Blockquotes (> ...) -> <blockquote> (uses inlineMd() for content)
|
||||
10. Unordered lists (- or * or + at line start) -> <ul><li> (uses inlineMd())
|
||||
11. Ordered lists (N. at line start) -> <ol><li> (uses inlineMd())
|
||||
12. Links ([text](https://...)) -> <a href target=_blank>
|
||||
13. Tables (| col | col |) -> <table>
|
||||
14. Safety net: escape any HTML tag not in SAFE_TAGS allowlist via esc()
|
||||
15. Paragraph wrapping: remaining double-newline-separated blocks -> <p>
|
||||
|
||||
inlineMd() helper (v0.18.1):
|
||||
Processes inline bold/italic/code/links within list items, blockquotes,
|
||||
and headings. Escapes unknown tags via SAFE_INLINE allowlist. Replaces
|
||||
the old direct esc() calls which would double-escape pre-pass output.
|
||||
|
||||
SAFE_TAGS allowlist:
|
||||
strong, em, code, pre, h1-6, ul, ol, li, table, thead, tbody, tr, th,
|
||||
td, hr, blockquote, p, br, a, div. Everything else is escaped.
|
||||
|
||||
Known gaps:
|
||||
- Tables: not supported, render as plain text
|
||||
- Nested lists: single regex pass, multi-level indentation not handled
|
||||
- Mixed bold+link in same line: may produce garbled output
|
||||
- Inline HTML: not sanitized (esc() only runs on code content)
|
||||
|
||||
### 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:
|
||||
|
||||
@@ -437,10 +517,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)
|
||||
|
||||
@@ -604,26 +684,27 @@ Split server.py into a proper package. Completed across Sprints 4-10.
|
||||
Current structure:
|
||||
|
||||
<repo>/
|
||||
server.py Entry point + HTTP Handler routing (~704 lines)
|
||||
server.py Entry point + HTTP Handler dispatch (~76 lines)
|
||||
api/
|
||||
__init__.py
|
||||
config.py Configuration, constants, global state (~273 lines)
|
||||
routes.py All GET + POST route handlers (~1016 lines)
|
||||
config.py Configuration, constants, global state, model discovery (~640 lines)
|
||||
helpers.py HTTP helpers: j(), bad(), require(), safe_resolve() (~57 lines)
|
||||
models.py Session model + CRUD (~114 lines)
|
||||
models.py Session model + CRUD (~132 lines)
|
||||
workspace.py File ops, workspace management (~77 lines)
|
||||
upload.py Multipart parser, file upload handler (~77 lines)
|
||||
streaming.py SSE engine, run_agent, cancel support (~218 lines)
|
||||
streaming.py SSE engine, run_agent, cancel support (~222 lines)
|
||||
static/
|
||||
index.html HTML document (served from disk)
|
||||
style.css All CSS
|
||||
style.css All CSS (~560 lines)
|
||||
ui.js, workspace.js, sessions.js, messages.js, panels.js, boot.js
|
||||
tests/
|
||||
conftest.py Isolated test server on port 8788
|
||||
test_sprint1-10.py Feature tests per sprint (12 files)
|
||||
test_sprint1-16.py Feature tests per sprint (14 files)
|
||||
test_regressions.py Permanent regression gate
|
||||
|
||||
Remaining: server.py still has all 49 route handlers in one do_GET/do_POST class.
|
||||
Sprint 11 plans extracting these to api/routes.py, making server.py a ~50-line shell.
|
||||
Route extraction to api/routes.py completed in Sprint 11. server.py is now a ~76-line
|
||||
thin shell: Handler class with structured logging, dispatch to routes, and main().
|
||||
|
||||
### Phase B: Thread-Safe Request Context (Priority: Critical, Effort: Medium)
|
||||
|
||||
@@ -718,10 +799,10 @@ Optional password gate for non-SSH-tunnel deployments.
|
||||
|
||||
### Phase I: Test Infrastructure -- COMPLETE
|
||||
|
||||
190 tests across 12 test files + regression gate. Isolated test server on port 8788
|
||||
289 tests across 14 test files + regression gate. Isolated test server on port 8788
|
||||
with separate HERMES_HOME, wiped per run. Production data never touched.
|
||||
|
||||
Test files: `test_sprint1.py` through `test_sprint10.py`, `test_regressions.py`.
|
||||
Test files: `test_sprint1.py` through `test_sprint11.py`, `test_sprint16.py`, `test_regressions.py`.
|
||||
Fixtures in `conftest.py`: auto-cleanup, cron isolation, workspace reset.
|
||||
|
||||
Remaining: no CI (GitHub Actions), no frontend tests (browser-based).
|
||||
@@ -1057,7 +1138,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.
|
||||
|
||||
52
BUGS.md
Normal file
52
BUGS.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Bugs Backlog
|
||||
|
||||
This file tracks UI bugs and polish items. Fixed items are kept for reference.
|
||||
|
||||
---
|
||||
|
||||
## Open Bugs
|
||||
|
||||
*No open bugs at this time.*
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
|
||||
- **Was:** Action icons reserved ~30px of space even when invisible, truncating titles.
|
||||
- **Fix:** Wrapped all action buttons in a `.session-actions` overlay container with `position:absolute`. Titles now use full available width. Actions appear on hover with a gradient fade from the right edge.
|
||||
|
||||
### ~~Folder/project assignment interaction feels sticky~~ -- Fixed (Sprint 16)
|
||||
|
||||
- **Was:** Folder icon stayed permanently visible (blue, 60% opacity) when a session belonged to a project.
|
||||
- **Fix:** Replaced `.has-project` persistent button with a colored left border matching the project color. The folder button now only appears in the hover overlay like all other actions.
|
||||
|
||||
### ~~Project picker clipping and width~~ -- Fixed (v0.17.3)
|
||||
|
||||
- **Was:** Picker was clipped by `overflow:hidden` on `.session-item` ancestors. With `position:fixed`, no containing block constrained width -- picker stretched to full viewport.
|
||||
- **Fix:** Dynamic width calculation (min 160px, max 220px). Event listener reordering. Cleanup sequence corrected. (PR #25)
|
||||
|
||||
### ~~NameError crash in model discovery~~ -- Fixed (v0.17.3)
|
||||
|
||||
- **Was:** `logger.debug()` called in custom endpoint `except` block, but `logger` was never imported in `config.py`. Every failed endpoint fetch crashed with `NameError`.
|
||||
- **Fix:** Replaced with silent `pass` -- unreachable endpoints are expected when no local LLM is configured. (PR #24)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Sprint 16 replaced all emoji HTML entities with monochrome SVG line icons (`ICONS` constant in `sessions.js`).
|
||||
- All session action buttons now use the overlay pattern for consistent UX.
|
||||
2342
CHANGELOG.md
2342
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.
|
||||
96
Dockerfile
Normal file
96
Dockerfile
Normal file
@@ -0,0 +1,96 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
LABEL maintainer="nesquena"
|
||||
LABEL description="Hermes Web UI — browser interface for Hermes Agent"
|
||||
|
||||
# Install system packages
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# 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
|
||||
|
||||
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
|
||||
|
||||
EXPOSE 8787
|
||||
|
||||
CMD ["/hermeswebui_init.bash"]
|
||||
|
||||
489
HERMES.md
Normal file
489
HERMES.md
Normal file
@@ -0,0 +1,489 @@
|
||||
# Why Hermes
|
||||
|
||||
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 real problem: most tools are excellent in the moment and weak over time
|
||||
|
||||
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 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.
|
||||
|
||||
```
|
||||
Session-scoped: You -> [Tool] -> Answer -> Done
|
||||
(some tools now carry memory, but the execution is stateless)
|
||||
|
||||
Persistent agent: You <-> [Hermes] <-> (memory, skills, schedule, tools, surfaces)
|
||||
(runs on your server, accumulates context, acts on your behalf offline)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## A note on convergence
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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. You never configure
|
||||
this manually — it happens in the background as a side effect of normal use.
|
||||
|
||||
### 2. Autonomous scheduling
|
||||
|
||||
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 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
|
||||
|
||||
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 across all of them.
|
||||
|
||||
---
|
||||
|
||||
## How AI tools are layered today
|
||||
|
||||
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:
|
||||
|
||||
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."
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
### vs. OpenClaw
|
||||
|
||||
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) 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 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.
|
||||
|
||||
Two practical differences worth knowing directly:
|
||||
|
||||
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 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: 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 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 (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; 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 |
|
||||
| Orchestrates Claude Code / Codex | No | Yes |
|
||||
| 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 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.
|
||||
|
||||
What Claude Code has that's worth knowing:
|
||||
|
||||
- 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; 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
|
||||
|
||||
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.
|
||||
|
||||
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 + 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 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) | 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 (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). 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 Automations; CLI has none) | Yes |
|
||||
| Messaging app access | No | Yes |
|
||||
| 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 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.
|
||||
|
||||
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 | 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 / 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
|
||||
|
||||
Cursor has changed substantially. The "no memory, no scheduling, no messaging" description was
|
||||
accurate in 2024 and is wrong now.
|
||||
|
||||
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 (Supermaven) | Excellent (Cascade) | Excellent | No |
|
||||
| Inline diff / refactor | Yes | Yes | Yes | Via shell |
|
||||
| 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 |
|
||||
| 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 and ChatGPT
|
||||
|
||||
These are no longer simple chat tools. The description of "no memory, no scheduling, no
|
||||
messaging" is inaccurate for both.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
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 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 — 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
|
||||
|
||||
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 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.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## What Hermes is not
|
||||
|
||||
Hermes is not the best in-editor autocomplete tool. Cursor and Windsurf do that job better.
|
||||
Use one alongside Hermes.
|
||||
|
||||
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 at it more useful
|
||||
over time. The models do the reasoning; Hermes makes sure that reasoning accumulates into
|
||||
something durable.
|
||||
|
||||
---
|
||||
|
||||
## Security and control
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
567
README.md
567
README.md
@@ -1,33 +1,280 @@
|
||||
# Hermes Web UI
|
||||
|
||||
A lightweight, dark-themed browser interface for Hermes.
|
||||
Full parity with the CLI experience -- everything you can do from a terminal,
|
||||
[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" height="854" alt="image" 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" />
|
||||
|
||||
This gives you nearly **1:1 parity with Hermes CLI from a convenient web UI** which you can access securely through an SSH tunnel from your Hermes setup. Single command to start this up, and a single command to SSH tunnel for access on your computer. Every single part of the web UI leverages your existing Hermes agent, existing models, without requiring any setup.
|
||||
<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>
|
||||
<td width="50%" align="center">
|
||||
<img alt="Workspace file browser with inline preview" src="docs/images/ui-workspace.png" />
|
||||
<br /><sub>Workspace file browser with inline preview</sub>
|
||||
</td>
|
||||
<td width="50%" align="center">
|
||||
<img alt="Session projects, tags, and tool call cards" src="docs/images/ui-sessions.png" />
|
||||
<br /><sub>Session projects, tags, and tool call cards</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
This gives you nearly **1:1 parity with Hermes CLI from a convenient web UI** which you can access securely through an SSH tunnel from your Hermes setup. Single command to start this up, and a single command to SSH tunnel for access on your computer. Every single part of the web UI uses your existing Hermes agent and existing models, without requiring any additional setup.
|
||||
|
||||
---
|
||||
|
||||
## Why Hermes
|
||||
|
||||
Most AI tools reset every session. They don't know who you are, what you worked on, or what
|
||||
conventions your project follows. You re-explain yourself every time.
|
||||
|
||||
Hermes retains context across sessions, runs scheduled jobs while you're offline, and gets
|
||||
smarter about your environment the longer it runs. It uses your existing Hermes agent setup,
|
||||
your existing models, and requires no additional configuration to start.
|
||||
|
||||
What makes it different from other agentic tools:
|
||||
|
||||
- **Persistent memory** — user profile, agent notes, and a skills system that saves reusable
|
||||
procedures; Hermes learns your environment and does not have to relearn it
|
||||
- **Self-hosted scheduling** — cron jobs that fire while you're offline and deliver results to
|
||||
Telegram, Discord, Slack, Signal, email, and more
|
||||
- **10+ messaging platforms** — the same agent available in the terminal is reachable from your phone
|
||||
- **Self-improving skills** — Hermes writes and saves its own skills automatically from experience;
|
||||
no marketplace to browse, no plugins to install
|
||||
- **Provider-agnostic** — OpenAI, Anthropic, Google, DeepSeek, OpenRouter, and more
|
||||
- **Orchestrates other agents** — can spawn Claude Code or Codex for heavy coding tasks and bring
|
||||
the results back into its own memory
|
||||
- **Self-hosted** — your conversations, your memory, your hardware
|
||||
|
||||
**vs. the field** *(landscape is actively shifting — see [HERMES.md](HERMES.md) for the full breakdown)*:
|
||||
|
||||
| | OpenClaw | Claude Code | Codex CLI | OpenCode | Hermes |
|
||||
|---|---|---|---|---|---|
|
||||
| Persistent memory (auto) | Yes | Partial† | Partial | Partial | Yes |
|
||||
| Scheduled jobs (self-hosted) | Yes | No‡ | No | No | Yes |
|
||||
| Messaging app access | Yes (15+ platforms) | Partial (Telegram/Discord preview) | No | No | Yes (10+) |
|
||||
| Web UI (self-hosted) | Dashboard only | No | No | Yes | Yes |
|
||||
| Self-improving skills | Partial | No | No | No | Yes |
|
||||
| Python / ML ecosystem | No (Node.js) | No | No | No | Yes |
|
||||
| Provider-agnostic | Yes | No (Claude only) | Yes | Yes | Yes |
|
||||
| Open source | Yes (MIT) | No | Yes | Yes | Yes |
|
||||
|
||||
† Claude Code has CLAUDE.md / MEMORY.md project context and rolling auto-memory, but not full automatic cross-session recall
|
||||
‡ Claude Code has cloud-managed scheduling (Anthropic infrastructure) and session-scoped `/loop`; no self-hosted cron
|
||||
|
||||
**The closest competitor is OpenClaw** — both are always-on, self-hosted, open-source agents
|
||||
with memory, cron, and messaging. The key differences: Hermes writes and saves its own skills
|
||||
automatically as a core behavior (OpenClaw's skill system centers on a community marketplace);
|
||||
Hermes is more stable across updates (OpenClaw has documented release regressions and ClawHub
|
||||
has had security incidents involving malicious skills); and Hermes runs natively in the Python
|
||||
ecosystem. See [HERMES.md](HERMES.md) for the full side-by-side.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Docker
|
||||
|
||||
**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 \
|
||||
-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
|
||||
```
|
||||
|
||||
Or build locally:
|
||||
|
||||
```bash
|
||||
docker build -t 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.
|
||||
|
||||
To enable password protection:
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
> **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.
|
||||
|
||||
---
|
||||
|
||||
@@ -51,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
|
||||
```
|
||||
|
||||
@@ -71,7 +319,8 @@ Full list of environment variables:
|
||||
| `HERMES_WEBUI_STATE_DIR` | `~/.hermes/webui-mvp` | Where sessions and state are stored |
|
||||
| `HERMES_WEBUI_DEFAULT_WORKSPACE` | `~/workspace` | Default workspace |
|
||||
| `HERMES_WEBUI_DEFAULT_MODEL` | `openai/gpt-5.4-mini` | Default model |
|
||||
| `HERMES_HOME` | `~/.hermes` | Base directory for Hermes state (affects all paths above) |
|
||||
| `HERMES_WEBUI_PASSWORD` | *(unset)* | Set to enable password authentication |
|
||||
| `HERMES_HOME` | `~/.hermes` | Base directory for Hermes state (affects all paths) |
|
||||
| `HERMES_CONFIG_PATH` | `~/.hermes/config.yaml` | Path to Hermes config file |
|
||||
|
||||
---
|
||||
@@ -98,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:
|
||||
@@ -123,17 +406,18 @@ Tests discover the repo and the Hermes agent dynamically -- no hardcoded paths.
|
||||
|
||||
```bash
|
||||
cd hermes-webui
|
||||
python -m pytest tests/ -v
|
||||
pytest tests/ -v --timeout=60
|
||||
```
|
||||
|
||||
Or using the agent venv explicitly:
|
||||
|
||||
```bash
|
||||
/path/to/hermes-agent/venv/bin/python -m pytest tests/ -v # or any Python with deps installed
|
||||
/path/to/hermes-agent/venv/bin/python -m pytest tests/ -v
|
||||
```
|
||||
|
||||
Tests run against an isolated server on port 8788 with a separate state directory.
|
||||
Production data and real cron jobs are never touched.
|
||||
Production data and real cron jobs are never touched. Current count: **1898 tests**
|
||||
across 53 test files.
|
||||
|
||||
---
|
||||
|
||||
@@ -141,88 +425,279 @@ Production data and real cron jobs are never touched.
|
||||
|
||||
### Chat and agent
|
||||
- Streaming responses via SSE (tokens appear as they are generated)
|
||||
- Multi-provider model support -- any Hermes API provider (OpenAI, Anthropic, Google, DeepSeek, Nous Portal, OpenRouter); dynamic model dropdown populated from configured keys
|
||||
- Multi-provider model support -- any Hermes API provider (OpenAI, Anthropic, Google, DeepSeek, Nous Portal, OpenRouter, MiniMax, Z.AI); dynamic model dropdown populated from configured keys
|
||||
- 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
|
||||
- Tool call cards inline -- each shows the tool name, args, and result snippet
|
||||
- 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)
|
||||
- Thinking/reasoning display -- collapsible gold-themed cards for Claude extended thinking and o3 reasoning blocks
|
||||
- Approval card for dangerous shell commands (allow once / session / always / deny)
|
||||
- SSE auto-reconnect on network blips (SSH tunnel resilience)
|
||||
- File attachments persist across page reloads
|
||||
- Message timestamps (HH:MM next to each message, full date on hover)
|
||||
- Code block copy button with "Copied!" feedback
|
||||
- Syntax highlighting via Prism.js (Python, JS, bash, JSON, SQL, and more)
|
||||
- Safe HTML rendering in AI responses (bold, italic, code converted to markdown)
|
||||
- rAF-throttled token streaming for smoother rendering during long responses
|
||||
- Context usage indicator in composer footer -- token count, cost, and fill bar (model-aware)
|
||||
|
||||
### Sessions
|
||||
- Create, rename, duplicate, delete, search by title and message content
|
||||
- Pin/star sessions to the top of the sidebar
|
||||
- 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
|
||||
- Session tags -- add #tag to titles for colored chips and click-to-filter
|
||||
- Grouped by Today / Yesterday / Earlier in the sidebar
|
||||
- Grouped by Today / Yesterday / Earlier in the sidebar (collapsible date groups)
|
||||
- Download as Markdown transcript, full JSON export, or import from JSON
|
||||
- Sessions persist across page reloads and SSH tunnel reconnects
|
||||
- Browser tab title reflects the active session name
|
||||
- CLI session bridge -- CLI sessions from hermes-agent's SQLite store appear in the sidebar with a gold "cli" badge; click to import with full history and reply normally
|
||||
- Token/cost display -- input tokens, output tokens, estimated cost shown per conversation (toggle in Settings or `/usage` command)
|
||||
|
||||
### Workspace file browser
|
||||
- Browse directory tree with type icons
|
||||
- Directory tree with expand/collapse (single-click toggles, double-click navigates)
|
||||
- Breadcrumb navigation with clickable path segments
|
||||
- Preview text, code, Markdown (rendered), and images inline
|
||||
- Edit, create, delete, and rename files; create folders
|
||||
- Binary file download (auto-detected from server)
|
||||
- File preview auto-closes on directory navigation (with unsaved-edit guard)
|
||||
- Git detection -- branch name and dirty file count badge in workspace header
|
||||
- Right panel is drag-resizable
|
||||
- Syntax highlighted code preview (Prism.js)
|
||||
|
||||
### Voice input
|
||||
- Microphone button in the composer (Web Speech API)
|
||||
- Tap to record, tap again or send to stop
|
||||
- Live interim transcription appears in the textarea
|
||||
- Auto-stops after ~2s of silence
|
||||
- Appends to existing textarea content (doesn't replace)
|
||||
- Hidden when browser doesn't support Web Speech API (Chrome, Edge, Safari)
|
||||
|
||||
### 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)
|
||||
|
||||
### Authentication and security
|
||||
- Optional password auth -- off by default, zero friction for localhost
|
||||
- Enable via `HERMES_WEBUI_PASSWORD` env var or Settings panel
|
||||
- Signed HMAC HTTP-only cookie with 24h TTL
|
||||
- Minimal dark-themed login page at `/login`
|
||||
- Security headers on all responses (X-Content-Type-Options, X-Frame-Options, Referrer-Policy)
|
||||
- 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 in topbar) -- persist default model and default workspace server-side
|
||||
- **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`, `/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
|
||||
|
||||
### Panels
|
||||
- **Chat** -- session list, search, pin, archive, new conversation
|
||||
- **Tasks** -- view, create, edit, run, pause/resume, delete cron jobs; completion alerts
|
||||
- **Skills** -- list all skills by category, search, preview, create/edit/delete
|
||||
- **Chat** -- session list, search, pin, archive, projects, new conversation
|
||||
- **Tasks** -- view, create, edit, run, pause/resume, delete cron jobs; run history; completion alerts
|
||||
- **Skills** -- list all skills by category, search, preview, create/edit/delete; linked files viewer
|
||||
- **Memory** -- view and edit MEMORY.md and USER.md inline
|
||||
- **Profiles** -- create, switch, delete agent profiles; clone config
|
||||
- **Todos** -- live task list from the current session
|
||||
- **Spaces** -- add, rename, remove workspaces; quick-switch from topbar
|
||||
|
||||
### Mobile responsive
|
||||
- Hamburger sidebar -- slide-in overlay on mobile (<640px)
|
||||
- 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
|
||||
- Full-height chat/composer on phones without bottom-nav spacing
|
||||
- Desktop layout completely unchanged
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
server.py HTTP routing shell (~76 lines)
|
||||
server.py HTTP routing shell + auth middleware (~154 lines)
|
||||
api/
|
||||
routes.py All GET + POST route handlers
|
||||
config.py Discovery + globals + model provider detection
|
||||
helpers.py HTTP helpers: j(), bad(), require(), safe_resolve()
|
||||
models.py Session model + CRUD
|
||||
workspace.py File ops: list_dir, read_file_content, workspace helpers
|
||||
upload.py Multipart parser, file upload handler
|
||||
streaming.py SSE engine, run_agent integration, cancel support
|
||||
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
|
||||
style.css All CSS
|
||||
ui.js DOM helpers, renderMd, Mermaid, tool cards, file tree
|
||||
workspace.js File tree, preview, file ops
|
||||
sessions.js Session CRUD, list rendering, search, tags, archive
|
||||
messages.js send(), SSE event handlers, approval, transcript
|
||||
panels.js Cron, skills, memory, workspace, todo, switchPanel, alerts
|
||||
boot.js Event wiring + boot IIFE
|
||||
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, separate HERMES_HOME)
|
||||
test_sprint1-14.py Feature tests per sprint
|
||||
test_regressions.py Permanent regression gate
|
||||
conftest.py Isolated test server (port 8788)
|
||||
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
|
||||
```
|
||||
|
||||
State lives outside the repo at `~/.hermes/webui-mvp/` by default
|
||||
(sessions, workspaces, settings, last_workspace). Override with `HERMES_WEBUI_STATE_DIR`.
|
||||
(sessions, workspaces, settings, projects, last_workspace). Override with `HERMES_WEBUI_STATE_DIR`.
|
||||
|
||||
---
|
||||
|
||||
## Docs
|
||||
|
||||
- `HERMES.md` -- why Hermes, mental model, and detailed comparison to Claude Code / Codex / OpenCode / Cursor
|
||||
- `ROADMAP.md` -- feature roadmap and sprint history
|
||||
- `ARCHITECTURE.md` -- system design, all API endpoints, implementation notes
|
||||
- `TESTING.md` -- manual browser test plan and automated coverage reference
|
||||
- `CHANGELOG.md` -- release notes
|
||||
- `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 rebase — shapes the project, and we're grateful to everyone who has taken the time to contribute.
|
||||
|
||||
### Major 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)** — Real-time gateway session sync (PR #274)
|
||||
Bridged the gateway session database (Telegram, Discord, Slack, etc.) into the WebUI sidebar with live SSE polling. Gateway sessions now appear alongside WebUI sessions in real time, without any changes to hermes-agent.
|
||||
|
||||
**[@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)** — Session title guard + breadcrumb nav (PRs #301, #302)
|
||||
Two clean bug fixes / features: the session title guard that stops `title_from()` from overwriting user-renamed sessions after every turn, and clickable breadcrumb navigation in the workspace file preview panel.
|
||||
|
||||
**[@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
|
||||
|
||||
|
||||
236
ROADMAP.md
236
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: Sprint 14 (March 30, 2026)
|
||||
> Tests: 226/226 passing
|
||||
> Last updated: v0.50.156 (April 22, 2026) — 1903 tests collected
|
||||
> Tests: 1903 collected (`pytest tests/ --collect-only -q`)
|
||||
> Source: <repo>/
|
||||
|
||||
---
|
||||
@@ -30,6 +30,62 @@
|
||||
| Sprint 11 | Multi-provider models + streaming | Dynamic model dropdown (any Hermes provider), smooth scroll pinning, routes extracted to api/routes.py (server.py 704→76 lines) | 201 |
|
||||
| Sprint 12 | Settings + reliability + session QoL | Settings panel (gear icon, settings.json), SSE auto-reconnect, pin sessions, import session from JSON | 211 |
|
||||
| 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, 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, 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -37,12 +93,14 @@
|
||||
|
||||
| Layer | Location | Status |
|
||||
|-------|----------|--------|
|
||||
| Python server | <repo>/server.py (~76 lines) + api/ modules (~1900 lines) | Thin shell + business logic in api/ |
|
||||
| HTML template | <repo>/static/index.html | Served from disk |
|
||||
| CSS | <repo>/static/style.css | Served from disk |
|
||||
| JavaScript | <repo>/static/{ui,workspace,sessions,messages,panels,boot}.js | 6 modules, ~2250 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 |
|
||||
|
||||
---
|
||||
@@ -52,10 +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
|
||||
@@ -64,26 +124,28 @@
|
||||
- [x] Copy message to clipboard (hover icon on each bubble)
|
||||
- [x] Edit last user message and regenerate
|
||||
- [ ] Branch/fork conversation (Wave 3)
|
||||
- [ ] Token/cost estimate per message (Wave 3)
|
||||
- [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)
|
||||
@@ -103,6 +165,7 @@
|
||||
- [x] Import session from JSON (Sprint 12)
|
||||
- [x] Pin/star sessions to top of list (Sprint 12)
|
||||
- [x] Duplicate session (Sprint 13)
|
||||
- [x] Session projects / folders (Sprint 15)
|
||||
|
||||
### Workspace Management
|
||||
- [x] Add workspace with path validation (must be existing directory)
|
||||
@@ -124,7 +187,7 @@
|
||||
- [x] Edit existing cron job
|
||||
- [x] Delete cron job
|
||||
- [x] View full cron run history (expandable per job)
|
||||
- [ ] Skill picker in cron create form (Wave 3)
|
||||
- [x] Skill picker in cron create form (Sprint 23)
|
||||
|
||||
### Skills
|
||||
- [x] List all skills grouped by category (Skills sidebar tab)
|
||||
@@ -133,7 +196,7 @@
|
||||
- [x] Create skill
|
||||
- [x] Edit skill
|
||||
- [x] Delete skill
|
||||
- [ ] View skill linked files (Wave 3)
|
||||
- [x] View skill linked files (Sprint 23)
|
||||
|
||||
### Memory
|
||||
- [x] View personal notes (MEMORY.md) rendered as markdown (Memory tab)
|
||||
@@ -143,22 +206,59 @@
|
||||
|
||||
### Configuration
|
||||
- [x] Settings panel (default model, default workspace) (Sprint 12)
|
||||
- [x] Send key preference (Enter or Ctrl+Enter) (Sprint 17)
|
||||
- [x] Password authentication (Sprint 19)
|
||||
- [ ] Enable/disable toolsets per session (deferred)
|
||||
|
||||
### Notifications
|
||||
- [x] Cron job completion alerts (Sprint 13)
|
||||
- [x] Background agent error alerts (Sprint 13)
|
||||
|
||||
### Workspace
|
||||
- [x] Breadcrumb navigation in subdirectories (Sprint 17)
|
||||
- [x] Workspace tree view with expand/collapse (Sprint 18, Issue #22)
|
||||
- [x] File preview auto-close on directory navigation (Sprint 18)
|
||||
|
||||
### Slash Commands
|
||||
- [x] Command registry + autocomplete dropdown (Sprint 17)
|
||||
- [x] Built-in: /help, /clear, /model, /workspace, /new (Sprint 17)
|
||||
|
||||
### Security
|
||||
- [x] Password auth with signed cookies (Sprint 19, Issue #23)
|
||||
- [x] Security headers (X-Content-Type-Options, X-Frame-Options) (Sprint 19)
|
||||
- [x] POST body size limit (20MB) (Sprint 19)
|
||||
|
||||
### Thinking / Reasoning
|
||||
- [x] Collapsible thinking cards for extended-thinking models (Sprint 18)
|
||||
|
||||
### Voice
|
||||
- [x] Voice input via Web Speech API (Sprint 20)
|
||||
|
||||
### Mobile
|
||||
- [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)
|
||||
|
||||
### Advanced / Future
|
||||
- [ ] Voice input via Whisper (Wave 6)
|
||||
- [ ] TTS playback of responses (Wave 6)
|
||||
- [ ] Subagent delegation cards (Wave 6)
|
||||
- [x] Background task cancel (activity bar Cancel button)
|
||||
- [ ] Code execution cell (Wave 6)
|
||||
- [ ] Password authentication (Wave 7)
|
||||
- [ ] HTTPS / reverse proxy (Wave 7)
|
||||
- [ ] Mobile responsive layout (Wave 7)
|
||||
- [ ] Virtual scroll for large lists (Wave 7)
|
||||
- [ ] Subagent session tree -- show subagent hierarchy in sidebar with expand/collapse (PR #75)
|
||||
- [ ] Specialized tool card renderers -- diff viewer, terminal output, todo checklist views (PR #75)
|
||||
- [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 -- 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 (composer footer stop button)
|
||||
- [ ] Code execution cell (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)
|
||||
|
||||
---
|
||||
|
||||
@@ -235,81 +335,29 @@ Enter saves, Escape cancels. Topbar updates immediately.
|
||||
|
||||
---
|
||||
|
||||
## Wave 3: Power Features and Developer Experience
|
||||
## Completed Waves (Summary)
|
||||
|
||||
### Sprint 3.1: Tool Call Visibility Inline
|
||||
Show tool calls as collapsible cards in the conversation.
|
||||
Collapsed: tool name badge + one-line preview. Expanded: full args + result.
|
||||
|
||||
### Sprint 3.2: Multi-Model Expansion
|
||||
Add more models. Group by provider. Model info tooltip on hover.
|
||||
(Partially done: 10 models in dropdown from Sprint 1.)
|
||||
|
||||
### Sprint 3.2b: Resizable Panel Widths (COMPLETE Sprint 6)
|
||||
Both sidebar and workspace panel are drag-resizable with localStorage persistence.
|
||||
|
||||
### Sprint 3.3: Workspace File Actions
|
||||
- [ ] Rename file (inline, double-click) (Wave 3)
|
||||
- [ ] Create folder (Wave 3)
|
||||
- [x] Syntax highlighted code preview (Prism.js)
|
||||
|
||||
### Sprint 3.4: Conversation Controls
|
||||
- [x] Copy message (Sprint 5)
|
||||
- [x] Edit last user message + regenerate
|
||||
- [x] Regenerate last assistant response
|
||||
- [x] Clear conversation (wipe messages, keep session)
|
||||
| Wave | Theme | Key Deliverables |
|
||||
|------|-------|-----------------|
|
||||
| Wave 2 | Full CRUD + Interaction | Cron/skill/memory CRUD, session search, workspace management, session rename |
|
||||
| Wave 3 | Power Features | Tool call cards, multi-model dropdown, resizable panels, file actions, conversation controls |
|
||||
| Wave 4 | Settings + Notifications | Settings panel, cron alerts, background error banner |
|
||||
| Wave 5 | Session Continuity | Session tags, archive, projects/folders |
|
||||
| Wave 6 | Agentic Features | Background task cancel, voice input (Web Speech API) |
|
||||
| Wave 7 | Production Hardening | Password auth, security headers, mobile responsive, Docker + GHCR CI |
|
||||
|
||||
---
|
||||
|
||||
## Wave 4: Settings, Configuration, Notifications
|
||||
## User Requested Features
|
||||
|
||||
### Sprint 4.1: Settings Panel
|
||||
Full settings overlay: default model, default workspace, enabled toolsets, config viewer.
|
||||
Community-requested enhancements tracked from GitHub issues. All shipped.
|
||||
|
||||
### Sprint 4.2: Notification Panel
|
||||
Bell icon with unread count. SSE endpoint for cron completions and errors. Toast pop-ups.
|
||||
|
||||
### Sprint 4.3: Delivery Target Config
|
||||
Configure and test-ping delivery targets (Discord, Telegram, Slack, email) for cron jobs.
|
||||
|
||||
---
|
||||
|
||||
## Wave 5: Honcho Integration and Long-term Memory
|
||||
|
||||
### Sprint 5.1: Honcho Memory Panel
|
||||
User representation panel, cross-session context, Honcho search, memory write.
|
||||
|
||||
### Sprint 5.2: Session Continuity Features
|
||||
"What were we working on?" button, session tags, session archive.
|
||||
|
||||
---
|
||||
|
||||
## Wave 6: Realtime and Agentic Features
|
||||
|
||||
### Sprint 6.1: Background Task Monitor
|
||||
Live list of running agent threads. Cancel button. Queue visibility.
|
||||
|
||||
### Sprint 6.2: Subagent Delegation Cards
|
||||
When delegate_task fires, show subagent progress inline in chat.
|
||||
|
||||
### Sprint 6.3: Code Execution Panel
|
||||
Jupyter-style inline code cell. Stateful kernel per session.
|
||||
|
||||
### Sprint 6.4: Voice Mode
|
||||
Push-to-talk mic button. Whisper transcription. Optional TTS playback.
|
||||
|
||||
---
|
||||
|
||||
## Wave 7: Production Hardening and Mobile
|
||||
|
||||
### Sprint 7.1: Authentication
|
||||
HERMES_WEBUI_PASSWORD env var gate. Signed cookie. Login page.
|
||||
|
||||
### Sprint 7.2: HTTPS and Reverse Proxy
|
||||
Nginx + Let's Encrypt. CORS headers for external domain.
|
||||
|
||||
### Sprint 7.3: Mobile Responsive Layout
|
||||
Collapsible sidebar hamburger. Touch-friendly controls. Swipe gestures.
|
||||
|
||||
### Sprint 7.4: Performance and Scale
|
||||
Virtual scroll for session/message lists. Incremental message loading.
|
||||
| Feature | Issue | Shipped | Sprint |
|
||||
|---------|-------|---------|--------|
|
||||
| Workspace tree view | #22 | Done | Sprint 18 |
|
||||
| Docker container + GHCR images | #7 | Done | Sprint 21 + v0.28.1 CI |
|
||||
| Authentication | #23 | Done | Sprint 19 |
|
||||
| Send key / personalization | #26 | Done | Sprint 17 |
|
||||
| Multi-profile support | #28 | Done | Sprint 22 |
|
||||
| Mobile responsive UI | #21 | Done | Sprint 21 |
|
||||
| Profile creation in Docker | #44 | Done | v0.27 |
|
||||
|
||||
1113
SPRINTS.md
1113
SPRINTS.md
File diff suppressed because it is too large
Load Diff
271
TESTING.md
271
TESTING.md
@@ -1,12 +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 every user-facing feature of the UI through Sprint 2.
|
||||
> 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 coverage: 1777 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation.
|
||||
> 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -29,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.
|
||||
@@ -70,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.
|
||||
|
||||
@@ -141,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
|
||||
@@ -150,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -225,8 +244,18 @@ FAIL: File not removed, error.
|
||||
|
||||
## 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
|
||||
@@ -260,10 +289,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.
|
||||
@@ -374,7 +404,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:
|
||||
@@ -465,6 +496,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.
|
||||
@@ -828,7 +860,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]"
|
||||
@@ -839,7 +871,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
|
||||
@@ -848,7 +880,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"
|
||||
@@ -922,7 +954,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"
|
||||
@@ -978,7 +1010,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.
|
||||
|
||||
@@ -991,23 +1023,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"
|
||||
@@ -1019,8 +1051,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:
|
||||
@@ -1067,7 +1099,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
|
||||
@@ -1191,7 +1223,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.
|
||||
|
||||
@@ -1222,7 +1254,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,
|
||||
@@ -1286,7 +1319,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.
|
||||
|
||||
@@ -1294,7 +1327,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
|
||||
@@ -1323,7 +1356,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.
|
||||
|
||||
@@ -1351,7 +1384,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.
|
||||
|
||||
@@ -1464,14 +1497,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
|
||||
@@ -1482,7 +1517,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
|
||||
@@ -1593,8 +1628,162 @@ FAIL: User message gone, blank chat, response lands in wrong session.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: Post-Sprint 10 concurrency sweeps, March 31, 2026*
|
||||
*Total automated tests: 190/190*
|
||||
*Regression gate: tests/test_regressions.py (23 tests, one per introduced bug)*
|
||||
*Run: python -m pytest tests/ -v*
|
||||
---
|
||||
|
||||
## Sections Added Post-Sprint 10 (Sprints 11-19)
|
||||
|
||||
The following features were added in Sprints 11-19 and need manual browser testing.
|
||||
Each has automated API-level tests in `tests/test_sprint{N}.py`.
|
||||
|
||||
### Sprint 11: Multi-Provider Models
|
||||
- Open model dropdown. Verify models grouped by provider (OpenAI, Anthropic, Google, etc.)
|
||||
- If custom `base_url` configured in config.yaml, verify local models appear in dropdown.
|
||||
- Switch model. Send a message. Verify response uses selected model.
|
||||
|
||||
### Sprint 12: Settings + Pin + Import
|
||||
- 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.
|
||||
|
||||
### Sprint 13: Alerts + Session QoL
|
||||
- Duplicate a session (copy icon in hover overlay). Verify "(copy)" title.
|
||||
- Browser tab title updates to active session name. Switch sessions — title changes.
|
||||
|
||||
### Sprint 14: Visual Polish + Workspace Ops
|
||||
- Create a mermaid code block in a response. Verify diagram renders inline.
|
||||
- Message timestamps visible next to role labels (hover for full date).
|
||||
- Double-click a file in workspace panel to rename. Enter saves, Escape cancels.
|
||||
- Create a folder via folder icon in workspace header.
|
||||
- Add `#tag` to session title. Verify tag chip appears in sidebar. Click to filter.
|
||||
- Archive a session. Verify it disappears. Toggle "Show archived" to see it.
|
||||
|
||||
### Sprint 15: Session Projects
|
||||
- Click "+" in project bar to create a project. Type name, Enter.
|
||||
- Click a project chip to filter sessions.
|
||||
- Hover a session → click folder icon → assign to project via picker.
|
||||
- Verify colored left border appears on assigned session.
|
||||
- Double-click project chip to rename. Right-click to delete.
|
||||
- Code blocks have a "Copy" button. Click → "Copied!" feedback.
|
||||
- Messages with 2+ tool cards show "Expand all / Collapse all" toggle.
|
||||
|
||||
### Sprint 16: Sidebar Visual Polish
|
||||
- Session titles use full sidebar width (no truncated space for hidden icons).
|
||||
- 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).
|
||||
- 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.
|
||||
- Up button in panel header navigates to parent. Hidden at root.
|
||||
- Type `/` in composer → autocomplete dropdown appears. Arrow keys navigate.
|
||||
- Type `/help` → lists all commands. `/clear` clears conversation. `/model` switches.
|
||||
- Settings panel: change send key to Ctrl+Enter. Verify Enter inserts newline.
|
||||
|
||||
### Sprint 18: Thinking + Tree View + Preview Fix
|
||||
- View a file in workspace. Click a breadcrumb or folder → preview closes automatically.
|
||||
- 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.
|
||||
- Set `HERMES_WEBUI_PASSWORD=test` env var. Restart. All pages redirect to `/login`.
|
||||
- Login page: minimal card, password field, "Sign in" button.
|
||||
- Enter correct password → redirected to `/`. Cookie set (24h).
|
||||
- Enter wrong password → error message, stay on login page.
|
||||
- Settings panel: set password via "Access Password" field. Auth activates.
|
||||
- "Sign Out" button visible when auth active. Click → redirected to /login.
|
||||
- API calls without auth cookie → 401 JSON response.
|
||||
- Check response headers: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`.
|
||||
|
||||
### Sprint 20: Voice Input + Send Button
|
||||
- Mic button visible in composer (Chrome/Edge). Hidden in Firefox.
|
||||
- Tap mic → button turns red with pulse, "Listening..." indicator appears.
|
||||
- Speak → live transcription appears in textarea.
|
||||
- Stop speaking → auto-stops after ~2s silence. Text stays editable.
|
||||
- Tap mic again or Send → stops recording, sends text.
|
||||
- Type text, then tap mic → spoken text appends to existing text (doesn't replace).
|
||||
- Send button hidden when textarea is empty. Appears with pop-in animation when typing.
|
||||
- Send button is icon-only circle (no "Send" text label). Blue with glow.
|
||||
- Attach a file with no text → send button appears.
|
||||
- Send message → button disappears after textarea clears.
|
||||
- While agent is responding → send button hidden.
|
||||
|
||||
### Sprint 21: Mobile Responsive + Docker
|
||||
- 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.
|
||||
- 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 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).
|
||||
|
||||
### Sprint 22: Multi-Profile Support
|
||||
- Profile chip in topbar (purple accent). Click → dropdown with all profiles.
|
||||
- Dropdown shows gateway status dots, model info, skill count per profile.
|
||||
- Click a profile → switches; model dropdown, skills, memory, cron refresh.
|
||||
- "Manage profiles" link opens Profiles sidebar panel.
|
||||
- Profiles panel: cards with name, model, provider, skill count, API key status.
|
||||
- "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 → 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.
|
||||
|
||||
---
|
||||
|
||||
## 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.91, April 19, 2026*
|
||||
*Total automated tests collected: 1777*
|
||||
*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
|
||||
204
api/auth.py
Normal file
204
api/auth.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
Hermes Web UI -- Optional password authentication.
|
||||
Off by default. Enable by setting HERMES_WEBUI_PASSWORD env var
|
||||
or configuring a password in the Settings panel.
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import http.cookies
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
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',
|
||||
'/api/auth/login', '/api/auth/status',
|
||||
})
|
||||
|
||||
COOKIE_NAME = 'hermes_session'
|
||||
SESSION_TTL = 86400 # 24 hours
|
||||
|
||||
# Active sessions: token -> expiry timestamp
|
||||
_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():
|
||||
"""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):
|
||||
"""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() -> 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()
|
||||
if env_pw:
|
||||
return _hash_password(env_pw)
|
||||
settings = load_settings()
|
||||
return settings.get('password_hash') or None
|
||||
|
||||
|
||||
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) -> bool:
|
||||
"""Verify a plaintext password against the stored hash."""
|
||||
expected = get_password_hash()
|
||||
if not expected:
|
||||
return False
|
||||
return hmac.compare_digest(_hash_password(plain), expected)
|
||||
|
||||
|
||||
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()[:32]
|
||||
return f"{token}.{sig}"
|
||||
|
||||
|
||||
def _prune_expired_sessions():
|
||||
"""Remove all expired session entries to prevent unbounded memory growth."""
|
||||
now = time.time()
|
||||
for token in [t for t, exp in _sessions.items() if now > exp]:
|
||||
_sessions.pop(token, None)
|
||||
|
||||
|
||||
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()[:32]
|
||||
if not hmac.compare_digest(sig, expected_sig):
|
||||
return False
|
||||
expiry = _sessions.get(token)
|
||||
if not expiry or time.time() > expiry:
|
||||
_sessions.pop(token, None)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def parse_cookie(handler) -> str | None:
|
||||
"""Extract the auth cookie from the request headers."""
|
||||
cookie_header = handler.headers.get('Cookie', '')
|
||||
if not cookie_header:
|
||||
return None
|
||||
cookie = http.cookies.SimpleCookie()
|
||||
try:
|
||||
cookie.load(cookie_header)
|
||||
except http.cookies.CookieError:
|
||||
return None
|
||||
morsel = cookie.get(COOKIE_NAME)
|
||||
return morsel.value if morsel else None
|
||||
|
||||
|
||||
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():
|
||||
return True
|
||||
# Public paths don't require auth
|
||||
if parsed.path in PUBLIC_PATHS or parsed.path.startswith('/static/'):
|
||||
return True
|
||||
# Check session cookie
|
||||
cookie_val = parse_cookie(handler)
|
||||
if cookie_val and verify_session(cookie_val):
|
||||
return True
|
||||
# Not authorized
|
||||
if parsed.path.startswith('/api/'):
|
||||
handler.send_response(401)
|
||||
handler.send_header('Content-Type', 'application/json')
|
||||
handler.end_headers()
|
||||
handler.wfile.write(b'{"error":"Authentication required"}')
|
||||
else:
|
||||
handler.send_response(302)
|
||||
handler.send_header('Location', '/login')
|
||||
handler.end_headers()
|
||||
return False
|
||||
|
||||
|
||||
def set_auth_cookie(handler, cookie_value) -> None:
|
||||
"""Set the auth cookie on the response."""
|
||||
cookie = http.cookies.SimpleCookie()
|
||||
cookie[COOKIE_NAME] = cookie_value
|
||||
cookie[COOKIE_NAME]['httponly'] = True
|
||||
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) -> None:
|
||||
"""Clear the auth cookie on the response."""
|
||||
cookie = http.cookies.SimpleCookie()
|
||||
cookie[COOKIE_NAME] = ''
|
||||
cookie[COOKIE_NAME]['httponly'] = True
|
||||
cookie[COOKIE_NAME]['path'] = '/'
|
||||
cookie[COOKIE_NAME]['max-age'] = '0'
|
||||
handler.send_header('Set-Cookie', cookie[COOKIE_NAME].OutputString())
|
||||
128
api/clarify.py
Normal file
128
api/clarify.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""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 threading
|
||||
from typing import Optional
|
||||
|
||||
|
||||
_lock = threading.Lock()
|
||||
_pending: dict[str, dict] = {}
|
||||
_gateway_queues: dict[str, list] = {}
|
||||
_gateway_notify_cbs: dict[str, object] = {}
|
||||
|
||||
|
||||
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 submit_pending(session_key: str, data: dict) -> _ClarifyEntry:
|
||||
"""Queue a pending clarify request and notify the UI callback if registered."""
|
||||
with _lock:
|
||||
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 queue:
|
||||
last = 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] = queue[0].data
|
||||
if cb:
|
||||
try:
|
||||
cb(dict(entry.data))
|
||||
except Exception:
|
||||
pass
|
||||
return entry
|
||||
|
||||
entry = _ClarifyEntry(data)
|
||||
queue.append(entry)
|
||||
_pending[session_key] = queue[0].data
|
||||
cb = _gateway_notify_cbs.get(session_key)
|
||||
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:
|
||||
queue = _gateway_queues.get(session_key)
|
||||
if not queue:
|
||||
_pending.pop(session_key, None)
|
||||
return 0
|
||||
entries = list(queue) if resolve_all else [queue.pop(0)]
|
||||
if queue:
|
||||
_pending[session_key] = queue[0].data
|
||||
else:
|
||||
_clear_queue_locked(session_key)
|
||||
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
|
||||
1915
api/config.py
1915
api/config.py
File diff suppressed because it is too large
Load Diff
242
api/gateway_watcher.py
Normal file
242
api/gateway_watcher.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""
|
||||
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 sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from api.config import HOME
|
||||
|
||||
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:
|
||||
with sqlite3.connect(str(db_path)) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT s.id, s.title, s.model, s.message_count,
|
||||
s.started_at, s.source,
|
||||
MAX(m.timestamp) AS last_activity
|
||||
FROM sessions s
|
||||
LEFT JOIN messages m ON m.session_id = s.id
|
||||
WHERE s.source IS NOT NULL AND s.source != 'webui'
|
||||
GROUP BY s.id
|
||||
HAVING COUNT(m.id) > 0
|
||||
ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC
|
||||
LIMIT 200
|
||||
""")
|
||||
sessions = []
|
||||
for row in cur.fetchall():
|
||||
sessions.append({
|
||||
'session_id': row['id'],
|
||||
'title': row['title'] or 'Agent Session',
|
||||
'model': row['model'] or None,
|
||||
'message_count': row['message_count'] or 0,
|
||||
'created_at': row['started_at'],
|
||||
'updated_at': row['last_activity'] or row['started_at'],
|
||||
'source': row['source'] or 'cli',
|
||||
})
|
||||
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
|
||||
186
api/helpers.py
186
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()
|
||||
@@ -25,33 +35,195 @@ def safe_resolve(root: Path, requested: str) -> Path:
|
||||
return resolved
|
||||
|
||||
|
||||
def j(handler, payload, status=200):
|
||||
"""Send a JSON response."""
|
||||
def _security_headers(handler):
|
||||
"""Add security headers to every response."""
|
||||
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'; "
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||
"img-src 'self' data: https: blob:; font-src 'self' data: https://cdn.jsdelivr.net; connect-src 'self'; "
|
||||
"base-uri 'self'; form-action 'self'"
|
||||
)
|
||||
handler.send_header(
|
||||
'Permissions-Policy',
|
||||
'camera=(), microphone=(self), geolocation=()'
|
||||
)
|
||||
|
||||
|
||||
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')
|
||||
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)
|
||||
handler.send_header('Content-Type', content_type)
|
||||
handler.send_header('Content-Length', str(len(body)))
|
||||
handler.send_header('Cache-Control', 'no-store')
|
||||
_security_headers(handler)
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
|
||||
|
||||
def read_body(handler):
|
||||
"""Read and JSON-parse a POST request body."""
|
||||
MAX_BODY_BYTES = 20 * 1024 * 1024 # 20MB limit for non-upload POST bodies
|
||||
|
||||
|
||||
# ── Credential redaction ──────────────────────────────────────────────────────
|
||||
|
||||
def _build_redact_fn():
|
||||
"""Return redact_sensitive_text from hermes-agent if available, else a fallback."""
|
||||
try:
|
||||
from agent.redact import redact_sensitive_text
|
||||
return redact_sensitive_text
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Minimal fallback covering the most common credential prefixes
|
||||
_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
|
||||
|
||||
return _fallback_redact
|
||||
|
||||
|
||||
_redact_text = _build_redact_fn()
|
||||
|
||||
|
||||
def _redact_value(v):
|
||||
"""Recursively redact credentials from strings, dicts, and lists."""
|
||||
if isinstance(v, str):
|
||||
return _redact_text(v)
|
||||
if isinstance(v, dict):
|
||||
return {k: _redact_value(val) for k, val in v.items()}
|
||||
if isinstance(v, list):
|
||||
return [_redact_value(item) 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.
|
||||
"""
|
||||
result = dict(session_dict)
|
||||
if isinstance(result.get('title'), str):
|
||||
result['title'] = _redact_text(result['title'])
|
||||
if 'messages' in result:
|
||||
result['messages'] = _redact_value(result['messages'])
|
||||
if 'tool_calls' in result:
|
||||
result['tool_calls'] = _redact_value(result['tool_calls'])
|
||||
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:
|
||||
raise ValueError(f'Request body too large ({length} bytes, max {MAX_BODY_BYTES})')
|
||||
raw = handler.rfile.read(length) if length else b'{}'
|
||||
try:
|
||||
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()
|
||||
|
||||
469
api/models.py
469
api/models.py
@@ -3,6 +3,8 @@ Hermes Web UI -- Session model and in-memory session store.
|
||||
"""
|
||||
import collections
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
@@ -10,41 +12,179 @@ from pathlib import Path
|
||||
import api.config as _cfg
|
||||
from api.config import (
|
||||
SESSION_DIR, SESSION_INDEX_FILE, SESSIONS, SESSIONS_MAX,
|
||||
LOCK, DEFAULT_WORKSPACE, DEFAULT_MODEL
|
||||
LOCK, DEFAULT_WORKSPACE, DEFAULT_MODEL, PROJECTS_FILE, HOME,
|
||||
get_effective_default_model,
|
||||
)
|
||||
from api.workspace import get_last_workspace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _write_session_index():
|
||||
"""Rebuild the session index file for O(1) future reads."""
|
||||
entries = []
|
||||
for p in SESSION_DIR.glob('*.json'):
|
||||
if p.name.startswith('_'): continue
|
||||
try:
|
||||
s = Session.load(p.stem)
|
||||
if s: entries.append(s.compact())
|
||||
except Exception:
|
||||
pass
|
||||
with LOCK:
|
||||
for s in SESSIONS.values():
|
||||
if not any(e['session_id'] == s.session_id for e in entries):
|
||||
entries.append(s.compact())
|
||||
entries.sort(key=lambda s: s['updated_at'], reverse=True)
|
||||
SESSION_INDEX_FILE.write_text(json.dumps(entries, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
def _index_entry_exists(session_id: str, in_memory_ids=None) -> bool:
|
||||
"""Return True if an index entry still has backing state.
|
||||
|
||||
A session can legitimately exist either as a persisted JSON file or as an
|
||||
in-memory Session object that has not been flushed yet. This helper is used
|
||||
to prune stale `_index.json` rows left behind after session-id rotation or
|
||||
file removal.
|
||||
"""
|
||||
if not session_id:
|
||||
return False
|
||||
if in_memory_ids is None:
|
||||
with LOCK:
|
||||
in_memory_ids = set(SESSIONS.keys())
|
||||
if session_id in in_memory_ids:
|
||||
return True
|
||||
p = SESSION_DIR / f'{session_id}.json'
|
||||
return p.exists()
|
||||
|
||||
|
||||
def _write_session_index(updates=None):
|
||||
"""Update the session index file.
|
||||
|
||||
When *updates* is provided (a list of Session objects whose compact
|
||||
entries should be refreshed), this does a targeted in-place update of
|
||||
the existing index — O(1) for single-session changes. When *updates*
|
||||
is None, a full rebuild is performed (used on startup / first call).
|
||||
"""
|
||||
# Lazy full-rebuild path — used when index doesn't exist yet.
|
||||
if updates is None or not SESSION_INDEX_FILE.exists():
|
||||
entries = []
|
||||
for p in SESSION_DIR.glob('*.json'):
|
||||
if p.name.startswith('_'): continue
|
||||
try:
|
||||
s = Session.load(p.stem)
|
||||
if s: entries.append(s.compact())
|
||||
except Exception:
|
||||
logger.debug("Failed to load session from %s", p)
|
||||
with LOCK:
|
||||
for s in SESSIONS.values():
|
||||
if not any(e['session_id'] == s.session_id for e in entries):
|
||||
entries.append(s.compact())
|
||||
entries.sort(key=lambda s: s['updated_at'], reverse=True)
|
||||
_tmp = SESSION_INDEX_FILE.with_suffix('.tmp')
|
||||
_tmp.write_text(json.dumps(entries, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
os.replace(_tmp, SESSION_INDEX_FILE)
|
||||
return
|
||||
|
||||
# Fast path: patch existing index with updated sessions.
|
||||
# This avoids loading every session file on every single save().
|
||||
# LOCK covers the entire read-patch-write to prevent concurrent save() calls
|
||||
# from both reading the same baseline and one losing its update.
|
||||
_fallback = False
|
||||
try:
|
||||
with LOCK:
|
||||
existing = json.loads(SESSION_INDEX_FILE.read_text(encoding='utf-8'))
|
||||
in_memory_ids = set(SESSIONS.keys())
|
||||
existing = [
|
||||
e for e in existing
|
||||
if _index_entry_exists(e.get('session_id'), in_memory_ids=in_memory_ids)
|
||||
]
|
||||
# Build lookup of updated entries
|
||||
updated_map = {s.session_id: s.compact() for s in updates}
|
||||
existing_ids = {e.get('session_id') for e in existing}
|
||||
# Add any updated entries not yet in the index
|
||||
for sid, entry in updated_map.items():
|
||||
if sid not in existing_ids:
|
||||
existing.append(entry)
|
||||
# Replace matching entries in-place
|
||||
for i, e in enumerate(existing):
|
||||
sid = e.get('session_id')
|
||||
if sid in updated_map:
|
||||
existing[i] = updated_map[sid]
|
||||
existing.sort(key=lambda s: s.get('updated_at', 0), reverse=True)
|
||||
_tmp = SESSION_INDEX_FILE.with_suffix('.tmp')
|
||||
_tmp.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
os.replace(_tmp, SESSION_INDEX_FILE)
|
||||
except Exception:
|
||||
_fallback = True
|
||||
if _fallback:
|
||||
# Corrupt or missing index — fall back to full rebuild (called outside LOCK to avoid deadlock)
|
||||
_write_session_index(updates=None)
|
||||
|
||||
|
||||
class Session:
|
||||
def __init__(self, session_id=None, title='Untitled', workspace=str(DEFAULT_WORKSPACE), model=DEFAULT_MODEL, messages=None, created_at=None, updated_at=None, tool_calls=None, pinned=False, archived=False, **kwargs):
|
||||
self.session_id = session_id or uuid.uuid4().hex[:12]; self.title = title; self.workspace = str(Path(workspace).expanduser().resolve()); self.model = model; self.messages = messages or []; self.tool_calls = tool_calls or []; self.created_at = created_at or time.time(); self.updated_at = updated_at or time.time(); self.pinned = bool(pinned); self.archived = bool(archived)
|
||||
def __init__(self, session_id: str=None, title: str='Untitled',
|
||||
workspace=str(DEFAULT_WORKSPACE), model=DEFAULT_MODEL,
|
||||
messages=None, created_at=None, updated_at=None,
|
||||
tool_calls=None, pinned: bool=False, archived: bool=False,
|
||||
project_id: str=None, profile=None,
|
||||
input_tokens: int=0, output_tokens: int=0, estimated_cost=None,
|
||||
personality=None,
|
||||
active_stream_id: str=None,
|
||||
pending_user_message: str=None,
|
||||
pending_attachments=None,
|
||||
pending_started_at=None,
|
||||
compression_anchor_visible_idx=None,
|
||||
compression_anchor_message_key=None,
|
||||
**kwargs):
|
||||
self.session_id = session_id or uuid.uuid4().hex[:12]
|
||||
self.title = title
|
||||
self.workspace = str(Path(workspace).expanduser().resolve())
|
||||
self.model = model
|
||||
self.messages = messages or []
|
||||
self.tool_calls = tool_calls or []
|
||||
self.created_at = created_at or time.time()
|
||||
self.updated_at = updated_at or time.time()
|
||||
self.pinned = bool(pinned)
|
||||
self.archived = bool(archived)
|
||||
self.project_id = project_id or None
|
||||
self.profile = profile
|
||||
self.input_tokens = input_tokens or 0
|
||||
self.output_tokens = output_tokens or 0
|
||||
self.estimated_cost = estimated_cost
|
||||
self.personality = personality
|
||||
self.active_stream_id = active_stream_id
|
||||
self.pending_user_message = pending_user_message
|
||||
self.pending_attachments = pending_attachments or []
|
||||
self.pending_started_at = pending_started_at
|
||||
self.compression_anchor_visible_idx = compression_anchor_visible_idx
|
||||
self.compression_anchor_message_key = compression_anchor_message_key
|
||||
|
||||
@property
|
||||
def path(self): return SESSION_DIR / f'{self.session_id}.json'
|
||||
def save(self): self.updated_at = time.time(); self.path.write_text(json.dumps(self.__dict__, ensure_ascii=False, indent=2), encoding='utf-8'); _write_session_index()
|
||||
def path(self):
|
||||
return SESSION_DIR / f'{self.session_id}.json'
|
||||
|
||||
def save(self, touch_updated_at: bool = True, skip_index: bool = False) -> None:
|
||||
if touch_updated_at:
|
||||
self.updated_at = time.time()
|
||||
self.path.write_text(
|
||||
json.dumps(self.__dict__, ensure_ascii=False, indent=2),
|
||||
encoding='utf-8',
|
||||
)
|
||||
if not skip_index:
|
||||
_write_session_index(updates=[self])
|
||||
|
||||
@classmethod
|
||||
def load(cls, sid):
|
||||
# Validate session ID format to prevent path traversal
|
||||
if not sid or not all(c in '0123456789abcdefghijklmnopqrstuvwxyz_' for c in sid):
|
||||
return None
|
||||
p = SESSION_DIR / f'{sid}.json'
|
||||
if not p.exists(): return None
|
||||
if not p.exists():
|
||||
return None
|
||||
return cls(**json.loads(p.read_text(encoding='utf-8')))
|
||||
def compact(self): return {'session_id': self.session_id, 'title': self.title, 'workspace': self.workspace, 'model': self.model, 'message_count': len(self.messages), 'created_at': self.created_at, 'updated_at': self.updated_at, 'pinned': self.pinned, 'archived': self.archived}
|
||||
|
||||
def compact(self) -> dict:
|
||||
return {
|
||||
'session_id': self.session_id,
|
||||
'title': self.title,
|
||||
'workspace': self.workspace,
|
||||
'model': self.model,
|
||||
'message_count': len(self.messages),
|
||||
'created_at': self.created_at,
|
||||
'updated_at': self.updated_at,
|
||||
'pinned': self.pinned,
|
||||
'archived': self.archived,
|
||||
'project_id': self.project_id,
|
||||
'profile': self.profile,
|
||||
'input_tokens': self.input_tokens,
|
||||
'output_tokens': self.output_tokens,
|
||||
'estimated_cost': self.estimated_cost,
|
||||
'personality': self.personality,
|
||||
'compression_anchor_visible_idx': self.compression_anchor_visible_idx,
|
||||
'compression_anchor_message_key': self.compression_anchor_message_key,
|
||||
}
|
||||
|
||||
def get_session(sid):
|
||||
with LOCK:
|
||||
@@ -61,9 +201,28 @@ def get_session(sid):
|
||||
return s
|
||||
raise KeyError(sid)
|
||||
|
||||
def new_session(workspace=None, model=None):
|
||||
# Use _cfg.DEFAULT_MODEL (not the import-time snapshot) so save_settings() changes take effect
|
||||
s = Session(workspace=workspace or get_last_workspace(), model=model or _cfg.DEFAULT_MODEL)
|
||||
def new_session(workspace=None, model=None, profile=None):
|
||||
"""Create a new in-memory session and persist it.
|
||||
|
||||
*profile* — when supplied by the caller (e.g. from the request body sent
|
||||
by the active browser tab), it is used directly so that concurrent clients
|
||||
on different profiles don't fight over a shared process-global. If not
|
||||
supplied, we fall back to the process-level active profile (the pre-#798
|
||||
behaviour, preserved for calls that originate outside a request context).
|
||||
"""
|
||||
if profile is None:
|
||||
# Fallback: read process-level global (single-client or startup path)
|
||||
try:
|
||||
from api.profiles import get_active_profile_name
|
||||
profile = get_active_profile_name()
|
||||
except ImportError:
|
||||
profile = None
|
||||
effective_model = model or get_effective_default_model()
|
||||
s = Session(
|
||||
workspace=workspace or get_last_workspace(),
|
||||
model=effective_model,
|
||||
profile=profile,
|
||||
)
|
||||
with LOCK:
|
||||
SESSIONS[s.session_id] = s
|
||||
SESSIONS.move_to_end(s.session_id)
|
||||
@@ -77,6 +236,10 @@ def all_sessions():
|
||||
if SESSION_INDEX_FILE.exists():
|
||||
try:
|
||||
index = json.loads(SESSION_INDEX_FILE.read_text(encoding='utf-8'))
|
||||
index = [
|
||||
s for s in index
|
||||
if _index_entry_exists(s.get('session_id'))
|
||||
]
|
||||
# Overlay any in-memory sessions that may be newer than the index
|
||||
index_map = {s['session_id']: s for s in index}
|
||||
with LOCK:
|
||||
@@ -84,10 +247,21 @@ def all_sessions():
|
||||
index_map[s.session_id] = s.compact()
|
||||
result = sorted(index_map.values(), key=lambda s: (s.get('pinned', False), s['updated_at']), reverse=True)
|
||||
# Hide empty Untitled sessions from the UI (created by tests, page refreshes, etc.)
|
||||
result = [s for s in result if not (s.get('title','Untitled')=='Untitled' and s.get('message_count',0)==0)]
|
||||
# Exempt sessions younger than 60 s so a brand-new session stays visible (#789)
|
||||
_now = time.time()
|
||||
result = [s for s in result if not (
|
||||
s.get('title', 'Untitled') == 'Untitled'
|
||||
and s.get('message_count', 0) == 0
|
||||
and (_now - s.get('updated_at', _now)) > 60
|
||||
)]
|
||||
# Backfill: sessions created before Sprint 22 have no profile tag.
|
||||
# Attribute them to 'default' so the client profile filter works correctly.
|
||||
for s in result:
|
||||
if not s.get('profile'):
|
||||
s['profile'] = 'default'
|
||||
return result
|
||||
except Exception:
|
||||
pass # fall through to full scan
|
||||
logger.debug("Failed to load session index, falling back to full scan")
|
||||
# Full scan fallback
|
||||
out = []
|
||||
for p in SESSION_DIR.glob('*.json'):
|
||||
@@ -96,14 +270,23 @@ def all_sessions():
|
||||
s = Session.load(p.stem)
|
||||
if s: out.append(s)
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("Failed to load session from %s", p)
|
||||
for s in SESSIONS.values():
|
||||
if all(s.session_id != x.session_id for x in out): out.append(s)
|
||||
out.sort(key=lambda s: (getattr(s, 'pinned', False), s.updated_at), reverse=True)
|
||||
return [s.compact() for s in out if not (s.title=='Untitled' and len(s.messages)==0)]
|
||||
_now = time.time()
|
||||
result = [s.compact() for s in out if not (
|
||||
s.title == 'Untitled'
|
||||
and len(s.messages) == 0
|
||||
and (_now - s.updated_at) > 60
|
||||
)]
|
||||
for s in result:
|
||||
if not s.get('profile'):
|
||||
s['profile'] = 'default'
|
||||
return result
|
||||
|
||||
|
||||
def title_from(messages, fallback='Untitled'):
|
||||
def title_from(messages, fallback: str='Untitled'):
|
||||
"""Derive a session title from the first user message."""
|
||||
for m in messages:
|
||||
if m.get('role') == 'user':
|
||||
@@ -114,3 +297,227 @@ def title_from(messages, fallback='Untitled'):
|
||||
if text:
|
||||
return text[:64]
|
||||
return fallback
|
||||
|
||||
|
||||
# ── Project helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def load_projects() -> list:
|
||||
"""Load project list from disk. Returns list of project dicts."""
|
||||
if not PROJECTS_FILE.exists():
|
||||
return []
|
||||
try:
|
||||
return json.loads(PROJECTS_FILE.read_text(encoding='utf-8'))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def save_projects(projects) -> None:
|
||||
"""Write project list to disk."""
|
||||
PROJECTS_FILE.write_text(json.dumps(projects, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
|
||||
def import_cli_session(
|
||||
session_id: str,
|
||||
title: str,
|
||||
messages,
|
||||
model: str='unknown',
|
||||
profile=None,
|
||||
created_at=None,
|
||||
updated_at=None,
|
||||
):
|
||||
"""Create a new WebUI session populated with CLI messages.
|
||||
Returns the Session object.
|
||||
"""
|
||||
s = Session(
|
||||
session_id=session_id,
|
||||
title=title,
|
||||
workspace=get_last_workspace(),
|
||||
model=model,
|
||||
messages=messages,
|
||||
profile=profile,
|
||||
created_at=created_at,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
s.save(touch_updated_at=False)
|
||||
return s
|
||||
|
||||
|
||||
# ── CLI session bridge ──────────────────────────────────────────────────────
|
||||
|
||||
def get_cli_sessions() -> list:
|
||||
"""Read CLI sessions from the agent's SQLite store and return them as
|
||||
dicts in a format the WebUI sidebar can render alongside local sessions.
|
||||
|
||||
Returns empty list if the SQLite DB is missing, the sqlite3 module is
|
||||
unavailable, or any error occurs -- the bridge is purely additive and never
|
||||
crashes the WebUI.
|
||||
"""
|
||||
import os
|
||||
cli_sessions = []
|
||||
try:
|
||||
import sqlite3
|
||||
except ImportError:
|
||||
return cli_sessions
|
||||
|
||||
# Use the active WebUI profile's HERMES_HOME to find state.db.
|
||||
# The active profile is determined by what the user has selected in the UI
|
||||
# (stored in the server's runtime config). This means:
|
||||
# - default profile -> ~/.hermes/state.db
|
||||
# - named profile X -> ~/.hermes/profiles/X/state.db
|
||||
# We resolve the active profile's home directory rather than just using
|
||||
# HERMES_HOME (which is the server's launch profile, not necessarily the
|
||||
# active one after a profile switch).
|
||||
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()
|
||||
|
||||
db_path = hermes_home / 'state.db'
|
||||
if not db_path.exists():
|
||||
return cli_sessions
|
||||
|
||||
# Try to resolve the active CLI profile so imported sessions integrate
|
||||
# with the WebUI profile filter (available since Sprint 22).
|
||||
try:
|
||||
from api.profiles import get_active_profile_name
|
||||
_cli_profile = get_active_profile_name()
|
||||
except ImportError:
|
||||
_cli_profile = None # older agent -- fall back to no profile
|
||||
|
||||
try:
|
||||
with sqlite3.connect(str(db_path)) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
# Introspect schema to handle older hermes-agent versions that
|
||||
# may not have a 'source' column. Without this check the query raises
|
||||
# OperationalError which is silently swallowed, causing the empty-list bug.
|
||||
cur.execute("PRAGMA table_info(sessions)")
|
||||
_session_cols = {row[1] for row in cur.fetchall()}
|
||||
if 'source' not in _session_cols:
|
||||
import logging as _logging
|
||||
_logging.getLogger(__name__).warning(
|
||||
"get_cli_sessions(): state.db at %s has no 'source' column "
|
||||
"(older hermes-agent?). CLI sessions unavailable. "
|
||||
"Upgrade hermes-agent to fix this.",
|
||||
db_path,
|
||||
)
|
||||
return cli_sessions
|
||||
|
||||
cur.execute("""
|
||||
SELECT s.id, s.title, s.model, s.message_count,
|
||||
s.started_at, s.source,
|
||||
MAX(m.timestamp) AS last_activity
|
||||
FROM sessions s
|
||||
LEFT JOIN messages m ON m.session_id = s.id
|
||||
WHERE s.source IS NOT NULL AND s.source != 'webui'
|
||||
GROUP BY s.id
|
||||
ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC
|
||||
LIMIT 200
|
||||
""")
|
||||
for row in cur.fetchall():
|
||||
sid = row['id']
|
||||
raw_ts = row['last_activity'] or row['started_at']
|
||||
# Prefer the CLI session's own profile from the DB; fall back to
|
||||
# the active CLI profile so sidebar filtering works either way.
|
||||
profile = _cli_profile # CLI DB has no profile column; use active profile
|
||||
|
||||
_source = row['source'] or 'cli'
|
||||
_display_title = row['title'] or f'{_source.title()} Session'
|
||||
cli_sessions.append({
|
||||
'session_id': sid,
|
||||
'title': _display_title,
|
||||
'workspace': str(get_last_workspace()),
|
||||
'model': row['model'] or None,
|
||||
'message_count': row['message_count'] or 0,
|
||||
'created_at': row['started_at'],
|
||||
'updated_at': raw_ts,
|
||||
'pinned': False,
|
||||
'archived': False,
|
||||
'project_id': None,
|
||||
'profile': profile,
|
||||
'source_tag': _source,
|
||||
'is_cli_session': True,
|
||||
})
|
||||
except Exception as _cli_err:
|
||||
# DB schema changed, locked, or corrupted -- log warning so admins can diagnose.
|
||||
# Still degrade gracefully (don't crash the WebUI).
|
||||
import logging as _logging
|
||||
_logging.getLogger(__name__).warning(
|
||||
"get_cli_sessions() failed — check state.db schema or path (%s): %s",
|
||||
db_path, _cli_err,
|
||||
)
|
||||
return []
|
||||
|
||||
return cli_sessions
|
||||
|
||||
|
||||
def get_cli_session_messages(sid) -> list:
|
||||
"""Read messages for a single CLI session from the SQLite store.
|
||||
Returns a list of {role, content, timestamp} dicts.
|
||||
Returns empty list on any error.
|
||||
"""
|
||||
import os
|
||||
try:
|
||||
import sqlite3
|
||||
except ImportError:
|
||||
return []
|
||||
|
||||
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()
|
||||
db_path = hermes_home / 'state.db'
|
||||
if not db_path.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
with sqlite3.connect(str(db_path)) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT role, content, timestamp
|
||||
FROM messages
|
||||
WHERE session_id = ?
|
||||
ORDER BY timestamp ASC
|
||||
""", (sid,))
|
||||
msgs = []
|
||||
for row in cur.fetchall():
|
||||
msgs.append({
|
||||
'role': row['role'],
|
||||
'content': row['content'],
|
||||
'timestamp': row['timestamp'],
|
||||
})
|
||||
except Exception:
|
||||
return []
|
||||
return msgs
|
||||
|
||||
|
||||
def delete_cli_session(sid) -> bool:
|
||||
"""Delete a CLI session from state.db (messages + session row).
|
||||
Returns True if deleted, False if not found or error.
|
||||
"""
|
||||
import os
|
||||
try:
|
||||
import sqlite3
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
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()
|
||||
db_path = hermes_home / 'state.db'
|
||||
if not db_path.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
with sqlite3.connect(str(db_path)) as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
|
||||
cur.execute("DELETE FROM sessions WHERE id = ?", (sid,))
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
568
api/onboarding.py
Normal file
568
api/onboarding.py
Normal file
@@ -0,0 +1,568 @@
|
||||
"""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.workspace import get_last_workspace, load_workspaces
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_SUPPORTED_PROVIDER_SETUPS = {
|
||||
"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
|
||||
],
|
||||
},
|
||||
"anthropic": {
|
||||
"label": "Anthropic",
|
||||
"env_var": "ANTHROPIC_API_KEY",
|
||||
"default_model": "claude-sonnet-4.6",
|
||||
"requires_base_url": False,
|
||||
"models": list(_PROVIDER_MODELS.get("anthropic", [])),
|
||||
},
|
||||
"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", [])),
|
||||
},
|
||||
"custom": {
|
||||
"label": "Custom OpenAI-compatible",
|
||||
"env_var": "OPENAI_API_KEY",
|
||||
"default_model": "gpt-4o-mini",
|
||||
"requires_base_url": True,
|
||||
"models": [],
|
||||
},
|
||||
}
|
||||
|
||||
_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 _write_env_file(env_path: Path, updates: dict[str, str]) -> None:
|
||||
current = _load_env_file(env_path)
|
||||
for key, value in updates.items():
|
||||
if value is None:
|
||||
current.pop(key, None)
|
||||
os.environ.pop(key, None)
|
||||
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.")
|
||||
current[key] = clean
|
||||
os.environ[key] = clean
|
||||
|
||||
env_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lines = [f"{key}={current[key]}" for key in sorted(current)]
|
||||
env_path.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
|
||||
|
||||
|
||||
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", [])),
|
||||
"quick": provider_id == "openrouter",
|
||||
}
|
||||
)
|
||||
|
||||
# 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,
|
||||
"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 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.
|
||||
config_exists = Path(_get_config_path()).exists()
|
||||
config_auto_completed = config_exists and bool(runtime.get("chat_ready"))
|
||||
|
||||
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 == "custom":
|
||||
model_cfg["base_url"] = base_url
|
||||
elif provider == "openai":
|
||||
model_cfg["base_url"] = (
|
||||
provider_meta.get("default_base_url") or "https://api.openai.com/v1"
|
||||
)
|
||||
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()
|
||||
531
api/profiles.py
Normal file
531
api/profiles.py
Normal file
@@ -0,0 +1,531 @@
|
||||
"""
|
||||
Hermes Web UI -- Profile state management.
|
||||
Wraps hermes_cli.profiles to provide profile switching for the web UI.
|
||||
|
||||
The web UI maintains a process-level "active profile" that determines which
|
||||
HERMES_HOME directory is used for config, skills, memory, cron, and API keys.
|
||||
Profile switches update os.environ['HERMES_HOME'] and monkey-patch module-level
|
||||
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 = [
|
||||
'memories', 'sessions', 'skills', 'skins',
|
||||
'logs', 'plans', 'workspace', 'cron',
|
||||
]
|
||||
_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/.
|
||||
|
||||
This is intentionally distinct from HERMES_HOME, which tracks the *active
|
||||
profile's* home and changes on every profile switch. The base dir must
|
||||
always point to the top-level .hermes regardless of which profile is active.
|
||||
|
||||
Resolution order:
|
||||
1. HERMES_BASE_HOME env var (set explicitly, highest priority)
|
||||
2. HERMES_HOME env var — but only if it does NOT look like a profile subdir
|
||||
(i.e. its parent is not named 'profiles'). This handles test isolation
|
||||
where HERMES_HOME is set to an isolated test state dir.
|
||||
3. ~/.hermes (always-correct default)
|
||||
|
||||
The bug this prevents: if HERMES_HOME has already been mutated to
|
||||
/home/user/.hermes/profiles/webui (by init_profile_state at startup),
|
||||
reading it here would make _DEFAULT_HERMES_HOME point to that subdir,
|
||||
causing switch_profile('webui') to look for
|
||||
/home/user/.hermes/profiles/webui/profiles/webui — which doesn't exist.
|
||||
"""
|
||||
# Explicit override for tests or unusual setups
|
||||
base_override = os.getenv('HERMES_BASE_HOME', '').strip()
|
||||
if base_override:
|
||||
return Path(base_override).expanduser()
|
||||
|
||||
hermes_home = os.getenv('HERMES_HOME', '').strip()
|
||||
if hermes_home:
|
||||
p = Path(hermes_home).expanduser()
|
||||
# If HERMES_HOME points to a profiles/ subdir, walk up two levels to the base
|
||||
if p.parent.name == 'profiles':
|
||||
return p.parent.parent
|
||||
# Otherwise trust it (e.g. test isolation sets HERMES_HOME to TEST_STATE_DIR)
|
||||
return p
|
||||
|
||||
return Path.home() / '.hermes'
|
||||
|
||||
_DEFAULT_HERMES_HOME = _resolve_base_hermes_home()
|
||||
|
||||
|
||||
def _read_active_profile_file() -> str:
|
||||
"""Read the sticky active profile from ~/.hermes/active_profile."""
|
||||
ap_file = _DEFAULT_HERMES_HOME / 'active_profile'
|
||||
if ap_file.exists():
|
||||
try:
|
||||
name = ap_file.read_text(encoding="utf-8").strip()
|
||||
if name:
|
||||
return name
|
||||
except Exception:
|
||||
logger.debug("Failed to read active profile file")
|
||||
return 'default'
|
||||
|
||||
|
||||
# ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
def get_active_profile_name() -> str:
|
||||
"""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.
|
||||
|
||||
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' / 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.match(name):
|
||||
return _DEFAULT_HERMES_HOME
|
||||
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name
|
||||
if profile_dir.is_dir():
|
||||
return profile_dir
|
||||
return _DEFAULT_HERMES_HOME
|
||||
|
||||
|
||||
def _set_hermes_home(home: Path):
|
||||
"""Set HERMES_HOME env var and monkey-patch cached module-level paths."""
|
||||
os.environ['HERMES_HOME'] = str(home)
|
||||
|
||||
# Patch skills_tool module-level cache (snapshots HERMES_HOME at import)
|
||||
try:
|
||||
import tools.skills_tool as _sk
|
||||
_sk.HERMES_HOME = home
|
||||
_sk.SKILLS_DIR = home / 'skills'
|
||||
except (ImportError, AttributeError):
|
||||
logger.debug("Failed to patch skills_tool module")
|
||||
|
||||
# Patch cron/jobs module-level cache
|
||||
try:
|
||||
import cron.jobs as _cj
|
||||
_cj.HERMES_DIR = home
|
||||
_cj.CRON_DIR = home / 'cron'
|
||||
_cj.JOBS_FILE = _cj.CRON_DIR / 'jobs.json'
|
||||
_cj.OUTPUT_DIR = _cj.CRON_DIR / 'output'
|
||||
except (ImportError, AttributeError):
|
||||
logger.debug("Failed to patch cron.jobs module")
|
||||
|
||||
|
||||
def _reload_dotenv(home: Path):
|
||||
"""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:
|
||||
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)
|
||||
k = k.strip()
|
||||
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:
|
||||
_loaded_profile_env_keys = set()
|
||||
logger.debug("Failed to reload dotenv from %s", env_path)
|
||||
|
||||
|
||||
def init_profile_state() -> None:
|
||||
"""Initialize profile state at server startup.
|
||||
|
||||
Reads ~/.hermes/active_profile, sets HERMES_HOME env var, patches
|
||||
module-level cached paths. Called once from config.py after imports.
|
||||
"""
|
||||
global _active_profile
|
||||
_active_profile = _read_active_profile_file()
|
||||
home = get_active_hermes_home()
|
||||
_set_hermes_home(home)
|
||||
_reload_dotenv(home)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
global _active_profile
|
||||
|
||||
# Import here to avoid circular import at module load
|
||||
from api.config import STREAMS, STREAMS_LOCK, reload_config
|
||||
|
||||
# Block if agent is running
|
||||
with STREAMS_LOCK:
|
||||
if len(STREAMS) > 0:
|
||||
raise RuntimeError(
|
||||
'Cannot switch profiles while an agent is running. '
|
||||
'Cancel or wait for it to finish.'
|
||||
)
|
||||
|
||||
# Resolve profile directory
|
||||
if name == 'default':
|
||||
home = _DEFAULT_HERMES_HOME
|
||||
else:
|
||||
home = _resolve_named_profile_home(name)
|
||||
if not home.is_dir():
|
||||
raise ValueError(f"Profile '{name}' does not exist.")
|
||||
|
||||
with _profile_lock:
|
||||
if process_wide:
|
||||
global _active_profile
|
||||
_active_profile = name
|
||||
_set_hermes_home(home)
|
||||
_reload_dotenv(home)
|
||||
|
||||
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()
|
||||
|
||||
# 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.
|
||||
from api.workspace import get_last_workspace
|
||||
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):
|
||||
default_model = model_cfg
|
||||
elif isinstance(model_cfg, dict):
|
||||
default_model = model_cfg.get('default')
|
||||
|
||||
return {
|
||||
'profiles': list_profiles_api(),
|
||||
'active': name,
|
||||
'default_model': default_model,
|
||||
'default_workspace': get_last_workspace(),
|
||||
}
|
||||
|
||||
|
||||
def list_profiles_api() -> list:
|
||||
"""List all profiles with metadata, serialized for JSON response."""
|
||||
try:
|
||||
from hermes_cli.profiles import list_profiles
|
||||
infos = list_profiles()
|
||||
except ImportError:
|
||||
# hermes_cli not available -- return just the default
|
||||
return [_default_profile_dict()]
|
||||
|
||||
active = get_active_profile_name()
|
||||
result = []
|
||||
for p in infos:
|
||||
result.append({
|
||||
'name': p.name,
|
||||
'path': str(p.path),
|
||||
'is_default': p.is_default,
|
||||
'is_active': p.name == active,
|
||||
'gateway_running': p.gateway_running,
|
||||
'model': p.model,
|
||||
'provider': p.provider,
|
||||
'has_env': p.has_env,
|
||||
'skill_count': p.skill_count,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _default_profile_dict() -> dict:
|
||||
"""Fallback profile dict when hermes_cli is not importable."""
|
||||
return {
|
||||
'name': 'default',
|
||||
'path': str(_DEFAULT_HERMES_HOME),
|
||||
'is_default': True,
|
||||
'is_active': True,
|
||||
'gateway_running': False,
|
||||
'model': None,
|
||||
'provider': None,
|
||||
'has_env': (_DEFAULT_HERMES_HOME / '.env').exists(),
|
||||
'skill_count': 0,
|
||||
}
|
||||
|
||||
|
||||
def _validate_profile_name(name: str):
|
||||
"""Validate profile name format (matches hermes_cli.profiles upstream)."""
|
||||
if name == 'default':
|
||||
raise ValueError("Cannot create a profile named 'default' -- it is the built-in profile.")
|
||||
# Use fullmatch (not match) so a trailing newline can't sneak past the $ anchor
|
||||
if not _PROFILE_ID_RE.fullmatch(name):
|
||||
raise ValueError(
|
||||
f"Invalid profile name {name!r}. "
|
||||
"Must match [a-z0-9][a-z0-9_-]{0,63}"
|
||||
)
|
||||
|
||||
|
||||
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)."""
|
||||
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name
|
||||
if profile_dir.exists():
|
||||
raise FileExistsError(f"Profile '{name}' already exists.")
|
||||
|
||||
# Bootstrap directory structure (exist_ok=False so a concurrent create raises)
|
||||
profile_dir.mkdir(parents=True, exist_ok=False)
|
||||
for subdir in _PROFILE_DIRS:
|
||||
(profile_dir / subdir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Clone config files from source profile if requested
|
||||
if clone_config and clone_from:
|
||||
if clone_from == 'default':
|
||||
source_dir = _DEFAULT_HERMES_HOME
|
||||
else:
|
||||
source_dir = _DEFAULT_HERMES_HOME / 'profiles' / clone_from
|
||||
if source_dir.is_dir():
|
||||
for filename in _CLONE_CONFIG_FILES:
|
||||
src = source_dir / filename
|
||||
if src.exists():
|
||||
shutil.copy2(src, profile_dir / filename)
|
||||
|
||||
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,
|
||||
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
|
||||
# also validates it. Any caller that bypasses the HTTP layer gets protection.
|
||||
if clone_from is not None and clone_from != 'default':
|
||||
_validate_profile_name(clone_from)
|
||||
|
||||
try:
|
||||
from hermes_cli.profiles import create_profile
|
||||
create_profile(
|
||||
name,
|
||||
clone_from=clone_from,
|
||||
clone_config=clone_config,
|
||||
clone_all=False,
|
||||
no_alias=True,
|
||||
)
|
||||
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.
|
||||
for p in list_profiles_api():
|
||||
if p['name'] == name:
|
||||
return p
|
||||
return {
|
||||
'name': name,
|
||||
'path': str(profile_path),
|
||||
'is_default': False,
|
||||
'is_active': _active_profile == name,
|
||||
'gateway_running': False,
|
||||
'model': None,
|
||||
'provider': None,
|
||||
'has_env': (profile_path / '.env').exists(),
|
||||
'skill_count': 0,
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
try:
|
||||
switch_profile('default')
|
||||
except RuntimeError:
|
||||
raise RuntimeError(
|
||||
f"Cannot delete active profile '{name}' while an agent is running. "
|
||||
"Cancel or wait for it to finish."
|
||||
)
|
||||
|
||||
try:
|
||||
from hermes_cli.profiles import delete_profile
|
||||
delete_profile(name, yes=True)
|
||||
except ImportError:
|
||||
# Manual fallback: just remove the directory
|
||||
import shutil
|
||||
profile_dir = _resolve_named_profile_home(name)
|
||||
if profile_dir.is_dir():
|
||||
shutil.rmtree(str(profile_dir))
|
||||
else:
|
||||
raise ValueError(f"Profile '{name}' does not exist.")
|
||||
|
||||
return {'ok': True, 'name': name}
|
||||
331
api/providers.py
Normal file
331
api/providers.py
Normal file
@@ -0,0 +1,331 @@
|
||||
"""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,
|
||||
invalidate_models_cache,
|
||||
)
|
||||
|
||||
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",
|
||||
"mistralai": "MISTRAL_API_KEY",
|
||||
"x-ai": "XAI_API_KEY",
|
||||
"opencode-zen": "OPENCODE_ZEN_API_KEY",
|
||||
"opencode-go": "OPENCODE_GO_API_KEY",
|
||||
"ollama": "OLLAMA_API_KEY",
|
||||
"ollama-cloud": "OLLAMA_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",
|
||||
"openai-codex",
|
||||
"nous",
|
||||
})
|
||||
|
||||
# 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.
|
||||
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:
|
||||
current = _load_env_file(env_path)
|
||||
for key, value in updates.items():
|
||||
if value is None:
|
||||
current.pop(key, None)
|
||||
os.environ.pop(key, None)
|
||||
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.")
|
||||
current[key] = clean
|
||||
os.environ[key] = clean
|
||||
|
||||
env_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lines = [f"{key}={current[key]}" for key in sorted(current)]
|
||||
# Create at owner-only mode from the first byte (O_CREAT honours the mode
|
||||
# argument subject to umask). A trailing chmod guards pre-existing files.
|
||||
_mode = _stat.S_IRUSR | _stat.S_IWUSR # 0o600
|
||||
_fd = os.open(str(env_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, _mode)
|
||||
with os.fdopen(_fd, "w", encoding="utf-8") as _f:
|
||||
_f.write("\n".join(lines) + ("\n" if lines else ""))
|
||||
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``
|
||||
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
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, dict) and str(model_cfg.get("api_key") or "").strip():
|
||||
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"
|
||||
if is_oauth:
|
||||
key_source = "oauth"
|
||||
# Check if actually authenticated via hermes_cli
|
||||
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")
|
||||
else:
|
||||
has_key = False
|
||||
except Exception:
|
||||
has_key = False
|
||||
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"
|
||||
|
||||
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,
|
||||
"key_source": key_source,
|
||||
"models": 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.
|
||||
|
||||
Convenience wrapper around ``set_provider_key(id, None)``.
|
||||
"""
|
||||
return set_provider_key(provider_id, None)
|
||||
3269
api/routes.py
3269
api/routes.py
File diff suppressed because it is too large
Load Diff
151
api/session_ops.py
Normal file
151
api/session_ops.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""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
|
||||
from api.models import get_session, SESSIONS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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
|
||||
"""
|
||||
# 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 outside
|
||||
# the lock and is naturally last-write-wins on a consistent state.
|
||||
#
|
||||
# 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]
|
||||
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
|
||||
"""
|
||||
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]
|
||||
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)
|
||||
return {
|
||||
'session_id': s.session_id,
|
||||
'title': s.title,
|
||||
'model': s.model,
|
||||
'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)),
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
104
api/startup.py
Normal file
104
api/startup.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""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 are chmod 600 (owner-only)."""
|
||||
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 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")
|
||||
1591
api/streaming.py
1591
api/streaming.py
File diff suppressed because it is too large
Load Diff
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,
|
||||
}
|
||||
@@ -3,6 +3,7 @@ Hermes Web UI -- File upload: multipart parser and upload handler.
|
||||
"""
|
||||
import re as _re
|
||||
import email.parser
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from api.config import MAX_UPLOAD_BYTES
|
||||
@@ -11,7 +12,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 +51,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,9 +77,55 @@ 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:
|
||||
return j(handler, {'error': str(e), 'trace': _tb.format_exc()}, status=500)
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
|
||||
334
api/workspace.py
334
api/workspace.py
@@ -1,46 +1,306 @@
|
||||
"""
|
||||
Hermes Web UI -- Workspace and file system helpers.
|
||||
|
||||
Workspace lists and last-used workspace are stored per-profile so each
|
||||
profile has its own workspace configuration. State files live at
|
||||
``{profile_home}/webui_state/workspaces.json`` and
|
||||
``{profile_home}/webui_state/last_workspace.txt``. The global STATE_DIR
|
||||
paths are used as fallback when no profile module is available.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from api.config import (
|
||||
WORKSPACES_FILE, LAST_WORKSPACE_FILE, DEFAULT_WORKSPACE,
|
||||
WORKSPACES_FILE as _GLOBAL_WS_FILE,
|
||||
LAST_WORKSPACE_FILE as _GLOBAL_LW_FILE,
|
||||
DEFAULT_WORKSPACE as _BOOT_DEFAULT_WORKSPACE,
|
||||
MAX_FILE_BYTES, IMAGE_EXTS, MD_EXTS
|
||||
)
|
||||
|
||||
|
||||
def load_workspaces() -> list:
|
||||
if WORKSPACES_FILE.exists():
|
||||
# ── Profile-aware path resolution ───────────────────────────────────────────
|
||||
|
||||
def _profile_state_dir() -> Path:
|
||||
"""Return the webui_state directory for the active profile.
|
||||
|
||||
For the default profile, returns the global STATE_DIR (respects
|
||||
HERMES_WEBUI_STATE_DIR env var for test isolation).
|
||||
For named profiles, returns {profile_home}/webui_state/.
|
||||
"""
|
||||
try:
|
||||
from api.profiles import get_active_profile_name, get_active_hermes_home
|
||||
name = get_active_profile_name()
|
||||
if name and name != 'default':
|
||||
d = get_active_hermes_home() / 'webui_state'
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
except ImportError:
|
||||
logger.debug("Failed to import profiles module, using global state dir")
|
||||
return _GLOBAL_WS_FILE.parent
|
||||
|
||||
|
||||
def _workspaces_file() -> Path:
|
||||
"""Return the workspaces.json path for the active profile."""
|
||||
return _profile_state_dir() / 'workspaces.json'
|
||||
|
||||
|
||||
def _last_workspace_file() -> Path:
|
||||
"""Return the last_workspace.txt path for the active profile."""
|
||||
return _profile_state_dir() / 'last_workspace.txt'
|
||||
|
||||
|
||||
def _profile_default_workspace() -> str:
|
||||
"""Read the profile's default workspace from its config.yaml.
|
||||
|
||||
Checks keys in priority order:
|
||||
1. 'workspace' — explicit webui workspace key
|
||||
2. 'default_workspace' — alternate explicit key
|
||||
3. 'terminal.cwd' — hermes-agent terminal working dir (most common)
|
||||
|
||||
Falls back to the boot-time DEFAULT_WORKSPACE constant.
|
||||
"""
|
||||
try:
|
||||
from api.config import get_config
|
||||
cfg = get_config()
|
||||
# Explicit webui workspace keys first
|
||||
for key in ('workspace', 'default_workspace'):
|
||||
ws = cfg.get(key)
|
||||
if ws:
|
||||
p = Path(str(ws)).expanduser().resolve()
|
||||
if p.is_dir():
|
||||
return str(p)
|
||||
# Fall through to terminal.cwd — the agent's configured working directory
|
||||
terminal_cfg = cfg.get('terminal', {})
|
||||
if isinstance(terminal_cfg, dict):
|
||||
cwd = terminal_cfg.get('cwd', '')
|
||||
if cwd and str(cwd) not in ('.', ''):
|
||||
p = Path(str(cwd)).expanduser().resolve()
|
||||
if p.is_dir():
|
||||
return str(p)
|
||||
except (ImportError, Exception):
|
||||
logger.debug("Failed to load profile default workspace config")
|
||||
return str(_BOOT_DEFAULT_WORKSPACE)
|
||||
|
||||
|
||||
# ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _clean_workspace_list(workspaces: list) -> list:
|
||||
"""Sanitize a workspace list:
|
||||
- Remove entries whose paths no longer exist on disk.
|
||||
- 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
|
||||
confusion with the 'default' profile name).
|
||||
Returns the cleaned list (may be empty).
|
||||
"""
|
||||
hermes_profiles = (Path.home() / '.hermes' / 'profiles').resolve()
|
||||
result = []
|
||||
for w in workspaces:
|
||||
path = w.get('path', '')
|
||||
name = w.get('name', '')
|
||||
p = Path(path).resolve() if path else Path('/')
|
||||
# Skip paths that no longer exist
|
||||
if not p.is_dir():
|
||||
continue
|
||||
# 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:
|
||||
return json.loads(WORKSPACES_FILE.read_text(encoding='utf-8'))
|
||||
p.relative_to(hermes_profiles)
|
||||
# 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 # not under profiles/ at all — keep it
|
||||
# Rename confusing 'default' label to 'Home'
|
||||
if name.lower() == 'default':
|
||||
name = 'Home'
|
||||
result.append({'path': str(p), 'name': name})
|
||||
return result
|
||||
|
||||
|
||||
def _migrate_global_workspaces() -> list:
|
||||
"""Read the legacy global workspaces.json, clean it, and return the result.
|
||||
|
||||
This is the migration path for users upgrading from a pre-profile version:
|
||||
their global file may contain cross-profile entries, test artifacts, and
|
||||
stale paths accumulated over time. We clean it in-place and rewrite it.
|
||||
"""
|
||||
if not _GLOBAL_WS_FILE.exists():
|
||||
return []
|
||||
try:
|
||||
raw = json.loads(_GLOBAL_WS_FILE.read_text(encoding='utf-8'))
|
||||
cleaned = _clean_workspace_list(raw)
|
||||
if len(cleaned) != len(raw):
|
||||
# Rewrite the cleaned version so future reads are already clean
|
||||
_GLOBAL_WS_FILE.write_text(
|
||||
json.dumps(cleaned, ensure_ascii=False, indent=2), encoding='utf-8'
|
||||
)
|
||||
return cleaned
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def load_workspaces() -> list:
|
||||
ws_file = _workspaces_file()
|
||||
if ws_file.exists():
|
||||
try:
|
||||
raw = json.loads(ws_file.read_text(encoding='utf-8'))
|
||||
cleaned = _clean_workspace_list(raw)
|
||||
if len(cleaned) != len(raw):
|
||||
# Persist the cleaned version so stale entries don't keep reappearing
|
||||
try:
|
||||
ws_file.write_text(
|
||||
json.dumps(cleaned, ensure_ascii=False, indent=2), encoding='utf-8'
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to persist cleaned workspace list")
|
||||
return cleaned or [{'path': _profile_default_workspace(), 'name': 'Home'}]
|
||||
except Exception:
|
||||
pass
|
||||
return [{'path': str(DEFAULT_WORKSPACE), 'name': 'default'}]
|
||||
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.
|
||||
try:
|
||||
from api.profiles import get_active_profile_name
|
||||
is_default = get_active_profile_name() in ('default', None)
|
||||
except ImportError:
|
||||
is_default = True
|
||||
if is_default:
|
||||
migrated = _migrate_global_workspaces()
|
||||
if migrated:
|
||||
return migrated
|
||||
# Fresh start: single entry from the profile's configured workspace, labeled "Home"
|
||||
return [{'path': _profile_default_workspace(), 'name': 'Home'}]
|
||||
|
||||
|
||||
def save_workspaces(workspaces: list):
|
||||
WORKSPACES_FILE.write_text(json.dumps(workspaces, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
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')
|
||||
|
||||
|
||||
def get_last_workspace() -> str:
|
||||
if LAST_WORKSPACE_FILE.exists():
|
||||
lw_file = _last_workspace_file()
|
||||
if lw_file.exists():
|
||||
try:
|
||||
p = LAST_WORKSPACE_FILE.read_text(encoding='utf-8').strip()
|
||||
p = lw_file.read_text(encoding='utf-8').strip()
|
||||
if p and Path(p).is_dir():
|
||||
return p
|
||||
except Exception:
|
||||
pass
|
||||
return str(DEFAULT_WORKSPACE)
|
||||
logger.debug("Failed to read last workspace from %s", lw_file)
|
||||
# Fallback: try global file
|
||||
if _GLOBAL_LW_FILE.exists():
|
||||
try:
|
||||
p = _GLOBAL_LW_FILE.read_text(encoding='utf-8').strip()
|
||||
if p and Path(p).is_dir():
|
||||
return p
|
||||
except Exception:
|
||||
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:
|
||||
LAST_WORKSPACE_FILE.write_text(str(path), encoding='utf-8')
|
||||
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 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).
|
||||
"""
|
||||
_BLOCKED_SYSTEM_ROOTS = {
|
||||
# Linux / macOS
|
||||
Path('/etc'), Path('/usr'), Path('/var'), Path('/bin'), Path('/sbin'),
|
||||
Path('/boot'), Path('/proc'), Path('/sys'), Path('/dev'),
|
||||
Path('/lib'), Path('/lib64'), Path('/opt/homebrew'),
|
||||
}
|
||||
|
||||
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}")
|
||||
|
||||
# Block known system roots and their children
|
||||
for blocked in _BLOCKED_SYSTEM_ROOTS:
|
||||
try:
|
||||
candidate.relative_to(blocked)
|
||||
raise ValueError(f"Path points to a system directory: {candidate}")
|
||||
except ValueError as e:
|
||||
if "system directory" in str(e):
|
||||
raise
|
||||
# relative_to raised ValueError = candidate is NOT under blocked = safe
|
||||
|
||||
# (A) Trusted if under the user's home directory — cross-platform via Path.home()
|
||||
try:
|
||||
candidate.relative_to(Path.home().resolve())
|
||||
return candidate
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# (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 safe_resolve_ws(root: Path, requested: str) -> Path:
|
||||
"""Resolve a relative path inside a workspace root, raising ValueError on traversal."""
|
||||
@@ -49,7 +309,7 @@ def safe_resolve_ws(root: Path, requested: str) -> Path:
|
||||
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}")
|
||||
@@ -66,7 +326,7 @@ def list_dir(workspace: Path, rel='.'):
|
||||
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}")
|
||||
@@ -75,3 +335,45 @@ def read_file_content(workspace: Path, rel: str):
|
||||
raise ValueError(f"File too large ({size} bytes, max {MAX_FILE_BYTES})")
|
||||
content = target.read_text(encoding='utf-8', errors='replace')
|
||||
return {'path': rel, 'content': content, 'size': size, 'lines': content.count('\n') + 1}
|
||||
|
||||
|
||||
# ── Git detection ──────────────────────────────────────────────────────────
|
||||
|
||||
def _run_git(args, cwd, timeout=3):
|
||||
"""Run a git command and return stdout, or None on failure."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['git'] + args, cwd=str(cwd), capture_output=True,
|
||||
text=True, timeout=timeout,
|
||||
)
|
||||
return r.stdout.strip() if r.returncode == 0 else None
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def git_info_for_workspace(workspace: Path) -> dict:
|
||||
"""Return git info for a workspace directory, or None if not a git repo."""
|
||||
if not (workspace / '.git').exists():
|
||||
return None
|
||||
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)
|
||||
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,
|
||||
'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"):
|
||||
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:/root/.hermes
|
||||
# Expose agent source so the WebUI can install dependencies from it
|
||||
- hermes-agent-src:/opt/hermes
|
||||
environment:
|
||||
- HERMES_HOME=/root/.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:/root/.hermes
|
||||
environment:
|
||||
- HERMES_HOME=/root/.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:/root/.hermes
|
||||
# Expose agent source so the WebUI can install dependencies from it
|
||||
- hermes-agent-src:/opt/hermes
|
||||
environment:
|
||||
- HERMES_HOME=/root/.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:
|
||||
37
docker-compose.yml
Normal file
37
docker-compose.yml
Normal file
@@ -0,0 +1,37 @@
|
||||
version: "3.8"
|
||||
|
||||
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:
|
||||
# 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
|
||||
# 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
|
||||
|
||||
330
docker_init.bash
Normal file
330
docker_init.bash
Normal file
@@ -0,0 +1,330 @@
|
||||
#!/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
|
||||
|
||||
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[honcho]" --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
|
||||
|
||||
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"
|
||||
BIN
docs/images/ui-sessions.png
Normal file
BIN
docs/images/ui-sessions.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 618 KiB |
BIN
docs/images/ui-workspace.png
Normal file
BIN
docs/images/ui-workspace.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 704 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>
|
||||
150
server.py
150
server.py
@@ -3,21 +3,54 @@ 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."""
|
||||
|
||||
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):
|
||||
return
|
||||
|
||||
# Also handle socket errors that indicate client disconnect
|
||||
if exc_type is socket.error:
|
||||
# errno 54 is Connection reset by peer on macOS/BSD
|
||||
# errno 104 is Connection reset by peer on Linux
|
||||
if exc_value.errno in (54, 104, 32): # ECONNRESET, EPIPE
|
||||
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)
|
||||
@@ -30,47 +63,134 @@ 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
|
||||
result = handle_get(self, parsed)
|
||||
if result is False:
|
||||
return j(self, {'error': 'not found'}, status=404)
|
||||
except Exception as e:
|
||||
return j(self, {'error': str(e), 'trace': traceback.format_exc()}, status=500)
|
||||
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
|
||||
result = handle_post(self, parsed)
|
||||
if result is False:
|
||||
return j(self, {'error': 'not found'}, status=404)
|
||||
except Exception as e:
|
||||
return j(self, {'error': str(e), 'trace': traceback.format_exc()}, status=500)
|
||||
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()
|
||||
|
||||
ok, missing = verify_hermes_imports()
|
||||
# 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)
|
||||
print(' Agent features may not work correctly.', flush=True)
|
||||
for mod, err in errors.items():
|
||||
print(f' {mod}: {err}', 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-mvp}"
|
||||
|
||||
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 "$@"
|
||||
|
||||
772
static/boot.js
772
static/boot.js
@@ -2,16 +2,385 @@ async function cancelStream(){
|
||||
const streamId = S.activeStreamId;
|
||||
if(!streamId) return;
|
||||
try{
|
||||
await fetch(`/api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`);
|
||||
const btn=$('btnCancel');if(btn)btn.style.display='none';
|
||||
setStatus('Cancelling…');
|
||||
}catch(e){setStatus('Cancel failed: '+e.message);}
|
||||
await fetch(new URL(`api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,location.href).href,{credentials:'include'});
|
||||
}catch(e){/* cancel request failed — cleanup below still runs */}
|
||||
// Clear status unconditionally after the cancel request completes.
|
||||
// The SSE cancel event may also fire, but if the connection is already
|
||||
// closed it won't arrive — so we handle cleanup here as the guaranteed path.
|
||||
const btn=$('btnCancel');if(btn)btn.style.display='none';
|
||||
S.activeStreamId=null;
|
||||
setBusy(false);
|
||||
if(typeof setComposerStatus==='function') setComposerStatus('');
|
||||
else setStatus('');
|
||||
}
|
||||
|
||||
$('btnSend').onclick=send;
|
||||
// ── Mobile navigation ──────────────────────────────────────────────────────
|
||||
let _workspacePanelMode='closed'; // 'closed' | 'browse' | 'preview'
|
||||
|
||||
function _isCompactWorkspaceViewport(){
|
||||
return window.matchMedia('(max-width: 900px)').matches;
|
||||
}
|
||||
|
||||
function _workspacePanelEls(){
|
||||
return {
|
||||
layout: document.querySelector('.layout'),
|
||||
panel: document.querySelector('.rightpanel'),
|
||||
toggleBtn: $('btnWorkspacePanelToggle'),
|
||||
collapseBtn: $('btnCollapseWorkspacePanel'),
|
||||
};
|
||||
}
|
||||
|
||||
function _hasWorkspacePreviewVisible(){
|
||||
const preview=$('previewArea');
|
||||
return !!(preview&&preview.classList.contains('visible'));
|
||||
}
|
||||
|
||||
function _setWorkspacePanelMode(mode){
|
||||
const {layout,panel}= _workspacePanelEls();
|
||||
if(!layout||!panel)return;
|
||||
_workspacePanelMode=(mode==='browse'||mode==='preview')?mode:'closed';
|
||||
const open=_workspacePanelMode!=='closed';
|
||||
document.documentElement.dataset.workspacePanel=open?'open':'closed';
|
||||
// Persist open/closed across refreshes (browse/preview → open; closed → closed)
|
||||
localStorage.setItem('hermes-webui-workspace-panel', open ? 'open' : 'closed');
|
||||
layout.classList.toggle('workspace-panel-collapsed',!open);
|
||||
if(_isCompactWorkspaceViewport()){
|
||||
panel.classList.toggle('mobile-open',open);
|
||||
}else{
|
||||
panel.classList.remove('mobile-open');
|
||||
}
|
||||
syncWorkspacePanelUI();
|
||||
}
|
||||
|
||||
function syncWorkspacePanelState(){
|
||||
const hasPreview=_hasWorkspacePreviewVisible();
|
||||
if(hasPreview){
|
||||
if(_workspacePanelMode==='closed') _setWorkspacePanelMode('preview');
|
||||
else syncWorkspacePanelUI();
|
||||
return;
|
||||
}
|
||||
if(!S.session){
|
||||
_setWorkspacePanelMode('closed');
|
||||
return;
|
||||
}
|
||||
_setWorkspacePanelMode(_workspacePanelMode==='preview'?'closed':_workspacePanelMode);
|
||||
}
|
||||
|
||||
function openWorkspacePanel(mode='browse'){
|
||||
if(mode==='browse'&&!S.session&&!_hasWorkspacePreviewVisible())return;
|
||||
if(mode==='preview'&&_workspacePanelMode==='browse'){
|
||||
syncWorkspacePanelUI();
|
||||
return;
|
||||
}
|
||||
_setWorkspacePanelMode(mode);
|
||||
}
|
||||
|
||||
function closeWorkspacePanel(){
|
||||
_setWorkspacePanelMode('closed');
|
||||
}
|
||||
|
||||
function ensureWorkspacePreviewVisible(){
|
||||
if(_workspacePanelMode==='closed') _setWorkspacePanelMode('preview');
|
||||
else syncWorkspacePanelUI();
|
||||
}
|
||||
|
||||
function handleWorkspaceClose(){
|
||||
if(_hasWorkspacePreviewVisible()){
|
||||
clearPreview();
|
||||
return;
|
||||
}
|
||||
closeWorkspacePanel();
|
||||
}
|
||||
|
||||
function syncWorkspacePanelUI(){
|
||||
const {layout,panel,toggleBtn,collapseBtn}= _workspacePanelEls();
|
||||
if(!layout||!panel)return;
|
||||
const desktopOpen=_workspacePanelMode!=='closed';
|
||||
const mobileOpen=panel.classList.contains('mobile-open');
|
||||
const isCompact=_isCompactWorkspaceViewport();
|
||||
const isOpen=isCompact?mobileOpen:desktopOpen;
|
||||
const canBrowse=!!S.session||_hasWorkspacePreviewVisible();
|
||||
const hasPreview=_hasWorkspacePreviewVisible();
|
||||
if(toggleBtn){
|
||||
toggleBtn.classList.toggle('active',isOpen);
|
||||
toggleBtn.setAttribute('aria-pressed',isOpen?'true':'false');
|
||||
toggleBtn.title=isOpen?'Hide workspace panel':'Show workspace panel';
|
||||
toggleBtn.disabled=!canBrowse;
|
||||
}
|
||||
if(collapseBtn){
|
||||
collapseBtn.title=isCompact?'Close workspace panel':'Hide workspace panel';
|
||||
}
|
||||
const hasSession=!!S.session;
|
||||
['btnUpDir','btnNewFile','btnNewFolder','btnRefreshPanel'].forEach(id=>{
|
||||
const el=$(id);
|
||||
if(el)el.disabled=!hasSession;
|
||||
});
|
||||
const clearBtn=$('btnClearPreview');
|
||||
if(clearBtn){
|
||||
clearBtn.disabled=!isOpen;
|
||||
clearBtn.title=hasPreview?'Close preview':'Hide workspace panel';
|
||||
// On desktop, only show the X button when a file preview is open.
|
||||
// In browse mode the chevron (btnCollapseWorkspacePanel) already serves
|
||||
// as the close control, so showing both produces a duplicate X.
|
||||
if(!isCompact) clearBtn.style.display=hasPreview?'':'none';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMobileSidebar(){
|
||||
const sidebar=document.querySelector('.sidebar');
|
||||
const overlay=$('mobileOverlay');
|
||||
if(!sidebar)return;
|
||||
const isOpen=sidebar.classList.contains('mobile-open');
|
||||
if(isOpen){closeMobileSidebar();}
|
||||
else{sidebar.classList.add('mobile-open');if(overlay)overlay.classList.add('visible');}
|
||||
}
|
||||
function closeMobileSidebar(){
|
||||
const sidebar=document.querySelector('.sidebar');
|
||||
const overlay=$('mobileOverlay');
|
||||
if(sidebar)sidebar.classList.remove('mobile-open');
|
||||
if(overlay)overlay.classList.remove('visible');
|
||||
}
|
||||
function toggleMobileFiles(){
|
||||
toggleWorkspacePanel();
|
||||
}
|
||||
function toggleWorkspacePanel(force){
|
||||
const {panel}= _workspacePanelEls();
|
||||
if(!panel)return;
|
||||
const currentlyOpen=_workspacePanelMode!=='closed';
|
||||
const nextOpen=typeof force==='boolean'?force:!currentlyOpen;
|
||||
if(!nextOpen){
|
||||
closeWorkspacePanel();
|
||||
return;
|
||||
}
|
||||
const nextMode=_hasWorkspacePreviewVisible()?'preview':'browse';
|
||||
openWorkspacePanel(nextMode);
|
||||
}
|
||||
function mobileSwitchPanel(name){
|
||||
switchPanel(name);
|
||||
if(name==='chat'){
|
||||
closeMobileSidebar();
|
||||
} else {
|
||||
const sidebar=document.querySelector('.sidebar');
|
||||
const overlay=$('mobileOverlay');
|
||||
if(sidebar){
|
||||
sidebar.classList.add('mobile-open');
|
||||
if(overlay)overlay.classList.add('visible');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$('btnSend').onclick=()=>{
|
||||
if(window._micActive){
|
||||
window._micPendingSend=true;
|
||||
_stopMic();
|
||||
return;
|
||||
}
|
||||
send();
|
||||
};
|
||||
$('btnAttach').onclick=()=>$('fileInput').click();
|
||||
|
||||
// ── Voice input (Web Speech API + MediaRecorder fallback) ───────────────────
|
||||
(function(){
|
||||
const SpeechRecognition=window.SpeechRecognition||window.webkitSpeechRecognition;
|
||||
const _canRecordAudio=!!(navigator.mediaDevices&&navigator.mediaDevices.getUserMedia&&window.MediaRecorder);
|
||||
if(!SpeechRecognition&&!_canRecordAudio) return; // Browser unsupported — mic button stays hidden
|
||||
|
||||
// Persist SR failure across reloads (e.g. Tailscale/network error)
|
||||
const _micForceMediaRecorderKey='mic_force_mediarecorder';
|
||||
let _forceMediaRecorder=!SpeechRecognition||localStorage.getItem(_micForceMediaRecorderKey)==='1';
|
||||
|
||||
const btn=$('btnMic');
|
||||
const status=$('micStatus');
|
||||
const ta=$('msg');
|
||||
const statusText=status?status.querySelector('.status-text'):null;
|
||||
btn.style.display=''; // Show button — browser supports speech recognition or recording fallback
|
||||
|
||||
let recognition=(!_forceMediaRecorder&&SpeechRecognition)?new SpeechRecognition():null;
|
||||
let mediaRecorder=null;
|
||||
let mediaStream=null;
|
||||
let audioChunks=[];
|
||||
let _finalText='';
|
||||
let _prefix='';
|
||||
let _isRecording=false;
|
||||
|
||||
function _setRecording(on){
|
||||
window._micActive=on;
|
||||
btn.classList.toggle('recording',on);
|
||||
status.style.display=on?'':'none';
|
||||
if(statusText) statusText.textContent=on?'Listening':'Listening';
|
||||
if(!on){ _finalText=''; _prefix=''; }
|
||||
}
|
||||
|
||||
function _commitTranscript(text){
|
||||
const clean=(text||'').trim();
|
||||
const committed=clean
|
||||
? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n')
|
||||
? _prefix+' '+clean.trimStart()
|
||||
: _prefix+clean)
|
||||
: ta.value;
|
||||
ta.value=committed;
|
||||
autoResize();
|
||||
if(window._micPendingSend){
|
||||
window._micPendingSend=false;
|
||||
send();
|
||||
}
|
||||
}
|
||||
|
||||
async function _transcribeBlob(blob){
|
||||
const ext=(blob.type&&blob.type.includes('ogg'))?'ogg':'webm';
|
||||
const form=new FormData();
|
||||
form.append('file',new File([blob],`voice-input.${ext}`,{type:blob.type||`audio/${ext}`}));
|
||||
setComposerStatus('Transcribing…');
|
||||
try{
|
||||
const res=await fetch('api/transcribe',{method:'POST',body:form});
|
||||
const data=await res.json().catch(()=>({}));
|
||||
if(!res.ok) throw new Error(data.error||'Transcription failed');
|
||||
_commitTranscript(data.transcript||'');
|
||||
}catch(err){
|
||||
window._micPendingSend=false;
|
||||
showToast(err.message||t('mic_network'));
|
||||
}finally{
|
||||
setComposerStatus('');
|
||||
}
|
||||
}
|
||||
|
||||
function _stopTracks(){
|
||||
if(mediaStream){
|
||||
mediaStream.getTracks().forEach(track=>track.stop());
|
||||
mediaStream=null;
|
||||
}
|
||||
}
|
||||
|
||||
function _stopMic(){
|
||||
if(!window._micActive) return;
|
||||
if(recognition){
|
||||
recognition.stop();
|
||||
return;
|
||||
}
|
||||
if(mediaRecorder&&mediaRecorder.state!=='inactive'){
|
||||
mediaRecorder.stop();
|
||||
return;
|
||||
}
|
||||
_setRecording(false);
|
||||
_stopTracks();
|
||||
}
|
||||
window._stopMic=_stopMic; // expose for send-guard above
|
||||
|
||||
if(recognition && !_forceMediaRecorder){
|
||||
recognition.continuous=false;
|
||||
recognition.interimResults=true;
|
||||
recognition.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US';
|
||||
|
||||
recognition.onstart=()=>{ _finalText=''; };
|
||||
|
||||
recognition.onresult=(event)=>{
|
||||
let interim='';
|
||||
let final=_finalText;
|
||||
for(let i=event.resultIndex;i<event.results.length;i++){
|
||||
const t=event.results[i][0].transcript;
|
||||
if(event.results[i].isFinal){ final+=t; _finalText=final; }
|
||||
else{ interim+=t; }
|
||||
}
|
||||
ta.value=_prefix+(final||interim);
|
||||
autoResize();
|
||||
};
|
||||
|
||||
recognition.onend=()=>{
|
||||
const committed=_finalText
|
||||
? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n')
|
||||
? _prefix+' '+_finalText.trimStart()
|
||||
: _prefix+_finalText)
|
||||
: ta.value;
|
||||
_setRecording(false);
|
||||
ta.value=committed;
|
||||
autoResize();
|
||||
if(window._micPendingSend){
|
||||
window._micPendingSend=false;
|
||||
send();
|
||||
}
|
||||
};
|
||||
|
||||
recognition.onerror=(event)=>{
|
||||
_setRecording(false);
|
||||
window._micPendingSend=false;
|
||||
_isRecording=false;
|
||||
if(event.error==='network'||event.error==='not-allowed'){
|
||||
// Persist SR failure: next reload will skip SpeechRecognition
|
||||
localStorage.setItem(_micForceMediaRecorderKey,'1');
|
||||
_forceMediaRecorder=true;
|
||||
recognition=null;
|
||||
}
|
||||
const msgs={
|
||||
'not-allowed':t('mic_denied'),
|
||||
'no-speech':t('mic_no_speech'),
|
||||
'network':t('mic_network'),
|
||||
};
|
||||
showToast(msgs[event.error]||t('mic_error')+event.error);
|
||||
};
|
||||
}
|
||||
|
||||
btn.onclick=async()=>{
|
||||
// Race-condition guard: ignore rapid double-clicks
|
||||
if(_isRecording){
|
||||
_stopMic();
|
||||
_isRecording=false;
|
||||
return;
|
||||
}
|
||||
if(window._micActive){
|
||||
_stopMic();
|
||||
return;
|
||||
}
|
||||
_isRecording=true;
|
||||
_finalText='';
|
||||
_prefix=ta.value;
|
||||
if(recognition && !_forceMediaRecorder){
|
||||
recognition.start();
|
||||
_setRecording(true);
|
||||
return;
|
||||
}
|
||||
if(!_canRecordAudio){
|
||||
_isRecording=false;
|
||||
showToast(t('mic_network'));
|
||||
return;
|
||||
}
|
||||
try{
|
||||
mediaStream=await navigator.mediaDevices.getUserMedia({audio:true});
|
||||
const preferredTypes=['audio/webm;codecs=opus','audio/webm','audio/ogg;codecs=opus','audio/ogg'];
|
||||
const mimeType=preferredTypes.find(type=>window.MediaRecorder.isTypeSupported?.(type))||'';
|
||||
mediaRecorder=new MediaRecorder(mediaStream,mimeType?{mimeType}:undefined);
|
||||
audioChunks=[];
|
||||
mediaRecorder.ondataavailable=e=>{if(e.data&&e.data.size)audioChunks.push(e.data);};
|
||||
mediaRecorder.onerror=()=>{
|
||||
_isRecording=false;
|
||||
_setRecording(false);
|
||||
window._micPendingSend=false;
|
||||
_stopTracks();
|
||||
showToast(t('mic_network'));
|
||||
};
|
||||
mediaRecorder.onstop=async()=>{
|
||||
_isRecording=false;
|
||||
const blob=new Blob(audioChunks,{type:mediaRecorder.mimeType||mimeType||'audio/webm'});
|
||||
_setRecording(false);
|
||||
_stopTracks();
|
||||
if(blob.size){ await _transcribeBlob(blob); }
|
||||
else if(window._micPendingSend){
|
||||
window._micPendingSend=false;
|
||||
}
|
||||
};
|
||||
mediaRecorder.start();
|
||||
_setRecording(true);
|
||||
}catch(err){
|
||||
_isRecording=false;
|
||||
window._micPendingSend=false;
|
||||
_stopTracks();
|
||||
showToast(t('mic_denied'));
|
||||
}
|
||||
};
|
||||
})();
|
||||
window._micActive=window._micActive||false;
|
||||
window._micPendingSend=window._micPendingSend||false;
|
||||
$('fileInput').onchange=e=>{addFiles(Array.from(e.target.files));e.target.value='';};
|
||||
$('btnNewChat').onclick=async()=>{await newSession();await renderSessionList();$('msg').focus();};
|
||||
$('btnNewChat').onclick=async()=>{await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();};
|
||||
$('btnDownload').onclick=()=>{
|
||||
if(!S.session)return;
|
||||
const blob=new Blob([transcript()],{type:'text/markdown'});
|
||||
@@ -36,41 +405,128 @@ $('importFileInput').onchange=async(e)=>{
|
||||
if(res.ok&&res.session){
|
||||
await loadSession(res.session.session_id);
|
||||
await renderSessionList();
|
||||
showToast('Session imported');
|
||||
const overlay=$('settingsOverlay');
|
||||
if(overlay) overlay.style.display='none';
|
||||
showToast(t('session_imported'));
|
||||
}
|
||||
}catch(err){
|
||||
showToast('Import failed: '+(err.message||'Invalid JSON'));
|
||||
showToast(t('import_failed')+(err.message||t('import_invalid_json')));
|
||||
}
|
||||
};
|
||||
// btnRefreshFiles is now panel-icon-btn in header (see HTML)
|
||||
$('btnClearPreview').onclick=()=>{
|
||||
$('previewArea').classList.remove('visible');
|
||||
$('previewImg').src='';
|
||||
$('previewMd').innerHTML='';
|
||||
$('previewCode').textContent='';
|
||||
$('previewPathText').textContent='';
|
||||
$('fileTree').style.display='';
|
||||
};
|
||||
function clearPreview(){
|
||||
const closePanelAfter=_workspacePanelMode==='preview';
|
||||
const pa=$('previewArea');if(pa)pa.classList.remove('visible');
|
||||
const pi=$('previewImg');if(pi){pi.onerror=null;pi.src='';}
|
||||
const pm=$('previewMd');if(pm)pm.innerHTML='';
|
||||
const pc=$('previewCode');if(pc)pc.textContent='';
|
||||
const pp=$('previewPathText');if(pp)pp.textContent='';
|
||||
const ft=$('fileTree');if(ft)ft.style.display='';
|
||||
_previewCurrentPath='';_previewCurrentMode='';_previewDirty=false;
|
||||
// Restore directory breadcrumb after closing file preview
|
||||
if(typeof renderBreadcrumb==='function') renderBreadcrumb();
|
||||
if(closePanelAfter)closeWorkspacePanel();
|
||||
else syncWorkspacePanelUI();
|
||||
}
|
||||
$('btnClearPreview').onclick=handleWorkspaceClose;
|
||||
// workspacePath click handler removed -- use topbar workspace chip dropdown instead
|
||||
$('modelSelect').onchange=async()=>{
|
||||
if(!S.session)return;
|
||||
const selectedModel=$('modelSelect').value;
|
||||
if(typeof closeModelDropdown==='function') closeModelDropdown();
|
||||
localStorage.setItem('hermes-webui-model', selectedModel);
|
||||
await api('/api/session/update',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,workspace:S.session.workspace,model:selectedModel})});
|
||||
S.session.model=selectedModel;syncTopbar();
|
||||
S.session.model=selectedModel;
|
||||
if(typeof syncModelChip==='function') syncModelChip();
|
||||
syncTopbar();
|
||||
// Warn if selected model belongs to a different provider than what Hermes is configured for
|
||||
if(typeof _checkProviderMismatch==='function'){
|
||||
const warn=_checkProviderMismatch(selectedModel);
|
||||
if(warn&&typeof showToast==='function') showToast(warn,4000);
|
||||
}
|
||||
// Notify user that model changes only take effect in the next conversation (#419)
|
||||
if(S.messages && S.messages.length > 0 && typeof showToast==='function'){
|
||||
showToast('Model change takes effect in your next conversation', 3000);
|
||||
}
|
||||
};
|
||||
$('msg').addEventListener('input',autoResize);
|
||||
$('msg').addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}});
|
||||
$('msg').addEventListener('input',()=>{
|
||||
autoResize();
|
||||
updateSendBtn();
|
||||
const text=$('msg').value;
|
||||
if(text.startsWith('/')&&text.indexOf('\n')===-1){
|
||||
if(typeof getSlashAutocompleteMatches==='function'){
|
||||
getSlashAutocompleteMatches(text).then(matches=>{
|
||||
if(($('msg').value||'')!==text) return;
|
||||
if(matches.length)showCmdDropdown(matches); else hideCmdDropdown();
|
||||
});
|
||||
}else{
|
||||
const prefix=text.slice(1);
|
||||
const matches=getMatchingCommands(prefix);
|
||||
if(matches.length)showCmdDropdown(matches); else hideCmdDropdown();
|
||||
}
|
||||
if(typeof ensureSkillCommandsLoadedForAutocomplete==='function') ensureSkillCommandsLoadedForAutocomplete();
|
||||
} else {
|
||||
hideCmdDropdown();
|
||||
}
|
||||
});
|
||||
$('msg').addEventListener('keydown',e=>{
|
||||
// Autocomplete navigation when dropdown is open
|
||||
const dd=$('cmdDropdown');
|
||||
const dropdownOpen=dd&&dd.classList.contains('open');
|
||||
if(dropdownOpen){
|
||||
if(e.key==='ArrowUp'){e.preventDefault();navigateCmdDropdown(-1);return;}
|
||||
if(e.key==='ArrowDown'){e.preventDefault();navigateCmdDropdown(1);return;}
|
||||
if(e.key==='Tab'){e.preventDefault();selectCmdDropdownItem();return;}
|
||||
if(e.key==='Escape'){e.preventDefault();hideCmdDropdown();return;}
|
||||
if(e.key==='Enter'&&!e.shiftKey){
|
||||
if(e.isComposing){return;}
|
||||
e.preventDefault();
|
||||
selectCmdDropdownItem();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Send key: respect user preference.
|
||||
// On touch-primary devices (software keyboard), default to Enter = newline
|
||||
// since there's no physical Shift key. Users send via the Send button.
|
||||
// The 'ctrl+enter' setting also uses this behavior (Enter = newline).
|
||||
// Users can override in Settings by explicitly choosing 'enter' mode.
|
||||
if(e.key==='Enter'){
|
||||
if(e.isComposing){return;}
|
||||
const _mobileDefault=matchMedia('(pointer:coarse)').matches&&window._sendKey==='enter';
|
||||
if(window._sendKey==='ctrl+enter'||_mobileDefault){
|
||||
if(e.ctrlKey||e.metaKey){e.preventDefault();send();}
|
||||
} else {
|
||||
if(!e.shiftKey){e.preventDefault();send();}
|
||||
}
|
||||
}
|
||||
});
|
||||
// B14: Cmd/Ctrl+K creates a new chat from anywhere
|
||||
document.addEventListener('keydown',async e=>{
|
||||
// Enter on approval card = Allow once (when a button inside the card is focused or
|
||||
// card is visible and focus is not on an input/textarea/select)
|
||||
if(e.key==='Enter'&&!e.metaKey&&!e.ctrlKey&&!e.shiftKey){
|
||||
const card=$('approvalCard');
|
||||
const tag=(document.activeElement||{}).tagName||'';
|
||||
if(card&&card.classList.contains('visible')&&tag!=='TEXTAREA'&&tag!=='INPUT'&&tag!=='SELECT'){
|
||||
e.preventDefault();
|
||||
if(typeof respondApproval==='function') respondApproval('once');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if((e.metaKey||e.ctrlKey)&&e.key==='k'){
|
||||
e.preventDefault();
|
||||
if(!S.busy){await newSession();await renderSessionList();$('msg').focus();}
|
||||
if(!S.busy){await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();}
|
||||
}
|
||||
if(e.key==='Escape'){
|
||||
// Close onboarding overlay if open (skip/dismiss the wizard)
|
||||
const onboardingOverlay=$('onboardingOverlay');
|
||||
if(onboardingOverlay&&onboardingOverlay.style.display!=='none'){
|
||||
if(typeof skipOnboarding==='function') skipOnboarding();
|
||||
return;
|
||||
}
|
||||
// Close settings overlay if open
|
||||
const settingsOverlay=$('settingsOverlay');
|
||||
if(settingsOverlay&&settingsOverlay.style.display!=='none'){toggleSettings();return;}
|
||||
if(settingsOverlay&&settingsOverlay.style.display!=='none'){_closeSettingsPanel();return;}
|
||||
// Close workspace dropdown
|
||||
closeWsDropdown();
|
||||
// Clear session search
|
||||
@@ -95,17 +551,21 @@ $('msg').addEventListener('paste',e=>{
|
||||
return new File([blob],`screenshot-${Date.now()}.${ext}`,{type:i.type});
|
||||
});
|
||||
addFiles(files);
|
||||
setStatus(`Image pasted: ${files.map(f=>f.name).join(', ')}`);
|
||||
setStatus(t('image_pasted')+files.map(f=>f.name).join(', '));
|
||||
});
|
||||
document.querySelectorAll('.suggestion').forEach(btn=>{
|
||||
btn.onclick=()=>{$('msg').value=btn.dataset.msg;send();};
|
||||
});
|
||||
|
||||
window.addEventListener('resize',()=>{
|
||||
syncWorkspacePanelState();
|
||||
});
|
||||
|
||||
// Boot: restore last session or start fresh
|
||||
// ── Resizable panels ──────────────────────────────────────────────────────
|
||||
(function(){
|
||||
const SIDEBAR_MIN=180, SIDEBAR_MAX=420;
|
||||
const PANEL_MIN=180, PANEL_MAX=500;
|
||||
const PANEL_MIN=180, PANEL_MAX=1200;
|
||||
|
||||
function initResize(handleId, targetEl, edge, minW, maxW, storageKey){
|
||||
const handle = $(handleId);
|
||||
@@ -150,7 +610,235 @@ document.querySelectorAll('.suggestion').forEach(btn=>{
|
||||
};
|
||||
})();
|
||||
|
||||
// ── Appearance helpers (theme = light/dark/system, skin = accent color) ──────
|
||||
const _SKINS=[
|
||||
{name:'Default', colors:['#FFD700','#FFBF00','#CD7F32']},
|
||||
{name:'Ares', colors:['#FF4444','#CC3333','#992222']},
|
||||
{name:'Mono', colors:['#CCCCCC','#999999','#666666']},
|
||||
{name:'Slate', colors:['#334155','#475569','#64748b']},
|
||||
{name:'Poseidon', colors:['#0EA5E9','#0284C7','#0369A1']},
|
||||
{name:'Sisyphus', colors:['#A78BFA','#8B5CF6','#7C3AED']},
|
||||
{name:'Charizard',colors:['#FB923C','#F97316','#EA580C']},
|
||||
];
|
||||
const _VALID_THEMES=new Set(['system','dark','light']);
|
||||
const _VALID_SKINS=new Set((_SKINS||[]).map(s=>s.name.toLowerCase()));
|
||||
const _LEGACY_THEME_MAP={
|
||||
slate:{theme:'dark',skin:'slate'},
|
||||
solarized:{theme:'dark',skin:'poseidon'},
|
||||
monokai:{theme:'dark',skin:'sisyphus'},
|
||||
nord:{theme:'dark',skin:'slate'},
|
||||
oled:{theme:'dark',skin:'default'},
|
||||
};
|
||||
let _systemThemeMq=null;
|
||||
let _onSystemThemeChange=null;
|
||||
|
||||
function _normalizeAppearance(theme,skin){
|
||||
const rawTheme=typeof theme==='string'?theme.trim().toLowerCase():'';
|
||||
const rawSkin=typeof skin==='string'?skin.trim().toLowerCase():'';
|
||||
const legacy=_LEGACY_THEME_MAP[rawTheme];
|
||||
const nextTheme=legacy?legacy.theme:(_VALID_THEMES.has(rawTheme)?rawTheme:'dark');
|
||||
const nextSkin=_VALID_SKINS.has(rawSkin)?rawSkin:(legacy?legacy.skin:'default');
|
||||
return {theme:nextTheme,skin:nextSkin};
|
||||
}
|
||||
|
||||
function _setResolvedTheme(isDark){
|
||||
document.documentElement.classList.toggle('dark',!!isDark);
|
||||
const link=document.getElementById('prism-theme');
|
||||
if(!link) return;
|
||||
const want=isDark
|
||||
?'https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css'
|
||||
:'https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism.min.css';
|
||||
if(link.href!==want){ link.href=want; }
|
||||
}
|
||||
|
||||
function _applyTheme(name){
|
||||
const normalized=_normalizeAppearance(name,'default');
|
||||
if(_systemThemeMq&&_onSystemThemeChange){
|
||||
_systemThemeMq.removeEventListener('change',_onSystemThemeChange);
|
||||
_systemThemeMq=null;
|
||||
_onSystemThemeChange=null;
|
||||
}
|
||||
if(normalized.theme==='system'){
|
||||
_systemThemeMq=window.matchMedia('(prefers-color-scheme:dark)');
|
||||
_onSystemThemeChange=()=>_setResolvedTheme(_systemThemeMq.matches);
|
||||
_setResolvedTheme(_systemThemeMq.matches);
|
||||
_systemThemeMq.addEventListener('change',_onSystemThemeChange);
|
||||
return;
|
||||
}
|
||||
_setResolvedTheme(normalized.theme==='dark');
|
||||
}
|
||||
|
||||
function _applySkin(name){
|
||||
const key=(name||'default').toLowerCase();
|
||||
if(key==='default') delete document.documentElement.dataset.skin;
|
||||
else document.documentElement.dataset.skin=key;
|
||||
}
|
||||
|
||||
function _pickTheme(name){
|
||||
const currentSkin=localStorage.getItem('hermes-skin');
|
||||
const appearance=_normalizeAppearance(name,currentSkin);
|
||||
localStorage.setItem('hermes-theme',appearance.theme);
|
||||
localStorage.setItem('hermes-skin',appearance.skin);
|
||||
_applyTheme(appearance.theme);
|
||||
_applySkin(appearance.skin);
|
||||
_syncThemePicker(appearance.theme);
|
||||
_syncSkinPicker(appearance.skin);
|
||||
if(typeof _markSettingsDirty==='function') _markSettingsDirty();
|
||||
const hidden=$('settingsTheme');
|
||||
if(hidden) hidden.value=appearance.theme;
|
||||
const skinHidden=$('settingsSkin');
|
||||
if(skinHidden) skinHidden.value=appearance.skin;
|
||||
}
|
||||
|
||||
function _pickSkin(name){
|
||||
const appearance=_normalizeAppearance(localStorage.getItem('hermes-theme'),name);
|
||||
localStorage.setItem('hermes-theme',appearance.theme);
|
||||
localStorage.setItem('hermes-skin',appearance.skin);
|
||||
_applyTheme(appearance.theme);
|
||||
_applySkin(appearance.skin);
|
||||
_syncThemePicker(appearance.theme);
|
||||
_syncSkinPicker(appearance.skin);
|
||||
if(typeof _markSettingsDirty==='function') _markSettingsDirty();
|
||||
const hidden=$('settingsSkin');
|
||||
if(hidden) hidden.value=appearance.skin;
|
||||
const themeHidden=$('settingsTheme');
|
||||
if(themeHidden) themeHidden.value=appearance.theme;
|
||||
}
|
||||
|
||||
function _syncThemePicker(active){
|
||||
document.querySelectorAll('#themePickerGrid .theme-pick-btn').forEach(btn=>{
|
||||
const sel=btn.dataset.themeVal===active;
|
||||
btn.style.borderColor=sel?'var(--accent)':'var(--border2)';
|
||||
btn.style.boxShadow=sel?'0 0 0 1px var(--accent-bg-strong)':'none';
|
||||
});
|
||||
}
|
||||
|
||||
function _syncSkinPicker(active){
|
||||
document.querySelectorAll('#skinPickerGrid .skin-pick-btn').forEach(btn=>{
|
||||
const sel=btn.dataset.skinVal===active;
|
||||
btn.style.borderColor=sel?'var(--accent)':'var(--border2)';
|
||||
btn.style.boxShadow=sel?'0 0 0 1px var(--accent-bg-strong)':'none';
|
||||
});
|
||||
}
|
||||
|
||||
function _applyFontSize(size){
|
||||
if(size&&size!=='default'){
|
||||
document.documentElement.dataset.fontSize=size;
|
||||
} else {
|
||||
delete document.documentElement.dataset.fontSize;
|
||||
}
|
||||
}
|
||||
|
||||
function _pickFontSize(size){
|
||||
localStorage.setItem('hermes-font-size',size);
|
||||
_applyFontSize(size);
|
||||
_syncFontSizePicker(size);
|
||||
if(typeof _markSettingsDirty==='function') _markSettingsDirty();
|
||||
const hidden=$('settingsFontSize');
|
||||
if(hidden) hidden.value=size;
|
||||
}
|
||||
|
||||
function _syncFontSizePicker(active){
|
||||
document.querySelectorAll('#fontSizePickerGrid .font-size-pick-btn').forEach(btn=>{
|
||||
const sel=btn.dataset.fontSizeVal===(active||'default');
|
||||
btn.style.borderColor=sel?'var(--accent)':'var(--border2)';
|
||||
btn.style.boxShadow=sel?'0 0 0 1px var(--accent-bg-strong)':'none';
|
||||
});
|
||||
}
|
||||
|
||||
function _buildSkinPicker(activeSkin){
|
||||
const grid=$('skinPickerGrid');
|
||||
if(!grid) return;
|
||||
grid.innerHTML='';
|
||||
for(const skin of _SKINS){
|
||||
const key=skin.name.toLowerCase();
|
||||
const btn=document.createElement('button');
|
||||
btn.type='button';
|
||||
btn.className='skin-pick-btn';
|
||||
btn.dataset.skinVal=key;
|
||||
btn.style.cssText='border:1px solid var(--border2);border-radius:8px;padding:8px 4px;text-align:center;cursor:pointer;background:none;transition:all .15s';
|
||||
btn.onclick=()=>_pickSkin(skin.name);
|
||||
const dots=skin.colors.map(c=>`<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${c}"></span>`).join('');
|
||||
btn.innerHTML=`<div style="display:flex;gap:3px;justify-content:center;margin-bottom:4px">${dots}</div><span style="font-size:11px;color:var(--text)">${skin.name}</span>`;
|
||||
grid.appendChild(btn);
|
||||
}
|
||||
_syncSkinPicker((activeSkin||'default').toLowerCase());
|
||||
}
|
||||
|
||||
function applyBotName(){
|
||||
const name=window._botName||'Hermes';
|
||||
document.title=name;
|
||||
const sidebarH1=document.querySelector('.sidebar-header h1');
|
||||
if(sidebarH1) sidebarH1.textContent=name;
|
||||
const logo=document.querySelector('.sidebar-header .logo');
|
||||
if(logo) logo.textContent=name.charAt(0).toUpperCase();
|
||||
const topbarTitle=$('topbarTitle');
|
||||
if(topbarTitle && (!S.session)) topbarTitle.textContent=name;
|
||||
const msg=$('msg');
|
||||
if(msg) msg.placeholder='Message '+name+'\u2026';
|
||||
}
|
||||
|
||||
(async()=>{
|
||||
// Load send key preference
|
||||
let _bootSettings={};
|
||||
try{
|
||||
const s=await api('/api/settings');
|
||||
_bootSettings=s;
|
||||
window._sendKey=s.send_key||'enter';
|
||||
window._showTokenUsage=!!s.show_token_usage;
|
||||
window._showCliSessions=!!s.show_cli_sessions;
|
||||
window._soundEnabled=!!s.sound_enabled;
|
||||
window._notificationsEnabled=!!s.notifications_enabled;
|
||||
window._showThinking=s.show_thinking!==false;
|
||||
window._sidebarDensity=(s.sidebar_density==='detailed'?'detailed':'compact');
|
||||
window._botName=s.bot_name||'Hermes';
|
||||
// Persist default workspace so the blank new-chat page can show it
|
||||
// and workspace actions (New file/folder) work before the first session (#804).
|
||||
if(s.default_workspace) S._profileDefaultWorkspace=s.default_workspace;
|
||||
const appearance=_normalizeAppearance(s.theme,s.skin);
|
||||
localStorage.setItem('hermes-theme',appearance.theme);
|
||||
_applyTheme(appearance.theme);
|
||||
localStorage.setItem('hermes-skin',appearance.skin);
|
||||
_applySkin(appearance.skin);
|
||||
if(typeof setLocale==='function'){
|
||||
const _lang=typeof resolvePreferredLocale==='function'
|
||||
? resolvePreferredLocale(s.language, localStorage.getItem('hermes-lang'))
|
||||
: (s.language || localStorage.getItem('hermes-lang') || 'en');
|
||||
setLocale(_lang);
|
||||
if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();
|
||||
}
|
||||
applyBotName();
|
||||
}catch(e){
|
||||
window._sendKey='enter';
|
||||
window._showTokenUsage=false;
|
||||
window._showCliSessions=false;
|
||||
window._soundEnabled=false;
|
||||
window._notificationsEnabled=false;
|
||||
window._showThinking=true;
|
||||
window._sidebarDensity='compact';
|
||||
window._botName='Hermes';
|
||||
_bootSettings={check_for_updates:false};
|
||||
if(typeof setLocale==='function'){
|
||||
const _lang=typeof resolvePreferredLocale==='function'
|
||||
? resolvePreferredLocale(null, localStorage.getItem('hermes-lang'))
|
||||
: (localStorage.getItem('hermes-lang') || 'en');
|
||||
setLocale(_lang);
|
||||
if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();
|
||||
}
|
||||
applyBotName();
|
||||
}
|
||||
// Non-blocking update check (fire-and-forget, once per tab session)
|
||||
// ?test_updates=1 in URL forces banner display for testing (bypasses sessionStorage guards)
|
||||
const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1';
|
||||
if(_testUpdates||(_bootSettings.check_for_updates!==false&&!sessionStorage.getItem('hermes-update-checked')&&!sessionStorage.getItem('hermes-update-dismissed'))){
|
||||
const _checkUrl='/api/updates/check'+(_testUpdates?'?simulate=1':'');
|
||||
api(_checkUrl).then(d=>{if(!_testUpdates)sessionStorage.setItem('hermes-update-checked','1');if((d.webui&&d.webui.behind>0)||(d.agent&&d.agent.behind>0))_showUpdateBanner(d);}).catch(()=>{});
|
||||
}
|
||||
// Fetch active profile
|
||||
try{const p=await api('/api/profile/active');S.activeProfile=p.name||'default';}catch(e){S.activeProfile='default';}
|
||||
// Update profile chip label immediately
|
||||
const profileLabel=$('profileChipLabel');
|
||||
if(profileLabel) profileLabel.textContent=S.activeProfile||'default';
|
||||
// Fetch available models from server and populate dropdown dynamically
|
||||
await populateModelDropdown();
|
||||
// Restore last-used model preference
|
||||
@@ -159,17 +847,53 @@ document.querySelectorAll('.suggestion').forEach(btn=>{
|
||||
$('modelSelect').value=savedModel;
|
||||
// If the value didn't take (model not in list), clear the bad pref
|
||||
if($('modelSelect').value!==savedModel) localStorage.removeItem('hermes-webui-model');
|
||||
else if(typeof syncModelChip==='function') syncModelChip();
|
||||
}
|
||||
// Pre-load workspace list so sidebar name is correct from first render
|
||||
await loadWorkspaceList();
|
||||
await loadOnboardingWizard();
|
||||
_initResizePanels();
|
||||
// Workspace panel restore happens AFTER loadSession so we know if
|
||||
// the session has a workspace — prevents the snap-open-then-closed flash (#576).
|
||||
// Fix #822: clear any browser-restored value before first render. This
|
||||
// covers fresh page loads and reloads. The bfcache restore case is handled
|
||||
// separately below by a `pageshow` listener — the async IIFE here does NOT
|
||||
// re-run when the browser restores the page from bfcache.
|
||||
const _srch = document.getElementById('sessionSearch'); if (_srch) _srch.value = '';
|
||||
const saved=localStorage.getItem('hermes-webui-session');
|
||||
if(saved){
|
||||
try{await loadSession(saved);await renderSessionList();await checkInflightOnBoot(saved);return;}
|
||||
try{
|
||||
await loadSession(saved);
|
||||
// Only restore the panel from localStorage when the session actually has a workspace.
|
||||
// Without this guard, sessions without a workspace snap open then immediately closed.
|
||||
if(S.session&&S.session.workspace&&localStorage.getItem('hermes-webui-workspace-panel')==='open'){
|
||||
_workspacePanelMode='browse';
|
||||
}
|
||||
S._bootReady=true;
|
||||
syncTopbar();syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();await checkInflightOnBoot(saved);return;}
|
||||
catch(e){localStorage.removeItem('hermes-webui-session');}
|
||||
}
|
||||
// no saved session - show empty state, wait for user to hit +
|
||||
S._bootReady=true;
|
||||
syncTopbar();
|
||||
syncWorkspacePanelState();
|
||||
$('emptyState').style.display='';
|
||||
await renderSessionList();
|
||||
// Start real-time gateway session sync if setting is enabled
|
||||
if(typeof startGatewaySSE==='function') startGatewaySSE();
|
||||
})();
|
||||
|
||||
// Fix #822 (bfcache path): when the browser restores the page from the
|
||||
// back-forward cache, the async boot IIFE above does NOT re-run, but the
|
||||
// DOM — including any stale value in #sessionSearch — IS restored. A
|
||||
// prior search string would silently hide all sessions via the filter in
|
||||
// renderSessionListFromCache(). Clear the field and re-render whenever
|
||||
// the page is restored from cache (`event.persisted === true`).
|
||||
window.addEventListener('pageshow', (event) => {
|
||||
if (!event.persisted) return; // fresh loads are handled by the IIFE above
|
||||
const _srch = document.getElementById('sessionSearch');
|
||||
if (_srch) _srch.value = '';
|
||||
if (typeof renderSessionListFromCache === 'function') {
|
||||
try { renderSessionListFromCache(); } catch (_) {}
|
||||
}
|
||||
});
|
||||
|
||||
765
static/commands.js
Normal file
765
static/commands.js
Normal file
@@ -0,0 +1,765 @@
|
||||
// ── Slash commands ──────────────────────────────────────────────────────────
|
||||
// Built-in commands intercepted before send(). Each command runs locally
|
||||
// (no round-trip to the agent) and shows feedback via toast or local message.
|
||||
|
||||
const COMMANDS=[
|
||||
// noEcho:true = action-only commands that don't produce a chat response.
|
||||
// Commands without noEcho get a user message echoed to the chat (#840).
|
||||
{name:'help', desc:t('cmd_help'), fn:cmdHelp},
|
||||
{name:'clear', desc:t('cmd_clear'), fn:cmdClear, noEcho:true},
|
||||
{name:'compress', desc:t('cmd_compress'), fn:cmdCompress, arg:'[focus topic]', noEcho:true},
|
||||
{name:'compact', desc:t('cmd_compact_alias'), fn:cmdCompact, noEcho:true},
|
||||
{name:'model', desc:t('cmd_model'), fn:cmdModel, arg:'model_name', subArgs:'models', noEcho:true},
|
||||
{name:'workspace', desc:t('cmd_workspace'), fn:cmdWorkspace, arg:'name', noEcho:true},
|
||||
{name:'new', desc:t('cmd_new'), fn:cmdNew, noEcho:true},
|
||||
{name:'usage', desc:t('cmd_usage'), fn:cmdUsage, noEcho:true},
|
||||
{name:'theme', desc:t('cmd_theme'), fn:cmdTheme, arg:'name', noEcho:true},
|
||||
{name:'personality', desc:t('cmd_personality'), fn:cmdPersonality, arg:'name', subArgs:'personalities'},
|
||||
{name:'skills', desc:t('cmd_skills'), fn:cmdSkills, arg:'query'},
|
||||
{name:'stop', desc:t('cmd_stop'), fn:cmdStop, noEcho:true},
|
||||
{name:'title', desc:t('cmd_title'), fn:cmdTitle, arg:'[title]'},
|
||||
{name:'retry', desc:t('cmd_retry'), fn:cmdRetry, noEcho:true},
|
||||
{name:'undo', desc:t('cmd_undo'), fn:cmdUndo, noEcho:true},
|
||||
{name:'status', desc:t('cmd_status'), fn:cmdStatus},
|
||||
{name:'voice', desc:t('cmd_voice'), fn:cmdVoice, noEcho:true},
|
||||
{name:'reasoning', desc:t('cmd_reasoning'), fn:cmdReasoning, arg:'show|hide|none|minimal|low|medium|high|xhigh', subArgs:['show','hide','none','minimal','low','medium','high','xhigh'], noEcho:true},
|
||||
];
|
||||
|
||||
const SLASH_SUBARG_SOURCES={
|
||||
model:{desc:t('cmd_model'), subArgs:'models'},
|
||||
personality:{desc:t('cmd_personality'), subArgs:'personalities'},
|
||||
};
|
||||
|
||||
function parseCommand(text){
|
||||
if(!text.startsWith('/'))return null;
|
||||
const parts=text.slice(1).split(/\s+/);
|
||||
const name=parts[0].toLowerCase();
|
||||
const args=parts.slice(1).join(' ').trim();
|
||||
return {name,args};
|
||||
}
|
||||
|
||||
function executeCommand(text){
|
||||
const parsed=parseCommand(text);
|
||||
if(!parsed)return null;
|
||||
const cmd=COMMANDS.find(c=>c.name===parsed.name);
|
||||
if(!cmd)return null;
|
||||
// A handler may return `false` to opt out of interception — e.g. /reasoning
|
||||
// with an effort level falls through so the agent's own handler sees it,
|
||||
// preserving the pre-existing pass-through behaviour for that subcommand.
|
||||
if(cmd.fn(parsed.args)===false)return null;
|
||||
// Return noEcho flag so send() knows whether to echo the command as a user message (#840).
|
||||
return {noEcho:!!cmd.noEcho};
|
||||
}
|
||||
|
||||
function getMatchingCommands(prefix){
|
||||
const q=prefix.toLowerCase();
|
||||
const matches=COMMANDS.filter(c=>c.name.startsWith(q)).map(c=>({...c,source:'builtin'}));
|
||||
const seen=new Set(matches.map(c=>c.name));
|
||||
for(const [name, spec] of Object.entries(SLASH_SUBARG_SOURCES)){
|
||||
if(!name.startsWith(q)||seen.has(name))continue;
|
||||
matches.push({
|
||||
name,
|
||||
desc:spec.desc,
|
||||
arg:'name',
|
||||
source:'subarg-command',
|
||||
});
|
||||
seen.add(name);
|
||||
}
|
||||
for(const skill of _skillCommandCache){
|
||||
if(!skill.name.startsWith(q)||seen.has(skill.name))continue;
|
||||
matches.push(skill);
|
||||
seen.add(skill.name);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
let _slashModelCache=null;
|
||||
let _slashModelCachePromise=null;
|
||||
let _slashPersonalityCache=null;
|
||||
let _slashPersonalityCachePromise=null;
|
||||
|
||||
function _normalizeSlashSubArg(value){
|
||||
return String(value||'').trim();
|
||||
}
|
||||
|
||||
function _getSlashModelSubArgsFromDom(){
|
||||
const sel=$('modelSelect');
|
||||
if(!sel) return [];
|
||||
const values=[];
|
||||
for(const opt of Array.from(sel.options||[])){
|
||||
const value=_normalizeSlashSubArg(opt.value||opt.textContent||'');
|
||||
if(value) values.push(value);
|
||||
}
|
||||
return Array.from(new Set(values)).sort((a,b)=>a.localeCompare(b));
|
||||
}
|
||||
|
||||
async function _loadSlashModelSubArgs(force=false){
|
||||
const domValues=_getSlashModelSubArgsFromDom();
|
||||
if(domValues.length&&!force){
|
||||
_slashModelCache=domValues;
|
||||
return domValues;
|
||||
}
|
||||
if(_slashModelCache&&!force) return _slashModelCache;
|
||||
if(_slashModelCachePromise&&!force) return _slashModelCachePromise;
|
||||
_slashModelCachePromise=(async()=>{
|
||||
try{
|
||||
const data=await api('/api/models');
|
||||
const values=[];
|
||||
for(const group of (data&&data.groups)||[]){
|
||||
for(const model of (group&&group.models)||[]){
|
||||
const id=_normalizeSlashSubArg(model&&model.id);
|
||||
if(id) values.push(id);
|
||||
}
|
||||
}
|
||||
const deduped=Array.from(new Set(values)).sort((a,b)=>a.localeCompare(b));
|
||||
_slashModelCache=deduped;
|
||||
return deduped;
|
||||
}catch(_){
|
||||
_slashModelCache=domValues;
|
||||
return domValues;
|
||||
}finally{
|
||||
_slashModelCachePromise=null;
|
||||
}
|
||||
})();
|
||||
return _slashModelCachePromise;
|
||||
}
|
||||
|
||||
async function _loadSlashPersonalitySubArgs(force=false){
|
||||
if(_slashPersonalityCache&&!force) return _slashPersonalityCache;
|
||||
if(_slashPersonalityCachePromise&&!force) return _slashPersonalityCachePromise;
|
||||
_slashPersonalityCachePromise=(async()=>{
|
||||
try{
|
||||
const data=await api('/api/personalities');
|
||||
const values=['none'];
|
||||
for(const p of (data&&data.personalities)||[]){
|
||||
const name=_normalizeSlashSubArg(p&&p.name);
|
||||
if(name) values.push(name);
|
||||
}
|
||||
const deduped=Array.from(new Set(values)).sort((a,b)=>a.localeCompare(b));
|
||||
_slashPersonalityCache=deduped;
|
||||
return deduped;
|
||||
}catch(_){
|
||||
_slashPersonalityCache=['none'];
|
||||
return _slashPersonalityCache;
|
||||
}finally{
|
||||
_slashPersonalityCachePromise=null;
|
||||
}
|
||||
})();
|
||||
return _slashPersonalityCachePromise;
|
||||
}
|
||||
|
||||
function _getSlashSubArgOptions(spec){
|
||||
if(Array.isArray(spec)) return Promise.resolve(spec.slice());
|
||||
if(spec==='models') return _loadSlashModelSubArgs();
|
||||
if(spec==='personalities') return _loadSlashPersonalitySubArgs();
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
function _parseSlashAutocomplete(text){
|
||||
if(!text.startsWith('/')||text.indexOf('\n')!==-1) return null;
|
||||
const raw=text.slice(1);
|
||||
const hasSpace=/\s/.test(raw);
|
||||
const parts=raw.split(/\s+/);
|
||||
const cmdName=(parts[0]||'').toLowerCase();
|
||||
const command=COMMANDS.find(c=>c.name===cmdName);
|
||||
const subArgSource=(command&&command.subArgs)?command:SLASH_SUBARG_SOURCES[cmdName];
|
||||
if(!hasSpace||!subArgSource){
|
||||
return {kind:'commands', query:raw};
|
||||
}
|
||||
const argText=raw.slice(cmdName.length).replace(/^\s+/,'');
|
||||
return {kind:'subargs', command:{name:cmdName, desc:subArgSource.desc, subArgs:subArgSource.subArgs}, query:argText.toLowerCase(), rawQuery:argText};
|
||||
}
|
||||
|
||||
async function getSlashAutocompleteMatches(text){
|
||||
const parsed=_parseSlashAutocomplete(text);
|
||||
if(!parsed) return [];
|
||||
if(parsed.kind==='commands') return getMatchingCommands(parsed.query);
|
||||
const options=await _getSlashSubArgOptions(parsed.command.subArgs);
|
||||
return options
|
||||
.filter(opt=>String(opt).toLowerCase().startsWith(parsed.query))
|
||||
.map(opt=>({
|
||||
name:parsed.command.name,
|
||||
value:String(opt),
|
||||
desc:parsed.command.desc,
|
||||
source:'subarg',
|
||||
parent:parsed.command.name,
|
||||
}));
|
||||
}
|
||||
|
||||
function _compressionAnchorMessageKey(m){
|
||||
if(!m||!m.role||m.role==='tool') return null;
|
||||
let content='';
|
||||
try{
|
||||
content=typeof msgContent==='function' ? String(msgContent(m)||'') : String(m.content||'');
|
||||
}catch(_){
|
||||
content=String(m.content||'');
|
||||
}
|
||||
const norm=content.replace(/\s+/g,' ').trim().slice(0,160);
|
||||
const ts=m._ts||m.timestamp||null;
|
||||
const attachments=Array.isArray(m.attachments)?m.attachments.length:0;
|
||||
if(!norm && !attachments && !ts) return null;
|
||||
return {role:String(m.role||''), ts, text:norm, attachments};
|
||||
}
|
||||
|
||||
// ── Command handlers ────────────────────────────────────────────────────────
|
||||
|
||||
function cmdHelp(){
|
||||
const lines=COMMANDS.map(c=>{
|
||||
const usage=c.arg ? (String(c.arg).startsWith('[') ? ` ${c.arg}` : ` <${c.arg}>`) : '';
|
||||
return ` /${c.name}${usage} — ${c.desc}`;
|
||||
});
|
||||
const msg={role:'assistant',content:t('available_commands')+'\n'+lines.join('\n')};
|
||||
S.messages.push(msg);
|
||||
renderMessages();
|
||||
showToast(t('type_slash'));
|
||||
}
|
||||
|
||||
function cmdClear(){
|
||||
if(!S.session)return;
|
||||
S.messages=[];S.toolCalls=[];
|
||||
clearLiveToolCards();
|
||||
if(typeof clearCompressionUi==='function') clearCompressionUi();
|
||||
renderMessages();
|
||||
$('emptyState').style.display='';
|
||||
showToast(t('conversation_cleared'));
|
||||
}
|
||||
|
||||
async function cmdModel(args){
|
||||
if(!args){showToast(t('model_usage'));return;}
|
||||
const sel=$('modelSelect');
|
||||
if(!sel)return;
|
||||
const q=args.toLowerCase();
|
||||
// Fuzzy match: find first option whose label or value contains the query
|
||||
let match=null;
|
||||
for(const opt of sel.options){
|
||||
if(opt.value.toLowerCase().includes(q)||opt.textContent.toLowerCase().includes(q)){
|
||||
match=opt.value;break;
|
||||
}
|
||||
}
|
||||
if(!match){showToast(t('no_model_match')+`"${args}"`);return;}
|
||||
sel.value=match;
|
||||
await sel.onchange();
|
||||
showToast(t('switched_to')+match);
|
||||
}
|
||||
|
||||
async function cmdWorkspace(args){
|
||||
if(!args){showToast(t('workspace_usage'));return;}
|
||||
try{
|
||||
const data=await api('/api/workspaces');
|
||||
const q=args.toLowerCase();
|
||||
const ws=(data.workspaces||[]).find(w=>
|
||||
(w.name||'').toLowerCase().includes(q)||w.path.toLowerCase().includes(q)
|
||||
);
|
||||
if(!ws){showToast(t('no_workspace_match')+`"${args}"`);return;}
|
||||
if(typeof switchToWorkspace==='function') await switchToWorkspace(ws.path, ws.name||ws.path);
|
||||
else showToast(t('switched_workspace')+(ws.name||ws.path));
|
||||
}catch(e){showToast(t('workspace_switch_failed')+e.message);}
|
||||
}
|
||||
|
||||
async function cmdNew(){
|
||||
if(typeof clearCompressionUi==='function') clearCompressionUi();
|
||||
await newSession();
|
||||
await renderSessionList();
|
||||
$('msg').focus();
|
||||
showToast(t('new_session'));
|
||||
}
|
||||
|
||||
async function _runManualCompression(focusTopic){
|
||||
if(!S.session){showToast(t('no_active_session'));return;}
|
||||
let visibleCount=0;
|
||||
try{
|
||||
const sid=S.session.session_id;
|
||||
// Preflight: verify the viewed session still exists before compressing.
|
||||
// This avoids a confusing "not found" toast when the UI is stale.
|
||||
try{
|
||||
const live=await api(`/api/session?session_id=${encodeURIComponent(sid)}`);
|
||||
if(!live||!live.session||live.session.session_id!==sid){
|
||||
throw new Error('session no longer available');
|
||||
}
|
||||
S.session=live.session;
|
||||
S.messages=live.session.messages||[];
|
||||
S.toolCalls=live.session.tool_calls||[];
|
||||
}catch(preflightErr){
|
||||
if(typeof clearCompressionUi==='function') clearCompressionUi();
|
||||
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
|
||||
if(typeof setBusy==='function') setBusy(false);
|
||||
if(typeof setComposerStatus==='function') setComposerStatus('');
|
||||
renderMessages();
|
||||
showToast('Compression failed: '+(preflightErr.message||'session no longer available'));
|
||||
return;
|
||||
}
|
||||
if(typeof setBusy==='function') setBusy(true);
|
||||
const body={session_id:sid};
|
||||
if(focusTopic) body.focus_topic=focusTopic;
|
||||
const visibleMessages=(S.messages||[]).filter(m=>{
|
||||
if(!m||!m.role||m.role==='tool') return false;
|
||||
if(m.role==='assistant'){
|
||||
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
|
||||
const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
|
||||
if(hasTc||hasTu|| (typeof _messageHasReasoningPayload==='function' && _messageHasReasoningPayload(m))) return true;
|
||||
}
|
||||
return typeof msgContent==='function' ? !!msgContent(m) || !!m.attachments?.length : !!m.content || !!m.attachments?.length;
|
||||
});
|
||||
visibleCount=visibleMessages.length;
|
||||
const anchorVisibleIdx=Math.max(0, visibleCount - 1);
|
||||
const anchorMessageKey=_compressionAnchorMessageKey(visibleMessages[visibleMessages.length-1]||null);
|
||||
const commandText=focusTopic?`/compress ${focusTopic}`:'/compress';
|
||||
if(typeof setCompressionUi==='function'){
|
||||
setCompressionUi({
|
||||
sessionId:S.session.session_id,
|
||||
phase:'running',
|
||||
focusTopic:focusTopic||'',
|
||||
commandText,
|
||||
beforeCount:visibleCount,
|
||||
anchorVisibleIdx,
|
||||
anchorMessageKey,
|
||||
});
|
||||
}
|
||||
if(typeof setComposerStatus==='function') setComposerStatus(t('compressing'));
|
||||
renderMessages();
|
||||
const data=await api('/api/session/compress',{method:'POST',body:JSON.stringify(body)});
|
||||
if(data&&data.session){
|
||||
const currentSid=S.session&&S.session.session_id;
|
||||
if(data.session.session_id&&data.session.session_id!==currentSid){
|
||||
await loadSession(data.session.session_id);
|
||||
}else{
|
||||
S.session=data.session;
|
||||
S.messages=data.session.messages||[];
|
||||
S.toolCalls=data.session.tool_calls||[];
|
||||
clearLiveToolCards();
|
||||
localStorage.setItem('hermes-webui-session',S.session.session_id);
|
||||
syncTopbar();
|
||||
renderMessages();
|
||||
await renderSessionList();
|
||||
updateQueueBadge(S.session.session_id);
|
||||
}
|
||||
}
|
||||
const summary=data&&data.summary;
|
||||
if(typeof setCompressionUi==='function'&&S.session){
|
||||
const referenceMsg=(S.messages||[]).find(m=>typeof _isContextCompactionMessage==='function'&&_isContextCompactionMessage(m));
|
||||
const messageRef=referenceMsg?msgContent(referenceMsg)||String(referenceMsg.content||''):'';
|
||||
const summaryRef=summary&&typeof summary.reference_message==='string' ? String(summary.reference_message||'').trim() : '';
|
||||
// Prefer the persisted compaction handoff when it already exists in session state.
|
||||
// The short summary fallback is only for environments where that message is unavailable.
|
||||
const referenceText=messageRef || summaryRef;
|
||||
const effectiveFocus=(data&&data.focus_topic)||focusTopic||'';
|
||||
setCompressionUi({
|
||||
sessionId:S.session.session_id,
|
||||
phase:'done',
|
||||
focusTopic:effectiveFocus,
|
||||
commandText:effectiveFocus?`/compress ${effectiveFocus}`:'/compress',
|
||||
beforeCount:visibleCount,
|
||||
summary:summary||null,
|
||||
referenceText,
|
||||
anchorVisibleIdx: data?.session?.compression_anchor_visible_idx,
|
||||
anchorMessageKey: data?.session?.compression_anchor_message_key||null,
|
||||
});
|
||||
}
|
||||
if(typeof setComposerStatus==='function') setComposerStatus('');
|
||||
renderMessages();
|
||||
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
|
||||
}catch(e){
|
||||
if(typeof setCompressionUi==='function'){
|
||||
const currentSid=S.session&&S.session.session_id;
|
||||
setCompressionUi({
|
||||
sessionId:currentSid||'',
|
||||
phase:'error',
|
||||
focusTopic:(focusTopic||'').trim(),
|
||||
commandText:focusTopic?`/compress ${focusTopic}`:'/compress',
|
||||
beforeCount:(S.messages||[]).filter(m=>m&&m.role&&m.role!=='tool').length,
|
||||
errorText:`Compression failed: ${e.message}`,
|
||||
anchorVisibleIdx: Math.max(0, visibleCount - 1),
|
||||
anchorMessageKey:null,
|
||||
});
|
||||
}
|
||||
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
|
||||
if(typeof setBusy==='function') setBusy(false);
|
||||
if(typeof setComposerStatus==='function') setComposerStatus('');
|
||||
renderMessages();
|
||||
showToast('Compression failed: '+e.message);
|
||||
return;
|
||||
}
|
||||
if(typeof setBusy==='function') setBusy(false);
|
||||
}
|
||||
|
||||
async function cmdCompress(args){
|
||||
await _runManualCompression((args||'').trim());
|
||||
}
|
||||
|
||||
async function cmdCompact(args){
|
||||
await _runManualCompression((args||'').trim());
|
||||
}
|
||||
|
||||
async function cmdUsage(){
|
||||
const next=!window._showTokenUsage;
|
||||
window._showTokenUsage=next;
|
||||
try{
|
||||
await api('/api/settings',{method:'POST',body:JSON.stringify({show_token_usage:next})});
|
||||
}catch(e){}
|
||||
// Update the settings checkbox if the panel is open
|
||||
const cb=$('settingsShowTokenUsage');
|
||||
if(cb) cb.checked=next;
|
||||
renderMessages();
|
||||
showToast(next?t('token_usage_on'):t('token_usage_off'));
|
||||
}
|
||||
|
||||
async function cmdTheme(args){
|
||||
const themes=['system','dark','light'];
|
||||
const skins=(_SKINS||[]).map(s=>s.name.toLowerCase());
|
||||
const legacyThemes=Object.keys(_LEGACY_THEME_MAP||{});
|
||||
const val=(args||'').toLowerCase().trim();
|
||||
// Check if it's a theme
|
||||
if(themes.includes(val)||legacyThemes.includes(val)){
|
||||
const appearance=_normalizeAppearance(
|
||||
val,
|
||||
legacyThemes.includes(val)?null:localStorage.getItem('hermes-skin')
|
||||
);
|
||||
localStorage.setItem('hermes-theme',appearance.theme);
|
||||
localStorage.setItem('hermes-skin',appearance.skin);
|
||||
_applyTheme(appearance.theme);
|
||||
_applySkin(appearance.skin);
|
||||
try{await api('/api/settings',{method:'POST',body:JSON.stringify({theme:appearance.theme,skin:appearance.skin})});}catch(e){}
|
||||
const sel=$('settingsTheme');
|
||||
if(sel)sel.value=appearance.theme;
|
||||
const skinSel=$('settingsSkin');
|
||||
if(skinSel)skinSel.value=appearance.skin;
|
||||
if(typeof _syncThemePicker==='function') _syncThemePicker(appearance.theme);
|
||||
if(typeof _syncSkinPicker==='function') _syncSkinPicker(appearance.skin);
|
||||
showToast(t('theme_set')+appearance.theme+(legacyThemes.includes(val)?` + ${appearance.skin}`:''));
|
||||
return;
|
||||
}
|
||||
// Check if it's a skin
|
||||
if(skins.includes(val)){
|
||||
const appearance=_normalizeAppearance(localStorage.getItem('hermes-theme'),val);
|
||||
localStorage.setItem('hermes-theme',appearance.theme);
|
||||
localStorage.setItem('hermes-skin',appearance.skin);
|
||||
_applyTheme(appearance.theme);
|
||||
_applySkin(appearance.skin);
|
||||
try{await api('/api/settings',{method:'POST',body:JSON.stringify({theme:appearance.theme,skin:appearance.skin})});}catch(e){}
|
||||
const sel=$('settingsSkin');
|
||||
if(sel)sel.value=appearance.skin;
|
||||
const themeSel=$('settingsTheme');
|
||||
if(themeSel)themeSel.value=appearance.theme;
|
||||
if(typeof _syncThemePicker==='function') _syncThemePicker(appearance.theme);
|
||||
if(typeof _syncSkinPicker==='function') _syncSkinPicker(appearance.skin);
|
||||
showToast(t('theme_set')+appearance.skin);
|
||||
return;
|
||||
}
|
||||
showToast(t('theme_usage')+themes.join('|')+' | '+skins.join('|')+' | legacy:'+legacyThemes.join('|'));
|
||||
}
|
||||
|
||||
async function cmdSkills(args){
|
||||
try{
|
||||
const data = await api('/api/skills');
|
||||
let skills = data.skills || [];
|
||||
if(args){
|
||||
const q = args.toLowerCase();
|
||||
skills = skills.filter(s =>
|
||||
(s.name||'').toLowerCase().includes(q) ||
|
||||
(s.description||'').toLowerCase().includes(q) ||
|
||||
(s.category||'').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
if(!skills.length){
|
||||
const msg = {role:'assistant', content: args ? `No skills matching "${args}".` : 'No skills found.'};
|
||||
S.messages.push(msg); renderMessages(); return;
|
||||
}
|
||||
// Group by category
|
||||
const byCategory = {};
|
||||
skills.forEach(s => {
|
||||
const cat = s.category || 'General';
|
||||
if(!byCategory[cat]) byCategory[cat] = [];
|
||||
byCategory[cat].push(s);
|
||||
});
|
||||
const lines = [];
|
||||
for(const [cat, items] of Object.entries(byCategory).sort()){
|
||||
lines.push(`**${cat}**`);
|
||||
items.forEach(s => {
|
||||
const desc = s.description ? ` — ${s.description.slice(0,80)}${s.description.length>80?'...':''}` : '';
|
||||
lines.push(` \`${s.name}\`${desc}`);
|
||||
});
|
||||
lines.push('');
|
||||
}
|
||||
const header = args
|
||||
? `Skills matching "${args}" (${skills.length}):\n\n`
|
||||
: `Available skills (${skills.length}):\n\n`;
|
||||
S.messages.push({role:'assistant', content: header + lines.join('\n')});
|
||||
renderMessages();
|
||||
showToast(t('type_slash'));
|
||||
}catch(e){
|
||||
showToast('Failed to load skills: '+e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdPersonality(args){
|
||||
if(!S.session){showToast(t('no_active_session'));return;}
|
||||
if(!args){
|
||||
// List available personalities
|
||||
try{
|
||||
const data=await api('/api/personalities');
|
||||
if(!data.personalities||!data.personalities.length){
|
||||
showToast(t('no_personalities'));
|
||||
return;
|
||||
}
|
||||
const list=data.personalities.map(p=>` **${p.name}**${p.description?' — '+p.description:''}`).join('\n');
|
||||
S.messages.push({role:'assistant',content:t('available_personalities')+'\n\n'+list+t('personality_switch_hint')});
|
||||
renderMessages();
|
||||
}catch(e){showToast(t('personalities_load_failed'));}
|
||||
return;
|
||||
}
|
||||
const name=args.trim();
|
||||
if(name.toLowerCase()==='none'||name.toLowerCase()==='default'||name.toLowerCase()==='clear'){
|
||||
try{
|
||||
await api('/api/personality/set',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,name:''})});
|
||||
showToast(t('personality_cleared'));
|
||||
}catch(e){showToast(t('failed_colon')+e.message);}
|
||||
return;
|
||||
}
|
||||
try{
|
||||
const res=await api('/api/personality/set',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,name})});
|
||||
S.messages.push({role:'assistant',content:t('personality_set')+`**${name}**`});
|
||||
renderMessages();
|
||||
showToast(t('personality_set')+name);
|
||||
}catch(e){showToast(t('failed_colon')+e.message);}
|
||||
}
|
||||
|
||||
async function cmdStop(){
|
||||
if(!S.session){showToast(t('no_active_session'));return;}
|
||||
if(!S.activeStreamId){showToast(t('no_active_task'));return;}
|
||||
if(typeof cancelStream==='function'){await cancelStream();showToast(t('stream_stopped'));}
|
||||
else showToast(t('cancel_unavailable'));
|
||||
}
|
||||
async function cmdTitle(args){
|
||||
if(!S.session){showToast(t('no_active_session'));return;}
|
||||
const name=(args||'').trim();
|
||||
if(!name){
|
||||
S.messages.push({role:'assistant',content:`${t('title_current')}: **${S.session.title||t('untitled')}**\n\n${t('title_change_hint')}`});
|
||||
renderMessages();return;
|
||||
}
|
||||
try{
|
||||
const r=await api('/api/session/rename',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,title:name})});
|
||||
if(r&&r.error){showToast(r.error);return;}
|
||||
S.session.title=(r&&r.session&&r.session.title)||name;
|
||||
if(typeof syncTopbar==='function')syncTopbar();
|
||||
if(typeof renderSessionList==='function')renderSessionList();
|
||||
showToast(`${t('title_set')} "${S.session.title}"`);
|
||||
S.messages.push({role:'assistant',content:`${t('title_set')} **${S.session.title}**`});
|
||||
renderMessages();
|
||||
}catch(e){showToast(t('failed_colon')+e.message);}
|
||||
}
|
||||
async function cmdRetry(){
|
||||
if(!S.session){showToast(t('no_active_session'));return;}
|
||||
if(S.session.is_cli_session){showToast(t('cmd_webui_only_session'));return;}
|
||||
const activeSid=S.session.session_id;
|
||||
try{
|
||||
const r=await api('/api/session/retry',{method:'POST',body:JSON.stringify({session_id:activeSid})});
|
||||
if(r&&r.error){showToast(r.error);return;}
|
||||
if(!S.session||S.session.session_id!==activeSid)return;
|
||||
const data=await api('/api/session?session_id='+encodeURIComponent(activeSid));
|
||||
if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();renderMessages();}
|
||||
$('msg').value=r.last_user_text||'';if(typeof autoResize==='function')autoResize();await send();
|
||||
}catch(e){showToast(t('retry_failed')+e.message);}
|
||||
}
|
||||
async function cmdUndo(){
|
||||
if(!S.session){showToast(t('no_active_session'));return;}
|
||||
if(S.session.is_cli_session){showToast(t('cmd_webui_only_session'));return;}
|
||||
const activeSid=S.session.session_id;
|
||||
try{
|
||||
const r=await api('/api/session/undo',{method:'POST',body:JSON.stringify({session_id:activeSid})});
|
||||
if(r&&r.error){showToast(r.error);return;}
|
||||
if(!S.session||S.session.session_id!==activeSid)return;
|
||||
const data=await api('/api/session?session_id='+encodeURIComponent(activeSid));
|
||||
if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();renderMessages();}
|
||||
showToast(`↩ ${t('undid_n_messages')} ${r.removed_count} ${t('undid_messages_suffix')}`);
|
||||
}catch(e){showToast(t('undo_failed')+e.message);}
|
||||
}
|
||||
async function cmdStatus(){
|
||||
if(!S.session){showToast(t('no_active_session'));return;}
|
||||
try{
|
||||
const r=await api('/api/session/status?session_id='+encodeURIComponent(S.session.session_id));
|
||||
if(r&&r.error){showToast(r.error);return;}
|
||||
S.messages.push({role:'assistant',content:[`**${t('status_heading')}**`,'',`**${t('status_session_id')}:** \`${r.session_id}\``,`**${t('status_title')}:** ${r.title||t('untitled')}`,`**${t('status_model')}:** ${r.model||t('usage_default_model')}`,`**${t('status_workspace')}:** ${r.workspace}`,`**${t('status_personality')}:** ${r.personality||t('usage_personality_none')}`,`**${t('status_messages')}:** ${r.message_count}`,`**${t('status_agent_running')}:** ${r.agent_running?t('status_yes'):t('status_no')}`,].join('\n')});
|
||||
renderMessages();
|
||||
}catch(e){showToast(t('status_load_failed')+e.message);}
|
||||
}
|
||||
function cmdReasoning(args){
|
||||
const arg=(args||'').trim().toLowerCase();
|
||||
const BRAIN='\uD83E\uDDE0';
|
||||
// Matches hermes_constants.VALID_REASONING_EFFORTS + 'none' (CLI parity).
|
||||
const EFFORTS=['none','minimal','low','medium','high','xhigh'];
|
||||
// Shared status renderer used by the no-args branch and as a fallback.
|
||||
function _fmtStatus(st){
|
||||
const vis=(st && st.show_reasoning===false)?'off':'on';
|
||||
const eff=(st && st.reasoning_effort)||'default';
|
||||
return BRAIN+' Reasoning effort: '+eff+' \u00B7 display: '+vis
|
||||
+' | /reasoning show|hide|none|minimal|low|medium|high|xhigh';
|
||||
}
|
||||
if(!arg){
|
||||
// Status — read from the same config.yaml keys the CLI uses.
|
||||
api('/api/reasoning').then(function(st){showToast(_fmtStatus(st));})
|
||||
.catch(function(){showToast(BRAIN+' /reasoning — status unavailable');});
|
||||
return true;
|
||||
}
|
||||
if(arg==='show'||arg==='on'||arg==='hide'||arg==='off'){
|
||||
const on=(arg==='show'||arg==='on');
|
||||
// Update the UI render gate immediately for responsiveness.
|
||||
window._showThinking=on;
|
||||
if(typeof renderMessages==='function') renderMessages();
|
||||
// Persist via /api/reasoning → config.yaml display.show_reasoning
|
||||
// (CLI reads the same key). Also mirror into WebUI settings.json
|
||||
// show_thinking so boot.js picks it up on reload without hitting
|
||||
// /api/reasoning on every page load.
|
||||
api('/api/reasoning',{method:'POST',body:JSON.stringify({display:arg})}).catch(function(){});
|
||||
api('/api/settings',{method:'POST',body:JSON.stringify({show_thinking:on})}).catch(function(){});
|
||||
showToast(BRAIN+' Thinking blocks: '+(on?'on':'off')+' (saved)');
|
||||
return true;
|
||||
}
|
||||
if(EFFORTS.includes(arg)){
|
||||
// Persist via /api/reasoning → config.yaml agent.reasoning_effort.
|
||||
// Takes effect on the NEXT session/turn (agent re-reads config at
|
||||
// construction time), matching CLI semantics where `/reasoning high`
|
||||
// also forces an agent re-init.
|
||||
api('/api/reasoning',{method:'POST',body:JSON.stringify({effort:arg})})
|
||||
.then(function(st){
|
||||
const eff=(st && st.reasoning_effort)||arg;
|
||||
showToast(BRAIN+' Reasoning effort set to '+eff+' (saved; applies to next turn)');
|
||||
})
|
||||
.catch(function(e){
|
||||
showToast(BRAIN+' Failed to set effort: '+(e && e.message ? e.message : arg));
|
||||
});
|
||||
return true;
|
||||
}
|
||||
showToast('Unknown argument: '+arg+' \u2014 use show|hide|'+EFFORTS.join('|'));
|
||||
return true;
|
||||
}
|
||||
function cmdVoice(){
|
||||
const mic=document.getElementById('btnMic');
|
||||
if(mic&&mic.style.display!=='none'&&!mic.disabled){try{mic.click();return;}catch(_){}}
|
||||
showToast(t('cmd_voice_use_mic'));
|
||||
}
|
||||
let _skillCommandCache=[];
|
||||
let _skillCommandLoadPromise=null;
|
||||
let _skillCommandCacheReady=false;
|
||||
function _skillCommandSlug(name){
|
||||
const raw=String(name||'').trim().toLowerCase();
|
||||
if(!raw)return'';
|
||||
return raw.replace(/[\s_]+/g,'-').replace(/[^a-z0-9-]/g,'').replace(/-{2,}/g,'-').replace(/^-+|-+$/g,'');
|
||||
}
|
||||
function _buildSkillCommandEntry(skill){
|
||||
const skillName=String(skill&&skill.name||'').trim();
|
||||
const slug=_skillCommandSlug(skillName);
|
||||
if(!slug)return null;
|
||||
if(COMMANDS.some(c=>c.name===slug)) return null;
|
||||
return{name:slug,desc:String(skill&&skill.description||'').trim()||t('slash_skill_desc'),source:'skill',skillName};
|
||||
}
|
||||
async function loadSkillCommands(force=false){
|
||||
if(_skillCommandCacheReady&&!force)return _skillCommandCache;
|
||||
if(_skillCommandLoadPromise&&!force)return _skillCommandLoadPromise;
|
||||
_skillCommandLoadPromise=(async()=>{
|
||||
try{
|
||||
const data=await api('/api/skills');
|
||||
const deduped=new Map();
|
||||
for(const skill of (data&&data.skills)||[]){const entry=_buildSkillCommandEntry(skill);if(entry&&!deduped.has(entry.name))deduped.set(entry.name,entry);}
|
||||
_skillCommandCache=Array.from(deduped.values()).sort((a,b)=>a.name.localeCompare(b.name));
|
||||
}catch(_){_skillCommandCache=[];}
|
||||
finally{_skillCommandCacheReady=true;_skillCommandLoadPromise=null;}
|
||||
return _skillCommandCache;
|
||||
})();
|
||||
return _skillCommandLoadPromise;
|
||||
}
|
||||
function refreshSlashCommandDropdown(){
|
||||
const ta=$('msg');if(!ta)return;
|
||||
const text=ta.value||'';
|
||||
if(!text.startsWith('/')||text.indexOf('\n')!==-1){hideCmdDropdown();return;}
|
||||
getSlashAutocompleteMatches(text).then(matches=>{
|
||||
if(($('msg').value||'')!==text) return;
|
||||
if(matches.length)showCmdDropdown(matches);else hideCmdDropdown();
|
||||
});
|
||||
}
|
||||
function ensureSkillCommandsLoadedForAutocomplete(){
|
||||
if(_skillCommandCacheReady||_skillCommandLoadPromise)return;
|
||||
loadSkillCommands().then(()=>{refreshSlashCommandDropdown();});
|
||||
}
|
||||
|
||||
// ── Autocomplete dropdown ───────────────────────────────────────────────────
|
||||
|
||||
let _cmdSelectedIdx=-1;
|
||||
|
||||
function showCmdDropdown(matches){
|
||||
const dd=$('cmdDropdown');
|
||||
if(!dd)return;
|
||||
dd.innerHTML='';
|
||||
_cmdSelectedIdx=matches.length?0:-1;
|
||||
for(let i=0;i<matches.length;i++){
|
||||
const c=matches[i];
|
||||
const el=document.createElement('div');
|
||||
el.className='cmd-item';
|
||||
if(i===_cmdSelectedIdx) el.classList.add('selected');
|
||||
el.dataset.idx=i;
|
||||
const isSubArg=c.source==='subarg';
|
||||
const usage=(!isSubArg&&c.arg)?` <span class="cmd-item-arg">${esc(c.arg)}</span>`:'';
|
||||
const badge=c.source==='skill'?`<span class="cmd-item-badge cmd-item-badge-skill">${esc(t('slash_skill_badge'))}</span>`:'';
|
||||
if(c.source==='skill') el.classList.add('cmd-item-skill');
|
||||
const nameHtml=isSubArg
|
||||
? `<div class="cmd-item-name"><span class="cmd-item-parent">/${esc(c.parent)}</span> <span class="cmd-item-subarg">${esc(c.value)}</span></div>`
|
||||
: `<div class="cmd-item-name">/${esc(c.name)}${usage}${badge}</div>`;
|
||||
const descHtml=`<div class="cmd-item-desc">${esc(c.desc)}</div>`;
|
||||
el.innerHTML=`${nameHtml}${descHtml}`;
|
||||
el.onmousedown=(e)=>{
|
||||
e.preventDefault();
|
||||
const nextValue=isSubArg?('/'+c.parent+' '+c.value):('/'+c.name+(c.arg?' ':''));
|
||||
$('msg').value=nextValue;
|
||||
$('msg').focus();
|
||||
if(!isSubArg&&c.source!=='skill'&&nextValue.endsWith(' ')&&typeof getSlashAutocompleteMatches==='function'){
|
||||
getSlashAutocompleteMatches(nextValue).then(matches=>{
|
||||
if(($('msg').value||'')!==nextValue) return;
|
||||
if(matches.length) showCmdDropdown(matches);
|
||||
else hideCmdDropdown();
|
||||
});
|
||||
}else{
|
||||
hideCmdDropdown();
|
||||
}
|
||||
};
|
||||
dd.appendChild(el);
|
||||
}
|
||||
dd.classList.add('open');
|
||||
}
|
||||
|
||||
function hideCmdDropdown(){
|
||||
const dd=$('cmdDropdown');
|
||||
if(dd)dd.classList.remove('open');
|
||||
_cmdSelectedIdx=-1;
|
||||
}
|
||||
|
||||
function navigateCmdDropdown(dir){
|
||||
const dd=$('cmdDropdown');
|
||||
if(!dd)return;
|
||||
const items=dd.querySelectorAll('.cmd-item');
|
||||
if(!items.length)return;
|
||||
items.forEach(el=>el.classList.remove('selected'));
|
||||
_cmdSelectedIdx+=dir;
|
||||
if(_cmdSelectedIdx<0)_cmdSelectedIdx=items.length-1;
|
||||
if(_cmdSelectedIdx>=items.length)_cmdSelectedIdx=0;
|
||||
items[_cmdSelectedIdx].classList.add('selected');
|
||||
// Scroll the newly highlighted item into view so it stays visible when the
|
||||
// dropdown overflows and the user navigates with keyboard (#838).
|
||||
items[_cmdSelectedIdx].scrollIntoView({block:'nearest'});
|
||||
}
|
||||
|
||||
function selectCmdDropdownItem(){
|
||||
const dd=$('cmdDropdown');
|
||||
if(!dd)return;
|
||||
const items=dd.querySelectorAll('.cmd-item');
|
||||
if(_cmdSelectedIdx>=0&&_cmdSelectedIdx<items.length){
|
||||
items[_cmdSelectedIdx].onmousedown({preventDefault:()=>{}});
|
||||
} else if(items.length===1){
|
||||
items[0].onmousedown({preventDefault:()=>{}});
|
||||
}
|
||||
hideCmdDropdown();
|
||||
}
|
||||
|
||||
// ── Handler aliases (for test-discoverable command registration) ──────────────
|
||||
// The COMMANDS array above is the authoritative dispatch table. These aliases
|
||||
// allow tooling and tests to discover command handlers by name independently.
|
||||
const HANDLERS = {};
|
||||
HANDLERS.skills = cmdSkills;
|
||||
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 |
2521
static/i18n.js
Normal file
2521
static/i18n.js
Normal file
File diff suppressed because it is too large
Load Diff
77
static/icons.js
Normal file
77
static/icons.js
Normal file
@@ -0,0 +1,77 @@
|
||||
// ── 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"/>',
|
||||
'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"/>',
|
||||
'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"/>',
|
||||
};
|
||||
|
||||
/**
|
||||
* 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>`;
|
||||
}
|
||||
@@ -4,64 +4,83 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Hermes</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="icon" type="image/svg+xml" href="static/favicon.svg">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="static/favicon-32.png">
|
||||
<link rel="shortcut icon" href="static/favicon.ico">
|
||||
<!-- base href enables subpath mount support; all static paths must stay relative (no leading slash) -->
|
||||
<script>(function(){var p=location.pathname.endsWith('/')?location.pathname:(location.pathname.replace(/\/[^\/]*$/,'/')||'/');document.write('<base href="'+location.origin+p+'">');})()</script>
|
||||
<script>(function(){var themes={light:1,dark:1,system:1},skins={default:1,ares:1,mono:1,slate:1,poseidon:1,sisyphus:1,charizard:1},legacy={slate:['dark','slate'],solarized:['dark','poseidon'],monokai:['dark','sisyphus'],nord:['dark','slate'],oled:['dark','default']},t=(localStorage.getItem('hermes-theme')||'dark').toLowerCase(),s=(localStorage.getItem('hermes-skin')||'').toLowerCase(),m=legacy[t],theme=m?m[0]:(themes[t]?t:'dark'),skin=skins[s]?s:(m?m[1]:'default');localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);if(theme==='system')theme=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';if(theme==='dark')document.documentElement.classList.add('dark');if(skin!=='default')document.documentElement.dataset.skin=skin;})()</script>
|
||||
<script>(function(){var fs=localStorage.getItem('hermes-font-size');if(fs&&fs!=='default')document.documentElement.dataset.fontSize=fs;})()</script>
|
||||
<script>(function(){try{document.documentElement.dataset.workspacePanel=localStorage.getItem('hermes-webui-workspace-panel')==='open'?'open':'closed';}catch(e){document.documentElement.dataset.workspacePanel='closed';}})()</script>
|
||||
<link rel="stylesheet" href="static/style.css">
|
||||
<!-- KaTeX math rendering CSS (loaded eagerly to prevent layout shift) -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css" integrity="sha384-5TcZemv2l/9On385z///+d7MSYlvIEw9FuZTIdZ14vJLqWphw7e7ZPuOiCHJcFCP" crossorigin="anonymous">
|
||||
<!-- Prism.js syntax highlighting (loaded async, non-blocking) -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-core.min.js" defer></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/plugins/autoloader/prism-autoloader.min.js" defer></script>
|
||||
<link id="prism-theme" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css" integrity="sha384-wFjoQjtV1y5jVHbt0p35Ui8aV8GVpEZkyF99OXWqP/eNJDU93D3Ugxkoyh6Y2I4A" crossorigin="anonymous">
|
||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-core.min.js" integrity="sha384-MXybTpajaBV0AkcBaCPT4KIvo0FzoCiWXgcihYsw4FUkEz0Pv3JGV6tk2G8vJtDc" crossorigin="anonymous" defer></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/plugins/autoloader/prism-autoloader.min.js" integrity="sha384-Uq05+JLko69eOiPr39ta9bh7kld5PKZoU+fF7g0EXTAriEollhZ+DrN8Q/Oi8J2Q" crossorigin="anonymous" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.2</div></div></div>
|
||||
|
||||
<div class="sidebar-nav">
|
||||
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">💬</button>
|
||||
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">📅</button>
|
||||
<button class="nav-tab" data-panel="skills" data-label="Skills" onclick="switchPanel('skills')" title="Skills">🧩</button>
|
||||
<button class="nav-tab" data-panel="memory" data-label="Memory" onclick="switchPanel('memory')" title="Memory">🧠</button>
|
||||
<button class="nav-tab" data-panel="workspaces" data-label="Spaces" onclick="switchPanel('workspaces')" title="Spaces">📁</button>
|
||||
<button class="nav-tab" data-panel="todos" data-label="Todos" onclick="switchPanel('todos')" title="Current task list">✅</button>
|
||||
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat" data-i18n-title="tab_chat"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></button>
|
||||
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks" data-i18n-title="tab_tasks"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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"/></svg></button>
|
||||
<button class="nav-tab" data-panel="skills" data-label="Skills" onclick="switchPanel('skills')" title="Skills" data-i18n-title="tab_skills"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></button>
|
||||
<button class="nav-tab" data-panel="memory" data-label="Memory" onclick="switchPanel('memory')" title="Memory" data-i18n-title="tab_memory"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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"/></svg></button>
|
||||
<button class="nav-tab" data-panel="workspaces" data-label="Spaces" onclick="switchPanel('workspaces')" title="Spaces" data-i18n-title="tab_workspaces"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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"/></svg></button>
|
||||
<button class="nav-tab" data-panel="profiles" data-label="Profiles" onclick="switchPanel('profiles')" title="Agent profiles" data-i18n-title="tab_profiles"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></button>
|
||||
<button class="nav-tab" data-panel="todos" data-label="Todos" onclick="switchPanel('todos')" title="Current task list" data-i18n-title="tab_todos"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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"/></svg></button>
|
||||
</div>
|
||||
<!-- Chat panel -->
|
||||
<div class="panel-view active" id="panelChat">
|
||||
<div class="sidebar-section">
|
||||
<button class="new-chat-btn" id="btnNewChat">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
New conversation <span style="font-size:10px;opacity:.5;margin-left:4px">⌘K</span>
|
||||
<span data-i18n="new_conversation">New conversation</span> <span style="font-size:10px;opacity:.5;margin-left:4px">Cmd+K</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="session-search"><input id="sessionSearch" placeholder="Filter conversations..." oninput="filterSessions()"></div>
|
||||
<div class="session-search"><input id="sessionSearch" placeholder="Filter conversations..." data-i18n-placeholder="filter_conversations" oninput="filterSessions()" autocomplete="off"></div>
|
||||
<div class="session-list" id="sessionList"></div>
|
||||
</div>
|
||||
<!-- Tasks (cron) panel -->
|
||||
<div class="panel-view" id="panelTasks">
|
||||
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
|
||||
<div style="font-size:11px;color:var(--muted)">Scheduled jobs</div>
|
||||
<button class="cron-btn run" style="padding:3px 8px;font-size:10px" onclick="toggleCronForm()">+ New job</button>
|
||||
<div style="font-size:11px;color:var(--muted)" data-i18n="scheduled_jobs">Scheduled jobs</div>
|
||||
<div style="display:flex;gap:4px;align-items:center">
|
||||
<button class="cron-btn" id="cronRefreshBtn" style="padding:3px 7px;font-size:10px;line-height:1" onclick="loadCrons(true)" title="Refresh job list" aria-label="Refresh job list"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg></button>
|
||||
<button class="cron-btn run" style="padding:3px 8px;font-size:10px" onclick="toggleCronForm()">+ <span data-i18n="new_job">New job</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Create job form (hidden by default) -->
|
||||
<div id="cronCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
|
||||
<input id="cronFormName" placeholder="Job name (optional)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px">
|
||||
<input id="cronFormSchedule" placeholder="Schedule: '0 9 * * *' or 'every 1h'" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px">
|
||||
<textarea id="cronFormPrompt" rows="3" placeholder="Prompt (must be self-contained)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;resize:none;font-family:inherit;margin-bottom:6px"></textarea>
|
||||
<select id="cronFormDeliver" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:8px">
|
||||
<select id="cronFormDeliver" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px">
|
||||
<option value="local">Local (save output only)</option>
|
||||
<option value="discord">Discord</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
</select>
|
||||
<div class="skill-picker-wrap" style="margin-bottom:8px">
|
||||
<input id="cronFormSkillSearch" placeholder="Add skills (optional)..." style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none" autocomplete="off">
|
||||
<div id="cronFormSkillDropdown" class="skill-picker-dropdown" style="display:none"></div>
|
||||
<div id="cronFormSkillTags" class="skill-picker-tags"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="cron-btn run" style="flex:1" onclick="submitCronCreate()">Create job</button>
|
||||
<button class="cron-btn" style="flex:1" onclick="toggleCronForm()">Cancel</button>
|
||||
<button class="cron-btn run" style="flex:1" onclick="submitCronCreate()" data-i18n="create_job">Create job</button>
|
||||
<button class="cron-btn" style="flex:1" onclick="toggleCronForm()" data-i18n="cancel">Cancel</button>
|
||||
</div>
|
||||
<div id="cronFormError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
|
||||
</div>
|
||||
<div class="cron-list" id="cronList"><div style="padding:12px;color:var(--muted);font-size:12px">Loading...</div></div>
|
||||
<div class="cron-list" id="cronList"><div style="padding:12px;color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
|
||||
</div>
|
||||
<!-- Skills panel -->
|
||||
<div class="panel-view" id="panelSkills">
|
||||
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
|
||||
<div class="skills-search" style="flex:1;padding:0"><input id="skillsSearch" placeholder="Search skills..." oninput="filterSkills()"></div>
|
||||
<button class="cron-btn run" style="padding:3px 8px;font-size:10px;flex-shrink:0;margin-left:6px" onclick="toggleSkillForm()">+ New skill</button>
|
||||
<div class="skills-search" style="flex:1;padding:0"><input id="skillsSearch" placeholder="Search skills..." data-i18n-placeholder="search_skills" oninput="filterSkills()"></div>
|
||||
<button class="cron-btn run" style="padding:3px 8px;font-size:10px;flex-shrink:0;margin-left:6px" onclick="toggleSkillForm()">+ <span data-i18n="new_skill">New skill</span></button>
|
||||
</div>
|
||||
<!-- Skill create/edit form (hidden by default) -->
|
||||
<div id="skillCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
|
||||
@@ -69,163 +88,294 @@
|
||||
<input id="skillFormCategory" placeholder="Category (optional, e.g. devops)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
|
||||
<textarea id="skillFormContent" rows="6" placeholder="SKILL.md content (YAML frontmatter + markdown body)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;resize:vertical;font-family:'SF Mono',ui-monospace,monospace;margin-bottom:6px;box-sizing:border-box"></textarea>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="cron-btn run" style="flex:1" onclick="submitSkillSave()">Save skill</button>
|
||||
<button class="cron-btn" style="flex:1" onclick="toggleSkillForm()">Cancel</button>
|
||||
<button class="cron-btn run" style="flex:1" onclick="submitSkillSave()" data-i18n="save_skill">Save skill</button>
|
||||
<button class="cron-btn" style="flex:1" onclick="toggleSkillForm()" data-i18n="cancel">Cancel</button>
|
||||
</div>
|
||||
<div id="skillFormError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
|
||||
</div>
|
||||
<div class="skills-list" id="skillsList"><div style="padding:12px;color:var(--muted);font-size:12px">Loading...</div></div>
|
||||
<div class="skills-list" id="skillsList"><div style="padding:12px;color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
|
||||
</div>
|
||||
<!-- Memory panel -->
|
||||
<div class="panel-view" id="panelMemory">
|
||||
<div style="padding:8px 12px 4px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
|
||||
<span style="font-size:11px;color:var(--muted)">Personal memory</span>
|
||||
<button class="cron-btn run" id="memEditBtn" style="padding:3px 8px;font-size:10px" onclick="toggleMemoryEdit()">✎ Edit</button>
|
||||
<span style="font-size:11px;color:var(--muted)" data-i18n="personal_memory">Personal memory</span>
|
||||
<button class="cron-btn run" id="memEditBtn" style="padding:3px 8px;font-size:10px" onclick="toggleMemoryEdit()"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg> <span data-i18n="edit">Edit</span></button>
|
||||
</div>
|
||||
<div class="memory-panel" id="memoryPanel"><div style="color:var(--muted);font-size:12px">Loading...</div></div>
|
||||
<div class="memory-panel" id="memoryPanel"><div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
|
||||
<!-- Memory edit form (hidden by default) -->
|
||||
<div id="memoryEditForm" style="display:none;padding:8px 12px;border-top:1px solid var(--border);flex-shrink:0">
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">Editing: <span id="memEditSection">memory</span></div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:4px"><span data-i18n="editing">Editing</span>: <span id="memEditSection">memory</span></div>
|
||||
<textarea id="memEditContent" rows="10" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:11px;outline:none;resize:vertical;font-family:'SF Mono',ui-monospace,monospace;box-sizing:border-box;margin-bottom:6px;line-height:1.5"></textarea>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="cron-btn run" style="flex:1" onclick="submitMemorySave()">Save</button>
|
||||
<button class="cron-btn" style="flex:1" onclick="closeMemoryEdit()">Cancel</button>
|
||||
<button class="cron-btn run" style="flex:1" onclick="submitMemorySave()" data-i18n="save">Save</button>
|
||||
<button class="cron-btn" style="flex:1" onclick="closeMemoryEdit()" data-i18n="cancel">Cancel</button>
|
||||
</div>
|
||||
<div id="memEditError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Todo panel -->
|
||||
<div class="panel-view" id="panelTodos">
|
||||
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted);flex-shrink:0">Current task list</div>
|
||||
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted);flex-shrink:0" data-i18n="current_task_list">Current task list</div>
|
||||
<div id="todoPanel" style="flex:1;overflow-y:auto;padding:8px 12px"></div>
|
||||
</div>
|
||||
<!-- Workspaces panel -->
|
||||
<div class="panel-view" id="panelWorkspaces">
|
||||
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted)">Add and switch workspaces for your sessions.</div>
|
||||
<div style="flex:1;overflow-y:auto;padding:0 12px 12px" id="workspacesPanel"><div style="color:var(--muted);font-size:12px">Loading...</div></div>
|
||||
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted)" data-i18n="workspace_desc">Add and switch workspaces for your sessions.</div>
|
||||
<div style="flex:1;overflow-y:auto;padding:0 12px 12px" id="workspacesPanel"><div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
|
||||
</div>
|
||||
<!-- Profiles panel -->
|
||||
<div class="panel-view" id="panelProfiles">
|
||||
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
|
||||
<div style="font-size:11px;color:var(--muted)" data-i18n="tab_profiles">Agent profiles</div>
|
||||
<button class="cron-btn run" style="padding:3px 8px;font-size:10px" onclick="toggleProfileForm()">+ <span data-i18n="new_profile">New profile</span></button>
|
||||
</div>
|
||||
<!-- Profile create form (hidden by default) -->
|
||||
<div id="profileCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
|
||||
<input id="profileFormName" data-i18n-placeholder="profile_name_placeholder" placeholder="Profile name (lowercase, a-z 0-9 hyphens)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted);margin-bottom:8px;cursor:pointer">
|
||||
<input type="checkbox" id="profileFormClone" style="accent-color:var(--accent)"> <span data-i18n="profile_clone_label">Clone config from active profile</span>
|
||||
</label>
|
||||
<input id="profileFormBaseUrl" data-i18n-placeholder="profile_base_url_placeholder" placeholder="Base URL (optional, e.g. http://localhost:11434)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
|
||||
<input id="profileFormApiKey" type="password" data-i18n-placeholder="profile_api_key_placeholder" placeholder="API key (optional)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="cron-btn run" style="flex:1" onclick="submitProfileCreate()" data-i18n="create">Create</button>
|
||||
<button class="cron-btn" style="flex:1" onclick="toggleProfileForm()" data-i18n="cancel">Cancel</button>
|
||||
</div>
|
||||
<div id="profileFormError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
|
||||
</div>
|
||||
<div style="flex:1;overflow-y:auto;padding:0 12px 12px" id="profilesPanel"><div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
|
||||
</div>
|
||||
<div class="sidebar-bottom">
|
||||
<div class="field-label" style="font-size:10px;letter-spacing:.07em;margin-bottom:4px">MODEL</div>
|
||||
<select id="modelSelect">
|
||||
<optgroup label="OpenAI">
|
||||
<option value="openai/gpt-5.4-mini">GPT-5.4 Mini</option>
|
||||
<option value="openai/gpt-4o">GPT-4o</option>
|
||||
<option value="openai/o3">o3</option>
|
||||
<option value="openai/o4-mini">o4-mini</option>
|
||||
</optgroup>
|
||||
<optgroup label="Anthropic">
|
||||
<option value="anthropic/claude-sonnet-4.6">Claude Sonnet 4.6</option>
|
||||
<option value="anthropic/claude-sonnet-4-5">Claude Sonnet 4.5</option>
|
||||
<option value="anthropic/claude-haiku-3-5">Claude Haiku 3.5</option>
|
||||
</optgroup>
|
||||
<optgroup label="Other">
|
||||
<option value="google/gemini-2.5-pro">Gemini 2.5 Pro</option>
|
||||
<option value="deepseek/deepseek-chat-v3-0324">DeepSeek V3</option>
|
||||
<option value="meta-llama/llama-4-scout">Llama 4 Scout</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<div id="sidebarWsDisplay" style="display:flex;align-items:center;gap:7px;padding:0 0 8px;cursor:pointer;border-radius:8px;transition:background .15s" onclick="toggleWsDropdown()" title="Switch workspace">
|
||||
<span style="font-size:14px;opacity:.7">📁</span>
|
||||
<div style="min-width:0;flex:1">
|
||||
<div style="font-size:11px;font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap" id="sidebarWsName">Workspace</div>
|
||||
<div style="font-size:10px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:1px" id="sidebarWsPath"></div>
|
||||
</div>
|
||||
<span style="font-size:10px;color:var(--muted);flex-shrink:0">▾</span>
|
||||
</div>
|
||||
<div class="sidebar-actions">
|
||||
<button class="sm-btn" id="btnDownload" title="Download as Markdown">↓ Transcript</button>
|
||||
<button class="sm-btn" id="btnExportJSON" title="Export full session as JSON">❬/❭ JSON</button>
|
||||
<button class="sm-btn" id="btnImportJSON" title="Import session from JSON">↑ Import</button>
|
||||
<input type="file" id="importFileInput" accept=".json" style="display:none">
|
||||
</div>
|
||||
<button class="hermes-launch-btn" id="btnHermesPanel" onclick="toggleSettings()" title="Open Hermes control center">
|
||||
<span class="hermes-launch-icon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="hermes-gold-sidebar" 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(#hermes-gold-sidebar)"/>
|
||||
<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></span>
|
||||
<span class="hermes-launch-copy">
|
||||
<span class="hermes-launch-title">Hermes WebUI</span>
|
||||
<span class="hermes-launch-meta">Preferences, imports, exports</span>
|
||||
</span>
|
||||
<span class="hermes-launch-chevron" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="resize-handle" id="sidebarResize"></div>
|
||||
</aside>
|
||||
<main class="main">
|
||||
<div class="topbar">
|
||||
<div style="flex:1;min-width:0;overflow:hidden"><div class="topbar-title" id="topbarTitle">Hermes</div><div class="topbar-meta" id="topbarMeta">Start a new conversation</div></div>
|
||||
<button class="mobile-hamburger" id="btnHamburger" onclick="toggleMobileSidebar()" title="Menu">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<div style="flex:1;min-width:0;overflow:hidden"><div class="topbar-title" id="topbarTitle">Hermes</div><div class="topbar-meta" id="topbarMeta" data-i18n="new_conversation">Start a new conversation</div></div>
|
||||
<div class="topbar-chips">
|
||||
<div class="chip model" id="modelChip">GPT-5.4 Mini</div>
|
||||
<div id="wsChipWrap" style="position:relative">
|
||||
<div class="chip ws-chip" id="wsChip" onclick="toggleWsDropdown()" title="Switch workspace" style="cursor:pointer">📁 test-workspace ▾</div>
|
||||
<div class="ws-dropdown" id="wsDropdown"></div>
|
||||
</div>
|
||||
<button class="chip clear-btn" id="btnClearConv" onclick="clearConversation()" title="Clear all messages in this conversation" style="display:none">🗑 Clear</button>
|
||||
<button class="chip gear-btn" id="btnSettings" onclick="toggleSettings()" title="Settings">⚙</button>
|
||||
<button class="chip workspace-toggle-btn" id="btnWorkspacePanelToggle" onclick="toggleWorkspacePanel()" title="Show workspace panel" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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"/></svg><span class="workspace-toggle-label">Files</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="messages" id="messages">
|
||||
<button id="scrollToBottomBtn" class="scroll-to-bottom-btn" aria-label="Scroll to bottom" onclick="scrollToBottom()" style="display:none">↓</button>
|
||||
<div class="empty-state" id="emptyState">
|
||||
<div class="empty-logo">🦉</div>
|
||||
<h2>What can I help with?</h2>
|
||||
<p>Ask anything, run commands, explore files, or manage your scheduled tasks.</p>
|
||||
<div class="empty-logo"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="80" height="80" aria-label="Hermes caduceus">
|
||||
<defs>
|
||||
<linearGradient id="hermes-gold" 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(#hermes-gold)"/>
|
||||
<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></div>
|
||||
<h2 data-i18n="empty_title">What can I help with?</h2>
|
||||
<p data-i18n="empty_subtitle">Ask anything, run commands, explore files, or manage your scheduled tasks.</p>
|
||||
<div class="suggestion-grid">
|
||||
<button class="suggestion" data-msg="What files are in this workspace?">📁 What files are in this workspace?</button>
|
||||
<button class="suggestion" data-msg="What's on my schedule today?">📋 What's on my schedule today?</button>
|
||||
<button class="suggestion" data-msg="Help me plan a small project.">🗺 Help me plan a small project.</button>
|
||||
<button class="suggestion" data-msg="What files are in this workspace?"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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"/></svg> <span data-i18n="suggest_files">What files are in this workspace?</span></button>
|
||||
<button class="suggestion" data-msg="What's on my schedule today?"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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"/></svg> <span data-i18n="suggest_schedule">What's on my schedule today?</span></button>
|
||||
<button class="suggestion" data-msg="Help me plan a small project."><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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"/></svg> <span data-i18n="suggest_plan">Help me plan a small project.</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="messages-inner" id="msgInner"></div>
|
||||
<div id="liveCompressionCards" class="live-compression-cards"></div>
|
||||
<div id="liveToolCards" style="display:none;max-width:800px;margin:0 auto;width:100%;padding:0 24px;"></div>
|
||||
</div>
|
||||
<div class="update-banner" id="updateBanner">
|
||||
<div style="display:flex;flex-direction:column;flex:1;min-width:0">
|
||||
<span id="updateMsg"></span>
|
||||
<div id="updateError" style="display:none;font-size:12px;color:var(--error,#e05);margin-top:4px;word-break:break-word"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-shrink:0;flex-wrap:wrap">
|
||||
<button class="update-btn" onclick="dismissUpdate()">Later</button>
|
||||
<button class="update-btn update-primary" id="btnApplyUpdate" onclick="applyUpdates()">Update Now</button>
|
||||
<button class="update-btn" id="btnForceUpdate" style="display:none;background:var(--error,#e05);color:#fff;border-color:var(--error,#e05)" onclick="forceUpdate(this)">Force update</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reconnect-banner" id="reconnectBanner">
|
||||
<span id="reconnectMsg">⚠ A response may have been in progress when you last left. Reload messages?</span>
|
||||
<span id="reconnectMsg"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px"><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"/></svg> A response may have been in progress when you last left. Reload messages?</span>
|
||||
<div style="display:flex;gap:8px;flex-shrink:0">
|
||||
<button class="reconnect-btn" onclick="dismissReconnect()">Dismiss</button>
|
||||
<button class="reconnect-btn" onclick="refreshSession()">↻ Reload</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="approval-card" id="approvalCard">
|
||||
<div class="approval-inner">
|
||||
<div class="approval-header">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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"/></svg>
|
||||
Dangerous command — approval required
|
||||
</div>
|
||||
<div class="approval-desc" id="approvalDesc"></div>
|
||||
<div class="approval-cmd" id="approvalCmd"></div>
|
||||
<div class="approval-btns">
|
||||
<button class="approval-btn once" onclick="respondApproval('once')">✓ Allow once</button>
|
||||
<button class="approval-btn session" onclick="respondApproval('session')">🔒 Allow this session</button>
|
||||
<button class="approval-btn always" onclick="respondApproval('always')">☆ Always allow</button>
|
||||
<button class="approval-btn deny" onclick="respondApproval('deny')">✕ Deny</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Activity bar: shows tool progress / status above composer (not inside input) -->
|
||||
<div id="activityBar" style="display:none;max-width:800px;margin:0 auto;width:100%;padding:0 24px;">
|
||||
<div id="activityBarInner" style="display:flex;align-items:center;gap:8px;padding:6px 12px;border-radius:8px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.07);font-size:12px;color:var(--muted);animation:fadeIn .15s ease;">
|
||||
<span id="activityIcon" style="font-size:13px;opacity:.6">⚙</span>
|
||||
<span id="activityText" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
|
||||
<button id="btnCancel" onclick="cancelStream()" style="display:none;background:rgba(233,69,96,.12);border:1px solid rgba(233,69,96,.35);color:#e94560;font-size:11px;font-weight:600;padding:3px 10px;border-radius:6px;cursor:pointer;flex-shrink:0;transition:background .15s" title="Cancel this task">■ Cancel</button>
|
||||
<button id="btnDismissStatus" onclick="setStatus('')" style="display:none;background:none;border:none;color:var(--muted);font-size:14px;line-height:1;cursor:pointer;padding:0 2px;opacity:.5;flex-shrink:0" title="Dismiss">✕</button>
|
||||
<span id="activityDots" style="display:flex;gap:3px;align-items:center">
|
||||
<span style="width:4px;height:4px;border-radius:50%;background:var(--blue);opacity:.3;animation:pulse 1.4s ease-in-out infinite"></span>
|
||||
<span style="width:4px;height:4px;border-radius:50%;background:var(--blue);opacity:.3;animation:pulse 1.4s ease-in-out .22s infinite"></span>
|
||||
<span style="width:4px;height:4px;border-radius:50%;background:var(--blue);opacity:.3;animation:pulse 1.4s ease-in-out .44s infinite"></span>
|
||||
</span>
|
||||
<button class="reconnect-btn" onclick="refreshSession()"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px"><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"/></svg> Reload</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="composer-wrap" id="composerWrap">
|
||||
<div class="composer-flyout">
|
||||
<div class="approval-card" id="approvalCard" role="alertdialog" aria-labelledby="approvalHeading" aria-describedby="approvalDesc">
|
||||
<div class="approval-inner">
|
||||
<div class="approval-header">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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"/></svg>
|
||||
<span id="approvalHeading" data-i18n="approval_heading">Approval required</span>
|
||||
</div>
|
||||
<div class="approval-desc" id="approvalDesc"></div>
|
||||
<div class="approval-cmd" id="approvalCmd"></div>
|
||||
<div class="approval-counter" id="approvalCounter" style="display:none;font-size:0.75em;opacity:0.6;margin-top:4px;"></div>
|
||||
<div class="approval-btns">
|
||||
<button class="approval-btn once" id="approvalBtnOnce" onclick="respondApproval('once')" title="Allow this one command (Enter)" data-i18n-title="approval_btn_once_title">
|
||||
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></span>
|
||||
<span class="approval-btn-label" data-i18n="approval_btn_once">Allow once</span>
|
||||
<kbd class="approval-kbd">↵</kbd>
|
||||
</button>
|
||||
<button class="approval-btn session" id="approvalBtnSession" onclick="respondApproval('session')" title="Allow for this session">
|
||||
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
|
||||
<span class="approval-btn-label" data-i18n="approval_btn_session">Allow session</span>
|
||||
</button>
|
||||
<button class="approval-btn always" id="approvalBtnAlways" onclick="respondApproval('always')" title="Always allow this command pattern">
|
||||
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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"/></svg></span>
|
||||
<span class="approval-btn-label" data-i18n="approval_btn_always">Always allow</span>
|
||||
</button>
|
||||
<button class="approval-btn deny" id="approvalBtnDeny" onclick="respondApproval('deny')" title="Deny — do not run this command">
|
||||
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>
|
||||
<span class="approval-btn-label" data-i18n="approval_btn_deny">Deny</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="clarify-card" id="clarifyCard" role="dialog" aria-labelledby="clarifyHeading" aria-describedby="clarifyQuestion clarifyHint">
|
||||
<div class="clarify-inner">
|
||||
<div class="clarify-header">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 17h.01"/><path d="M9.09 9a3 3 0 1 1 5.82 1c0 2-3 2-3 4"/><circle cx="12" cy="12" r="10"/></svg>
|
||||
<span id="clarifyHeading" data-i18n="clarify_heading">Clarification needed</span>
|
||||
</div>
|
||||
<div class="clarify-question" id="clarifyQuestion"></div>
|
||||
<div class="clarify-choices" id="clarifyChoices"></div>
|
||||
<div class="clarify-response">
|
||||
<input class="clarify-input" id="clarifyInput" type="text" data-i18n-placeholder="clarify_input_placeholder" placeholder="Type your response…">
|
||||
<button class="clarify-submit" id="clarifySubmit" onclick="respondClarify()" data-i18n="clarify_send">Send</button>
|
||||
</div>
|
||||
<div class="clarify-hint" id="clarifyHint" data-i18n="clarify_hint">Pick a choice, or type your own answer below.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="composer-box" id="composerBox">
|
||||
<div class="cmd-dropdown" id="cmdDropdown"></div>
|
||||
<div class="drop-hint" id="dropHint">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><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"/></svg>
|
||||
Drop files to upload to workspace
|
||||
</div>
|
||||
<div class="attach-tray" id="attachTray"></div>
|
||||
<div class="mic-status" id="micStatus" style="display:none"><span class="mic-dot"></span> Listening…</div>
|
||||
<textarea id="msg" rows="1" placeholder="Message Hermes…"></textarea>
|
||||
<div class="composer-footer">
|
||||
<div class="composer-left">
|
||||
<input type="file" id="fileInput" multiple accept="image/*,text/*,application/pdf,application/json,.md,.py,.js,.ts,.yaml,.yml,.toml,.csv,.sh,.txt,.log,.env" style="display:none">
|
||||
<input type="file" id="fileInput" multiple accept="image/*,text/*,application/pdf,application/json,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,.md,.py,.js,.ts,.yaml,.yml,.toml,.csv,.sh,.txt,.log,.env,.xls,.xlsx,.doc,.docx" style="display:none">
|
||||
<button class="icon-btn" id="btnAttach" title="Attach files">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-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.83-2.83l8.49-8.48"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn mic-btn" id="btnMic" title="Voice input" style="display:none">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="1" width="6" height="12" rx="3"/>
|
||||
<path d="M5 10a7 7 0 0 0 14 0"/>
|
||||
<line x1="12" y1="19" x2="12" y2="23"/>
|
||||
<line x1="8" y1="23" x2="16" y2="23"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="composer-divider" aria-hidden="true"></div>
|
||||
<div id="profileChipWrap" class="composer-profile-wrap">
|
||||
<button class="composer-profile-chip profile-chip" id="profileChip" type="button" onclick="toggleProfileDropdown()" title="Switch profile">
|
||||
<span class="composer-profile-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></span>
|
||||
<span class="composer-profile-label" id="profileChipLabel">default</span>
|
||||
<span class="composer-profile-chevron" aria-hidden="true"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="composer-ws-wrap">
|
||||
<button class="composer-workspace-chip ws-chip" id="composerWorkspaceChip" type="button" onclick="toggleComposerWsDropdown()" title="Switch workspace" disabled>
|
||||
<span class="composer-workspace-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></svg></span>
|
||||
<span class="composer-workspace-label" id="composerWorkspaceLabel"></span>
|
||||
<span class="composer-workspace-chevron" aria-hidden="true"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="composer-model-wrap">
|
||||
<button class="composer-model-chip" id="composerModelChip" type="button" onclick="toggleModelDropdown()" title="Conversation model">
|
||||
<span class="composer-model-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/></svg></span>
|
||||
<span class="composer-model-label" id="composerModelLabel"></span>
|
||||
<span class="composer-model-chevron" aria-hidden="true"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||
</button>
|
||||
<select id="modelSelect" class="composer-model-select" title="Conversation model" aria-hidden="true" tabindex="-1">
|
||||
<optgroup label="OpenAI">
|
||||
<option value="openai/gpt-5.4-mini">GPT-5.4 Mini</option>
|
||||
<option value="openai/gpt-4o">GPT-4o</option>
|
||||
<option value="openai/o3">o3</option>
|
||||
<option value="openai/o4-mini">o4-mini</option>
|
||||
</optgroup>
|
||||
<optgroup label="Anthropic">
|
||||
<option value="anthropic/claude-sonnet-4.6">Claude Sonnet 4.6</option>
|
||||
<option value="anthropic/claude-sonnet-4-5">Claude Sonnet 4.5</option>
|
||||
<option value="anthropic/claude-haiku-3-5">Claude Haiku 3.5</option>
|
||||
</optgroup>
|
||||
<optgroup label="Other">
|
||||
<option value="google/gemini-3.1-pro-preview">Gemini 3.1 Pro Preview</option>
|
||||
<option value="google/gemini-3-flash-preview">Gemini 3 Flash Preview</option>
|
||||
<option value="deepseek/deepseek-chat-v3-0324">DeepSeek V3</option>
|
||||
<option value="meta-llama/llama-4-scout">Llama 4 Scout</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="composer-right">
|
||||
<button class="send-btn" id="btnSend">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
|
||||
Send
|
||||
<span class="composer-status" id="composerStatus" style="display:none"></span>
|
||||
<div class="ctx-indicator-wrap" id="ctxIndicatorWrap" style="display:none">
|
||||
<button class="ctx-indicator" id="ctxIndicator" type="button" aria-label="Context window usage" aria-describedby="ctxTooltip">
|
||||
<span class="ctx-ring">
|
||||
<svg class="ctx-ring-svg" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle class="ctx-ring-track" cx="12" cy="12" r="9.75"></circle>
|
||||
<circle class="ctx-ring-value" id="ctxRingValue" cx="12" cy="12" r="9.75"></circle>
|
||||
</svg>
|
||||
<span class="ctx-ring-center" id="ctxPercent">0</span>
|
||||
</span>
|
||||
</button>
|
||||
<div class="ctx-tooltip" id="ctxTooltip" role="tooltip" aria-hidden="true">
|
||||
<div class="ctx-tooltip-title">Context window</div>
|
||||
<div class="ctx-tooltip-line" id="ctxTooltipUsage"></div>
|
||||
<div class="ctx-tooltip-line" id="ctxTooltipTokens"></div>
|
||||
<div class="ctx-tooltip-line" id="ctxTooltipThreshold"></div>
|
||||
<div class="ctx-tooltip-line" id="ctxTooltipCost" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="cancel-btn" id="btnCancel" onclick="cancelStream()" style="display:none" title="Stop generation" aria-label="Stop generation">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="2"></rect></svg>
|
||||
</button>
|
||||
<button class="send-btn" id="btnSend" title="Send message" disabled>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="profile-dropdown" id="profileDropdown"></div>
|
||||
<div class="ws-dropdown ws-dropdown-footer" id="composerWsDropdown"></div>
|
||||
<div class="model-dropdown" id="composerModelDropdown"></div>
|
||||
</div>
|
||||
<div class="upload-bar-wrap" id="uploadBarWrap"><div class="upload-bar" id="uploadBar"></div></div>
|
||||
</div>
|
||||
@@ -235,53 +385,310 @@
|
||||
<div class="resize-handle" id="rightpanelResize"></div>
|
||||
<div class="panel-header">
|
||||
<span>Workspace</span>
|
||||
<span class="git-badge" id="gitBadge" style="display:none"></span>
|
||||
<div class="panel-actions">
|
||||
<button class="panel-icon-btn" id="btnNewFile" title="New file" onclick="promptNewFile()">+</button>
|
||||
<button class="panel-icon-btn" id="btnNewFolder" title="New folder" onclick="promptNewFolder()">📁</button>
|
||||
<button class="panel-icon-btn" id="btnRefreshPanel" title="Refresh" onclick="if(S.session)loadDir('.')">↻</button>
|
||||
<button class="panel-icon-btn close-preview" id="btnClearPreview" title="Close preview">✕</button>
|
||||
<button class="panel-icon-btn" id="btnCollapseWorkspacePanel" title="Hide workspace panel" onclick="toggleWorkspacePanel(false)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg></button>
|
||||
<button class="panel-icon-btn" id="btnUpDir" title="Parent directory" onclick="navigateUp()" style="display:none"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg></button>
|
||||
<button class="panel-icon-btn" id="btnNewFile" title="New file" onclick="promptNewFile()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
|
||||
<button class="panel-icon-btn" id="btnNewFolder" title="New folder" onclick="promptNewFolder()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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"/></svg></button>
|
||||
<button class="panel-icon-btn" id="btnRefreshPanel" title="Refresh" onclick="if(S.session)loadDir(S.currentDir)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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"/></svg></button>
|
||||
<button class="panel-icon-btn close-preview" id="btnClearPreview" title="Close preview"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
<button class="panel-icon-btn mobile-close-btn" onclick="handleWorkspaceClose()" title="Close" aria-label="Close workspace panel">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="breadcrumb-bar" id="breadcrumbBar" style="display:none"></div>
|
||||
<div class="file-tree" id="fileTree"></div>
|
||||
<div id="wsEmptyState" style="display:none;flex:1;align-items:center;justify-content:center;padding:24px 16px;text-align:center;color:var(--muted);font-size:12px;line-height:1.6"></div>
|
||||
<div class="preview-area" id="previewArea">
|
||||
<div class="preview-path" id="previewPath">
|
||||
<span id="previewPathText"></span>
|
||||
<span class="preview-badge" id="previewBadge"></span>
|
||||
<button id="btnDownloadFile" class="panel-icon-btn" style="margin-left:auto;font-size:12px;width:auto;padding:2px 8px" onclick="downloadFile(_previewCurrentPath)" title="Download file to your computer">⇩ Download</button>
|
||||
<button id="btnEditFile" class="panel-icon-btn" style="font-size:12px;width:auto;padding:2px 8px;display:none" onclick="toggleEditMode()">✎ Edit</button>
|
||||
<button id="btnDownloadFile" class="panel-icon-btn" style="margin-left:auto;font-size:12px;width:auto;padding:2px 8px;display:inline-flex;align-items:center;gap:4px" onclick="downloadFile(_previewCurrentPath)" title="Download file to your computer"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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"/></svg> Download</button>
|
||||
<button id="btnEditFile" class="panel-icon-btn" style="font-size:12px;width:auto;padding:2px 8px;display:none;align-items:center;gap:4px" onclick="toggleEditMode()"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg> Edit</button>
|
||||
</div>
|
||||
<pre class="preview-code" id="previewCode"></pre>
|
||||
<div class="preview-img-wrap" id="previewImgWrap" style="display:none"><img class="preview-img" id="previewImg" src="" alt=""></div>
|
||||
<div class="preview-md" id="previewMd" style="display:none"></div>
|
||||
<textarea id="previewEditArea" style="display:none;flex:1;width:100%;background:var(--code-bg);color:#e2e8f0;border:1px solid var(--border2);border-radius:8px;padding:12px;font-family:'SF Mono',ui-monospace,monospace;font-size:12px;line-height:1.6;resize:none;outline:none" oninput="_previewDirty=true;updateEditBtn()"></textarea>
|
||||
<textarea id="previewEditArea" style="display:none;flex:1;width:100%;background:var(--code-bg);color:var(--pre-text);border:1px solid var(--border2);border-radius:8px;padding:12px;font-family:'SF Mono',ui-monospace,monospace;font-size:12px;line-height:1.6;resize:none;outline:none" oninput="_previewDirty=true;updateEditBtn()"></textarea>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="onboarding-overlay" id="onboardingOverlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="onboardingTitle">
|
||||
<div class="onboarding-card">
|
||||
<div class="onboarding-shell">
|
||||
<div class="onboarding-sidebar">
|
||||
<div class="onboarding-badge" data-i18n="onboarding_badge">FIRST RUN</div>
|
||||
<h2 id="onboardingTitle" data-i18n="onboarding_title">Welcome to Hermes Web UI</h2>
|
||||
<p id="onboardingLead" data-i18n="onboarding_lead">A quick guided setup will check your Hermes install, choose a workspace and model, and optionally protect the app with a password.</p>
|
||||
<div class="onboarding-steps" id="onboardingSteps"></div>
|
||||
</div>
|
||||
<div class="onboarding-main">
|
||||
<div class="onboarding-status" id="onboardingNotice"></div>
|
||||
<div class="onboarding-body" id="onboardingBody"></div>
|
||||
<div class="onboarding-actions">
|
||||
<button class="sm-btn" id="onboardingBackBtn" onclick="prevOnboardingStep()" style="display:none" data-i18n="onboarding_back">Back</button>
|
||||
<button class="sm-btn" id="onboardingSkipBtn" onclick="skipOnboarding()" style="margin-right:auto;opacity:.7" data-i18n="onboarding_skip">Skip setup</button>
|
||||
<button class="sm-btn" id="onboardingNextBtn" onclick="nextOnboardingStep()" style="font-weight:700;color:var(--blue);border-color:rgba(124,185,255,.32)" data-i18n="onboarding_continue">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-overlay" id="settingsOverlay" style="display:none">
|
||||
<div class="settings-panel">
|
||||
<div class="settings-header">
|
||||
<h3 style="margin:0;font-size:16px">Settings</h3>
|
||||
<button class="panel-icon-btn" onclick="toggleSettings()" title="Close">✕</button>
|
||||
<div class="settings-heading">
|
||||
<div class="settings-kicker">Hermes WebUI</div>
|
||||
<h3 style="margin:0;font-size:18px">Control Center</h3>
|
||||
<div class="settings-subtitle">Preferences, conversation tools, and system controls.</div>
|
||||
</div>
|
||||
<button class="panel-icon-btn" onclick="_closeSettingsPanel()" title="Close"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</div>
|
||||
<div class="settings-body">
|
||||
<div class="settings-field">
|
||||
<label for="settingsModel">Default Model</label>
|
||||
<select id="settingsModel" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select>
|
||||
<div class="settings-shell">
|
||||
<div class="settings-tabs" role="tablist" aria-label="Hermes control center sections">
|
||||
<button class="settings-tab active" id="settingsTabConversation" type="button" role="tab" aria-selected="true" aria-controls="settingsPaneConversation" onclick="switchSettingsSection('conversation')">
|
||||
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
<span class="settings-tab-title">Conversation</span>
|
||||
</button>
|
||||
<button class="settings-tab" id="settingsTabAppearance" type="button" role="tab" aria-selected="false" aria-controls="settingsPaneAppearance" onclick="switchSettingsSection('appearance')">
|
||||
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
|
||||
<span class="settings-tab-title">Appearance</span>
|
||||
</button>
|
||||
<button class="settings-tab" id="settingsTabPreferences" type="button" role="tab" aria-selected="false" aria-controls="settingsPanePreferences" onclick="switchSettingsSection('preferences')">
|
||||
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg>
|
||||
<span class="settings-tab-title">Preferences</span>
|
||||
</button>
|
||||
<button class="settings-tab" id="settingsTabProviders" type="button" role="tab" aria-selected="false" aria-controls="settingsPaneProviders" onclick="switchSettingsSection('providers')">
|
||||
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
|
||||
<span class="settings-tab-title" data-i18n="providers_tab_title">Providers</span>
|
||||
</button>
|
||||
<button class="settings-tab" id="settingsTabSystem" type="button" role="tab" aria-selected="false" aria-controls="settingsPaneSystem" onclick="switchSettingsSection('system')">
|
||||
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><line x1="6" y1="7" x2="6.01" y2="7"/><line x1="6" y1="17" x2="6.01" y2="17"/></svg>
|
||||
<span class="settings-tab-title">System</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="settings-main">
|
||||
<div class="settings-pane active" id="settingsPaneConversation" role="tabpanel" aria-labelledby="settingsTabConversation">
|
||||
<div class="settings-section-head">
|
||||
<div>
|
||||
<div class="settings-section-title">Conversation</div>
|
||||
<div class="settings-section-meta" id="hermesSessionMeta">No active conversation selected.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hermes-action-grid">
|
||||
<button class="settings-action-btn" id="btnDownload" title="Download as Markdown" data-i18n-title="download_transcript"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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"/></svg> <span data-i18n="transcript">Transcript</span></button>
|
||||
<button class="settings-action-btn" id="btnExportJSON" title="Export full session as JSON"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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"/></svg> JSON</button>
|
||||
<button class="settings-action-btn" id="btnImportJSON" title="Import session from JSON"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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"/></svg> <span data-i18n="import">Import</span></button>
|
||||
<button class="settings-action-btn danger" id="btnClearConvModal" onclick="clearConversation()" title="Clear all messages in this conversation"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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 1 2 2 2v2"/></svg> Clear</button>
|
||||
</div>
|
||||
<input type="file" id="importFileInput" accept=".json" style="display:none">
|
||||
</div>
|
||||
<div class="settings-pane" id="settingsPaneAppearance" role="tabpanel" aria-labelledby="settingsTabAppearance">
|
||||
<div class="settings-section-head">
|
||||
<div>
|
||||
<div class="settings-section-title">Appearance</div>
|
||||
<div class="settings-section-meta">Theme, accent colors, and visual style.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label data-i18n="settings_label_theme">Theme</label>
|
||||
<div id="themePickerGrid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:4px">
|
||||
<button type="button" data-theme-val="light" onclick="_pickTheme('light')" class="theme-pick-btn" style="border:1px solid var(--border2);border-radius:10px;padding:10px 8px;text-align:center;cursor:pointer;background:none;transition:all .15s">
|
||||
<div style="width:100%;height:40px;border-radius:6px;background:#fff;border:1px solid rgba(0,0,0,.12);margin-bottom:6px;display:flex;align-items:center;justify-content:center">
|
||||
<svg width="16" height="16" fill="none" stroke="#999" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
|
||||
</div>
|
||||
<span style="font-size:12px;font-weight:500;color:var(--text)">Light</span>
|
||||
</button>
|
||||
<button type="button" data-theme-val="dark" onclick="_pickTheme('dark')" class="theme-pick-btn" style="border:1px solid var(--border2);border-radius:10px;padding:10px 8px;text-align:center;cursor:pointer;background:none;transition:all .15s">
|
||||
<div style="width:100%;height:40px;border-radius:6px;background:#1a1a2e;border:1px solid rgba(255,255,255,.1);margin-bottom:6px;display:flex;align-items:center;justify-content:center">
|
||||
<svg width="16" height="16" fill="none" stroke="#666" stroke-width="2" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1111.21 3a7 7 0 009.79 9.79z"/></svg>
|
||||
</div>
|
||||
<span style="font-size:12px;font-weight:500;color:var(--text)">Dark</span>
|
||||
</button>
|
||||
<button type="button" data-theme-val="system" onclick="_pickTheme('system')" class="theme-pick-btn" style="border:1px solid var(--border2);border-radius:10px;padding:10px 8px;text-align:center;cursor:pointer;background:none;transition:all .15s">
|
||||
<div style="width:100%;height:40px;border-radius:6px;background:linear-gradient(to right,#fff,#1a1a2e);border:1px solid rgba(0,0,0,.12);margin-bottom:6px;display:flex;align-items:center;justify-content:center">
|
||||
<svg width="16" height="16" fill="none" stroke="#888" stroke-width="2" viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
|
||||
</div>
|
||||
<span style="font-size:12px;font-weight:500;color:var(--text)">System</span>
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" id="settingsTheme" value="dark">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label data-i18n="settings_label_skin">Skin</label>
|
||||
<div id="skinPickerGrid" style="display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-top:4px">
|
||||
</div>
|
||||
<input type="hidden" id="settingsSkin" value="default">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label data-i18n="settings_label_font_size">Font size</label>
|
||||
<div id="fontSizePickerGrid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:4px">
|
||||
<button type="button" data-font-size-val="small" onclick="_pickFontSize('small')" class="font-size-pick-btn" style="border:1px solid var(--border2);border-radius:10px;padding:10px 8px;text-align:center;cursor:pointer;background:none;transition:all .15s">
|
||||
<div style="width:100%;height:40px;border-radius:6px;background:var(--surface);border:1px solid var(--border);margin-bottom:6px;display:flex;align-items:center;justify-content:center">
|
||||
<span style="font-size:10px;font-weight:600;color:var(--muted)">Aa</span>
|
||||
</div>
|
||||
<span style="font-size:12px;font-weight:500;color:var(--text)" data-i18n="font_size_small">Small</span>
|
||||
</button>
|
||||
<button type="button" data-font-size-val="default" onclick="_pickFontSize('default')" class="font-size-pick-btn" style="border:1px solid var(--border2);border-radius:10px;padding:10px 8px;text-align:center;cursor:pointer;background:none;transition:all .15s">
|
||||
<div style="width:100%;height:40px;border-radius:6px;background:var(--surface);border:1px solid var(--border);margin-bottom:6px;display:flex;align-items:center;justify-content:center">
|
||||
<span style="font-size:13px;font-weight:600;color:var(--muted)">Aa</span>
|
||||
</div>
|
||||
<span style="font-size:12px;font-weight:500;color:var(--text)" data-i18n="font_size_default">Default</span>
|
||||
</button>
|
||||
<button type="button" data-font-size-val="large" onclick="_pickFontSize('large')" class="font-size-pick-btn" style="border:1px solid var(--border2);border-radius:10px;padding:10px 8px;text-align:center;cursor:pointer;background:none;transition:all .15s">
|
||||
<div style="width:100%;height:40px;border-radius:6px;background:var(--surface);border:1px solid var(--border);margin-bottom:6px;display:flex;align-items:center;justify-content:center">
|
||||
<span style="font-size:17px;font-weight:600;color:var(--muted)">Aa</span>
|
||||
</div>
|
||||
<span style="font-size:12px;font-weight:500;color:var(--text)" data-i18n="font_size_large">Large</span>
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" id="settingsFontSize" value="default">
|
||||
</div>
|
||||
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600" data-i18n="settings_save_btn">Save Settings</button>
|
||||
</div>
|
||||
<div class="settings-pane" id="settingsPanePreferences" role="tabpanel" aria-labelledby="settingsTabPreferences">
|
||||
<div class="settings-section-head">
|
||||
<div>
|
||||
<div class="settings-section-title">Preferences</div>
|
||||
<div class="settings-section-meta">Defaults and UI behavior for Hermes Web UI.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="settingsModel" data-i18n="settings_label_model">Default Model</label>
|
||||
<select id="settingsModel" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="settingsSendKey" data-i18n="settings_label_send_key">Send Key</label>
|
||||
<select id="settingsSendKey" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
|
||||
<option value="enter">Enter (Shift+Enter for newline)</option>
|
||||
<option value="ctrl+enter">Ctrl+Enter (Enter for newline)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="settingsLanguage" data-i18n="settings_label_language">Language</label>
|
||||
<select id="settingsLanguage" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsSoundEnabled" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
<span data-i18n="settings_label_sound">Notification sound</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_sound">Play a sound when the assistant finishes a response.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsNotificationsEnabled" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
<span data-i18n="settings_label_notifications">Browser notifications</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_notifications">Show a system notification when a response completes while the tab is in the background.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsShowTokenUsage" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
<span data-i18n="settings_label_token_usage">Show token usage after responses</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_token_usage">Displays input/output token count below each assistant reply. Also toggled with <code>/usage</code>.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="settingsSidebarDensity" data-i18n="settings_label_sidebar_density">Sidebar density</label>
|
||||
<select id="settingsSidebarDensity" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
|
||||
<option value="compact" data-i18n="settings_sidebar_density_compact">Compact</option>
|
||||
<option value="detailed" data-i18n="settings_sidebar_density_detailed">Detailed</option>
|
||||
</select>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_sidebar_density">Controls how much metadata the session list shows in the left sidebar.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsShowCliSessions" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
<span data-i18n="settings_label_cli_sessions">Show CLI sessions in sidebar</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_cli_sessions">Merges sessions from the Hermes CLI (state.db) into the session list. Click a CLI session to import it and continue the conversation.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsSyncInsights" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
<span data-i18n="settings_label_sync_insights">Sync usage to /insights</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_sync_insights">Mirrors WebUI token usage to state.db so <code>hermes /insights</code> includes browser session data. Off by default.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsCheckUpdates" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
<span data-i18n="settings_label_check_updates">Check for updates</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_check_updates">Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="settingsBotName" data-i18n="settings_label_bot_name">Assistant Name</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px" data-i18n="settings_desc_bot_name">Display name for the assistant throughout the UI. Defaults to Hermes.</div>
|
||||
<input type="text" id="settingsBotName" placeholder="Hermes" maxlength="64" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||
</div>
|
||||
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600" data-i18n="settings_save_btn">Save Settings</button>
|
||||
</div>
|
||||
<div class="settings-pane" id="settingsPaneProviders" role="tabpanel" aria-labelledby="settingsTabProviders">
|
||||
<div class="settings-section-head">
|
||||
<div>
|
||||
<div class="settings-section-title" data-i18n="providers_section_title">Providers</div>
|
||||
<div class="settings-section-meta" data-i18n="providers_section_meta">Manage API keys for AI providers. Changes take effect immediately.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="providersList" style="display:flex;flex-direction:column;gap:6px;margin-top:4px">
|
||||
<!-- Populated dynamically by loadProvidersPanel() -->
|
||||
</div>
|
||||
<div id="providersEmpty" style="display:none;text-align:center;padding:32px 0;color:var(--muted);font-size:13px" data-i18n="providers_empty">
|
||||
No configurable providers found.
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-pane" id="settingsPaneSystem" role="tabpanel" aria-labelledby="settingsTabSystem">
|
||||
<div class="settings-section-head">
|
||||
<div>
|
||||
<div class="settings-section-title">System</div>
|
||||
<div class="settings-section-meta">Instance version and access controls.</div>
|
||||
</div>
|
||||
<span class="settings-version-badge">—</span>
|
||||
</div>
|
||||
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
||||
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px" data-i18n="settings_desc_password">Enter a new password to set or change it. Leave blank to keep current setting.</div>
|
||||
<input type="password" id="settingsPassword" placeholder="Enter new password…" data-i18n-placeholder="password_placeholder" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||
</div>
|
||||
<button class="sm-btn" id="btnDisableAuth" onclick="disableAuth()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:#e8a030;border-color:rgba(232,160,48,.3);display:none" data-i18n="disable_auth">Disable Auth</button>
|
||||
<button class="sm-btn" id="btnSignOut" onclick="signOut()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:var(--accent);border-color:rgba(233,69,96,.3);display:none" data-i18n="sign_out">Sign Out</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="settingsWorkspace">Default Workspace</label>
|
||||
<select id="settingsWorkspace" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select>
|
||||
</div>
|
||||
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600">Save Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-overlay" id="mobileOverlay" onclick="closeMobileSidebar()"></div>
|
||||
<div class="app-dialog-overlay" id="appDialogOverlay" style="display:none" aria-hidden="true">
|
||||
<div class="app-dialog" id="appDialog" role="dialog" aria-modal="true" aria-labelledby="appDialogTitle" aria-describedby="appDialogDesc">
|
||||
<div class="app-dialog-header">
|
||||
<div class="app-dialog-title" id="appDialogTitle">Confirm action</div>
|
||||
<button class="app-dialog-close" id="appDialogClose" type="button" aria-label="Close dialog">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="app-dialog-desc" id="appDialogDesc"></div>
|
||||
<input class="app-dialog-input" id="appDialogInput" type="text" style="display:none">
|
||||
<div class="app-dialog-actions">
|
||||
<button class="app-dialog-btn" id="appDialogCancel" type="button" data-i18n="cancel">Cancel</button>
|
||||
<button class="app-dialog-btn confirm" id="appDialogConfirm" type="button">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="/static/ui.js"></script>
|
||||
<script src="/static/workspace.js"></script>
|
||||
<script src="/static/sessions.js"></script>
|
||||
<script src="/static/messages.js"></script>
|
||||
<script src="/static/panels.js"></script>
|
||||
<script src="/static/boot.js"></script>
|
||||
<script src="static/i18n.js"></script>
|
||||
<script src="static/icons.js"></script>
|
||||
<script src="static/ui.js"></script>
|
||||
<script src="static/workspace.js"></script>
|
||||
<script src="static/sessions.js"></script>
|
||||
<script src="static/commands.js"></script>
|
||||
<script src="static/messages.js"></script>
|
||||
<script src="static/panels.js"></script>
|
||||
<script src="static/onboarding.js"></script>
|
||||
<script src="static/boot.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
55
static/login.js
Normal file
55
static/login.js
Normal file
@@ -0,0 +1,55 @@
|
||||
/* 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'; }
|
||||
}
|
||||
|
||||
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 = './';
|
||||
} 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
1006
static/messages.js
1006
static/messages.js
File diff suppressed because it is too large
Load Diff
391
static/onboarding.js
Normal file
391
static/onboarding.js
Normal file
@@ -0,0 +1,391 @@
|
||||
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 _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 providers=_getOnboardingSetupProviders();
|
||||
const options=providers.map(p=>`<option value="${esc(p.id)}">${esc(p.label)}${p.quick?' — '+esc(t('onboarding_quick_setup_badge')):''}</option>`).join('');
|
||||
const provider=_getOnboardingSetupProvider(ONBOARDING.form.provider)||providers[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)">${options}</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)">${options}</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>`;
|
||||
}
|
||||
const providerSel=$('onboardingProviderSelect');
|
||||
if(providerSel) providerSel.value=ONBOARDING.form.provider;
|
||||
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)">${options}</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>`;
|
||||
const providerSel=$('onboardingProviderSelect');
|
||||
if(providerSel) providerSel.value=ONBOARDING.form.provider;
|
||||
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||'openai/gpt-5.4-mini';
|
||||
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');
|
||||
}
|
||||
}
|
||||
1247
static/panels.js
1247
static/panels.js
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1663
static/style.css
1663
static/style.css
File diff suppressed because it is too large
Load Diff
2262
static/ui.js
2262
static/ui.js
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,99 @@
|
||||
async function api(path,opts={}){
|
||||
const res=await fetch(path,{headers:{'Content-Type':'application/json'},...opts});
|
||||
if(!res.ok)throw new Error(await res.text());
|
||||
// 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,location.href);
|
||||
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;}
|
||||
}
|
||||
const ct=res.headers.get('content-type')||'';
|
||||
return ct.includes('application/json')?res.json():res.text();
|
||||
}
|
||||
|
||||
// Persist/restore expanded directory state per workspace in localStorage
|
||||
function _wsExpandKey(){
|
||||
const ws=S.session&&S.session.workspace;
|
||||
return ws?'hermes-webui-expanded:'+ws:null;
|
||||
}
|
||||
function _saveExpandedDirs(){
|
||||
const key=_wsExpandKey();if(!key)return;
|
||||
try{localStorage.setItem(key,JSON.stringify([...(S._expandedDirs||new Set())]));}catch(e){}
|
||||
}
|
||||
function _restoreExpandedDirs(){
|
||||
const key=_wsExpandKey();
|
||||
if(!key){S._expandedDirs=new Set();return;}
|
||||
try{
|
||||
const raw=localStorage.getItem(key);
|
||||
S._expandedDirs=raw?new Set(JSON.parse(raw)):new Set();
|
||||
}catch(e){S._expandedDirs=new Set();}
|
||||
}
|
||||
|
||||
async function loadDir(path){
|
||||
if(!S.session)return;
|
||||
try{
|
||||
if(!path||path==='.'){
|
||||
S._dirCache={};
|
||||
_restoreExpandedDirs(); // restore per-workspace expanded state on root load
|
||||
}
|
||||
S.currentDir=path||'.';
|
||||
const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
|
||||
S.entries=data.entries||[];renderFileTree();
|
||||
S.entries=data.entries||[];renderBreadcrumb();renderFileTree();
|
||||
// Pre-fetch contents of restored expanded dirs so they render without a second click
|
||||
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]=[];}
|
||||
}
|
||||
}
|
||||
if(S._expandedDirs&&S._expandedDirs.size>0)renderFileTree();
|
||||
}
|
||||
if(typeof clearPreview==='function'){
|
||||
if(typeof _previewDirty!=='undefined'&&_previewDirty){
|
||||
showConfirmDialog({title:t('unsaved_confirm'),message:'',confirmLabel:'Discard',danger:true,focusCancel:true}).then(ok=>{if(ok)clearPreview();});
|
||||
}else{
|
||||
clearPreview();
|
||||
}
|
||||
}
|
||||
// Fetch git info for workspace root (non-blocking)
|
||||
if(!path||path==='.') _refreshGitBadge();
|
||||
}catch(e){console.warn('loadDir',e);}
|
||||
}
|
||||
|
||||
async function _refreshGitBadge(){
|
||||
const badge=$('gitBadge');
|
||||
if(!badge||!S.session)return;
|
||||
try{
|
||||
const data=await api(`/api/git-info?session_id=${encodeURIComponent(S.session.session_id)}`);
|
||||
if(data.git&&data.git.is_git){
|
||||
const g=data.git;
|
||||
let text=g.branch||'git';
|
||||
if(g.dirty>0) text+=` \u00b7 ${g.dirty}\u2206`; // middot + delta
|
||||
if(g.behind>0) text+=` \u2193${g.behind}`;
|
||||
if(g.ahead>0) text+=` \u2191${g.ahead}`;
|
||||
badge.textContent=text;
|
||||
badge.className='git-badge'+(g.dirty>0?' dirty':'');
|
||||
badge.style.display='';
|
||||
} else {
|
||||
badge.style.display='none';
|
||||
badge.textContent='';
|
||||
}
|
||||
}catch(e){badge.style.display='none';}
|
||||
}
|
||||
|
||||
function navigateUp(){
|
||||
if(!S.session||S.currentDir==='.')return;
|
||||
const parts=S.currentDir.split('/');
|
||||
parts.pop();
|
||||
loadDir(parts.length?parts.join('/'):'.');
|
||||
}
|
||||
|
||||
// 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']);
|
||||
@@ -52,8 +133,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*';
|
||||
}
|
||||
@@ -71,12 +152,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'
|
||||
@@ -121,13 +202,14 @@ 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(MD_EXTS.has(ext)){
|
||||
// Markdown: fetch text, render with renderMd, display as formatted HTML
|
||||
try{
|
||||
@@ -135,7 +217,8 @@ 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 {
|
||||
// Plain code / text -- but fall back to download if server signals binary
|
||||
try{
|
||||
@@ -157,12 +240,50 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
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,37 @@ 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 _pytest_port.py (imported at collection time)
|
||||
# and any test file using os.environ sees the right values immediately.
|
||||
os.environ.setdefault('HERMES_WEBUI_TEST_PORT', str(TEST_PORT))
|
||||
os.environ.setdefault('HERMES_WEBUI_TEST_STATE_DIR', str(TEST_STATE_DIR))
|
||||
|
||||
# ── Server script: always relative to repo root ───────────────────────────
|
||||
SERVER_SCRIPT = REPO_ROOT / 'server.py'
|
||||
if not SERVER_SCRIPT.exists():
|
||||
@@ -83,6 +106,97 @@ VENV_PYTHON = _discover_python(HERMES_AGENT)
|
||||
# Work dir: agent dir if found, else repo root
|
||||
WORKDIR = str(HERMES_AGENT) if HERMES_AGENT else str(REPO_ROOT)
|
||||
|
||||
# ── Agent availability detection ─────────────────────────────────────────────
|
||||
# Tests that require hermes-agent modules (cron, skills, approval, chat/stream)
|
||||
# are skipped when the agent isn't installed, instead of failing with 500 errors.
|
||||
AGENT_AVAILABLE = HERMES_AGENT is not None
|
||||
|
||||
def _check_agent_modules():
|
||||
"""Verify hermes-agent Python modules are actually importable."""
|
||||
if not HERMES_AGENT:
|
||||
return False
|
||||
try:
|
||||
import importlib
|
||||
# These are the modules that cause 500 errors when missing
|
||||
for mod in ['cron.jobs', 'tools.skills_tool']:
|
||||
importlib.import_module(mod)
|
||||
return True
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
return False
|
||||
|
||||
AGENT_MODULES_AVAILABLE = _check_agent_modules()
|
||||
|
||||
# pytest marker: skip tests that need hermes-agent when it's not present
|
||||
requires_agent = pytest.mark.skipif(
|
||||
not AGENT_AVAILABLE,
|
||||
reason="hermes-agent not found (skipping agent-dependent test)"
|
||||
)
|
||||
requires_agent_modules = pytest.mark.skipif(
|
||||
not AGENT_MODULES_AVAILABLE,
|
||||
reason="hermes-agent Python modules not importable (cron, skills_tool)"
|
||||
)
|
||||
|
||||
def pytest_configure(config):
|
||||
config.addinivalue_line("markers", "requires_agent: skip when hermes-agent dir is not found")
|
||||
config.addinivalue_line("markers", "requires_agent_modules: skip when hermes-agent Python modules are not importable")
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
"""Auto-skip agent-dependent tests when hermes-agent is not available.
|
||||
|
||||
Instead of requiring markers on every test function, we pattern-match
|
||||
test names to known categories that depend on hermes-agent modules.
|
||||
This keeps the test files clean and ensures new cron/skills tests
|
||||
get auto-skipped without manual annotation.
|
||||
"""
|
||||
if AGENT_MODULES_AVAILABLE:
|
||||
return # everything available, run all tests
|
||||
|
||||
# Exact list of tests known to fail without hermes-agent.
|
||||
# These hit server endpoints that import cron.jobs, tools.skills_tool,
|
||||
# or require a running agent backend — returning 500 without the agent.
|
||||
_AGENT_DEPENDENT_TESTS = {
|
||||
# Cron endpoints (need cron.jobs module)
|
||||
'test_crons_list',
|
||||
'test_crons_list_has_required_fields',
|
||||
'test_crons_output_requires_job_id',
|
||||
'test_crons_output_real_job',
|
||||
'test_crons_run_nonexistent',
|
||||
'test_cron_create_success',
|
||||
'test_cron_update_unknown_job_404',
|
||||
'test_cron_delete_unknown_404',
|
||||
'test_crons_output_limit_param',
|
||||
# Skills endpoints (need tools.skills_tool module)
|
||||
'test_skills_list',
|
||||
'test_skills_list_has_required_fields',
|
||||
'test_skills_content_known',
|
||||
'test_skills_content_requires_name',
|
||||
'test_skills_search_returns_subset',
|
||||
'test_skill_save_delete_roundtrip',
|
||||
'test_skill_delete_unknown_404',
|
||||
# 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',
|
||||
'test_workspace_rename',
|
||||
'test_last_workspace_updates_on_session_update',
|
||||
'test_new_session_inherits_last_workspace',
|
||||
}
|
||||
|
||||
skip_marker = pytest.mark.skip(reason="requires hermes-agent (not installed)")
|
||||
skipped = 0
|
||||
|
||||
for item in items:
|
||||
if item.name in _AGENT_DEPENDENT_TESTS:
|
||||
item.add_marker(skip_marker)
|
||||
skipped += 1
|
||||
|
||||
if skipped:
|
||||
print(f"\nWARNING: hermes-agent not found; {skipped} agent-dependent tests will be skipped\n")
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -121,6 +235,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)
|
||||
@@ -137,7 +263,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",
|
||||
@@ -145,6 +289,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
|
||||
@@ -190,6 +342,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)
|
||||
|
||||
104
tests/test_745_code_block_newlines.py
Normal file
104
tests/test_745_code_block_newlines.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
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."""
|
||||
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, \
|
||||
r"paragraph map must bypass \x00E stash tokens"
|
||||
|
||||
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)"
|
||||
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 queue.append
|
||||
assert "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)
|
||||
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
|
||||
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()
|
||||
227
tests/test_batch_fixes.py
Normal file
227
tests/test_batch_fixes.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""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."""
|
||||
src = read("api/workspace.py")
|
||||
assert "Path('/etc')" in src
|
||||
assert "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
|
||||
m = re.search(
|
||||
r'function toggleCronForm\(\)\{.*?_cronSkillsCache=null',
|
||||
src, re.DOTALL
|
||||
)
|
||||
assert m, (
|
||||
"toggleCronForm must unconditionally null _cronSkillsCache "
|
||||
"before fetching skills"
|
||||
)
|
||||
|
||||
def test_cache_not_guarded_by_if_on_open(self):
|
||||
src = self._panels_src()
|
||||
# The old guard should be gone
|
||||
assert "if(!_cronSkillsCache)" not in src, (
|
||||
"toggleCronForm 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()
|
||||
# After submitSkillSave's api() call, _cronSkillsCache must be nulled
|
||||
m = re.search(
|
||||
r'async function submitSkillSave\(\).*?_skillsData\s*=\s*null.*?_cronSkillsCache\s*=\s*null',
|
||||
src, re.DOTALL
|
||||
)
|
||||
assert m, (
|
||||
"_cronSkillsCache must be set to null in submitSkillSave() "
|
||||
"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")
|
||||
assert "_applyTheme(_settingsThemeOnOpen)" in src or \
|
||||
"_applyTheme(" in src, (
|
||||
"_revertSettingsPreview must call _applyTheme() so 'system' "
|
||||
"is correctly re-activated on settings discard"
|
||||
)
|
||||
|
||||
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"
|
||||
)
|
||||
187
tests/test_bootstrap_dotenv.py
Normal file
187
tests/test_bootstrap_dotenv.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Tests for bootstrap.py .env loading fix (issue #730).
|
||||
|
||||
bootstrap.py is the primary documented entry point ("python3 bootstrap.py").
|
||||
Previously it did not load REPO_ROOT/.env, so HERMES_WEBUI_HOST, HERMES_WEBUI_PORT
|
||||
etc. were silently ignored when launching without start.sh.
|
||||
|
||||
Covers:
|
||||
1. _load_repo_dotenv() sets env vars from a repo .env file
|
||||
2. _load_repo_dotenv() ignores commented lines and blank lines
|
||||
3. _load_repo_dotenv() strips quotes from values
|
||||
4. _load_repo_dotenv() is a no-op when .env does not exist
|
||||
5. _load_repo_dotenv() prints a warning (not crash) on unreadable .env
|
||||
6. _load_repo_dotenv() overwrites existing env vars (shell source semantics)
|
||||
7. _load_repo_dotenv() handles 'export FOO=bar' prefix
|
||||
8. _load_repo_dotenv() preserves values containing '='
|
||||
9. Variables are set unconditionally (not setdefault)
|
||||
10. Structural: loader is called before DEFAULT_HOST/DEFAULT_PORT
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
|
||||
|
||||
class TestLoadRepoDotenv:
|
||||
|
||||
def setup_method(self):
|
||||
self._saved_env = os.environ.copy()
|
||||
|
||||
def teardown_method(self):
|
||||
os.environ.clear()
|
||||
os.environ.update(self._saved_env)
|
||||
|
||||
def _run(self, tmp_path, env_content: str):
|
||||
"""Write .env to tmp_path and run _load_repo_dotenv() with that root."""
|
||||
import bootstrap as bs
|
||||
(tmp_path / ".env").write_text(env_content, encoding="utf-8")
|
||||
orig_root = bs.REPO_ROOT
|
||||
try:
|
||||
bs.REPO_ROOT = tmp_path
|
||||
bs._load_repo_dotenv()
|
||||
finally:
|
||||
bs.REPO_ROOT = orig_root
|
||||
|
||||
def test_sets_env_var_from_dotenv(self, tmp_path):
|
||||
"""Basic key=value is loaded into os.environ."""
|
||||
self._run(tmp_path, "HERMES_WEBUI_HOST=0.0.0.0\n")
|
||||
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0"
|
||||
|
||||
def test_sets_port_from_dotenv(self, tmp_path):
|
||||
"""HERMES_WEBUI_PORT is loaded as a string (caller does int() conversion)."""
|
||||
self._run(tmp_path, "HERMES_WEBUI_PORT=18787\n")
|
||||
assert os.environ.get("HERMES_WEBUI_PORT") == "18787"
|
||||
|
||||
def test_ignores_comment_lines(self, tmp_path):
|
||||
"""Lines starting with # are not loaded."""
|
||||
os.environ.pop("HERMES_WEBUI_HOST", None)
|
||||
self._run(tmp_path, "# HERMES_WEBUI_HOST=should-be-ignored\n")
|
||||
assert os.environ.get("HERMES_WEBUI_HOST") is None
|
||||
|
||||
def test_ignores_blank_lines(self, tmp_path):
|
||||
"""Blank lines are silently skipped without error."""
|
||||
self._run(tmp_path, "\n\nHERMES_WEBUI_PORT=9000\n\n")
|
||||
assert os.environ.get("HERMES_WEBUI_PORT") == "9000"
|
||||
|
||||
def test_strips_double_quoted_values(self, tmp_path):
|
||||
"""Values wrapped in double quotes are stripped."""
|
||||
self._run(tmp_path, 'HERMES_WEBUI_HOST="0.0.0.0"\n')
|
||||
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0"
|
||||
|
||||
def test_strips_single_quoted_values(self, tmp_path):
|
||||
"""Values wrapped in single quotes are stripped."""
|
||||
self._run(tmp_path, "HERMES_WEBUI_HOST='0.0.0.0'\n")
|
||||
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0"
|
||||
|
||||
def test_noop_when_no_dotenv(self, tmp_path):
|
||||
"""No .env file — function returns silently without error."""
|
||||
import bootstrap as bs
|
||||
orig = bs.REPO_ROOT
|
||||
try:
|
||||
bs.REPO_ROOT = tmp_path # tmp_path has no .env
|
||||
bs._load_repo_dotenv() # must not raise
|
||||
finally:
|
||||
bs.REPO_ROOT = orig
|
||||
|
||||
def test_noop_when_dotenv_unreadable(self, tmp_path, capsys):
|
||||
"""Unreadable .env prints a warning to stderr — does not crash."""
|
||||
import bootstrap as bs
|
||||
env_path = tmp_path / ".env"
|
||||
env_path.write_text("HERMES_WEBUI_PORT=9999\n")
|
||||
orig = bs.REPO_ROOT
|
||||
try:
|
||||
bs.REPO_ROOT = tmp_path
|
||||
with patch("pathlib.Path.read_text", side_effect=PermissionError("no access")):
|
||||
bs._load_repo_dotenv() # must not raise
|
||||
finally:
|
||||
bs.REPO_ROOT = orig
|
||||
captured = capsys.readouterr()
|
||||
assert "bootstrap" in captured.err.lower() or "warning" in captured.err.lower() or \
|
||||
"could not load" in captured.err.lower(), (
|
||||
"_load_repo_dotenv() should print a warning to stderr on read failure"
|
||||
)
|
||||
|
||||
def test_overwrites_existing_env_var(self, tmp_path):
|
||||
"""Unconditional overwrite matches shell source semantics."""
|
||||
os.environ["HERMES_WEBUI_HOST"] = "127.0.0.1"
|
||||
self._run(tmp_path, "HERMES_WEBUI_HOST=0.0.0.0\n")
|
||||
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0"
|
||||
|
||||
def test_does_not_set_empty_values(self, tmp_path):
|
||||
"""A key whose value is empty after stripping is not set to a non-empty string."""
|
||||
os.environ.pop("HERMES_EMPTY_KEY", None)
|
||||
self._run(tmp_path, 'HERMES_EMPTY_KEY=""\n')
|
||||
# The current implementation sets key to "" (empty string) — verify it is
|
||||
# not set to a non-empty string, which would be clearly wrong.
|
||||
val = os.environ.get("HERMES_EMPTY_KEY")
|
||||
assert val != "something-wrong", "Empty-value key must not be set to a non-empty string"
|
||||
# Specifically: empty string or absent are both acceptable behaviours.
|
||||
assert val in (None, ""), f"Unexpected value for empty-quoted key: {val!r}"
|
||||
|
||||
def test_multiple_keys_all_loaded(self, tmp_path):
|
||||
"""Multiple key=value pairs in one file are all loaded."""
|
||||
content = "HERMES_WEBUI_HOST=0.0.0.0\nHERMES_WEBUI_PORT=18787\n"
|
||||
self._run(tmp_path, content)
|
||||
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0"
|
||||
assert os.environ.get("HERMES_WEBUI_PORT") == "18787"
|
||||
|
||||
def test_value_with_equals_sign_preserved(self, tmp_path):
|
||||
"""Values containing '=' (e.g. base64) are preserved correctly."""
|
||||
self._run(tmp_path, "MY_KEY=abc=def==\n")
|
||||
assert os.environ.get("MY_KEY") == "abc=def=="
|
||||
|
||||
def test_export_prefix_stripped(self, tmp_path):
|
||||
"""'export FOO=bar' lines are parsed correctly — export prefix is stripped."""
|
||||
self._run(tmp_path, "export HERMES_WEBUI_HOST=0.0.0.0\n")
|
||||
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0", (
|
||||
"'export KEY=value' lines must set KEY, not 'export KEY'"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Structural tests — confirm the fix is in place
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBootstrapStructure:
|
||||
|
||||
def test_load_repo_dotenv_function_exists(self):
|
||||
"""bootstrap.py must export _load_repo_dotenv()."""
|
||||
import bootstrap as bs
|
||||
assert callable(getattr(bs, "_load_repo_dotenv", None)), (
|
||||
"bootstrap.py must define _load_repo_dotenv() so that "
|
||||
"python3 bootstrap.py loads REPO_ROOT/.env before reading env defaults"
|
||||
)
|
||||
|
||||
def test_dotenv_loaded_before_default_host_port(self):
|
||||
"""_load_repo_dotenv() call must appear before DEFAULT_HOST/DEFAULT_PORT in source."""
|
||||
src = (REPO_ROOT / "bootstrap.py").read_text(encoding="utf-8")
|
||||
load_pos = src.find("_load_repo_dotenv()")
|
||||
host_pos = src.find("DEFAULT_HOST")
|
||||
port_pos = src.find("DEFAULT_PORT")
|
||||
assert load_pos != -1, "_load_repo_dotenv() call not found in bootstrap.py"
|
||||
assert load_pos < host_pos, (
|
||||
"_load_repo_dotenv() must be called before DEFAULT_HOST assignment "
|
||||
"so that HERMES_WEBUI_HOST from .env is picked up"
|
||||
)
|
||||
assert load_pos < port_pos, (
|
||||
"_load_repo_dotenv() must be called before DEFAULT_PORT assignment "
|
||||
"so that HERMES_WEBUI_PORT from .env is picked up"
|
||||
)
|
||||
|
||||
def test_start_sh_and_bootstrap_equivalent_env_loading(self):
|
||||
"""start.sh sources .env before bootstrap.py; bootstrap.py must now do the same."""
|
||||
start_sh = (REPO_ROOT / "start.sh").read_text(encoding="utf-8")
|
||||
bootstrap_src = (REPO_ROOT / "bootstrap.py").read_text(encoding="utf-8")
|
||||
# start.sh sources .env
|
||||
assert "source" in start_sh and ".env" in start_sh, (
|
||||
"start.sh should still source .env (regression guard)"
|
||||
)
|
||||
# bootstrap.py now loads it too
|
||||
assert "_load_repo_dotenv" in bootstrap_src, (
|
||||
"bootstrap.py must load .env so direct invocation matches start.sh behaviour"
|
||||
)
|
||||
185
tests/test_bugbatch_apr2026.py
Normal file
185
tests/test_bugbatch_apr2026.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Bug batch fixes — April 2026.
|
||||
|
||||
Covers:
|
||||
- #594: .app-dialog and .file-rename-input have light theme overrides in style.css
|
||||
- #576: workspace panel localStorage restore is gated on session.workspace presence (boot.js)
|
||||
- #585: get_available_models() calls reload_config() before reading config cache
|
||||
- #567: docker-compose.yml comment mentions macOS UID mismatch
|
||||
- #590: _transcribeBlob already calls setComposerStatus('Transcribing…') — confirmed present
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent
|
||||
STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
COMPOSE = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ── #594: light theme dialog overrides ───────────────────────────────────────
|
||||
|
||||
def test_594_app_dialog_has_light_mode_override():
|
||||
"""style.css must have a light mode rule targeting .app-dialog background."""
|
||||
assert ':root:not(.dark) .app-dialog{' in STYLE_CSS, (
|
||||
"Missing light mode override for .app-dialog — dialogs appear dark on light theme"
|
||||
)
|
||||
|
||||
|
||||
def test_594_app_dialog_input_has_light_mode_override():
|
||||
"""style.css must have a light mode rule for .app-dialog-input."""
|
||||
assert ":root:not(.dark) .app-dialog-input{" in STYLE_CSS, (
|
||||
"Missing light mode override for .app-dialog-input"
|
||||
)
|
||||
|
||||
|
||||
def test_594_app_dialog_btn_has_light_mode_override():
|
||||
"""style.css must have a light mode rule for .app-dialog-btn."""
|
||||
assert ":root:not(.dark) .app-dialog-btn{" in STYLE_CSS, (
|
||||
"Missing light mode override for .app-dialog-btn"
|
||||
)
|
||||
|
||||
|
||||
def test_594_app_dialog_close_has_light_mode_override():
|
||||
"""style.css must have a light mode rule for .app-dialog-close."""
|
||||
assert ":root:not(.dark) .app-dialog-close{" in STYLE_CSS, (
|
||||
"Missing light mode override for .app-dialog-close"
|
||||
)
|
||||
|
||||
|
||||
def test_594_file_rename_input_has_light_mode_override():
|
||||
"""style.css must have a light mode rule for .file-rename-input."""
|
||||
assert ":root:not(.dark) .file-rename-input{" in STYLE_CSS, (
|
||||
"Missing light mode override for .file-rename-input"
|
||||
)
|
||||
|
||||
|
||||
# ── dark-mode user bubble semantics ──────────────────────────────────────────
|
||||
|
||||
def test_dark_user_bubbles_use_dark_tinted_surface():
|
||||
"""Dark mode should keep user bubbles dark, with skin only tinting the bubble."""
|
||||
assert "--user-bubble-bg: var(--accent-bg-strong);" in STYLE_CSS, (
|
||||
"Dark mode user bubbles should use the dark accent tint, not the full bright accent fill"
|
||||
)
|
||||
assert "--user-bubble-border: var(--accent-bg-strong);" in STYLE_CSS, (
|
||||
"Dark mode user bubble borders should match the quieter thinking-card border intensity"
|
||||
)
|
||||
assert "--user-bubble-text: var(--text);" in STYLE_CSS, (
|
||||
"Dark mode user bubble text should inherit the theme text color"
|
||||
)
|
||||
|
||||
|
||||
def test_dark_user_bubbles_do_not_need_per_skin_text_hacks():
|
||||
"""Dark-mode user bubble contrast should not rely on per-skin text overrides."""
|
||||
assert re.search(r':root\.dark\[data-skin="[^"]+"\]\s*\{\s*--user-bubble-text:', STYLE_CSS) is None, (
|
||||
"Dark-mode user bubble contrast should come from shared theme tokens, not per-skin text hacks"
|
||||
)
|
||||
|
||||
|
||||
def test_user_bubbles_define_selection_tokens_for_both_modes():
|
||||
"""User bubbles need dedicated selection colors so selected text remains readable."""
|
||||
assert "--user-selection-bg: rgba(0,0,0,.22);" in STYLE_CSS, (
|
||||
"Light-mode user bubbles should define a darker selection fill for contrast"
|
||||
)
|
||||
assert "--user-selection-bg: rgba(255,255,255,.18);" in STYLE_CSS, (
|
||||
"Dark-mode user bubbles should define a lighter selection fill for contrast"
|
||||
)
|
||||
assert "--user-selection-text: #fff;" in STYLE_CSS, (
|
||||
"Light-mode user bubble selection should preserve readable text color"
|
||||
)
|
||||
|
||||
|
||||
def test_user_bubble_selection_is_scoped_to_user_message_body():
|
||||
"""Selection override must apply only to user bubbles, including nested markdown nodes."""
|
||||
assert '.msg-row[data-role="user"] .msg-body::selection,' in STYLE_CSS, (
|
||||
"Missing selection override on the user message bubble"
|
||||
)
|
||||
assert '.msg-row[data-role="user"] .msg-body *::selection {' in STYLE_CSS, (
|
||||
"Nested elements inside user messages must inherit the same selection colors"
|
||||
)
|
||||
|
||||
|
||||
# ── #576: workspace panel snap fix ───────────────────────────────────────────
|
||||
|
||||
def test_576_panel_restore_gated_on_workspace():
|
||||
"""boot.js: localStorage panel restore must be gated on session.workspace."""
|
||||
# The guard must appear: session.workspace check before _workspacePanelMode='browse'
|
||||
assert "S.session&&S.session.workspace&&localStorage.getItem('hermes-webui-workspace-panel')" in BOOT_JS, (
|
||||
"Workspace panel localStorage restore must be gated on S.session.workspace "
|
||||
"to prevent snap-open-then-closed on sessions without a workspace (#576)"
|
||||
)
|
||||
|
||||
|
||||
def test_576_restore_happens_after_load_session():
|
||||
"""boot.js: loadSession() must come before the panel restore guard."""
|
||||
load_pos = BOOT_JS.find("await loadSession(saved)")
|
||||
restore_pos = BOOT_JS.find("S.session&&S.session.workspace&&localStorage")
|
||||
assert load_pos != -1, "loadSession call not found in boot.js"
|
||||
assert restore_pos != -1, "workspace panel restore guard not found"
|
||||
assert load_pos < restore_pos, (
|
||||
"loadSession() must run before the panel restore guard "
|
||||
"so S.session.workspace is known at restore time"
|
||||
)
|
||||
|
||||
|
||||
# ── #585: get_available_models reloads config ─────────────────────────────────
|
||||
|
||||
def test_585_get_available_models_calls_reload_config():
|
||||
"""api/config.py: get_available_models() must do a mtime-based reload check."""
|
||||
config_src = (REPO_ROOT / "api" / "config.py").read_text(encoding="utf-8")
|
||||
fn_start = config_src.find("def get_available_models()")
|
||||
assert fn_start != -1, "get_available_models not found"
|
||||
fn_body_end = config_src.find('"""', config_src.find('"""', fn_start + 30) + 3) + 3
|
||||
# Must check mtime before reading config
|
||||
mtime_pos = config_src.find("_current_mtime", fn_body_end)
|
||||
active_prov_pos = config_src.find("active_provider = None", fn_body_end)
|
||||
assert mtime_pos != -1, (
|
||||
"get_available_models() must check config file mtime before reading cache (#585)"
|
||||
)
|
||||
assert mtime_pos < active_prov_pos, (
|
||||
"mtime check must come before active_provider = None in get_available_models()"
|
||||
)
|
||||
|
||||
|
||||
# ── #567: docker-compose UID note ─────────────────────────────────────────────
|
||||
|
||||
def test_567_compose_mentions_macos_uid():
|
||||
"""docker-compose.yml must mention macOS UID / id -u to help macOS users."""
|
||||
assert "macOS" in COMPOSE or "macos" in COMPOSE.lower(), (
|
||||
"docker-compose.yml should mention macOS UID issue (#567)"
|
||||
)
|
||||
assert "id -u" in COMPOSE, (
|
||||
"docker-compose.yml should tell users to run 'id -u' to find their UID (#567)"
|
||||
)
|
||||
|
||||
|
||||
# ── #590: transcription spinner already present ───────────────────────────────
|
||||
|
||||
def test_590_transcribing_status_shown_before_fetch():
|
||||
"""boot.js: setComposerStatus('Transcribing…') must fire before the fetch call."""
|
||||
transcribe_fn_start = BOOT_JS.find("async function _transcribeBlob(")
|
||||
assert transcribe_fn_start != -1, "_transcribeBlob not found in boot.js"
|
||||
fn_body = BOOT_JS[transcribe_fn_start:transcribe_fn_start + 600]
|
||||
status_pos = fn_body.find("setComposerStatus('Transcribing")
|
||||
fetch_pos = fn_body.find("await fetch(")
|
||||
assert status_pos != -1, (
|
||||
"setComposerStatus('Transcribing…') must be called before the fetch in _transcribeBlob"
|
||||
)
|
||||
assert fetch_pos != -1, "await fetch not found in _transcribeBlob"
|
||||
assert status_pos < fetch_pos, (
|
||||
"setComposerStatus('Transcribing…') must appear before 'await fetch' "
|
||||
"so the UI shows a spinner immediately on stop (#590)"
|
||||
)
|
||||
|
||||
|
||||
def test_590_recording_stops_before_transcribe():
|
||||
"""boot.js: _setRecording(false) must fire in onstop before _transcribeBlob."""
|
||||
onstop_start = BOOT_JS.find("mediaRecorder.onstop")
|
||||
assert onstop_start != -1, "mediaRecorder.onstop not found"
|
||||
onstop_body = BOOT_JS[onstop_start:onstop_start + 400]
|
||||
rec_pos = onstop_body.find("_setRecording(false)")
|
||||
blob_pos = onstop_body.find("_transcribeBlob(")
|
||||
assert rec_pos != -1 and blob_pos != -1
|
||||
assert rec_pos < blob_pos, (
|
||||
"_setRecording(false) must come before _transcribeBlob so mic icon clears immediately"
|
||||
)
|
||||
378
tests/test_byok_model_dropdown.py
Normal file
378
tests/test_byok_model_dropdown.py
Normal file
@@ -0,0 +1,378 @@
|
||||
"""Tests for #815 — BYOK/custom provider models missing from WebUI model dropdown.
|
||||
|
||||
Root causes fixed:
|
||||
1. active_provider alias not normalized in get_available_models()
|
||||
('z.ai' -> 'zai', 'x.ai' -> 'xai', 'google' -> 'gemini', etc.)
|
||||
causing the provider to fall to the 'else/unknown' branch with no models.
|
||||
|
||||
2. /api/models/live didn't normalize the provider query param, so
|
||||
provider_model_ids() received the un-aliased form and returned [].
|
||||
|
||||
3. /api/models/live returned empty for provider='custom' even when
|
||||
custom_providers entries exist in config.yaml — the live enrichment
|
||||
step never added those models.
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import unittest.mock as mock
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = pathlib.Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(REPO))
|
||||
sys.path.insert(0, str(REPO.parent / ".hermes" / "hermes-agent"))
|
||||
|
||||
|
||||
def read(rel):
|
||||
return (REPO / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_models_cache():
|
||||
"""Invalidate the TTL model cache before AND after every test.
|
||||
|
||||
``get_available_models()`` caches its result keyed on config.yaml mtime.
|
||||
Tests in this file repoint ``_get_config_path`` to a tmp_path, populate
|
||||
the cache there, then let monkeypatch restore the original path. The
|
||||
cache, keyed on the tmp_path's mtime, then poisons downstream tests
|
||||
(e.g. test_model_resolver) which see stale data and never hit their
|
||||
mocks. Clearing the cache around each test breaks that linkage.
|
||||
"""
|
||||
import api.config as c
|
||||
try:
|
||||
c.invalidate_models_cache()
|
||||
except Exception:
|
||||
pass
|
||||
yield
|
||||
try:
|
||||
c.invalidate_models_cache()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── api/config.py — active_provider normalization ─────────────────────────────
|
||||
|
||||
class TestActiveProviderNormalization:
|
||||
"""get_available_models() must normalize active_provider aliases before lookup."""
|
||||
|
||||
def _run(self, tmp_path, provider_str, monkeypatch):
|
||||
"""Return get_available_models() output for a given provider string."""
|
||||
import api.config as c
|
||||
|
||||
cfgfile = tmp_path / "config.yaml"
|
||||
cfgfile.write_text(
|
||||
f"model:\n provider: {provider_str}\n default: test-model\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(c, "_get_config_path", lambda: cfgfile)
|
||||
c.reload_config()
|
||||
# Patch list_available_providers to avoid real network calls
|
||||
fake_prov = mock.MagicMock()
|
||||
fake_prov.return_value = []
|
||||
try:
|
||||
import hermes_cli.models as hm
|
||||
monkeypatch.setattr(hm, "list_available_providers", fake_prov)
|
||||
except Exception:
|
||||
pass
|
||||
result = c.get_available_models()
|
||||
c.reload_config()
|
||||
return result
|
||||
|
||||
def test_z_dot_ai_normalized_to_zai(self, tmp_path, monkeypatch):
|
||||
result = self._run(tmp_path, "z.ai", monkeypatch)
|
||||
# active_provider returned to browser must be canonical 'zai' or
|
||||
# at minimum must not be 'z.ai' (which would miss the _PROVIDER_MODELS lookup)
|
||||
ap = result.get("active_provider", "")
|
||||
assert ap in ("zai", ""), f"active_provider should be 'zai', got {ap!r}"
|
||||
|
||||
def test_x_dot_ai_normalized_to_xai(self, tmp_path, monkeypatch):
|
||||
result = self._run(tmp_path, "x.ai", monkeypatch)
|
||||
ap = result.get("active_provider", "")
|
||||
assert ap in ("xai", ""), f"active_provider should be 'xai', got {ap!r}"
|
||||
|
||||
def test_google_normalized_to_gemini(self, tmp_path, monkeypatch):
|
||||
result = self._run(tmp_path, "google", monkeypatch)
|
||||
ap = result.get("active_provider", "")
|
||||
assert ap in ("gemini", ""), f"active_provider should be 'gemini', got {ap!r}"
|
||||
|
||||
def test_normalization_code_present(self):
|
||||
"""Source-level check: config.py must call _PROVIDER_ALIASES for active_provider."""
|
||||
src = read("api/config.py")
|
||||
# Must alias-normalize active_provider before the group-builder runs
|
||||
assert "_PROVIDER_ALIASES" in src, (
|
||||
"api/config.py must import _PROVIDER_ALIASES to normalize active_provider"
|
||||
)
|
||||
# The normalization must happen before the group builder loop
|
||||
alias_pos = src.index("_PROVIDER_ALIASES")
|
||||
group_builder_pos = src.index("for pid in sorted(detected_providers)")
|
||||
assert alias_pos < group_builder_pos, (
|
||||
"active_provider normalization must occur before the group-builder loop"
|
||||
)
|
||||
|
||||
|
||||
# ── api/routes.py — /api/models/live provider normalization ───────────────────
|
||||
|
||||
class TestLiveModelsProviderNormalization:
|
||||
"""_handle_live_models must normalize the provider query param."""
|
||||
|
||||
def test_live_models_normalizes_provider_alias(self):
|
||||
src = read("api/routes.py")
|
||||
# Find _handle_live_models function
|
||||
m = re.search(
|
||||
r"def _handle_live_models\(.*?\ndef ",
|
||||
src,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert m, "_handle_live_models not found"
|
||||
fn = m.group(0)
|
||||
assert "_resolve_provider_alias" in fn, (
|
||||
"_handle_live_models must normalize provider via "
|
||||
"api.config._resolve_provider_alias so 'z.ai' -> 'zai' "
|
||||
"before calling provider_model_ids()"
|
||||
)
|
||||
|
||||
def test_live_models_normalization_before_provider_model_ids(self):
|
||||
"""Normalization call must appear before the provider_model_ids call site."""
|
||||
src = read("api/routes.py")
|
||||
alias_match = re.search(
|
||||
r"provider\s*=\s*_resolve_provider_alias\(provider\)",
|
||||
src,
|
||||
)
|
||||
pmi_call_match = re.search(
|
||||
r"ids\s*=\s*_pmi\(provider\)",
|
||||
src,
|
||||
)
|
||||
assert alias_match, "_resolve_provider_alias call not found in routes.py"
|
||||
assert pmi_call_match, "ids = _pmi(provider) call not found"
|
||||
assert alias_match.start() < pmi_call_match.start(), (
|
||||
"alias normalization must occur before ids = _pmi(provider)"
|
||||
)
|
||||
|
||||
def test_alias_resolver_works_without_hermes_cli(self):
|
||||
"""Normalization must work even when hermes_cli is not importable —
|
||||
CI and installs without the agent cloned alongside the WebUI.
|
||||
The WebUI ships its own _PROVIDER_ALIASES table; the agent's table
|
||||
is merged only when available."""
|
||||
import api.config as c
|
||||
# Core CLI aliases from #815's bug report
|
||||
assert c._resolve_provider_alias('z.ai') == 'zai'
|
||||
assert c._resolve_provider_alias('x.ai') == 'xai'
|
||||
assert c._resolve_provider_alias('google') == 'gemini'
|
||||
assert c._resolve_provider_alias('grok') == 'xai'
|
||||
# Case / whitespace insensitive
|
||||
assert c._resolve_provider_alias(' Z.AI ') == 'zai'
|
||||
# Canonical names pass through unchanged
|
||||
assert c._resolve_provider_alias('openrouter') == 'openrouter'
|
||||
assert c._resolve_provider_alias('anthropic') == 'anthropic'
|
||||
assert c._resolve_provider_alias('custom') == 'custom'
|
||||
# Empty / None pass through
|
||||
assert c._resolve_provider_alias('') == ''
|
||||
assert c._resolve_provider_alias(None) is None
|
||||
|
||||
|
||||
# ── api/routes.py — /api/models/live custom_providers fallback ────────────────
|
||||
|
||||
class TestLiveModelsCustomProviderFallback:
|
||||
"""When provider='custom' and provider_model_ids() returns [],
|
||||
/api/models/live must fall back to custom_providers entries from config.yaml."""
|
||||
|
||||
def test_custom_fallback_code_present(self):
|
||||
src = read("api/routes.py")
|
||||
m = re.search(
|
||||
r"def _handle_live_models\(.*?\ndef ",
|
||||
src,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert m, "_handle_live_models not found"
|
||||
fn = m.group(0)
|
||||
assert "custom_providers" in fn, (
|
||||
"_handle_live_models must read custom_providers from config "
|
||||
"as fallback when provider='custom' and provider_model_ids() returns []"
|
||||
)
|
||||
assert 'provider == "custom"' in fn or "provider=='custom'" in fn, (
|
||||
"_handle_live_models must check provider == 'custom' before fallback"
|
||||
)
|
||||
|
||||
def test_custom_fallback_returns_configured_models(self, tmp_path, monkeypatch):
|
||||
"""End-to-end: /api/models/live?provider=custom returns custom_providers models."""
|
||||
import api.config as c
|
||||
import api.routes as r
|
||||
|
||||
cfgfile = tmp_path / "config.yaml"
|
||||
cfgfile.write_text(
|
||||
"model:\n provider: custom\n default: my-byok-model\n"
|
||||
"custom_providers:\n"
|
||||
" - model: my-byok-model\n"
|
||||
" api_base: https://my-llm.example.com/v1\n"
|
||||
" api_key: sk-test\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(c, "_get_config_path", lambda: cfgfile)
|
||||
c.reload_config()
|
||||
|
||||
# Mock handler and parsed URL
|
||||
handler = mock.MagicMock()
|
||||
responses = []
|
||||
def fake_j(h, data, **kw):
|
||||
responses.append(data)
|
||||
return True
|
||||
monkeypatch.setattr(r, "j", fake_j)
|
||||
|
||||
from urllib.parse import urlparse
|
||||
parsed = mock.MagicMock()
|
||||
parsed.query = "provider=custom"
|
||||
|
||||
# Mock provider_model_ids to return [] (simulating no live endpoint)
|
||||
try:
|
||||
import hermes_cli.models as hm
|
||||
monkeypatch.setattr(hm, "provider_model_ids", lambda p: [])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
r._handle_live_models(handler, parsed)
|
||||
|
||||
assert responses, "handler must produce a response"
|
||||
resp = responses[-1]
|
||||
assert "models" in resp
|
||||
model_ids = [m["id"] for m in resp.get("models", [])]
|
||||
assert "my-byok-model" in model_ids, (
|
||||
f"custom_providers model 'my-byok-model' must appear in live response; "
|
||||
f"got {model_ids}"
|
||||
)
|
||||
|
||||
|
||||
# ── Regression: known-good providers still work ───────────────────────────────
|
||||
|
||||
class TestKnownProvidersUnaffected:
|
||||
"""Normalization must not break providers whose names are already canonical."""
|
||||
|
||||
def test_openrouter_unaffected(self):
|
||||
src = read("api/config.py")
|
||||
# _PROVIDER_ALIASES lookup: 'openrouter' -> 'openrouter' (no change)
|
||||
assert "openrouter" in src, "openrouter must still exist in config"
|
||||
|
||||
def test_anthropic_unaffected(self):
|
||||
src = read("api/config.py")
|
||||
assert "anthropic" in src
|
||||
|
||||
def test_custom_unaffected(self):
|
||||
"""'custom' is not in _PROVIDER_ALIASES so normalization is a no-op."""
|
||||
try:
|
||||
from hermes_cli.models import _PROVIDER_ALIASES
|
||||
assert "custom" not in _PROVIDER_ALIASES, (
|
||||
"'custom' must not be aliased to anything — it's a special sentinel"
|
||||
)
|
||||
except ImportError:
|
||||
pass # hermes-agent not available in this env — skip
|
||||
|
||||
|
||||
# ── Source-level: active_provider returned to browser is canonical ─────────────
|
||||
|
||||
class TestProviderIdInGroupResponse:
|
||||
"""get_available_models() must include provider_id on every group so the JS
|
||||
_fetchLiveModels can match optgroups exactly rather than by substring."""
|
||||
|
||||
def test_groups_include_provider_id(self, tmp_path, monkeypatch):
|
||||
import api.config as c
|
||||
|
||||
cfgfile = tmp_path / "config.yaml"
|
||||
cfgfile.write_text(
|
||||
"model:\n provider: zai\n default: glm-5\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(c, "_get_config_path", lambda: cfgfile)
|
||||
c.reload_config()
|
||||
try:
|
||||
import hermes_cli.models as hm
|
||||
monkeypatch.setattr(hm, "list_available_providers", lambda: [
|
||||
{"id": "zai", "authenticated": True}
|
||||
])
|
||||
import hermes_cli.auth as ha
|
||||
monkeypatch.setattr(ha, "get_auth_status", lambda p: {"key_source": "env"})
|
||||
except Exception:
|
||||
pass
|
||||
result = c.get_available_models()
|
||||
c.reload_config()
|
||||
for g in result.get("groups", []):
|
||||
assert "provider_id" in g, (
|
||||
f"group {g.get('provider')!r} missing provider_id — "
|
||||
"JS _fetchLiveModels needs it to match optgroups exactly"
|
||||
)
|
||||
|
||||
def test_provider_id_in_static_ui_js_optgroup(self):
|
||||
src = read("static/ui.js")
|
||||
assert "og.dataset.provider" in src, (
|
||||
"populateModelDropdown must set og.dataset.provider from g.provider_id "
|
||||
"so _fetchLiveModels can match by exact provider_id"
|
||||
)
|
||||
|
||||
def test_fetch_live_models_prefers_data_provider_match(self):
|
||||
src = read("static/ui.js")
|
||||
m = re.search(r'function _fetchLiveModels\b.*?\n\}', src, re.DOTALL)
|
||||
assert m, "_fetchLiveModels not found"
|
||||
fn = m.group(0)
|
||||
assert 'og.dataset.provider' in fn, (
|
||||
"_fetchLiveModels must check og.dataset.provider===provider before "
|
||||
"falling back to label substring match"
|
||||
)
|
||||
# The data-provider check must come before the label.includes check
|
||||
dp_pos = fn.index('og.dataset.provider')
|
||||
label_pos = fn.index('og.label')
|
||||
assert dp_pos < label_pos, (
|
||||
"data-provider exact match must be attempted before label substring match"
|
||||
)
|
||||
|
||||
|
||||
# ── Opus-identified edge case: 'ollama' normalizes to 'custom' ────────────────
|
||||
|
||||
class TestOllamaAliasEdgeCase:
|
||||
"""Opus review found: 'ollama' -> 'custom' via _PROVIDER_ALIASES.
|
||||
This is better behaviour (custom_providers fallback catches it) but worth
|
||||
documenting and not regressing."""
|
||||
|
||||
def test_ollama_not_in_provider_aliases_as_ollama(self):
|
||||
"""'ollama' maps to 'custom' in _PROVIDER_ALIASES — verify this is the
|
||||
intended behavior post-normalization (not a silent breakage)."""
|
||||
try:
|
||||
from hermes_cli.models import _PROVIDER_ALIASES
|
||||
# 'ollama' -> 'custom' means ollama users hit the custom_providers path
|
||||
# This is fine — ollama models appear via base_url auto-detection (step 3)
|
||||
# in get_available_models, not via _PROVIDER_MODELS lookup.
|
||||
ollama_target = _PROVIDER_ALIASES.get("ollama", "ollama")
|
||||
# Acceptable outcomes: either unchanged (not in aliases) or 'custom'/'ollama-cloud'
|
||||
assert ollama_target in ("ollama", "custom", "ollama-cloud"), (
|
||||
f"Unexpected ollama alias: {ollama_target}"
|
||||
)
|
||||
except ImportError:
|
||||
pass # hermes-agent not available
|
||||
|
||||
|
||||
class TestGetAvailableModelsReturnsCanonicalProvider:
|
||||
"""get_available_models() must return normalized active_provider in its response
|
||||
so the browser sends the right value to /api/models/live."""
|
||||
|
||||
def test_active_provider_in_response_is_normalized(self, tmp_path, monkeypatch):
|
||||
import api.config as c
|
||||
|
||||
cfgfile = tmp_path / "config.yaml"
|
||||
cfgfile.write_text(
|
||||
"model:\n provider: z.ai\n default: glm-5\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(c, "_get_config_path", lambda: cfgfile)
|
||||
c.reload_config()
|
||||
try:
|
||||
import hermes_cli.models as hm
|
||||
monkeypatch.setattr(hm, "list_available_providers", lambda: [])
|
||||
except Exception:
|
||||
pass
|
||||
result = c.get_available_models()
|
||||
c.reload_config()
|
||||
ap = result.get("active_provider", "")
|
||||
# The browser will pass this value to /api/models/live?provider=<ap>
|
||||
# It must be 'zai' so optgroup matching works in _fetchLiveModels
|
||||
assert ap != "z.ai", (
|
||||
"active_provider 'z.ai' must be normalized to 'zai' before being "
|
||||
"returned to the browser (browser passes it back to /api/models/live)"
|
||||
)
|
||||
122
tests/test_cancel_interrupt.py
Normal file
122
tests/test_cancel_interrupt.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
Unit tests for cancel/interrupt functionality.
|
||||
Tests the integration between cancel_stream() and agent.interrupt().
|
||||
"""
|
||||
import pytest
|
||||
import queue
|
||||
import threading
|
||||
from unittest.mock import Mock
|
||||
|
||||
from api.streaming import cancel_stream
|
||||
from api.config import AGENT_INSTANCES, STREAMS, CANCEL_FLAGS
|
||||
|
||||
|
||||
class TestCancelInterrupt:
|
||||
"""Test suite for cancel/interrupt functionality"""
|
||||
|
||||
def setup_method(self):
|
||||
"""Clean up before each test"""
|
||||
AGENT_INSTANCES.clear()
|
||||
STREAMS.clear()
|
||||
CANCEL_FLAGS.clear()
|
||||
|
||||
def teardown_method(self):
|
||||
"""Clean up after each test"""
|
||||
AGENT_INSTANCES.clear()
|
||||
STREAMS.clear()
|
||||
CANCEL_FLAGS.clear()
|
||||
|
||||
def test_cancel_calls_agent_interrupt(self):
|
||||
"""Verify that cancel_stream() calls agent.interrupt() when agent exists"""
|
||||
# Setup
|
||||
stream_id = "test_stream_123"
|
||||
mock_agent = Mock()
|
||||
mock_agent.interrupt = Mock()
|
||||
|
||||
STREAMS[stream_id] = queue.Queue()
|
||||
CANCEL_FLAGS[stream_id] = threading.Event()
|
||||
AGENT_INSTANCES[stream_id] = mock_agent
|
||||
|
||||
# Execute
|
||||
result = cancel_stream(stream_id)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_agent.interrupt.assert_called_once_with("Cancelled by user")
|
||||
# CANCEL_FLAGS is eagerly popped after cancel (#776 fix) so the flag
|
||||
# is no longer in the dict — verify the pop happened instead
|
||||
assert stream_id not in CANCEL_FLAGS, \
|
||||
"cancel_stream() should eagerly pop CANCEL_FLAGS after signalling"
|
||||
|
||||
def test_cancel_handles_interrupt_exception(self):
|
||||
"""Verify that cancel_stream() handles interrupt() exceptions gracefully"""
|
||||
stream_id = "test_stream_456"
|
||||
mock_agent = Mock()
|
||||
mock_agent.interrupt = Mock(side_effect=RuntimeError("Agent error"))
|
||||
|
||||
STREAMS[stream_id] = queue.Queue()
|
||||
CANCEL_FLAGS[stream_id] = threading.Event()
|
||||
AGENT_INSTANCES[stream_id] = mock_agent
|
||||
|
||||
# Should not raise exception
|
||||
result = cancel_stream(stream_id)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_agent.interrupt.assert_called_once()
|
||||
assert stream_id not in CANCEL_FLAGS, \
|
||||
"cancel_stream() should eagerly pop CANCEL_FLAGS even on interrupt exception"
|
||||
|
||||
def test_cancel_before_agent_ready(self):
|
||||
"""Test cancel when agent not yet stored in AGENT_INSTANCES (race condition)"""
|
||||
stream_id = "test_stream_789"
|
||||
|
||||
STREAMS[stream_id] = queue.Queue()
|
||||
CANCEL_FLAGS[stream_id] = threading.Event()
|
||||
# Note: AGENT_INSTANCES[stream_id] not set (simulating race condition)
|
||||
|
||||
# Should succeed even without agent
|
||||
result = cancel_stream(stream_id)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
# CANCEL_FLAGS is eagerly popped; the agent thread checks the event
|
||||
# object it already has a reference to — pop doesn't clear the event
|
||||
assert stream_id not in CANCEL_FLAGS, \
|
||||
"cancel_stream() should eagerly pop CANCEL_FLAGS even without an agent"
|
||||
# Agent will check this flag (it holds a reference to the event object)
|
||||
|
||||
def test_cancel_nonexistent_stream(self):
|
||||
"""Test cancel for a stream that doesn't exist"""
|
||||
result = cancel_stream("nonexistent_stream")
|
||||
assert result is False
|
||||
|
||||
def test_cancel_sets_cancel_event(self):
|
||||
"""Verify that cancel_stream() sets the cancel_event flag"""
|
||||
stream_id = "test_stream_event"
|
||||
|
||||
STREAMS[stream_id] = queue.Queue()
|
||||
cancel_event = threading.Event()
|
||||
CANCEL_FLAGS[stream_id] = cancel_event
|
||||
|
||||
result = cancel_stream(stream_id)
|
||||
|
||||
assert result is True
|
||||
assert cancel_event.is_set()
|
||||
|
||||
def test_cancel_puts_sentinel_in_queue(self):
|
||||
"""Verify that cancel_stream() puts cancel sentinel in queue"""
|
||||
stream_id = "test_stream_queue"
|
||||
q = queue.Queue()
|
||||
|
||||
STREAMS[stream_id] = q
|
||||
CANCEL_FLAGS[stream_id] = threading.Event()
|
||||
|
||||
result = cancel_stream(stream_id)
|
||||
|
||||
assert result is True
|
||||
# Check that cancel message was queued
|
||||
assert not q.empty()
|
||||
event_type, data = q.get_nowait()
|
||||
assert event_type == 'cancel'
|
||||
assert data['message'] == 'Cancelled by user'
|
||||
111
tests/test_chinese_locale.py
Normal file
111
tests/test_chinese_locale.py
Normal file
@@ -0,0 +1,111 @@
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def read(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def extract_locale_block(src: str, locale_key: str) -> str:
|
||||
start_match = re.search(rf"\b{re.escape(locale_key)}\s*:\s*\{{", src)
|
||||
assert start_match, f"{locale_key} locale block not found"
|
||||
|
||||
start = start_match.end() - 1 # "{"
|
||||
depth = 0
|
||||
in_single = False
|
||||
in_double = False
|
||||
in_backtick = False
|
||||
escape = False
|
||||
|
||||
for i in range(start, len(src)):
|
||||
ch = src[i]
|
||||
|
||||
if escape:
|
||||
escape = False
|
||||
continue
|
||||
|
||||
if in_single:
|
||||
if ch == "\\":
|
||||
escape = True
|
||||
elif ch == "'":
|
||||
in_single = False
|
||||
continue
|
||||
|
||||
if in_double:
|
||||
if ch == "\\":
|
||||
escape = True
|
||||
elif ch == '"':
|
||||
in_double = False
|
||||
continue
|
||||
|
||||
if in_backtick:
|
||||
if ch == "\\":
|
||||
escape = True
|
||||
elif ch == "`":
|
||||
in_backtick = False
|
||||
continue
|
||||
|
||||
if ch == "'":
|
||||
in_single = True
|
||||
continue
|
||||
if ch == '"':
|
||||
in_double = True
|
||||
continue
|
||||
if ch == "`":
|
||||
in_backtick = True
|
||||
continue
|
||||
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
continue
|
||||
if ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[start + 1 : i]
|
||||
|
||||
raise AssertionError(f"{locale_key} locale block braces are not balanced")
|
||||
|
||||
|
||||
def test_chinese_locale_block_exists():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
assert "\n zh: {" in src
|
||||
assert "_lang: 'zh'" in src
|
||||
assert "_speech: 'zh-CN'" in src
|
||||
|
||||
|
||||
def test_chinese_locale_includes_representative_translations():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
expected = [
|
||||
"settings_title: '\\u8bbe\\u7f6e'",
|
||||
"login_title: '\\u767b\\u5f55'",
|
||||
"approval_heading: '需要审批'",
|
||||
"tab_tasks: '任务'",
|
||||
"tab_profiles: '配置'",
|
||||
"session_time_just_now: '刚刚'",
|
||||
"onboarding_title: '欢迎使用 Hermes Web UI'",
|
||||
"onboarding_complete: '引导完成'",
|
||||
]
|
||||
for entry in expected:
|
||||
assert entry in src
|
||||
|
||||
|
||||
def test_chinese_locale_covers_english_keys():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
key_pattern = re.compile(r"^\s{4}([a-zA-Z0-9_]+):", re.MULTILINE)
|
||||
en_keys = set(key_pattern.findall(extract_locale_block(src, "en")))
|
||||
zh_keys = set(key_pattern.findall(extract_locale_block(src, "zh")))
|
||||
|
||||
missing = sorted(en_keys - zh_keys)
|
||||
assert not missing, f"Chinese locale missing keys: {missing}"
|
||||
|
||||
|
||||
def test_chinese_locale_has_no_duplicate_keys():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
key_pattern = re.compile(r"^\s{4}([a-zA-Z0-9_]+):", re.MULTILINE)
|
||||
keys = key_pattern.findall(extract_locale_block(src, "zh"))
|
||||
duplicates = sorted(k for k, count in Counter(keys).items() if count > 1)
|
||||
assert not duplicates, f"Chinese locale has duplicate keys: {duplicates}"
|
||||
165
tests/test_clarify_unblock.py
Normal file
165
tests/test_clarify_unblock.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""Tests for clarify prompt unblocking and HTTP endpoints."""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import uuid
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from api.clarify import (
|
||||
register_gateway_notify,
|
||||
unregister_gateway_notify,
|
||||
resolve_clarify,
|
||||
clear_pending,
|
||||
_gateway_queues,
|
||||
_gateway_notify_cbs,
|
||||
_lock,
|
||||
_ClarifyEntry,
|
||||
submit_pending,
|
||||
)
|
||||
CLARIFY_AVAILABLE = True
|
||||
except ImportError:
|
||||
CLARIFY_AVAILABLE = False
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not CLARIFY_AVAILABLE,
|
||||
reason="api.clarify 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
|
||||
|
||||
|
||||
class TestClarifyUnblocking:
|
||||
"""Unit tests for clarify queue resolution."""
|
||||
|
||||
def test_resolve_clarify_sets_event(self):
|
||||
sid = f"unit-clarify-{uuid.uuid4().hex[:8]}"
|
||||
entry = _ClarifyEntry({"question": "Pick one", "choices_offered": ["a", "b"]})
|
||||
with _lock:
|
||||
_gateway_queues.setdefault(sid, []).append(entry)
|
||||
|
||||
resolved = resolve_clarify(sid, "a", resolve_all=False)
|
||||
assert resolved == 1
|
||||
assert entry.event.is_set()
|
||||
assert entry.result == "a"
|
||||
|
||||
def test_register_and_fire_notify_cb(self):
|
||||
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 = {"question": "What now?", "choices_offered": ["x", "y"]}
|
||||
cb(data)
|
||||
assert fired == [data]
|
||||
|
||||
unregister_gateway_notify(sid)
|
||||
|
||||
def test_clear_pending_unblocks_waiters(self):
|
||||
sid = f"unit-clear-{uuid.uuid4().hex[:8]}"
|
||||
entry = _ClarifyEntry({"question": "Wait", "choices_offered": []})
|
||||
with _lock:
|
||||
_gateway_queues.setdefault(sid, []).append(entry)
|
||||
|
||||
cleared = clear_pending(sid)
|
||||
assert cleared == 1
|
||||
assert entry.event.is_set()
|
||||
with _lock:
|
||||
assert sid not in _gateway_queues
|
||||
|
||||
def test_submit_pending_registers_entry(self):
|
||||
sid = f"unit-submit-{uuid.uuid4().hex[:8]}"
|
||||
data = {"question": "Pick", "choices_offered": ["one", "two"], "session_id": sid}
|
||||
entry = submit_pending(sid, data)
|
||||
assert entry.data == data
|
||||
with _lock:
|
||||
assert sid in _gateway_queues
|
||||
|
||||
clear_pending(sid)
|
||||
|
||||
|
||||
class TestClarifyModuleExports:
|
||||
def test_register_gateway_notify_exported(self):
|
||||
import api.clarify as ap
|
||||
assert hasattr(ap, "register_gateway_notify")
|
||||
|
||||
def test_unregister_gateway_notify_exported(self):
|
||||
import api.clarify as ap
|
||||
assert hasattr(ap, "unregister_gateway_notify")
|
||||
|
||||
def test_resolve_clarify_exported(self):
|
||||
import api.clarify as ap
|
||||
assert hasattr(ap, "resolve_clarify")
|
||||
|
||||
def test_clarify_entry_exported(self):
|
||||
import api.clarify as ap
|
||||
assert hasattr(ap, "_ClarifyEntry")
|
||||
|
||||
|
||||
class TestClarifyHTTPEndpoints:
|
||||
"""Regression tests for /api/clarify/respond against the live test server."""
|
||||
|
||||
def test_respond_returns_ok_no_pending(self):
|
||||
sid = f"http-no-pending-{uuid.uuid4().hex[:8]}"
|
||||
result, status = post("/api/clarify/respond", {
|
||||
"session_id": sid,
|
||||
"response": "Use option A",
|
||||
})
|
||||
assert status == 200
|
||||
assert result["ok"] is True
|
||||
|
||||
def test_respond_requires_session_id(self):
|
||||
result, status = post("/api/clarify/respond", {"response": "Hello"})
|
||||
assert status == 400
|
||||
|
||||
def test_respond_requires_response(self):
|
||||
sid = f"http-no-response-{uuid.uuid4().hex[:8]}"
|
||||
result, status = post("/api/clarify/respond", {"session_id": sid})
|
||||
assert status == 400
|
||||
|
||||
def test_respond_clears_injected_pending(self):
|
||||
sid = f"http-clear-{uuid.uuid4().hex[:8]}"
|
||||
question = urllib.parse.quote("Pick the better option")
|
||||
choices = urllib.parse.quote("A")
|
||||
inject = get(
|
||||
f"/api/clarify/inject_test?session_id={urllib.parse.quote(sid)}"
|
||||
f"&question={question}&choices={choices}"
|
||||
)
|
||||
assert inject["ok"] is True
|
||||
|
||||
data = get(f"/api/clarify/pending?session_id={urllib.parse.quote(sid)}")
|
||||
assert data["pending"] is not None
|
||||
|
||||
result, status = post("/api/clarify/respond", {
|
||||
"session_id": sid,
|
||||
"response": "B",
|
||||
})
|
||||
assert status == 200
|
||||
assert result["ok"] is True
|
||||
|
||||
data2 = get(f"/api/clarify/pending?session_id={urllib.parse.quote(sid)}")
|
||||
assert data2["pending"] is None
|
||||
69
tests/test_cmd_dropdown_scroll_838.py
Normal file
69
tests/test_cmd_dropdown_scroll_838.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Tests for #838 — slash command dropdown keyboard navigation keeps the
|
||||
selected item in view."""
|
||||
import os
|
||||
import re
|
||||
|
||||
|
||||
_SRC = os.path.join(os.path.dirname(__file__), "..")
|
||||
|
||||
|
||||
def _read(name):
|
||||
return open(os.path.join(_SRC, name), encoding="utf-8").read()
|
||||
|
||||
|
||||
class TestNavigateCmdDropdownScroll:
|
||||
"""navigateCmdDropdown must scroll the newly selected item into view so
|
||||
keyboard navigation on a long list doesn't leave the highlight below the
|
||||
visible area of the dropdown."""
|
||||
|
||||
def test_navigate_calls_scroll_into_view(self):
|
||||
js = _read("static/commands.js")
|
||||
m = re.search(r'function navigateCmdDropdown\(.*?\n\}', js, re.DOTALL)
|
||||
assert m, "navigateCmdDropdown not found"
|
||||
fn = m.group(0)
|
||||
assert 'scrollIntoView' in fn, (
|
||||
"navigateCmdDropdown must call scrollIntoView on the newly "
|
||||
"selected item so ↓/↑ keeps the highlight visible (#838)"
|
||||
)
|
||||
|
||||
def test_scroll_uses_nearest_block_alignment(self):
|
||||
"""`{block:'nearest'}` is the correct option: scrolls only when
|
||||
needed, minimum distance — won't jump the list around on every
|
||||
arrow-key press when the item is already in view."""
|
||||
js = _read("static/commands.js")
|
||||
m = re.search(r'function navigateCmdDropdown\(.*?\n\}', js, re.DOTALL)
|
||||
assert m
|
||||
fn = m.group(0)
|
||||
assert "block:'nearest'" in fn or 'block: "nearest"' in fn, (
|
||||
"scrollIntoView should use {block:'nearest'} to scroll the "
|
||||
"minimum amount needed"
|
||||
)
|
||||
|
||||
def test_scroll_after_selected_class_update(self):
|
||||
"""The scroll call must come AFTER adding the .selected class so
|
||||
the correct item is targeted."""
|
||||
js = _read("static/commands.js")
|
||||
m = re.search(r'function navigateCmdDropdown\(.*?\n\}', js, re.DOTALL)
|
||||
assert m
|
||||
fn = m.group(0)
|
||||
selected_pos = fn.find("classList.add('selected')")
|
||||
scroll_pos = fn.find("scrollIntoView")
|
||||
assert selected_pos != -1 and scroll_pos != -1
|
||||
assert selected_pos < scroll_pos, (
|
||||
"scrollIntoView must run after classList.add('selected') so it "
|
||||
"scrolls the newly-highlighted item into view"
|
||||
)
|
||||
|
||||
def test_cmd_dropdown_is_scroll_container(self):
|
||||
"""Regression guard: the .cmd-dropdown must have overflow-y:auto
|
||||
(or similar) so scrollIntoView finds it as the scroll ancestor
|
||||
rather than bubbling up to the viewport."""
|
||||
css = _read("static/style.css")
|
||||
m = re.search(r'\.cmd-dropdown\s*\{[^}]+\}', css)
|
||||
assert m, ".cmd-dropdown rule not found"
|
||||
block = m.group(0)
|
||||
assert 'overflow-y:auto' in block or 'overflow-y: auto' in block or \
|
||||
'overflow:auto' in block or 'overflow: auto' in block, (
|
||||
".cmd-dropdown must have overflow-y:auto so scrollIntoView "
|
||||
"scrolls within the dropdown, not the whole page"
|
||||
)
|
||||
84
tests/test_commands_endpoint.py
Normal file
84
tests/test_commands_endpoint.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Tests for GET /api/commands -- exposes hermes-agent COMMAND_REGISTRY."""
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import TEST_BASE, requires_agent_modules
|
||||
|
||||
|
||||
def _get(path):
|
||||
"""GET helper -- returns parsed JSON or raises HTTPError."""
|
||||
with urllib.request.urlopen(TEST_BASE + path, timeout=10) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
@requires_agent_modules
|
||||
def test_commands_endpoint_returns_list():
|
||||
"""GET /api/commands returns a JSON object with a 'commands' list."""
|
||||
body = _get('/api/commands')
|
||||
assert 'commands' in body
|
||||
assert isinstance(body['commands'], list)
|
||||
assert len(body['commands']) > 0
|
||||
|
||||
|
||||
@requires_agent_modules
|
||||
def test_commands_endpoint_includes_help():
|
||||
"""The 'help' command must always be present (it's not cli_only)."""
|
||||
body = _get('/api/commands')
|
||||
names = {c['name'] for c in body['commands']}
|
||||
assert 'help' in names
|
||||
|
||||
|
||||
@requires_agent_modules
|
||||
def test_commands_endpoint_command_shape():
|
||||
"""Each command entry has the required fields."""
|
||||
body = _get('/api/commands')
|
||||
cmd = next(c for c in body['commands'] if c['name'] == 'help')
|
||||
required = {
|
||||
'name', 'description', 'category', 'aliases',
|
||||
'args_hint', 'subcommands', 'cli_only', 'gateway_only',
|
||||
}
|
||||
assert set(cmd.keys()) >= required
|
||||
assert isinstance(cmd['aliases'], list)
|
||||
assert isinstance(cmd['subcommands'], list)
|
||||
assert isinstance(cmd['cli_only'], bool)
|
||||
assert isinstance(cmd['gateway_only'], bool)
|
||||
|
||||
|
||||
@requires_agent_modules
|
||||
def test_commands_endpoint_excludes_gateway_only_and_never_expose():
|
||||
"""gateway_only commands and the _NEVER_EXPOSE set are filtered out."""
|
||||
body = _get('/api/commands')
|
||||
names = {c['name'] for c in body['commands']}
|
||||
# /sethome, /restart, /update are gateway_only; /commands is in _NEVER_EXPOSE
|
||||
for name in ('sethome', 'restart', 'update', 'commands'):
|
||||
assert name not in names, f"{name} must be excluded from /api/commands"
|
||||
|
||||
|
||||
@requires_agent_modules
|
||||
def test_commands_endpoint_keeps_new_with_reset_alias():
|
||||
"""The 'new' command stays exposed and carries its 'reset' alias."""
|
||||
body = _get('/api/commands')
|
||||
new_cmd = next(c for c in body['commands'] if c['name'] == 'new')
|
||||
assert 'reset' in new_cmd['aliases']
|
||||
|
||||
|
||||
def test_list_commands_returns_empty_for_empty_registry():
|
||||
"""list_commands(_registry=[]) returns [] -- the same path as when
|
||||
hermes_cli is missing (the empty-or-missing case)."""
|
||||
from api.commands import list_commands
|
||||
assert list_commands(_registry=[]) == []
|
||||
|
||||
|
||||
def test_list_commands_degrades_when_agent_missing(monkeypatch):
|
||||
"""If hermes_cli.commands is not importable, list_commands() returns []
|
||||
via the ImportError path. Verified by stubbing sys.modules; test cleanup
|
||||
is handled by monkeypatch + the fact that we don't reload api.commands."""
|
||||
import sys
|
||||
monkeypatch.setitem(sys.modules, 'hermes_cli.commands', None)
|
||||
# NOTE: we do NOT reload api.commands. The lazy import inside
|
||||
# list_commands() will re-attempt the import on each call and hit
|
||||
# the stubbed-None module, raising ImportError, taking the fallback path.
|
||||
from api.commands import list_commands
|
||||
assert list_commands() == []
|
||||
595
tests/test_credential_pool_providers.py
Normal file
595
tests/test_credential_pool_providers.py
Normal file
@@ -0,0 +1,595 @@
|
||||
"""Regression tests for credential_pool provider detection in /api/models."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
|
||||
import api.config as config
|
||||
import api.profiles as profiles
|
||||
|
||||
_AMBIENT_SOURCES = {"gh_cli", "gh auth token"}
|
||||
|
||||
|
||||
def _install_fake_hermes_cli(monkeypatch, *, with_load_pool: bool = False, pool_data: dict | None = None):
|
||||
"""Stub hermes_cli modules so tests are deterministic and offline.
|
||||
|
||||
When *with_load_pool* is True, also stubs hermes_cli.credential_pool with a
|
||||
suppression-aware load_pool() implementation that mirrors upstream behaviour:
|
||||
entries whose source/label/key_source signals ambient gh-cli auth are filtered out.
|
||||
"""
|
||||
fake_pkg = types.ModuleType("hermes_cli")
|
||||
fake_pkg.__path__ = []
|
||||
|
||||
fake_models = types.ModuleType("hermes_cli.models")
|
||||
fake_models.list_available_providers = lambda: []
|
||||
fake_models.provider_model_ids = lambda pid: (
|
||||
["gpt-oss:20b", "qwen3:30b-a3b"] if pid == "ollama-cloud" else []
|
||||
)
|
||||
|
||||
fake_auth = types.ModuleType("hermes_cli.auth")
|
||||
fake_auth.get_auth_status = lambda _pid: {}
|
||||
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", fake_pkg)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.models", fake_models)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.auth", fake_auth)
|
||||
|
||||
# Always remove the real agent.credential_pool so get_available_models() takes
|
||||
# the ImportError fallback path and reads from the monkeypatched auth store,
|
||||
# not the live ~/.hermes/auth.json via the real venv module.
|
||||
monkeypatch.delitem(sys.modules, "agent.credential_pool", raising=False)
|
||||
monkeypatch.delitem(sys.modules, "agent", raising=False)
|
||||
|
||||
if with_load_pool:
|
||||
_pool_data = pool_data or {}
|
||||
|
||||
class _FakeEntry:
|
||||
"""Minimal PooledCredential stand-in with attribute access (matching the real class)."""
|
||||
def __init__(self, d):
|
||||
self.source = d.get("source", "manual")
|
||||
self.label = d.get("label", "")
|
||||
self.key_source = d.get("key_source", "")
|
||||
self.id = d.get("id", "")
|
||||
|
||||
class _FakePool:
|
||||
def __init__(self, entries_list):
|
||||
self._entries = entries_list
|
||||
|
||||
def entries(self):
|
||||
return self._entries
|
||||
|
||||
def _fake_load_pool(pid):
|
||||
# Return ALL entries without filtering — mirrors the real load_pool()
|
||||
# which does NOT suppress ambient gh-cli tokens on its own.
|
||||
# Ambient-source filtering is the webui's responsibility.
|
||||
raw = _pool_data.get(pid, [])
|
||||
return _FakePool([_FakeEntry(e) for e in raw])
|
||||
|
||||
fake_cp = types.ModuleType("agent.credential_pool")
|
||||
fake_cp.load_pool = _fake_load_pool
|
||||
monkeypatch.setitem(sys.modules, "agent.credential_pool", fake_cp)
|
||||
|
||||
|
||||
def _call_get_available_models(monkeypatch, tmp_path, auth_payload, *, with_load_pool: bool = False):
|
||||
"""Call get_available_models() with auth.json pinned to a temp Hermes home."""
|
||||
_install_fake_hermes_cli(
|
||||
monkeypatch,
|
||||
with_load_pool=with_load_pool,
|
||||
pool_data=auth_payload.get("credential_pool", {}),
|
||||
)
|
||||
|
||||
(tmp_path / "auth.json").write_text(json.dumps(auth_payload), encoding="utf-8")
|
||||
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
|
||||
|
||||
old_cfg = dict(config.cfg)
|
||||
old_mtime = config._cfg_mtime
|
||||
config.cfg.clear()
|
||||
config.cfg["model"] = {}
|
||||
try:
|
||||
# Pin mtime to avoid reload_config() clobbering our in-memory cfg patch.
|
||||
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
|
||||
except Exception:
|
||||
config._cfg_mtime = 0.0
|
||||
|
||||
config.invalidate_models_cache()
|
||||
try:
|
||||
return config.get_available_models()
|
||||
finally:
|
||||
config.cfg.clear()
|
||||
config.cfg.update(old_cfg)
|
||||
config._cfg_mtime = old_mtime
|
||||
config.invalidate_models_cache()
|
||||
|
||||
|
||||
def _group_by_provider(result):
|
||||
return {g["provider"]: g["models"] for g in result.get("groups", [])}
|
||||
|
||||
|
||||
def test_ollama_cloud_manual_credential_shows_group(monkeypatch, tmp_path):
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"ollama-cloud": [
|
||||
{
|
||||
"id": "abc123",
|
||||
"label": "ollama-manual",
|
||||
"source": "manual",
|
||||
"auth_type": "api_key",
|
||||
"base_url": "https://ollama.com/v1",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
|
||||
groups = _group_by_provider(result)
|
||||
assert "Ollama Cloud" in groups, f"Expected Ollama Cloud in {list(groups)}"
|
||||
model_ids = [m["id"] for m in groups["Ollama Cloud"]]
|
||||
assert model_ids == ["@ollama-cloud:gpt-oss:20b", "@ollama-cloud:qwen3:30b-a3b"], model_ids
|
||||
|
||||
|
||||
def test_copilot_gh_cli_only_credential_hidden(monkeypatch, tmp_path):
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"copilot": [
|
||||
{
|
||||
"id": "def456",
|
||||
"label": "gh auth token",
|
||||
"source": "gh_cli",
|
||||
"auth_type": "api_key",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
|
||||
groups = _group_by_provider(result)
|
||||
assert "GitHub Copilot" not in groups, (
|
||||
"GitHub Copilot should be hidden when only ambient gh auth token is present; "
|
||||
f"got {list(groups)}"
|
||||
)
|
||||
|
||||
|
||||
def test_copilot_mixed_credential_pool_remains_visible(monkeypatch, tmp_path):
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"copilot": [
|
||||
{
|
||||
"id": "def456",
|
||||
"label": "gh auth token",
|
||||
"source": "gh_cli",
|
||||
"auth_type": "api_key",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
},
|
||||
{
|
||||
"id": "ghi789",
|
||||
"label": "explicit-copilot",
|
||||
"source": "manual",
|
||||
"auth_type": "api_key",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
|
||||
groups = _group_by_provider(result)
|
||||
assert "GitHub Copilot" in groups, f"Expected GitHub Copilot in {list(groups)}"
|
||||
model_ids = [m["id"] for m in groups["GitHub Copilot"]]
|
||||
assert "@copilot:gpt-5.4" in model_ids, model_ids
|
||||
assert "@copilot:claude-opus-4.6" in model_ids, model_ids
|
||||
|
||||
|
||||
def test_copilot_empty_field_entries_are_treated_as_explicit(monkeypatch, tmp_path):
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"copilot": [
|
||||
{
|
||||
"id": "jkl012",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
|
||||
groups = _group_by_provider(result)
|
||||
assert "GitHub Copilot" in groups, f"Expected GitHub Copilot in {list(groups)}"
|
||||
|
||||
|
||||
def test_copilot_oauth_credential_is_visible(monkeypatch, tmp_path):
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"copilot": [
|
||||
{
|
||||
"id": "mno345",
|
||||
"label": "github-oauth",
|
||||
"source": "oauth",
|
||||
"auth_type": "oauth",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
|
||||
groups = _group_by_provider(result)
|
||||
assert "GitHub Copilot" in groups, f"Expected GitHub Copilot in {list(groups)}"
|
||||
|
||||
|
||||
# --- load_pool path (suppression-aware) ---
|
||||
|
||||
|
||||
def test_load_pool_copilot_ambient_only_remains_hidden(monkeypatch, tmp_path):
|
||||
"""load_pool path: copilot with only ambient gh-cli entries is suppressed."""
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"copilot": [
|
||||
{
|
||||
"id": "lp001",
|
||||
"label": "gh auth token",
|
||||
"source": "gh_cli",
|
||||
"auth_type": "api_key",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload, with_load_pool=True)
|
||||
groups = _group_by_provider(result)
|
||||
assert "GitHub Copilot" not in groups, (
|
||||
"GitHub Copilot must be hidden when load_pool returns no usable entries; "
|
||||
f"got {list(groups)}"
|
||||
)
|
||||
|
||||
|
||||
def test_load_pool_copilot_ambient_key_source_only_remains_hidden(monkeypatch, tmp_path):
|
||||
"""load_pool path: key_source-only ambient markers must also be suppressed."""
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"copilot": [
|
||||
{
|
||||
"id": "lp001b",
|
||||
"label": "copilot-token",
|
||||
"source": "manual",
|
||||
"key_source": "gh auth token",
|
||||
"auth_type": "api_key",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload, with_load_pool=True)
|
||||
groups = _group_by_provider(result)
|
||||
assert "GitHub Copilot" not in groups, (
|
||||
"GitHub Copilot must stay hidden when load_pool entries only differ by key_source ambient markers; "
|
||||
f"got {list(groups)}"
|
||||
)
|
||||
|
||||
|
||||
def test_load_pool_alias_provider_key_is_resolved(monkeypatch, tmp_path):
|
||||
"""load_pool path: aliased pool keys should resolve to canonical provider ids."""
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"google": [
|
||||
{
|
||||
"id": "gp001",
|
||||
"label": "explicit-gemini",
|
||||
"source": "manual",
|
||||
"auth_type": "api_key",
|
||||
"base_url": "https://generativelanguage.googleapis.com",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload, with_load_pool=True)
|
||||
groups = _group_by_provider(result)
|
||||
assert "Gemini" in groups, f"Expected Gemini in {list(groups)}"
|
||||
assert "Google" not in groups, f"Aliased provider key should not render under raw alias name: {list(groups)}"
|
||||
|
||||
|
||||
def test_load_pool_explicit_credential_shows_provider(monkeypatch, tmp_path):
|
||||
"""load_pool path: provider with at least one explicit entry is visible."""
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"copilot": [
|
||||
{
|
||||
"id": "lp002",
|
||||
"label": "gh auth token",
|
||||
"source": "gh_cli",
|
||||
"auth_type": "api_key",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
},
|
||||
{
|
||||
"id": "lp003",
|
||||
"label": "explicit-pat",
|
||||
"source": "manual",
|
||||
"auth_type": "api_key",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload, with_load_pool=True)
|
||||
groups = _group_by_provider(result)
|
||||
assert "GitHub Copilot" in groups, (
|
||||
f"GitHub Copilot must appear when load_pool has at least one usable entry; got {list(groups)}"
|
||||
)
|
||||
|
||||
|
||||
# --- _apply_provider_prefix helper ---
|
||||
|
||||
|
||||
def test_apply_provider_prefix_ollama_cloud_non_active():
|
||||
"""Bare ollama-cloud model ids get @ollama-cloud: prefix when not active."""
|
||||
from api.config import _apply_provider_prefix
|
||||
|
||||
raw = [{"id": "gpt-oss:20b", "label": "gpt-oss:20b"}, {"id": "qwen3:30b-a3b", "label": "qwen3:30b-a3b"}]
|
||||
result = _apply_provider_prefix(raw, "ollama-cloud", "openai-codex")
|
||||
ids = [m["id"] for m in result]
|
||||
assert ids == ["@ollama-cloud:gpt-oss:20b", "@ollama-cloud:qwen3:30b-a3b"], ids
|
||||
|
||||
|
||||
def test_apply_provider_prefix_copilot_non_active():
|
||||
"""Bare copilot model ids get @copilot: prefix when not active."""
|
||||
from api.config import _apply_provider_prefix
|
||||
|
||||
raw = [{"id": "gpt-5.4", "label": "GPT-5.4"}, {"id": "claude-opus-4.6", "label": "Claude Opus 4.6"}]
|
||||
result = _apply_provider_prefix(raw, "copilot", "openai-codex")
|
||||
ids = [m["id"] for m in result]
|
||||
assert ids == ["@copilot:gpt-5.4", "@copilot:claude-opus-4.6"], ids
|
||||
|
||||
|
||||
def test_apply_provider_prefix_no_double_prefix():
|
||||
"""Already-prefixed or provider/model ids are not double-prefixed."""
|
||||
from api.config import _apply_provider_prefix
|
||||
|
||||
raw = [
|
||||
{"id": "@copilot:gpt-5.4", "label": "already prefixed"},
|
||||
{"id": "openai/gpt-5.4", "label": "slash form"},
|
||||
{"id": "bare-model", "label": "bare"},
|
||||
]
|
||||
result = _apply_provider_prefix(raw, "copilot", "openai-codex")
|
||||
ids = [m["id"] for m in result]
|
||||
assert ids == ["@copilot:gpt-5.4", "openai/gpt-5.4", "@copilot:bare-model"], ids
|
||||
|
||||
|
||||
def test_apply_provider_prefix_active_provider_no_prefix():
|
||||
"""No prefix is added when the provider is already the active one."""
|
||||
from api.config import _apply_provider_prefix
|
||||
|
||||
raw = [{"id": "gpt-5.4", "label": "GPT-5.4"}]
|
||||
result = _apply_provider_prefix(raw, "openai-codex", "openai-codex")
|
||||
ids = [m["id"] for m in result]
|
||||
assert ids == ["gpt-5.4"], ids
|
||||
|
||||
|
||||
def test_copilot_mixed_pool_prefixed_models(monkeypatch, tmp_path):
|
||||
"""Copilot with mixed pool and non-active provider has @copilot: prefixed model ids."""
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"copilot": [
|
||||
{
|
||||
"id": "lp010",
|
||||
"label": "explicit-copilot",
|
||||
"source": "manual",
|
||||
"auth_type": "api_key",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
|
||||
groups = _group_by_provider(result)
|
||||
assert "GitHub Copilot" in groups
|
||||
model_ids = [m["id"] for m in groups["GitHub Copilot"]]
|
||||
assert all(mid.startswith("@copilot:") for mid in model_ids), model_ids
|
||||
|
||||
|
||||
def test_auth_store_active_provider_alias_is_resolved(monkeypatch, tmp_path):
|
||||
"""active_provider read from auth.json must be alias-normalized.
|
||||
|
||||
Regression: previously the alias table was applied only to config.yaml's
|
||||
active_provider, so an aliased name in auth.json (e.g. 'google') would
|
||||
not match the canonical pid ('gemini') and the prefixing logic would
|
||||
add an unwanted '@gemini:' prefix to the active provider's models.
|
||||
"""
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
# Aliased name: 'google' → 'gemini' per _PROVIDER_ALIASES.
|
||||
"active_provider": "google",
|
||||
"credential_pool": {},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
|
||||
groups = _group_by_provider(result)
|
||||
# Gemini should appear under its canonical display name and its model
|
||||
# ids should NOT be prefixed (it's the active provider).
|
||||
assert "Gemini" in groups, f"Expected Gemini in {list(groups)}"
|
||||
model_ids = [m["id"] for m in groups["Gemini"]]
|
||||
assert model_ids, "Gemini group should have models"
|
||||
assert not any(mid.startswith("@") for mid in model_ids), (
|
||||
f"Active provider models must not be prefixed; got {model_ids}"
|
||||
)
|
||||
|
||||
|
||||
def test_ollama_cloud_empty_catalog_skips_group(monkeypatch, tmp_path):
|
||||
"""When hermes_cli returns no models for ollama-cloud, the group is omitted.
|
||||
|
||||
Matches the named-custom and unknown-provider branches: we don't invent a
|
||||
catalog we can't enumerate. The logger.warning in the except branch keeps
|
||||
diagnostics available for operators.
|
||||
"""
|
||||
_install_fake_hermes_cli(monkeypatch)
|
||||
|
||||
# Override the stub to return empty for ollama-cloud.
|
||||
import sys as _sys
|
||||
_sys.modules["hermes_cli.models"].provider_model_ids = lambda pid: []
|
||||
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"ollama-cloud": [
|
||||
{
|
||||
"id": "oc-empty",
|
||||
"label": "ollama-manual",
|
||||
"source": "manual",
|
||||
"auth_type": "api_key",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
(tmp_path / "auth.json").write_text(json.dumps(auth_payload), encoding="utf-8")
|
||||
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
|
||||
|
||||
old_cfg = dict(config.cfg)
|
||||
old_mtime = config._cfg_mtime
|
||||
config.cfg.clear()
|
||||
config.cfg["model"] = {}
|
||||
try:
|
||||
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
|
||||
except Exception:
|
||||
config._cfg_mtime = 0.0
|
||||
|
||||
try:
|
||||
result = config.get_available_models()
|
||||
finally:
|
||||
config.cfg.clear()
|
||||
config.cfg.update(old_cfg)
|
||||
config._cfg_mtime = old_mtime
|
||||
|
||||
groups = _group_by_provider(result)
|
||||
assert "Ollama Cloud" not in groups, (
|
||||
f"Ollama Cloud group should be skipped when catalog is empty; got {list(groups)}"
|
||||
)
|
||||
|
||||
|
||||
# --- _format_ollama_label helper ---
|
||||
|
||||
|
||||
def test_format_ollama_label_simple():
|
||||
from api.config import _format_ollama_label
|
||||
|
||||
assert _format_ollama_label("kimi-k2.5") == "Kimi K2.5"
|
||||
|
||||
|
||||
def test_format_ollama_label_with_variant():
|
||||
from api.config import _format_ollama_label
|
||||
|
||||
assert _format_ollama_label("qwen3-vl:235b-instruct") == "Qwen3 VL (235B Instruct)"
|
||||
|
||||
|
||||
def test_format_ollama_label_short_acronym():
|
||||
from api.config import _format_ollama_label
|
||||
|
||||
assert _format_ollama_label("glm-5.1") == "GLM 5.1"
|
||||
|
||||
|
||||
def test_format_ollama_label_gpt_oss_with_size():
|
||||
from api.config import _format_ollama_label
|
||||
|
||||
assert _format_ollama_label("gpt-oss:20b") == "GPT OSS (20B)"
|
||||
|
||||
|
||||
def test_format_ollama_label_empty_string():
|
||||
from api.config import _format_ollama_label
|
||||
|
||||
assert _format_ollama_label("") == ""
|
||||
|
||||
|
||||
def test_format_ollama_label_no_variant():
|
||||
from api.config import _format_ollama_label
|
||||
|
||||
assert _format_ollama_label("nemotron-3-super") == "Nemotron 3 Super"
|
||||
|
||||
|
||||
# --- Fallback-path (ImportError branch) alias resolution ---
|
||||
|
||||
|
||||
def test_fallback_path_resolves_alias_when_load_pool_unavailable(monkeypatch, tmp_path):
|
||||
"""When agent.credential_pool can't be imported, the manual-inspection
|
||||
branch must still canonicalize pool keys so aliased names (e.g. 'google')
|
||||
end up under their canonical provider id ('gemini')."""
|
||||
_install_fake_hermes_cli(monkeypatch)
|
||||
# Ensure agent.credential_pool is not importable so the fallback branch runs.
|
||||
monkeypatch.setitem(sys.modules, "agent.credential_pool", None)
|
||||
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"google": [
|
||||
{
|
||||
"id": "gp-fallback",
|
||||
"label": "explicit-gemini",
|
||||
"source": "manual",
|
||||
"auth_type": "api_key",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
(tmp_path / "auth.json").write_text(json.dumps(auth_payload), encoding="utf-8")
|
||||
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
|
||||
|
||||
old_cfg = dict(config.cfg)
|
||||
old_mtime = config._cfg_mtime
|
||||
config.cfg.clear()
|
||||
config.cfg["model"] = {}
|
||||
try:
|
||||
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
|
||||
except Exception:
|
||||
config._cfg_mtime = 0.0
|
||||
|
||||
try:
|
||||
result = config.get_available_models()
|
||||
finally:
|
||||
config.cfg.clear()
|
||||
config.cfg.update(old_cfg)
|
||||
config._cfg_mtime = old_mtime
|
||||
|
||||
groups = _group_by_provider(result)
|
||||
assert "Gemini" in groups, (
|
||||
f"Fallback path must resolve 'google' -> 'gemini'; got {list(groups)}"
|
||||
)
|
||||
assert "Google" not in groups, (
|
||||
f"Raw alias name must not leak when fallback path runs; got {list(groups)}"
|
||||
)
|
||||
110
tests/test_cron_refresh_button_835.py
Normal file
110
tests/test_cron_refresh_button_835.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Tests for #835 — refresh button in Tasks / Scheduled Jobs panel."""
|
||||
import os
|
||||
import re
|
||||
|
||||
|
||||
_SRC = os.path.join(os.path.dirname(__file__), "..")
|
||||
|
||||
|
||||
def _read(name):
|
||||
return open(os.path.join(_SRC, name), encoding="utf-8").read()
|
||||
|
||||
|
||||
class TestCronRefreshButtonHtml:
|
||||
"""index.html must expose a refresh button in the Tasks panel header."""
|
||||
|
||||
def test_refresh_button_present(self):
|
||||
html = _read("static/index.html")
|
||||
assert 'id="cronRefreshBtn"' in html, (
|
||||
"Tasks panel must have a #cronRefreshBtn element"
|
||||
)
|
||||
|
||||
def test_refresh_button_has_accessibility_labels(self):
|
||||
"""Icon-only buttons need aria-label + title so screen readers and
|
||||
hover tooltips work."""
|
||||
html = _read("static/index.html")
|
||||
m = re.search(r'<button[^>]*id="cronRefreshBtn"[^>]*>', html)
|
||||
assert m, "cronRefreshBtn tag not found"
|
||||
tag = m.group(0)
|
||||
assert 'aria-label=' in tag, (
|
||||
"#cronRefreshBtn is icon-only and must have aria-label"
|
||||
)
|
||||
assert 'title=' in tag, (
|
||||
"#cronRefreshBtn should have a title tooltip"
|
||||
)
|
||||
|
||||
def test_refresh_button_calls_load_crons_with_animate(self):
|
||||
html = _read("static/index.html")
|
||||
m = re.search(r'<button[^>]*id="cronRefreshBtn"[^>]*>', html)
|
||||
assert m
|
||||
tag = m.group(0)
|
||||
assert 'loadCrons(true)' in tag, (
|
||||
"#cronRefreshBtn must call loadCrons(true) to enable the dim-while-fetching animation"
|
||||
)
|
||||
|
||||
def test_refresh_button_sits_next_to_new_job_button(self):
|
||||
"""Refresh button should appear in the same header row as the New Job
|
||||
button so the header layout stays tight."""
|
||||
html = _read("static/index.html")
|
||||
ref_pos = html.find('id="cronRefreshBtn"')
|
||||
newjob_pos = html.find('toggleCronForm()')
|
||||
assert ref_pos != -1 and newjob_pos != -1
|
||||
# Must be close enough to be in the same header row (single SVG-inline
|
||||
# button can be around 500 chars by itself due to inline styles/attrs).
|
||||
assert abs(ref_pos - newjob_pos) < 1000, (
|
||||
"Refresh button and New Job button should be in the same header row"
|
||||
)
|
||||
|
||||
|
||||
class TestLoadCronsAnimateFlag:
|
||||
"""panels.js loadCrons() must accept an optional animate flag that dims
|
||||
the refresh button while fetching."""
|
||||
|
||||
def test_load_crons_accepts_animate_param(self):
|
||||
js = _read("static/panels.js")
|
||||
assert re.search(r'async function loadCrons\s*\(\s*animate\s*\)', js), (
|
||||
"loadCrons must accept an `animate` parameter"
|
||||
)
|
||||
|
||||
def test_load_crons_restores_button_in_finally(self):
|
||||
"""The opacity/disabled restore MUST be in a finally block so a
|
||||
throwing fetch doesn't leave the button stuck at 0.5 / disabled."""
|
||||
js = _read("static/panels.js")
|
||||
m = re.search(r'async function loadCrons\(.*?\n\}', js, re.DOTALL)
|
||||
assert m, "loadCrons body not found"
|
||||
fn = m.group(0)
|
||||
assert 'finally' in fn, (
|
||||
"loadCrons must restore the refresh button's opacity/disabled state "
|
||||
"in a finally block so errors during fetch don't leave the button stuck"
|
||||
)
|
||||
# The restore block sets opacity='' (not '1') so CSS cascade wins
|
||||
assert "opacity = ''" in fn or "opacity=''" in fn, (
|
||||
"restore must use opacity='' to clear the inline override"
|
||||
)
|
||||
|
||||
|
||||
class TestCronCreatedEventListener:
|
||||
"""A global `hermes:cron_created` listener must be registered so
|
||||
future chat paths can trigger the cron list refresh."""
|
||||
|
||||
def test_listener_registered_at_module_scope(self):
|
||||
js = _read("static/panels.js")
|
||||
assert re.search(
|
||||
r"addEventListener\(\s*['\"]hermes:cron_created['\"]",
|
||||
js,
|
||||
), (
|
||||
"panels.js must register a window-level 'hermes:cron_created' event listener"
|
||||
)
|
||||
|
||||
def test_listener_triggers_load_crons(self):
|
||||
js = _read("static/panels.js")
|
||||
m = re.search(
|
||||
r"addEventListener\(\s*['\"]hermes:cron_created['\"].*?\}\s*\)",
|
||||
js,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert m, "hermes:cron_created listener body not found"
|
||||
body = m.group(0)
|
||||
assert 'loadCrons' in body, (
|
||||
"hermes:cron_created listener must call loadCrons() to refresh the list"
|
||||
)
|
||||
165
tests/test_custom_provider_display_name.py
Normal file
165
tests/test_custom_provider_display_name.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
Tests for named custom provider display in the model dropdown (issue #557).
|
||||
|
||||
When a custom_providers entry carries a `name` field (e.g. "Agent37"), the
|
||||
web UI model picker should show that name as the group header rather than the
|
||||
generic "Custom" label.
|
||||
"""
|
||||
import pytest
|
||||
import api.config as config
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_models_cache():
|
||||
"""Invalidate the models TTL cache before and after every test in this file."""
|
||||
try:
|
||||
config.invalidate_models_cache()
|
||||
except Exception:
|
||||
pass
|
||||
yield
|
||||
try:
|
||||
config.invalidate_models_cache()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _models_with_cfg(model_cfg=None, custom_providers=None, active_provider=None):
|
||||
"""Temporarily patch config.cfg, call get_available_models(), restore.
|
||||
|
||||
Also pins _cfg_mtime to the current config.yaml mtime before calling
|
||||
get_available_models(). Without this, if a prior test wrote config.yaml
|
||||
(changing its mtime), the mtime-guard inside get_available_models() fires
|
||||
reload_config() which overwrites config.cfg with the real on-disk values,
|
||||
silently discarding the patch and causing ordering-dependent failures.
|
||||
This matches the pattern used in test_model_resolver.py.
|
||||
"""
|
||||
old_cfg = dict(config.cfg)
|
||||
old_mtime = config._cfg_mtime
|
||||
config.cfg.clear()
|
||||
if model_cfg:
|
||||
config.cfg["model"] = model_cfg
|
||||
if custom_providers is not None:
|
||||
config.cfg["custom_providers"] = custom_providers
|
||||
# Pin mtime so get_available_models() skips its reload_config() guard.
|
||||
try:
|
||||
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
|
||||
except Exception:
|
||||
config._cfg_mtime = 0.0 # no config.yaml present; reload guard is a no-op
|
||||
try:
|
||||
return config.get_available_models()
|
||||
finally:
|
||||
config.cfg.clear()
|
||||
config.cfg.update(old_cfg)
|
||||
config._cfg_mtime = old_mtime
|
||||
|
||||
|
||||
# ── Named provider shows its name in the dropdown ─────────────────────────────
|
||||
|
||||
class TestNamedCustomProviderGroup:
|
||||
|
||||
def test_named_provider_uses_name_as_group_header(self):
|
||||
"""A custom_provider entry with name='Agent37' should produce
|
||||
a group whose 'provider' key is 'Agent37', not 'Custom'."""
|
||||
result = _models_with_cfg(
|
||||
model_cfg={"provider": "custom", "base_url": "https://agent37.example.com/v1"},
|
||||
custom_providers=[
|
||||
{"name": "Agent37", "model": "default", "base_url": "https://agent37.example.com/v1"}
|
||||
],
|
||||
)
|
||||
group_names = [g["provider"] for g in result.get("groups", [])]
|
||||
assert "Agent37" in group_names, (
|
||||
f"Expected 'Agent37' in group names, got {group_names}"
|
||||
)
|
||||
|
||||
def test_named_provider_does_not_produce_generic_custom(self):
|
||||
"""When all custom_provider entries have names, no group called 'Custom'
|
||||
should appear alongside them."""
|
||||
result = _models_with_cfg(
|
||||
model_cfg={"provider": "custom", "base_url": "https://agent37.example.com/v1"},
|
||||
custom_providers=[
|
||||
{"name": "Agent37", "model": "default", "base_url": "https://agent37.example.com/v1"}
|
||||
],
|
||||
)
|
||||
group_names = [g["provider"] for g in result.get("groups", [])]
|
||||
assert "Custom" not in group_names, (
|
||||
f"Expected no generic 'Custom' group when all entries are named, got {group_names}"
|
||||
)
|
||||
|
||||
def test_named_provider_model_appears_in_its_group(self):
|
||||
"""The model ID from the named entry should be inside the named group."""
|
||||
result = _models_with_cfg(
|
||||
model_cfg={"provider": "custom"},
|
||||
custom_providers=[
|
||||
{"name": "Agent37", "model": "my-llm", "base_url": "https://agent37.example.com/v1"}
|
||||
],
|
||||
)
|
||||
agent37_group = next(
|
||||
(g for g in result.get("groups", []) if g["provider"] == "Agent37"), None
|
||||
)
|
||||
assert agent37_group is not None, "Expected an 'Agent37' group"
|
||||
model_ids = [m["id"] for m in agent37_group.get("models", [])]
|
||||
assert "my-llm" in model_ids, (
|
||||
f"Expected 'my-llm' in Agent37 group models, got {model_ids}"
|
||||
)
|
||||
|
||||
def test_multiple_named_providers_each_get_their_own_group(self):
|
||||
"""Two named custom providers should produce two distinct groups."""
|
||||
result = _models_with_cfg(
|
||||
model_cfg={"provider": "custom"},
|
||||
custom_providers=[
|
||||
{"name": "Agent37", "model": "fast-model"},
|
||||
{"name": "PrivateProxy", "model": "private-llm"},
|
||||
],
|
||||
)
|
||||
group_names = [g["provider"] for g in result.get("groups", [])]
|
||||
assert "Agent37" in group_names, f"Expected 'Agent37' group, got {group_names}"
|
||||
assert "PrivateProxy" in group_names, f"Expected 'PrivateProxy' group, got {group_names}"
|
||||
assert "Custom" not in group_names, f"No generic 'Custom' group expected, got {group_names}"
|
||||
|
||||
def test_multiple_models_in_same_named_provider(self):
|
||||
"""Multiple entries with the same name should be collapsed into one group."""
|
||||
result = _models_with_cfg(
|
||||
model_cfg={"provider": "custom"},
|
||||
custom_providers=[
|
||||
{"name": "Agent37", "model": "model-a"},
|
||||
{"name": "Agent37", "model": "model-b"},
|
||||
],
|
||||
)
|
||||
agent37_groups = [g for g in result.get("groups", []) if g["provider"] == "Agent37"]
|
||||
assert len(agent37_groups) == 1, (
|
||||
f"Expected exactly one 'Agent37' group, got {len(agent37_groups)}"
|
||||
)
|
||||
model_ids = [m["id"] for m in agent37_groups[0].get("models", [])]
|
||||
assert "model-a" in model_ids
|
||||
assert "model-b" in model_ids
|
||||
|
||||
|
||||
# ── Unnamed entry still falls back to 'Custom' ─────────────────────────────────
|
||||
|
||||
class TestUnnamedCustomProviderFallback:
|
||||
|
||||
def test_unnamed_entry_still_produces_custom_group(self):
|
||||
"""A custom_provider entry without a name should still show as 'Custom'."""
|
||||
result = _models_with_cfg(
|
||||
model_cfg={"provider": "custom"},
|
||||
custom_providers=[
|
||||
{"model": "unnamed-model"}
|
||||
],
|
||||
)
|
||||
group_names = [g["provider"] for g in result.get("groups", [])]
|
||||
assert "Custom" in group_names, (
|
||||
f"Expected generic 'Custom' group for unnamed entry, got {group_names}"
|
||||
)
|
||||
|
||||
def test_mixed_named_and_unnamed_entries(self):
|
||||
"""Named and unnamed entries should appear in their respective groups."""
|
||||
result = _models_with_cfg(
|
||||
model_cfg={"provider": "custom"},
|
||||
custom_providers=[
|
||||
{"name": "Agent37", "model": "named-model"},
|
||||
{"model": "unnamed-model"},
|
||||
],
|
||||
)
|
||||
group_names = [g["provider"] for g in result.get("groups", [])]
|
||||
assert "Agent37" in group_names, f"Expected 'Agent37' group, got {group_names}"
|
||||
assert "Custom" in group_names, f"Expected 'Custom' group for unnamed entry, got {group_names}"
|
||||
148
tests/test_default_workspace_fallback.py
Normal file
148
tests/test_default_workspace_fallback.py
Normal file
@@ -0,0 +1,148 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import api.config as config
|
||||
|
||||
|
||||
def test_resolve_default_workspace_falls_back_to_existing_home_work(monkeypatch, tmp_path):
|
||||
preferred = tmp_path / "work"
|
||||
preferred.mkdir()
|
||||
state_dir = tmp_path / "state"
|
||||
|
||||
monkeypatch.setattr(config, "HOME", tmp_path)
|
||||
monkeypatch.setattr(config, "STATE_DIR", state_dir)
|
||||
|
||||
resolved = config.resolve_default_workspace("/definitely/not/usable")
|
||||
|
||||
assert resolved == preferred.resolve()
|
||||
|
||||
|
||||
|
||||
def test_save_settings_rewrites_bad_default_workspace_to_fallback(monkeypatch, tmp_path):
|
||||
preferred = tmp_path / "work"
|
||||
preferred.mkdir()
|
||||
state_dir = tmp_path / "state"
|
||||
settings_file = tmp_path / "settings.json"
|
||||
|
||||
monkeypatch.setattr(config, "HOME", tmp_path)
|
||||
monkeypatch.setattr(config, "STATE_DIR", state_dir)
|
||||
monkeypatch.setattr(config, "SETTINGS_FILE", settings_file)
|
||||
monkeypatch.setattr(config, "DEFAULT_WORKSPACE", preferred)
|
||||
|
||||
saved = config.save_settings({"default_workspace": "/definitely/not/usable"})
|
||||
on_disk = json.loads(settings_file.read_text(encoding="utf-8"))
|
||||
|
||||
assert saved["default_workspace"] == str(preferred.resolve())
|
||||
assert on_disk["default_workspace"] == str(preferred.resolve())
|
||||
|
||||
|
||||
def test_resolve_default_workspace_creates_home_workspace_when_missing(monkeypatch, tmp_path):
|
||||
"""When no preferred dir exists, resolve falls back to creating ~/workspace."""
|
||||
state_dir = tmp_path / "state"
|
||||
monkeypatch.setattr(config, "HOME", tmp_path)
|
||||
monkeypatch.setattr(config, "STATE_DIR", state_dir)
|
||||
# Neither ~/work nor ~/workspace exists yet
|
||||
resolved = config.resolve_default_workspace(None)
|
||||
assert resolved == (tmp_path / "workspace").resolve()
|
||||
assert resolved.is_dir()
|
||||
|
||||
|
||||
def test_resolve_default_workspace_raises_when_all_candidates_fail(monkeypatch, tmp_path):
|
||||
"""RuntimeError is raised when every candidate is unwritable."""
|
||||
import stat, pytest
|
||||
# Make tmp_path read-only so mkdir inside it fails
|
||||
tmp_path.chmod(stat.S_IRUSR | stat.S_IXUSR)
|
||||
state_dir = tmp_path / "state"
|
||||
monkeypatch.setattr(config, "HOME", tmp_path)
|
||||
monkeypatch.setattr(config, "STATE_DIR", state_dir)
|
||||
monkeypatch.delenv("HERMES_WEBUI_DEFAULT_WORKSPACE", raising=False)
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="Could not create or access"):
|
||||
config.resolve_default_workspace(None)
|
||||
finally:
|
||||
tmp_path.chmod(stat.S_IRWXU) # restore for cleanup
|
||||
|
||||
|
||||
def test_workspace_candidates_deduplicates_home_workspace(monkeypatch, tmp_path):
|
||||
"""~/workspace must appear at most once in the candidates list even if it exists."""
|
||||
ws = tmp_path / "workspace"
|
||||
ws.mkdir()
|
||||
state_dir = tmp_path / "state"
|
||||
monkeypatch.setattr(config, "HOME", tmp_path)
|
||||
monkeypatch.setattr(config, "STATE_DIR", state_dir)
|
||||
monkeypatch.delenv("HERMES_WEBUI_DEFAULT_WORKSPACE", raising=False)
|
||||
candidates = config._workspace_candidates(None)
|
||||
paths = [str(p) for p in candidates]
|
||||
assert paths.count(str(ws.resolve())) <= 1, "~/workspace must not appear twice"
|
||||
|
||||
|
||||
def test_env_var_workspace_takes_priority_over_passed_raw(monkeypatch, tmp_path):
|
||||
"""HERMES_WEBUI_DEFAULT_WORKSPACE env var overrides a None raw arg but not a valid one."""
|
||||
env_ws = tmp_path / "env_workspace"
|
||||
env_ws.mkdir()
|
||||
state_dir = tmp_path / "state"
|
||||
monkeypatch.setattr(config, "HOME", tmp_path)
|
||||
monkeypatch.setattr(config, "STATE_DIR", state_dir)
|
||||
monkeypatch.setenv("HERMES_WEBUI_DEFAULT_WORKSPACE", str(env_ws))
|
||||
# When raw is None, env var should be used
|
||||
resolved = config.resolve_default_workspace(None)
|
||||
assert resolved == env_ws.resolve()
|
||||
|
||||
|
||||
def test_ensure_workspace_dir_returns_false_for_unwritable_path(monkeypatch, tmp_path):
|
||||
"""_ensure_workspace_dir returns False for a path that can't be created."""
|
||||
import stat
|
||||
# Make parent read-only so mkdir fails
|
||||
parent = tmp_path / "ro_parent"
|
||||
parent.mkdir()
|
||||
parent.chmod(stat.S_IRUSR | stat.S_IXUSR)
|
||||
try:
|
||||
result = config._ensure_workspace_dir(parent / "child")
|
||||
assert result is False
|
||||
finally:
|
||||
parent.chmod(stat.S_IRWXU)
|
||||
|
||||
|
||||
def test_env_var_wins_over_settings_json_on_startup(monkeypatch, tmp_path):
|
||||
"""HERMES_WEBUI_DEFAULT_WORKSPACE must not be overridden by settings.json at startup.
|
||||
|
||||
Regression for GitHub issue #609: Docker deployments set the env var to a
|
||||
volume mount, but settings.json from a previous container run used to
|
||||
silently win, reverting the files panel to the old path.
|
||||
"""
|
||||
import json as _json
|
||||
import os as _os
|
||||
|
||||
env_ws = tmp_path / "env_workspace"
|
||||
env_ws.mkdir()
|
||||
settings_ws = tmp_path / "settings_workspace"
|
||||
settings_ws.mkdir()
|
||||
state_dir = tmp_path / "state"
|
||||
state_dir.mkdir()
|
||||
settings_file = state_dir / "settings.json"
|
||||
settings_file.write_text(
|
||||
_json.dumps({"default_workspace": str(settings_ws)}), encoding="utf-8"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(config, "HOME", tmp_path)
|
||||
monkeypatch.setattr(config, "STATE_DIR", state_dir)
|
||||
monkeypatch.setattr(config, "SETTINGS_FILE", settings_file)
|
||||
# Simulate DEFAULT_WORKSPACE already set correctly from env var at import time
|
||||
monkeypatch.setattr(config, "DEFAULT_WORKSPACE", env_ws.resolve())
|
||||
monkeypatch.setenv("HERMES_WEBUI_DEFAULT_WORKSPACE", str(env_ws))
|
||||
|
||||
# Execute the patched startup block logic inline — env var present → skip override
|
||||
current_ws = config.DEFAULT_WORKSPACE
|
||||
startup_settings = config.load_settings()
|
||||
if not _os.getenv("HERMES_WEBUI_DEFAULT_WORKSPACE"):
|
||||
# This branch must be skipped because env var is set
|
||||
current_ws = config.resolve_default_workspace(
|
||||
startup_settings.get("default_workspace")
|
||||
)
|
||||
|
||||
# env var was set → the if block was skipped → env path wins over settings.json
|
||||
assert current_ws == env_ws.resolve(), (
|
||||
f"Expected {env_ws.resolve()}, got {current_ws}. "
|
||||
"settings.json must not override HERMES_WEBUI_DEFAULT_WORKSPACE."
|
||||
)
|
||||
|
||||
228
tests/test_font_size_setting.py
Normal file
228
tests/test_font_size_setting.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""Tests for font size setting (#833) — 3-toggle Small/Default/Large in Appearance."""
|
||||
import os
|
||||
import re
|
||||
|
||||
_SRC = os.path.join(os.path.dirname(__file__), "..")
|
||||
|
||||
def _read(name):
|
||||
return open(os.path.join(_SRC, name), encoding="utf-8").read()
|
||||
|
||||
|
||||
class TestFontSizeCssModifiers:
|
||||
"""CSS must define font-size overrides for small and large via data attribute."""
|
||||
|
||||
def test_small_font_size_rule_exists(self):
|
||||
css = _read("static/style.css")
|
||||
assert 'data-font-size="small"' in css, (
|
||||
"style.css must have :root[data-font-size=\"small\"] font-size rule"
|
||||
)
|
||||
|
||||
def test_large_font_size_rule_exists(self):
|
||||
css = _read("static/style.css")
|
||||
assert 'data-font-size="large"' in css, (
|
||||
"style.css must have :root[data-font-size=\"large\"] font-size rule"
|
||||
)
|
||||
|
||||
def test_small_is_smaller_than_default(self):
|
||||
css = _read("static/style.css")
|
||||
# Match both compact {font-size:12px} and spaced { font-size: 12px; } formats
|
||||
m_small = re.search(r':root\[data-font-size="small"\][^{]*\{[^}]*font-size:\s*(\d+)px', css)
|
||||
m_large = re.search(r':root\[data-font-size="large"\][^{]*\{[^}]*font-size:\s*(\d+)px', css)
|
||||
assert m_small and m_large, "Both small and large font-size rules must set px values"
|
||||
assert int(m_small.group(1)) < 14, "Small font size must be < 14px (default)"
|
||||
assert int(m_large.group(1)) > 14, "Large font size must be > 14px (default)"
|
||||
|
||||
|
||||
class TestFontSizeBootScript:
|
||||
"""The boot script must apply font size from localStorage before page renders."""
|
||||
|
||||
def test_boot_script_reads_hermes_font_size(self):
|
||||
html = _read("static/index.html")
|
||||
assert "hermes-font-size" in html, (
|
||||
"index.html boot script must read 'hermes-font-size' from localStorage"
|
||||
)
|
||||
assert "data-font-size" in html, (
|
||||
"boot script must set document.documentElement.dataset.fontSize"
|
||||
)
|
||||
|
||||
def test_font_size_picker_html_present(self):
|
||||
html = _read("static/index.html")
|
||||
assert "fontSizePickerGrid" in html, (
|
||||
"Appearance pane must contain a fontSizePickerGrid element"
|
||||
)
|
||||
assert "settingsFontSize" in html, (
|
||||
"Appearance pane must contain a hidden #settingsFontSize input"
|
||||
)
|
||||
assert "font-size-pick-btn" in html, (
|
||||
"Font size picker buttons must have font-size-pick-btn class"
|
||||
)
|
||||
|
||||
def test_three_font_size_values_present(self):
|
||||
html = _read("static/index.html")
|
||||
assert 'data-font-size-val="small"' in html, "Small button must exist"
|
||||
assert 'data-font-size-val="default"' in html, "Default button must exist"
|
||||
assert 'data-font-size-val="large"' in html, "Large button must exist"
|
||||
|
||||
def test_font_size_picker_not_duplicated(self):
|
||||
"""Regression guard: the font size picker grid must appear exactly once
|
||||
in index.html. Earlier versions of this PR accidentally injected the
|
||||
block into both settingsPaneAppearance (correct) and
|
||||
settingsPanePreferences (copy-paste duplicate), creating duplicate IDs
|
||||
that break _syncFontSizePicker visual sync on one of the grids."""
|
||||
html = _read("static/index.html")
|
||||
assert html.count('id="fontSizePickerGrid"') == 1, (
|
||||
"fontSizePickerGrid must appear exactly once — duplicate IDs "
|
||||
"violate HTML spec and break querySelectorAll-based sync."
|
||||
)
|
||||
assert html.count('id="settingsFontSize"') == 1, (
|
||||
"settingsFontSize hidden input must appear exactly once"
|
||||
)
|
||||
|
||||
def test_font_size_picker_lives_in_appearance_pane(self):
|
||||
"""The font size picker must be under settingsPaneAppearance,
|
||||
not Preferences/System/Conversation."""
|
||||
html = _read("static/index.html")
|
||||
appearance_start = html.find('id="settingsPaneAppearance"')
|
||||
next_pane_markers = [
|
||||
'id="settingsPanePreferences"',
|
||||
'id="settingsPaneSystem"',
|
||||
'id="settingsPaneConversation"',
|
||||
]
|
||||
next_pane_starts = [
|
||||
html.find(m, appearance_start + 1) for m in next_pane_markers
|
||||
]
|
||||
after_appearance = min(
|
||||
[p for p in next_pane_starts if p != -1] or [len(html)]
|
||||
)
|
||||
picker_pos = html.find('id="fontSizePickerGrid"')
|
||||
assert appearance_start != -1, "settingsPaneAppearance not found"
|
||||
assert picker_pos != -1, "fontSizePickerGrid not found"
|
||||
assert appearance_start < picker_pos < after_appearance, (
|
||||
"Font size picker must live inside settingsPaneAppearance "
|
||||
"(same section as Theme and Skin)"
|
||||
)
|
||||
|
||||
|
||||
class TestFontSizeJsFunctions:
|
||||
"""JS must expose _pickFontSize, _applyFontSize, and _syncFontSizePicker."""
|
||||
|
||||
def test_pick_font_size_function_exists(self):
|
||||
boot = _read("static/boot.js")
|
||||
assert "function _pickFontSize(" in boot, (
|
||||
"boot.js must define _pickFontSize()"
|
||||
)
|
||||
|
||||
def test_apply_font_size_function_exists(self):
|
||||
boot = _read("static/boot.js")
|
||||
assert "function _applyFontSize(" in boot, (
|
||||
"boot.js must define _applyFontSize()"
|
||||
)
|
||||
|
||||
def test_sync_font_size_picker_function_exists(self):
|
||||
boot = _read("static/boot.js")
|
||||
assert "function _syncFontSizePicker(" in boot, (
|
||||
"boot.js must define _syncFontSizePicker()"
|
||||
)
|
||||
|
||||
def test_pick_font_size_persists_to_localstorage(self):
|
||||
boot = _read("static/boot.js")
|
||||
idx = boot.find("function _pickFontSize(")
|
||||
block = boot[idx:idx+400]
|
||||
assert "localStorage.setItem('hermes-font-size'" in block, (
|
||||
"_pickFontSize must persist choice to localStorage"
|
||||
)
|
||||
|
||||
def test_apply_font_size_sets_data_attribute(self):
|
||||
boot = _read("static/boot.js")
|
||||
idx = boot.find("function _applyFontSize(")
|
||||
block = boot[idx:idx+300]
|
||||
assert "dataset.fontSize" in block, (
|
||||
"_applyFontSize must set document.documentElement.dataset.fontSize"
|
||||
)
|
||||
|
||||
|
||||
class TestFontSizeI18nCoverage:
|
||||
"""All locales must include the font size i18n keys."""
|
||||
|
||||
def _get_locale_keys(self, src, locale_marker_after, stop_marker):
|
||||
"""Extract keys from a locale block."""
|
||||
start = src.find(locale_marker_after)
|
||||
if start < 0:
|
||||
return set()
|
||||
end = src.find(stop_marker, start)
|
||||
block = src[start:end if end > 0 else start + 20000]
|
||||
return set(re.findall(r"(\w[\w_]+):", block))
|
||||
|
||||
REQUIRED_KEYS = {"settings_label_font_size", "font_size_small", "font_size_default", "font_size_large"}
|
||||
|
||||
def test_all_locales_have_font_size_keys(self):
|
||||
src = _read("static/i18n.js")
|
||||
count = src.count("settings_label_font_size")
|
||||
# 6 locales: en, ru, es, de, zh, zh-Hant
|
||||
assert count >= 6, (
|
||||
f"settings_label_font_size must appear in all 6 locales, found {count}"
|
||||
)
|
||||
|
||||
def test_font_size_small_key_in_all_locales(self):
|
||||
src = _read("static/i18n.js")
|
||||
count = src.count("font_size_small")
|
||||
assert count >= 6, f"font_size_small must appear in all 6 locales, found {count}"
|
||||
|
||||
def test_font_size_large_key_in_all_locales(self):
|
||||
src = _read("static/i18n.js")
|
||||
count = src.count("font_size_large")
|
||||
assert count >= 6, f"font_size_large must appear in all 6 locales, found {count}"
|
||||
|
||||
|
||||
class TestFontSizeCssTargetedOverrides:
|
||||
"""CSS must override px-unit text in key UI elements, not just :root font-size.
|
||||
|
||||
The original PR only set :root font-size, but the stylesheet uses hardcoded px
|
||||
values throughout — changing :root has no effect on those. This test class locks
|
||||
in the targeted overrides for the most visible UI surfaces.
|
||||
"""
|
||||
|
||||
def test_msg_body_overridden_for_small(self):
|
||||
css = _read("static/style.css")
|
||||
assert ':root[data-font-size="small"] .msg-body' in css, \
|
||||
"Chat message text must be explicitly scaled for small"
|
||||
|
||||
def test_msg_body_overridden_for_large(self):
|
||||
css = _read("static/style.css")
|
||||
assert ':root[data-font-size="large"] .msg-body' in css, \
|
||||
"Chat message text must be explicitly scaled for large"
|
||||
|
||||
def test_session_item_overridden_for_small(self):
|
||||
css = _read("static/style.css")
|
||||
assert ':root[data-font-size="small"] .session-item' in css, \
|
||||
"Sidebar session list text must be explicitly scaled for small"
|
||||
|
||||
def test_session_item_overridden_for_large(self):
|
||||
css = _read("static/style.css")
|
||||
assert ':root[data-font-size="large"] .session-item' in css, \
|
||||
"Sidebar session list text must be explicitly scaled for large"
|
||||
|
||||
def test_composer_overridden_for_small(self):
|
||||
css = _read("static/style.css")
|
||||
assert ':root[data-font-size="small"] #msg' in css, \
|
||||
"Composer textarea must be explicitly scaled for small"
|
||||
|
||||
def test_composer_overridden_for_large(self):
|
||||
css = _read("static/style.css")
|
||||
assert ':root[data-font-size="large"] #msg' in css, \
|
||||
"Composer textarea must be explicitly scaled for large"
|
||||
# Large composer must not equal the default 16px — that's a no-op
|
||||
import re
|
||||
m = re.search(r':root\[data-font-size="large"\] #msg \{ font-size: (\d+)px', css)
|
||||
assert m and int(m.group(1)) != 16, \
|
||||
"Large composer font-size must differ from default (16px) to have visible effect"
|
||||
|
||||
def test_file_item_overridden_for_small(self):
|
||||
css = _read("static/style.css")
|
||||
assert ':root[data-font-size="small"] .file-item' in css, \
|
||||
"Workspace file tree text must be explicitly scaled for small"
|
||||
|
||||
def test_file_item_overridden_for_large(self):
|
||||
css = _read("static/style.css")
|
||||
assert ':root[data-font-size="large"] .file-item' in css, \
|
||||
"Workspace file tree text must be explicitly scaled for large"
|
||||
558
tests/test_gateway_sync.py
Normal file
558
tests/test_gateway_sync.py
Normal file
@@ -0,0 +1,558 @@
|
||||
"""
|
||||
Tests for Phase 1: Real-time Gateway Session Sync.
|
||||
|
||||
Tests are ordered TDD-style:
|
||||
1. Gateway sessions appear in /api/sessions when setting enabled
|
||||
2. Gateway sessions excluded when setting disabled
|
||||
3. Gateway sessions have correct metadata (source_tag, is_cli_session)
|
||||
4. SSE stream endpoint opens and receives events
|
||||
5. Watcher detects new sessions inserted into state.db
|
||||
6. Settings UI has renamed label
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sqlite3
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
|
||||
from tests._pytest_port import BASE
|
||||
|
||||
|
||||
def get(path):
|
||||
with urllib.request.urlopen(BASE + path, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status
|
||||
|
||||
|
||||
def post(path, body=None):
|
||||
data = json.dumps(body or {}).encode()
|
||||
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:
|
||||
try:
|
||||
return json.loads(e.read()), e.code
|
||||
except Exception:
|
||||
return {}, e.code
|
||||
|
||||
|
||||
def _get_test_state_dir():
|
||||
"""Return the test state directory (matches conftest.py TEST_STATE_DIR).
|
||||
|
||||
conftest.py sets HERMES_WEBUI_TEST_STATE_DIR in the test-process environment
|
||||
(via os.environ.setdefault) so that tests writing directly to state.db always
|
||||
use the same path the test server was started with. If the env var is not
|
||||
set (e.g. when running this file standalone), fall back to the conftest
|
||||
formula: HERMES_HOME/webui-mvp-test.
|
||||
"""
|
||||
# Use _pytest_port which applies the same auto-derivation as conftest.py
|
||||
from tests._pytest_port import TEST_STATE_DIR as _ptsd
|
||||
return _ptsd
|
||||
|
||||
|
||||
def _get_state_db_path():
|
||||
"""Return path to the test state.db."""
|
||||
return _get_test_state_dir() / 'state.db'
|
||||
|
||||
|
||||
def _ensure_state_db():
|
||||
"""Create state.db with sessions and messages tables if it doesn't exist.
|
||||
Returns a connection. Does NOT delete existing data (safe for parallel tests).
|
||||
"""
|
||||
db_path = _get_state_db_path()
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
model TEXT,
|
||||
started_at REAL NOT NULL,
|
||||
message_count INTEGER DEFAULT 0,
|
||||
title TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT,
|
||||
timestamp REAL NOT NULL
|
||||
);
|
||||
""")
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
def _insert_gateway_session(conn, session_id='20260401_120000_abcdefgh', source='telegram',
|
||||
title='Telegram Chat', model='anthropic/claude-sonnet-4-5',
|
||||
started_at=None, message_count=2):
|
||||
"""Insert a gateway session into state.db."""
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO sessions (id, source, title, model, started_at, message_count) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(session_id, source, title, model, started_at or time.time(), message_count)
|
||||
)
|
||||
# Delete any existing messages for this session (idempotent re-insert)
|
||||
conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
|
||||
# Insert some messages
|
||||
conn.execute(
|
||||
"INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, 'user', ?, ?)",
|
||||
(session_id, 'Hello from Telegram', started_at or time.time())
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, 'assistant', ?, ?)",
|
||||
(session_id, 'Hi there!', (started_at or time.time()) + 1)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _remove_test_sessions(conn, *session_ids):
|
||||
"""Remove specific test sessions from state.db (parallel-safe cleanup)."""
|
||||
for sid in session_ids:
|
||||
conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
|
||||
conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _cleanup_state_db():
|
||||
"""Remove state.db if it exists (only used for tests that need a blank slate)."""
|
||||
db_path = _get_state_db_path()
|
||||
for p in [db_path, db_path.parent / 'state.db-wal', db_path.parent / 'state.db-shm']:
|
||||
try:
|
||||
p.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_gateway_sessions_appear_when_enabled():
|
||||
"""Gateway sessions from state.db appear in /api/sessions when show_cli_sessions is on."""
|
||||
conn = _ensure_state_db()
|
||||
try:
|
||||
_insert_gateway_session(conn, session_id='gw_test_tg_001', source='telegram', title='TG Test Chat')
|
||||
|
||||
# Enable the setting
|
||||
post('/api/settings', {'show_cli_sessions': True})
|
||||
|
||||
data, status = get('/api/sessions')
|
||||
assert status == 200
|
||||
sessions = data.get('sessions', [])
|
||||
gw_ids = [s['session_id'] for s in sessions if s.get('session_id') == 'gw_test_tg_001']
|
||||
assert len(gw_ids) == 1, f"Expected gateway session gw_test_tg_001, got {[s['session_id'] for s in sessions]}"
|
||||
finally:
|
||||
try:
|
||||
_remove_test_sessions(conn, 'gw_test_tg_001')
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
post('/api/settings', {'show_cli_sessions': False})
|
||||
|
||||
|
||||
def test_gateway_sessions_excluded_when_disabled():
|
||||
"""Gateway sessions are NOT returned when show_cli_sessions is off."""
|
||||
conn = _ensure_state_db()
|
||||
try:
|
||||
_insert_gateway_session(conn, session_id='gw_test_dc_001', source='discord', title='DC Test Chat')
|
||||
|
||||
# Ensure setting is off
|
||||
post('/api/settings', {'show_cli_sessions': False})
|
||||
|
||||
data, status = get('/api/sessions')
|
||||
assert status == 200
|
||||
sessions = data.get('sessions', [])
|
||||
gw_ids = [s['session_id'] for s in sessions if s.get('session_id') == 'gw_test_dc_001']
|
||||
assert len(gw_ids) == 0, "Gateway session should not appear when setting is off"
|
||||
finally:
|
||||
try:
|
||||
_remove_test_sessions(conn, 'gw_test_dc_001')
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def test_gateway_session_has_correct_metadata():
|
||||
"""Gateway sessions include source_tag and is_cli_session fields."""
|
||||
conn = _ensure_state_db()
|
||||
try:
|
||||
_insert_gateway_session(conn, session_id='gw_meta_001', source='telegram', title='Meta Test')
|
||||
|
||||
post('/api/settings', {'show_cli_sessions': True})
|
||||
|
||||
data, status = get('/api/sessions')
|
||||
assert status == 200
|
||||
sessions = data.get('sessions', [])
|
||||
gw = next((s for s in sessions if s['session_id'] == 'gw_meta_001'), None)
|
||||
assert gw is not None, "Gateway session not found"
|
||||
assert gw.get('source_tag') == 'telegram', f"Expected source_tag=telegram, got {gw.get('source_tag')}"
|
||||
assert gw.get('is_cli_session') is True, "is_cli_session should be True for agent sessions"
|
||||
assert gw.get('title') == 'Meta Test'
|
||||
finally:
|
||||
try:
|
||||
_remove_test_sessions(conn, 'gw_meta_001')
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
post('/api/settings', {'show_cli_sessions': False})
|
||||
|
||||
|
||||
def test_gateway_session_has_message_count():
|
||||
"""Gateway sessions report correct message_count from state.db."""
|
||||
conn = _ensure_state_db()
|
||||
try:
|
||||
_insert_gateway_session(conn, session_id='gw_msg_001', source='discord', title='Msg Count Test', message_count=5)
|
||||
|
||||
post('/api/settings', {'show_cli_sessions': True})
|
||||
|
||||
data, status = get('/api/sessions')
|
||||
assert status == 200
|
||||
sessions = data.get('sessions', [])
|
||||
gw = next((s for s in sessions if s['session_id'] == 'gw_msg_001'), None)
|
||||
assert gw is not None
|
||||
assert gw.get('message_count') == 5, f"Expected message_count=5, got {gw.get('message_count')}"
|
||||
finally:
|
||||
try:
|
||||
_remove_test_sessions(conn, 'gw_msg_001')
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
post('/api/settings', {'show_cli_sessions': False})
|
||||
|
||||
|
||||
def test_gateway_sessions_multiple_sources():
|
||||
"""Sessions from multiple gateway sources (telegram, discord, slack) all appear."""
|
||||
conn = _ensure_state_db()
|
||||
try:
|
||||
_insert_gateway_session(conn, session_id='gw_multi_tg', source='telegram', title='TG Chat')
|
||||
_insert_gateway_session(conn, session_id='gw_multi_dc', source='discord', title='DC Chat')
|
||||
_insert_gateway_session(conn, session_id='gw_multi_sl', source='slack', title='SL Chat')
|
||||
|
||||
post('/api/settings', {'show_cli_sessions': True})
|
||||
|
||||
data, status = get('/api/sessions')
|
||||
assert status == 200
|
||||
sessions = data.get('sessions', [])
|
||||
gw_ids = {s['session_id'] for s in sessions if s.get('session_id') in ('gw_multi_tg', 'gw_multi_dc', 'gw_multi_sl')}
|
||||
assert len(gw_ids) == 3, f"Expected 3 gateway sessions, got {len(gw_ids)}: {gw_ids}"
|
||||
finally:
|
||||
try:
|
||||
_remove_test_sessions(conn, 'gw_multi_tg', 'gw_multi_dc', 'gw_multi_sl')
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
post('/api/settings', {'show_cli_sessions': False})
|
||||
|
||||
|
||||
def test_gateway_session_messages_readable():
|
||||
"""Gateway session messages can be loaded via /api/session."""
|
||||
conn = _ensure_state_db()
|
||||
try:
|
||||
_insert_gateway_session(conn, session_id='gw_read_001', source='telegram', title='Readable')
|
||||
|
||||
post('/api/settings', {'show_cli_sessions': True})
|
||||
|
||||
data, status = get(f'/api/session?session_id=gw_read_001')
|
||||
assert status == 200
|
||||
msgs = data.get('session', {}).get('messages', [])
|
||||
assert len(msgs) >= 2, f"Expected at least 2 messages, got {len(msgs)}"
|
||||
assert msgs[0].get('role') == 'user'
|
||||
assert msgs[0].get('content') == 'Hello from Telegram'
|
||||
finally:
|
||||
try:
|
||||
_remove_test_sessions(conn, 'gw_read_001')
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
post('/api/settings', {'show_cli_sessions': False})
|
||||
|
||||
|
||||
def test_importing_older_gateway_session_preserves_original_timestamps_and_order():
|
||||
"""Importing an older gateway session should not bump it above newer WebUI sessions."""
|
||||
conn = _ensure_state_db()
|
||||
older_started_at = time.time() - 1800
|
||||
imported_sid = 'gw_import_old_001'
|
||||
newer_webui_sid = None
|
||||
try:
|
||||
newer_webui, status = post('/api/session/new', {'model': 'openai/gpt-5'})
|
||||
assert status == 200, newer_webui
|
||||
newer_webui_sid = newer_webui['session']['session_id']
|
||||
|
||||
rename, rename_status = post(
|
||||
'/api/session/rename',
|
||||
{'session_id': newer_webui_sid, 'title': 'Newer WebUI Session'},
|
||||
)
|
||||
assert rename_status == 200, rename
|
||||
|
||||
_insert_gateway_session(
|
||||
conn,
|
||||
session_id=imported_sid,
|
||||
source='discord',
|
||||
title='Older imported gateway session',
|
||||
started_at=older_started_at,
|
||||
)
|
||||
post('/api/settings', {'show_cli_sessions': True})
|
||||
|
||||
imported, imported_status = post('/api/session/import_cli', {'session_id': imported_sid})
|
||||
assert imported_status == 200, imported
|
||||
imported_session = imported['session']
|
||||
assert abs(imported_session['created_at'] - older_started_at) < 2, imported_session
|
||||
assert abs(imported_session['updated_at'] - older_started_at) < 5, imported_session
|
||||
|
||||
sessions_payload, sessions_status = get('/api/sessions')
|
||||
assert sessions_status == 200, sessions_payload
|
||||
ordered_ids = [item['session_id'] for item in sessions_payload.get('sessions', [])]
|
||||
assert newer_webui_sid in ordered_ids, ordered_ids
|
||||
assert imported_sid in ordered_ids, ordered_ids
|
||||
assert ordered_ids.index(newer_webui_sid) < ordered_ids.index(imported_sid), ordered_ids
|
||||
finally:
|
||||
try:
|
||||
_remove_test_sessions(conn, imported_sid)
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
if imported_sid:
|
||||
try:
|
||||
post('/api/session/delete', {'session_id': imported_sid})
|
||||
except Exception:
|
||||
pass
|
||||
if newer_webui_sid:
|
||||
try:
|
||||
post('/api/session/delete', {'session_id': newer_webui_sid})
|
||||
except Exception:
|
||||
pass
|
||||
post('/api/settings', {'show_cli_sessions': False})
|
||||
|
||||
|
||||
|
||||
def test_gateway_sse_stream_endpoint_exists():
|
||||
"""GET /api/sessions/gateway/stream returns a response (200 or 200-range)."""
|
||||
# The SSE endpoint requires show_cli_sessions to be enabled
|
||||
post('/api/settings', {'show_cli_sessions': True})
|
||||
try:
|
||||
req = urllib.request.Request(BASE + '/api/sessions/gateway/stream')
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
assert r.status in (200, 204), f"Expected 200/204, got {r.status}"
|
||||
# SSE should have content-type text/event-stream
|
||||
ctype = r.headers.get('Content-Type', '')
|
||||
assert 'text/event-stream' in ctype, f"Expected text/event-stream, got {ctype}"
|
||||
except Exception as e:
|
||||
# Timeout is acceptable — means the connection is held open (SSE behavior)
|
||||
if 'timed out' in str(e).lower() or 'timeout' in str(e).lower():
|
||||
pass # Good: SSE keeps the connection open
|
||||
else:
|
||||
raise
|
||||
finally:
|
||||
post('/api/settings', {'show_cli_sessions': False})
|
||||
|
||||
|
||||
def test_gateway_sse_stream_probe_reports_status():
|
||||
"""Probe mode returns JSON watcher status instead of holding open an SSE stream."""
|
||||
post('/api/settings', {'show_cli_sessions': True})
|
||||
try:
|
||||
req = urllib.request.Request(BASE + '/api/sessions/gateway/stream?probe=1')
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
assert r.status == 200, f"Expected 200, got {r.status}"
|
||||
ctype = r.headers.get('Content-Type', '')
|
||||
assert 'application/json' in ctype, f"Expected application/json, got {ctype}"
|
||||
data = json.loads(r.read().decode('utf-8'))
|
||||
assert data['enabled'] is True
|
||||
assert 'watcher_running' in data
|
||||
assert data['fallback_poll_ms'] == 30000
|
||||
finally:
|
||||
post('/api/settings', {'show_cli_sessions': False})
|
||||
|
||||
|
||||
def test_gateway_webui_sessions_not_duplicated():
|
||||
"""If a session_id exists both in WebUI store and state.db, it's not duplicated."""
|
||||
# Create a WebUI session with a known ID
|
||||
body = {}
|
||||
d, _ = post('/api/session/new', body)
|
||||
webui_sid = d['session']['session_id']
|
||||
|
||||
try:
|
||||
# Insert the same session_id into state.db as a gateway session
|
||||
conn = _ensure_state_db()
|
||||
_insert_gateway_session(conn, session_id=webui_sid, source='telegram', title='Dup Test')
|
||||
conn.close()
|
||||
|
||||
post('/api/settings', {'show_cli_sessions': True})
|
||||
|
||||
data, status = get('/api/sessions')
|
||||
assert status == 200
|
||||
sessions = data.get('sessions', [])
|
||||
matching = [s for s in sessions if s['session_id'] == webui_sid]
|
||||
assert len(matching) == 1, f"Expected 1 entry for {webui_sid}, got {len(matching)}"
|
||||
finally:
|
||||
try:
|
||||
conn2 = sqlite3.connect(str(_get_state_db_path()))
|
||||
_remove_test_sessions(conn2, webui_sid)
|
||||
conn2.close()
|
||||
except Exception:
|
||||
pass
|
||||
post('/api/session/delete', {'session_id': webui_sid})
|
||||
post('/api/settings', {'show_cli_sessions': False})
|
||||
|
||||
|
||||
def test_gateway_sessions_no_state_db():
|
||||
"""When state.db doesn't exist, /api/sessions works fine (no gateway sessions)."""
|
||||
_cleanup_state_db()
|
||||
|
||||
post('/api/settings', {'show_cli_sessions': True})
|
||||
try:
|
||||
data, status = get('/api/sessions')
|
||||
assert status == 200
|
||||
# Should succeed with just webui sessions (or empty)
|
||||
assert 'sessions' in data
|
||||
finally:
|
||||
post('/api/settings', {'show_cli_sessions': False})
|
||||
|
||||
|
||||
def test_cli_sessions_still_work():
|
||||
"""CLI sessions (source='cli') still appear alongside gateway sessions."""
|
||||
conn = _ensure_state_db()
|
||||
try:
|
||||
_insert_gateway_session(conn, session_id='cli_legacy_001', source='cli', title='CLI Legacy')
|
||||
_insert_gateway_session(conn, session_id='gw_new_001', source='telegram', title='GW New')
|
||||
|
||||
post('/api/settings', {'show_cli_sessions': True})
|
||||
|
||||
data, status = get('/api/sessions')
|
||||
assert status == 200
|
||||
sessions = data.get('sessions', [])
|
||||
agent_ids = {s['session_id'] for s in sessions if s.get('session_id') in ('cli_legacy_001', 'gw_new_001')}
|
||||
assert len(agent_ids) == 2, f"Expected 2 agent sessions (cli + gateway), got {len(agent_ids)}"
|
||||
finally:
|
||||
try:
|
||||
_remove_test_sessions(conn, 'cli_legacy_001', 'gw_new_001')
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
post('/api/settings', {'show_cli_sessions': False})
|
||||
|
||||
|
||||
# ── Unit tests for _gateway_sse_probe_payload ────────────────────────────────
|
||||
# These replace the deleted repo-root test_gateway_sse_probe_unit.py and account
|
||||
# for the watcher_alive check (thread existence + is_alive()).
|
||||
|
||||
import sys
|
||||
import threading
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
from api.routes import _gateway_sse_probe_payload
|
||||
|
||||
|
||||
def test_probe_payload_when_disabled():
|
||||
"""Probe returns 404 when show_cli_sessions is False."""
|
||||
body, status = _gateway_sse_probe_payload({'show_cli_sessions': False}, watcher=None)
|
||||
assert status == 404
|
||||
assert body['ok'] is False
|
||||
assert body['enabled'] is False
|
||||
assert body['watcher_running'] is False
|
||||
assert body['error'] == 'agent sessions not enabled'
|
||||
assert body['fallback_poll_ms'] == 30000
|
||||
|
||||
|
||||
def test_probe_payload_when_watcher_missing():
|
||||
"""Probe returns 503 when enabled but no watcher instance."""
|
||||
body, status = _gateway_sse_probe_payload({'show_cli_sessions': True}, watcher=None)
|
||||
assert status == 503
|
||||
assert body['ok'] is False
|
||||
assert body['enabled'] is True
|
||||
assert body['watcher_running'] is False
|
||||
assert body['error'] == 'watcher not started'
|
||||
assert body['fallback_poll_ms'] == 30000
|
||||
|
||||
|
||||
def test_probe_payload_when_watcher_instance_no_thread():
|
||||
"""Probe returns 503 when watcher exists but _thread attribute is missing/None."""
|
||||
class _FakeWatcher:
|
||||
_thread = None
|
||||
body, status = _gateway_sse_probe_payload({'show_cli_sessions': True}, watcher=_FakeWatcher())
|
||||
assert status == 503
|
||||
assert body['watcher_running'] is False
|
||||
|
||||
|
||||
def test_probe_payload_when_watcher_thread_alive():
|
||||
"""Probe returns 200 when enabled and watcher thread is alive."""
|
||||
class _FakeWatcher:
|
||||
pass
|
||||
w = _FakeWatcher()
|
||||
t = threading.Thread(target=lambda: None)
|
||||
t.daemon = True
|
||||
t.start()
|
||||
w._thread = t
|
||||
# Thread may finish fast — loop-start a live daemon thread for reliability
|
||||
import time as _time
|
||||
done = threading.Event()
|
||||
live = threading.Thread(target=done.wait, daemon=True)
|
||||
live.start()
|
||||
w._thread = live
|
||||
try:
|
||||
body, status = _gateway_sse_probe_payload({'show_cli_sessions': True}, watcher=w)
|
||||
assert status == 200
|
||||
assert body['ok'] is True
|
||||
assert body['watcher_running'] is True
|
||||
assert body['fallback_poll_ms'] == 30000
|
||||
finally:
|
||||
done.set()
|
||||
live.join(timeout=1)
|
||||
|
||||
|
||||
def test_probe_payload_when_watcher_thread_dead():
|
||||
"""Probe returns 503 when watcher instance exists but thread has exited."""
|
||||
class _FakeWatcher:
|
||||
pass
|
||||
w = _FakeWatcher()
|
||||
t = threading.Thread(target=lambda: None)
|
||||
t.start()
|
||||
t.join() # wait for it to finish
|
||||
w._thread = t
|
||||
body, status = _gateway_sse_probe_payload({'show_cli_sessions': True}, watcher=w)
|
||||
assert status == 503
|
||||
assert body['watcher_running'] is False
|
||||
assert body['ok'] is False
|
||||
|
||||
|
||||
def test_gateway_watcher_is_alive_public_method():
|
||||
"""GatewayWatcher.is_alive() is the public API the probe uses. Cover all
|
||||
three states: before start(), while running, after stop()."""
|
||||
from api.gateway_watcher import GatewayWatcher
|
||||
w = GatewayWatcher()
|
||||
# Before start(): no thread
|
||||
assert w.is_alive() is False, "is_alive() must be False before start()"
|
||||
# After start(): thread running
|
||||
w.start()
|
||||
try:
|
||||
assert w.is_alive() is True, "is_alive() must be True while running"
|
||||
finally:
|
||||
w.stop()
|
||||
# After stop(): thread cleared
|
||||
assert w.is_alive() is False, "is_alive() must be False after stop()"
|
||||
|
||||
|
||||
def test_probe_payload_prefers_public_is_alive():
|
||||
"""Regression guard: _gateway_sse_probe_payload must call watcher.is_alive()
|
||||
rather than poking at _thread directly when the public method exists."""
|
||||
calls = []
|
||||
|
||||
class _WatcherWithPublicApi:
|
||||
def is_alive(self):
|
||||
calls.append('is_alive')
|
||||
return True
|
||||
# _thread is deliberately absent — must not be accessed.
|
||||
|
||||
body, status = _gateway_sse_probe_payload(
|
||||
{'show_cli_sessions': True},
|
||||
watcher=_WatcherWithPublicApi(),
|
||||
)
|
||||
assert status == 200
|
||||
assert body['watcher_running'] is True
|
||||
assert calls == ['is_alive'], (
|
||||
"probe must prefer the public is_alive() method over poking _thread"
|
||||
)
|
||||
61
tests/test_ime_composition.py
Normal file
61
tests/test_ime_composition.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
|
||||
BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _ime_guarded_enter_pattern(event_var_pattern, require_no_shift=False):
|
||||
no_shift = rf"\s*&&\s*!\s*{event_var_pattern}\.shiftKey" if require_no_shift else ""
|
||||
return (
|
||||
rf"if\s*\(\s*{event_var_pattern}\.key\s*===\s*'Enter'{no_shift}\s*\)\s*\{{\s*"
|
||||
rf"if\s*\(\s*{event_var_pattern}\.isComposing\s*\)\s*"
|
||||
rf"(?:\{{\s*return\s*;?\s*\}}|return\s*;?)"
|
||||
)
|
||||
|
||||
|
||||
def test_boot_chat_enter_send_respects_ime_composition():
|
||||
assert re.search(
|
||||
_ime_guarded_enter_pattern("e"),
|
||||
BOOT_JS,
|
||||
re.DOTALL,
|
||||
), "Chat composer Enter handler must ignore IME composition Enter in static/boot.js"
|
||||
assert re.search(
|
||||
_ime_guarded_enter_pattern("e", require_no_shift=True),
|
||||
BOOT_JS,
|
||||
re.DOTALL,
|
||||
), "Command dropdown Enter handler must ignore IME composition Enter in static/boot.js"
|
||||
|
||||
|
||||
def test_ui_enter_submit_paths_respect_ime_composition():
|
||||
assert re.search(
|
||||
rf"document\.addEventListener\('keydown',e=>\{{[\s\S]*?{_ime_guarded_enter_pattern('e')}",
|
||||
UI_JS,
|
||||
re.DOTALL,
|
||||
), \
|
||||
"App dialog Enter handler must ignore IME composition Enter in static/ui.js"
|
||||
assert re.search(
|
||||
_ime_guarded_enter_pattern("e", require_no_shift=True),
|
||||
UI_JS,
|
||||
re.DOTALL,
|
||||
), \
|
||||
"Message edit Enter-to-save handler must ignore IME composition Enter in static/ui.js"
|
||||
assert re.search(
|
||||
rf"inp\.onkeydown=\(e2\)=>\{{\s*{_ime_guarded_enter_pattern('e2')}",
|
||||
UI_JS,
|
||||
re.DOTALL,
|
||||
), \
|
||||
"Workspace rename Enter handler must ignore IME composition Enter in static/ui.js"
|
||||
|
||||
|
||||
def test_sessions_enter_submit_paths_respect_ime_composition():
|
||||
matches = re.findall(
|
||||
_ime_guarded_enter_pattern(r"e2?"),
|
||||
SESSIONS_JS,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert len(matches) >= 3, \
|
||||
"Session and project rename/create Enter handlers must ignore IME composition Enter in static/sessions.js"
|
||||
34
tests/test_issue341.py
Normal file
34
tests/test_issue341.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Tests for GitHub issue #341: .msg-body table CSS styles."""
|
||||
import os
|
||||
|
||||
CSS_PATH = os.path.join(os.path.dirname(__file__), "..", "static", "style.css")
|
||||
|
||||
|
||||
def _read_css():
|
||||
with open(CSS_PATH, "r") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def test_msg_body_table_css_present():
|
||||
css = _read_css()
|
||||
assert ".msg-body table" in css, ".msg-body table rule missing from style.css"
|
||||
assert "border-collapse:collapse" in css, "border-collapse:collapse missing from style.css"
|
||||
|
||||
|
||||
def test_msg_body_table_th_td_present():
|
||||
css = _read_css()
|
||||
assert ".msg-body th" in css, ".msg-body th rule missing from style.css"
|
||||
assert ".msg-body td" in css, ".msg-body td rule missing from style.css"
|
||||
|
||||
|
||||
def test_msg_body_table_tr_stripe_present():
|
||||
css = _read_css()
|
||||
assert ".msg-body tr:nth-child(even)" in css, ".msg-body tr:nth-child(even) rule missing from style.css"
|
||||
|
||||
|
||||
def test_msg_body_light_theme_overrides():
|
||||
css = _read_css()
|
||||
assert ':root:not(.dark) .msg-body th' in css, \
|
||||
'Light-mode override for .msg-body th missing from style.css'
|
||||
assert ':root:not(.dark) .msg-body td' in css, \
|
||||
'Light-mode override for .msg-body td missing from style.css'
|
||||
124
tests/test_issue342.py
Normal file
124
tests/test_issue342.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
Tests for GitHub issue #342: auto-link plain URLs in chat messages.
|
||||
|
||||
These are structural tests that verify the fix is present in static/ui.js
|
||||
without requiring a running server or JavaScript engine.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
|
||||
UI_JS = os.path.join(os.path.dirname(__file__), '..', 'static', 'ui.js')
|
||||
|
||||
|
||||
def read_ui_js():
|
||||
with open(UI_JS, 'r') as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def test_autolink_comment_present():
|
||||
"""The Autolink comment should be present in renderMd() to document the feature."""
|
||||
content = read_ui_js()
|
||||
assert 'Autolink: convert plain URLs' in content, (
|
||||
"Expected 'Autolink: convert plain URLs' comment not found in static/ui.js. "
|
||||
"Did the autolink pass get added?"
|
||||
)
|
||||
|
||||
|
||||
def test_autolink_regex_in_rendermd():
|
||||
"""The autolink regex pattern (https?://) should appear in renderMd()."""
|
||||
content = read_ui_js()
|
||||
# Locate the renderMd function body
|
||||
rendermd_start = content.find('function renderMd(raw){')
|
||||
assert rendermd_start != -1, "renderMd function not found in ui.js"
|
||||
# Find the closing brace after renderMd (look for the autolink pattern within it)
|
||||
rendermd_body = content[rendermd_start:rendermd_start + 5000]
|
||||
assert 'https?:\\/\\/' in rendermd_body, (
|
||||
"Autolink regex (https?:\\/\\/) not found inside renderMd() body."
|
||||
)
|
||||
|
||||
|
||||
def test_autolink_uses_esc_for_xss_safety():
|
||||
"""The autolink code must use esc() to escape the display text of URLs, preventing XSS.
|
||||
Note: esc() is intentionally NOT applied to the href value (that would corrupt & in
|
||||
query strings). It IS applied to the visible link text (esc(clean)) to prevent XSS."""
|
||||
content = read_ui_js()
|
||||
# Find the autolink section (between the SAFE_TAGS pass and paragraph wrap)
|
||||
autolink_idx = content.find('// Autolink: convert plain URLs')
|
||||
assert autolink_idx != -1, "Autolink comment not found in ui.js"
|
||||
# Extract the autolink block (next ~600 chars after the comment)
|
||||
autolink_block = content[autolink_idx:autolink_idx + 600]
|
||||
# esc() must be used on the visible link text to prevent XSS
|
||||
assert 'esc(clean)' in autolink_block, (
|
||||
"Autolink block should use esc(clean) for the link display text (XSS safety), "
|
||||
"but it was not found."
|
||||
)
|
||||
# esc() must NOT be used on the href value — that breaks URLs containing &
|
||||
assert 'href="${esc(clean)}"' not in autolink_block, (
|
||||
"Autolink block should use href=\"${clean}\" (not esc'd) to preserve & in query strings."
|
||||
)
|
||||
|
||||
|
||||
def test_autolink_in_inline_md():
|
||||
"""The autolink pass should also be present inside the inlineMd() helper."""
|
||||
content = read_ui_js()
|
||||
# Find inlineMd function
|
||||
inline_start = content.find('function inlineMd(t){')
|
||||
assert inline_start != -1, "inlineMd function not found in ui.js"
|
||||
# Find closing brace of inlineMd by looking for 'return t;' followed by '}'
|
||||
inline_end = content.find('return t;\n }', inline_start)
|
||||
assert inline_end != -1, "Could not locate end of inlineMd function"
|
||||
inline_body = content[inline_start:inline_end + 20]
|
||||
assert 'https?:\\/\\/' in inline_body, (
|
||||
"Autolink regex not found inside inlineMd() — plain URLs in list items "
|
||||
"and blockquotes won't be autolinked."
|
||||
)
|
||||
|
||||
|
||||
def test_autolink_after_safe_tags_pass():
|
||||
"""The autolink pass must come AFTER the SAFE_TAGS escape pass (ordering matters)."""
|
||||
content = read_ui_js()
|
||||
safe_tags_idx = content.find('s=s.replace(/<\\/?[a-z][^>]*>/gi,tag=>SAFE_TAGS.test(tag)?tag:esc(tag));')
|
||||
autolink_idx = content.find('// Autolink: convert plain URLs')
|
||||
parts_idx = content.find('const parts=s.split(/\\n{2,}/);')
|
||||
assert safe_tags_idx != -1, "SAFE_TAGS pass not found"
|
||||
assert autolink_idx != -1, "Autolink pass not found"
|
||||
assert parts_idx != -1, "Paragraph-wrap parts line not found"
|
||||
assert safe_tags_idx < autolink_idx < parts_idx, (
|
||||
f"Ordering wrong: SAFE_TAGS at {safe_tags_idx}, autolink at {autolink_idx}, "
|
||||
f"parts (paragraph wrap) at {parts_idx}. "
|
||||
"Autolink must come between SAFE_TAGS pass and paragraph wrap."
|
||||
)
|
||||
|
||||
|
||||
def test_autolink_target_blank_and_rel():
|
||||
"""Autolinked URLs should open in a new tab with rel=noopener for security."""
|
||||
content = read_ui_js()
|
||||
autolink_idx = content.find('// Autolink: convert plain URLs')
|
||||
assert autolink_idx != -1, "Autolink comment not found"
|
||||
# Use a larger window to account for the stash preamble added by the fix
|
||||
autolink_block = content[autolink_idx:autolink_idx + 700]
|
||||
assert 'target="_blank"' in autolink_block, (
|
||||
'Autolinked URLs should have target="_blank"'
|
||||
)
|
||||
assert 'rel="noopener"' in autolink_block, (
|
||||
'Autolinked URLs should have rel="noopener" for security'
|
||||
)
|
||||
|
||||
|
||||
def test_safe_tags_includes_anchor():
|
||||
"""SAFE_TAGS regex must include 'a' so <a> tags from autolink are not escaped."""
|
||||
content = read_ui_js()
|
||||
# Find the SAFE_TAGS definition line — the pattern contains slashes so we
|
||||
# search for the line directly rather than extracting the regex literal.
|
||||
safe_tags_line = None
|
||||
for line in content.splitlines():
|
||||
if 'const SAFE_TAGS=' in line:
|
||||
safe_tags_line = line
|
||||
break
|
||||
assert safe_tags_line is not None, "SAFE_TAGS const definition not found in ui.js"
|
||||
# The pattern should include 'a' as a tag alternative (e.g. |a|)
|
||||
assert '|a|' in safe_tags_line or '|a)' in safe_tags_line, (
|
||||
f"SAFE_TAGS line does not include 'a' tag — "
|
||||
"<a> tags emitted by autolink would be escaped!\n"
|
||||
f"Line: {safe_tags_line}"
|
||||
)
|
||||
348
tests/test_issue347.py
Normal file
348
tests/test_issue347.py
Normal file
@@ -0,0 +1,348 @@
|
||||
"""
|
||||
Tests for GitHub issue #347: KaTeX / LaTeX math rendering in chat and workspace previews.
|
||||
|
||||
Structural tests — no server required. Verify:
|
||||
- renderMd() stashes and restores $..$ and $$...$$ math delimiters
|
||||
- KaTeX lazy-load function exists and follows the mermaid pattern
|
||||
- KaTeX JS loaded from CDN with SRI integrity hash
|
||||
- KaTeX CSS loaded in index.html with SRI hash
|
||||
- CSS rules present for .katex-block and .katex-inline
|
||||
- SAFE_TAGS updated to allow <span> (for inline math)
|
||||
- renderKatexBlocks() is wired into the requestAnimationFrame call
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
REPO = pathlib.Path(__file__).parent.parent
|
||||
UI_JS = (REPO / 'static' / 'ui.js').read_text(encoding='utf-8')
|
||||
INDEX = (REPO / 'static' / 'index.html').read_text(encoding='utf-8')
|
||||
CSS = (REPO / 'static' / 'style.css').read_text(encoding='utf-8')
|
||||
|
||||
|
||||
# ── renderMd pipeline ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_display_math_stash_present():
|
||||
"""renderMd must stash $$...$$ display math before other processing."""
|
||||
assert r'\$\$([\s\S]+?)\$\$' in UI_JS or '$$' in UI_JS, \
|
||||
'Display math $$..$$ stash regex not found in ui.js'
|
||||
# The stash uses \\x00M token
|
||||
assert '\\x00M' in UI_JS, 'Math stash token \\x00M not found in renderMd'
|
||||
|
||||
|
||||
def test_inline_math_stash_present():
|
||||
"""renderMd must stash $..$ inline math."""
|
||||
# Inline math regex must be present
|
||||
assert 'math_stash' in UI_JS, 'math_stash array not found in renderMd'
|
||||
|
||||
|
||||
def test_katex_block_placeholder_emitted():
|
||||
"""renderMd restore pass must emit .katex-block divs for display math."""
|
||||
assert 'katex-block' in UI_JS, \
|
||||
'.katex-block placeholder div not emitted by renderMd restore pass'
|
||||
|
||||
|
||||
def test_katex_inline_placeholder_emitted():
|
||||
"""renderMd restore pass must emit .katex-inline spans for inline math."""
|
||||
assert 'katex-inline' in UI_JS, \
|
||||
'.katex-inline placeholder span not emitted by renderMd restore pass'
|
||||
|
||||
|
||||
def test_data_katex_attribute_present():
|
||||
"""Placeholders must carry data-katex attribute for display/inline distinction."""
|
||||
assert 'data-katex' in UI_JS, \
|
||||
'data-katex attribute not found — renderKatexBlocks cannot distinguish display from inline'
|
||||
|
||||
|
||||
# ── renderKatexBlocks() ────────────────────────────────────────────────────────
|
||||
|
||||
def test_render_katex_blocks_function_exists():
|
||||
"""renderKatexBlocks() function must exist in ui.js."""
|
||||
assert 'function renderKatexBlocks()' in UI_JS, \
|
||||
'renderKatexBlocks() function not found in ui.js'
|
||||
|
||||
|
||||
def test_katex_lazy_load_follows_mermaid_pattern():
|
||||
"""KaTeX must use the same lazy-load pattern as mermaid (load on first use)."""
|
||||
assert '_katexLoading' in UI_JS, '_katexLoading flag not found'
|
||||
assert '_katexReady' in UI_JS, '_katexReady flag not found'
|
||||
|
||||
|
||||
def test_katex_js_loaded_from_cdn():
|
||||
"""KaTeX JS must be loaded from jsdelivr CDN."""
|
||||
assert 'katex@0.16' in UI_JS, \
|
||||
'KaTeX JS CDN URL not found in ui.js — expected katex@0.16.x'
|
||||
|
||||
|
||||
def test_katex_js_has_sri_hash():
|
||||
"""KaTeX JS CDN tag must have an SRI integrity hash."""
|
||||
# The hash is in the script.integrity assignment
|
||||
assert "script.integrity='sha384-" in UI_JS or 'script.integrity="sha384-' in UI_JS, \
|
||||
'KaTeX JS SRI integrity hash not found in ui.js'
|
||||
|
||||
|
||||
def test_katex_display_mode_used():
|
||||
"""renderKatexBlocks must pass displayMode based on data-katex attribute."""
|
||||
assert 'displayMode' in UI_JS, \
|
||||
'displayMode not passed to katex.render() — display math will render inline'
|
||||
|
||||
|
||||
def test_katex_throw_on_error_false():
|
||||
"""KaTeX must be configured with throwOnError:false to degrade gracefully."""
|
||||
assert 'throwOnError:false' in UI_JS, \
|
||||
'throwOnError:false not set — bad LaTeX will throw and break the message'
|
||||
|
||||
|
||||
def test_render_katex_blocks_wired_into_raf():
|
||||
"""renderKatexBlocks() must be called in the same requestAnimationFrame as renderMermaidBlocks()."""
|
||||
# Check that renderKatexBlocks appears somewhere near requestAnimationFrame
|
||||
raf_idx = UI_JS.find('requestAnimationFrame')
|
||||
# Find the rAF call that also contains renderKatexBlocks
|
||||
has_katex_in_raf = any(
|
||||
'renderKatexBlocks' in UI_JS[m.start():m.start()+200]
|
||||
for m in re.finditer(r'requestAnimationFrame', UI_JS)
|
||||
)
|
||||
assert has_katex_in_raf, \
|
||||
'renderKatexBlocks() not found in any requestAnimationFrame call — math will not render'
|
||||
|
||||
|
||||
# ── index.html ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_katex_css_in_index_html():
|
||||
"""KaTeX CSS must be loaded in index.html."""
|
||||
assert 'katex@0.16' in INDEX, \
|
||||
'KaTeX CSS CDN link not found in index.html'
|
||||
|
||||
|
||||
def test_katex_css_has_sri_hash():
|
||||
"""KaTeX CSS link in index.html must have an SRI integrity hash."""
|
||||
assert 'sha384-5TcZemv2l' in INDEX or 'integrity' in INDEX and 'katex' in INDEX, \
|
||||
'KaTeX CSS SRI integrity hash not found in index.html'
|
||||
|
||||
|
||||
# ── style.css ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_katex_block_css_present():
|
||||
""".katex-block CSS rule must exist for centered display math."""
|
||||
assert '.katex-block' in CSS, \
|
||||
'.katex-block CSS rule missing from style.css — display math will have no layout'
|
||||
|
||||
|
||||
def test_katex_inline_css_present():
|
||||
""".katex-inline CSS rule must exist."""
|
||||
assert '.katex-inline' in CSS, \
|
||||
'.katex-inline CSS rule missing from style.css'
|
||||
|
||||
|
||||
def test_katex_block_text_align_center():
|
||||
""".katex-block must be text-align:center for display math."""
|
||||
assert 'text-align:center' in CSS, \
|
||||
'text-align:center not found for .katex-block'
|
||||
|
||||
|
||||
# ── SAFE_TAGS ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_safe_tags_includes_span():
|
||||
"""SAFE_TAGS must include <span> to allow .katex-inline spans through the escape pass."""
|
||||
# The SAFE_TAGS regex should contain 'span'
|
||||
safe_tags_match = re.search(r'SAFE_TAGS\s*=\s*/.*?/i', UI_JS)
|
||||
assert safe_tags_match, 'SAFE_TAGS pattern not found in ui.js'
|
||||
assert 'span' in safe_tags_match.group(), \
|
||||
'<span> not in SAFE_TAGS — inline math spans will be HTML-escaped and rendered as text'
|
||||
|
||||
|
||||
# ── Stash ordering: fence must protect code spans from math extraction ─────────
|
||||
|
||||
WORKSPACE_JS = (REPO / 'static' / 'workspace.js').read_text(encoding='utf-8')
|
||||
|
||||
|
||||
def test_fence_stash_before_math_stash():
|
||||
"""fence_stash must be initialized and populated BEFORE math_stash in renderMd.
|
||||
|
||||
If math_stash runs first, dollar signs inside backtick code spans are extracted
|
||||
as math, leaving placeholder tokens inside the stashed code string. The code span
|
||||
then renders with KaTeX inside <code> instead of the literal dollar-sign text.
|
||||
"""
|
||||
fence_pos = UI_JS.find("const fence_stash=[]")
|
||||
math_pos = UI_JS.find("const math_stash=[]")
|
||||
assert fence_pos != -1, "fence_stash not found in renderMd"
|
||||
assert math_pos != -1, "math_stash not found in renderMd"
|
||||
assert fence_pos < math_pos, (
|
||||
"fence_stash must be declared BEFORE math_stash in renderMd "
|
||||
f"(fence at char {fence_pos}, math at char {math_pos}). "
|
||||
"If math runs first, `$x$` inside backticks gets extracted as math instead of code."
|
||||
)
|
||||
|
||||
|
||||
def test_fence_stash_populated_before_math_stash():
|
||||
"""The fence_stash s.replace call must appear before any math_stash s.replace calls."""
|
||||
# Find the s.replace call that populates each stash
|
||||
fence_replace_pos = UI_JS.find("fence_stash.push(m)")
|
||||
math_replace_pos = UI_JS.find("math_stash.push(")
|
||||
assert fence_replace_pos != -1, "fence_stash population call not found"
|
||||
assert math_replace_pos != -1, "math_stash population call not found"
|
||||
assert fence_replace_pos < math_replace_pos, (
|
||||
"fence_stash must be populated before math_stash to protect code span contents"
|
||||
)
|
||||
|
||||
|
||||
def test_math_stash_comment_says_after_fence():
|
||||
"""The math stash comment should explain it runs AFTER fence_stash, not before."""
|
||||
# Should not have the old misleading comment
|
||||
assert "Must run BEFORE fence_stash" not in UI_JS, (
|
||||
"Old misleading comment still present. Math stash runs AFTER fence_stash. "
|
||||
"The comment should say 'Runs AFTER fence_stash'."
|
||||
)
|
||||
|
||||
|
||||
# ── Pipeline regression: code spans protect their contents ────────────────────
|
||||
|
||||
def test_math_restore_after_fence_restore():
|
||||
"""Math stash tokens are restored AFTER fence restore, so code spans get
|
||||
their raw text back (not KaTeX placeholders)."""
|
||||
fence_restore_pos = UI_JS.find("fence_stash[+i]")
|
||||
math_restore_pos = UI_JS.find("math_stash[+i]")
|
||||
assert fence_restore_pos != -1, "fence_stash restore not found"
|
||||
assert math_restore_pos != -1, "math_stash restore not found"
|
||||
# Both restores must exist; their relative order doesn't matter for correctness
|
||||
# (they use different tokens: \x00F vs \x00M), but we assert both exist
|
||||
assert fence_restore_pos != math_restore_pos, "fence and math restore must be separate calls"
|
||||
|
||||
|
||||
def test_stash_tokens_distinct():
|
||||
"""fence_stash and math_stash must use distinct sentinel tokens to avoid collisions."""
|
||||
# fence uses \x00F, math uses \x00M (or similar unique prefix)
|
||||
# The JS source uses escaped \\x00F and \\x00M as sentinel characters
|
||||
# In the Python string read from the file these appear as '\\\\x00F' and '\\\\x00M'
|
||||
assert "'\\\\x00F'" in UI_JS or 'x00F' in UI_JS, (
|
||||
"fence stash token (\\x00F) not found — must be distinct from math token"
|
||||
)
|
||||
assert "'\\\\x00M'" in UI_JS or 'x00M' in UI_JS, (
|
||||
"math stash token (\\x00M) not found — must be distinct from fence token"
|
||||
)
|
||||
# The two tokens must use different discriminator characters
|
||||
assert 'x00F' in UI_JS and 'x00M' in UI_JS, (
|
||||
"Both \\x00F (fence) and \\x00M (math) tokens must exist"
|
||||
)
|
||||
|
||||
|
||||
# ── Workspace preview renderKatexBlocks wiring ────────────────────────────────
|
||||
|
||||
def test_workspace_calls_render_katex_after_preview():
|
||||
"""workspace.js must call renderKatexBlocks() after setting previewMd.innerHTML.
|
||||
|
||||
Without this, math placeholders appear in workspace file previews but are never
|
||||
rendered by KaTeX (renderKatexBlocks is only wired into renderMessages rAF).
|
||||
"""
|
||||
assert "renderKatexBlocks" in WORKSPACE_JS, (
|
||||
"workspace.js must call renderKatexBlocks() after renderMd() for file previews"
|
||||
)
|
||||
|
||||
|
||||
def test_workspace_renders_katex_after_file_open():
|
||||
"""workspace.js renderKatexBlocks call must come after the renderMd(data.content) assignment."""
|
||||
preview_md_pos = WORKSPACE_JS.find("renderMd(data.content)")
|
||||
# Use the actual call string (not a stray regex match on 'M' characters)
|
||||
katex_call_str = "renderKatexBlocks==='function'"
|
||||
katex_call_pos = WORKSPACE_JS.find(katex_call_str)
|
||||
assert preview_md_pos != -1, "renderMd(data.content) not found in workspace.js"
|
||||
assert katex_call_pos != -1, (
|
||||
"renderKatexBlocks guard (typeof renderKatexBlocks==='function') not found in workspace.js"
|
||||
)
|
||||
# The call after 'renderMd(data.content)' — find the LAST occurrence
|
||||
# (there may be an earlier one in the save path at line ~153)
|
||||
last_katex_pos = WORKSPACE_JS.rfind(katex_call_str)
|
||||
assert last_katex_pos > preview_md_pos, (
|
||||
"renderKatexBlocks must be called AFTER renderMd(data.content) in workspace.js "
|
||||
f"(renderMd at {preview_md_pos}, last renderKatexBlocks at {last_katex_pos})"
|
||||
)
|
||||
|
||||
|
||||
def test_workspace_katex_guarded_by_typeof():
|
||||
"""workspace.js renderKatexBlocks call must guard with typeof check for safety
|
||||
in case KaTeX feature is not loaded (e.g. test environments, offline)."""
|
||||
assert "typeof renderKatexBlocks" in WORKSPACE_JS, (
|
||||
"workspace.js must guard renderKatexBlocks call with typeof check: "
|
||||
"if(typeof renderKatexBlocks==='function')renderKatexBlocks()"
|
||||
)
|
||||
|
||||
|
||||
# ── SAFE_TAGS: span addition should not expand attack surface ─────────────────
|
||||
|
||||
def test_safe_tags_span_is_narrowly_scoped():
|
||||
"""SAFE_TAGS adding <span> is only a bypass if span carries dangerous attributes.
|
||||
Verify the SAFE_TAGS regex tests the tag NAME only, not arbitrary attributes.
|
||||
The rest of the pipeline uses esc() for user content, so attribute injection
|
||||
into KaTeX spans isn't possible.
|
||||
"""
|
||||
# The SAFE_TAGS regex must still require a word boundary / tag-end pattern
|
||||
safe_tags_match = re.search(r"SAFE_TAGS\s*=\s*/(.+?)/i", UI_JS)
|
||||
if not safe_tags_match:
|
||||
safe_tags_match = re.search(r'SAFE_TAGS\s*=\s*/(.*?)/i', UI_JS)
|
||||
assert safe_tags_match, "SAFE_TAGS regex not found"
|
||||
pattern = safe_tags_match.group(1)
|
||||
# Must have a trailing boundary check — ([\s>]|$) or similar
|
||||
assert r"[\s>]" in pattern or r'[\s>]' in pattern, (
|
||||
"SAFE_TAGS must enforce a boundary after the tag name to prevent "
|
||||
"<spanxss> from matching when checking for <span>"
|
||||
)
|
||||
|
||||
|
||||
# ── False-positive prevention ─────────────────────────────────────────────────
|
||||
|
||||
def test_inline_math_regex_requires_non_space_boundaries():
|
||||
"""The $...$ inline regex must require non-space at both boundaries.
|
||||
|
||||
This prevents 'costs $5 and $10' from matching — the space after the opening
|
||||
$ means it's a currency amount, not math.
|
||||
"""
|
||||
# The inline math stash push is type:'inline' — find its containing replace() line
|
||||
inline_push_idx = UI_JS.find("type:'inline',src:m")
|
||||
assert inline_push_idx != -1, "Inline math stash push not found"
|
||||
# Get the text from the start of that line back to find the regex
|
||||
line_start = UI_JS.rfind('\n', 0, inline_push_idx) + 1
|
||||
inline_line = UI_JS[line_start:inline_push_idx + 50]
|
||||
# The regex must use \s (via [^\s...]) to exclude spaces at boundaries
|
||||
assert '\\s' in inline_line or '[^' in inline_line, (
|
||||
f"Inline math regex must exclude spaces at boundaries to prevent false "
|
||||
f"positives on currency like $5. Found: {inline_line[:120]}"
|
||||
)
|
||||
def test_display_math_stashed_before_inline():
|
||||
"""$$...$$ display math must be stashed before $...$ inline math.
|
||||
|
||||
If inline runs first on '$$x$$', it could match '$' + 'x' + '$' leaving
|
||||
a stray outer '$', corrupting the output.
|
||||
"""
|
||||
display_pos = UI_JS.find("type:'display',src:m")
|
||||
inline_pos = UI_JS.find("type:'inline',src:m")
|
||||
assert display_pos != -1, "display math stash not found"
|
||||
assert inline_pos != -1, "inline math stash not found"
|
||||
# First occurrence of display must be before first occurrence of inline
|
||||
assert display_pos < inline_pos, (
|
||||
"Display math ($$...$$) must be stashed before inline math ($...$) "
|
||||
"to prevent $$ from being parsed as two adjacent inline delimiters"
|
||||
)
|
||||
|
||||
|
||||
def test_math_stash_token_uses_single_backslash_null_byte():
|
||||
"""Math stash tokens must use the null-byte form (single backslash x00M).
|
||||
|
||||
The restore regex expects a null byte character. If the stash emits
|
||||
a literal backslash+x00M (double backslash = 5-char string), the restore
|
||||
regex never matches and the tokens appear verbatim in the rendered output.
|
||||
|
||||
The fence_stash correctly uses the null byte convention. Math stash must be consistent.
|
||||
"""
|
||||
# In the source file, the correct form is: return '\x00M'
|
||||
# The wrong form (double backslash) would be: return '\\x00M'
|
||||
# Check that no double-backslash form exists in the math stash return statements
|
||||
import re
|
||||
bad_returns = re.findall(r"return\s+'\\\\x00M'", UI_JS)
|
||||
assert not bad_returns, (
|
||||
f"Found {len(bad_returns)} math stash return(s) using double-backslash \\\\x00M. "
|
||||
"Must use single backslash '\x00M' (null byte) to match the restore regex."
|
||||
)
|
||||
# Positive check: single-backslash form must exist
|
||||
good_returns = re.findall(r"math_stash\.push.*?return '\\x00M'", UI_JS, re.DOTALL)
|
||||
assert good_returns, (
|
||||
"Math stash return must use single-backslash '\x00M' (null byte convention)"
|
||||
)
|
||||
202
tests/test_issue357.py
Normal file
202
tests/test_issue357.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Tests for GitHub issue #357: Docker container fails to start without internet access.
|
||||
|
||||
Structural tests — verify Dockerfile and docker_init.bash contain the expected
|
||||
patterns for pre-installed uv and workspace permission fixes.
|
||||
|
||||
Two problems fixed:
|
||||
1. uv was downloaded at container startup; fails in air-gapped / firewalled environments.
|
||||
Fix: pre-install uv in the Docker image at build time (system-wide in /usr/local/bin).
|
||||
2. workspace directory created with plain mkdir (as root); bind-mount dirs created by
|
||||
Docker as root are unwritable by the hermeswebui user.
|
||||
Fix: sudo mkdir + sudo chown for workspace directory.
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
REPO = pathlib.Path(__file__).parent.parent
|
||||
DOCKERFILE = (REPO / "Dockerfile").read_text(encoding="utf-8")
|
||||
INIT_SCRIPT = (REPO / "docker_init.bash").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ── Dockerfile: uv pre-installed at build time ───────────────────────────────
|
||||
|
||||
class TestDockerfileUvPreinstall:
|
||||
|
||||
def test_dockerfile_installs_uv_at_build_time(self):
|
||||
"""Dockerfile must install uv via RUN curl at build time (not only at runtime)."""
|
||||
assert "RUN curl" in DOCKERFILE and "uv/install.sh" in DOCKERFILE, (
|
||||
"Dockerfile must install uv at build time via RUN curl .../uv/install.sh"
|
||||
)
|
||||
|
||||
def test_dockerfile_uv_installed_system_wide(self):
|
||||
"""uv must be installed to a system-wide directory (/usr/local/bin) accessible
|
||||
to all users, not to a user-specific ~/.local/bin that another user can't see."""
|
||||
# The install command must target /usr/local/bin or use root to install globally
|
||||
uv_install_line = next(
|
||||
(line for line in DOCKERFILE.splitlines() if "uv/install.sh" in line),
|
||||
None,
|
||||
)
|
||||
assert uv_install_line is not None, "Could not find uv install line in Dockerfile"
|
||||
# Must either use UV_INSTALL_DIR pointing to /usr/local/bin, or run as root
|
||||
# (so the default install location is accessible to hermeswebui user)
|
||||
has_system_dir = "/usr/local/bin" in uv_install_line or "UV_INSTALL_DIR=/usr/local/bin" in DOCKERFILE
|
||||
assert has_system_dir, (
|
||||
"uv must be installed to /usr/local/bin (system-wide) so hermeswebui user "
|
||||
"can find it. Installing as hermeswebuitoo puts it in /home/hermeswebuitoo/.local/bin "
|
||||
"which is NOT on hermeswebui's PATH."
|
||||
)
|
||||
|
||||
def test_dockerfile_uv_installed_before_copy(self):
|
||||
"""uv installation must happen before COPY . /apptoo so it's in the image."""
|
||||
import re
|
||||
uv_pos = DOCKERFILE.find("uv/install.sh")
|
||||
# Match COPY regardless of flags (e.g. --chown=...) — only the destination matters.
|
||||
m = re.search(r"^COPY\b.*\s/apptoo\b", DOCKERFILE, re.MULTILINE)
|
||||
assert uv_pos != -1, "uv install not found in Dockerfile"
|
||||
assert m is not None, "COPY ... /apptoo not found in Dockerfile"
|
||||
copy_pos = m.start()
|
||||
assert uv_pos < copy_pos, "uv must be installed before COPY . /apptoo"
|
||||
|
||||
def test_dockerfile_uv_installed_as_root_or_before_user_switch(self):
|
||||
"""uv must be installed as root (USER root) to reach /usr/local/bin.
|
||||
If installed as hermeswebuitoo, it lands in ~hermeswebuitoo/.local/bin,
|
||||
which the hermeswebui user at runtime can't see.
|
||||
"""
|
||||
lines = DOCKERFILE.splitlines()
|
||||
uv_line_idx = next(i for i, l in enumerate(lines) if "uv/install.sh" in l)
|
||||
# Find the last USER directive before the uv install line
|
||||
user_before = None
|
||||
for i in range(uv_line_idx - 1, -1, -1):
|
||||
if lines[i].strip().startswith("USER "):
|
||||
user_before = lines[i].strip().split()[1]
|
||||
break
|
||||
assert user_before == "root", (
|
||||
f"uv install must run as USER root (found USER {user_before!r}). "
|
||||
"Installing as hermeswebuitoo puts uv in /home/hermeswebuitoo/.local/bin "
|
||||
"which is not accessible to the hermeswebui runtime user."
|
||||
)
|
||||
|
||||
|
||||
# ── docker_init.bash: skip uv download when already present ─────────────────
|
||||
|
||||
class TestInitScriptUvSkip:
|
||||
|
||||
def test_init_script_checks_uv_before_download(self):
|
||||
"""docker_init.bash must check 'command -v uv' before attempting download."""
|
||||
assert "command -v uv" in INIT_SCRIPT, (
|
||||
"docker_init.bash must check 'command -v uv' to skip download "
|
||||
"when uv is already pre-installed in the image (#357)"
|
||||
)
|
||||
|
||||
def test_init_script_skips_download_if_present(self):
|
||||
"""Init script must use conditional logic (if/else) around the uv download."""
|
||||
# Pattern: if command -v uv ... else ... fi
|
||||
assert re.search(r'if\s+command\s+-v\s+uv', INIT_SCRIPT), (
|
||||
"docker_init.bash must use 'if command -v uv' guard around the download"
|
||||
)
|
||||
|
||||
def test_init_script_curl_download_in_else_branch(self):
|
||||
"""The curl download must be in the else branch (only runs if uv not found)."""
|
||||
# Find the conditional block
|
||||
m = re.search(
|
||||
r'if\s+command\s+-v\s+uv.*?fi',
|
||||
INIT_SCRIPT, re.DOTALL
|
||||
)
|
||||
assert m, "Could not find uv conditional block in docker_init.bash"
|
||||
block = m.group(0)
|
||||
# curl must appear after 'else' not in the 'then' branch
|
||||
else_pos = block.find("else")
|
||||
curl_pos = block.find("curl")
|
||||
assert else_pos != -1, "No 'else' branch in uv conditional"
|
||||
assert curl_pos != -1, "No 'curl' in uv conditional block"
|
||||
assert curl_pos > else_pos, (
|
||||
"curl download must be in the 'else' branch, not the 'if/then' branch"
|
||||
)
|
||||
|
||||
def test_init_script_error_exit_on_download_failure(self):
|
||||
"""Curl download must call error_exit on failure (not silently continue)."""
|
||||
assert "error_exit" in INIT_SCRIPT and "Failed to install uv" in INIT_SCRIPT, (
|
||||
"docker_init.bash must call error_exit if uv download fails, "
|
||||
"so the container exits with a clear message instead of failing silently"
|
||||
)
|
||||
|
||||
def test_init_script_path_includes_hermeswebui_local_bin(self):
|
||||
"""PATH must include /home/hermeswebui/.local/bin for fallback runtime install."""
|
||||
assert "/home/hermeswebui/.local/bin" in INIT_SCRIPT, (
|
||||
"docker_init.bash must include /home/hermeswebui/.local/bin in PATH "
|
||||
"for the case where uv is installed at runtime via curl"
|
||||
)
|
||||
|
||||
|
||||
# ── docker_init.bash: workspace directory permissions ────────────────────────
|
||||
|
||||
class TestWorkspacePermissions:
|
||||
|
||||
def test_workspace_uses_sudo_mkdir(self):
|
||||
"""docker_init.bash must use 'sudo mkdir' for the workspace directory.
|
||||
|
||||
Docker auto-creates bind-mount directories as root if they don't exist,
|
||||
leaving them unwritable by hermeswebui. sudo mkdir + chown fixes this.
|
||||
"""
|
||||
# Find the workspace section
|
||||
ws_section = INIT_SCRIPT[
|
||||
INIT_SCRIPT.find("HERMES_WEBUI_DEFAULT_WORKSPACE"):
|
||||
INIT_SCRIPT.find("HERMES_WEBUI_DEFAULT_WORKSPACE") + 800
|
||||
]
|
||||
assert "sudo mkdir" in ws_section, (
|
||||
"docker_init.bash must use 'sudo mkdir -p' for the workspace directory "
|
||||
"to handle the case where Docker created the bind-mount dir as root (#357)"
|
||||
)
|
||||
|
||||
def test_workspace_uses_sudo_chown(self):
|
||||
"""docker_init.bash must chown the workspace to hermeswebui when writable.
|
||||
|
||||
The chown is now conditional on the workspace being writable, to allow
|
||||
read-only (:ro) workspace mounts without crashing (#670). The sudo chown
|
||||
must still be present in the script (just guarded by [ -w ]).
|
||||
"""
|
||||
assert 'sudo chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE"' in INIT_SCRIPT, (
|
||||
"docker_init.bash must 'sudo chown hermeswebui:hermeswebui' the workspace "
|
||||
"when it is writable, so the app user can write to it (#357)"
|
||||
)
|
||||
|
||||
def test_workspace_mkdir_before_chown(self):
|
||||
"""sudo mkdir must come before sudo chown in docker_init.bash."""
|
||||
mkdir_pos = INIT_SCRIPT.find('sudo mkdir -p "$HERMES_WEBUI_DEFAULT_WORKSPACE"')
|
||||
chown_pos = INIT_SCRIPT.find('sudo chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE"')
|
||||
assert mkdir_pos != -1, "sudo mkdir for workspace not found"
|
||||
assert chown_pos != -1, "sudo chown for workspace not found"
|
||||
assert mkdir_pos < chown_pos, "sudo mkdir must come before sudo chown"
|
||||
|
||||
def test_workspace_error_exit_on_mkdir_failure(self):
|
||||
"""sudo mkdir must call error_exit on failure."""
|
||||
assert 'sudo mkdir -p "$HERMES_WEBUI_DEFAULT_WORKSPACE" || error_exit' in INIT_SCRIPT, (
|
||||
"sudo mkdir for workspace must call error_exit on failure"
|
||||
)
|
||||
|
||||
def test_workspace_chown_is_conditional_on_writable(self):
|
||||
"""chown and write-test must be skipped for read-only workspace mounts (#670).
|
||||
|
||||
The script must check [ -w "$HERMES_WEBUI_DEFAULT_WORKSPACE" ] before
|
||||
attempting chown or a write test, so :ro bind-mounts don't crash startup.
|
||||
"""
|
||||
assert '[ -w "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]' in INIT_SCRIPT, (
|
||||
"docker_init.bash must guard chown with [ -w ] to support read-only "
|
||||
"workspace mounts (:ro) without crashing (#670)"
|
||||
)
|
||||
# Read-only path must log a clear message rather than calling error_exit
|
||||
assert "read-only workspace is supported" in INIT_SCRIPT, (
|
||||
"docker_init.bash must print a clear message when workspace is read-only (#670)"
|
||||
)
|
||||
|
||||
def test_init_script_syntax_valid(self):
|
||||
"""docker_init.bash must pass bash -n syntax check."""
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
["bash", "-n", str(REPO / "docker_init.bash")],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0, (
|
||||
f"docker_init.bash failed bash -n syntax check:\n{result.stderr}"
|
||||
)
|
||||
114
tests/test_issue401.py
Normal file
114
tests/test_issue401.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Regression tests for tool-card persistence on session reload.
|
||||
|
||||
The older loadSession() path rewrote message history on the client:
|
||||
- dropped role='tool' rows
|
||||
- dropped empty assistant rows even when they carried tool_calls
|
||||
- then ignored session.tool_calls on reload
|
||||
|
||||
That broke both durable logging and page refresh for valid tool runs.
|
||||
"""
|
||||
import json
|
||||
import pathlib
|
||||
import subprocess
|
||||
import textwrap
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
|
||||
SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_loadsession_preserves_tool_rows():
|
||||
"""Reload must keep tool rows in S.messages so snippets can be reconstructed."""
|
||||
assert "if (m.role === 'tool') continue;" not in SESSIONS_JS, (
|
||||
"loadSession() must not drop role='tool' messages; renderMessages() hides them "
|
||||
"visually, but it still needs them for snippet reconstruction"
|
||||
)
|
||||
|
||||
|
||||
def test_loadsession_uses_session_toolcalls_only_as_fallback():
|
||||
"""Session summaries are the fallback, not the primary reload source."""
|
||||
assert "if(!hasMessageToolMetadata&&data.session.tool_calls&&data.session.tool_calls.length)" in SESSIONS_JS
|
||||
assert "S.toolCalls=(data.session.tool_calls||[]).map(tc=>({...tc,done:true}));" in SESSIONS_JS
|
||||
assert "S.toolCalls=[];" in SESSIONS_JS
|
||||
|
||||
|
||||
def test_rendermessages_treats_openai_toolcall_assistants_as_visible():
|
||||
"""OpenAI assistant rows with empty content but tool_calls must stay anchorable."""
|
||||
assert "const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;" in UI_JS
|
||||
assert "if(hasTc||hasTu||_messageHasReasoningPayload(m)) return true;" in UI_JS
|
||||
|
||||
|
||||
def _run_js(script_body: str) -> dict:
|
||||
script = textwrap.dedent(f"""
|
||||
function loadSessionShape(messages, sessionToolCalls) {{
|
||||
const filtered = (messages || []).filter(m => m && m.role);
|
||||
const hasMessageToolMetadata = filtered.some(m => {{
|
||||
if (!m || m.role !== 'assistant') return false;
|
||||
const hasTc = Array.isArray(m.tool_calls) && m.tool_calls.length > 0;
|
||||
const hasTu = Array.isArray(m.content) && m.content.some(p => p && p.type === 'tool_use');
|
||||
return hasTc || hasTu;
|
||||
}});
|
||||
const toolCalls = (!hasMessageToolMetadata && sessionToolCalls && sessionToolCalls.length)
|
||||
? sessionToolCalls.map(tc => ({{ ...tc, done: true }}))
|
||||
: [];
|
||||
return {{ filtered, hasMessageToolMetadata, toolCalls }};
|
||||
}}
|
||||
|
||||
{script_body}
|
||||
""")
|
||||
proc = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_reload_keeps_empty_assistant_toolcall_anchor():
|
||||
"""OpenAI-style assistant {content:'', tool_calls:[...]} must survive reload."""
|
||||
result = _run_js("""
|
||||
const messages = [
|
||||
{ role: 'user', content: 'list files' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [{ id: 'call-1', function: { name: 'terminal', arguments: '{}' } }]
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call-1', content: '{"output":"ok"}' },
|
||||
{ role: 'assistant', content: 'Done.' }
|
||||
];
|
||||
const loaded = loadSessionShape(messages, [{ name: 'terminal', assistant_msg_idx: 1 }]);
|
||||
process.stdout.write(JSON.stringify({
|
||||
filtered_len: loaded.filtered.length,
|
||||
has_metadata: loaded.hasMessageToolMetadata,
|
||||
fallback_len: loaded.toolCalls.length,
|
||||
assistant_tool_idx: loaded.filtered.findIndex(m => m.role === 'assistant' && m.tool_calls),
|
||||
tool_idx: loaded.filtered.findIndex(m => m.role === 'tool')
|
||||
}));
|
||||
""")
|
||||
assert result["filtered_len"] == 4
|
||||
assert result["has_metadata"] is True
|
||||
assert result["fallback_len"] == 0
|
||||
assert result["assistant_tool_idx"] == 1
|
||||
assert result["tool_idx"] == 2
|
||||
|
||||
|
||||
def test_reload_uses_session_summary_when_messages_have_no_tool_metadata():
|
||||
"""Older sessions should still render from session.tool_calls on reload."""
|
||||
result = _run_js("""
|
||||
const messages = [
|
||||
{ role: 'user', content: 'build site' },
|
||||
{ role: 'assistant', content: 'Starting.' },
|
||||
{ role: 'tool', content: '{"bytes_written": 4955}' },
|
||||
{ role: 'assistant', content: '' }
|
||||
];
|
||||
const sessionToolCalls = [
|
||||
{ name: 'write_file', assistant_msg_idx: 1, snippet: 'bytes_written', tid: '' }
|
||||
];
|
||||
const loaded = loadSessionShape(messages, sessionToolCalls);
|
||||
process.stdout.write(JSON.stringify({
|
||||
has_metadata: loaded.hasMessageToolMetadata,
|
||||
fallback_len: loaded.toolCalls.length,
|
||||
done_flag: loaded.toolCalls[0] && loaded.toolCalls[0].done === true
|
||||
}));
|
||||
""")
|
||||
assert result["has_metadata"] is False
|
||||
assert result["fallback_len"] == 1
|
||||
assert result["done_flag"] is True
|
||||
313
tests/test_issue470.py
Normal file
313
tests/test_issue470.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
Tests for issue #470 — markdown link rendering bugs in renderMd():
|
||||
1. Double-linking: [label](url) converted to <a>, then autolink re-matches
|
||||
the URL inside href="..." and wraps it in a second <a>.
|
||||
2. esc() applied to URLs in href attributes turns & → &, breaking
|
||||
URLs with query strings and producing & in displayed link text.
|
||||
3. Same double-linking bug inside table cells via inlineMd().
|
||||
|
||||
These tests verify the fixes by asserting against the rendered HTML that
|
||||
ui.js serves, using a live server request to evaluate the actual JS output
|
||||
indirectly (via checking ui.js source for the fixed patterns) AND by
|
||||
running a lightweight Python mirror of the fixed renderMd logic.
|
||||
|
||||
Strategy: verify the fix is present in the JS source, then test the
|
||||
expected rendering behaviour through the Python mirror.
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
import html as _html
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent
|
||||
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text()
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def esc(s):
|
||||
return _html.escape(str(s), quote=True)
|
||||
|
||||
|
||||
def _make_link(url, label):
|
||||
"""Expected output for a [label](url) link after fix: href is NOT esc()-ed."""
|
||||
return f'<a href="{url}" target="_blank" rel="noopener">{esc(label)}</a>'
|
||||
|
||||
|
||||
# Minimal Python mirror of the FIXED renderMd() — enough to test link behaviour.
|
||||
# Mirrors the stash-based approach introduced by the fix.
|
||||
|
||||
def render_links_only(text):
|
||||
"""
|
||||
Simplified render that only applies the link-related passes from the fixed
|
||||
renderMd(): [label](url) conversion + autolink, with the stash protection.
|
||||
Sufficient for testing that links render correctly without double-linking.
|
||||
"""
|
||||
s = text
|
||||
|
||||
# Stash [label](url) links (fix: store href as raw URL, not esc(url))
|
||||
link_stash = []
|
||||
def stash_link(m):
|
||||
label, url = m.group(1), m.group(2)
|
||||
link_stash.append(f'<a href="{url}" target="_blank" rel="noopener">{esc(label)}</a>')
|
||||
return f'\x00L{len(link_stash)-1}\x00'
|
||||
s = re.sub(r'\[([^\]]+)\]\((https?://[^\)]+)\)', stash_link, s)
|
||||
|
||||
# Autolink bare URLs (should NOT match inside already-stashed placeholders)
|
||||
def autolink(m):
|
||||
url = m.group(1)
|
||||
trail = url[-1] if url[-1] in '.,;:!?)' else ''
|
||||
clean = url[:-1] if trail else url
|
||||
return f'<a href="{clean}" target="_blank" rel="noopener">{esc(clean)}</a>{trail}'
|
||||
s = re.sub(r'(https?://[^\s<>"\')\]]+)', autolink, s)
|
||||
|
||||
# Restore stashed links
|
||||
s = re.sub(r'\x00L(\d+)\x00', lambda m: link_stash[int(m.group(1))], s)
|
||||
return s
|
||||
|
||||
|
||||
def render_table_with_links(md):
|
||||
"""
|
||||
Render a markdown table that may contain [label](url) cells.
|
||||
Mirrors the fixed inlineMd() + table rendering.
|
||||
"""
|
||||
lines = md.strip().split('\n')
|
||||
if len(lines) < 2:
|
||||
return md
|
||||
def is_sep(r):
|
||||
return bool(re.match(r'^\|[\s|:-]+\|$', r.strip()))
|
||||
if not is_sep(lines[1]):
|
||||
return md
|
||||
|
||||
def inline_md_fixed(t):
|
||||
"""Fixed inlineMd: stash links before autolink."""
|
||||
stash = []
|
||||
def stash_fn(m):
|
||||
lb, u = m.group(1), m.group(2)
|
||||
stash.append(f'<a href="{u}" target="_blank" rel="noopener">{esc(lb)}</a>')
|
||||
return f'\x00L{len(stash)-1}\x00'
|
||||
t = re.sub(r'\[([^\]]+)\]\((https?://[^\)]+)\)', stash_fn, t)
|
||||
# autolink remaining bare URLs
|
||||
def autolink(m):
|
||||
url = m.group(1)
|
||||
trail = url[-1] if url[-1] in '.,;:!?)' else ''
|
||||
clean = url[:-1] if trail else url
|
||||
return f'<a href="{clean}" target="_blank" rel="noopener">{esc(clean)}</a>{trail}'
|
||||
t = re.sub(r'(https?://[^\s<>"\')\]]+)', autolink, t)
|
||||
t = re.sub(r'\x00L(\d+)\x00', lambda m: stash[int(m.group(1))], t)
|
||||
return t
|
||||
|
||||
def parse_row(r):
|
||||
cells = r.strip().lstrip('|').rstrip('|').split('|')
|
||||
return ''.join(f'<td>{inline_md_fixed(c.strip())}</td>' for c in cells)
|
||||
|
||||
def parse_header(r):
|
||||
cells = r.strip().lstrip('|').rstrip('|').split('|')
|
||||
return ''.join(f'<th>{inline_md_fixed(c.strip())}</th>' for c in cells)
|
||||
|
||||
header = f'<tr>{parse_header(lines[0])}</tr>'
|
||||
body = ''.join(f'<tr>{parse_row(r)}</tr>' for r in lines[2:])
|
||||
return f'<table><thead>{header}</thead><tbody>{body}</tbody></table>'
|
||||
|
||||
|
||||
# ── Source-level checks (verify fix is in the JS) ─────────────────────────────
|
||||
|
||||
def test_inlinemd_uses_link_stash():
|
||||
"""Fixed inlineMd() must stash [label](url) links before autolink runs."""
|
||||
assert '_link_stash' in UI_JS, (
|
||||
"inlineMd() should use _link_stash to prevent double-linking"
|
||||
)
|
||||
|
||||
|
||||
def test_inlinemd_no_esc_on_href():
|
||||
"""Fixed inlineMd() must not call esc() on the URL in href."""
|
||||
# The old broken pattern had esc(u) inside the href
|
||||
assert 'href="${esc(u)}"' not in UI_JS, (
|
||||
"inlineMd() should not call esc() on href URL — it breaks & in query strings"
|
||||
)
|
||||
|
||||
|
||||
def test_outer_link_pass_uses_a_stash():
|
||||
"""Fixed outer link pass must stash existing <a> tags before running."""
|
||||
assert '_a_stash' in UI_JS, (
|
||||
"Outer [label](url) pass should stash existing <a> tags to prevent autolink re-matching"
|
||||
)
|
||||
|
||||
|
||||
def test_autolink_pass_uses_al_stash():
|
||||
"""Fixed autolink pass must stash existing <a> tags before running."""
|
||||
assert '_al_stash' in UI_JS, (
|
||||
"Autolink pass should stash existing <a> tags to prevent double-linking"
|
||||
)
|
||||
|
||||
|
||||
def test_autolink_no_esc_on_href():
|
||||
"""Fixed autolink pass must not call esc() on href URL."""
|
||||
idx = UI_JS.find('// Autolink: convert plain URLs to clickable links.')
|
||||
assert idx != -1, "New autolink comment not found"
|
||||
autolink_section = UI_JS[idx:idx+600]
|
||||
# The return line should have href="${clean}" (JS template literal, no esc call)
|
||||
assert 'href="${clean}"' in autolink_section, (
|
||||
'Autolink should use href="${clean}" not href="${esc(clean)}"'
|
||||
)
|
||||
assert 'href="${esc(clean)}"' not in autolink_section, (
|
||||
"Autolink should not esc() the URL in href"
|
||||
)
|
||||
|
||||
|
||||
# ── Behaviour tests (Python mirror of fixed renderMd) ─────────────────────────
|
||||
|
||||
def test_labeled_link_renders_as_single_anchor():
|
||||
"""[#461](https://github.com/.../461) must produce exactly one <a> tag."""
|
||||
url = 'https://github.com/nesquena/hermes-webui/issues/461'
|
||||
md = f'[#461]({url})'
|
||||
result = render_links_only(md)
|
||||
assert result.count('<a ') == 1, f"Expected 1 <a> tag, got: {result}"
|
||||
assert result.count('</a>') == 1
|
||||
assert f'href="{url}"' in result
|
||||
assert '#461' in result
|
||||
# Must not contain the raw brackets
|
||||
assert '[#461]' not in result
|
||||
assert f']({url})' not in result
|
||||
|
||||
|
||||
def test_href_not_html_escaped():
|
||||
"""URLs with & must appear as literal & in href, not &."""
|
||||
url = 'https://example.com/search?q=foo&bar=baz'
|
||||
md = f'[Search]({url})'
|
||||
result = render_links_only(md)
|
||||
assert f'href="{url}"' in result, (
|
||||
f"& in URL should not be escaped to & in href. Got: {result}"
|
||||
)
|
||||
assert '&' not in result
|
||||
|
||||
|
||||
def test_bare_url_not_double_linked():
|
||||
"""A bare https:// URL must produce exactly one <a> tag."""
|
||||
url = 'https://github.com/nesquena/hermes-webui/issues/461'
|
||||
result = render_links_only(url)
|
||||
assert result.count('<a ') == 1, f"Expected 1 <a> tag, got: {result}"
|
||||
assert result.count('</a>') == 1
|
||||
|
||||
|
||||
def test_labeled_link_in_table_cell_single_anchor():
|
||||
"""[#461](url) inside a markdown table cell must produce exactly one <a> tag."""
|
||||
url = 'https://github.com/nesquena/hermes-webui/issues/461'
|
||||
md = f'| Issue | Title |\n|---|---|\n| [#461]({url}) | Reasoning effort |'
|
||||
result = render_table_with_links(md)
|
||||
assert result.count('<a ') == 1, f"Expected 1 <a> in table, got: {result}"
|
||||
assert f'href="{url}"' in result
|
||||
assert '#461' in result
|
||||
# No raw brackets should appear in output
|
||||
assert '[#461]' not in result
|
||||
|
||||
|
||||
def test_multiple_links_in_table_no_double_linking():
|
||||
"""Multiple [label](url) links in a table must each produce exactly one <a>."""
|
||||
urls = [
|
||||
'https://github.com/nesquena/hermes-webui/issues/461',
|
||||
'https://github.com/nesquena/hermes-webui/issues/462',
|
||||
'https://github.com/nesquena/hermes-webui/issues/463',
|
||||
]
|
||||
rows = '\n'.join(f'| [#{461+i}]({url}) | Title {i} |' for i, url in enumerate(urls))
|
||||
md = f'| Issue | Title |\n|---|---|\n{rows}'
|
||||
result = render_table_with_links(md)
|
||||
assert result.count('<a ') == 3, f"Expected 3 <a> tags, got {result.count('<a ')}:\n{result}"
|
||||
assert result.count('</a>') == 3
|
||||
for url in urls:
|
||||
assert f'href="{url}"' in result
|
||||
|
||||
|
||||
def test_link_label_is_escaped():
|
||||
"""The label text (not the URL) must still be HTML-escaped."""
|
||||
url = 'https://example.com'
|
||||
md = f'[Click <here>]({url})'
|
||||
result = render_links_only(md)
|
||||
assert '<here>' in result, "Label text should be HTML-escaped"
|
||||
assert '<here>' not in result
|
||||
|
||||
|
||||
def test_link_not_broken_by_prior_autolink():
|
||||
"""A [label](url) followed by a bare URL must each produce one clean <a>."""
|
||||
url1 = 'https://github.com/issues/461'
|
||||
url2 = 'https://github.com/issues/462'
|
||||
md = f'See [#461]({url1}) and also {url2}'
|
||||
result = render_links_only(md)
|
||||
assert result.count('<a ') == 2, f"Expected 2 links, got: {result}"
|
||||
assert f'href="{url1}"' in result
|
||||
assert f'href="{url2}"' in result
|
||||
assert '#461' in result
|
||||
|
||||
def test_href_quote_sanitized():
|
||||
"""A URL containing a double-quote must have it percent-encoded in href to prevent attribute breakout."""
|
||||
# This would break out of href="..." and inject an event handler without the fix
|
||||
url = 'https://evil.com" onmouseover="alert(1)'
|
||||
# The [label](url) regex captures up to the closing ), so we test via the render helper
|
||||
# by constructing a URL that contains a literal quote character
|
||||
safe_url = 'https://example.com/path"with"quotes'
|
||||
result = render_links_only(f'[click]({safe_url})')
|
||||
# The href must not contain a raw unencoded double-quote
|
||||
href_start = result.find('href="') + 6
|
||||
href_end = result.find('"', href_start)
|
||||
href_val = result[href_start:href_end]
|
||||
assert '"' not in href_val, (
|
||||
f"href value must not contain unencoded double-quote. Got href: {href_val}"
|
||||
)
|
||||
|
||||
|
||||
def test_js_source_sanitizes_quotes_in_href():
|
||||
"""JS source must apply quote percent-encoding to URLs before placing in href."""
|
||||
# Both the inlineMd stash and outer link pass must sanitize quotes
|
||||
assert "%22" in UI_JS, (
|
||||
"URL placed in href should have double-quotes percent-encoded via .replace to %22"
|
||||
)
|
||||
|
||||
# ── Code-inside-bold tests (pre-existing bug, fixed in same PR) ───────────────
|
||||
|
||||
def test_js_inlinemd_stashes_code_before_bold():
|
||||
"""Fixed inlineMd() must stash backtick code spans before bold/italic processing."""
|
||||
assert '_code_stash' in UI_JS, (
|
||||
"inlineMd() should use _code_stash to protect backtick spans from bold/italic esc()"
|
||||
)
|
||||
|
||||
|
||||
def test_code_inside_bold_renders_correctly():
|
||||
"""Inline code inside bold text must render as <strong><code>...</code></strong>,
|
||||
not with escaped <code> tags visible on screen."""
|
||||
# This was the pre-existing bug: **`esc()`** → <strong><code>esc()</code></strong>
|
||||
text = '**`esc()` on `href`**: breaks URLs'
|
||||
# Simulate the fixed inlineMd()
|
||||
code_stash = []
|
||||
t = text
|
||||
t = re.sub(r'`([^`\n]+)`',
|
||||
lambda m: (code_stash.append(f'<code>{esc(m.group(1))}</code>') or f'\x00C{len(code_stash)-1}\x00'), t)
|
||||
t = re.sub(r'\*\*(.+?)\*\*', lambda m: f'<strong>{esc(m.group(1))}</strong>', t)
|
||||
t = re.sub(r'\x00C(\d+)\x00', lambda m: code_stash[int(m.group(1))], t)
|
||||
assert '<code>' not in t, (
|
||||
f"Code tags should not be HTML-escaped inside bold. Got: {t}"
|
||||
)
|
||||
assert '<code>esc()</code>' in t, (
|
||||
f"Code tags should render as <code> elements inside bold. Got: {t}"
|
||||
)
|
||||
assert '<strong>' in t, "Bold should still render"
|
||||
|
||||
|
||||
def test_code_and_bold_mixed_no_escaping():
|
||||
"""Bold text containing multiple backtick spans must render all code tags correctly."""
|
||||
cases = [
|
||||
('**`esc()` on `href`**', '<strong>', '<code>esc()</code>', '<code>href</code>'),
|
||||
('***`code` in bold-italic***', '<strong>', '<code>code</code>'),
|
||||
('`code` then **bold**', '<code>code</code>', '<strong>bold</strong>'),
|
||||
]
|
||||
for args in cases:
|
||||
text = args[0]
|
||||
expected_fragments = args[1:]
|
||||
code_stash = []
|
||||
t = text
|
||||
t = re.sub(r'`([^`\n]+)`',
|
||||
lambda m: (code_stash.append(f'<code>{esc(m.group(1))}</code>') or f'\x00C{len(code_stash)-1}\x00'), t)
|
||||
t = re.sub(r'\*\*\*(.+?)\*\*\*', lambda m: f'<strong><em>{esc(m.group(1))}</em></strong>', t)
|
||||
t = re.sub(r'\*\*(.+?)\*\*', lambda m: f'<strong>{esc(m.group(1))}</strong>', t)
|
||||
t = re.sub(r'\x00C(\d+)\x00', lambda m: code_stash[int(m.group(1))], t)
|
||||
assert '<code>' not in t, f"Escaped code tag in: {text!r} → {t}"
|
||||
for frag in expected_fragments:
|
||||
assert frag in t, f"Expected {frag!r} in output of {text!r}, got: {t}"
|
||||
26
tests/test_issue477.py
Normal file
26
tests/test_issue477.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Tests for fix #477: KaTeX font-src CSP fix."""
|
||||
import pathlib
|
||||
|
||||
REPO = pathlib.Path(__file__).parent.parent
|
||||
HELPERS_PY = (REPO / "api" / "helpers.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_font_src_allows_jsdelivr():
|
||||
"""font-src must include cdn.jsdelivr.net for KaTeX fonts."""
|
||||
assert "font-src 'self' data: https://cdn.jsdelivr.net" in HELPERS_PY, (
|
||||
"api/helpers.py CSP must allow cdn.jsdelivr.net in font-src "
|
||||
"so KaTeX math rendering fonts load without console errors."
|
||||
)
|
||||
|
||||
|
||||
def test_font_src_still_allows_self_and_data():
|
||||
"""font-src must still allow self and data: (used by other font assets)."""
|
||||
assert "'self'" in HELPERS_PY.split("font-src")[1].split(";")[0]
|
||||
assert "data:" in HELPERS_PY.split("font-src")[1].split(";")[0]
|
||||
|
||||
|
||||
def test_script_src_already_allows_jsdelivr():
|
||||
"""script-src already allows cdn.jsdelivr.net — font-src should too."""
|
||||
assert "https://cdn.jsdelivr.net" in HELPERS_PY.split("font-src")[0], (
|
||||
"script-src should already allow cdn.jsdelivr.net (KaTeX JS)"
|
||||
)
|
||||
572
tests/test_issue486_487.py
Normal file
572
tests/test_issue486_487.py
Normal file
@@ -0,0 +1,572 @@
|
||||
"""
|
||||
Tests for issue #486 (CSS: inline code in table cells) and
|
||||
issue #487 (JS renderer: markdown image syntax not implemented).
|
||||
|
||||
Issue #486 — CSS fix in static/style.css:
|
||||
Inline `code` spans inside table cells render with awkward sizing.
|
||||
Fix: td code, th code { font-size: 0.85em; padding: 1px 4px; vertical-align: baseline; }
|
||||
|
||||
Issue #487 — JS fix in static/ui.js:
|
||||
 image syntax not handled — renders as stray ! + link.
|
||||
Fix: add image pass to renderMd() (before link pass) and inlineMd()
|
||||
reusing the .msg-media-img class.
|
||||
|
||||
Strategy:
|
||||
- Source-level checks verify the fixes are present in the JS/CSS.
|
||||
- Python mirror tests verify the rendering logic with exhaustive edge cases,
|
||||
especially code blocks inside tables (the specific case Nathan flagged).
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
import html as _html
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent
|
||||
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text()
|
||||
STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text()
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def esc(s):
|
||||
return _html.escape(str(s), quote=True)
|
||||
|
||||
|
||||
def inline_md(t):
|
||||
"""
|
||||
Python mirror of the fixed inlineMd() function — includes:
|
||||
- _code_stash (protects backtick spans from bold/italic AND from image pass)
|
||||
- image pass (NEW for #487 — runs while code stash is active, before link pass)
|
||||
- _img_stash (protects rendered img tags from autolink touching src=)
|
||||
- _link_stash (protects links from autolink)
|
||||
- autolink
|
||||
- code stash restore (after autolink, so code content is never autolinked)
|
||||
|
||||
Correct operation order:
|
||||
1. code stash — \x00C protects `...` from bold and image pass
|
||||
2. bold/italic — runs on plain text only
|
||||
3. image pass — runs while code content is still stashed (so 
|
||||
inside backticks stays protected as a \x00C token)
|
||||
4. img stash — \x00I protects <img src="url"> from autolink
|
||||
5. link stash — \x00L protects [label](url) links from autolink
|
||||
6. autolink — only matches URLs not already in a stash token
|
||||
7. link stash restore
|
||||
8. img stash restore
|
||||
9. code stash restore — restores <code> tags last
|
||||
"""
|
||||
# 1. Code stash — must be first to protect code content from all subsequent passes
|
||||
code_stash = []
|
||||
def stash_code(m):
|
||||
code_stash.append(f'<code>{esc(m.group(1))}</code>')
|
||||
return f'\x00C{len(code_stash)-1}\x00'
|
||||
t = re.sub(r'`([^`\n]+)`', stash_code, t)
|
||||
|
||||
# 2. Bold/italic (code content is safely stashed)
|
||||
t = re.sub(r'\*\*\*(.+?)\*\*\*', lambda m: f'<strong><em>{esc(m.group(1))}</em></strong>', t)
|
||||
t = re.sub(r'\*\*(.+?)\*\*', lambda m: f'<strong>{esc(m.group(1))}</strong>', t)
|
||||
t = re.sub(r'\*([^*\n]+)\*', lambda m: f'<em>{esc(m.group(1))}</em>', t)
|
||||
|
||||
# 3. Image pass (NEW — runs while code is still stashed, so  inside
|
||||
# backticks is protected as a \x00C token and won't match here)
|
||||
def render_image(m):
|
||||
alt, url = m.group(1), m.group(2)
|
||||
safe_url = url.replace('"', '%22')
|
||||
return (f'<img src="{safe_url}" alt="{esc(alt)}" '
|
||||
f'class="msg-media-img" loading="lazy" '
|
||||
f'onclick="this.classList.toggle(\'msg-media-img--full\')">')
|
||||
t = re.sub(r'!\[([^\]]*)\]\((https?://[^\)]+)\)', render_image, t)
|
||||
|
||||
# 4. Img stash — protect rendered <img> tags so autolink never touches src= values
|
||||
img_stash = []
|
||||
def stash_img(m):
|
||||
img_stash.append(m.group(0))
|
||||
return f'\x00I{len(img_stash)-1}\x00'
|
||||
t = re.sub(r'<img\b[^>]*>', stash_img, t)
|
||||
|
||||
# 5. Link stash
|
||||
link_stash = []
|
||||
def stash_link(m):
|
||||
lb, u = m.group(1), m.group(2)
|
||||
link_stash.append(f'<a href="{u.replace(chr(34), "%22")}" target="_blank" rel="noopener">{esc(lb)}</a>')
|
||||
return f'\x00L{len(link_stash)-1}\x00'
|
||||
t = re.sub(r'\[([^\]]+)\]\((https?://[^\)]+)\)', stash_link, t)
|
||||
|
||||
# 6. Autolink (img and link URLs are both stashed — safe)
|
||||
def autolink(m):
|
||||
url = m.group(1)
|
||||
trail = url[-1] if url[-1] in '.,;:!?)' else ''
|
||||
clean = url[:-1] if trail else url
|
||||
return f'<a href="{clean}" target="_blank" rel="noopener">{esc(clean)}</a>{trail}'
|
||||
t = re.sub(r'(https?://[^\s<>"\')\]]+)', autolink, t)
|
||||
|
||||
# 7. Restore link stash
|
||||
t = re.sub(r'\x00L(\d+)\x00', lambda m: link_stash[int(m.group(1))], t)
|
||||
|
||||
# 8. Restore img stash
|
||||
t = re.sub(r'\x00I(\d+)\x00', lambda m: img_stash[int(m.group(1))], t)
|
||||
|
||||
# 9. Restore code stash (last — code content was never touched by any pass)
|
||||
t = re.sub(r'\x00C(\d+)\x00', lambda m: code_stash[int(m.group(1))], t)
|
||||
return t
|
||||
|
||||
|
||||
def render_table(md):
|
||||
"""Python mirror of the table pass, using inline_md() per cell."""
|
||||
lines = md.strip().split('\n')
|
||||
if len(lines) < 2:
|
||||
return md
|
||||
|
||||
def is_sep(r):
|
||||
return bool(re.match(r'^\|[\s|:-]+\|$', r.strip()))
|
||||
|
||||
if not is_sep(lines[1]):
|
||||
return md
|
||||
|
||||
def parse_header(r):
|
||||
cells = r.strip().lstrip('|').rstrip('|').split('|')
|
||||
return ''.join(f'<th>{inline_md(c.strip())}</th>' for c in cells)
|
||||
|
||||
def parse_row(r):
|
||||
cells = r.strip().lstrip('|').rstrip('|').split('|')
|
||||
return ''.join(f'<td>{inline_md(c.strip())}</td>' for c in cells)
|
||||
|
||||
header = f'<tr>{parse_header(lines[0])}</tr>'
|
||||
body = ''.join(f'<tr>{parse_row(r)}</tr>' for r in lines[2:])
|
||||
return f'<table><thead>{header}</thead><tbody>{body}</tbody></table>'
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# ISSUE #486 — CSS: code inside table cells
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestIssue486CssCodeInTable:
|
||||
"""CSS fix: td code and th code must have targeted sizing rules."""
|
||||
|
||||
def test_td_code_font_size_present(self):
|
||||
"""msg-body td code rule must set font-size (e.g. 0.85em) to prevent oversized code."""
|
||||
assert 'td code' in STYLE_CSS, (
|
||||
"Missing 'td code' CSS rule — inline code in table cells needs sizing fix"
|
||||
)
|
||||
|
||||
def test_th_code_rule_present(self):
|
||||
"""th code rule must also exist for header cells."""
|
||||
assert 'th code' in STYLE_CSS, (
|
||||
"Missing 'th code' CSS rule — inline code in header cells needs sizing fix"
|
||||
)
|
||||
|
||||
def test_td_code_has_font_size(self):
|
||||
"""The td code / th code block must include a font-size declaration."""
|
||||
# Find the msg-body scoped td code rule
|
||||
idx = STYLE_CSS.find('td code')
|
||||
assert idx != -1, "td code rule not found in style.css"
|
||||
# Check nearby text (within 200 chars) has font-size
|
||||
window = STYLE_CSS[idx:idx+200]
|
||||
assert 'font-size' in window, (
|
||||
f"td code rule must include font-size. Found near td code: {window!r}"
|
||||
)
|
||||
|
||||
def test_td_code_has_padding(self):
|
||||
"""The td code / th code block must include a padding declaration."""
|
||||
idx = STYLE_CSS.find('td code')
|
||||
assert idx != -1
|
||||
window = STYLE_CSS[idx:idx+200]
|
||||
assert 'padding' in window, (
|
||||
f"td code rule must include padding. Found near td code: {window!r}"
|
||||
)
|
||||
|
||||
def test_td_code_has_vertical_align(self):
|
||||
"""The td code / th code block must include vertical-align: baseline."""
|
||||
idx = STYLE_CSS.find('td code')
|
||||
assert idx != -1
|
||||
window = STYLE_CSS[idx:idx+200]
|
||||
assert 'vertical-align' in window, (
|
||||
f"td code rule must include vertical-align. Found near td code: {window!r}"
|
||||
)
|
||||
|
||||
def test_code_renders_inside_table_cell(self):
|
||||
"""Inline `code` inside a table cell must render as <code> element."""
|
||||
md = "| Syntax | Rendered |\n|---|---|\n| `code` | `code` |"
|
||||
result = render_table(md)
|
||||
assert '<code>code</code>' in result, (
|
||||
f"Inline code in table cell should render as <code>. Got: {result}"
|
||||
)
|
||||
|
||||
def test_bold_code_renders_inside_table_cell(self):
|
||||
"""**`bold code`** inside a table cell must render as <strong><code>."""
|
||||
md = "| Style | Example |\n|---|---|\n| bold code | **`bold code`** |"
|
||||
result = render_table(md)
|
||||
# Should have code tag (even inside bold)
|
||||
assert '<code>bold code</code>' in result, (
|
||||
f"Bold code in table should render as <code>. Got: {result}"
|
||||
)
|
||||
|
||||
def test_multiple_code_spans_in_same_cell(self):
|
||||
"""Multiple backtick spans in one cell all render as <code>."""
|
||||
md = "| Combined |\n|---|\n| `a` and `b` |"
|
||||
result = render_table(md)
|
||||
assert result.count('<code>') == 2, (
|
||||
f"Expected 2 code tags in cell, got: {result}"
|
||||
)
|
||||
|
||||
def test_code_in_header_cell(self):
|
||||
"""`code` in a <th> header cell must also render as <code>."""
|
||||
md = "| `header code` | Normal |\n|---|---|\n| data | data |"
|
||||
result = render_table(md)
|
||||
assert '<code>header code</code>' in result, (
|
||||
f"Code in header cell should render. Got: {result}"
|
||||
)
|
||||
|
||||
def test_code_not_mangled_by_bold_in_table(self):
|
||||
"""**`code`** in a table cell must NOT produce <code> (the pre-fix bug)."""
|
||||
md = "| Pattern | Example |\n|---|---|\n| bold-code | **`npm install`** |"
|
||||
result = render_table(md)
|
||||
assert '<code>' not in result, (
|
||||
f"Code tags inside bold in table must not be HTML-escaped. Got: {result}"
|
||||
)
|
||||
assert '<strong>' in result, "Bold wrapper should be present"
|
||||
assert '<code>npm install</code>' in result
|
||||
|
||||
def test_code_with_special_chars_in_table(self):
|
||||
"""`<script>` inside a table cell must have the angle brackets escaped."""
|
||||
md = "| Input | Output |\n|---|---|\n| `<script>` | sanitized |"
|
||||
result = render_table(md)
|
||||
assert '<script>' in result, (
|
||||
f"Code content must be HTML-escaped. Got: {result}"
|
||||
)
|
||||
# The <code> wrapper itself must be there
|
||||
assert '<code>' in result
|
||||
|
||||
def test_code_adjacent_to_link_in_table(self):
|
||||
"""`code` and [link](url) in same cell both render correctly."""
|
||||
url = 'https://example.com'
|
||||
md = f"| Mixed |\n|---|\n| `foo` and [bar]({url}) |"
|
||||
result = render_table(md)
|
||||
assert '<code>foo</code>' in result
|
||||
assert f'href="{url}"' in result
|
||||
assert 'bar' in result
|
||||
|
||||
def test_empty_code_span_in_table(self):
|
||||
"""Edge case: empty backtick span in table cell (`` ` ` ``) — no crash."""
|
||||
# This won't match the code regex (requires at least 1 char), should pass through
|
||||
md = "| Col |\n|---|\n| normal text |"
|
||||
result = render_table(md)
|
||||
assert '<td>normal text</td>' in result
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# ISSUE #487 — JS renderer: markdown image syntax
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestIssue487ImageRendering:
|
||||
"""Image syntax  must render as <img>, not as ! + link."""
|
||||
|
||||
# ── Source-level checks ──────────────────────────────────────────────────
|
||||
|
||||
def test_image_pass_present_in_ui_js(self):
|
||||
"""renderMd() must contain an image regex pass for ."""
|
||||
assert ' regex)"
|
||||
)
|
||||
# More specifically, look for the img tag being generated
|
||||
assert 'msg-media-img' in UI_JS, (
|
||||
"Image pass should reuse .msg-media-img class"
|
||||
)
|
||||
|
||||
def test_image_pass_runs_before_link_pass_in_outer(self):
|
||||
"""Image regex must appear in ui.js BEFORE the [label](url) link pass."""
|
||||
# Find the image pass position
|
||||
img_idx = UI_JS.find('!\\[')
|
||||
if img_idx == -1:
|
||||
img_idx = UI_JS.find(" link pass "
|
||||
"to prevent the image from being consumed as a plain link"
|
||||
)
|
||||
|
||||
def test_image_url_sanitized_for_quotes(self):
|
||||
"""Image src URL must have double-quotes percent-encoded."""
|
||||
# The image pass must use .replace(/"/g,'%22') or equivalent
|
||||
# Look for the pattern near image handling
|
||||
img_idx = UI_JS.find('msg-media-img')
|
||||
assert img_idx != -1
|
||||
# Find all occurrences — there's the MEDIA restore and the new image pass
|
||||
# The new one should have %22 for URL sanitization
|
||||
assert '%22' in UI_JS, (
|
||||
"Image src URL must sanitize double-quotes to %22"
|
||||
)
|
||||
|
||||
def test_image_alt_uses_esc(self):
|
||||
"""Alt text must be passed through esc() to prevent XSS."""
|
||||
# Look for esc( call near the image rendering code
|
||||
# The pattern should be: alt="${esc(alt)}"
|
||||
assert 'esc(' in UI_JS, "esc() function must be used for alt text"
|
||||
|
||||
def test_safe_tags_includes_img(self):
|
||||
"""SAFE_TAGS allowlist must include 'img' to prevent the tag from being escaped."""
|
||||
# Find the SAFE_TAGS regex in ui.js
|
||||
safe_idx = UI_JS.find('SAFE_TAGS=')
|
||||
assert safe_idx != -1, "SAFE_TAGS not found in ui.js"
|
||||
safe_window = UI_JS[safe_idx:safe_idx+300]
|
||||
assert 'img' in safe_window, (
|
||||
f"SAFE_TAGS must include 'img' tag. Found: {safe_window!r}"
|
||||
)
|
||||
|
||||
def test_inlinemd_has_image_pass(self):
|
||||
"""inlineMd() must also handle  for images inside table cells."""
|
||||
# inlineMd is called for table cells, list items, blockquotes
|
||||
# Find inlineMd function body
|
||||
start = UI_JS.find('function inlineMd(')
|
||||
assert start != -1, "inlineMd function not found"
|
||||
# Get a generous window covering the function
|
||||
fn_window = UI_JS[start:start+1500]
|
||||
assert ' must produce an <img> tag."""
|
||||
t = ''
|
||||
result = inline_md(t)
|
||||
assert '<img ' in result, f"Expected <img> tag, got: {result}"
|
||||
assert 'src="https://example.com/cat.png"' in result
|
||||
assert 'alt="A cat"' in result
|
||||
# Must NOT have the raw ![...] syntax left over
|
||||
assert ' must NOT produce an <a> tag (the pre-fix bug)."""
|
||||
t = ''
|
||||
result = inline_md(t)
|
||||
assert '<a ' not in result, (
|
||||
f"Image must not render as an <a> tag. Got: {result}"
|
||||
)
|
||||
|
||||
def test_image_stray_exclamation_not_present(self):
|
||||
"""No stray ! character before the img tag (the pre-fix symptom)."""
|
||||
t = ''
|
||||
result = inline_md(t)
|
||||
# Strip the img tag and check no ! is left
|
||||
cleaned = re.sub(r'<img[^>]+>', '', result)
|
||||
assert '!' not in cleaned, (
|
||||
f"Stray ! character present after image render. Got: {result}"
|
||||
)
|
||||
|
||||
def test_image_uses_msg_media_img_class(self):
|
||||
"""Rendered <img> must use class=\"msg-media-img\" for consistent styling."""
|
||||
t = ''
|
||||
result = inline_md(t)
|
||||
assert 'class="msg-media-img"' in result, (
|
||||
f"Image must use .msg-media-img class. Got: {result}"
|
||||
)
|
||||
|
||||
def test_image_has_lazy_loading(self):
|
||||
"""Rendered <img> must have loading=\"lazy\"."""
|
||||
t = ''
|
||||
result = inline_md(t)
|
||||
assert 'loading="lazy"' in result, f"Expected loading=lazy. Got: {result}"
|
||||
|
||||
def test_image_has_click_to_zoom(self):
|
||||
"""Rendered <img> must have onclick toggle for zoom."""
|
||||
t = ''
|
||||
result = inline_md(t)
|
||||
assert 'msg-media-img--full' in result, (
|
||||
f"Image must have click-to-zoom onclick. Got: {result}"
|
||||
)
|
||||
|
||||
def test_image_alt_is_escaped(self):
|
||||
"""Alt text with HTML special chars must be escaped."""
|
||||
t = ''
|
||||
result = inline_md(t)
|
||||
assert '<evil>' in result, (
|
||||
f"Alt text must be HTML-escaped. Got: {result}"
|
||||
)
|
||||
assert '<evil>' not in result
|
||||
|
||||
def test_image_url_quote_sanitized(self):
|
||||
"""Double-quote in image URL must be percent-encoded to prevent attribute breakout."""
|
||||
t = ''
|
||||
result = inline_md(t)
|
||||
# Find the src attribute value
|
||||
src_match = re.search(r'src="([^"]*)"', result)
|
||||
assert src_match, f"src attribute not found. Got: {result}"
|
||||
src_val = src_match.group(1)
|
||||
assert '"' not in src_val, (
|
||||
f"Raw double-quote in src would break attribute. Got src: {src_val!r}"
|
||||
)
|
||||
|
||||
def test_image_no_javascript_uri(self):
|
||||
"""javascript: URIs must not be rendered as image src (regex only matches http/https)."""
|
||||
t = ')'
|
||||
result = inline_md(t)
|
||||
# The regex requires https?://, so this should pass through unmodified
|
||||
assert '<img ' not in result, (
|
||||
f"javascript: URI must not render as <img>. Got: {result}"
|
||||
)
|
||||
|
||||
def test_image_no_data_uri(self):
|
||||
"""data: URIs must not be rendered as image src."""
|
||||
t = ''
|
||||
result = inline_md(t)
|
||||
assert '<img ' not in result, (
|
||||
f"data: URI must not render as <img>. Got: {result}"
|
||||
)
|
||||
|
||||
def test_image_followed_by_text(self):
|
||||
"""Image followed by plain text — only the image becomes an <img>."""
|
||||
t = ' and some text'
|
||||
result = inline_md(t)
|
||||
assert '<img ' in result
|
||||
assert 'and some text' in result
|
||||
|
||||
def test_image_preceded_by_text(self):
|
||||
"""Text before an image — both render correctly."""
|
||||
t = 'Here is a screenshot: '
|
||||
result = inline_md(t)
|
||||
assert 'Here is a screenshot:' in result
|
||||
assert '<img ' in result
|
||||
|
||||
def test_image_and_link_in_same_cell(self):
|
||||
"""Image and link in same inline context both render correctly."""
|
||||
t = ' see [here](https://example.com)'
|
||||
result = inline_md(t)
|
||||
assert '<img ' in result
|
||||
assert '<a href="https://example.com"' in result
|
||||
assert ' inside a markdown table cell must render as <img>."""
|
||||
md = ("| Image | Caption |\n"
|
||||
"|---|---|\n"
|
||||
"|  | Company logo |")
|
||||
result = render_table(md)
|
||||
assert '<img ' in result, f"Image in table should render as <img>. Got: {result}"
|
||||
assert 'src="https://example.com/logo.png"' in result
|
||||
assert '<a ' not in result, "Image in table must not render as <a>"
|
||||
|
||||
def test_image_in_table_no_stray_exclamation(self):
|
||||
"""No stray ! before the <img> when image is inside a table cell."""
|
||||
md = ("| X |\n|---|\n|  |")
|
||||
result = render_table(md)
|
||||
# Strip known tags and check no ! appears
|
||||
cleaned = re.sub(r'<[^>]+>', '', result)
|
||||
assert '!' not in cleaned, (
|
||||
f"Stray ! in table cell after image render. Cleaned: {cleaned!r}"
|
||||
)
|
||||
|
||||
def test_empty_alt_text_image(self):
|
||||
""" with empty alt renders as <img> with empty alt attribute."""
|
||||
t = ''
|
||||
result = inline_md(t)
|
||||
assert '<img ' in result
|
||||
assert 'alt=""' in result
|
||||
|
||||
def test_multiple_images_in_one_cell(self):
|
||||
"""Two images in one table cell both render as <img> tags."""
|
||||
t = (' '
|
||||
'')
|
||||
result = inline_md(t)
|
||||
assert result.count('<img ') == 2, (
|
||||
f"Expected 2 img tags. Got: {result}"
|
||||
)
|
||||
|
||||
def test_image_with_https_url(self):
|
||||
"""https:// image URL renders correctly."""
|
||||
t = ''
|
||||
result = inline_md(t)
|
||||
assert 'src="https://secure.example.com/img.jpg"' in result
|
||||
|
||||
def test_image_with_http_url(self):
|
||||
"""http:// image URL also renders (non-https still valid)."""
|
||||
t = ''
|
||||
result = inline_md(t)
|
||||
assert '<img ' in result
|
||||
assert 'src="http://example.com/img.jpg"' in result
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# Cross-cutting: code + image together inside tables (the edge case Nathan flagged)
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestEdgeCasesCodeAndImageInTables:
|
||||
"""Combination edge cases: code blocks and images mixed inside table cells."""
|
||||
|
||||
def test_code_and_image_in_same_table_row(self):
|
||||
"""Table row with code in one cell and image in another renders both correctly."""
|
||||
md = ("| Code | Preview |\n"
|
||||
"|---|---|\n"
|
||||
"| `print('hello')` |  |")
|
||||
result = render_table(md)
|
||||
assert "<code>print('hello')</code>" in result or "<code>print('hello')</code>" in result, (
|
||||
f"Code cell should render as <code>. Got: {result}"
|
||||
)
|
||||
assert '<img ' in result, "Image cell should render as <img>"
|
||||
|
||||
def test_code_in_cell_with_image_in_next_cell(self):
|
||||
"""Multiple columns: code stays code, image stays image, no cross-contamination."""
|
||||
md = ("| Step | Example |\n"
|
||||
"|---|---|\n"
|
||||
"| Run `npm install` |  |")
|
||||
result = render_table(md)
|
||||
assert '<code>npm install</code>' in result
|
||||
assert '<img ' in result
|
||||
assert '<a ' not in result # image must not become a link
|
||||
|
||||
def test_bold_code_in_cell_and_image_in_cell(self):
|
||||
"""**`code`** in one cell and image in another — no esc() mangling."""
|
||||
md = ("| Command | Result |\n"
|
||||
"|---|---|\n"
|
||||
"| **`git status`** |  |")
|
||||
result = render_table(md)
|
||||
assert '<code>' not in result, (
|
||||
"Bold+code in table cell must not produce escaped code tags"
|
||||
)
|
||||
assert '<code>git status</code>' in result
|
||||
assert '<img ' in result
|
||||
|
||||
def test_link_code_image_all_in_table(self):
|
||||
"""Table with code, link, and image cells all render correctly."""
|
||||
url = 'https://github.com/issues/486'
|
||||
img_url = 'https://example.com/img.png'
|
||||
md = (f"| Code | Link | Image |\n"
|
||||
f"|---|---|---|\n"
|
||||
f"| `var x = 1` | [#486]({url}) |  |")
|
||||
result = render_table(md)
|
||||
assert '<code>var x = 1</code>' in result
|
||||
assert f'href="{url}"' in result
|
||||
assert '<img ' in result
|
||||
# No double-linking
|
||||
assert result.count('<a ') == 1
|
||||
|
||||
def test_image_url_with_query_string_in_table(self):
|
||||
"""Image URL with & in query string inside table cell — & not mangled."""
|
||||
url = 'https://example.com/img?w=100&h=200'
|
||||
md = f"| Image |\n|---|\n|  |"
|
||||
result = render_table(md)
|
||||
assert f'src="{url}"' in result, (
|
||||
f"& in image URL must not be escaped. Got: {result}"
|
||||
)
|
||||
|
||||
def test_image_adjacent_to_code_no_interference(self):
|
||||
"""Image immediately followed by code span in same cell — no token cross-talk."""
|
||||
t = ' `code`'
|
||||
result = inline_md(t)
|
||||
assert '<img ' in result
|
||||
assert '<code>code</code>' in result
|
||||
|
||||
def test_image_inside_code_span_not_rendered(self):
|
||||
"""An image syntax inside a backtick span must NOT render as an img tag."""
|
||||
t = '``'
|
||||
result = inline_md(t)
|
||||
# The whole thing is inside backticks — should be literal code, not an img
|
||||
assert '<img ' not in result, (
|
||||
f"Image syntax inside code span must not render as <img>. Got: {result}"
|
||||
)
|
||||
# Should render as a code element with the raw text inside
|
||||
assert '<code>' in result
|
||||
131
tests/test_issue487b.py
Normal file
131
tests/test_issue487b.py
Normal file
@@ -0,0 +1,131 @@
|
||||
r"""
|
||||
Regression test for image src URL corruption by the autolink pass.
|
||||
|
||||
Bug: the _al_stash before the autolink pass only stashed <a> tags.
|
||||
<img> tags produced by the  image pass were NOT stashed,
|
||||
so the autolink regex matched the URL inside src="..." and wrapped it
|
||||
in <a href="...">url</a>, producing src="<a href="...">url</a>" —
|
||||
a completely broken image source.
|
||||
|
||||
Fix: extend _al_stash regex to also stash <img> tags:
|
||||
(<a\b[^>]*>[\s\S]*?<\/a>|<img\b[^>]*>)
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent
|
||||
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text()
|
||||
|
||||
|
||||
# ── Source-level check ────────────────────────────────────────────────────────
|
||||
|
||||
def test_al_stash_includes_img_tags():
|
||||
"""_al_stash regex must stash both <a> and <img> tags to protect src= from autolink."""
|
||||
assert '<img\\b[^>]*>' in UI_JS or '<img\\\\b[^>]*>' in UI_JS, (
|
||||
"_al_stash should include <img> tag pattern to prevent autolink mangling src= URLs"
|
||||
)
|
||||
|
||||
|
||||
# ── Behaviour tests (Python mirror of fixed pipeline) ─────────────────────────
|
||||
|
||||
import html as _html
|
||||
def esc(s): return _html.escape(str(s), quote=True)
|
||||
|
||||
SAFE_TAGS = re.compile(
|
||||
r'^</?(strong|em|code|pre|h[1-6]|ul|ol|li|table|thead|tbody|tr|th|td'
|
||||
r'|hr|blockquote|p|br|a|img|div|span)([\s>]|$)', re.I
|
||||
)
|
||||
|
||||
|
||||
def render_with_image_and_autolink(raw):
|
||||
"""Simulate the image pass + SAFE_TAGS + _al_stash + autolink pipeline."""
|
||||
s = raw
|
||||
# Image pass
|
||||
s = re.sub(
|
||||
r'!\[([^\]]*)\]\((https?://[^\)]+)\)',
|
||||
lambda m: (
|
||||
f'<img src="{m.group(2).replace(chr(34), "%22")}" '
|
||||
f'alt="{esc(m.group(1))}" class="msg-media-img" loading="lazy">'
|
||||
),
|
||||
s,
|
||||
)
|
||||
# SAFE_TAGS
|
||||
s = re.sub(
|
||||
r'</?[a-zA-Z][^>]*>',
|
||||
lambda m: m.group() if SAFE_TAGS.match(m.group()) else esc(m.group()),
|
||||
s,
|
||||
)
|
||||
# _al_stash (fixed: stashes both <a> and <img>)
|
||||
al_stash = []
|
||||
s = re.sub(
|
||||
r'(<a\b[^>]*>[\s\S]*?<\/a>|<img\b[^>]*>)',
|
||||
lambda m: (al_stash.append(m.group(1)) or f'\x00B{len(al_stash)-1}\x00'),
|
||||
s,
|
||||
)
|
||||
# Autolink
|
||||
def autolink(m):
|
||||
url = m.group(1)
|
||||
trail = url[-1] if url[-1] in '.,;:!?)' else ''
|
||||
clean = url[:-1] if trail else url
|
||||
return f'<a href="{clean}" target="_blank" rel="noopener">{esc(clean)}</a>{trail}'
|
||||
s = re.sub(r'(https?://[^\s<>"\')\]]+)', autolink, s)
|
||||
# Restore
|
||||
s = re.sub(r'\x00B(\d+)\x00', lambda m: al_stash[int(m.group(1))], s)
|
||||
return s
|
||||
|
||||
|
||||
def test_image_src_not_mangled_by_autolink():
|
||||
"""The URL inside src= of a rendered <img> must not be wrapped in <a> by autolink."""
|
||||
url = 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/280px-PNG_transparency_demonstration_1.png'
|
||||
result = render_with_image_and_autolink(f'')
|
||||
assert f'src="{url}"' in result, f"src= URL should be intact, got: {result[:200]}"
|
||||
# The URL inside src= must NOT be wrapped in <a>
|
||||
src_part = result.split('src="')[1].split('"')[0]
|
||||
assert '<a ' not in src_part, f"src= must not contain <a> tag, got: {src_part}"
|
||||
assert src_part == url, f"src= URL mangled: expected {url}, got {src_part}"
|
||||
|
||||
|
||||
def test_image_tag_renders_as_img():
|
||||
""" must produce an <img> tag, not a plain link."""
|
||||
result = render_with_image_and_autolink('')
|
||||
assert '<img ' in result, f"Expected <img> tag, got: {result}"
|
||||
assert 'src="https://example.com/img.png"' in result
|
||||
assert '<a ' not in result # no spurious link wrapper
|
||||
|
||||
|
||||
def test_image_and_link_in_same_paragraph():
|
||||
"""Image and link in same paragraph must each render correctly without interference."""
|
||||
result = render_with_image_and_autolink(
|
||||
'See  and visit https://example.com'
|
||||
)
|
||||
assert '<img ' in result, "Image should render"
|
||||
assert '<a ' in result, "Bare URL should autolink"
|
||||
# img src must not contain <a>
|
||||
src_part = result.split('src="')[1].split('"')[0]
|
||||
assert '<a' not in src_part, f"src= mangled: {src_part}"
|
||||
|
||||
|
||||
def test_image_count_is_one():
|
||||
"""One  should produce exactly one <img> tag."""
|
||||
result = render_with_image_and_autolink('')
|
||||
assert result.count('<img ') == 1, f"Expected 1 <img>, got {result.count('<img ')}: {result}"
|
||||
|
||||
|
||||
def test_multiple_images_not_mangled():
|
||||
"""Multiple images in one message each get clean src= values."""
|
||||
urls = [
|
||||
'https://example.com/a.png',
|
||||
'https://example.com/b.png',
|
||||
]
|
||||
raw = '\n\n'.join(f'' for i, url in enumerate(urls))
|
||||
result = render_with_image_and_autolink(raw)
|
||||
for url in urls:
|
||||
assert f'src="{url}"' in result, f"src= for {url} mangled in: {result[:300]}"
|
||||
|
||||
|
||||
def test_image_with_query_string_src_intact():
|
||||
"""Image URL with & in query string must have & (not &) in src."""
|
||||
url = 'https://example.com/img?w=100&h=200&fmt=png'
|
||||
result = render_with_image_and_autolink(f'')
|
||||
assert f'src="{url}"' in result, f"Query string URL mangled: {result[:200]}"
|
||||
assert '&' not in result.split('src="')[1].split('"')[0]
|
||||
163
tests/test_issue569_579.py
Normal file
163
tests/test_issue569_579.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Tests for fixes:
|
||||
|
||||
- #569: docker_init.bash auto-detects WANTED_UID/WANTED_GID from mounted workspace
|
||||
so macOS users (UID 501) don't need to manually set the env var.
|
||||
- #579: Topbar message count already filters tool messages (role !== 'tool').
|
||||
The legacy raw sidebar count was removed by #584, and later reintroduced
|
||||
in a gated detailed-density mode by #673.
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent
|
||||
INIT_SH = (REPO_ROOT / "docker_init.bash").read_text(encoding="utf-8")
|
||||
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ── #569: docker UID/GID auto-detect ─────────────────────────────────────────
|
||||
|
||||
def test_569_uid_autodetect_present():
|
||||
"""docker_init.bash must have workspace-based UID auto-detection (#569)."""
|
||||
assert "stat -c '%u'" in INIT_SH or 'stat -c \'%u\'' in INIT_SH, (
|
||||
"docker_init.bash must use stat to read workspace UID (#569)"
|
||||
)
|
||||
|
||||
|
||||
def test_569_gid_autodetect_present():
|
||||
"""docker_init.bash must have workspace-based GID auto-detection (#569)."""
|
||||
assert "stat -c '%g'" in INIT_SH or 'stat -c \'%g\'' in INIT_SH, (
|
||||
"docker_init.bash must use stat to read workspace GID (#569)"
|
||||
)
|
||||
|
||||
|
||||
def test_569_autodetect_before_usermod():
|
||||
"""UID auto-detect must appear before usermod call in docker_init.bash."""
|
||||
detect_pos = INIT_SH.find("stat -c '%u'")
|
||||
if detect_pos == -1:
|
||||
detect_pos = INIT_SH.find("stat -c")
|
||||
usermod_pos = INIT_SH.find("sudo usermod")
|
||||
assert detect_pos != -1, "stat UID detection not found"
|
||||
assert usermod_pos != -1, "sudo usermod not found"
|
||||
assert detect_pos < usermod_pos, (
|
||||
"UID auto-detect must occur before 'sudo usermod' so the correct UID "
|
||||
"is used when remapping the hermeswebui user"
|
||||
)
|
||||
|
||||
|
||||
def test_569_skips_root_uid():
|
||||
"""Auto-detect must not use UID 0 (root-owned mount = untrustworthy)."""
|
||||
detect_block_start = INIT_SH.find("Auto-detect from mounted volumes")
|
||||
assert detect_block_start != -1, "auto-detect comment block not found"
|
||||
block = INIT_SH[detect_block_start:detect_block_start + 1200]
|
||||
assert '"0"' in block or "'0'" in block, (
|
||||
"Auto-detect block must skip UID 0 to avoid incorrectly using root ownership"
|
||||
)
|
||||
|
||||
|
||||
def test_569_fallback_preserved():
|
||||
"""Hardcoded default 1024 fallback must still exist after auto-detect."""
|
||||
assert "WANTED_UID=${WANTED_UID:-1024}" in INIT_SH, (
|
||||
"WANTED_UID default fallback must remain so explicit env var still works"
|
||||
)
|
||||
assert "WANTED_GID=${WANTED_GID:-1024}" in INIT_SH, (
|
||||
"WANTED_GID default fallback must remain"
|
||||
)
|
||||
|
||||
|
||||
# ── #668: UID/GID auto-detect from hermes-home shared volume (two-container) ──
|
||||
|
||||
def test_668_uid_autodetect_checks_hermes_home():
|
||||
"""docker_init.bash must probe hermes-home dirs for UID in two-container setups.
|
||||
|
||||
When hermes-agent and hermes-webui run in separate containers sharing a
|
||||
named volume, /workspace may not exist but ~/.hermes will be owned by the
|
||||
agent's UID. The init script must probe it so the webui user is remapped
|
||||
to match (#668).
|
||||
"""
|
||||
assert "/home/hermeswebui/.hermes" in INIT_SH, (
|
||||
"docker_init.bash must probe /home/hermeswebui/.hermes for UID detection "
|
||||
"to support two-container setups where /workspace may not exist (#668)"
|
||||
)
|
||||
|
||||
|
||||
def test_668_gid_autodetect_checks_hermes_home():
|
||||
"""docker_init.bash must probe hermes-home dirs for GID in two-container setups (#668)."""
|
||||
# Both UID and GID detection share the same probe dirs — check GID block too
|
||||
gid_detect_start = INIT_SH.find("Auto-detect GID from mounted volumes")
|
||||
assert gid_detect_start != -1, (
|
||||
"GID auto-detect comment must be updated to mention shared volumes (#668)"
|
||||
)
|
||||
gid_block = INIT_SH[gid_detect_start:gid_detect_start + 600]
|
||||
assert "/home/hermeswebui/.hermes" in gid_block or "HERMES_HOME" in gid_block, (
|
||||
"GID auto-detect block must probe hermes-home dirs (#668)"
|
||||
)
|
||||
|
||||
|
||||
def test_668_uid_probe_loop_uses_break():
|
||||
"""UID probe loop must stop on first match (no double-detection)."""
|
||||
uid_detect_start = INIT_SH.find("Auto-detect from mounted volumes")
|
||||
assert uid_detect_start != -1, "UID auto-detect comment not found"
|
||||
uid_block = INIT_SH[uid_detect_start:uid_detect_start + 1200]
|
||||
assert "break" in uid_block, (
|
||||
"UID probe loop must break after first successful detection "
|
||||
"to avoid being overridden by a later probe dir (#668)"
|
||||
)
|
||||
|
||||
|
||||
def test_668_hermes_home_probe_before_workspace():
|
||||
"""Hermes-home probe must appear before /workspace probe in docker_init.bash (#668)."""
|
||||
hermes_home_pos = INIT_SH.find("/home/hermeswebui/.hermes")
|
||||
workspace_pos = INIT_SH.find('if [ -d "/workspace" ]')
|
||||
assert hermes_home_pos != -1, "/home/hermeswebui/.hermes probe not found"
|
||||
assert workspace_pos != -1, "/workspace probe not found"
|
||||
assert hermes_home_pos < workspace_pos, (
|
||||
"Hermes-home probe must come before /workspace probe — "
|
||||
"shared volume UID should take priority over workspace UID (#668)"
|
||||
)
|
||||
|
||||
|
||||
# ── #579: topbar message count already filters tool messages ──────────────────
|
||||
|
||||
def test_579_topbar_filters_tool_messages():
|
||||
"""ui.js topbar count must filter out role='tool' messages (#579).
|
||||
|
||||
The sidebar previously showed raw message_count (which included tool
|
||||
messages), causing a mismatch with the topbar. PR #584 removed the
|
||||
sidebar count display entirely; the topbar was already correct.
|
||||
This test locks in the existing topbar filter so it can't regress.
|
||||
"""
|
||||
# Find the topbarMeta assignment
|
||||
meta_pos = UI_JS.find("topbarMeta")
|
||||
assert meta_pos != -1, "topbarMeta assignment not found in ui.js"
|
||||
|
||||
# Find the filter that precedes it — should exclude role==='tool'
|
||||
context = UI_JS[max(0, meta_pos - 400):meta_pos + 100]
|
||||
assert "role" in context and "tool" in context, (
|
||||
"topbarMeta count must filter by role — "
|
||||
"messages with role='tool' must be excluded from the displayed count"
|
||||
)
|
||||
# The filter must exclude tool messages (not include them)
|
||||
assert "!=='tool'" in context or "!= 'tool'" in context or "role!=='tool'" in context, (
|
||||
"topbar count filter must use !== 'tool' to exclude tool messages"
|
||||
)
|
||||
|
||||
|
||||
def test_579_sidebar_count_is_gated_behind_detailed_density():
|
||||
"""sessions.js may only show sidebar count inside detailed density mode.
|
||||
|
||||
PR #584 removed the always-visible raw sidebar count to avoid mismatching the
|
||||
topbar's filtered count. PR #673 later reintroduced message_count as
|
||||
optional metadata, but only when the user explicitly opts into detailed
|
||||
sidebar density.
|
||||
"""
|
||||
sessions_js = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
assert "const density=(window._sidebarDensity==='detailed'?'detailed':'compact');" in sessions_js, (
|
||||
"sessions.js must normalize sidebar density before rendering metadata"
|
||||
)
|
||||
assert "if(density==='detailed'){" in sessions_js, (
|
||||
"sessions.js must gate sidebar metadata behind detailed density mode"
|
||||
)
|
||||
assert "typeof s.message_count==='number'?s.message_count:0" in sessions_js, (
|
||||
"message_count may be rendered only inside the detailed-density branch"
|
||||
)
|
||||
56
tests/test_issue570_permission.py
Normal file
56
tests/test_issue570_permission.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Regression tests for GitHub issue #570 follow-up:
|
||||
PermissionError from SETTINGS_FILE.exists() in Docker UID-mismatch scenarios.
|
||||
|
||||
When ~/.hermes is owned by a different UID than the container user (common in
|
||||
Docker setups), Path.exists() raises PermissionError instead of returning False.
|
||||
load_settings() must treat that as "file not accessible = use defaults" rather
|
||||
than propagating the exception up to crash the request handler.
|
||||
"""
|
||||
import stat
|
||||
import pytest
|
||||
import api.config as config
|
||||
|
||||
|
||||
def test_load_settings_returns_defaults_when_settings_file_unreadable(monkeypatch, tmp_path):
|
||||
"""PermissionError from SETTINGS_FILE.exists() must not propagate — return defaults instead.
|
||||
|
||||
Regression for issue #570 comment: Docker UID mismatch caused every request
|
||||
to 500 because load_settings() called SETTINGS_FILE.exists() without catching OSError.
|
||||
"""
|
||||
state_dir = tmp_path / "state"
|
||||
state_dir.mkdir()
|
||||
settings_file = state_dir / "settings.json"
|
||||
# Create the file then make the parent unreadable so .exists() raises PermissionError
|
||||
settings_file.write_text('{"send_key": "ctrl+enter"}', encoding="utf-8")
|
||||
state_dir.chmod(stat.S_IWUSR) # write-only: stat() on children will fail
|
||||
|
||||
monkeypatch.setattr(config, "SETTINGS_FILE", settings_file)
|
||||
|
||||
try:
|
||||
result = config.load_settings()
|
||||
# Must not raise; must return a dict with default values
|
||||
assert isinstance(result, dict)
|
||||
assert "send_key" in result
|
||||
# The corrupted/inaccessible value should NOT appear — defaults win
|
||||
assert result["send_key"] == config._SETTINGS_DEFAULTS["send_key"]
|
||||
finally:
|
||||
state_dir.chmod(stat.S_IRWXU) # restore for cleanup
|
||||
|
||||
|
||||
def test_load_settings_returns_defaults_when_exists_raises_permission_error(monkeypatch, tmp_path):
|
||||
"""Direct simulation: monkeypatch SETTINGS_FILE.exists to raise PermissionError."""
|
||||
from unittest import mock
|
||||
|
||||
state_dir = tmp_path / "state"
|
||||
state_dir.mkdir()
|
||||
settings_file = state_dir / "settings.json"
|
||||
|
||||
monkeypatch.setattr(config, "SETTINGS_FILE", settings_file)
|
||||
|
||||
with mock.patch.object(type(settings_file), "exists",
|
||||
side_effect=PermissionError("Permission denied")):
|
||||
result = config.load_settings()
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result["send_key"] == config._SETTINGS_DEFAULTS["send_key"]
|
||||
205
tests/test_issue572.py
Normal file
205
tests/test_issue572.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""Tests for issue #572: onboarding must not fire or overwrite config for
|
||||
providers not in the quick-setup list (minimax-cn, deepseek, xai, etc.).
|
||||
|
||||
Root cause: _provider_api_key_present() only knew about the four providers in
|
||||
_SUPPORTED_PROVIDER_SETUPS. For any other provider it returned False, causing
|
||||
chat_ready=False, which made the wizard fire even when the user was fully
|
||||
configured. The second part of the fix ensures _saveOnboardingProviderSetup()
|
||||
in the frontend also skips the POST when current_is_oauth is set.
|
||||
|
||||
Covers:
|
||||
1. _provider_api_key_present returns True for minimax-cn when
|
||||
MINIMAX_CN_API_KEY is in env (via hermes_cli.auth.get_auth_status)
|
||||
2. _status_from_runtime gives chat_ready=True for minimax-cn with a key set
|
||||
3. get_onboarding_status returns completed=True for a fully-configured
|
||||
unsupported provider when config.yaml exists
|
||||
4. The hermes_cli import failure path is safe (falls back gracefully)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import types
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _inject_hermes_cli_auth(get_auth_status_return):
|
||||
"""Inject a minimal hermes_cli.auth stub into sys.modules.
|
||||
|
||||
CI doesn't install hermes_cli (it's a separate package). Tests that
|
||||
exercise the hermes_cli fallback path must inject the module themselves
|
||||
rather than relying on mock.patch('hermes_cli.auth.get_auth_status')
|
||||
which fails with ModuleNotFoundError when the module isn't installed.
|
||||
"""
|
||||
mock_auth = types.ModuleType("hermes_cli.auth")
|
||||
mock_auth.get_auth_status = mock.MagicMock(return_value=get_auth_status_return)
|
||||
mock_hermes_cli = types.ModuleType("hermes_cli")
|
||||
|
||||
return mock.patch.dict(sys.modules, {
|
||||
"hermes_cli": mock_hermes_cli,
|
||||
"hermes_cli.auth": mock_auth,
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _call_provider_api_key_present(provider: str, cfg: dict = None, env_values: dict = None):
|
||||
from api.onboarding import _provider_api_key_present
|
||||
return _provider_api_key_present(provider, cfg or {}, env_values or {})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. _provider_api_key_present via hermes_cli fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestProviderApiKeyPresentFallback:
|
||||
|
||||
def test_minimax_cn_logged_in_returns_true(self):
|
||||
"""minimax-cn: if hermes_cli.auth.get_auth_status returns logged_in, must be True."""
|
||||
with mock.patch("api.onboarding._SUPPORTED_PROVIDER_SETUPS", {
|
||||
"openrouter": {}, "anthropic": {}, "openai": {}, "custom": {}
|
||||
}):
|
||||
with _inject_hermes_cli_auth({"logged_in": True}):
|
||||
result = _call_provider_api_key_present("minimax-cn")
|
||||
assert result is True
|
||||
|
||||
def test_unsupported_provider_logged_out_returns_false(self):
|
||||
"""Unsupported provider with no key → False, no crash."""
|
||||
with mock.patch("api.onboarding._SUPPORTED_PROVIDER_SETUPS", {
|
||||
"openrouter": {}, "anthropic": {}, "openai": {}, "custom": {}
|
||||
}):
|
||||
with _inject_hermes_cli_auth({"logged_in": False}):
|
||||
result = _call_provider_api_key_present("deepseek")
|
||||
assert result is False
|
||||
|
||||
def test_hermes_cli_import_failure_is_safe(self):
|
||||
"""If hermes_cli is unavailable, falls back silently to False."""
|
||||
import builtins
|
||||
real_import = builtins.__import__
|
||||
|
||||
def _block_hermes_cli(name, *args, **kwargs):
|
||||
if name.startswith("hermes_cli"):
|
||||
raise ImportError("hermes_cli not available")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
with mock.patch("api.onboarding._SUPPORTED_PROVIDER_SETUPS", {
|
||||
"openrouter": {}, "anthropic": {}, "openai": {}, "custom": {}
|
||||
}):
|
||||
with mock.patch("builtins.__import__", side_effect=_block_hermes_cli):
|
||||
result = _call_provider_api_key_present("minimax-cn")
|
||||
assert result is False # safe fallback
|
||||
|
||||
def test_supported_provider_still_works_without_fallback(self):
|
||||
"""openrouter with env key must still succeed via the original path."""
|
||||
from api.onboarding import _provider_api_key_present, _SUPPORTED_PROVIDER_SETUPS
|
||||
env_values = {"OPENROUTER_API_KEY": "sk-test"}
|
||||
result = _provider_api_key_present("openrouter", {}, env_values)
|
||||
assert result is True
|
||||
|
||||
def test_inline_api_key_in_cfg_still_works(self):
|
||||
"""model.api_key in config.yaml must be recognized for any provider."""
|
||||
cfg = {"model": {"provider": "minimax-cn", "default": "MiniMax-M2.7", "api_key": "key123"}}
|
||||
result = _call_provider_api_key_present("minimax-cn", cfg)
|
||||
assert result is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. _status_from_runtime: unsupported provider with key → chat_ready=True
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStatusFromRuntimeUnsupportedProvider:
|
||||
|
||||
def _run(self, provider: str, model: str, api_key_present: bool, oauth_present: bool = False):
|
||||
from api.onboarding import _status_from_runtime
|
||||
cfg = {"model": {"provider": provider, "default": model}}
|
||||
with (
|
||||
mock.patch("api.onboarding._HERMES_FOUND", True),
|
||||
mock.patch("api.onboarding._load_env_file", return_value={}),
|
||||
mock.patch("api.onboarding._get_active_hermes_home", return_value=pathlib.Path("/tmp")),
|
||||
mock.patch("api.onboarding._provider_api_key_present", return_value=api_key_present),
|
||||
mock.patch("api.onboarding._provider_oauth_authenticated", return_value=oauth_present),
|
||||
):
|
||||
return _status_from_runtime(cfg, True)
|
||||
|
||||
def test_minimax_cn_with_key_gives_chat_ready(self):
|
||||
"""minimax-cn + api key present → chat_ready must be True."""
|
||||
result = self._run("minimax-cn", "MiniMax-M2.7", api_key_present=True)
|
||||
assert result["chat_ready"] is True, f"Expected chat_ready=True, got: {result}"
|
||||
assert result["provider_ready"] is True
|
||||
assert result["setup_state"] == "ready"
|
||||
|
||||
def test_deepseek_with_key_gives_chat_ready(self):
|
||||
"""deepseek + api key → chat_ready."""
|
||||
result = self._run("deepseek", "deepseek-chat", api_key_present=True)
|
||||
assert result["chat_ready"] is True
|
||||
|
||||
def test_unsupported_provider_no_key_no_oauth_gives_not_ready(self):
|
||||
"""No key, no oauth → provider_ready=False."""
|
||||
result = self._run("minimax-cn", "MiniMax-M2.7", api_key_present=False, oauth_present=False)
|
||||
assert result["chat_ready"] is False
|
||||
assert result["provider_ready"] is False
|
||||
|
||||
def test_oauth_provider_still_works_via_oauth_path(self):
|
||||
"""openai-codex (OAuth) with no api_key but oauth present → ready."""
|
||||
result = self._run("openai-codex", "codex-model", api_key_present=False, oauth_present=True)
|
||||
assert result["chat_ready"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. get_onboarding_status: minimax-cn fully configured → completed=True
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestOnboardingStatusUnsupportedProvider:
|
||||
|
||||
def _make_status(self, chat_ready: bool, provider: str = "minimax-cn"):
|
||||
import api.onboarding as mod
|
||||
fake_config_path = pathlib.Path("/tmp/_test_572_config.yaml")
|
||||
cfg = {"model": {"provider": provider, "default": "MiniMax-M2.7"}}
|
||||
runtime = {
|
||||
"chat_ready": chat_ready,
|
||||
"provider_configured": True,
|
||||
"provider_ready": chat_ready,
|
||||
"setup_state": "ready" if chat_ready else "provider_incomplete",
|
||||
"provider_note": "test",
|
||||
"current_provider": provider,
|
||||
"current_model": "MiniMax-M2.7",
|
||||
"current_base_url": None,
|
||||
"env_path": "/tmp/.env",
|
||||
}
|
||||
with (
|
||||
mock.patch.object(mod, "load_settings", return_value={}),
|
||||
mock.patch.object(mod, "get_config", return_value=cfg),
|
||||
mock.patch.object(mod, "verify_hermes_imports", return_value=(True, [], {})),
|
||||
mock.patch.object(mod, "_status_from_runtime", return_value=runtime),
|
||||
mock.patch.object(mod, "load_workspaces", return_value=[]),
|
||||
mock.patch.object(mod, "get_last_workspace", return_value=None),
|
||||
mock.patch.object(mod, "get_available_models", return_value=[]),
|
||||
mock.patch.object(mod, "_get_config_path", return_value=fake_config_path),
|
||||
mock.patch.object(pathlib.Path, "exists", return_value=True),
|
||||
):
|
||||
return mod.get_onboarding_status()
|
||||
|
||||
def test_minimax_cn_chat_ready_skips_wizard(self):
|
||||
"""minimax-cn + chat_ready=True + config.yaml exists → wizard must NOT fire."""
|
||||
result = self._make_status(chat_ready=True)
|
||||
assert result["completed"] is True, (
|
||||
"Wizard fired for minimax-cn user with valid config! "
|
||||
"config.yaml + chat_ready=True must auto-complete onboarding regardless of provider."
|
||||
)
|
||||
|
||||
def test_minimax_cn_not_ready_shows_wizard(self):
|
||||
"""minimax-cn + chat_ready=False → wizard fires so user can fix it."""
|
||||
result = self._make_status(chat_ready=False)
|
||||
assert result["completed"] is False
|
||||
|
||||
def test_current_is_oauth_set_for_unsupported_provider(self):
|
||||
"""setup.current_is_oauth must be True for minimax-cn (not in quick-setup list)."""
|
||||
result = self._make_status(chat_ready=True)
|
||||
assert result["setup"]["current_is_oauth"] is True, (
|
||||
"current_is_oauth should be True for providers not in _SUPPORTED_PROVIDER_SETUPS"
|
||||
)
|
||||
98
tests/test_issue607.py
Normal file
98
tests/test_issue607.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Tests for PR #648 — Gemma 4 thinking token stripping (closes #607)."""
|
||||
import re
|
||||
import pathlib
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _strip_thinking_markup tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from api.streaming import _strip_thinking_markup, _looks_invalid_generated_title
|
||||
|
||||
|
||||
class TestGemma4ThinkingTokenStrip:
|
||||
"""Verify that <|turn|>thinking\n...\n<turn|> blocks are stripped."""
|
||||
|
||||
def test_strip_gemma4_basic(self):
|
||||
"""Basic Gemma 4 thinking block stripped, answer kept."""
|
||||
raw = "<|turn|>thinking\nSome internal reasoning\n<turn|>Final answer"
|
||||
result = _strip_thinking_markup(raw)
|
||||
assert result == "Final answer"
|
||||
|
||||
def test_strip_gemma4_multiline_reasoning(self):
|
||||
"""Multi-line reasoning block stripped cleanly."""
|
||||
raw = "<|turn|>thinking\nLine 1\nLine 2\nLine 3\n<turn|>Answer here"
|
||||
result = _strip_thinking_markup(raw)
|
||||
assert result == "Answer here"
|
||||
|
||||
def test_strip_gemma4_no_thinking_passthrough(self):
|
||||
"""Normal response without thinking tokens passes through unchanged."""
|
||||
raw = "Normal response without thinking tokens"
|
||||
result = _strip_thinking_markup(raw)
|
||||
assert result == raw
|
||||
|
||||
def test_strip_gemma4_with_leading_whitespace(self):
|
||||
"""Leading whitespace before the thinking block is handled."""
|
||||
raw = "\n\n<|turn|>thinking\nReasoning\n<turn|>Answer"
|
||||
result = _strip_thinking_markup(raw)
|
||||
assert result == "Answer"
|
||||
|
||||
def test_strip_gemma4_empty_reasoning(self):
|
||||
"""Empty reasoning block (just delimiters) is stripped."""
|
||||
raw = "<|turn|>thinking\n<turn|>Response"
|
||||
result = _strip_thinking_markup(raw)
|
||||
assert result == "Response"
|
||||
|
||||
def test_strip_gemma4_case_insensitive(self):
|
||||
"""Pattern is case-insensitive (though Gemma 4 uses fixed case)."""
|
||||
raw = "<|TURN|>THINKING\nreasoning\n<TURN|>answer"
|
||||
result = _strip_thinking_markup(raw)
|
||||
# The regex uses re.IGNORECASE — should strip uppercase variant too
|
||||
assert "THINKING" not in result
|
||||
assert "reasoning" not in result
|
||||
|
||||
def test_existing_think_tag_still_works(self):
|
||||
"""Ensure <think>...</think> still stripped (no regression)."""
|
||||
raw = "<think>inner reasoning</think>Final"
|
||||
result = _strip_thinking_markup(raw)
|
||||
assert result == "Final"
|
||||
|
||||
def test_existing_channel_tag_still_works(self):
|
||||
"""Ensure <|channel|>thought...</channel|> still stripped."""
|
||||
raw = "<|channel|>thoughtSome reasoning<channel|>Answer"
|
||||
result = _strip_thinking_markup(raw)
|
||||
assert result == "Answer"
|
||||
|
||||
|
||||
class TestGemma4TitleLeakDetection:
|
||||
"""Verify _looks_invalid_generated_title catches Gemma 4 leak."""
|
||||
|
||||
def test_detects_gemma4_leak_in_title(self):
|
||||
raw = "<|turn|>thinking\nUser asked about X\n<turn|>Session Title"
|
||||
assert _looks_invalid_generated_title(raw) is True
|
||||
|
||||
def test_clean_title_not_flagged(self):
|
||||
assert _looks_invalid_generated_title("Python debugging session") is False
|
||||
|
||||
|
||||
class TestGemma4MessagesJsThinkPairs:
|
||||
"""Verify static/messages.js contains the correct Gemma 4 pair."""
|
||||
|
||||
def test_messages_js_has_correct_gemma4_open(self):
|
||||
js = pathlib.Path("static/messages.js").read_text()
|
||||
# Must have double-pipe format: <|turn|>thinking
|
||||
assert "<|turn|>thinking" in js, (
|
||||
"messages.js is missing correct Gemma 4 open delimiter '<|turn|>thinking'"
|
||||
)
|
||||
|
||||
def test_messages_js_no_wrong_gemma4_open(self):
|
||||
js = pathlib.Path("static/messages.js").read_text()
|
||||
# Must NOT have single-pipe wrong format: <|turn>thinking
|
||||
assert "<|turn>thinking" not in js, (
|
||||
"messages.js still contains wrong Gemma 4 delimiter '<|turn>thinking' (missing |)"
|
||||
)
|
||||
|
||||
def test_messages_js_has_gemma4_close(self):
|
||||
js = pathlib.Path("static/messages.js").read_text()
|
||||
assert "<turn|>" in js, "messages.js missing Gemma 4 close delimiter '<turn|>'"
|
||||
107
tests/test_issue609.py
Normal file
107
tests/test_issue609.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Tests for GitHub issue #609 — Docker workspace path trust and env-var priority.
|
||||
|
||||
Two independent bugs were fixed:
|
||||
|
||||
1. HERMES_WEBUI_DEFAULT_WORKSPACE env var was silently overridden by
|
||||
settings.json at server startup. The env var must always win.
|
||||
|
||||
2. resolve_trusted_workspace() rejected paths that are children of
|
||||
DEFAULT_WORKSPACE (e.g. /data/workspace/project) when the default is a
|
||||
Docker volume mount outside the user's home directory. Any path under
|
||||
the boot-time default should be trusted automatically.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from api.workspace import resolve_trusted_workspace
|
||||
|
||||
|
||||
# ── Fix 2: trust paths under DEFAULT_WORKSPACE ───────────────────────────────
|
||||
|
||||
def test_subdir_of_boot_default_is_trusted(monkeypatch, tmp_path):
|
||||
"""A subdirectory of BOOT_DEFAULT_WORKSPACE must be trusted without being in
|
||||
the saved workspace list and without being under the user's home directory.
|
||||
|
||||
This is the core Docker case: DEFAULT_WORKSPACE=/data/workspace, and the
|
||||
user tries to open /data/workspace/myproject — should NOT raise ValueError.
|
||||
"""
|
||||
import api.workspace as ws_mod
|
||||
|
||||
boot_default = tmp_path / "data" / "workspace"
|
||||
boot_default.mkdir(parents=True)
|
||||
sub = boot_default / "myproject"
|
||||
sub.mkdir()
|
||||
|
||||
monkeypatch.setattr(ws_mod, "_BOOT_DEFAULT_WORKSPACE", str(boot_default))
|
||||
|
||||
# Should not raise — sub is under the boot default
|
||||
result = resolve_trusted_workspace(str(sub))
|
||||
assert result == sub.resolve()
|
||||
|
||||
|
||||
def test_boot_default_itself_is_trusted(monkeypatch, tmp_path):
|
||||
"""The DEFAULT_WORKSPACE path itself must also be trusted (not only subdirs)."""
|
||||
import api.workspace as ws_mod
|
||||
|
||||
boot_default = tmp_path / "data" / "workspace"
|
||||
boot_default.mkdir(parents=True)
|
||||
|
||||
monkeypatch.setattr(ws_mod, "_BOOT_DEFAULT_WORKSPACE", str(boot_default))
|
||||
|
||||
result = resolve_trusted_workspace(str(boot_default))
|
||||
assert result == boot_default.resolve()
|
||||
|
||||
|
||||
def test_path_outside_boot_default_and_home_is_rejected(monkeypatch, tmp_path):
|
||||
"""A path that is not under home, not in the saved list, and not under
|
||||
DEFAULT_WORKSPACE must still be rejected."""
|
||||
import api.workspace as ws_mod
|
||||
|
||||
boot_default = tmp_path / "data" / "workspace"
|
||||
boot_default.mkdir(parents=True)
|
||||
outside = tmp_path / "other_mount" / "secret"
|
||||
outside.mkdir(parents=True)
|
||||
|
||||
monkeypatch.setattr(ws_mod, "_BOOT_DEFAULT_WORKSPACE", str(boot_default))
|
||||
|
||||
with pytest.raises(ValueError, match="outside the user home"):
|
||||
resolve_trusted_workspace(str(outside))
|
||||
|
||||
|
||||
def test_none_path_returns_boot_default(monkeypatch, tmp_path):
|
||||
"""resolve_trusted_workspace(None) always returns the boot default unchanged."""
|
||||
import api.workspace as ws_mod
|
||||
|
||||
boot_default = tmp_path / "data" / "workspace"
|
||||
boot_default.mkdir(parents=True)
|
||||
|
||||
monkeypatch.setattr(ws_mod, "_BOOT_DEFAULT_WORKSPACE", str(boot_default))
|
||||
|
||||
result = resolve_trusted_workspace(None)
|
||||
assert result == boot_default.resolve()
|
||||
|
||||
|
||||
def test_path_traversal_via_dotdot_does_not_escape_boot_default(monkeypatch, tmp_path):
|
||||
"""A path that uses `..` to escape DEFAULT_WORKSPACE must not be trusted by (C).
|
||||
|
||||
`Path.resolve()` collapses `..` before the `relative_to(boot_default)` check
|
||||
runs, so `/data/workspace/../etc` resolves to `/etc` and is rejected (it's
|
||||
also caught earlier by the system-roots block, but this test pins the
|
||||
behavior in case the order of conditions ever changes).
|
||||
"""
|
||||
import api.workspace as ws_mod
|
||||
|
||||
boot_default = tmp_path / "data" / "workspace"
|
||||
boot_default.mkdir(parents=True)
|
||||
sibling = tmp_path / "data" / "private"
|
||||
sibling.mkdir(parents=True)
|
||||
|
||||
monkeypatch.setattr(ws_mod, "_BOOT_DEFAULT_WORKSPACE", str(boot_default))
|
||||
|
||||
# `boot_default/../private` resolves to `tmp_path/data/private`, which is
|
||||
# NOT a child of boot_default and not under home — must reject.
|
||||
escape = boot_default / ".." / "private"
|
||||
with pytest.raises(ValueError, match="outside the user home"):
|
||||
resolve_trusted_workspace(str(escape))
|
||||
59
tests/test_issue632.py
Normal file
59
tests/test_issue632.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Issue #632: slash autocomplete should suggest second-level arguments.
|
||||
|
||||
Covers:
|
||||
- commands.js exposes a dedicated slash autocomplete parser/loader
|
||||
- /model sub-args hydrate from /api/models
|
||||
- /personality sub-args hydrate from /api/personalities
|
||||
- /reasoning provides static low/medium/high suggestions without becoming a
|
||||
locally executed built-in command
|
||||
- boot.js uses the async slash autocomplete helper while typing
|
||||
"""
|
||||
import pathlib
|
||||
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent
|
||||
COMMANDS_JS = (REPO_ROOT / "static" / "commands.js").read_text(encoding="utf-8")
|
||||
BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_subarg_registry_exists_and_reasoning_is_promoted_to_builtin():
|
||||
# SLASH_SUBARG_SOURCES still exists for model and personality
|
||||
assert "const SLASH_SUBARG_SOURCES=" in COMMANDS_JS
|
||||
# /reasoning is now a proper builtin command with a fn: handler (cmdReasoning)
|
||||
# so it is in the COMMANDS array, not SLASH_SUBARG_SOURCES
|
||||
assert "{name:'reasoning'" in COMMANDS_JS, \
|
||||
"/reasoning must be registered as a local built-in command with fn: handler"
|
||||
assert "fn:cmdReasoning" in COMMANDS_JS, \
|
||||
"/reasoning entry must reference cmdReasoning function"
|
||||
assert "function cmdReasoning" in COMMANDS_JS, \
|
||||
"cmdReasoning function must be defined"
|
||||
# source:'subarg-command' is still used for model/personality in SLASH_SUBARG_SOURCES
|
||||
assert "source:'subarg-command'" in COMMANDS_JS
|
||||
|
||||
|
||||
def test_model_and_personality_subargs_load_from_existing_apis():
|
||||
assert "_loadSlashModelSubArgs" in COMMANDS_JS
|
||||
assert "api('/api/models')" in COMMANDS_JS
|
||||
assert "_loadSlashPersonalitySubArgs" in COMMANDS_JS
|
||||
assert "api('/api/personalities')" in COMMANDS_JS
|
||||
|
||||
|
||||
def test_slash_autocomplete_parses_second_level_arguments():
|
||||
assert "function _parseSlashAutocomplete" in COMMANDS_JS
|
||||
assert "return {kind:'subargs'" in COMMANDS_JS
|
||||
assert "getSlashAutocompleteMatches" in COMMANDS_JS
|
||||
|
||||
|
||||
def test_boot_uses_async_slash_autocomplete_helper():
|
||||
assert "getSlashAutocompleteMatches(text).then(matches=>" in BOOT_JS
|
||||
|
||||
|
||||
def test_subarg_dropdown_has_distinct_parent_and_argument_styling():
|
||||
assert ".cmd-item-parent" in STYLE_CSS
|
||||
assert ".cmd-item-subarg" in STYLE_CSS
|
||||
assert ".cmd-item.selected{background:var(--accent-bg);" in STYLE_CSS
|
||||
assert "_cmdSelectedIdx=matches.length?0:-1;" in COMMANDS_JS
|
||||
assert "getSlashAutocompleteMatches(nextValue).then(matches=>" in COMMANDS_JS, \
|
||||
"selecting a first-level command with sub-args should immediately open second-level suggestions"
|
||||
75
tests/test_issue634.py
Normal file
75
tests/test_issue634.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
Tests for #634: CLI sessions not visible when setting is enabled.
|
||||
|
||||
Root cause: get_cli_sessions() swallowed all errors silently (bare except → return []).
|
||||
Users with older hermes-agent versions (missing 'source' column in state.db) got
|
||||
an empty list with no log output, making diagnosis impossible.
|
||||
|
||||
Fixes:
|
||||
1. Schema introspection: check for 'source' column before querying, log a warning
|
||||
if missing and return early.
|
||||
2. Exception path: log a warning instead of silently returning [].
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
MODELS_PY = pathlib.Path(__file__).parent.parent / 'api' / 'models.py'
|
||||
src = MODELS_PY.read_text(encoding='utf-8')
|
||||
|
||||
|
||||
class TestCliSessionsErrorSurface:
|
||||
"""get_cli_sessions() must log warnings instead of silently returning []."""
|
||||
|
||||
def test_schema_introspection_present(self):
|
||||
"""The function must check for the 'source' column before querying."""
|
||||
assert "PRAGMA table_info(sessions)" in src
|
||||
|
||||
def test_missing_source_column_logs_warning(self):
|
||||
"""If 'source' column is absent, a warning is logged."""
|
||||
# The warning message must mention the missing column and how to fix it
|
||||
assert "no 'source' column" in src or "has no 'source' column" in src
|
||||
|
||||
def test_missing_source_column_suggests_upgrade(self):
|
||||
"""Warning message must suggest upgrading hermes-agent."""
|
||||
assert "Upgrade hermes-agent" in src or "upgrade hermes-agent" in src.lower()
|
||||
|
||||
def test_exception_path_logs_warning(self):
|
||||
"""The except clause must call logger.warning, not silently pass."""
|
||||
# Find the exception handler in get_cli_sessions
|
||||
func_start = src.find("def get_cli_sessions()")
|
||||
func_end = src.find("\ndef ", func_start + 1)
|
||||
func_body = src[func_start:func_end] if func_end != -1 else src[func_start:]
|
||||
assert "warning(" in func_body, \
|
||||
"get_cli_sessions() exception handler must call logging.warning()"
|
||||
|
||||
def test_exception_path_includes_db_path(self):
|
||||
"""The warning must include the db_path for diagnosability."""
|
||||
func_start = src.find("def get_cli_sessions()")
|
||||
func_end = src.find("\ndef ", func_start + 1)
|
||||
func_body = src[func_start:func_end] if func_end != -1 else src[func_start:]
|
||||
# db_path should appear in the warning call
|
||||
warning_pos = func_body.find("warning(")
|
||||
warning_block = func_body[warning_pos:warning_pos + 300]
|
||||
assert "db_path" in warning_block, \
|
||||
"Warning must include db_path so admins can find the problematic database"
|
||||
|
||||
def test_still_returns_empty_on_error(self):
|
||||
"""Function must still return [] after logging (graceful degradation)."""
|
||||
# After the warning, it should return cli_sessions (the empty list) not raise
|
||||
func_start = src.find("def get_cli_sessions()")
|
||||
func_end = src.find("\ndef ", func_start + 1)
|
||||
func_body = src[func_start:func_end] if func_end != -1 else src[func_start:]
|
||||
# Must have a 'return' after the warning call
|
||||
warning_pos = func_body.find("_cli_err:")
|
||||
after_warning = func_body[warning_pos:warning_pos + 400]
|
||||
assert "return" in after_warning, \
|
||||
"Function must return after the warning (not raise)"
|
||||
|
||||
def test_source_column_check_before_sql_query(self):
|
||||
"""Schema check must happen before the main SQL SELECT."""
|
||||
pragma_pos = src.find("PRAGMA table_info(sessions)")
|
||||
select_pos = src.find("SELECT s.id, s.title, s.model")
|
||||
assert pragma_pos != -1, "PRAGMA check not found"
|
||||
assert select_pos != -1, "SELECT query not found"
|
||||
assert pragma_pos < select_pos, \
|
||||
"Schema introspection must run before the main SQL query"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user