mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-07 14:21:06 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96ca4383c7 | ||
|
|
2ecdc2e511 | ||
|
|
5353097c90 | ||
|
|
9011bd107a | ||
|
|
52b104b1ac | ||
|
|
00a2cfa079 | ||
|
|
6eaf32075c | ||
|
|
698a87d999 | ||
|
|
6c6267f33c | ||
|
|
cec75af6ae | ||
|
|
8ac2a8385e | ||
|
|
894dc24c6c | ||
|
|
c4dcde7145 | ||
|
|
67d5f44bf8 | ||
|
|
6a35eb6c5f | ||
|
|
12d0a4a051 | ||
|
|
c5e0e14823 | ||
|
|
9bb00346a2 | ||
|
|
672612ac1c | ||
|
|
f4e7685ccf | ||
|
|
267a2f4462 | ||
|
|
eec705cc98 | ||
|
|
b0d5f6e57c | ||
|
|
f2413d77e9 | ||
|
|
4f8770d13e | ||
|
|
984f52c827 | ||
|
|
aebdc298f9 | ||
|
|
fd5823ab83 | ||
|
|
d50da4e3a1 | ||
|
|
624a226d13 | ||
|
|
fa3252ad5d | ||
|
|
9797056e4d | ||
|
|
e2361d80d2 | ||
|
|
afd24ea6ae | ||
|
|
4ebba26767 | ||
|
|
598372c3fd | ||
|
|
2e477ecd40 | ||
|
|
0e121c7a13 | ||
|
|
415e618085 | ||
|
|
dfe29bce61 | ||
|
|
f51b80c222 | ||
|
|
17c585c402 | ||
|
|
41e804d93c | ||
|
|
524ff90e1a | ||
|
|
86834afedc | ||
|
|
fa77717a57 | ||
|
|
60d847ba33 | ||
|
|
a3f76dfd63 | ||
|
|
b4418e30df | ||
|
|
8a7102ec4d | ||
|
|
be26cb7995 | ||
|
|
736a4136e1 | ||
|
|
1d31e2a1e4 | ||
|
|
b8f9c63ae7 | ||
|
|
cdc3a26577 | ||
|
|
cdf8b98f54 | ||
|
|
5be4964c2b | ||
|
|
d914211e64 | ||
|
|
71c674dcdb | ||
|
|
0d38517888 | ||
|
|
e51e84e22a | ||
|
|
cffd240834 | ||
|
|
062fd4a95e | ||
|
|
f73ae5a62e | ||
|
|
e6e14f2714 | ||
|
|
a6cbc3621e | ||
|
|
aee5db5eb1 | ||
|
|
611c1701d3 | ||
|
|
5ec845f6bc | ||
|
|
a23b5f209c | ||
|
|
94413c7057 | ||
|
|
8fda835383 | ||
|
|
4278304914 | ||
|
|
75c57be831 | ||
|
|
2dbb263b02 | ||
|
|
955c709d96 | ||
|
|
70efffb5b6 | ||
|
|
027691b33d | ||
|
|
00ce92068a | ||
|
|
18022ceaf3 | ||
|
|
1904c0261b | ||
|
|
642e76948e | ||
|
|
1d0a8051d5 | ||
|
|
9f04da3afc | ||
|
|
134f24f199 | ||
|
|
6655733d5e | ||
|
|
f9f297bf90 | ||
|
|
ec5b2bdc4e | ||
|
|
a2aeb4ecb1 | ||
|
|
3feaa2aa3f | ||
|
|
8ea7fef827 | ||
|
|
36a94857a1 | ||
|
|
28ce98bf16 | ||
|
|
0be62ccad7 | ||
|
|
86ba33845f | ||
|
|
6f507fb210 | ||
|
|
1a7b4ae236 | ||
|
|
a9dd554e13 | ||
|
|
3724818db5 | ||
|
|
e9546cb564 | ||
|
|
8de8d90287 | ||
|
|
e5f536a6cb | ||
|
|
b6cc7e681b | ||
|
|
f2323d876d | ||
|
|
10af5bb1e4 | ||
|
|
237eb667d3 | ||
|
|
d6f18babe9 | ||
|
|
1747c8e538 | ||
|
|
f1cb66d09e | ||
|
|
4eedb86b4f | ||
|
|
e917cf5b37 | ||
|
|
c506238ce2 | ||
|
|
c35710e388 | ||
|
|
fb321d62cb | ||
|
|
f7d7072841 | ||
|
|
1c1005952a | ||
|
|
41856f6594 | ||
|
|
0beae0bca6 | ||
|
|
9469412db2 | ||
|
|
a345d9f8cf | ||
|
|
a82477069b | ||
|
|
81dd215de9 | ||
|
|
4b5f8d89f2 | ||
|
|
dc352b839b | ||
|
|
e838e2beca | ||
|
|
6053a43841 | ||
|
|
faaf82acd7 | ||
|
|
8fee63f4f0 | ||
|
|
7519b1c75c | ||
|
|
dbf3bb2e64 | ||
|
|
86bdfbd0da | ||
|
|
796dbb77b4 | ||
|
|
96a47b79d5 | ||
|
|
9c46ab821a | ||
|
|
12595032e7 | ||
|
|
a3d9552015 | ||
|
|
01220edb67 | ||
|
|
02b07b686a | ||
|
|
a70b006929 | ||
|
|
4cca3fac20 | ||
|
|
995ff61f3c | ||
|
|
f5fc88c599 | ||
|
|
428950b96c | ||
|
|
0553ba6c3f | ||
|
|
fd19f9d6bf | ||
|
|
b43b0bb518 | ||
|
|
d2411af9fd | ||
|
|
49e35e8bb7 | ||
|
|
c25724a222 | ||
|
|
d689e6080d | ||
|
|
cf4f8befe7 | ||
|
|
1cace5c864 | ||
|
|
7e9b0dcc25 | ||
|
|
0b48e23fa5 | ||
|
|
f6e4cd6899 | ||
|
|
c0f944e127 | ||
|
|
6aba354c16 | ||
|
|
71abb6c163 | ||
|
|
cf45533cf6 | ||
|
|
95994c45c5 | ||
|
|
ea081e3312 | ||
|
|
8cfe988383 | ||
|
|
e5c70bbaa0 | ||
|
|
c70ae83d4c | ||
|
|
f232cbfed6 | ||
|
|
08224f228f | ||
|
|
c8ead78419 | ||
|
|
55e1a53cfd | ||
|
|
8da42765dd | ||
|
|
75c9f2d81e | ||
|
|
2e61fef4d1 | ||
|
|
fb141be3e0 | ||
|
|
9051f31306 | ||
|
|
17bc654b6d | ||
|
|
084e654bcb | ||
|
|
7d2307deb0 | ||
|
|
226d73b45b | ||
|
|
52543768c7 | ||
|
|
3826885f4b | ||
|
|
e7b3f15240 | ||
|
|
7ddc17a38f | ||
|
|
d62ff4a608 | ||
|
|
488dd8459c | ||
|
|
b2d2cf22eb | ||
|
|
7b3e2eaa0b | ||
|
|
f5f205bee0 | ||
|
|
0edd96ccf6 | ||
|
|
eb6c0a23f0 | ||
|
|
c523890228 | ||
|
|
5c523d55f7 | ||
|
|
45dc5e3400 | ||
|
|
c42f4f2969 | ||
|
|
5c8dd32252 | ||
|
|
5964d113f8 | ||
|
|
fea2d02aaa | ||
|
|
81a7260194 | ||
|
|
3b1ab2848d | ||
|
|
80a70b86de | ||
|
|
9222b6a1d3 | ||
|
|
67a48427b8 | ||
|
|
d64ae1b0b1 | ||
|
|
27c0cabd81 | ||
|
|
2f5f7c1135 | ||
|
|
3bf804c9c8 | ||
|
|
0af0f9f125 | ||
|
|
ea67f6999a | ||
|
|
eb818c50ed | ||
|
|
092a62daaa | ||
|
|
1ed28f8863 | ||
|
|
38c7f7017d | ||
|
|
c924d3c9d7 | ||
|
|
6f8800aefe | ||
|
|
814716336a | ||
|
|
0530a1f7c2 | ||
|
|
f6db09bae2 | ||
|
|
217ddda0eb | ||
|
|
734ada7e1b | ||
|
|
a14e85d8e6 | ||
|
|
996b8a4044 | ||
|
|
164dd3bbef | ||
|
|
6577b69d5d | ||
|
|
dca4217501 | ||
|
|
cf4bef2f78 | ||
|
|
84b1e9815e | ||
|
|
c266cfb3cb | ||
|
|
a8301a2709 | ||
|
|
669023fc08 | ||
|
|
2f3f363c33 | ||
|
|
b440640929 | ||
|
|
155995b65c | ||
|
|
e890e30766 | ||
|
|
7b04360f93 | ||
|
|
31785cce47 | ||
|
|
e22a3c9ba9 | ||
|
|
b7196c23de | ||
|
|
fcb0c2d0ec | ||
|
|
e17efa53ce | ||
|
|
3f81245864 | ||
|
|
9471faa5f0 | ||
|
|
3b51719abc | ||
|
|
2ba95cf61f | ||
|
|
437a60676f | ||
|
|
f1b6a2144c | ||
|
|
c62d980112 | ||
|
|
9ee8b0cf79 | ||
|
|
051db74cdf | ||
|
|
49c157fae3 | ||
|
|
0c467e9d45 | ||
|
|
76ac5842ff | ||
|
|
5de8d26949 | ||
|
|
20fed551ba | ||
|
|
e4b31d6f69 | ||
|
|
b96f174775 | ||
|
|
f0010a607f | ||
|
|
ed0ed38e2d | ||
|
|
c4aec611b2 | ||
|
|
3ae6b3041b | ||
|
|
ebaeda85f1 | ||
|
|
815256d89b | ||
|
|
b14862351b | ||
|
|
f8f4e8a7df | ||
|
|
acf63bef73 | ||
|
|
098fd545b3 | ||
|
|
a7cd2de97a | ||
|
|
be58604387 | ||
|
|
5701fc65ce | ||
|
|
0351ecb11d | ||
|
|
d9042a16bd | ||
|
|
642f5120a7 | ||
|
|
98a139c3c7 | ||
|
|
828a2559a9 | ||
|
|
bf8285b8c9 | ||
|
|
a71fd34758 | ||
|
|
7d55581527 | ||
|
|
034f2fdebe | ||
|
|
c5b13accfd | ||
|
|
4791d6b676 | ||
|
|
8bf178febf | ||
|
|
1fa22cd29a | ||
|
|
e2858b9855 | ||
|
|
e6dbbc312a | ||
|
|
425bca7199 | ||
|
|
1298a3971c | ||
|
|
c40a03e6dc | ||
|
|
eebb870922 | ||
|
|
dd8201d20d | ||
|
|
a4c503d7ce | ||
|
|
23b3dfc2ca | ||
|
|
8be3f916f2 | ||
|
|
424b364a88 | ||
|
|
0788a91c64 | ||
|
|
ad98bf1d17 | ||
|
|
c67180edf1 | ||
|
|
49e82263ad | ||
|
|
736d7a840c | ||
|
|
a539e12c97 | ||
|
|
2a22daa4f0 | ||
|
|
27a5707de5 | ||
|
|
72d0bf667e | ||
|
|
e7ded666be | ||
|
|
3ae2123bce | ||
|
|
6d4874a491 | ||
|
|
b1ebc3d7ca | ||
|
|
5e52cf569f | ||
|
|
ff9ca03f3c | ||
|
|
5278ba0043 | ||
|
|
1ef68f83e7 | ||
|
|
aec327ee51 | ||
|
|
9fe2ad3f55 | ||
|
|
ba6966d83f | ||
|
|
4c953b424d | ||
|
|
6f83fe2c42 | ||
|
|
2ccf57ec46 | ||
|
|
89799596a6 | ||
|
|
48ec4a59ac | ||
|
|
e0d79b02f9 | ||
|
|
d45cd9d3cf | ||
|
|
5bc7f8cab3 | ||
|
|
b7d5ea2f82 | ||
|
|
d232721db6 | ||
|
|
5f49ec4599 | ||
|
|
54f81315bf | ||
|
|
6878105560 | ||
|
|
7f97c35aca | ||
|
|
651a56806a | ||
|
|
d276adf277 | ||
|
|
d8f58af1b5 | ||
|
|
dcba249d01 | ||
|
|
5f426c38cb | ||
|
|
128223891e | ||
|
|
17c9b92bc9 | ||
|
|
d1dec64362 | ||
|
|
011c9e600a | ||
|
|
2355265fdc | ||
|
|
76f90e5843 | ||
|
|
328a3f5bd2 | ||
|
|
1df3a3108b | ||
|
|
daa654bd35 | ||
|
|
68864c564e | ||
|
|
2c27afec08 | ||
|
|
1c9bfd2a6e | ||
|
|
be4e24d421 | ||
|
|
3a7a6955cf | ||
|
|
4f864bcd2d | ||
|
|
2c8eb88196 | ||
|
|
e0cfb80f53 | ||
|
|
86d10ed8f9 | ||
|
|
1c2388876c | ||
|
|
01f8c32559 | ||
|
|
894f133b7d | ||
|
|
8a4106a47e | ||
|
|
4c3edf4765 | ||
|
|
f05725c332 | ||
|
|
e8cb1cba7f | ||
|
|
8de1ec4165 | ||
|
|
3fbcea2f79 | ||
|
|
dc16e3a7cb | ||
|
|
5cdb59cfa9 | ||
|
|
0cf83f610a | ||
|
|
18b4affb87 | ||
|
|
aae92ceb26 | ||
|
|
926e7a38b8 | ||
|
|
366c8dbaae | ||
|
|
70f4240728 | ||
|
|
30a67051d0 | ||
|
|
b8b381a3b9 | ||
|
|
25d025fef8 | ||
|
|
8895949ef7 | ||
|
|
0da82e740c | ||
|
|
95cfbd4cc4 | ||
|
|
c8b1436022 | ||
|
|
9de04cd44e | ||
|
|
31ca248fbe | ||
|
|
63214a5655 | ||
|
|
86620837b2 | ||
|
|
75d0d0b966 | ||
|
|
6515aebac2 | ||
|
|
f801953992 | ||
|
|
53752401f2 | ||
|
|
ab61f892d8 | ||
|
|
034422c4d3 | ||
|
|
aece46db89 | ||
|
|
da70196350 | ||
|
|
88fcf2b443 | ||
|
|
12a3c0739e | ||
|
|
1e7bcb8b6e | ||
|
|
7df82763e3 | ||
|
|
e5b4021c6f | ||
|
|
c5eb2dd923 | ||
|
|
88942998c5 | ||
|
|
0255581677 | ||
|
|
9b5b758358 | ||
|
|
29c811217f | ||
|
|
5f81c5dc47 | ||
|
|
cbf7f1d9f9 | ||
|
|
d6a6c1c797 | ||
|
|
4e83ef04d5 | ||
|
|
34d7b2a3ba | ||
|
|
a5f54c9796 | ||
|
|
31fa54fb68 | ||
|
|
bc3f293861 | ||
|
|
21b0fdb0d4 | ||
|
|
564d0abebe | ||
|
|
413544b6d9 | ||
|
|
634e5826a5 | ||
|
|
c4f1e05dbd | ||
|
|
e38025769d | ||
|
|
e2b4464917 | ||
|
|
10edc50f05 | ||
|
|
f353087104 | ||
|
|
79946b9c4f | ||
|
|
3d8d1079bc | ||
|
|
6c8247af60 | ||
|
|
4cb96acd81 | ||
|
|
2713121725 | ||
|
|
ca40c88ef4 | ||
|
|
e28889d120 | ||
|
|
890fee81b0 | ||
|
|
5e633b4ff7 | ||
|
|
c11e66aa8b | ||
|
|
7c11a196d5 | ||
|
|
9fcc101844 | ||
|
|
a7c50df002 | ||
|
|
a4c9336416 | ||
|
|
2189884a59 | ||
|
|
f7c645c065 | ||
|
|
8bf77c0755 | ||
|
|
8c41d3611c | ||
|
|
4455c8bd1d | ||
|
|
2aa1ec2d56 | ||
|
|
7a55995818 | ||
|
|
82a97251ea | ||
|
|
2e58e320b5 | ||
|
|
301ab7c7b4 | ||
|
|
0fce78c1c3 | ||
|
|
095e524fca | ||
|
|
88278af79a | ||
|
|
c864d804b6 | ||
|
|
51f418f15a | ||
|
|
9f38ee338c | ||
|
|
b2f2149431 | ||
|
|
ed61ad2a9c | ||
|
|
06e614017d | ||
|
|
2411e7cd3b | ||
|
|
d9220bd1a5 | ||
|
|
8cfe44b71d | ||
|
|
1b47c1d076 | ||
|
|
b1594fbbe5 | ||
|
|
d251b2c0db | ||
|
|
5a52007005 | ||
|
|
4dd62738df | ||
|
|
6f703a6dbe | ||
|
|
f6b4101127 | ||
|
|
d581286885 | ||
|
|
0b736f7702 | ||
|
|
b6825a493c | ||
|
|
c0b0869b03 | ||
|
|
2219cc99bf | ||
|
|
befdd9d3e1 | ||
|
|
4681762e3c | ||
|
|
2d718defc0 | ||
|
|
6b93b42877 | ||
|
|
d3cb2f58f1 | ||
|
|
9bb1dd2044 | ||
|
|
9f78a77e01 | ||
|
|
01389205e9 | ||
|
|
c3472caf8e | ||
|
|
a63b2d4dae | ||
|
|
f1bfcc2253 | ||
|
|
8e13982710 | ||
|
|
a64f1dd99b | ||
|
|
ff5577ad70 | ||
|
|
d17c32d6a3 | ||
|
|
1c20a59675 | ||
|
|
02791a6d18 | ||
|
|
cb725bb209 | ||
|
|
770292e41c | ||
|
|
672fd99ccc | ||
|
|
090121c31b | ||
|
|
1f727f4b52 | ||
|
|
ebe4db78d4 | ||
|
|
6b5ad4f27d | ||
|
|
b486d6f2b4 | ||
|
|
43127e3982 | ||
|
|
6c688faf22 | ||
|
|
0a35538b74 | ||
|
|
c3e9a998ad | ||
|
|
72f66767ca | ||
|
|
031adf99bf | ||
|
|
e3040e9e25 | ||
|
|
6f1eae68ff | ||
|
|
a0dbad8078 | ||
|
|
085474b8dc | ||
|
|
453cc0d56f | ||
|
|
1d6a040129 | ||
|
|
b73bd26a5c | ||
|
|
a0e09803ac | ||
|
|
bbd0a2c673 | ||
|
|
2fcc25aab6 | ||
|
|
67bb56bd1d | ||
|
|
d511c5102e | ||
|
|
d408fa2ff1 | ||
|
|
970c75c8f6 | ||
|
|
ee8e6404b0 | ||
|
|
a7eb99bba9 | ||
|
|
17cb817594 | ||
|
|
431803abb9 | ||
|
|
594e379291 | ||
|
|
fd4f7d445c | ||
|
|
226b5f5492 | ||
|
|
9dc1c96377 | ||
|
|
4e1e6bad4e | ||
|
|
3de97a8892 | ||
|
|
adf7ca9729 | ||
|
|
c23a4a5827 | ||
|
|
4aabf3b8f5 | ||
|
|
6ce6413606 | ||
|
|
4fd5b0646d | ||
|
|
703daada09 | ||
|
|
d69c9c4b4f | ||
|
|
b22206c7bc | ||
|
|
a4f7975324 | ||
|
|
bcf88b7383 | ||
|
|
6a55bcb5e9 | ||
|
|
c7938c2d3e | ||
|
|
69a6db1904 | ||
|
|
1b6f782faf | ||
|
|
215aa14f25 | ||
|
|
4fc2e140bd | ||
|
|
0b4e603cfd | ||
|
|
2ec3d2e381 | ||
|
|
a775b04695 | ||
|
|
6f99190f82 | ||
|
|
4292678b8f | ||
|
|
68a8cad3ed | ||
|
|
c23936be78 | ||
|
|
7e9531b3da | ||
|
|
512e298e6e | ||
|
|
740acc8934 | ||
|
|
72fc52251b | ||
|
|
0c79d7db94 | ||
|
|
d298999c3c | ||
|
|
632b4ed283 | ||
|
|
b111d1e830 | ||
|
|
56dee6b938 | ||
|
|
d101771758 | ||
|
|
a5b1c2cefa | ||
|
|
ca1d469e9a | ||
|
|
47d9d6c75a | ||
|
|
f2709679db | ||
|
|
c330caf4da | ||
|
|
7adced3db5 | ||
|
|
a0b6d1cd11 | ||
|
|
774096d16b | ||
|
|
76186bb595 | ||
|
|
e346198ff3 | ||
|
|
98faf8139e | ||
|
|
17b93dcb7c | ||
|
|
52ecf67ef7 | ||
|
|
f74ba2ea53 | ||
|
|
4fb0dde729 | ||
|
|
f94a34c330 | ||
|
|
c7c0e37bc4 | ||
|
|
2f4e42b9cf | ||
|
|
d61a443eb8 | ||
|
|
52cc6b4af0 | ||
|
|
31361a98bf | ||
|
|
87b7bb2ccb | ||
|
|
48c34f735a | ||
|
|
537f8fef8e | ||
|
|
83ae413176 | ||
|
|
80f7679b61 | ||
|
|
b8488e0d2b | ||
|
|
d0afabb6ab | ||
|
|
656478c68d | ||
|
|
fccfd343d7 | ||
|
|
5a9b4f4fc4 | ||
|
|
4509f2f997 | ||
|
|
f9e1fff983 | ||
|
|
a6ffc04d6d | ||
|
|
a5c7abe074 | ||
|
|
2793e3bd33 | ||
|
|
1b9b768939 | ||
|
|
642425bf2e | ||
|
|
cc500be1c9 | ||
|
|
13806c323e | ||
|
|
2f361a3757 | ||
|
|
2dfb254f7b | ||
|
|
28b150f854 | ||
|
|
676b57bff2 | ||
|
|
9424830516 | ||
|
|
00e8d10994 | ||
|
|
bf69ec2da4 | ||
|
|
7924bf65d2 | ||
|
|
867d7477ff | ||
|
|
f5410a6feb | ||
|
|
bb7e399c30 | ||
|
|
4bd5912695 | ||
|
|
8c07c0c36a | ||
|
|
149abc896b | ||
|
|
069384b4f6 | ||
|
|
c644312d15 | ||
|
|
ac71d2b32e | ||
|
|
7274c10e9a | ||
|
|
f0afedc50c | ||
|
|
b5d14279aa | ||
|
|
2e12958759 | ||
|
|
e4d22e7ef1 | ||
|
|
e59e64ec5d | ||
|
|
393eec80f9 | ||
|
|
06425cf932 | ||
|
|
afbd64bc4f | ||
|
|
2614b82c9a | ||
|
|
5b8cdaf2d9 | ||
|
|
ea6b385df8 | ||
|
|
8ca9093d7d | ||
|
|
ec841ca1e9 | ||
|
|
c4442fed95 | ||
|
|
2496795bd9 | ||
|
|
8d29804204 | ||
|
|
1523c592a6 | ||
|
|
f8c4410a38 | ||
|
|
df4706577e | ||
|
|
afd1f61cc5 | ||
|
|
31d857f817 | ||
|
|
4126250bf1 | ||
|
|
709f8dc63d | ||
|
|
3985c50777 | ||
|
|
f68ccf30c7 |
No files matched your search
@@ -66,7 +66,7 @@ body:
|
||||
attributes:
|
||||
label: Strata version
|
||||
description: "In the app: Settings > About. In the Terminal: `strata --version`. From source: the About tab of the web page, or `engine` in `GET /v1/status`, or the git commit."
|
||||
placeholder: "Strata 0.2.1 · engine 0.1.46"
|
||||
placeholder: "Strata 0.2.3 · engine 0.1.48"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
|
||||
@@ -13,7 +13,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: 'Release tag whose engine packages go into the images (for example v0.2.1)'
|
||||
description: 'Release tag whose engine packages go into the images (for example v0.2.3)'
|
||||
required: true
|
||||
type: string
|
||||
flavors:
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Metal build
|
||||
|
||||
# Compile and link the Metal engine on a macOS runner (no GPU needed: the kernels are compiled at run time). The shared host files
|
||||
# (src/core/verify.cpp, src/prefill, src/program/generate.cpp) call kernel entry points that exist only for CUDA / HIP until Metal gets a
|
||||
# stub or an implementation; a missing one is a link error here, not on a user's Mac (owner round 9, G6).
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'include/**'
|
||||
- 'metal/**'
|
||||
- 'third_party/**'
|
||||
- 'CMakeLists.txt'
|
||||
- '.github/workflows/metal-build.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
metal-link:
|
||||
runs-on: macos-14
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
# setup.py's LLAMA_CPP_COMMIT and metal/setup_mac.py's metal-cpp pin
|
||||
LLAMA_CPP_COMMIT: 3cf03257f219afbe7334045ff7c6a06ac68c627d
|
||||
METAL_CPP_URL: https://developer.apple.com/metal/cpp/files/metal-cpp_26.zip
|
||||
METAL_CPP_SHA256: 4df3c078b9aadcb516212e9cb03004cbc5ce9a3e9c068fa3144d021db585a3a4
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Tools
|
||||
run: brew install ninja
|
||||
- name: metal-cpp
|
||||
run: |
|
||||
curl -fsSL "$METAL_CPP_URL" -o metal-cpp.zip
|
||||
echo "$METAL_CPP_SHA256 metal-cpp.zip" | shasum -a 256 -c -
|
||||
unzip -q metal-cpp.zip -d _mcpp
|
||||
- name: llama.cpp (ggml) at the pinned commit
|
||||
run: |
|
||||
mkdir llama.cpp && cd llama.cpp
|
||||
git init -q
|
||||
git remote add origin https://github.com/ggml-org/llama.cpp.git
|
||||
git fetch -q --depth 1 origin "$LLAMA_CPP_COMMIT"
|
||||
git checkout -q FETCH_HEAD
|
||||
- name: Configure
|
||||
run: |
|
||||
cmake -S metal -B build-metal -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
||||
-DMETAL_CPP_DIR="$PWD/_mcpp/metal-cpp" -DSTRATA_GGML_DIR="$PWD/llama.cpp"
|
||||
- name: Build and link the engine
|
||||
run: cmake --build build-metal --target strata
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Strata runs the Qwen3.8-Flash-Next mixture-of-experts model (and its Coder, Swift 1.5, Unsloth, Gyro and NVFP4
|
||||
variants, and Qwen3.6-35B-A3B) on a normal PC: one NVIDIA or AMD graphics card plus system RAM on Windows or Linux,
|
||||
or an Apple Silicon Mac. Version 0.2.1, engine 0.1.46. It has three parts:
|
||||
or an Apple Silicon Mac. Version 0.2.3, engine 0.1.48. It has three parts:
|
||||
|
||||
- **The app** (`apps/desktop`, the Tauri shell; `apps/host`, the Node host and the web UI). It is for people who do
|
||||
not want a terminal. The installer has the engine inside.
|
||||
|
||||
@@ -11,7 +11,7 @@ if not exist ".venv\Scripts\python.exe" (
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo Quick speed check. For results other people can compare with (the standard benchmark, 20 to 30 minutes), run: .venv\Scripts\python.exe strata.py bench run
|
||||
echo Quick speed check. For results other people can compare with (the standard benchmark, a few minutes), run: .venv\Scripts\python.exe strata.py bench run
|
||||
echo.
|
||||
".venv\Scripts\python.exe" tools\strata_bench.py %*
|
||||
set "RC=%ERRORLEVEL%"
|
||||
|
||||
+173
-7
@@ -1,4 +1,4 @@
|
||||
# Strata Engine - top-level build (P1.S1).
|
||||
# Strata Engine - top-level build (P1.S1).
|
||||
#
|
||||
# Targets (phase-1 §P1.S1): `strata_artifact` (no CUDA dependency), `tests`. `strata_plan` and the C++
|
||||
# `strata-pack` are added when their sources exist - a target with no sources would make the build graph
|
||||
@@ -8,7 +8,7 @@
|
||||
# below refuses anything older: silently building for a pre-Ampere arch would produce a binary that runs
|
||||
# and is wrong about its own performance, which is the worst of both outcomes.
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(strata VERSION 0.1.46.2 LANGUAGES CXX)
|
||||
project(strata VERSION 0.1.49 LANGUAGES CXX)
|
||||
# EXPERIMENTAL: the Intel Arc engine, the SYCL port in sycl/ (PR #423; icpx + oneMKL, docs/INTEL_ARC.md). It is its
|
||||
# own project that takes the unported sources from src/; with this on, nothing below is configured.
|
||||
option(STRATA_ENABLE_SYCL "EXPERIMENTAL: build the Intel Arc (SYCL) engine from sycl/ - docs/INTEL_ARC.md" OFF)
|
||||
@@ -336,6 +336,8 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
|
||||
add_library(strata_core STATIC src/core/device.cu src/core/pinned.cu src/platform/memory.cpp src/core/graph.cpp src/core/weights.cpp
|
||||
src/core/layout.cpp)
|
||||
target_include_directories(strata_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
|
||||
# the DFlash2 drafter (src/core/dflash2.cpp, src/kernels/cuda/dflash2.cu) exists in the CUDA and HIP builds; the Metal and SYCL builds compile without it
|
||||
target_compile_definitions(strata_core PUBLIC STRATA_HAVE_DFLASH2=1)
|
||||
if(STRATA_ENABLE_CUDA AND STRATA_EXPERIMENTAL_SM60)
|
||||
# the runtime device check (src/core/device.cu) admits the cards this build was made for (#236)
|
||||
target_compile_definitions(strata_core PRIVATE STRATA_EXPERIMENTAL_SM60=1)
|
||||
@@ -418,6 +420,7 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
|
||||
src/kernels/cuda/dequant_bf16.cu
|
||||
src/kernels/cuda/qsa_select.cu
|
||||
src/kernels/cuda/verify_kernels.cu
|
||||
src/kernels/cuda/dflash2.cu
|
||||
src/kernels/cuda/iq_kernels.cu
|
||||
src/kernels/ngram.cpp
|
||||
src/ngram/ple_reader.cpp
|
||||
@@ -579,7 +582,7 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
|
||||
# about arithmetic, and this one knows the ORDER the arithmetic happens in.
|
||||
add_library(strata_engine STATIC src/core/layer.cpp src/core/session.cpp src/core/expert_source.cpp src/core/remote_experts.cpp
|
||||
src/core/remote_expert_opt.cu
|
||||
src/core/expert_cache.cpp src/core/native_head.cpp src/core/native_dense.cpp src/core/verify.cpp src/core/mtp.cpp
|
||||
src/core/expert_cache.cpp src/core/native_head.cpp src/core/native_dense.cpp src/core/verify.cpp src/core/mtp.cpp src/core/dflash2.cpp
|
||||
src/core/conversation_snapshot.cpp src/core/conversation_state.cpp src/core/conversation_memory.cpp src/core/peer_experts.cpp
|
||||
src/core/arch_defaults.cpp src/core/nv_defaults.cpp src/core/vmm.cpp src/core/conversation_file.cpp)
|
||||
target_include_directories(strata_engine PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
|
||||
@@ -732,6 +735,27 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
|
||||
target_link_libraries(tune_rocblas PRIVATE hip::host roc::rocblas)
|
||||
endif()
|
||||
|
||||
# DFlash2 drafter tools (not built by default: cmake --build <dir> --target dflash2_parity dflash2_gemm_bench dflash2_attn_bench): the drafter against
|
||||
# the torch port on a synthetic case (needs the pack and a case file, tools/dflash2/mk_parity_case.py), and the timing of its Q8 GEMM and attention
|
||||
add_executable(dflash2_parity EXCLUDE_FROM_ALL src/core/dflash2_parity.cpp)
|
||||
target_include_directories(dflash2_parity PRIVATE ${_strata_gpu_include_directories})
|
||||
target_link_libraries(dflash2_parity PRIVATE strata_engine strata_kernels ${_strata_gpu_runtime_target})
|
||||
add_executable(dflash2_gemm_bench EXCLUDE_FROM_ALL src/core/dflash2_gemm_bench.cpp)
|
||||
target_include_directories(dflash2_gemm_bench PRIVATE ${_strata_gpu_include_directories})
|
||||
target_link_libraries(dflash2_gemm_bench PRIVATE strata_kernels ${_strata_gpu_runtime_target})
|
||||
add_executable(dflash2_launch_bench EXCLUDE_FROM_ALL src/core/dflash2_launch_bench.cu)
|
||||
target_include_directories(dflash2_launch_bench PRIVATE ${_strata_gpu_include_directories})
|
||||
target_link_libraries(dflash2_launch_bench PRIVATE ${_strata_gpu_runtime_target})
|
||||
add_executable(dflash2_bw_bench EXCLUDE_FROM_ALL src/core/dflash2_bw_bench.cu)
|
||||
target_include_directories(dflash2_bw_bench PRIVATE ${_strata_gpu_include_directories})
|
||||
target_link_libraries(dflash2_bw_bench PRIVATE ${_strata_gpu_runtime_target})
|
||||
add_executable(dflash2_small_bench EXCLUDE_FROM_ALL src/core/dflash2_small_bench.cpp)
|
||||
target_include_directories(dflash2_small_bench PRIVATE ${_strata_gpu_include_directories})
|
||||
target_link_libraries(dflash2_small_bench PRIVATE strata_kernels ${_strata_gpu_runtime_target})
|
||||
add_executable(dflash2_attn_bench EXCLUDE_FROM_ALL src/core/dflash2_attn_bench.cpp)
|
||||
target_include_directories(dflash2_attn_bench PRIVATE ${_strata_gpu_include_directories})
|
||||
target_link_libraries(dflash2_attn_bench PRIVATE strata_kernels ${_strata_gpu_runtime_target})
|
||||
|
||||
add_executable(strata src/program/generate.cpp)
|
||||
strata_utf8_manifest(strata)
|
||||
target_include_directories(strata PRIVATE ${_strata_gpu_include_directories})
|
||||
@@ -770,6 +794,108 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
|
||||
target_compile_definitions(strata PRIVATE STRATA_HAVE_QWEN35=1)
|
||||
endif()
|
||||
|
||||
# DeepSeek-V4.1-Flash: the third engine path (src/dsv41, from Project Maya's maya-ds4, MIT, third_party/project-maya).
|
||||
# Its own executable for now (strata-dsv41); a family switch in `strata` comes after first light. OFF by default: it
|
||||
# changes no existing target (the vendored kernels live in strata::dsv41k, so no symbol is shared with strata_kernels).
|
||||
option(STRATA_BUILD_DSV41 "Build the DeepSeek-V4.1 engine path (strata-dsv41)" OFF)
|
||||
if(STRATA_BUILD_DSV41 AND NOT STRATA_HIP_GFX906)
|
||||
add_library(strata_dsv41_kernels STATIC src/dsv41/vendor/exl3.cu src/dsv41/vendor/sampler.cu src/dsv41/vendor/moe_win.cu
|
||||
src/dsv41/vendor/exl3_cpu.cpp)
|
||||
if(NOT STRATA_ENABLE_HIP) # (the pre-sm_80 NVIDIA decode kernels exl3.cu dispatches to)
|
||||
target_sources(strata_dsv41_kernels PRIVATE src/dsv41/vendor/exl3_legacy.cu)
|
||||
endif()
|
||||
target_include_directories(strata_dsv41_kernels PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include ${_strata_gpu_include_directories})
|
||||
target_link_libraries(strata_dsv41_kernels PUBLIC ${_strata_gpu_runtime_target})
|
||||
add_executable(strata-dsv41 src/dsv41/ds4_main.cpp src/dsv41/ds4_model.cu src/dsv41/ds4_kernels.cu src/dsv41/ds4_vision.cu
|
||||
src/dsv41/ds4_gguf.cu src/dsv41/ds4_gguf_cpu.cpp src/dsv41/ds4_iq.cu src/dsv41/ds4_iq_cpu.cpp
|
||||
src/dsv41/ds4_tq.cu src/dsv41/ds4_tq_cpu.cpp src/dsv41/ds4_gg_cpu.cpp src/dsv41/ds4_packinfo.cpp)
|
||||
# the commit the binary was built from, readable with --version (set -DSTRATA_BUILD_ID=... for a tree without .git)
|
||||
if(NOT STRATA_BUILD_ID AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/BUILD_ID) # (a git archive has no .git: the packer writes the commit here)
|
||||
file(STRINGS ${CMAKE_CURRENT_SOURCE_DIR}/BUILD_ID STRATA_BUILD_ID LIMIT_COUNT 1)
|
||||
endif()
|
||||
if(NOT STRATA_BUILD_ID)
|
||||
execute_process(COMMAND git -C ${CMAKE_CURRENT_SOURCE_DIR} rev-parse --short=9 HEAD OUTPUT_VARIABLE STRATA_BUILD_ID
|
||||
OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET)
|
||||
endif()
|
||||
if(NOT STRATA_BUILD_ID)
|
||||
set(STRATA_BUILD_ID unknown)
|
||||
endif()
|
||||
set_source_files_properties(src/dsv41/ds4_main.cpp PROPERTIES COMPILE_DEFINITIONS "DS4_BUILD_ID=\"${STRATA_BUILD_ID}\"")
|
||||
target_include_directories(strata-dsv41 PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${_strata_gpu_include_directories}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/third_party/ggml)
|
||||
target_link_libraries(strata-dsv41 PRIVATE strata_dsv41_kernels ${_strata_gpu_runtime_target} ${_strata_gpu_blas_target})
|
||||
find_package(Threads REQUIRED)
|
||||
target_link_libraries(strata-dsv41 PRIVATE Threads::Threads)
|
||||
if(WIN32 AND STRATA_ENABLE_HIP) # cudaMemGetInfo is the DXGI-budgeted hip_compat::mem_get_info (src/core/device.cu): on a Strix Halo the
|
||||
# carve-out is not all the GPU may use, and the engine sizes its expert tiers by what the budget says is free
|
||||
target_link_libraries(strata-dsv41 PRIVATE strata_core)
|
||||
endif()
|
||||
if(STRATA_NATIVE_EXPERTS) # Strata's CPU trellis kernels (tq_cpu.hpp) for the trellis experts on the CPU lane
|
||||
target_link_libraries(strata-dsv41 PRIVATE strata_kernels_cpu)
|
||||
target_compile_definitions(strata-dsv41 PRIVATE STRATA_DSV41_TQ_CPU=1)
|
||||
endif()
|
||||
if(STRATA_NATIVE_EXPERTS)
|
||||
# the CPU lane's ggml-type rows: ggml-cpu's dots against Strata's AVX2 i-quant rows (bits and GB/s); no GPU, no model
|
||||
add_executable(ds4_gg_bench tests/dsv41/gg_dot_bench.cpp src/dsv41/ds4_gg_cpu.cpp)
|
||||
target_include_directories(ds4_gg_bench PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/third_party/ggml)
|
||||
target_link_libraries(ds4_gg_bench PRIVATE strata_kernels_cpu Threads::Threads)
|
||||
target_compile_definitions(ds4_gg_bench PRIVATE STRATA_DSV41_TQ_CPU=1)
|
||||
endif()
|
||||
if(STRATA_NATIVE_EXPERTS)
|
||||
# the trellis experts (device kernels ds4_tq.cu, CPU lane ds4_tq_cpu.cpp) against a double reference; needs a GPU, no model
|
||||
# both test .cpp files launch kernels, so they compile as HIP on the AMD backend and as CUDA on the NVIDIA one
|
||||
if(STRATA_ENABLE_HIP)
|
||||
set(_ds4_test_lang HIP)
|
||||
elseif(STRATA_ENABLE_CUDA)
|
||||
set(_ds4_test_lang CUDA)
|
||||
else()
|
||||
set(_ds4_test_lang CXX)
|
||||
endif()
|
||||
set_source_files_properties(tests/dsv41/tq_expert_test.cpp PROPERTIES LANGUAGE ${_ds4_test_lang})
|
||||
add_executable(ds4_tq_test tests/dsv41/tq_expert_test.cpp src/dsv41/ds4_tq.cu src/dsv41/ds4_iq.cu src/dsv41/ds4_tq_cpu.cpp
|
||||
src/dsv41/ds4_iq_cpu.cpp src/dsv41/ds4_gg_cpu.cpp tools/trellis/tqenc.cpp)
|
||||
target_include_directories(ds4_tq_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${_strata_gpu_include_directories}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/third_party/ggml ${CMAKE_CURRENT_SOURCE_DIR}/src/kernels/cpu)
|
||||
target_link_libraries(ds4_tq_test PRIVATE strata_kernels_cpu ${_strata_gpu_runtime_target} Threads::Threads)
|
||||
target_compile_definitions(ds4_tq_test PRIVATE STRATA_DSV41_TQ_CPU=1)
|
||||
# the same with the plain-FMA tile instead of the gfx12 WMMA one (what CUDA and gfx11 builds run)
|
||||
add_executable(ds4_tq_test_portable tests/dsv41/tq_expert_test.cpp src/dsv41/ds4_tq.cu src/dsv41/ds4_iq.cu src/dsv41/ds4_tq_cpu.cpp
|
||||
src/dsv41/ds4_iq_cpu.cpp src/dsv41/ds4_gg_cpu.cpp tools/trellis/tqenc.cpp)
|
||||
target_include_directories(ds4_tq_test_portable PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${_strata_gpu_include_directories}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/third_party/ggml ${CMAKE_CURRENT_SOURCE_DIR}/src/kernels/cpu)
|
||||
target_link_libraries(ds4_tq_test_portable PRIVATE strata_kernels_cpu ${_strata_gpu_runtime_target} Threads::Threads)
|
||||
target_compile_definitions(ds4_tq_test_portable PRIVATE STRATA_DSV41_TQ_CPU=1 DS4_TQ_PORTABLE=1)
|
||||
if(STRATA_ENABLE_HIP)
|
||||
target_compile_options(ds4_tq_test_portable PRIVATE "$<$<COMPILE_LANGUAGE:HIP,CXX>:SHELL:-include ${CMAKE_CURRENT_SOURCE_DIR}/include/strata/dsv41/hip_extra.h>")
|
||||
endif()
|
||||
if(STRATA_ENABLE_HIP)
|
||||
target_compile_options(ds4_tq_test PRIVATE "$<$<COMPILE_LANGUAGE:HIP,CXX>:SHELL:-include ${CMAKE_CURRENT_SOURCE_DIR}/include/strata/dsv41/hip_extra.h>")
|
||||
endif()
|
||||
# the i-quant experts (ds4_iq.cu) against ggml's own decode of the same blocks + a double reference, device and CPU
|
||||
# lane, for every type the device takes: the gate a new type (IQ2_XS ..) has to pass before the engine uses it
|
||||
set_source_files_properties(tests/dsv41/iq_expert_test.cpp PROPERTIES LANGUAGE ${_ds4_test_lang})
|
||||
add_executable(ds4_iq_test tests/dsv41/iq_expert_test.cpp src/dsv41/ds4_iq.cu src/dsv41/ds4_iq_cpu.cpp
|
||||
src/dsv41/ds4_gg_cpu.cpp src/dsv41/ds4_tq.cu src/dsv41/ds4_tq_cpu.cpp)
|
||||
target_include_directories(ds4_iq_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${_strata_gpu_include_directories}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/third_party/ggml ${CMAKE_CURRENT_SOURCE_DIR}/src/kernels/cpu)
|
||||
target_link_libraries(ds4_iq_test PRIVATE strata_kernels_cpu ${_strata_gpu_runtime_target} Threads::Threads)
|
||||
target_compile_definitions(ds4_iq_test PRIVATE STRATA_DSV41_TQ_CPU=1)
|
||||
if(STRATA_ENABLE_HIP)
|
||||
target_compile_options(ds4_iq_test PRIVATE "$<$<COMPILE_LANGUAGE:HIP,CXX>:SHELL:-include ${CMAKE_CURRENT_SOURCE_DIR}/include/strata/dsv41/hip_extra.h>")
|
||||
endif()
|
||||
# the kept conversations' .meta file and prefix matching (ds4_slotmeta.hpp): no GPU, no model
|
||||
add_executable(ds4_slot_test tests/dsv41/slot_test.cpp)
|
||||
target_include_directories(ds4_slot_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41)
|
||||
target_compile_features(ds4_slot_test PRIVATE cxx_std_17)
|
||||
endif()
|
||||
if(STRATA_ENABLE_HIP)
|
||||
foreach(_t strata_dsv41_kernels strata-dsv41)
|
||||
target_compile_options(${_t} PRIVATE "$<$<COMPILE_LANGUAGE:HIP,CXX>:SHELL:-include ${CMAKE_CURRENT_SOURCE_DIR}/include/strata/dsv41/hip_extra.h>")
|
||||
endforeach()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(STRATA_ENABLE_HIP)
|
||||
enable_testing()
|
||||
set(_strata_hip_tests)
|
||||
@@ -992,6 +1118,12 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
|
||||
target_include_directories(iq_multi_parity PRIVATE ${_strata_gpu_include_directories})
|
||||
target_link_libraries(iq_multi_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
|
||||
add_test(NAME iq_multi_parity COMMAND iq_multi_parity)
|
||||
# the Gyro trellis decode kernels (TQ2_T / TQK6 / TQK7) on synthetic blocks: decode-once vs per-column bitwise, output
|
||||
# checksums to compare two builds byte for byte, --bench for timings (GPU, no model)
|
||||
add_executable(tq_pascal_bench src/kernels/tq_pascal_bench.cpp)
|
||||
target_include_directories(tq_pascal_bench PRIVATE ${_strata_gpu_include_directories})
|
||||
target_link_libraries(tq_pascal_bench PRIVATE strata_kernels ${_strata_gpu_runtime_target})
|
||||
add_test(NAME tq_pascal_bench COMMAND tq_pascal_bench)
|
||||
set_tests_properties(iq_multi_parity PROPERTIES ENVIRONMENT "STRATA_KQ_SPLIT=1") # the Q4_K / Q5_K / Q8_0 Split path against the old kernels
|
||||
# the same Split path on the other routes it can meet: the generic multi kernels (no V2K) and the fused gate/up + SwiGLU kernel, and the sub-16-row
|
||||
# gate/up route switched off (a K-quant row has 160 dots, not the 80 the sub-16 rows assume: STRATA_KQ_SPLIT=1 must stay bitwise on all of them)
|
||||
@@ -1075,7 +1207,6 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
|
||||
target_link_libraries(verify_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
|
||||
add_test(NAME verify_parity COMMAND verify_parity --selftest)
|
||||
endif()
|
||||
|
||||
add_executable(fused_gr_bench src/kernels/fused_gr_bench.cpp)
|
||||
target_include_directories(fused_gr_bench PRIVATE ${_strata_gpu_include_directories})
|
||||
target_link_libraries(fused_gr_bench PRIVATE strata_kernels ${_strata_gpu_runtime_target})
|
||||
@@ -1407,6 +1538,9 @@ if(STRATA_BUILD_TESTS)
|
||||
add_executable(arch_defaults_test src/core/arch_defaults_test.cpp src/core/arch_defaults.cpp)
|
||||
target_include_directories(arch_defaults_test PRIVATE include)
|
||||
add_test(NAME arch_defaults_test COMMAND arch_defaults_test)
|
||||
add_executable(uma_cache_test src/core/uma_cache_test.cpp)
|
||||
target_include_directories(uma_cache_test PRIVATE include)
|
||||
add_test(NAME uma_cache_test COMMAND uma_cache_test)
|
||||
add_executable(suffix_drafter_test src/spec/suffix_drafter_test.cpp)
|
||||
target_link_libraries(suffix_drafter_test PRIVATE strata_spec)
|
||||
add_test(NAME suffix_drafter_test COMMAND suffix_drafter_test)
|
||||
@@ -1416,6 +1550,9 @@ if(STRATA_BUILD_TESTS)
|
||||
add_executable(draft_policy_test src/spec/draft_policy_test.cpp)
|
||||
target_link_libraries(draft_policy_test PRIVATE strata_spec)
|
||||
add_test(NAME draft_policy_test COMMAND draft_policy_test)
|
||||
add_executable(spec_start_test src/spec/spec_start_test.cpp)
|
||||
target_include_directories(spec_start_test PRIVATE include)
|
||||
add_test(NAME spec_start_test COMMAND spec_start_test)
|
||||
endif()
|
||||
if(STRATA_BUILD_TESTS OR STRATA_BUILD_CONVERSATION_TESTS)
|
||||
add_executable(message_boundary_test src/program/message_boundary_test.cpp)
|
||||
@@ -1441,6 +1578,10 @@ if(STRATA_BUILD_TESTS OR STRATA_BUILD_CONVERSATION_TESTS)
|
||||
add_executable(spec_prob_test src/core/spec_prob_test.cpp)
|
||||
target_include_directories(spec_prob_test PRIVATE include)
|
||||
add_test(NAME spec_prob_test COMMAND spec_prob_test)
|
||||
# the per-card window cap of --draft dflash2 (header-only, CPU)
|
||||
add_executable(draft_cap_test src/core/draft_cap_test.cpp)
|
||||
target_include_directories(draft_cap_test PRIVATE include)
|
||||
add_test(NAME draft_cap_test COMMAND draft_cap_test)
|
||||
# the f32 -> bf16 rounding against ggml's, NaN included, over every f32 bit pattern (header-only, CPU)
|
||||
add_executable(bf16_bits_test src/kernels/bf16_bits_test.cpp)
|
||||
target_include_directories(bf16_bits_test PRIVATE include)
|
||||
@@ -1584,7 +1725,7 @@ if(STRATA_NATIVE_EXPERTS)
|
||||
set_source_files_properties(src/kernels/cpu/iq_avx1.cpp PROPERTIES COMPILE_OPTIONS "/arch:AVX")
|
||||
else()
|
||||
set_source_files_properties(src/kernels/cpu/iq_avx512.cpp src/kernels/cpu/nvfp4_avx512.cpp PROPERTIES COMPILE_OPTIONS
|
||||
"-mavx512f;-mavx512bw;-mavx512vl;-mavx512dq;-mavx512vnni;-mavx512vbmi;-mf16c;-mfma")
|
||||
"-mavx512f;-mavx512bw;-mavx512vl;-mavx512dq;-mavx512vnni;-mavx512vbmi;-mavx512vpopcntdq;-mbmi2;-mf16c;-mfma")
|
||||
set_source_files_properties(src/kernels/cpu/iq_avx2.cpp PROPERTIES COMPILE_OPTIONS "-mavx2;-mfma;-mf16c")
|
||||
set_source_files_properties(src/kernels/cpu/kq_avx2.cpp PROPERTIES COMPILE_OPTIONS "-mavx2;-mfma;-mf16c")
|
||||
set_source_files_properties(src/kernels/cpu/nvfp4_avx2.cpp PROPERTIES COMPILE_OPTIONS "-mavx2;-mfma;-mf16c")
|
||||
@@ -2095,14 +2236,14 @@ if(STRATA_BUILD_TESTS)
|
||||
add_executable(native_hadamard_test src/kernels/cpu/native_hadamard_test.cpp)
|
||||
target_link_libraries(native_hadamard_test PRIVATE strata_kernels_cpu strata_artifact)
|
||||
add_test(NAME native_hadamard_test COMMAND native_hadamard_test)
|
||||
set_tests_properties(native_hadamard_test PROPERTIES TIMEOUT 300)
|
||||
set_tests_properties(native_hadamard_test PROPERTIES TIMEOUT 300 SKIP_RETURN_CODE 77)
|
||||
# the CPU trellis kernels (tq_cpu.hpp): AVX2 / AVX-512 vs the scalar kernel bit for bit, vs ggml-cpu's vec_dot
|
||||
# and to_float, plain and with the Hadamard rotation; `tq_cpu_parity --bench` is the micro-benchmark
|
||||
add_executable(tq_cpu_parity src/kernels/cpu/tq_cpu_parity.cpp)
|
||||
target_link_libraries(tq_cpu_parity PRIVATE strata_kernels_cpu)
|
||||
add_test(NAME tq_cpu_parity COMMAND tq_cpu_parity)
|
||||
add_test(NAME tq_cpu_parity_avx2 COMMAND ${CMAKE_COMMAND} -E env STRATA_FORCE_AVX2=1 $<TARGET_FILE:tq_cpu_parity>)
|
||||
set_tests_properties(tq_cpu_parity tq_cpu_parity_avx2 PROPERTIES TIMEOUT 300)
|
||||
set_tests_properties(tq_cpu_parity tq_cpu_parity_avx2 PROPERTIES TIMEOUT 300 SKIP_RETURN_CODE 77)
|
||||
endif()
|
||||
|
||||
add_executable(expert_parity src/kernels/cpu/expert_parity.cpp)
|
||||
@@ -2115,10 +2256,19 @@ if(STRATA_BUILD_TESTS)
|
||||
add_executable(pool_test src/kernels/cpu/pool_test.cpp)
|
||||
target_link_libraries(pool_test PRIVATE strata_kernels_cpu)
|
||||
add_test(NAME pool_test COMMAND pool_test --selftest WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
# where the workers are placed (one per physical core by default) and --pool-cores (real pools, no model)
|
||||
add_executable(pool_cores_test src/kernels/cpu/pool_cores_test.cpp)
|
||||
target_link_libraries(pool_cores_test PRIVATE strata_kernels_cpu)
|
||||
add_test(NAME pool_cores_test COMMAND pool_cores_test)
|
||||
# the default CPU expert worker count on mock CPUs (8c/16t, 16c/32t, 64c/128t, 2-socket 128c/256t, ...)
|
||||
add_executable(pool_plan_test src/kernels/cpu/pool_plan_test.cpp)
|
||||
target_include_directories(pool_plan_test PRIVATE include)
|
||||
add_test(NAME pool_plan_test COMMAND pool_plan_test)
|
||||
# 0.2.2: when the pool splits per NUMA node by itself (a fake sysfs node folder, mock cores; STRATA_NUMA_POOL=0, one node = unchanged)
|
||||
add_executable(numa_pool_test src/kernels/cpu/numa_pool_test.cpp)
|
||||
target_include_directories(numa_pool_test PRIVATE include)
|
||||
target_compile_features(numa_pool_test PRIVATE cxx_std_17)
|
||||
add_test(NAME numa_pool_test COMMAND numa_pool_test)
|
||||
# #3204: when the HIP start-up warm-up of hipBLAS may run (pure policy, no GPU)
|
||||
add_executable(warm_policy_test src/prefill/warm_policy_test.cpp)
|
||||
target_include_directories(warm_policy_test PRIVATE include)
|
||||
@@ -2217,3 +2367,19 @@ if(STRATA_HIP_GFX906)
|
||||
add_executable(gfx906_qsa_query_swizzle_probe bench/gfx906_qsa_query_swizzle_probe.cpp)
|
||||
target_link_libraries(gfx906_qsa_query_swizzle_probe PRIVATE strata_kernels ${_strata_gpu_runtime_target})
|
||||
endif()
|
||||
|
||||
# ---- DeepSeek-V4.1 on a box with no GPU toolkit ------------------------------------------------
|
||||
# The whole DSV41 block above lives inside if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP), so with neither backend on
|
||||
# -DSTRATA_BUILD_DSV41=ON was silently unused and the host-side benchmark could not be built at all (found on p1004,
|
||||
# 2026-10-23: the CPU lane is exactly what a box without a usable GPU needs to measure). This branch builds the one target
|
||||
# that has no device code, so the CPU-lane A/B runs anywhere.
|
||||
if(STRATA_BUILD_DSV41 AND NOT STRATA_ENABLE_CUDA AND NOT STRATA_ENABLE_HIP AND NOT STRATA_HIP_GFX906)
|
||||
message(STATUS "STRATA_BUILD_DSV41: no GPU backend, building the host-side benchmark only (ds4_gg_bench)")
|
||||
if(STRATA_NATIVE_EXPERTS)
|
||||
add_executable(ds4_gg_bench tests/dsv41/gg_dot_bench.cpp src/dsv41/ds4_gg_cpu.cpp)
|
||||
target_include_directories(ds4_gg_bench PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/third_party/ggml)
|
||||
target_link_libraries(ds4_gg_bench PRIVATE strata_kernels_cpu Threads::Threads)
|
||||
target_compile_definitions(ds4_gg_bench PRIVATE STRATA_DSV41_TQ_CPU=1)
|
||||
endif()
|
||||
endif()
|
||||
+10
-3
@@ -5,6 +5,8 @@
|
||||
#
|
||||
# docker build -t strata:src . # RTX 20/30/40/50 (compiles for each, ~25 min)
|
||||
# docker build -t strata:src --build-arg CUDA_ARCHITECTURES=86 . # one card only (faster); 86 = RTX 30, 89 = RTX 40, 120 = RTX 50
|
||||
# docker build -t strata:src-cuda12 --build-arg CUDA_IMAGE=nvidia/cuda:12.9.1-devel-ubuntu24.04 \
|
||||
# --build-arg CUDA_ARCHITECTURES="60;61;70;75;80;86;89" --build-arg BUILD_VISION=0 . # GTX 10 / P100 / V100 (driver 575+)
|
||||
# docker run --rm --gpus all -p 127.0.0.1:8080:8080 --ulimit memlock=-1 --shm-size 2g --stop-timeout 120 \
|
||||
# -v strata-data:/data -e MODEL=IQ3_XXS -e API_KEY=change-me-please-123 strata:src
|
||||
#
|
||||
@@ -35,7 +37,9 @@ RUN /venv/bin/python -c "import setup; print(setup.get_llama_cpp())"
|
||||
RUN set -e; \
|
||||
real=""; for a in $(echo "$CUDA_ARCHITECTURES" | tr ';,' ' '); do real="$real;${a}-real"; done; real=${real#;}; \
|
||||
last=$(echo "$CUDA_ARCHITECTURES" | tr ';,' ' ' | awk '{print $NF}'); \
|
||||
sm60=OFF; for a in $(echo "$CUDA_ARCHITECTURES" | tr ';,' ' '); do if [ "$a" -lt 75 ]; then sm60=ON; fi; done; \
|
||||
/venv/bin/cmake -G Ninja -S . -B build -DCMAKE_BUILD_TYPE=Release -DSTRATA_ENABLE_CUDA=ON -DSTRATA_BUILD_TESTS=OFF \
|
||||
-DSTRATA_EXPERIMENTAL_SM60=$sm60 \
|
||||
-DSTRATA_NATIVE_EXPERTS=ON -DSTRATA_PORTABLE=ON -DSTRATA_Q6K_EXPERTS=ON -DSTRATA_MMQ_KQUANTS=ON \
|
||||
"-DCMAKE_CUDA_ARCHITECTURES=${real};${last}-virtual" -DSTRATA_GGML_DIR=/src/third_party/llama.cpp; \
|
||||
/venv/bin/cmake --build build --target strata strata_kq -j "$(nproc)"; \
|
||||
@@ -50,19 +54,22 @@ RUN set -e; \
|
||||
[ -f build/libstrata_kq.so ] && cp build/libstrata_kq.so /stage/ || true; \
|
||||
[ -f build-vision/bin/strata-vision ] && cp build-vision/bin/strata-vision /stage/ || true; \
|
||||
ver="${ENGINE_VERSION:-$(sed -n 's/^[[:space:]]*project([^)]*VERSION[[:space:]]*\([0-9][0-9.]*\).*/\1/p' CMakeLists.txt | head -1)}"; \
|
||||
echo "{\"version\": \"$ver\", \"source\": \"docker build\", \"archs\": \"$CUDA_ARCHITECTURES\", \"cuda\": \"13.0\", \"portable\": true}" > /stage/BUILD.json; \
|
||||
cudafull=$(nvcc --version | sed -n 's/.*release \([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' | head -1); cudamajor=${cudafull%%.*}; \
|
||||
if [ "$cudamajor" = 13 ]; then mindriver=580.65; else mindriver=575.51; fi; \
|
||||
vision_archs=""; [ -f /stage/strata-vision ] && vision_archs=", \"vision_archs\": \"$CUDA_ARCHITECTURES\""; \
|
||||
echo "{\"version\": \"$ver\", \"source\": \"docker build\", \"archs\": \"$CUDA_ARCHITECTURES\", \"cuda\": \"$cudafull\", \"portable\": true${vision_archs}}" > /stage/BUILD.json; \
|
||||
python3 packaging/collect_libs.py --binary build/strata --search /usr/local/cuda/lib64 --out /stage --also 'libcublasLt.so*'; \
|
||||
archs=""; for a in $(echo "$CUDA_ARCHITECTURES" | tr ';,' ' '); do archs="$archs --arch sm_$a"; done; \
|
||||
last=$(echo "$CUDA_ARCHITECTURES" | tr ';,' ' ' | awk '{print $NF}'); \
|
||||
python3 packaging/package_engine.py --src /stage --backend cuda --version "$ver" --out /engine $archs --arch "compute_$last" \
|
||||
--min-driver nvidia=580.65 --variant cuda13 --platform linux-x86_64 --build-id "docker-source-${ver}-cuda13.0" --include SYSTEM-LIBS.txt --include 'libstrata_kq*' \
|
||||
--min-driver nvidia=$mindriver --variant cuda$cudamajor --platform linux-x86_64 --build-id "docker-source-${ver}-cuda${cudafull}" --include SYSTEM-LIBS.txt --include 'libstrata_kq*' \
|
||||
--include strata-vision; \
|
||||
for d in /engine/*/; do python3 packaging/package_engine.py --verify "$d"; python3 packaging/binary_deps.py "$d"; done
|
||||
|
||||
# ---- 2. the Terminal (the release archive, built here)
|
||||
FROM ${RUNTIME_BASE} AS terminal
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends python3 ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends python3 ca-certificates git && rm -rf /var/lib/apt/lists/*
|
||||
COPY . /src/
|
||||
RUN python3 /src/packaging/package_terminal.py --out /out --cache /cache \
|
||||
&& mkdir -p /opt/strata \
|
||||
|
||||
+29
-14
@@ -33,7 +33,7 @@ Bilder und arbeitet mit deinen Apps und Coding-Agents zusammen. Deine Chats und
|
||||
- **Kostenlos.** MIT-Lizenz, kein Konto, keine Cloud.
|
||||
- **Privat.** Keine Telemetrie. Was Strata herunterlädt, wann es das Internet nutzt und wie es Downloads prüft, steht in [docs/SAFETY.md](docs/SAFETY.md).
|
||||
|
||||
Version 0.2.1 · Engine 0.1.46
|
||||
Version 0.2.3 · Engine 0.1.48
|
||||
|
||||
## Was du brauchst
|
||||
|
||||
@@ -73,16 +73,16 @@ unsicher, welche du brauchst? Lies den Absatz unter der Tabelle.
|
||||
|
||||
| System | Deine Grafikkarte | Datei zum Herunterladen |
|
||||
| --- | --- | --- |
|
||||
| **Windows 10 / 11** | NVIDIA RTX, Treiber 580 oder neuer | [`Strata-0.2.1-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-cuda13.exe) |
|
||||
| | NVIDIA, ältere Karte oder Treiber älter als 580 | [`Strata-0.2.1-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-cuda12.exe) |
|
||||
| | AMD Radeon RX 9000 Serie oder Radeon AI PRO R9700 | [`Strata-0.2.1-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-hip.exe) |
|
||||
| | AMD Radeon RX 7000 Serie oder Ryzen AI Max (Strix Halo) | [`Strata-0.2.1-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-hip-rdna3.exe) |
|
||||
| **Linux** | NVIDIA RTX, Treiber 580 oder neuer | [`Strata-0.2.1-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-nvidia-cuda13.AppImage) |
|
||||
| | NVIDIA, ältere Karte oder Treiber älter als 580 | [`Strata-0.2.1-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-nvidia-cuda12.AppImage) |
|
||||
| | AMD Radeon RX 9000 Serie oder Radeon AI PRO R9700 | [`Strata-0.2.1-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-rx9070.AppImage) |
|
||||
| | AMD Radeon RX 7000 Serie | [`Strata-0.2.1-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-rx7900.AppImage) |
|
||||
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.1-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-strixhalo.AppImage) |
|
||||
| **Mac** (Apple Silicon, macOS 15 oder neuer) | eingebaut | [`Strata-0.2.1-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-mac.dmg) |
|
||||
| **Windows 10 / 11** | NVIDIA RTX, Treiber 580 oder neuer | [`Strata-0.2.3-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda13.exe) |
|
||||
| | NVIDIA, ältere Karte oder Treiber älter als 580 | [`Strata-0.2.3-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda12.exe) |
|
||||
| | AMD Radeon RX 9000 Serie oder Radeon AI PRO R9700 | [`Strata-0.2.3-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip.exe) |
|
||||
| | AMD Radeon RX 7000 Serie oder Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip-rdna3.exe) |
|
||||
| **Linux** | NVIDIA RTX, Treiber 580 oder neuer | [`Strata-0.2.3-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda13.AppImage) |
|
||||
| | NVIDIA, ältere Karte oder Treiber älter als 580 | [`Strata-0.2.3-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda12.AppImage) |
|
||||
| | AMD Radeon RX 9000 Serie oder Radeon AI PRO R9700 | [`Strata-0.2.3-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx9070.AppImage) |
|
||||
| | AMD Radeon RX 7000 Serie | [`Strata-0.2.3-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx7900.AppImage) |
|
||||
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-strixhalo.AppImage) |
|
||||
| **Mac** (Apple Silicon, macOS 15 oder neuer) | eingebaut | [`Strata-0.2.3-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-mac.dmg) |
|
||||
|
||||
**Wenn du unsicher bist:** Bei NVIDIA nimmst du `cuda13`, wenn dein Treiber Version 580 oder neuer ist, sonst `cuda12`.
|
||||
`cuda12` ist für ältere Karten und ältere Treiber. Die Dateien `hip` und `amd` sind für AMD-Karten. Für andere AMD-Karten
|
||||
@@ -162,9 +162,9 @@ Größen, mehr oder weniger stark komprimiert. Kleinere Größen sind schneller.
|
||||
| Größe | Benötigter Speicher (RAM + VRAM) | Download | Was es ist |
|
||||
| --- | ---: | ---: | --- |
|
||||
| **Gyro-S** | 27,6 GB | 58,5 GB | Für PCs, auf denen nichts anderes läuft. Die Qualität ist geringer als bei IQ2_XS. |
|
||||
| **Gyro-M** | 35 GB | 92,0 GB | Ungefähr die Qualität von IQ3_XXS bei etwa 20 % weniger GPU-Speicher. Es schreibt Antworten auf einer 12-GB-Karte und auf einer R9700 17-21 % langsamer als Q2_0. |
|
||||
| **Gyro-M** | 35 GB | 92,0 GB | Ungefähr die Qualität von IQ3_XXS bei etwa 20 % weniger GPU-Speicher. Es schreibt Antworten langsamer als Q2_0: 16 % auf einer RTX 3060 mit 12 GB, 21 % auf einer R9700. |
|
||||
|
||||
Gyro läuft auf NVIDIA- und AMD-Karten (bei AMD experimentell), nicht auf Intel Arc und nicht auf dem Mac. Details und Messwerte:
|
||||
Gyro läuft auf NVIDIA- und AMD-Karten (bei AMD experimentell) auf Intel-Arc-Karten unter Linux (getestet auf einer Arc A750) und ab 0.2.2 auf Macs mit Apple Silicon (Gyro-S auf einem Mac mini mit 32 GB: 9 Tokens/s, die Experten werden von der SSD gelesen). Details und Messwerte:
|
||||
[docs/GYRO.md](docs/GYRO.md). Starte es mit `strata launch --model gyro-s`.
|
||||
|
||||
### Die anderen Größen
|
||||
@@ -172,7 +172,7 @@ Gyro läuft auf NVIDIA- und AMD-Karten (bei AMD experimentell), nicht auf Intel
|
||||
| Dein Speicher (RAM + VRAM) | Nimm | Hinweise |
|
||||
| --- | --- | --- |
|
||||
| **32 GB** | **Gyro-S**, oder den **[Coder](docs/MODELS.md#coder)** für Code | Der Coder braucht 32 GB RAM und behält die Hälfte der Experten, deshalb ist er außerhalb von Code schwächer |
|
||||
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 GB), oder **Gyro-M** (35 GB) | Swift denkt kürzer nach, bevor es antwortet. Gyro-M kommt der Qualität von IQ3_XXS nahe, schreibt Antworten aber 17-21 % langsamer als Q2_0 |
|
||||
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 GB), oder **Gyro-M** (35 GB) | Swift denkt kürzer nach, bevor es antwortet. Gyro-M kommt der Qualität von IQ3_XXS nahe, schreibt Antworten aber 16-21 % langsamer als Q2_0 |
|
||||
| **48 GB** | **IQ3_XXS** (47,0 GB) | Langsamer, besser |
|
||||
| **64 GB** | **IQ3_S** (54,8 GB), das Original oder Swift 1.5 | Am langsamsten, am besten. Es erreicht in den veröffentlichten Tests das volle Modell |
|
||||
| **96 GB oder mehr** | **UD-IQ4_XS** von Unsloth (etwa 72 GB RAM) bis **Q8_0** (etwa 136 GB), 4 bis 8 Bit | Näher am vollen Modell. Bei weniger RAM liest Strata einen Teil von der SSD, das ist viel langsamer |
|
||||
@@ -190,6 +190,21 @@ Weitere Auswahl:
|
||||
|
||||
Alle Größen, Downloads und was wo läuft: [docs/MODELS.md](docs/MODELS.md).
|
||||
|
||||
### DeepSeek V4.1-Flash (im Terminal, ab 0.2.4)
|
||||
|
||||
[DeepSeek V4.1-Flash](https://huggingface.co/ISTA-DASLab/DeepSeek-V4.1-Flash-1017-GSQ-RCO-GGUF) ist ein anderes, deutlich größeres Modell. ISTA-DASLab hat die drei Sets unten gebaut. Strata startet sie auf einem PC mit einer 12-GB-Grafikkarte und 46 bis 64 GB RAM und liest den Rest von der SSD. Es läuft auf NVIDIA-RTX-Karten der Serien 30 bis 50 und auf der Radeon AI PRO R9700. Es nutzt ein eigenes Engine-Paket, `strata-dsv41`, das `strata launch` lädt, wenn du DeepSeek wählst. Die Desktop-App bietet es noch nicht an.
|
||||
|
||||
| Set | Download | Qualität im Vergleich zum Originalmodell | Antwortet mit |
|
||||
| --- | ---: | --- | --- |
|
||||
| **IQ1_M** (1,75 Bit) | 239 GB | KL 0,217, Perplexität 3,80 (+21 %); Code +8,9 %, Agent-Aufgaben +10,2 %; schwächer bei einfacher englischer Prosa. Laut Modellkarte von ISTA-DASLab behält es 98,7 % des Aufgabendurchschnitts des Basismodells (AIME25, GPQA-Diamond, LiveCodeBench v6). | noch nicht gemessen |
|
||||
| **IQ2_XXS** (2.00 Bit) | 257 GB | KL 0,170, Perplexität 3,63 (+15,5 %); Code +8,6 %, Agent-Aufgaben +5,3 % | 6 bis 9 Tokens/s auf einer RTX 3060, etwa 5 auf einer RTX 5070 |
|
||||
| **IQ2_XS** (2.25 Bit) | 274 GB | KL 0,107, Perplexität 3,37 (+7,3 %); Code +3,2 %, Agent-Aufgaben +2,2 % | etwa 11 Tokens/s auf einer Radeon AI PRO R9700 |
|
||||
| **IQ4_NL** (Engram-Tabelle, kein drittes Modell) | 111 GB, schon in allen drei Downloads oben enthalten | Die 4-Bit-Engram-Nachschlagetabelle, die das Modell aus RAM oder SSD liest. Alle drei Sets nutzen diese eine Datei (`DeepSeek-V4.1-Flash-Engram-IQ4_NL.gguf`). | n/a |
|
||||
|
||||
4x Radeon AI PRO R9700: etwa 31 Tokens/s, 55 bis 69 mit DSpark.
|
||||
|
||||
Starte es mit `strata launch --model ist-das-2.00bpw` (oder `ist-das-1.75bpw`, `ist-das-2.25bpw`). Details, Geschwindigkeiten je Karte und die manuelle Installation: [docs/DEEPSEEK41.md](docs/DEEPSEEK41.md).
|
||||
|
||||
## Wie schnell ist es?
|
||||
|
||||
Gemessen mit einer Grafikkarte mit 12 GB (RTX 5070, Ryzen 5 7600, 64 GB RAM). Ergebnisse anderer Karten findest du hier:
|
||||
|
||||
+29
-14
@@ -33,7 +33,7 @@ con tus aplicaciones y tus agentes de programación. Tus chats y tus archivos se
|
||||
- **Gratis.** Licencia MIT, sin cuenta, sin nube.
|
||||
- **Privado.** Sin telemetría. Lo que Strata descarga, cuándo usa internet y cómo comprueba las descargas está en [docs/SAFETY.md](docs/SAFETY.md).
|
||||
|
||||
Versión 0.2.1 · motor 0.1.46
|
||||
Versión 0.2.3 · motor 0.1.48
|
||||
|
||||
## Lo que necesitas
|
||||
|
||||
@@ -73,16 +73,16 @@ sabes cuál elegir? Mira el párrafo que hay debajo de la tabla.
|
||||
|
||||
| Sistema | Tu tarjeta gráfica | Archivo que descargar |
|
||||
| --- | --- | --- |
|
||||
| **Windows 10 / 11** | NVIDIA RTX, controlador 580 o posterior | [`Strata-0.2.1-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-cuda13.exe) |
|
||||
| | NVIDIA, tarjeta más antigua o controlador anterior a 580 | [`Strata-0.2.1-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-cuda12.exe) |
|
||||
| | AMD Radeon serie RX 9000 o Radeon AI PRO R9700 | [`Strata-0.2.1-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-hip.exe) |
|
||||
| | AMD Radeon serie RX 7000 o Ryzen AI Max (Strix Halo) | [`Strata-0.2.1-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-hip-rdna3.exe) |
|
||||
| **Linux** | NVIDIA RTX, controlador 580 o posterior | [`Strata-0.2.1-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-nvidia-cuda13.AppImage) |
|
||||
| | NVIDIA, tarjeta más antigua o controlador anterior a 580 | [`Strata-0.2.1-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-nvidia-cuda12.AppImage) |
|
||||
| | AMD Radeon serie RX 9000 o Radeon AI PRO R9700 | [`Strata-0.2.1-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-rx9070.AppImage) |
|
||||
| | AMD Radeon serie RX 7000 | [`Strata-0.2.1-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-rx7900.AppImage) |
|
||||
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.1-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-strixhalo.AppImage) |
|
||||
| **Mac** (Apple Silicon, macOS 15 o posterior) | integrada | [`Strata-0.2.1-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-mac.dmg) |
|
||||
| **Windows 10 / 11** | NVIDIA RTX, controlador 580 o posterior | [`Strata-0.2.3-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda13.exe) |
|
||||
| | NVIDIA, tarjeta más antigua o controlador anterior a 580 | [`Strata-0.2.3-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda12.exe) |
|
||||
| | AMD Radeon serie RX 9000 o Radeon AI PRO R9700 | [`Strata-0.2.3-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip.exe) |
|
||||
| | AMD Radeon serie RX 7000 o Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip-rdna3.exe) |
|
||||
| **Linux** | NVIDIA RTX, controlador 580 o posterior | [`Strata-0.2.3-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda13.AppImage) |
|
||||
| | NVIDIA, tarjeta más antigua o controlador anterior a 580 | [`Strata-0.2.3-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda12.AppImage) |
|
||||
| | AMD Radeon serie RX 9000 o Radeon AI PRO R9700 | [`Strata-0.2.3-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx9070.AppImage) |
|
||||
| | AMD Radeon serie RX 7000 | [`Strata-0.2.3-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx7900.AppImage) |
|
||||
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-strixhalo.AppImage) |
|
||||
| **Mac** (Apple Silicon, macOS 15 o posterior) | integrada | [`Strata-0.2.3-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-mac.dmg) |
|
||||
|
||||
**Si no estás seguro:** con NVIDIA, elige `cuda13` si la versión de tu controlador es la 580 o posterior, y `cuda12` en caso
|
||||
contrario. `cuda12` es para tarjetas y controladores más antiguos. Los archivos `hip` y `amd` son para tarjetas AMD. Las
|
||||
@@ -162,9 +162,9 @@ comprimido. Los tamaños pequeños son más rápidos. Los grandes son algo más
|
||||
| Tamaño | Memoria que necesita (RAM + VRAM) | Descarga | Qué es |
|
||||
| --- | ---: | ---: | --- |
|
||||
| **Gyro-S** | 27,6 GB | 58,5 GB | Para PC donde no cabe nada más. La calidad es menor que la de IQ2_XS. |
|
||||
| **Gyro-M** | 35 GB | 92,0 GB | Calidad parecida a IQ3_XXS con un 20 % menos de memoria de GPU. Escribe las respuestas un 17-21 % más despacio que Q2_0 en una tarjeta de 12 GB y en una R9700. |
|
||||
| **Gyro-M** | 35 GB | 92,0 GB | Calidad parecida a IQ3_XXS con un 20 % menos de memoria de GPU. Escribe las respuestas un 16 % más despacio que Q2_0 en una RTX 3060 de 12 GB y un 21 % más despacio en una R9700. |
|
||||
|
||||
Gyro funciona en tarjetas NVIDIA y AMD (en AMD es experimental), no en Intel Arc ni en el Mac. Detalles y mediciones:
|
||||
Gyro funciona en tarjetas NVIDIA y AMD (en AMD es experimental) en tarjetas Intel Arc con Linux (probado en una Arc A750) y, desde 0.2.2, en Mac con Apple Silicon (Gyro-S en un Mac mini de 32 GB: 9 tokens/s, con los expertos leídos del SSD). Detalles y mediciones:
|
||||
[docs/GYRO.md](docs/GYRO.md). Arráncalo con `strata launch --model gyro-s`.
|
||||
|
||||
### Los demás tamaños
|
||||
@@ -172,7 +172,7 @@ Gyro funciona en tarjetas NVIDIA y AMD (en AMD es experimental), no en Intel Arc
|
||||
| Tu memoria (RAM + VRAM) | Elige | Notas |
|
||||
| --- | --- | --- |
|
||||
| **32 GB** | **Gyro-S**, o el **[Coder](docs/MODELS.md#coder)** para código | El Coder necesita 32 GB de RAM y conserva la mitad de los expertos, así que es más flojo fuera del código |
|
||||
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 GB), o **Gyro-M** (35 GB) | Swift piensa menos tiempo antes de responder. Gyro-M se acerca a la calidad de IQ3_XXS, pero escribe las respuestas un 17-21 % más despacio que Q2_0 |
|
||||
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 GB), o **Gyro-M** (35 GB) | Swift piensa menos tiempo antes de responder. Gyro-M se acerca a la calidad de IQ3_XXS, pero escribe las respuestas un 16-21 % más despacio que Q2_0 |
|
||||
| **48 GB** | **IQ3_XXS** (47,0 GB) | Más lento, mejor |
|
||||
| **64 GB** | **IQ3_S** (54,8 GB), el original o Swift 1.5 | El más lento, el mejor. Iguala al modelo completo en las pruebas publicadas |
|
||||
| **96 GB o más** | **UD-IQ4_XS** de Unsloth (unos 72 GB de RAM) hasta **Q8_0** (unos 136 GB), de 4 a 8 bits | Más cerca del modelo completo. Con menos RAM, Strata lee una parte desde el SSD, que es mucho más lento |
|
||||
@@ -190,6 +190,21 @@ Más opciones:
|
||||
|
||||
Todos los tamaños, descargas y lo que cabe en cada equipo: [docs/MODELS.md](docs/MODELS.md).
|
||||
|
||||
### DeepSeek V4.1-Flash (en el Terminal, desde 0.2.4)
|
||||
|
||||
[DeepSeek V4.1-Flash](https://huggingface.co/ISTA-DASLab/DeepSeek-V4.1-Flash-1017-GSQ-RCO-GGUF) es otro modelo, mucho más grande. ISTA-DASLab hizo los tres conjuntos de abajo. Strata los ejecuta en un PC con una tarjeta gráfica de 12 GB y de 46 a 64 GB de RAM, y lee el resto de tu SSD. Funciona en tarjetas NVIDIA RTX de las series 30 a 50 y en la Radeon AI PRO R9700. Usa su propio paquete de motor, `strata-dsv41`, que `strata launch` descarga cuando eliges DeepSeek. La app de escritorio todavía no lo ofrece.
|
||||
|
||||
| Conjunto | Descarga | Calidad frente al modelo original | Responde a |
|
||||
| --- | ---: | --- | --- |
|
||||
| **IQ1_M** (1,75 bits) | 239 GB | KL 0,217, perplejidad 3,80 (+21 %); código +8,9 %, tareas de agente +10,2 %; más débil con prosa inglesa corriente. Según la ficha del modelo de ISTA-DASLab, conserva el 98,7 % del promedio de tareas del modelo base (AIME25, GPQA-Diamond, LiveCodeBench v6). | aún sin medir |
|
||||
| **IQ2_XXS** (2.00 bits) | 257 GB | KL 0,170, perplejidad 3,63 (+15,5 %); código +8,6 %, tareas de agente +5,3 % | de 6 a 9 tokens/s en una RTX 3060, unos 5 en una RTX 5070 |
|
||||
| **IQ2_XS** (2.25 bits) | 274 GB | KL 0,107, perplejidad 3,37 (+7,3 %); código +3,2 %, tareas de agente +2,2 % | unos 11 tokens/s en una Radeon AI PRO R9700 |
|
||||
| **IQ4_NL** (tabla Engram, no es un tercer modelo) | 111 GB, ya incluidos en las tres descargas de arriba | La tabla de consulta Engram de 4 bits que el modelo lee de la RAM o del SSD. Los tres conjuntos usan este único archivo (`DeepSeek-V4.1-Flash-Engram-IQ4_NL.gguf`). | n/a |
|
||||
|
||||
4x Radeon AI PRO R9700: unos 31 tokens/s, de 55 a 69 con DSpark.
|
||||
|
||||
Inícialo con `strata launch --model ist-das-2.00bpw` (o `ist-das-1.75bpw`, `ist-das-2.25bpw`). Detalles, velocidades por tarjeta e instalación manual: [docs/DEEPSEEK41.md](docs/DEEPSEEK41.md).
|
||||
|
||||
## ¿Qué velocidad tiene?
|
||||
|
||||
Medido con una tarjeta gráfica de 12 GB (RTX 5070, Ryzen 5 7600, 64 GB de RAM). Resultados con otras tarjetas:
|
||||
|
||||
+29
-14
@@ -33,7 +33,7 @@ fonctionne avec vos applications et vos agents de code. Vos conversations et vos
|
||||
- **Gratuit.** Licence MIT, pas de compte, pas de cloud.
|
||||
- **Privé.** Aucune télémétrie. Ce que Strata télécharge, quand il utilise internet et comment il vérifie les téléchargements est expliqué dans [docs/SAFETY.md](docs/SAFETY.md).
|
||||
|
||||
Version 0.2.1 · moteur 0.1.46
|
||||
Version 0.2.3 · moteur 0.1.48
|
||||
|
||||
## Ce qu'il vous faut
|
||||
|
||||
@@ -73,16 +73,16 @@ Vous ne savez pas lequel prendre ? Lisez le paragraphe sous le tableau.
|
||||
|
||||
| Système | Votre carte graphique | Fichier à télécharger |
|
||||
| --- | --- | --- |
|
||||
| **Windows 10 / 11** | NVIDIA RTX, pilote 580 ou plus récent | [`Strata-0.2.1-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-cuda13.exe) |
|
||||
| | NVIDIA, carte plus ancienne ou pilote antérieur à 580 | [`Strata-0.2.1-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-cuda12.exe) |
|
||||
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.1-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-hip.exe) |
|
||||
| | AMD Radeon série RX 7000 ou Ryzen AI Max (Strix Halo) | [`Strata-0.2.1-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-hip-rdna3.exe) |
|
||||
| **Linux** | NVIDIA RTX, pilote 580 ou plus récent | [`Strata-0.2.1-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-nvidia-cuda13.AppImage) |
|
||||
| | NVIDIA, carte plus ancienne ou pilote antérieur à 580 | [`Strata-0.2.1-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-nvidia-cuda12.AppImage) |
|
||||
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.1-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-rx9070.AppImage) |
|
||||
| | AMD Radeon série RX 7000 | [`Strata-0.2.1-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-rx7900.AppImage) |
|
||||
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.1-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-strixhalo.AppImage) |
|
||||
| **Mac** (Apple Silicon, macOS 15 ou plus récent) | intégrée | [`Strata-0.2.1-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-mac.dmg) |
|
||||
| **Windows 10 / 11** | NVIDIA RTX, pilote 580 ou plus récent | [`Strata-0.2.3-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda13.exe) |
|
||||
| | NVIDIA, carte plus ancienne ou pilote antérieur à 580 | [`Strata-0.2.3-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda12.exe) |
|
||||
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.3-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip.exe) |
|
||||
| | AMD Radeon série RX 7000 ou Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip-rdna3.exe) |
|
||||
| **Linux** | NVIDIA RTX, pilote 580 ou plus récent | [`Strata-0.2.3-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda13.AppImage) |
|
||||
| | NVIDIA, carte plus ancienne ou pilote antérieur à 580 | [`Strata-0.2.3-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda12.AppImage) |
|
||||
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.3-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx9070.AppImage) |
|
||||
| | AMD Radeon série RX 7000 | [`Strata-0.2.3-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx7900.AppImage) |
|
||||
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-strixhalo.AppImage) |
|
||||
| **Mac** (Apple Silicon, macOS 15 ou plus récent) | intégrée | [`Strata-0.2.3-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-mac.dmg) |
|
||||
|
||||
**Si vous hésitez :** pour NVIDIA, prenez `cuda13` si la version de votre pilote est 580 ou plus récente, sinon `cuda12`.
|
||||
`cuda12` convient aux cartes plus anciennes et aux pilotes plus anciens. Les fichiers `hip` et `amd` sont pour les cartes
|
||||
@@ -163,9 +163,9 @@ plus ou moins compressé. Les petites tailles sont plus rapides. Les grandes son
|
||||
| Taille | Mémoire nécessaire (RAM + VRAM) | Téléchargement | Ce que c'est |
|
||||
| --- | ---: | ---: | --- |
|
||||
| **Gyro-S** | 27,6 Go | 58,5 Go | Pour les PC où rien d'autre ne tient. La qualité est inférieure à celle d'IQ2_XS. |
|
||||
| **Gyro-M** | 35 Go | 92,0 Go | À peu près la qualité d'IQ3_XXS avec environ 20 % de mémoire GPU en moins. Il écrit les réponses 17 à 21 % plus lentement que Q2_0 sur une carte de 12 Go et sur une R9700. |
|
||||
| **Gyro-M** | 35 Go | 92,0 Go | À peu près la qualité d'IQ3_XXS avec environ 20 % de mémoire GPU en moins. Il écrit les réponses 16 % plus lentement que Q2_0 sur une RTX 3060 de 12 Go et 21 % plus lentement sur une R9700. |
|
||||
|
||||
Gyro fonctionne sur les cartes NVIDIA et AMD (expérimental sur AMD), pas sur Intel Arc ni sur Mac. Détails et mesures :
|
||||
Gyro fonctionne sur les cartes NVIDIA et AMD (expérimental sur AMD) sur les cartes Intel Arc sous Linux (testé sur une Arc A750) et, depuis la 0.2.2, sur les Mac Apple Silicon (Gyro-S sur un Mac mini de 32 Go : 9 tokens/s, experts lus sur le SSD). Détails et mesures :
|
||||
[docs/GYRO.md](docs/GYRO.md). Lancez-le avec `strata launch --model gyro-s`.
|
||||
|
||||
### Les autres tailles
|
||||
@@ -173,7 +173,7 @@ Gyro fonctionne sur les cartes NVIDIA et AMD (expérimental sur AMD), pas sur In
|
||||
| Votre mémoire (RAM + VRAM) | Prenez | Remarques |
|
||||
| --- | --- | --- |
|
||||
| **32 Go** | **Gyro-S**, ou le **[Coder](docs/MODELS.md#coder)** pour le code | Le Coder demande 32 Go de RAM et garde la moitié des experts, il est donc moins bon hors du code |
|
||||
| **40 Go** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 Go), ou **Gyro-M** (35 Go) | Swift réfléchit moins longtemps avant de répondre. Gyro-M approche la qualité d'IQ3_XXS, mais il écrit les réponses 17 à 21 % plus lentement que Q2_0 |
|
||||
| **40 Go** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 Go), ou **Gyro-M** (35 Go) | Swift réfléchit moins longtemps avant de répondre. Gyro-M approche la qualité d'IQ3_XXS, mais il écrit les réponses 16 à 21 % plus lentement que Q2_0 |
|
||||
| **48 Go** | **IQ3_XXS** (47,0 Go) | Plus lent, meilleur |
|
||||
| **64 Go** | **IQ3_S** (54,8 Go), l'original ou Swift 1.5 | Le plus lent, le meilleur. Il égale le modèle complet dans les tests publiés |
|
||||
| **96 Go ou plus** | **UD-IQ4_XS** d'Unsloth (environ 72 Go de RAM) jusqu'à **Q8_0** (environ 136 Go), 4 à 8 bits | Plus proche du modèle complet. Avec moins de RAM, Strata lit une partie depuis le SSD, ce qui est beaucoup plus lent |
|
||||
@@ -191,6 +191,21 @@ Autres choix :
|
||||
|
||||
Toutes les tailles, les téléchargements et ce qui tient où : [docs/MODELS.md](docs/MODELS.md).
|
||||
|
||||
### DeepSeek V4.1-Flash (dans le Terminal, depuis 0.2.4)
|
||||
|
||||
[DeepSeek V4.1-Flash](https://huggingface.co/ISTA-DASLab/DeepSeek-V4.1-Flash-1017-GSQ-RCO-GGUF) est un autre modèle, beaucoup plus gros. ISTA-DASLab a produit les trois versions ci-dessous. Strata les fait tourner sur un PC avec une carte graphique de 12 Go et de 46 à 64 Go de RAM, et lit le reste sur votre SSD. Il fonctionne sur les cartes NVIDIA RTX des séries 30 à 50 et sur la Radeon AI PRO R9700. Il utilise son propre paquet de moteur, `strata-dsv41`, que `strata launch` télécharge quand vous choisissez DeepSeek. L’application de bureau ne le propose pas encore.
|
||||
|
||||
| Version | Téléchargement | Qualité par rapport au modèle d’origine | Écrit à |
|
||||
| --- | ---: | --- | --- |
|
||||
| **IQ1_M** (1,75 bit) | 239 Go | KL 0,217, perplexité 3,80 (+21 %) ; code +8,9 %, tâches d’agent +10,2 % ; plus faible sur la prose anglaise courante. D’après la fiche du modèle d’ISTA-DASLab, elle garde 98,7 % de la moyenne des tâches du modèle de base (AIME25, GPQA-Diamond, LiveCodeBench v6). | pas encore mesurée |
|
||||
| **IQ2_XXS** (2.00 bits) | 257 GB | KL 0,170, perplexité 3,63 (+15,5 %) ; code +8,6 %, tâches d’agent +5,3 % | 6 à 9 tokens/s sur une RTX 3060, environ 5 sur une RTX 5070 |
|
||||
| **IQ2_XS** (2.25 bits) | 274 GB | KL 0,107, perplexité 3,37 (+7,3 %) ; code +3,2 %, tâches d’agent +2,2 % | environ 11 tokens/s sur une Radeon AI PRO R9700 |
|
||||
| **IQ4_NL** (table Engram, pas un troisième modèle) | 111 Go, déjà compris dans les trois téléchargements ci-dessus | La table de consultation Engram en 4 bits que le modèle lit dans la RAM ou sur le SSD. Les trois versions utilisent ce seul fichier (`DeepSeek-V4.1-Flash-Engram-IQ4_NL.gguf`). | n/a |
|
||||
|
||||
4x Radeon AI PRO R9700 : environ 31 tokens/s, 55 à 69 avec DSpark.
|
||||
|
||||
Lancez-le avec `strata launch --model ist-das-2.00bpw` (ou `ist-das-1.75bpw`, `ist-das-2.25bpw`). Détails, vitesses par carte et installation manuelle : [docs/DEEPSEEK41.md](docs/DEEPSEEK41.md).
|
||||
|
||||
## Quelle est sa vitesse ?
|
||||
|
||||
Mesuré avec une carte graphique de 12 Go (RTX 5070, Ryzen 5 7600, 64 Go de RAM). Voir les résultats d'autres cartes :
|
||||
|
||||
+29
-14
@@ -33,7 +33,7 @@ Strata は **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next
|
||||
- **無料です。** MIT ライセンス。アカウントもクラウドも不要です。
|
||||
- **プライバシーを守ります。** テレメトリはありません。Strata が何をダウンロードするか、いつインターネットを使うか、ダウンロードをどう検証するかは [docs/SAFETY.md](docs/SAFETY.md) に書いてあります。
|
||||
|
||||
バージョン 0.2.1 · エンジン 0.1.46
|
||||
バージョン 0.2.3 · エンジン 0.1.48
|
||||
|
||||
## 必要な環境
|
||||
|
||||
@@ -72,16 +72,16 @@ Strata は **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next
|
||||
|
||||
| OS | グラフィックスカード | ダウンロードするファイル |
|
||||
| --- | --- | --- |
|
||||
| **Windows 10 / 11** | NVIDIA RTX、ドライバー 580 以降 | [`Strata-0.2.1-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-cuda13.exe) |
|
||||
| | NVIDIA、古いカード、またはドライバーが 580 より古い場合 | [`Strata-0.2.1-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-cuda12.exe) |
|
||||
| | AMD Radeon RX 9000 シリーズまたは Radeon AI PRO R9700 | [`Strata-0.2.1-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-hip.exe) |
|
||||
| | AMD Radeon RX 7000 シリーズまたは Ryzen AI Max (Strix Halo) | [`Strata-0.2.1-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-hip-rdna3.exe) |
|
||||
| **Linux** | NVIDIA RTX、ドライバー 580 以降 | [`Strata-0.2.1-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-nvidia-cuda13.AppImage) |
|
||||
| | NVIDIA、古いカード、またはドライバーが 580 より古い場合 | [`Strata-0.2.1-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-nvidia-cuda12.AppImage) |
|
||||
| | AMD Radeon RX 9000 シリーズまたは Radeon AI PRO R9700 | [`Strata-0.2.1-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-rx9070.AppImage) |
|
||||
| | AMD Radeon RX 7000 シリーズ | [`Strata-0.2.1-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-rx7900.AppImage) |
|
||||
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.1-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-strixhalo.AppImage) |
|
||||
| **Mac**(Apple Silicon、macOS 15 以降) | 内蔵 | [`Strata-0.2.1-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-mac.dmg) |
|
||||
| **Windows 10 / 11** | NVIDIA RTX、ドライバー 580 以降 | [`Strata-0.2.3-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda13.exe) |
|
||||
| | NVIDIA、古いカード、またはドライバーが 580 より古い場合 | [`Strata-0.2.3-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda12.exe) |
|
||||
| | AMD Radeon RX 9000 シリーズまたは Radeon AI PRO R9700 | [`Strata-0.2.3-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip.exe) |
|
||||
| | AMD Radeon RX 7000 シリーズまたは Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip-rdna3.exe) |
|
||||
| **Linux** | NVIDIA RTX、ドライバー 580 以降 | [`Strata-0.2.3-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda13.AppImage) |
|
||||
| | NVIDIA、古いカード、またはドライバーが 580 より古い場合 | [`Strata-0.2.3-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda12.AppImage) |
|
||||
| | AMD Radeon RX 9000 シリーズまたは Radeon AI PRO R9700 | [`Strata-0.2.3-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx9070.AppImage) |
|
||||
| | AMD Radeon RX 7000 シリーズ | [`Strata-0.2.3-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx7900.AppImage) |
|
||||
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-strixhalo.AppImage) |
|
||||
| **Mac**(Apple Silicon、macOS 15 以降) | 内蔵 | [`Strata-0.2.3-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-mac.dmg) |
|
||||
|
||||
**迷ったときは:** NVIDIA の場合、ドライバーがバージョン 580 以降なら `cuda13`、それより古ければ `cuda12` を選びます。`cuda12` は古いカードと古いドライバー向けです。
|
||||
`hip` と `amd` のファイルは AMD のカード用です。それ以外の AMD カード(RX 6000)には、まだ用意されたアプリがありません。
|
||||
@@ -156,9 +156,9 @@ AI ツールは [MCP サーバー](docs/MCP_SERVER.md)を通じて、Strata の
|
||||
| サイズ | 必要なメモリ(RAM + VRAM) | ダウンロード | 内容 |
|
||||
| --- | ---: | ---: | --- |
|
||||
| **Gyro-S** | 27.6 GB | 58.5 GB | ほかのサイズが入らないPC向け。品質は IQ2_XS より下がります。 |
|
||||
| **Gyro-M** | 35 GB | 92.0 GB | 品質は IQ3_XXS 程度で、GPUメモリの使用量は約20%少なくなります。12 GBのカードと R9700 では、回答の書き出しが Q2_0 より17〜21%遅くなります。 |
|
||||
| **Gyro-M** | 35 GB | 92.0 GB | 品質は IQ3_XXS 程度で、GPUメモリの使用量は約20%少なくなります。回答の書き出しは Q2_0 より、12 GBの RTX 3060 で16%、R9700 で21%遅くなります。 |
|
||||
|
||||
Gyro は NVIDIA と AMD のカードで動きます(AMD は実験的)。Intel Arc と Mac では動きません。詳細と測定結果は
|
||||
Gyro は NVIDIA と AMD のカード(AMD は実験的)、および Linux 上の Intel Arc カード(Arc A750 で確認)で動きます。0.2.2 からは Apple Silicon の Mac でも動きます(32 GB の Mac mini で Gyro-S は 9 トークン/秒、エキスパートは SSD から読み込み)。詳細と測定結果は
|
||||
[docs/GYRO.md](docs/GYRO.md) にあります。`strata launch --model gyro-s` で起動します。
|
||||
|
||||
### ほかのサイズ
|
||||
@@ -166,7 +166,7 @@ Gyro は NVIDIA と AMD のカードで動きます(AMD は実験的)。Inte
|
||||
| メモリ(RAM + VRAM) | 選ぶモデル | 備考 |
|
||||
| --- | --- | --- |
|
||||
| **32 GB** | **Gyro-S**、またはコード向けの **[Coder](docs/MODELS.md#coder)** | Coder はRAM 32 GBが必要で、エキスパートを半分だけ保持するため、コード以外は弱くなります |
|
||||
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS(39.2 GB)、または **Gyro-M**(35 GB) | Swift は答える前に考える時間が短くなります。Gyro-M は品質が IQ3_XXS に近いものの、回答の書き出しは Q2_0 より17〜21%遅くなります |
|
||||
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS(39.2 GB)、または **Gyro-M**(35 GB) | Swift は答える前に考える時間が短くなります。Gyro-M は品質が IQ3_XXS に近いものの、回答の書き出しは Q2_0 より16〜21%遅くなります |
|
||||
| **48 GB** | **IQ3_XXS**(47.0 GB) | 遅くなりますが、品質は上がります |
|
||||
| **64 GB** | **IQ3_S**(54.8 GB)、オリジナルまたは Swift 1.5 | いちばん遅く、いちばん高品質です。公開されているテストでは完全版と同等です |
|
||||
| **96 GB 以上** | Unsloth の **UD-IQ4_XS**(RAM 約72 GB)から **Q8_0**(約136 GB)まで、4〜8ビット | 完全版に近づきます。RAM が足りないと、Strata は一部を SSD から読むため、ずっと遅くなります |
|
||||
@@ -183,6 +183,21 @@ Gyro は NVIDIA と AMD のカードで動きます(AMD は実験的)。Inte
|
||||
|
||||
すべてのサイズ、ダウンロード、どの構成で何が動くかは [docs/MODELS.md](docs/MODELS.md) にあります。
|
||||
|
||||
### DeepSeek V4.1-Flash(ターミナル版、0.2.4 から)
|
||||
|
||||
[DeepSeek V4.1-Flash](https://huggingface.co/ISTA-DASLab/DeepSeek-V4.1-Flash-1017-GSQ-RCO-GGUF) は、これとは別のずっと大きなモデルです。下の 3 つのセットは ISTA-DASLab が作りました。Strata は、12 GB のグラフィックスカード 1 枚と 46 から 64 GB の RAM を持つ PC でこれを動かし、残りは SSD から読みます。NVIDIA RTX 30 から 50 シリーズのカードと Radeon AI PRO R9700 で動きます。専用のエンジンパッケージ `strata-dsv41` を使い、DeepSeek を選ぶと `strata launch` がダウンロードします。デスクトップアプリはまだ対応していません。
|
||||
|
||||
| セット | ダウンロード | 元のモデルとの品質差 | 出力速度 |
|
||||
| --- | ---: | --- | --- |
|
||||
| **IQ1_M**(1.75 ビット) | 239 GB | KL 0.217、パープレキシティ 3.80(+21%)、コード +8.9%、エージェント作業 +10.2%、通常の英語の散文では弱めです。ISTA-DASLab のモデルカードによると、基本モデルのタスク平均の 98.7% を保ちます(AIME25、GPQA-Diamond、LiveCodeBench v6)。 | 未測定 |
|
||||
| **IQ2_XXS** (2.00 ビット) | 257 GB | KL 0.170、パープレキシティ 3.63(+15.5%)、コード +8.6%、エージェント作業 +5.3% | RTX 3060 で毎秒 6 から 9 トークン、RTX 5070 で約 5 |
|
||||
| **IQ2_XS** (2.25 ビット) | 274 GB | KL 0.107、パープレキシティ 3.37(+7.3%)、コード +3.2%、エージェント作業 +2.2% | Radeon AI PRO R9700 1 枚で毎秒約 11 トークン |
|
||||
| **IQ4_NL**(Engram テーブル、3 つ目のモデルではありません) | 111 GB、上の 3 つのダウンロードに含まれています | モデルが RAM または SSD から読む 4 ビットの Engram 参照テーブルです。3 つのセットはこの 1 つのファイル(`DeepSeek-V4.1-Flash-Engram-IQ4_NL.gguf`)を共有します。 | 該当なし |
|
||||
|
||||
Radeon AI PRO R9700 4 枚:毎秒約 31 トークン、DSpark ありで 55 から 69。
|
||||
|
||||
`strata launch --model ist-das-2.00bpw`(または `ist-das-1.75bpw`、`ist-das-2.25bpw`)で起動します。詳細、カードごとの速度、手動インストールは [docs/DEEPSEEK41.md](docs/DEEPSEEK41.md) にあります。
|
||||
|
||||
## どのくらい速い?
|
||||
|
||||
12 GBのグラフィックスカード(RTX 5070、Ryzen 5 7600、RAM 64 GB)での数値です。ほかのカードの結果は
|
||||
|
||||
@@ -33,7 +33,7 @@ agents. Your chats and files stay on your PC.
|
||||
- **Free.** MIT license, no account, no cloud.
|
||||
- **Private.** No telemetry. What Strata downloads, when it uses the internet and how it checks downloads is in [docs/SAFETY.md](docs/SAFETY.md).
|
||||
|
||||
Version 0.2.1 · engine 0.1.46
|
||||
Version 0.2.3 · engine 0.1.48
|
||||
|
||||
## What you need
|
||||
|
||||
@@ -73,20 +73,21 @@ line under the table.
|
||||
|
||||
| System | Your graphics card | File to download |
|
||||
| --- | --- | --- |
|
||||
| **Windows 10 / 11** | NVIDIA RTX, driver 580 or newer | [`Strata-0.2.1-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-cuda13.exe) |
|
||||
| | NVIDIA, older card or driver older than 580 | [`Strata-0.2.1-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-cuda12.exe) |
|
||||
| | AMD Radeon RX 9000 series or Radeon AI PRO R9700 | [`Strata-0.2.1-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-hip.exe) |
|
||||
| | AMD Radeon RX 7000 series or Ryzen AI Max (Strix Halo) | [`Strata-0.2.1-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-hip-rdna3.exe) |
|
||||
| **Linux** | NVIDIA RTX, driver 580 or newer | [`Strata-0.2.1-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-nvidia-cuda13.AppImage) |
|
||||
| | NVIDIA, older card or driver older than 580 | [`Strata-0.2.1-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-nvidia-cuda12.AppImage) |
|
||||
| | AMD Radeon RX 9000 series or Radeon AI PRO R9700 | [`Strata-0.2.1-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-rx9070.AppImage) |
|
||||
| | AMD Radeon RX 7000 series | [`Strata-0.2.1-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-rx7900.AppImage) |
|
||||
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.1-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-strixhalo.AppImage) |
|
||||
| **Mac** (Apple Silicon, macOS 15 or newer) | built in | [`Strata-0.2.1-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-mac.dmg) |
|
||||
| **Windows 10 / 11** | NVIDIA RTX, driver 580 or newer | [`Strata-0.2.3-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda13.exe) |
|
||||
| | NVIDIA, older card or driver older than 580 | [`Strata-0.2.3-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda12.exe) |
|
||||
| | AMD Radeon RX 9000 series or Radeon AI PRO R9700 | [`Strata-0.2.3-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip.exe) |
|
||||
| | AMD Radeon RX 7000 series or Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip-rdna3.exe) |
|
||||
| **Linux** | NVIDIA RTX, driver 580 or newer | [`Strata-0.2.3-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda13.AppImage) |
|
||||
| | NVIDIA, older card or driver older than 580 | [`Strata-0.2.3-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda12.AppImage) |
|
||||
| | AMD Radeon RX 9000 series or Radeon AI PRO R9700 | [`Strata-0.2.3-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx9070.AppImage) |
|
||||
| | AMD Radeon RX 7000 series | [`Strata-0.2.3-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx7900.AppImage) |
|
||||
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-strixhalo.AppImage) |
|
||||
| **Mac** (Apple Silicon, macOS 15 or newer) | built in | [`Strata-0.2.3-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-mac.dmg) |
|
||||
|
||||
**If you are unsure:** for NVIDIA, take `cuda13` if your driver is version 580 or newer, otherwise `cuda12`. `cuda12` is
|
||||
for older cards and older drivers. The `hip` and `amd` files are for AMD cards. Other AMD cards (RX 6000) have no
|
||||
ready-made app yet: see [AMD cards](docs/AMD_HIP.md).
|
||||
for older cards and older drivers. The `hip` and `amd` files are for AMD cards. RX 6000 cards have no installer of
|
||||
their own: install `hip-rdna3` (Windows) or `amd-rx7900` (Linux), and on a PC with an RX 6000 card it fetches the RX 6000 engine by itself
|
||||
(also `strata update`). That engine is built but not yet tested on a real RX 6000 card: see [AMD cards](docs/AMD_HIP.md).
|
||||
|
||||
Then:
|
||||
|
||||
@@ -157,17 +158,19 @@ Smaller sizes are faster. Larger sizes are a bit smarter.
|
||||
| Size | Memory it needs (RAM + VRAM) | Download | What it is |
|
||||
| --- | ---: | ---: | --- |
|
||||
| **Gyro-S** | 27.6 GB | 58.5 GB | For PCs that fit nothing else. Quality is lower than IQ2_XS. |
|
||||
| **Gyro-M** | 35 GB | 92.0 GB | About IQ3_XXS quality with about 20% less GPU memory. It writes answers 17-21% slower than Q2_0 on a 12 GB card and on an R9700. |
|
||||
| **Gyro-M** | 35 GB | 92.0 GB | About IQ3_XXS quality with about 20% less GPU memory. It writes answers 16% (12 GB RTX 3060) to 21% (R9700) slower than Q2_0. |
|
||||
|
||||
Gyro runs on NVIDIA and AMD cards (AMD is experimental), not on Intel Arc or the Mac. Details and measurements:
|
||||
Gyro runs on NVIDIA and AMD cards (AMD is experimental) on Intel Arc cards on Linux (tested on an Arc A750) and, from 0.2.2, on Apple Silicon Macs (Gyro-S on a 32 GB Mac mini: 9 tok/s, streaming from the SSD; [docs/MAC.md](docs/MAC.md#gyro-s-and-gyro-m-agentionais-trellis-experts)). Details and measurements:
|
||||
[docs/GYRO.md](docs/GYRO.md). Start it with `strata launch --model gyro-s`.
|
||||
|
||||
### The other sizes
|
||||
|
||||
The tiers count your RAM plus your VRAM, less 5 GB that Windows and the system keep for themselves. Every other size stays a choice.
|
||||
|
||||
| Your memory (RAM + VRAM) | Take | Notes |
|
||||
| --- | --- | --- |
|
||||
| **32 GB** | **Gyro-S**, or the **[Coder](docs/MODELS.md#coder)** for code | The Coder needs 32 GB of RAM and keeps half of the experts, so it is weaker outside code |
|
||||
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39.2 GB), or **Gyro-M** (35 GB) | Swift thinks for a shorter time before it answers. Gyro-M is close to IQ3_XXS in quality, but it writes answers 17-21% slower than Q2_0 |
|
||||
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39.2 GB), or **Gyro-M** (35 GB) | Swift thinks for a shorter time before it answers. Gyro-M is close to IQ3_XXS in quality, but it writes answers 16-21% slower than Q2_0 |
|
||||
| **48 GB** | **IQ3_XXS** (47.0 GB) | Slower, better |
|
||||
| **64 GB** | **IQ3_S** (54.8 GB), the original or Swift 1.5 | The slowest, the best: it matches the full model on the published tests |
|
||||
| **96 GB or more** | Unsloth's **UD-IQ4_XS** (about 72 GB of RAM) to **Q8_0** (about 136 GB), 4 to 8 bit | Closer to the full model. With less RAM, Strata reads part of them from the SSD, which is much slower |
|
||||
@@ -185,6 +188,21 @@ More choices:
|
||||
|
||||
Every size, download and what fits where: [docs/MODELS.md](docs/MODELS.md).
|
||||
|
||||
### DeepSeek V4.1-Flash (in the Terminal, from 0.2.4)
|
||||
|
||||
[DeepSeek V4.1-Flash](https://huggingface.co/ISTA-DASLab/DeepSeek-V4.1-Flash-1017-GSQ-RCO-GGUF) is a different, much bigger model. ISTA-DASLab made the three sets below. Strata runs them on a PC with one 12 GB graphics card and 46 to 64 GB of RAM, and reads the rest from your SSD. It runs on NVIDIA RTX 30 to 50 cards and on the Radeon AI PRO R9700. It uses its own engine package, `strata-dsv41`, which `strata launch` downloads when you pick DeepSeek. The desktop app does not offer it yet.
|
||||
|
||||
| Set | Download | Quality against the original model | Writes at |
|
||||
| --- | ---: | --- | --- |
|
||||
| **IQ1_M** (1.75 bits) | 239 GB | KL 0.217, perplexity 3.80 (+21%); code +8.9%, agent work +10.2%; weaker on plain English prose. ISTA-DASLab's model card: keeps 98.7% of the base model's task average (AIME25, GPQA-Diamond, LiveCodeBench v6). | not measured yet |
|
||||
| **IQ2_XXS** (2.00 bits) | 257 GB | KL 0.170, perplexity 3.63 (+15.5%); code +8.6%, agent work +5.3% | 6 to 9 tokens/s on an RTX 3060, about 5 on an RTX 5070 |
|
||||
| **IQ2_XS** (2.25 bits) | 274 GB | KL 0.107, perplexity 3.37 (+7.3%); code +3.2%, agent work +2.2% | about 11 tokens/s on one Radeon AI PRO R9700 |
|
||||
| **IQ4_NL** (Engram table, not a third model) | 111 GB, already in all three downloads above | The 4-bit Engram lookup table the model reads from RAM or SSD. All three sets use this one file (`DeepSeek-V4.1-Flash-Engram-IQ4_NL.gguf`). | n/a |
|
||||
|
||||
4x Radeon AI PRO R9700: about 31 tokens/s, 55 to 69 with DSpark.
|
||||
|
||||
Start it with `strata launch --model ist-das-2.00bpw` (or `ist-das-1.75bpw`, `ist-das-2.25bpw`). Details, speeds per card and the manual install: [docs/DEEPSEEK41.md](docs/DEEPSEEK41.md).
|
||||
|
||||
## How fast is it?
|
||||
|
||||
Based on a 12 GB graphics card (RTX 5070, Ryzen 5 7600, 64 GB RAM). See results from other cards:
|
||||
|
||||
+29
-14
@@ -33,7 +33,7 @@ agentes de programação. Suas conversas e arquivos ficam no seu PC.
|
||||
- **Gratuito.** Licença MIT, sem conta, sem nuvem.
|
||||
- **Privado.** Sem telemetria. O que o Strata baixa, quando usa a internet e como confere os downloads está em [docs/SAFETY.md](docs/SAFETY.md).
|
||||
|
||||
Versão 0.2.1 · mecanismo 0.1.46
|
||||
Versão 0.2.3 · mecanismo 0.1.48
|
||||
|
||||
## O que você precisa
|
||||
|
||||
@@ -73,16 +73,16 @@ logo abaixo da tabela.
|
||||
|
||||
| Sistema | Sua placa de vídeo | Arquivo para baixar |
|
||||
| --- | --- | --- |
|
||||
| **Windows 10 / 11** | NVIDIA RTX, driver 580 ou mais novo | [`Strata-0.2.1-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-cuda13.exe) |
|
||||
| | NVIDIA, placa mais antiga ou driver anterior ao 580 | [`Strata-0.2.1-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-cuda12.exe) |
|
||||
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.1-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-hip.exe) |
|
||||
| | AMD Radeon série RX 7000 ou Ryzen AI Max (Strix Halo) | [`Strata-0.2.1-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-hip-rdna3.exe) |
|
||||
| **Linux** | NVIDIA RTX, driver 580 ou mais novo | [`Strata-0.2.1-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-nvidia-cuda13.AppImage) |
|
||||
| | NVIDIA, placa mais antiga ou driver anterior ao 580 | [`Strata-0.2.1-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-nvidia-cuda12.AppImage) |
|
||||
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.1-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-rx9070.AppImage) |
|
||||
| | AMD Radeon série RX 7000 | [`Strata-0.2.1-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-rx7900.AppImage) |
|
||||
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.1-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-strixhalo.AppImage) |
|
||||
| **Mac** (Apple Silicon, macOS 15 ou mais novo) | integrada | [`Strata-0.2.1-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-mac.dmg) |
|
||||
| **Windows 10 / 11** | NVIDIA RTX, driver 580 ou mais novo | [`Strata-0.2.3-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda13.exe) |
|
||||
| | NVIDIA, placa mais antiga ou driver anterior ao 580 | [`Strata-0.2.3-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda12.exe) |
|
||||
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.3-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip.exe) |
|
||||
| | AMD Radeon série RX 7000 ou Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip-rdna3.exe) |
|
||||
| **Linux** | NVIDIA RTX, driver 580 ou mais novo | [`Strata-0.2.3-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda13.AppImage) |
|
||||
| | NVIDIA, placa mais antiga ou driver anterior ao 580 | [`Strata-0.2.3-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda12.AppImage) |
|
||||
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.3-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx9070.AppImage) |
|
||||
| | AMD Radeon série RX 7000 | [`Strata-0.2.3-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx7900.AppImage) |
|
||||
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-strixhalo.AppImage) |
|
||||
| **Mac** (Apple Silicon, macOS 15 ou mais novo) | integrada | [`Strata-0.2.3-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-mac.dmg) |
|
||||
|
||||
**Se você estiver em dúvida:** para NVIDIA, use `cuda13` se o seu driver for a versão 580 ou mais nova, e `cuda12` nos outros casos. O `cuda12`
|
||||
serve para placas e drivers mais antigos. Os arquivos `hip` e `amd` são para placas AMD. Outras placas AMD (RX 6000) ainda não têm app pronto: veja [Placas AMD](docs/AMD_HIP.md).
|
||||
@@ -156,9 +156,9 @@ O [Gyro](https://huggingface.co/agentionai/Qwen3.8-Flash-Next-Gyro-GGUF) (da age
|
||||
| Tamanho | Memória necessária (RAM + VRAM) | Download | O que é |
|
||||
| --- | ---: | ---: | --- |
|
||||
| **Gyro-S** | 27,6 GB | 58,5 GB | Para PCs onde nenhum outro tamanho cabe. A qualidade é menor que a do IQ2_XS. |
|
||||
| **Gyro-M** | 35 GB | 92,0 GB | Qualidade próxima à do IQ3_XXS, com cerca de 20% menos memória da GPU. Ele escreve as respostas 17 a 21% mais devagar que o Q2_0 numa placa de 12 GB e numa R9700. |
|
||||
| **Gyro-M** | 35 GB | 92,0 GB | Qualidade próxima à do IQ3_XXS, com cerca de 20% menos memória da GPU. Ele escreve as respostas 16% mais devagar que o Q2_0 numa RTX 3060 de 12 GB e 21% mais devagar numa R9700. |
|
||||
|
||||
O Gyro roda em placas NVIDIA e AMD (AMD é experimental), e não roda em Intel Arc nem no Mac. Detalhes e medições:
|
||||
O Gyro roda em placas NVIDIA e AMD (AMD é experimental) em placas Intel Arc no Linux (testado numa Arc A750) e, desde a 0.2.2, em Macs com Apple Silicon (Gyro-S num Mac mini de 32 GB: 9 tokens/s, com os especialistas lidos do SSD). Detalhes e medições:
|
||||
[docs/GYRO.md](docs/GYRO.md). Inicie com `strata launch --model gyro-s`.
|
||||
|
||||
### Os outros tamanhos
|
||||
@@ -166,7 +166,7 @@ O Gyro roda em placas NVIDIA e AMD (AMD é experimental), e não roda em Intel A
|
||||
| Sua memória (RAM + VRAM) | Escolha | Observações |
|
||||
| --- | --- | --- |
|
||||
| **32 GB** | **Gyro-S**, ou o **[Coder](docs/MODELS.md#coder)** para código | O Coder precisa de 32 GB de RAM e mantém metade dos experts, então é mais fraco fora de código |
|
||||
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 GB), ou **Gyro-M** (35 GB) | O Swift pensa por menos tempo antes de responder. O Gyro-M chega perto do IQ3_XXS em qualidade, mas escreve as respostas 17 a 21% mais devagar que o Q2_0 |
|
||||
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 GB), ou **Gyro-M** (35 GB) | O Swift pensa por menos tempo antes de responder. O Gyro-M chega perto do IQ3_XXS em qualidade, mas escreve as respostas 16 a 21% mais devagar que o Q2_0 |
|
||||
| **48 GB** | **IQ3_XXS** (47,0 GB) | Mais lento, melhor |
|
||||
| **64 GB** | **IQ3_S** (54,8 GB), o original ou o Swift 1.5 | O mais lento, o melhor: empata com o modelo completo nos testes publicados |
|
||||
| **96 GB ou mais** | **UD-IQ4_XS** da Unsloth (cerca de 72 GB de RAM) até **Q8_0** (cerca de 136 GB), de 4 a 8 bits | Mais perto do modelo completo. Com menos RAM, o Strata lê parte deles do SSD, o que é bem mais lento |
|
||||
@@ -183,6 +183,21 @@ Mais opções:
|
||||
|
||||
Todos os tamanhos, downloads e o que cabe em cada configuração: [docs/MODELS.md](docs/MODELS.md).
|
||||
|
||||
### DeepSeek V4.1-Flash (no Terminal, a partir da 0.2.4)
|
||||
|
||||
[DeepSeek V4.1-Flash](https://huggingface.co/ISTA-DASLab/DeepSeek-V4.1-Flash-1017-GSQ-RCO-GGUF) é outro modelo, bem maior. A ISTA-DASLab fez os três conjuntos abaixo. O Strata os executa em um PC com uma placa de vídeo de 12 GB e de 46 a 64 GB de RAM, e lê o resto do seu SSD. Ele roda em placas NVIDIA RTX das séries 30 a 50 e na Radeon AI PRO R9700. Usa um pacote de motor próprio, `strata-dsv41`, que o `strata launch` baixa quando você escolhe o DeepSeek. O app de desktop ainda não o oferece.
|
||||
|
||||
| Conjunto | Download | Qualidade em relação ao modelo original | Responde a |
|
||||
| --- | ---: | --- | --- |
|
||||
| **IQ1_M** (1,75 bits) | 239 GB | KL 0,217, perplexidade 3,80 (+21%); código +8,9%, tarefas de agente +10,2%; mais fraco em prosa comum em inglês. Segundo o cartão do modelo da ISTA-DASLab, mantém 98,7% da média de tarefas do modelo base (AIME25, GPQA-Diamond, LiveCodeBench v6). | ainda não medida |
|
||||
| **IQ2_XXS** (2.00 bits) | 257 GB | KL 0,170, perplexidade 3,63 (+15,5%); código +8,6%, tarefas de agente +5,3% | 6 a 9 tokens/s em uma RTX 3060, cerca de 5 em uma RTX 5070 |
|
||||
| **IQ2_XS** (2.25 bits) | 274 GB | KL 0,107, perplexidade 3,37 (+7,3%); código +3,2%, tarefas de agente +2,2% | cerca de 11 tokens/s em uma Radeon AI PRO R9700 |
|
||||
| **IQ4_NL** (tabela Engram, não é um terceiro modelo) | 111 GB, já incluídos nos três downloads acima | A tabela de consulta Engram de 4 bits que o modelo lê da RAM ou do SSD. Os três conjuntos usam este único arquivo (`DeepSeek-V4.1-Flash-Engram-IQ4_NL.gguf`). | n/a |
|
||||
|
||||
4x Radeon AI PRO R9700: cerca de 31 tokens/s, 55 a 69 com DSpark.
|
||||
|
||||
Inicie com `strata launch --model ist-das-2.00bpw` (ou `ist-das-1.75bpw`, `ist-das-2.25bpw`). Detalhes, velocidades por placa e a instalação manual: [docs/DEEPSEEK41.md](docs/DEEPSEEK41.md).
|
||||
|
||||
## Qual é a velocidade?
|
||||
|
||||
Com base numa placa de vídeo de 12 GB (RTX 5070, Ryzen 5 7600, 64 GB de RAM). Veja resultados de outras placas:
|
||||
|
||||
+29
-14
@@ -33,7 +33,7 @@ Strata 运行 **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-N
|
||||
- **免费。** MIT 许可证,不用注册账号,不走云端。
|
||||
- **隐私。** 没有遥测。Strata 下载什么、什么时候联网、如何校验下载内容,都写在 [docs/SAFETY.md](docs/SAFETY.md)。
|
||||
|
||||
版本 0.2.1 · 引擎 0.1.46
|
||||
版本 0.2.3 · 引擎 0.1.48
|
||||
|
||||
## 硬件要求
|
||||
|
||||
@@ -72,16 +72,16 @@ Strata 运行 **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-N
|
||||
|
||||
| 系统 | 你的显卡 | 要下载的文件 |
|
||||
| --- | --- | --- |
|
||||
| **Windows 10 / 11** | NVIDIA RTX,驱动 580 或更新 | [`Strata-0.2.1-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-cuda13.exe) |
|
||||
| | NVIDIA,较旧的显卡,或驱动低于 580 | [`Strata-0.2.1-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-cuda12.exe) |
|
||||
| | AMD Radeon RX 9000 系列或 Radeon AI PRO R9700 | [`Strata-0.2.1-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-hip.exe) |
|
||||
| | AMD Radeon RX 7000 系列或 Ryzen AI Max (Strix Halo) | [`Strata-0.2.1-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-hip-rdna3.exe) |
|
||||
| **Linux** | NVIDIA RTX,驱动 580 或更新 | [`Strata-0.2.1-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-nvidia-cuda13.AppImage) |
|
||||
| | NVIDIA,较旧的显卡,或驱动低于 580 | [`Strata-0.2.1-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-nvidia-cuda12.AppImage) |
|
||||
| | AMD Radeon RX 9000 系列或 Radeon AI PRO R9700 | [`Strata-0.2.1-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-rx9070.AppImage) |
|
||||
| | AMD Radeon RX 7000 系列 | [`Strata-0.2.1-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-rx7900.AppImage) |
|
||||
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.1-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-strixhalo.AppImage) |
|
||||
| **Mac**(Apple 芯片,macOS 15 及以上) | 内置 | [`Strata-0.2.1-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-mac.dmg) |
|
||||
| **Windows 10 / 11** | NVIDIA RTX,驱动 580 或更新 | [`Strata-0.2.3-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda13.exe) |
|
||||
| | NVIDIA,较旧的显卡,或驱动低于 580 | [`Strata-0.2.3-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda12.exe) |
|
||||
| | AMD Radeon RX 9000 系列或 Radeon AI PRO R9700 | [`Strata-0.2.3-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip.exe) |
|
||||
| | AMD Radeon RX 7000 系列或 Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip-rdna3.exe) |
|
||||
| **Linux** | NVIDIA RTX,驱动 580 或更新 | [`Strata-0.2.3-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda13.AppImage) |
|
||||
| | NVIDIA,较旧的显卡,或驱动低于 580 | [`Strata-0.2.3-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda12.AppImage) |
|
||||
| | AMD Radeon RX 9000 系列或 Radeon AI PRO R9700 | [`Strata-0.2.3-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx9070.AppImage) |
|
||||
| | AMD Radeon RX 7000 系列 | [`Strata-0.2.3-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx7900.AppImage) |
|
||||
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-strixhalo.AppImage) |
|
||||
| **Mac**(Apple 芯片,macOS 15 及以上) | 内置 | [`Strata-0.2.3-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-mac.dmg) |
|
||||
|
||||
**不确定时:** NVIDIA 显卡的驱动版本在 580 及以上就选 `cuda13`,否则选 `cuda12`。`cuda12` 适用于较旧的显卡和驱动。
|
||||
`hip` 和 `amd` 的文件是给 AMD 显卡用的。其他 AMD 显卡(RX 6000)目前还没有现成的应用,
|
||||
@@ -155,9 +155,9 @@ Set up Strata on this PC for me: https://github.com/Niko1221/Strata - follow doc
|
||||
| 尺寸 | 所需内存(RAM + VRAM) | 下载大小 | 说明 |
|
||||
| --- | ---: | ---: | --- |
|
||||
| **Gyro-S** | 27.6 GB | 58.5 GB | 给其他尺寸都放不下的电脑。质量低于 IQ2_XS。 |
|
||||
| **Gyro-M** | 35 GB | 92.0 GB | 质量约相当于 IQ3_XXS,显存占用少约 20%。在 12 GB 显卡和 R9700 上,输出回答比 Q2_0 慢 17-21%。 |
|
||||
| **Gyro-M** | 35 GB | 92.0 GB | 质量约相当于 IQ3_XXS,显存占用少约 20%。输出回答比 Q2_0 慢,12 GB 的 RTX 3060 上慢 16%,R9700 上慢 21%。 |
|
||||
|
||||
Gyro 可在 NVIDIA 和 AMD 显卡上运行(AMD 为实验性支持),不支持 Intel Arc 和 Mac。详情和测试数据:
|
||||
Gyro 可在 NVIDIA 和 AMD 显卡(AMD 为实验性支持)以及 Linux 上的 Intel Arc 显卡(已在 Arc A750 上测试)上运行,自 0.2.2 起也可在 Apple Silicon 的 Mac 上运行(32 GB 的 Mac mini 上 Gyro-S 为 9 token/秒,专家权重从 SSD 读取)。详情和测试数据:
|
||||
[docs/GYRO.md](docs/GYRO.md)。用 `strata launch --model gyro-s` 启动。
|
||||
|
||||
### 其他尺寸
|
||||
@@ -165,7 +165,7 @@ Gyro 可在 NVIDIA 和 AMD 显卡上运行(AMD 为实验性支持),不支
|
||||
| 你的内存(RAM + VRAM) | 选择 | 备注 |
|
||||
| --- | --- | --- |
|
||||
| **32 GB** | **Gyro-S**,写代码可选 **[Coder](docs/MODELS.md#coder)** | Coder 需要 32 GB 内存,只保留一半的专家,所以代码以外的能力较弱 |
|
||||
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS(39.2 GB),或 **Gyro-M**(35 GB) | Swift 回答前思考的时间更短。Gyro-M 的质量接近 IQ3_XXS,但输出回答比 Q2_0 慢 17-21% |
|
||||
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS(39.2 GB),或 **Gyro-M**(35 GB) | Swift 回答前思考的时间更短。Gyro-M 的质量接近 IQ3_XXS,但输出回答比 Q2_0 慢 16-21% |
|
||||
| **48 GB** | **IQ3_XXS**(47.0 GB) | 更慢,更好 |
|
||||
| **64 GB** | **IQ3_S**(54.8 GB),原版或 Swift 1.5 | 最慢,最好:在公开测试中与完整模型持平 |
|
||||
| **96 GB 及以上** | Unsloth 的 **UD-IQ4_XS**(约 72 GB 内存)到 **Q8_0**(约 136 GB),4 到 8 位 | 更接近完整模型。内存不足时,Strata 会从固态硬盘读取一部分,速度会慢很多 |
|
||||
@@ -182,6 +182,21 @@ Gyro 可在 NVIDIA 和 AMD 显卡上运行(AMD 为实验性支持),不支
|
||||
|
||||
所有尺寸、下载大小,以及各种配置能跑什么:[docs/MODELS.md](docs/MODELS.md)。
|
||||
|
||||
### DeepSeek V4.1-Flash(终端版,0.2.4 起)
|
||||
|
||||
[DeepSeek V4.1-Flash](https://huggingface.co/ISTA-DASLab/DeepSeek-V4.1-Flash-1017-GSQ-RCO-GGUF) 是另一个大得多的模型。下面三个版本由 ISTA-DASLab 制作。Strata 在一台有一块 12 GB 显卡和 46 到 64 GB 内存的电脑上运行它们,其余部分从固态硬盘读取。它可在 NVIDIA RTX 30 到 50 系列显卡和 Radeon AI PRO R9700 上运行。它使用自己的引擎包 `strata-dsv41`,你选择 DeepSeek 时由 `strata launch` 下载。桌面应用暂时不提供它。
|
||||
|
||||
| 版本 | 下载大小 | 与原模型相比的质量 | 生成速度 |
|
||||
| --- | ---: | --- | --- |
|
||||
| **IQ1_M**(1.75 位) | 239 GB | KL 0.217,困惑度 3.80(+21%);代码 +8.9%,智能体任务 +10.2%;普通英文散文表现较弱。据 ISTA-DASLab 的模型卡,它保留了基础模型任务平均分的 98.7%(AIME25、GPQA-Diamond、LiveCodeBench v6)。 | 尚未测量 |
|
||||
| **IQ2_XXS** (2.00 位) | 257 GB | KL 0.170,困惑度 3.63(+15.5%);代码 +8.6%,智能体任务 +5.3% | RTX 3060 上每秒 6 到 9 个 token,RTX 5070 上约 5 个 |
|
||||
| **IQ2_XS** (2.25 位) | 274 GB | KL 0.107,困惑度 3.37(+7.3%);代码 +3.2%,智能体任务 +2.2% | 一块 Radeon AI PRO R9700 上每秒约 11 个 token |
|
||||
| **IQ4_NL**(Engram 表,不是第三个模型) | 111 GB,已包含在上面三个下载中 | 模型从内存或固态硬盘读取的 4 位 Engram 查找表。三个版本共用这一个文件(`DeepSeek-V4.1-Flash-Engram-IQ4_NL.gguf`)。 | 不适用 |
|
||||
|
||||
4 块 Radeon AI PRO R9700:每秒约 31 个 token,使用 DSpark 时为 55 到 69。
|
||||
|
||||
用 `strata launch --model ist-das-2.00bpw`(或 `ist-das-1.75bpw`、`ist-das-2.25bpw`)启动。详情、各显卡的速度和手动安装见 [docs/DEEPSEEK41.md](docs/DEEPSEEK41.md)。
|
||||
|
||||
## 速度如何?
|
||||
|
||||
以下数据基于 12 GB 显卡(RTX 5070、Ryzen 5 7600、64 GB 内存)。其他显卡的结果见:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "strata-desktop",
|
||||
"productName": "Strata",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.3",
|
||||
"private": true,
|
||||
"description": "Strata Desktop - local AI, tuned to one model.",
|
||||
"scripts": {
|
||||
|
||||
Generated
+1
-1
@@ -3524,7 +3524,7 @@ checksum = "ff7589a1859b09232522bb9edf076ca7fcf14ded96685d3fa629db73023ffbb7"
|
||||
|
||||
[[package]]
|
||||
name = "strata-desktop"
|
||||
version = "0.2.1"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"serde",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "strata-desktop"
|
||||
version = "0.2.1"
|
||||
version = "0.2.3"
|
||||
description = "Strata Desktop — local AI, tuned to one model."
|
||||
edition = "2021"
|
||||
rust-version = "1.77"
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "strata",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "strata",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.3",
|
||||
"dependencies": {
|
||||
"html-to-image": "^1.11.13",
|
||||
"katex": "^0.19.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "strata",
|
||||
"private": true,
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.3",
|
||||
"type": "module",
|
||||
"description": "Strata \u2014 local AI, tuned to one model. Web shell + engine host.",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* The context fixes against a REAL engine (not the scripted worker): Gyro-S at 64K on this PC, the agent bridge and the real Harness.
|
||||
*
|
||||
* gpu + heavy locks held first (python phaseA-tests\lock.py acquire gpu "023-agent: context e2e", then heavy); one engine; nothing of the
|
||||
* installed app is used or touched: its model files are read, its Harness is copied (STRATA_HARNESS_DIR), data goes to a scratch folder.
|
||||
*
|
||||
* node scripts/context-e2e.mjs (needs STRATA_HARNESS_DIR; see the E2E_* names below)
|
||||
*
|
||||
* It starts serve/server.py as ITS OWN child (foreground: this script waits for it and stops it by PID at the end or on a memory alarm),
|
||||
* runs four scenarios and prints one table. Exit code 0 when every expectation held.
|
||||
*
|
||||
* A a message that can never fit (about 60K tokens in a 64K window) is refused in seconds, before the engine reads it
|
||||
* B a chat of dense log messages grows past the window several times over: automatic compaction runs, nothing is refused
|
||||
* C /compact at a high fill: summarises, says the figures; and a second /compact is answered in plain words
|
||||
* D the needle: a fact from the first message is asked about at the end (reported, not asserted: it is the summary's quality)
|
||||
*/
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createAgentBridge } from '../server/agent/agent-bridge.mjs';
|
||||
import { countTokens } from '../server/agent/context-fit.mjs';
|
||||
|
||||
const REPO = join(import.meta.dirname, '..', '..', '..');
|
||||
const env = (name, fallback) => process.env[name] ?? fallback;
|
||||
const DOCS = env('E2E_DOCS', 'C:\\Users\\AI-Server\\Documents\\Strata');
|
||||
const ENGINE = env('E2E_ENGINE', 'C:\\Users\\AI-Server\\Desktop\\Strata\\GitHub\\wt-023-int\\build-cuda\\strata.exe');
|
||||
const PROFILE = env('E2E_EXPERT_PROFILE', 'C:\\Users\\AI-Server\\AppData\\Local\\Programs\\Strata\\control\\data\\expert-profile.bin');
|
||||
const PYTHON = env('E2E_PYTHON', 'python');
|
||||
const PORT = Number(env('E2E_PORT', '18590'));
|
||||
const MIN_FREE_GB = Number(env('E2E_MIN_FREE_GB', '7'));
|
||||
const GGUF = join(DOCS, 'models', 'gyro-Gyro-S', 'Qwen3.8-Flash-Next-Gyro-S-TQ1_0.gguf');
|
||||
const CUDA_BIN = env('E2E_CUDA_BIN', 'C:\\Program Files\\NVIDIA GPU Computing Toolkit\\CUDA\\v13.0\\bin');
|
||||
const WINDOW = 65536;
|
||||
/** A model server that is already running (the scripted worker, to try this script without a GPU): nothing is started or stopped. */
|
||||
const EXISTING = process.env.E2E_BASE_URL ?? '';
|
||||
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
const freeGb = () => {
|
||||
const out = spawnSync('powershell', ['-NoProfile', '-Command', '[math]::Round((Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory/1MB,1)'], { encoding: 'utf8' }).stdout.trim();
|
||||
return Number(out);
|
||||
};
|
||||
const rows = [];
|
||||
const note = (id, ok, detail) => { rows.push({ id, ok, detail }); console.log(`${ok ? 'PASS' : 'FAIL'} ${id} ${detail}`); };
|
||||
|
||||
if (!process.env.STRATA_HARNESS_DIR) { console.error('STRATA_HARNESS_DIR is not set'); process.exit(2); }
|
||||
if (!EXISTING) for (const f of [ENGINE, GGUF]) if (!existsSync(f)) { console.error(`missing: ${f}`); process.exit(2); }
|
||||
if (!EXISTING && freeGb() < 20) { console.error(`only ${freeGb()} GB of RAM are free; wait for the other streams (lock.py status) and run again`); process.exit(2); }
|
||||
|
||||
const scratch = mkdtempSync(join(tmpdir(), 'strata-ctx-e2e-'));
|
||||
const configFile = join(scratch, 'server.json');
|
||||
const logFile = join(scratch, 'server.log');
|
||||
const readyFile = join(scratch, 'ready.json');
|
||||
writeFileSync(configFile, JSON.stringify({
|
||||
exe: ENGINE,
|
||||
args: ['--pack', join(DOCS, 'packs', 'gyro-gyro-s'), '--native', GGUF, '--ple-gguf', GGUF, ...(existsSync(PROFILE) ? ['--expert-profile', PROFILE] : []),
|
||||
'--prefill', 'auto', '--spec-min-p', '0.5', '--mtp', join(DOCS, 'mtp', 'rt'), '--max-context', String(WINDOW), '--kv', 'int8', '--pool-workers', '5',
|
||||
'--expert-cache', 'auto', '--kv-resident', '32768', '--spec', '4', '--vram-reserve-mib', '1536'],
|
||||
cwd: REPO, tokenizer: join(DOCS, 'packs', 'gyro-gyro-s', 'tokenizer'), model_name: 'qwen3.8-flash-next-gyro-gyro-s', log: join(scratch, 'engine.log'), lib_dirs: [], port: PORT, gpu: 0,
|
||||
}, null, 2));
|
||||
|
||||
let server = null, bridge = null, alarm = null;
|
||||
const stopServer = () => {
|
||||
if (server?.pid) spawnSync('taskkill', ['/T', '/F', '/PID', String(server.pid)], { stdio: 'ignore' });
|
||||
server = null;
|
||||
};
|
||||
const finish = async (code) => {
|
||||
clearInterval(alarm);
|
||||
try { await bridge?.close(); } catch { /* going away */ }
|
||||
stopServer();
|
||||
await sleep(1500);
|
||||
console.log(`\nscratch folder: ${scratch}`);
|
||||
process.exit(code);
|
||||
};
|
||||
process.on('SIGINT', () => finish(130));
|
||||
|
||||
if (!EXISTING) server = spawn(PYTHON, ['serve/server.py', '--engine', 'strata', '--config', configFile, '--port', String(PORT), '--no-api-key', '--ready-file', readyFile], {
|
||||
cwd: REPO, env: { ...process.env, PATH: `${CUDA_BIN};${process.env.PATH}`, STRATA_DATA_DIR: join(scratch, 'data') }, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
|
||||
});
|
||||
const sink = [];
|
||||
if (server) { server.stdout.on('data', d => sink.push(String(d))); server.stderr.on('data', d => sink.push(String(d))); server.on('exit', code => { sink.push(`[server exited ${code}]`); server = null; }); }
|
||||
// Memory alarm: this run must never be what freezes the PC. Under the floor the engine is stopped (by PID) and the run ends.
|
||||
if (!EXISTING) alarm = setInterval(() => { const free = freeGb(); if (free < MIN_FREE_GB) { console.error(`ALARM: ${free} GB of RAM free, stopping the engine`); finish(3); } }, 3000);
|
||||
alarm?.unref?.();
|
||||
|
||||
const base = EXISTING || `http://127.0.0.1:${PORT}`;
|
||||
let ready = false;
|
||||
for (let i = 0; i < 120 && !ready; i++) {
|
||||
if (!EXISTING && !server) break;
|
||||
try { ready = (await fetch(`${base}/health`, { signal: AbortSignal.timeout(2000) })).ok; } catch { /* still loading */ }
|
||||
if (!ready) await sleep(2000);
|
||||
}
|
||||
writeFileSync(logFile, sink.join(''));
|
||||
if (!ready) { console.error('the engine did not become ready:\n' + sink.join('').slice(-1500)); await finish(4); }
|
||||
const model = (await (await fetch(`${base}/v1/models`)).json()).data[0].id;
|
||||
console.log(`engine ready: ${model}, free RAM ${freeGb()} GB`);
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- the pieces
|
||||
|
||||
const line = i => `2026-10-22 13:${String(Math.floor(i / 60) % 60).padStart(2, '0')}:${String(i % 60).padStart(2, '0')} INFO svc-${i % 17} req=${(i * 2654435761 >>> 0).toString(16)} status=${200 + (i % 7) * 3} ms=${(i * 37) % 900} path=/api/v1/items/${(i * 7919) % 100000}`;
|
||||
const logText = (chars, from = 0, needle = '') => {
|
||||
const out = [];
|
||||
let n = 0, i = from;
|
||||
while (n < chars) { const l = line(i++); out.push(l); n += l.length + 1; }
|
||||
if (needle) out.splice(Math.floor(out.length / 2), 0, needle);
|
||||
return out.join('\n');
|
||||
};
|
||||
const count = text => countTokens({ baseUrl: `${base}/v1`, model, text });
|
||||
|
||||
const turnsOf = async (message) => {
|
||||
const frames = [];
|
||||
const t0 = Date.now();
|
||||
await bridge.runTurn({ chatId: 'e2e', message, workspacePath: scratch, mode: 'bypass', thinking: 'Off', onFrame: f => frames.push(f) });
|
||||
return { frames, seconds: (Date.now() - t0) / 1000, last: frames.at(-1) };
|
||||
};
|
||||
const textOf = frames => frames.filter(f => f.type === 'delta' && !f.noteReason).map(f => f.text).join('').trim();
|
||||
const noteOf = frames => frames.find(f => f.type === 'delta' && f.noteReason === 'compacted')?.text?.trim() ?? null;
|
||||
|
||||
bridge = createAgentBridge({
|
||||
dataDir: join(scratch, 'agent'), env: process.env, startTimeoutMs: 120_000, log: l => console.log(' bridge:', l),
|
||||
localInstances: () => [{ id: 'e2e', label: 'Gyro-S', baseUrl: `${base}/v1`, modelId: model, context: WINDOW, slots: 1, key: '' }], connections: () => [],
|
||||
});
|
||||
mkdirSync(join(scratch, 'agent'), { recursive: true });
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- A
|
||||
{
|
||||
// Grow a log until the engine itself counts about 60K tokens.
|
||||
let chars = 150_000, tokens = await count(logText(chars));
|
||||
while (tokens && tokens < 58_000 && chars < 400_000) { chars = Math.round(chars * (60_000 / tokens)); tokens = await count(logText(chars)); }
|
||||
const t = await turnsOf(logText(chars) + '\n\nWhat is the most common status?');
|
||||
const refused = t.last.type === 'error' && t.last.code === 'CONTEXT_MESSAGE_TOO_LARGE';
|
||||
note('A too large is refused before it is read', refused && t.seconds < 15, `${chars} characters = ${tokens} tokens by the engine; answered in ${t.seconds.toFixed(1)} s; frame ${t.last.type}/${t.last.code ?? '-'}: ${String(t.last.message ?? '').slice(0, 110)}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- B
|
||||
const needle = 'NOTE FROM OPS: the vault code is COBALT-27. Do not lose it.';
|
||||
const timeline = [];
|
||||
let notes = 0, refusedAny = false, peak = 0;
|
||||
{
|
||||
for (let k = 0; k < 5; k++) {
|
||||
const body = logText(34_000, k * 1000, k === 0 ? needle : '');
|
||||
const t = await turnsOf(`${body}\n\nReply with the single word OK.`);
|
||||
const note = noteOf(t.frames);
|
||||
if (note) notes++;
|
||||
if (t.last.type === 'error') refusedAny = true;
|
||||
const used = t.last.contextTokens ?? 0;
|
||||
peak = Math.max(peak, ...t.frames.filter(f => f.type === 'context').map(f => f.contextTokens), used);
|
||||
timeline.push(`msg${k + 1}: ${t.last.type}${t.last.code ? '/' + t.last.code : ''} ${t.seconds.toFixed(0)} s context ${used} (${Math.round(100 * used / WINDOW)}%)${note ? ' [' + note.slice(0, 80) + ']' : ''}`);
|
||||
console.log(' ' + timeline.at(-1));
|
||||
if (t.last.type === 'error') break;
|
||||
}
|
||||
note('B five dense messages (about 14K tokens each) in a 64K window: no error', !refusedAny, `peak context ${peak} of ${WINDOW}; ${notes} compaction note(s) in the chat`);
|
||||
note('B compaction ran and was announced', notes >= 1, timeline.join(' | '));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- C
|
||||
{
|
||||
const run = async (line) => { const frames = []; await bridge.compactCommand({ chatId: 'e2e', message: line, workspacePath: scratch, mode: 'bypass', choice: null, onFrame: f => frames.push(f) }); return frames; };
|
||||
await turnsOf(`${logText(30_000, 90_000)}\n\nReply with the single word OK.`);
|
||||
const t0 = Date.now();
|
||||
const first = await run('/compact');
|
||||
const said = first.find(f => f.type === 'delta');
|
||||
const ok = said?.key === 'Older messages were summarized to make room. The chat was {0}% full and is now {1}% full.' && said.values[0] > said.values[1];
|
||||
note('C /compact summarises and states the figures', ok, `${((Date.now() - t0) / 1000).toFixed(0)} s: ${said?.text}`);
|
||||
const second = await run('/compact');
|
||||
const again = second.find(f => f.type === 'delta')?.text ?? '';
|
||||
note('C a second /compact is answered in plain words', /summarized|nothing|could not/i.test(again) && !/\{|400|invalid_request/.test(again), again);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- D
|
||||
{
|
||||
const t = await turnsOf('What was the vault code in the very first message? Answer with the code only.');
|
||||
const answer = textOf(t.frames);
|
||||
console.log(`D needle after compaction: "${answer.slice(0, 80)}" (${t.last.type})`);
|
||||
rows.push({ id: 'D the needle', ok: /COBALT-27/.test(answer), detail: `reported only: answer "${answer.slice(0, 80)}"` });
|
||||
}
|
||||
|
||||
console.log('\n' + rows.map(r => `${r.ok ? 'PASS' : r.id.startsWith('D') ? 'INFO' : 'FAIL'} ${r.id}${r.id.startsWith('D') ? ` ${r.detail}` : ''}`).join('\n'));
|
||||
const failed = rows.filter(r => !r.ok && !r.id.startsWith('D')).length;
|
||||
writeFileSync(join(scratch, 'result.json'), JSON.stringify(rows, null, 2));
|
||||
await finish(failed ? 1 : 0);
|
||||
@@ -14,6 +14,7 @@ and talks to its Gateway. Nothing here downloads or builds anything.
|
||||
| `children.mjs` | pure projection of one helper agent's events into its row and its approval card |
|
||||
| `preset.mjs` | the agent preset (which tools the chat has) |
|
||||
| `plugins/strata-gate.mjs` | the Manual-mode approval gate (a Harness plugin, loaded by path) |
|
||||
| `plugins/strata-context.mjs`, `context-budget.mjs`, `context-fit.mjs` | measured context pressure, summaries that fit the window, the check before a message goes out |
|
||||
| `agent-bridge.mjs` | the chat-to-session map, turns, approvals, Stop, status |
|
||||
|
||||
## Frames sent on `/api/chat/stream` for an agent turn
|
||||
@@ -132,3 +133,14 @@ A turn that keeps failing the same way is stopped by the app, never left to the
|
||||
- **Compaction:** after a guard stop (and after a turn the model server refused because the context was full) the bridge asks the Harness for `/compact` on that session, so the next message has room. Best effort and logged.
|
||||
- **Never silent:** a turn that ends (`completed` or `max-tokens`) with no answer text gets "I ran out of room in this chat..." when the last request used 85% of the window or more, otherwise "I stopped without writing an answer..." (not when tool cards were shown, not after Stop, not after an error, which says why by itself). Goal runs have their own ending lines and are not guarded.
|
||||
- Tests: `tests/agent-turn-guard.test.mjs` (counting, always; end to end with the real Harness, the scripted model's `searchloop` and `bloat` scripts and `searchMock: 'limited'` on the bridge, which runs the real search code on a pretend web that refuses, no network).
|
||||
|
||||
## Context: measured pressure, summaries that fit, the check before a message goes out (2026-10-22)
|
||||
|
||||
Found by the owner's hands-on test of the installed 0.2.1 (Gyro-S, 64K): a 118 KB log paste was 66,500 tokens to the engine and about 29,500 to the Harness (4 characters a token; logs and code are about 2); the engine refused it with a 400, the refusal stayed in the history, and then neither the next message nor `/compact` could go through, because the summary request carried the same history and was refused for its size too. `/compact` itself was never a command of the app: it went to the model as a question and the model answered "nothing to compact".
|
||||
|
||||
- **`plugins/strata-context.mjs`** (inserted by the profile, like the gate) wraps `tokenMeter.measure` and `llm.stream` in place, once each. Pressure = the last prompt size the model server reported for an answer, plus what changed since priced at the density that measurement showed (`context-budget.mjs`: `pressureNow`, `densityOf`); never lower than the Harness's own price. Node prices are scaled the same way, so the retained tail is in real tokens. A summary request (`purpose: 'compaction'`) that would not fit the window is asked for in pieces: a message bigger than a piece is cut for the request only, each piece builds on the summary before it, a piece the server refuses is halved and tried again. `/strata-context` (a Harness command, like `/strata-gate`) answers `{tokens, window}` for a session so the host can tell the person true figures.
|
||||
- **`context-fit.mjs`**: before a message is sent the model server counts it (`POST /v1/messages/count_tokens`, its own tokenizer). `decideFit`: send; summarise first and then send (the message fits an otherwise empty chat); or never fits. The last one is not sent: the error frame has `code: 'CONTEXT_MESSAGE_TOO_LARGE'` and the page offers "Attach as a file" (`oversize.mjs`, `/api/chat/oversize`: the text is saved in the chat's folder, the assistant reads it in parts) or "Send a shortened preview". An engine that cannot count lets the message through; the Harness's own compact-and-retry is the net.
|
||||
- **`/compact`** is handled by the host (`compactCommand`): the Harness's `/compact`, then a plain line with the measured figures ("The chat was 90% full and is now 38% full"), or "nothing older to summarize" when the newest message is what fills the chat.
|
||||
- **Visible:** `compaction/start` shows "Summarizing earlier messages..." in the status line; `compaction/end` adds one plain line to the reply. The projector also sends a `context` frame after every model request, so the meter follows the running turn.
|
||||
- **A refusal that is left** (`CONTEXT_WINDOW_EXCEEDED`) is a plain sentence with the engine's text under Details and the buttons "Summarize and continue" and "Start a new chat". "Try sending the message again" is for other errors only.
|
||||
- Tests: `tests/agent-context.test.mjs` (arithmetic; the real Harness against the scripted worker with `window`, `charsPerToken` and `noCount`), `tests/oversize.test.mjs`, `tests/context-ui.test.mjs`. The run against a real engine is `scripts/context-e2e.mjs`.
|
||||
@@ -22,13 +22,15 @@ import { randomUUID } from 'node:crypto';
|
||||
import { GatewayClient } from './gateway-client.mjs';
|
||||
import { HarnessProcess, resolveHarness, redact, stopStaleAgent } from './harness-runtime.mjs';
|
||||
import { StatusReporter } from './status.mjs';
|
||||
import { buildProfile, routeFor, MODE_PRESETS, modeOf, effortOf } from './profile.mjs';
|
||||
import { buildProfile, routeFor, MODE_PRESETS, modeOf, effortOf, localOutputBudget, compactionHeadroom } from './profile.mjs';
|
||||
import { TurnProjector, parseArguments as parseArgs } from './events.mjs';
|
||||
import { ChildProjector } from './children.mjs';
|
||||
import { nearestBudget } from './llm-shape.mjs';
|
||||
import { DeliverableTracker, ActivityGroups } from './deliverables.mjs';
|
||||
import { SkillsUsed, skillInfoFrom } from './skills-used.mjs';
|
||||
import { turnEngineSpeed } from './engine-speed.mjs';
|
||||
import { TurnGuard, guardNote, GUARD_NOTES } from './turn-guard.mjs';
|
||||
import { BASE_TOKENS, COUNT_ABOVE_CHARS, FIT_TEXT, countTokens, decideFit, fill, grouped } from './context-fit.mjs';
|
||||
import { PlanStore, planPrompt, parsePlan, parsePoints, stepObjective, stepLine, firstSentence, stepSummary, planAsPrompt, editedSteps, planView, researchPrompt, researchWritePrompt, RESEARCH_NUDGE, parseSources } from './plan.mjs';
|
||||
|
||||
/**
|
||||
@@ -110,11 +112,12 @@ const deferred = () => { let resolve; const promise = new Promise(r => { resolve
|
||||
* @param {string|null} [options.componentsDir] where installed components live
|
||||
* @param {() => Array} options.localInstances ready local instances (see profile.mjs)
|
||||
* @param {() => Array} options.connections saved API connections WITH their keys (host-internal)
|
||||
* @param {() => (string|null)} [options.defaultLocalId] the local instance the chat runs on: its tool servers are the agent's (T5)
|
||||
* @param {NodeJS.ProcessEnv} [options.env]
|
||||
* @param {string} [options.nodePath] the Node runtime that runs the Harness
|
||||
* @param {(line:string)=>void} [options.log]
|
||||
*/
|
||||
export function createAgentBridge({ dataDir, componentsDir = null, bundledDir = null, gate = true, localInstances, connections, tools = () => undefined, skillsDir = () => null, onGoal = () => {}, onPlan = () => {}, onQueue = () => {}, proxyBase = null, searchMock = null, expertSampling = () => null, stallMs = 4 * 60_000, stallToolMs = 20 * 60_000, env = process.env, nodePath = process.execPath, log = () => {}, startTimeoutMs = 120_000, sessionReadyMs = 60_000, sessionRetryMs = 1_500 }) {
|
||||
export function createAgentBridge({ dataDir, componentsDir = null, bundledDir = null, gate = true, localInstances, connections, tools = () => undefined, defaultLocalId = () => null, skillsDir = () => null, onGoal = () => {}, onPlan = () => {}, onQueue = () => {}, proxyBase = null, searchMock = null, expertSampling = () => null, engineProgress = () => null, engineGraceMs = 30 * 60_000, stallMs = 4 * 60_000, stallToolMs = 20 * 60_000, env = process.env, nodePath = process.execPath, log = () => {}, startTimeoutMs = 120_000, sessionReadyMs = 60_000, sessionRetryMs = 1_500 }) {
|
||||
const root = join(dataDir, 'agent');
|
||||
const home = join(root, 'home');
|
||||
const sessionsFile = join(root, 'sessions.json');
|
||||
@@ -170,7 +173,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
/** The agent's profile now, plus where its shaped routes really lead (read by the forwarding endpoint). */
|
||||
let shapeTargets = {};
|
||||
function buildProfileNow() {
|
||||
const profile = buildProfile({ local: localInstances(), connections: connections(), gate, tools: tools(), skillsDir: skillsDir(), proxyBase, searchMock });
|
||||
const profile = buildProfile({ local: localInstances(), connections: connections(), gate, tools: tools(), skillsDir: skillsDir(), proxyBase, searchMock, defaultLocalId: defaultLocalId() });
|
||||
shapeTargets = profile.shapeTargets ?? {};
|
||||
return profile;
|
||||
}
|
||||
@@ -192,6 +195,8 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
harness: { version: h.version },
|
||||
runtime: rt?.hp.running ? { pid: rt.hp.pid, generation: rt.generation } : null,
|
||||
routes: profile.routes.map(({ route, kind, modelId, name, contextWindow }) => ({ route, kind, modelId, name, contextWindow })),
|
||||
// tool servers that could not be wired (a name the rule does not allow, no program or address): the chat says so
|
||||
skippedServers: profile.mcpSkipped ?? [],
|
||||
activeTurns: activeTurns(),
|
||||
pendingApprovals: pending.size,
|
||||
};
|
||||
@@ -228,6 +233,13 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
return next;
|
||||
}
|
||||
|
||||
/** The local engine's own speed for this turn's requests (null when it cannot be read: the stream's estimate stays). */
|
||||
async function engineSpeedOf(route, projector) {
|
||||
const inst = localInstances().find(i => i.id === route.instanceId);
|
||||
if (!inst) return null;
|
||||
return turnEngineSpeed({ baseUrl: inst.baseUrl, key: inst.key, since: projector.startedAt, attempts: projector.streams.size, timeoutMs: 800 });
|
||||
}
|
||||
|
||||
/** Make sure a runtime exists that knows the route this turn needs. */
|
||||
async function ensureRuntime(choice, signal) {
|
||||
for (;;) {
|
||||
@@ -398,7 +410,27 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
const appliedMode = planTask ? 'plan' : modeOf(mode);
|
||||
await applyMode(runtime, sessionId, appliedMode);
|
||||
|
||||
const projector = new TurnProjector({ policy: MODE_PRESETS[appliedMode].approval });
|
||||
// Does the message fit the model's window? Asked before anything is sent, with the model server's own count (context-fit.mjs). A message
|
||||
// that fits once the older messages are a summary is sent after a summary; one that can never fit is not sent, and the person is told
|
||||
// plainly and offered the two ways out. Whatever cannot be counted goes through: the Harness recovers from a refusal by itself.
|
||||
if (!goal && route.kind === 'local') {
|
||||
const fit = await fitCheck({ runtime, route, sessionId, fresh, history, message }).catch(() => null);
|
||||
if (fit?.action === 'too-large') {
|
||||
reporter.clear();
|
||||
const values = [grouped(fit.tokens), grouped(fit.window)];
|
||||
const text = fill(FIT_TEXT.tooLarge, values);
|
||||
emit({ type: 'error', message: text, key: FIT_TEXT.tooLarge, values, code: 'CONTEXT_MESSAGE_TOO_LARGE', tokens: fit.tokens, window: fit.window });
|
||||
return { error: text };
|
||||
}
|
||||
if (fit?.action === 'summarize') {
|
||||
reporter.set({ phase: 'compacting', code: 'compacting', text: FIT_TEXT.summarizing, key: FIT_TEXT.summarizing, values: [] });
|
||||
const done = await compactChat(runtime, sessionId);
|
||||
reporter.clear();
|
||||
if (done.ok) emit(summarizedNote(done.before, done.after, fit.window));
|
||||
}
|
||||
}
|
||||
|
||||
const projector = new TurnProjector({ policy: MODE_PRESETS[appliedMode].approval, window: route.contextWindow });
|
||||
if (!goal) guard = new TurnGuard({ plan: !!planTask, contextMax: route.kind === 'local' ? route.contextWindow : 0 }); // a saved connection's window is often only assumed: never judged by a guess
|
||||
projector.onStatus = update => { if (!turn?.parentOpen && turn?.children.size && [...turn.children.values()].some(h => !h.done) && update.phase === 'waiting') return; reporter.set(update); };
|
||||
const turn = {
|
||||
@@ -407,7 +439,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
abortFromRuntime: reason => finishTurn(reason), finish: failure => finishTurn(failure),
|
||||
// Helper agents (subagents) the Harness started for this chat, and the state of the run around them.
|
||||
children: new Map(), subagentCalls: new Map(), parentOpen: false, stopping: false, settleTimer: null, mode: appliedMode,
|
||||
plan: null, advancing: false, goalRun: false, lastActivity: Date.now(), watchdog: null,
|
||||
plan: null, advancing: false, goalRun: false, lastActivity: Date.now(), heard: Date.now(), engineMark: null, watchdog: null,
|
||||
research: researchTopic ? { phase: 'gather', nudged: false, waitingStart: false } : null,
|
||||
};
|
||||
c.turn = turn;
|
||||
@@ -420,6 +452,13 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
if (turn.finished || turn.stopping) return;
|
||||
const toolRunning = [...projector.tools.values()].some(x => x.card?.state === 'running' || x.card?.state === 'proposed') || pending.size > 0 && pendingFor(chatId).length > 0;
|
||||
const limit = toolRunning ? stallToolMs : stallMs;
|
||||
// A local engine reading a long prompt sends the Harness nothing for minutes (#3623). Its /metrics progress counts as activity,
|
||||
// for at most engineGraceMs after the Harness last spoke: progress for another chat cannot keep a lost turn alive forever.
|
||||
if (turn.route?.kind === 'local' && Date.now() - turn.heard < engineGraceMs) {
|
||||
let mark = null;
|
||||
try { mark = engineProgress(turn.route.instanceId); } catch { /* no progress known: the Harness alone decides */ }
|
||||
if (mark != null && mark !== turn.engineMark) { turn.engineMark = mark; turn.lastActivity = Date.now(); return; }
|
||||
}
|
||||
if (Date.now() - turn.lastActivity < limit) return;
|
||||
turn.stalledFor = Math.round((Date.now() - turn.lastActivity) / 1000);
|
||||
turn.stopping = true;
|
||||
@@ -429,7 +468,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
turn.finish(`The assistant stopped answering for ${mins > 1 ? mins + ' minutes' : 'a minute'}, so I ended this turn. Anything it already finished is kept. Send your message again to go on.`);
|
||||
}, Math.min(15_000, Math.max(200, Math.floor(stallMs / 4))));
|
||||
turn.watchdog.unref?.();
|
||||
function finishTurn(failure) {
|
||||
async function finishTurn(failure) {
|
||||
if (turn.finished) return;
|
||||
turn.finished = true;
|
||||
clearTimeout(turn.settleTimer);
|
||||
@@ -450,7 +489,9 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
}
|
||||
if (noteReason && !extra.some(f => f.type === 'plan' && f.event === 'proposed')) { const note = guardNote(noteReason); if (note) extra.push(note); }
|
||||
for (const f of extra) emit(f);
|
||||
const frames = failure ? [{ type: 'error', message: plainAgentFailure(failure), details: failure, code: 'AGENT_STOPPED' }] : projector.finish({ model: route.name, contextMax: route.contextWindow });
|
||||
// The answer speed of a turn on this computer is the engine's own (tokens it produced over its decode time, as the Monitor shows it).
|
||||
const engine = !failure && route.kind === 'local' ? await engineSpeedOf(route, projector) : null;
|
||||
const frames = failure ? [{ type: 'error', message: plainAgentFailure(failure), details: failure, code: 'AGENT_STOPPED' }] : projector.finish({ model: route.name, contextMax: route.contextWindow, engine });
|
||||
for (const f of frames.slice(0, -1)) emit(f);
|
||||
// Messages typed while the goal ran and not delivered between steps go back to the page now, to be sent as ordinary messages.
|
||||
if (turn.goalRun && attached) {
|
||||
@@ -491,6 +532,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
function handle(item) {
|
||||
if (turn.finished) return;
|
||||
turn.lastActivity = Date.now();
|
||||
turn.heard = turn.lastActivity;
|
||||
if (item.type === 'event') {
|
||||
const e = item.event;
|
||||
if (e.seq <= turn.lastSeq) return;
|
||||
@@ -528,7 +570,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
emitRow(child, true);
|
||||
const fold = item => {
|
||||
if (turn.finished) return;
|
||||
turn.lastActivity = Date.now();
|
||||
turn.lastActivity = turn.heard = Date.now();
|
||||
// A helper that finishes before its throttled "working" row went out would show queued -> completed and never "working": send the held row first.
|
||||
if (item.type === 'event' && item.event.type === 'turn/end' && child.rowPending) emitRow(child, true);
|
||||
const { frames, row } = cp.consume(item);
|
||||
@@ -941,6 +983,16 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
|
||||
// -- /plan -----------------------------------------------------------------------------------------------
|
||||
|
||||
/** A short answer in the chat that the page translates (the sentence is in the interface catalog, "values" fill its slots). */
|
||||
function sayKey(options, template, values = []) {
|
||||
const { onFrame } = options;
|
||||
onFrame({ type: 'queued', requestId: 'compact-' + randomUUID() });
|
||||
onFrame({ type: 'start', model: '', contextMax: 0, demo: false, remoteModel: false, agent: true, mode: modeOf(options.mode), skills: [] });
|
||||
onFrame({ type: 'delta', text: fill(template, values), key: template, values });
|
||||
onFrame({ type: 'done', finishReason: 'stop', tokensIn: null, tokensOut: null, usageSource: 'unavailable', durationMs: 0 });
|
||||
return { compact: true };
|
||||
}
|
||||
|
||||
/** A short answer in the chat (no model): the same frames a turn sends. */
|
||||
function sayOnce(options, reply) {
|
||||
const { onFrame } = options;
|
||||
@@ -1140,6 +1192,93 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
* Stop one chat's turn. Resolves only once the turn has ended (or the wait times out), because the
|
||||
* Harness acknowledges a cancel as admitted, not as done.
|
||||
*/
|
||||
/** What the chat holds now and the model's window, as the context plugin measures them (null when it cannot be read). */
|
||||
async function contextOf(runtime, sessionId) {
|
||||
try {
|
||||
const r = await runtime.gw.rpc('commands/execute', { agentId: sessionId, line: '/strata-context', submittedAttachments: [] });
|
||||
if (r?.result?.kind !== 'success') return null;
|
||||
const v = JSON.parse(String(r.result.text));
|
||||
return Number.isFinite(v.tokens) ? { tokens: Math.round(v.tokens), window: Number(v.window) > 0 ? Math.round(v.window) : 0 } : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** The decision of context-fit.mjs for the message about to be sent, or null when the model server cannot count it. */
|
||||
async function fitCheck({ runtime, route, sessionId, fresh, history, message }) {
|
||||
const inst = localInstances().find(i => i.id === route.instanceId);
|
||||
if (!inst || !(route.contextWindow > 0)) return null;
|
||||
const carried = fresh && history.length ? withEarlierConversation(history, message) : message;
|
||||
if (carried.length < COUNT_ABOVE_CHARS) return null;
|
||||
const count = text => countTokens({ baseUrl: inst.baseUrl, key: inst.key, model: inst.modelId, text });
|
||||
let tokens = await count(carried);
|
||||
if (tokens == null) return null;
|
||||
const now = fresh ? null : await contextOf(runtime, sessionId);
|
||||
const window = now?.window || route.contextWindow;
|
||||
const pressure = now?.tokens ?? 0;
|
||||
// Where the Harness starts summarising by itself on this window (profile.mjs: the reply keeps its room, then the headroom).
|
||||
const reply = localOutputBudget(window);
|
||||
const threshold = Math.floor(Math.min(window * 0.8, window - reply - compactionHeadroom(window, reply)));
|
||||
let verdict = decideFit({ window, pressure, messageTokens: tokens, threshold });
|
||||
// The earlier conversation carried into a fresh session is what pushed it over: send the message alone instead.
|
||||
if (verdict.action === 'too-large' && carried !== message) {
|
||||
const alone = await count(message);
|
||||
if (alone != null) { tokens = alone; verdict = decideFit({ window, pressure, messageTokens: alone, threshold }); }
|
||||
}
|
||||
return { ...verdict, tokens, window, pressure };
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarise the older part of a chat with the Harness's /compact and say what happened. Never throws.
|
||||
* ok:true carries the figures before and after; ok:false says why (nothing to summarise, busy, failed).
|
||||
*/
|
||||
async function compactChat(runtime, sessionId) {
|
||||
const before = await contextOf(runtime, sessionId);
|
||||
let result;
|
||||
try { result = (await runtime.gw.rpc('commands/execute', { agentId: sessionId, line: '/compact', submittedAttachments: [] }))?.result; }
|
||||
catch (e) { return { ok: false, reason: /not.found|no such/i.test(String(e.message)) ? 'nothing' : 'failed', before, text: String(e.message ?? e) }; }
|
||||
const text = String(result?.text ?? '');
|
||||
log('agent · compact: ' + (result?.kind ?? 'none') + ' ' + text.slice(0, 140));
|
||||
if (result?.kind === 'success' && /^Compacted/i.test(text)) return { ok: true, before, after: await contextOf(runtime, sessionId) };
|
||||
if (result?.kind === 'success') return { ok: false, reason: 'nothing', before, text };
|
||||
if (/active compaction|not idle/i.test(text)) return { ok: false, reason: 'busy', before, text };
|
||||
// The Harness cannot make a summary smaller than what is left when the chat holds little beyond its first prompt: that is nothing to summarise, not a failure.
|
||||
const little = before && before.window > 0 && before.tokens - BASE_TOKENS < before.window * 0.25;
|
||||
return { ok: false, reason: little && /useful summary|not smaller/i.test(text) ? 'nothing' : 'failed', before, text };
|
||||
}
|
||||
|
||||
/** The line in the chat that explains why the context meter fell: how full it was and how full it is. */
|
||||
function summarizedNote(before, after, window) {
|
||||
const w = after?.window || before?.window || window || 0;
|
||||
const pct = v => Math.max(0, Math.min(100, Math.round(100 * v.tokens / w)));
|
||||
const known = w > 0 && before && after;
|
||||
const template = known ? FIT_TEXT.summarized : FIT_TEXT.summarizedShort;
|
||||
const values = known ? [pct(before), pct(after)] : [];
|
||||
return { type: 'delta', own: true, text: '\n\n' + fill(template, values), key: template, values, noteReason: 'compacted' };
|
||||
}
|
||||
|
||||
/** "/compact": summarise the older messages of this chat now, and say in plain words what happened. */
|
||||
async function compactCommand(options) {
|
||||
const { chatId, choice, signal } = options;
|
||||
const c = chat(chatId);
|
||||
const say = (template, values = []) => sayKey(options, template, values);
|
||||
if (c.turn || c.starting) return say(FIT_TEXT.compactBusy);
|
||||
const known = sessionMap[chatId]?.sessionId;
|
||||
if (!known) return say(FIT_TEXT.noChatYet);
|
||||
let runtime;
|
||||
try { ({ rt: runtime } = await ensureRuntime(choice, signal)); } catch { return say(FIT_TEXT.compactFailed); }
|
||||
const done = await compactChat(runtime, known);
|
||||
if (done.ok) {
|
||||
const note = summarizedNote(done.before, done.after, 0);
|
||||
return say(note.key, note.values);
|
||||
}
|
||||
if (done.reason === 'busy') return say(FIT_TEXT.compactBusy);
|
||||
if (done.reason === 'nothing') {
|
||||
const w = done.before?.window || 0;
|
||||
const pct = w > 0 && done.before ? Math.round(100 * done.before.tokens / w) : 0;
|
||||
return say(pct >= 60 ? FIT_TEXT.nothingFull : FIT_TEXT.nothingToSummarize, pct >= 60 ? [pct] : []);
|
||||
}
|
||||
return say(FIT_TEXT.compactFailed);
|
||||
}
|
||||
|
||||
/** Summarise the older part of a session with the Harness's own /compact. Best effort: the next message compacts again by itself when it must. */
|
||||
async function compactSession(runtime, sessionId) {
|
||||
try {
|
||||
@@ -1194,7 +1333,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
/** Let go of a chat's Harness session mapping (a task starts every run in a session of its own). */
|
||||
function forget(chatId) { if (chats.get(chatId)?.turn) return; delete sessionMap[chatId]; chats.delete(chatId); persist(); }
|
||||
|
||||
return { goalState, goalControl, harnessDir, status, runTurn, goalCommand, planCommand, researchCommand, llmTarget, setPlanBriefing, resetSession, reopenPlan, skipStep, planGoal, planControl, planState, queueMessage, cancelQueued, takeQueue, forget, approve, setMode, stop, pendingFor, pendingAll, close, sessionOf: chatId => sessionMap[chatId]?.sessionId ?? null, workingFolder: chatId => sessionMap[chatId]?.cwd ?? null, isRunning: chatId => !!chats.get(chatId)?.turn, _debug: () => ({ rt, sessionMap }) };
|
||||
return { compactCommand, goalState, goalControl, harnessDir, status, runTurn, goalCommand, planCommand, researchCommand, llmTarget, setPlanBriefing, resetSession, reopenPlan, skipStep, planGoal, planControl, planState, queueMessage, cancelQueued, takeQueue, forget, approve, setMode, stop, pendingFor, pendingAll, close, sessionOf: chatId => sessionMap[chatId]?.sessionId ?? null, workingFolder: chatId => sessionMap[chatId]?.cwd ?? null, isRunning: chatId => !!chats.get(chatId)?.turn, _debug: () => ({ rt, sessionMap }) };
|
||||
}
|
||||
|
||||
/** A goal that stopped by itself, in words. */
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Token arithmetic for the agent's context, with no I/O (the Harness plugin `plugins/strata-context.mjs` and the host both use it).
|
||||
*
|
||||
* Why it exists. The Harness prices a conversation with a fixed 4 characters per token. Code, logs and JSON are about twice as dense
|
||||
* (2.09 characters per token in the 2026-10-22 hands-on test), so its pressure read 31,794 tokens while the engine had been sent 59,048,
|
||||
* and compaction waited until the window was already past full. The engine reports the real prompt size with every answer; this module
|
||||
* turns that report into the number compaction decides on, and splits a summarising request that would not fit the window.
|
||||
*/
|
||||
|
||||
/** The Harness's own price of a text: characters over this, plus a fixed cost per block and per message. */
|
||||
export const CHARS_PER_TOKEN = 4;
|
||||
const BLOCK_OVERHEAD = 4;
|
||||
const MESSAGE_OVERHEAD = 4;
|
||||
/** The most a real token can be worth in the Harness's units (one character per token is the worst a tokenizer does). */
|
||||
export const MAX_DENSITY = 6;
|
||||
/** Room kept free in a window for the engine's own framing and the chat template (the engine keeps 64 tokens of slack itself). */
|
||||
export const WINDOW_SLACK = 1024;
|
||||
|
||||
/** The Harness's price (4 characters a token) of one message, the same arithmetic as its token meter. */
|
||||
export function estimateMessage(message) {
|
||||
const content = Array.isArray(message?.content) ? message.content : [];
|
||||
if (message?.role === 'system') {
|
||||
if (!content.length) return 0;
|
||||
let chars = 0;
|
||||
for (const block of content) chars += block?.type === 'text' ? String(block.text ?? '').length : JSON.stringify(block ?? {}).length;
|
||||
return Math.ceil(chars / CHARS_PER_TOKEN) + MESSAGE_OVERHEAD;
|
||||
}
|
||||
let tokens = MESSAGE_OVERHEAD;
|
||||
for (const block of content) {
|
||||
if (block?.type === 'text' || block?.type === 'reasoning') tokens += Math.ceil(String(block.text ?? '').length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
|
||||
else if (block?.type === 'tool-call') tokens += Math.ceil(String(block.name ?? '').length / CHARS_PER_TOKEN) + Math.ceil(String(block.arguments ?? '').length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
|
||||
else tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block ?? {}).length / CHARS_PER_TOKEN);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/** The Harness's price of the tool list of a request header. */
|
||||
export function estimateTools(header) {
|
||||
const tools = header?.tools;
|
||||
if (!Array.isArray(tools) || !tools.length) return 0;
|
||||
return Math.ceil(JSON.stringify(tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many real tokens one of the Harness's tokens is worth, from one measured request: the engine said the prompt was `real` tokens,
|
||||
* the Harness priced the same prompt at `estimated`. Never below 1 (a conversation of plain prose is priced about right, and a lower
|
||||
* number would only delay compaction) and never above MAX_DENSITY (a wild sample must not make every step compact).
|
||||
*/
|
||||
export function densityOf(real, estimated) {
|
||||
if (!(real > 0) || !(estimated > 0)) return 1;
|
||||
return Math.min(MAX_DENSITY, Math.max(1, real / estimated));
|
||||
}
|
||||
|
||||
/**
|
||||
* The prompt size compaction decides on. `sample` is the last measured request: `real` tokens for a prompt the Harness priced at
|
||||
* `estimated`. `now` is the Harness's price of the conversation now. What was added or removed since the sample is scaled by the sample's
|
||||
* density; the measured part is exact. Without a sample, or when the measurement is lower than the Harness's own price, the Harness's
|
||||
* price stands (it is never an underestimate that makes this smaller).
|
||||
*/
|
||||
export function pressureNow({ sample = null, now }) {
|
||||
if (!sample || !(sample.real > 0) || !(sample.estimated > 0)) return Math.max(0, now);
|
||||
const density = densityOf(sample.real, sample.estimated);
|
||||
return Math.max(Math.round(now), Math.round(sample.real + density * (now - sample.estimated)));
|
||||
}
|
||||
|
||||
/** The usage a finished model request reported, as the prompt side only: input plus the cache's reads and writes (output is not in the prompt). */
|
||||
export function promptTokensOf(usage) {
|
||||
if (!usage || typeof usage !== 'object') return 0;
|
||||
const n = value => (Number.isFinite(Number(value)) ? Math.max(0, Number(value)) : 0);
|
||||
return n(usage.inputTokens) + n(usage.cacheReadTokens) + n(usage.cacheWriteTokens);
|
||||
}
|
||||
|
||||
/** The usage of a settled assistant event (a `usage` on the message, or the last usage chunk of its stream). */
|
||||
export function usageOfEvent(event) {
|
||||
const data = event?.data;
|
||||
if (!data) return null;
|
||||
if (data.usage && typeof data.usage === 'object') return data.usage;
|
||||
const stream = Array.isArray(data.stream) ? data.stream : [];
|
||||
for (let i = stream.length - 1; i >= 0; i--) if (stream[i]?.type === 'chunk' && stream[i].chunk?.type === 'usage') return stream[i].chunk.usage ?? null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A summarising request is: the conversation's system prompt and tools, the messages to condense, then the instruction. This is how many
|
||||
* real tokens the messages may take in a window so that all of it, the summary and a safety margin fit.
|
||||
*/
|
||||
export function summaryBudget({ window, summaryTokens, toolsTokens = 0, systemTokens = 0, instructionTokens = 900, slack = WINDOW_SLACK }) {
|
||||
return Math.floor(window - summaryTokens - toolsTokens - systemTokens - instructionTokens - slack);
|
||||
}
|
||||
|
||||
/** Text blocks of a message, longest first: the ones worth shortening when a single message is bigger than the budget. */
|
||||
function textBlocks(message) {
|
||||
return (Array.isArray(message?.content) ? message.content : []).filter(b => (b?.type === 'text' || b?.type === 'reasoning') && typeof b.text === 'string');
|
||||
}
|
||||
|
||||
/**
|
||||
* One message cut down to `maxTokens` (in the Harness's price times `density`): the start and the end of its longest text are kept and
|
||||
* the middle is replaced by a line saying how much was left out. Only the copy sent to the summariser is cut; the conversation keeps
|
||||
* the whole message until the summary replaces it.
|
||||
*/
|
||||
export function clipMessage(message, maxTokens, density = 1) {
|
||||
if (estimateMessage(message) * density <= maxTokens) return message;
|
||||
const blocks = textBlocks(message);
|
||||
if (!blocks.length) return message;
|
||||
const others = estimateMessage({ ...message, content: (message.content ?? []).filter(b => !blocks.includes(b)) }) * density;
|
||||
const room = Math.max(256, maxTokens - others);
|
||||
const share = room / blocks.reduce((n, b) => n + b.text.length, 0) / density * CHARS_PER_TOKEN; // characters kept per character
|
||||
const content = (message.content ?? []).map((block) => {
|
||||
if (!blocks.includes(block)) return block;
|
||||
const keep = Math.max(200, Math.floor(block.text.length * Math.min(1, share) - 120));
|
||||
if (block.text.length <= keep) return block;
|
||||
const head = Math.ceil(keep * 0.6), tail = keep - head;
|
||||
const left = block.text.length - head - tail;
|
||||
return { ...block, text: `${block.text.slice(0, head)}\n\n[… ${left.toLocaleString('en-US')} characters from the middle are left out of this summary request …]\n\n${block.text.slice(block.text.length - tail)}` };
|
||||
});
|
||||
return { ...message, content };
|
||||
}
|
||||
|
||||
/** Whether cutting the list in front of index `i` would separate a tool call from its result. */
|
||||
function balancedBefore(messages, i) {
|
||||
let open = 0;
|
||||
for (let k = 0; k < i; k++) {
|
||||
const m = messages[k];
|
||||
if (m?.role === 'assistant') open += (Array.isArray(m.content) ? m.content : []).filter(b => b?.type === 'tool-call').length;
|
||||
else if (m?.role === 'tool') open -= 1;
|
||||
}
|
||||
return open <= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split messages into runs that each fit `budget` real tokens (price `density`), cutting between a tool call's result and the next
|
||||
* message when it can. A message bigger than the budget by itself is clipped first. Order is kept.
|
||||
*/
|
||||
export function splitMessages(messages, budget, density = 1) {
|
||||
const room = Math.max(512, budget);
|
||||
const sized = messages.map(m => clipMessage(m, Math.floor(room * 0.8), density));
|
||||
const cost = sized.map(m => estimateMessage(m) * density);
|
||||
const runs = [];
|
||||
let start = 0, used = 0;
|
||||
for (let i = 0; i < sized.length; i++) {
|
||||
if (i > start && used + cost[i] > room) {
|
||||
// Cut as late as possible, but never between a tool call and its result when a clean place exists inside the run.
|
||||
let cut = i;
|
||||
while (cut > start + 1 && !balancedBefore(sized, cut)) cut--;
|
||||
if (!balancedBefore(sized, cut)) cut = i;
|
||||
runs.push(sized.slice(start, cut));
|
||||
start = cut;
|
||||
used = cost.slice(start, i).reduce((n, x) => n + x, 0);
|
||||
}
|
||||
used += cost[i];
|
||||
}
|
||||
if (start < sized.length) runs.push(sized.slice(start));
|
||||
return runs;
|
||||
}
|
||||
|
||||
/** Whether a failure is the model server saying the prompt does not fit its window. */
|
||||
export function isContextOverflow(error) {
|
||||
if (!error) return false;
|
||||
if (error.code === 'CONTEXT_WINDOW_EXCEEDED') return true;
|
||||
const text = [error.message, error.cause?.message, error.failure?.message, error.failure?.code].filter(Boolean).join(' ');
|
||||
return /request exceeds the context window|leaves no room to answer|context[_ ]length|context window|too many tokens|maximum context/i.test(text);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Does a message fit the model's window? Asked before it is sent, with the model server's own count, so a message the server would refuse
|
||||
* is never read for half a minute first (2026-10-22: a 103 KB paste was 70,141 tokens in a 65,536-token window, and the 400 came back after
|
||||
* the whole prompt had been read). No I/O except the one count request; everything else is arithmetic the tests call directly.
|
||||
*
|
||||
* Three answers: send it; summarise the chat first and then send it (the message fits once the older messages are a summary); or it can
|
||||
* never fit, and the person is offered the two ways out that do (the text as a file the assistant reads in parts, or a shortened preview).
|
||||
*/
|
||||
|
||||
/** What a new chat already holds before the person's first word: the system prompt and the tool list. Measured 10,510 by the Harness's price. */
|
||||
export const BASE_TOKENS = 12000;
|
||||
/** The least room a reply needs after the prompt: an eighth of the window, at most this. */
|
||||
export const REPLY_FLOOR = 4096;
|
||||
/** A message shorter than this many characters cannot matter to a window of 8K or more, so it is not even counted. */
|
||||
export const COUNT_ABOVE_CHARS = 3000;
|
||||
|
||||
export const replyRoom = window => Math.max(512, Math.min(REPLY_FLOOR, Math.floor(window / 8)));
|
||||
|
||||
/**
|
||||
* @param {{window:number, pressure?:number, base?:number, messageTokens:number, threshold?:number}} input
|
||||
* `pressure` is what the chat holds now (measured), `base` what an empty chat holds, `messageTokens` the new message, `threshold` where the
|
||||
* Harness starts summarising by itself. The Harness looks at that only before its NEXT step, so a big message that arrives now would otherwise
|
||||
* be sent on top of a chat already near the threshold (2026-10-22: 54K of 64K held by the time it was looked at): summarise first.
|
||||
* @returns {{action:'send'|'summarize'|'too-large', room:number, needs:number}}
|
||||
*/
|
||||
export function decideFit({ window, pressure = 0, base: given = BASE_TOKENS, messageTokens, threshold = 0 }) {
|
||||
const reply = replyRoom(window);
|
||||
const base = Math.min(given, Math.floor(window * 0.6)); // on a small window the first prompt cannot be what the window is made of
|
||||
// A chat holds at least what an empty one does (the system prompt and the tools), so a new chat is not counted as empty.
|
||||
const needs = Math.max(pressure, base) + messageTokens + reply;
|
||||
if (!(window > 0) || !(messageTokens >= 0)) return { action: 'send', room: Infinity, needs: 0 };
|
||||
if (needs <= window) {
|
||||
const worthIt = pressure > base + window * 0.1; // older messages worth summarising: more than a tenth of the window beyond the first prompt
|
||||
if (threshold > 0 && worthIt && pressure + messageTokens > threshold) return { action: 'summarize', room: window - needs, needs, why: 'threshold' };
|
||||
return { action: 'send', room: window - needs, needs };
|
||||
}
|
||||
// Not as it stands. Does the message fit in an otherwise empty chat (only the system prompt and the tools before it)?
|
||||
if (base + messageTokens + reply <= window) return { action: 'summarize', room: window - (base + messageTokens + reply), needs };
|
||||
return { action: 'too-large', room: window - (base + messageTokens + reply), needs };
|
||||
}
|
||||
|
||||
/**
|
||||
* The model server's count of a text (its tokenizer and chat template), through its Anthropic-style count endpoint.
|
||||
* Returns the number, or null when it cannot be had (an old engine, a server that is busy, a timeout): then nothing is blocked.
|
||||
*/
|
||||
export async function countTokens({ baseUrl, key = '', model = '', text, fetchImpl = fetch, timeoutMs = 8000 }) {
|
||||
try {
|
||||
const root = String(baseUrl ?? '').replace(/\/v1\/?$/, '').replace(/\/$/, '');
|
||||
const res = await fetchImpl(`${root}/v1/messages/count_tokens`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', ...(key ? { authorization: `Bearer ${key}`, 'x-api-key': key } : {}) },
|
||||
body: JSON.stringify({ model, messages: [{ role: 'user', content: String(text) }] }),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const n = Number((await res.json())?.input_tokens);
|
||||
return Number.isFinite(n) && n >= 0 ? Math.round(n) : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** The plain sentences (also in the interface catalog in all eight languages). `{0}` slots are filled by the page. */
|
||||
export const FIT_TEXT = Object.freeze({
|
||||
tooLarge: 'This message is about {0} tokens long, and this model can read about {1} at a time. It is too large to send as it is.',
|
||||
tooLargeNext: 'Attach it as a file for the assistant to read in parts, or send a shortened preview.',
|
||||
full: 'This conversation no longer fits the model’s window, and summarizing the older messages did not make enough room.',
|
||||
fullNext: 'Summarize it now and continue, or start a new chat.',
|
||||
summarizing: 'Summarizing earlier messages…',
|
||||
summarized: 'Older messages were summarized to make room. The chat was {0}% full and is now {1}% full.',
|
||||
summarizedWas: 'Older messages were summarized to make room. The chat was {0}% full.',
|
||||
summarizedShort: 'Older messages were summarized to make room.',
|
||||
nothingFull: 'This chat is {0}% full, but there is nothing older to summarize: the newest message is what fills it. Start a new chat, or send a shorter message.',
|
||||
nothingToSummarize: 'There is nothing older to summarize yet. Everything in this chat is still needed by the assistant.',
|
||||
noChatYet: 'There is nothing to summarize yet. This chat has not started a conversation with the assistant.',
|
||||
compactFailed: 'The older messages could not be summarized right now. Try again in a moment, or start a new chat.',
|
||||
compactBusy: 'This chat is still working. Stop it or wait for it to finish, then try again.',
|
||||
});
|
||||
export const fill = (template, values = []) => String(template).replace(/\{(\d+)\}/g, (_, i) => String(values[Number(i)] ?? ''));
|
||||
|
||||
/** A number with thousands separators, the way the page writes it. */
|
||||
export const grouped = n => Math.round(n).toLocaleString('en-US');
|
||||
|
||||
/**
|
||||
* The two ways to send a message that is too large.
|
||||
* file the whole text goes in a file in the chat's folder; the message names it and shows how it starts
|
||||
* preview the start and the end of the text, cut to what the window can hold, with the number of characters left out
|
||||
* @param {{text:string, mode:'file'|'preview', tokens:number, window:number, pressure?:number, base?:number, path?:string}} input
|
||||
* @returns {{message:string, kept:number}}
|
||||
*/
|
||||
export function oversizedReplacement({ text, mode, tokens, window, pressure = 0, base = BASE_TOKENS, path = '' }) {
|
||||
const t = String(text);
|
||||
const charsPerToken = tokens > 0 ? Math.max(0.8, t.length / tokens) : 3;
|
||||
if (mode === 'file') {
|
||||
const start = t.slice(0, 1200).trimEnd();
|
||||
return { kept: start.length, message: `My message was too long to send in the chat, so I saved it as a file: ${path}\n\nIt starts like this:\n\n${start}\n\n[the rest is in the file]\n\nPlease read the file in parts (it is long) and then do what my message asks.` };
|
||||
}
|
||||
const room = Math.max(1000, Math.floor((window - Math.max(pressure, base) - replyRoom(window)) * 0.7));
|
||||
const keep = Math.max(500, Math.floor(room * charsPerToken * 0.9));
|
||||
const head = Math.ceil(keep * 0.7), tail = keep - head;
|
||||
const left = Math.max(0, t.length - head - tail);
|
||||
if (!left) return { kept: t.length, message: t };
|
||||
return { kept: head + tail, message: `${t.slice(0, head)}\n\n[… ${grouped(left)} characters from the middle are left out because the message was too long to send …]\n\n${t.slice(t.length - tail)}` };
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Runs the command of a command tool the person wrote (Settings -> Tools). Split from custom-tools-mcp.mjs so it can be tested.
|
||||
*
|
||||
* T7 (tools and images review, 2026-10-22). It used to run every command with `shell: true` (cmd.exe on Windows, so `Get-Content x` failed with
|
||||
* "not recognized"), in whatever folder the host happened to run in (so `python tools/foo.py` could not find its file), and ended it after two
|
||||
* minutes with 20,000 characters of output, silently cut.
|
||||
*
|
||||
* shell 'auto' (default): PowerShell for a Windows command that reads as PowerShell (Get-Content, Start-Process, $env:X, $(...)), cmd.exe for
|
||||
* any other Windows command (so every command that worked keeps working), /bin/sh elsewhere. 'cmd', 'powershell', 'bash' and 'sh' name one.
|
||||
* cwd the folder to run in: the tool's own, or the one the assistant names in the call (it knows the chat's folder); else the person's home.
|
||||
* timeoutSec the time allowed, 1 to 3600 (default 120). A command that runs over is ended together with the programs it started.
|
||||
*/
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
export const DEFAULT_TIMEOUT_SEC = 120;
|
||||
export const MAX_TIMEOUT_SEC = 3600;
|
||||
export const OUT_MAX = 20000;
|
||||
export const SHELLS = ['auto', 'cmd', 'powershell', 'bash', 'sh'];
|
||||
|
||||
/** A Windows command that is PowerShell and would fail in cmd.exe. */
|
||||
const POWERSHELL_LOOKS = /(^|[\s;|(&])[A-Z][A-Za-z]+-[A-Z][A-Za-z]+\b|\$\w|\$\(|\$env:|@\(|\|\s*(?:%|\?)\s*\{/;
|
||||
export const looksLikePowerShell = command => POWERSHELL_LOOKS.test(String(command ?? ''));
|
||||
|
||||
let powershellFound;
|
||||
/** pwsh when it is installed (PowerShell 7), else the Windows PowerShell every Windows has. */
|
||||
function powershellExe(platform) {
|
||||
if (powershellFound) return powershellFound;
|
||||
if (platform !== 'win32') return (powershellFound = 'pwsh');
|
||||
try { if (spawnSync('where', ['pwsh'], { windowsHide: true, timeout: 5000 }).status === 0) return (powershellFound = 'pwsh'); } catch { /* none */ }
|
||||
return (powershellFound = 'powershell.exe');
|
||||
}
|
||||
|
||||
/**
|
||||
* -> { file, args, shell } for spawn: which program runs `command`.
|
||||
* @param {string} command
|
||||
* @param {{shell?: string, platform?: string}} [options]
|
||||
*/
|
||||
export function planShell(command, { shell = 'auto', platform = process.platform } = {}) {
|
||||
const windows = platform === 'win32';
|
||||
let choice = SHELLS.includes(shell) ? shell : 'auto';
|
||||
if (choice === 'auto') choice = windows ? (looksLikePowerShell(command) ? 'powershell' : 'cmd') : 'sh';
|
||||
if (choice === 'powershell') return { file: powershellExe(platform), args: ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', command], shell: false, name: 'PowerShell' };
|
||||
if (choice === 'bash') return { file: 'bash', args: ['-c', command], shell: false, name: 'bash' };
|
||||
if (choice === 'sh') return windows ? { file: 'sh', args: ['-c', command], shell: false, name: 'sh' } : { file: command, args: [], shell: true, name: 'sh' };
|
||||
return { file: command, args: [], shell: true, name: windows ? 'cmd.exe' : 'sh' }; // cmd: spawn's shell:true is cmd.exe on Windows
|
||||
}
|
||||
|
||||
/** The folder a command runs in: -> { cwd } or { error } with a plain sentence. */
|
||||
export function resolveCwd(...candidates) {
|
||||
for (const candidate of candidates) {
|
||||
const text = typeof candidate === 'string' ? candidate.trim() : '';
|
||||
if (!text) continue;
|
||||
const expanded = text.replace(/^~(?=$|[\\/])/, homedir());
|
||||
try {
|
||||
if (!existsSync(expanded) || !statSync(expanded).isDirectory()) return { error: `The folder “${text}” does not exist, so the command was not run.` };
|
||||
} catch { return { error: `The folder “${text}” could not be opened, so the command was not run.` }; }
|
||||
return { cwd: expanded };
|
||||
}
|
||||
return { cwd: homedir() };
|
||||
}
|
||||
|
||||
export const clampTimeoutSec = value => {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n >= 1 ? Math.min(Math.floor(n), MAX_TIMEOUT_SEC) : DEFAULT_TIMEOUT_SEC;
|
||||
};
|
||||
|
||||
function endTree(child) {
|
||||
try {
|
||||
if (process.platform === 'win32' && child.pid) spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, timeout: 10000 });
|
||||
else child.kill('SIGKILL');
|
||||
} catch { /* gone */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{command: string, input?: string, cwd?: string, callCwd?: string, shell?: string, timeoutSec?: number, platform?: string}} job
|
||||
* @returns {Promise<{ok: boolean, text: string}>}
|
||||
*/
|
||||
export function runCommand({ command, input = '', cwd, callCwd, shell = 'auto', timeoutSec, platform = process.platform }) {
|
||||
return new Promise(resolve => {
|
||||
const where = resolveCwd(callCwd, cwd);
|
||||
if (where.error) return resolve({ ok: false, text: where.error });
|
||||
const plan = planShell(command, { shell, platform });
|
||||
const limit = clampTimeoutSec(timeoutSec);
|
||||
let child;
|
||||
try {
|
||||
child = spawn(plan.file, plan.args, { shell: plan.shell, windowsHide: true, cwd: where.cwd, env: { ...process.env, STRATA_INPUT: String(input ?? '') }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
} catch (e) { return resolve({ ok: false, text: `The command could not start in ${plan.name}: ${e.message}` }); }
|
||||
let text = '', cut = false, timedOut = false;
|
||||
const take = chunk => { if (text.length < OUT_MAX + 1) text += chunk.toString(); else cut = true; };
|
||||
child.stdout.on('data', take); child.stderr.on('data', take);
|
||||
const timer = setTimeout(() => { timedOut = true; endTree(child); }, limit * 1000);
|
||||
child.on('error', e => { clearTimeout(timer); resolve({ ok: false, text: `The command could not start in ${plan.name}: ${e.message}` }); });
|
||||
child.on('close', code => {
|
||||
clearTimeout(timer);
|
||||
if (text.length > OUT_MAX) { cut = true; text = text.slice(0, OUT_MAX); }
|
||||
const out = text.trim() + (cut ? `\n\n(The output was cut after ${OUT_MAX.toLocaleString('en-US')} characters.)` : '');
|
||||
if (timedOut) return resolve({ ok: false, text: `The command was stopped after ${limit} seconds.${out ? '\n' + out : ''}` });
|
||||
resolve({ ok: code === 0, text: out || (code === 0 ? 'The command finished without any error. It printed nothing.' : `The command failed (exit code ${code}) and printed nothing.`) });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -8,49 +8,35 @@
|
||||
* It speaks the Model Context Protocol over stdio (newline-delimited JSON-RPC), the transport the Harness's MCP client uses for a program
|
||||
* it starts. The file is re-read on every request, so a change in Settings is seen without restarting anything. The assistant's text for
|
||||
* a command tool reaches the command in the STRATA_INPUT environment variable (never spliced into the command line, so it cannot add
|
||||
* commands of its own). A command gets 2 minutes and 20,000 characters of output.
|
||||
* commands of its own). A command runs in the shell that suits it (PowerShell for a Windows command that reads as PowerShell, cmd.exe for
|
||||
* the rest, sh elsewhere), in its own folder or the one the assistant names, gets 2 minutes unless its tool allows more (up to an hour) and
|
||||
* 20,000 characters of output (custom-command-run.mjs).
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { runCommand } from './custom-command-run.mjs';
|
||||
|
||||
const [mode, file] = process.argv.slice(2);
|
||||
if (!['instructions', 'commands'].includes(mode) || !file) { console.error('usage: custom-tools-mcp.mjs instructions|commands <tools.json>'); process.exit(2); }
|
||||
|
||||
const load = () => { try { return JSON.parse(readFileSync(file, 'utf8'))[mode] ?? {}; } catch { return {}; } };
|
||||
const OUT_MAX = 20000;
|
||||
const TIME_MS = 120000;
|
||||
|
||||
const schema = mode === 'commands'
|
||||
? { type: 'object', properties: { input: { type: 'string', description: 'Text to give the command (optional).' } }, additionalProperties: false }
|
||||
? { type: 'object', properties: {
|
||||
input: { type: 'string', description: 'Text to give the command (optional).' },
|
||||
cwd: { type: 'string', description: 'The folder to run the command in (optional). Give the working folder of this chat when the command uses relative paths.' },
|
||||
}, additionalProperties: false }
|
||||
: { type: 'object', properties: {}, additionalProperties: false };
|
||||
|
||||
function listTools() {
|
||||
return Object.entries(load()).map(([name, t]) => ({ name, description: String(t.description ?? ''), inputSchema: schema, annotations: mode === 'instructions' ? { readOnlyHint: true } : {} }));
|
||||
}
|
||||
|
||||
function runCommand(command, input) {
|
||||
return new Promise(resolve => {
|
||||
const child = spawn(command, { shell: true, windowsHide: true, env: { ...process.env, STRATA_INPUT: String(input ?? '') }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let text = '';
|
||||
const take = chunk => { if (text.length < OUT_MAX) text += chunk.toString(); };
|
||||
child.stdout.on('data', take); child.stderr.on('data', take);
|
||||
const timer = setTimeout(() => { try { child.kill(); } catch { /* gone */ } }, TIME_MS);
|
||||
child.on('error', e => { clearTimeout(timer); resolve({ ok: false, text: `The command could not start: ${e.message}` }); });
|
||||
child.on('close', (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
const out = text.slice(0, OUT_MAX).trim();
|
||||
if (signal) resolve({ ok: false, text: `The command was stopped after ${TIME_MS / 1000} seconds.${out ? '\n' + out : ''}` });
|
||||
else resolve({ ok: code === 0, text: out || (code === 0 ? 'The command finished without any error. It printed nothing.' : `The command failed (exit code ${code}) and printed nothing.`) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function call(name, args) {
|
||||
const t = load()[name];
|
||||
if (!t) return { isError: true, content: [{ type: 'text', text: 'That tool is no longer available.' }] };
|
||||
if (mode === 'instructions') return { content: [{ type: 'text', text: String(t.text ?? '') }] };
|
||||
const r = await runCommand(String(t.command ?? ''), args?.input);
|
||||
const r = await runCommand({ command: String(t.command ?? ''), input: args?.input, callCwd: args?.cwd, cwd: t.cwd, shell: t.shell, timeoutSec: t.timeoutSec });
|
||||
return { isError: !r.ok, content: [{ type: 'text', text: r.text }] };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* How far a local engine has got, as a short text that changes whenever it makes progress. The agent's stall watchdog
|
||||
* compares it with the last one it saw: while a long prompt is read the Harness hears nothing for minutes, but the
|
||||
* engine's /metrics (polled every 2 s) shows `live.prompt_read` moving (#3623). Null when the engine is idle or says nothing.
|
||||
*/
|
||||
export function engineProgressMark(instance) {
|
||||
const live = instance?.metrics?.live;
|
||||
if (!live || live.state === 'idle' || live.state === 'unloaded') return null;
|
||||
const slots = Array.isArray(live.slots) ? live.slots.map(s => `${s?.state}:${s?.prompt_read ?? ''}:${s?.generated ?? ''}`).join(',') : '';
|
||||
return `${live.state}|${live.prompt_read ?? ''}|${live.generated ?? ''}|${slots}`;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* The engine's own answer speed for one assistant turn: every token its requests produced (thinking, answer and tool calls; with
|
||||
* MTP drafting only the tokens it kept) over the time they spent decoding. These are the numbers the Monitor and `/metrics` show,
|
||||
* so the chat footer reads the same. Nothing here is a guess from the stream: it returns null when the engine does not say.
|
||||
*/
|
||||
|
||||
/** Sum the engine's finished requests that began at or after `since` (ms). `attempts` is how many model requests the turn made:
|
||||
* when the engine lists more (another chat ran at the same time), only the newest `attempts` are the turn's. */
|
||||
export function sumEngineRequests(requests, { since, attempts = Infinity } = {}) {
|
||||
if (!Array.isArray(requests)) return null;
|
||||
const mine = requests
|
||||
.filter(r => Number.isFinite(r?.time) && r.time * 1000 >= since - 1500)
|
||||
.sort((a, b) => a.time - b.time)
|
||||
.slice(-Math.max(1, attempts));
|
||||
let tokens = 0, decodeMs = 0, promptRead = 0, promptMs = 0;
|
||||
for (const r of mine) {
|
||||
const made = Number.isFinite(r.engine_generated) ? r.engine_generated : r.output_tokens;
|
||||
if (!(made > 0) || !(r.decode_ms > 0)) continue;
|
||||
tokens += made; decodeMs += r.decode_ms;
|
||||
const read = Number.isFinite(r.prompt_read) ? r.prompt_read : (r.prompt_tokens ?? 0) - (r.reused ?? 0);
|
||||
if (read > 0 && r.prompt_ms > 0) { promptRead += read; promptMs += r.prompt_ms; }
|
||||
}
|
||||
if (!(tokens > 0 && decodeMs > 0)) return null;
|
||||
return {
|
||||
tokens, decodeMs, tps: Number((tokens / (decodeMs / 1000)).toFixed(1)),
|
||||
promptTps: promptRead > 0 && promptMs > 0 ? Number((promptRead / (promptMs / 1000)).toFixed(1)) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ask a local engine for its request history. Any failure is null: the caller keeps the stream's own estimate. */
|
||||
export async function turnEngineSpeed({ baseUrl, key, since, attempts, fetcher = fetch, timeoutMs = 1500 }) {
|
||||
try {
|
||||
const origin = new URL(baseUrl).origin;
|
||||
const res = await fetcher(`${origin}/metrics?requests=all`, {
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
headers: key ? { authorization: `Bearer ${key}` } : undefined,
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return sumEngineRequests((await res.json())?.requests, { since, attempts });
|
||||
} catch { return null; }
|
||||
}
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
import { toolUpdate, TokenCount, fmtTokens } from './status.mjs';
|
||||
import { askFor } from './ask.mjs';
|
||||
import { FIT_TEXT, fill } from './context-fit.mjs';
|
||||
import { isContextOverflow } from './context-budget.mjs';
|
||||
|
||||
const SHELL = new Set(['pwsh', 'bash', 'job_kill', 'job_output', 'job_list']);
|
||||
const WRITE = new Set(['write', 'edit']);
|
||||
@@ -157,6 +159,9 @@ export function withPlain(raw) {
|
||||
return plain ? `${plain}\n\nDetails: ${raw.replace(/^Error:\s*/, '')}` : raw;
|
||||
}
|
||||
|
||||
/** What a launch that printed nothing proves, and no more. */
|
||||
export const LAUNCHED_TEXT = 'The command finished without an error, so the program was started. Strata cannot confirm that a window or page actually opened.';
|
||||
|
||||
/** A command whose whole point is to open something: it has no output to show when it worked. */
|
||||
const LAUNCH = /(^|[\s;|&(])(start-process|start|invoke-item|ii|explorer(?:\.exe)?|xdg-open|open|notepad(?:\.exe)?|code)(?=[\s;|&)]|$)/i;
|
||||
|
||||
@@ -172,9 +177,10 @@ export function outcomeText(tool, text, args = {}) {
|
||||
text = text.replace(/\r?\n?\[stderr\]\r?\n(?:Cannot create type\. Only core types are supported in this language mode\.[\s\S]*?CannotCreateTypeConstrainedLanguage\s*)+$/, '');
|
||||
if (!text.trim()) text = '(no output)';
|
||||
}
|
||||
// Starting a program or opening a folder prints nothing when it worked. The model used to read "finished, nothing to show" as "cannot tell
|
||||
// whether it opened" and told the person so; a launch that returned without an error did start, and the result says that plainly.
|
||||
if ((tool === 'pwsh' || tool === 'bash') && /^\(no output\)$/.test(text.trim()) && LAUNCH.test(String(args?.command ?? ''))) return 'Started. The program, folder or file was opened.';
|
||||
// Starting a program or opening a folder prints nothing when it worked, and also when it did not (a browser given a file:// address, a
|
||||
// program that closed at once). T9 / #3576: the card used to say "was opened", and the model repeated it ("I previewed it in a browser to
|
||||
// confirm it renders"). It says what is known: the command returned without an error.
|
||||
if ((tool === 'pwsh' || tool === 'bash') && /^\(no output\)$/.test(text.trim()) && LAUNCH.test(String(args?.command ?? ''))) return LAUNCHED_TEXT;
|
||||
if ((tool === 'pwsh' || tool === 'bash') && /^\(no output\)$/.test(text.trim())) return 'The command finished without any error. It printed nothing, so there is nothing more to show.';
|
||||
return text;
|
||||
}
|
||||
@@ -196,8 +202,10 @@ export class TurnProjector {
|
||||
* no question ever reaches the person: the card goes straight from running to denied, without a moment where it
|
||||
* offers Approve and Deny buttons that would do nothing.
|
||||
*/
|
||||
constructor({ startedAt = Date.now(), policy = 'ask' } = {}) {
|
||||
constructor({ startedAt = Date.now(), policy = 'ask', window = 0 } = {}) {
|
||||
this.startedAt = startedAt;
|
||||
this.window = Number(window) || 0; // the model's window, for the figures in the line that says the chat was summarised
|
||||
this.summarizing = false;
|
||||
this.policy = policy;
|
||||
this.byPolicy = new Set(); // callIds the approval policy rejected without asking
|
||||
this.tools = new Map(); // callId -> { card, calledAt }
|
||||
@@ -303,6 +311,7 @@ export class TurnProjector {
|
||||
this.tokensIn += prompt;
|
||||
this.tokensOut += Number(c.usage.outputTokens) || 0;
|
||||
this.contextTokens = prompt + (Number(c.usage.outputTokens) || 0);
|
||||
out.push({ type: 'context', contextTokens: this.contextTokens, contextMax: this.window || undefined });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
@@ -371,6 +380,22 @@ export class TurnProjector {
|
||||
if (frame) out.push(frame);
|
||||
break;
|
||||
}
|
||||
case 'compaction/start':
|
||||
// The Harness is summarising the older messages to make room: say so while it does, and again (once) when it is done.
|
||||
this.summarizing = true;
|
||||
this.#status({ phase: 'compacting', code: 'compacting', text: FIT_TEXT.summarizing, key: FIT_TEXT.summarizing, values: [] });
|
||||
break;
|
||||
case 'compaction/end':
|
||||
if (!this.summarizing) break;
|
||||
this.summarizing = false;
|
||||
this.#afterTool();
|
||||
if (!d.error) {
|
||||
const before = this.window > 0 && this.contextTokens ? Math.min(100, Math.round(100 * this.contextTokens / this.window)) : null;
|
||||
const text = before === null ? FIT_TEXT.summarizedShort : FIT_TEXT.summarizedWas;
|
||||
const values = before === null ? [] : [before];
|
||||
out.push({ type: 'delta', own: true, text: '\n\n' + fill(text, values), key: text, values, noteReason: 'compacted' });
|
||||
}
|
||||
break;
|
||||
case 'turn/start':
|
||||
// The parent woke up again (a helper finished and reported back): the message goes on, as a new paragraph.
|
||||
this.ended = null;
|
||||
@@ -395,7 +420,7 @@ export class TurnProjector {
|
||||
}
|
||||
|
||||
/** The terminal frames for a finished turn. */
|
||||
finish({ model, contextMax } = {}) {
|
||||
finish({ model, contextMax, engine = null } = {}) {
|
||||
const reason = this.ended ?? { kind: 'interrupted' };
|
||||
const durationMs = Date.now() - this.startedAt;
|
||||
const usage = this.usageSeen
|
||||
@@ -403,8 +428,15 @@ export class TurnProjector {
|
||||
: { tokensIn: null, tokensOut: null, usageSource: 'unavailable' };
|
||||
const decoded = this.decode();
|
||||
const generated = this.usageSeen && this.tokensOut > 0 ? this.tokensOut : decoded.tokens;
|
||||
const tps = decoded.seconds >= 0.3 && generated > 0 ? Number((generated / decoded.seconds).toFixed(1)) : null;
|
||||
const base = { durationMs, ttftMs: this.firstTokenAt ? this.firstTokenAt - this.startedAt : null, model, contextMax, tps, contextTokens: this.contextTokens };
|
||||
// Answer speed, one definition everywhere: all the tokens the model produced over its decode time. The engine's own figures (the ones the
|
||||
// Monitor shows) when the bridge could read them; otherwise the stream's own span. A very short reply has almost no span, so its decode time
|
||||
// is then the time from its first chunk to the end of the turn (only when no tool ran, so no tool time is in it): the number is still shown.
|
||||
let tps = null;
|
||||
if (engine?.tps > 0) tps = engine.tps;
|
||||
else if (generated > 0 && decoded.seconds >= 0.3) tps = Number((generated / decoded.seconds).toFixed(1));
|
||||
else if (generated > 0 && this.tools.size === 0 && this.firstTokenAt && Date.now() - this.firstTokenAt >= 50) tps = Number((generated / ((Date.now() - this.firstTokenAt) / 1000)).toFixed(1));
|
||||
const promptTps = engine?.promptTps > 0 ? engine.promptTps : null;
|
||||
const base = { durationMs, ttftMs: this.firstTokenAt ? this.firstTokenAt - this.startedAt : null, model, contextMax, tps, ...(promptTps ? { promptTps } : {}), contextTokens: this.contextTokens };
|
||||
switch (reason.kind) {
|
||||
case 'completed':
|
||||
return [{ type: 'done', ...base, ...usage, finishReason: 'stop' }];
|
||||
@@ -413,7 +445,7 @@ export class TurnProjector {
|
||||
case 'aborted':
|
||||
return [{ type: 'done', ...base, ...usage, finishReason: 'aborted', cancelled: true }];
|
||||
case 'error':
|
||||
return [{ type: 'error', message: friendlyFailure(reason.error), code: reason.error?.code ?? 'UNKNOWN' }];
|
||||
return [{ type: 'error', message: friendlyFailure(reason.error), code: isContextFailure(reason.error) ? 'CONTEXT_WINDOW_EXCEEDED' : reason.error?.code ?? 'UNKNOWN', ...(isContextFailure(reason.error) ? { key: FIT_TEXT.full, details: String(reason.error?.message ?? '') } : {}) }];
|
||||
case 'blocked':
|
||||
return [{ type: 'error', message: 'The agent stopped because this turn was blocked by policy.', code: 'BLOCKED' }];
|
||||
default:
|
||||
@@ -422,10 +454,15 @@ export class TurnProjector {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a failure is the model server saying the conversation does not fit its window (its code, or the engine's own words). */
|
||||
export function isContextFailure(failure) {
|
||||
return isContextOverflow(failure) || /context window|too long for|exceeds the context/i.test(String(failure?.message ?? ''));
|
||||
}
|
||||
|
||||
/** A model-route failure in words a user can act on. */
|
||||
export function friendlyFailure(failure) {
|
||||
const message = String(failure?.message ?? 'The model request failed.');
|
||||
if (/context window|too long|exceeds/i.test(message)) return `The conversation no longer fits the model's context window. ${clip(message, 200)}`;
|
||||
if (isContextFailure(failure)) return FIT_TEXT.full;
|
||||
if (/ECONNREFUSED|fetch failed|connect/i.test(message)) return `The model is not reachable. ${clip(message, 200)}`;
|
||||
if (/401|403|credential|api key/i.test(message)) return 'The model rejected the credentials. Check the connection in Settings.';
|
||||
return clip(message, 400);
|
||||
|
||||
@@ -33,7 +33,7 @@ export function agentEnvironment(base = process.env, extra = {}) {
|
||||
}
|
||||
// DSH_PERMISSION_MODE is the composition default for a session no chat mode has touched (a child agent, a
|
||||
// seeded session): the strictest sandbox, which matches the Manual preset, never the Harness's wider default.
|
||||
return { ...out, DSH_TELEMETRY_DISABLED: '1', DSH_PERMISSION_MODE: 'read-only', ...extra };
|
||||
return { ...out, DSH_TELEMETRY_DISABLED: '1', DSH_PERMISSION_MODE: 'read-only', NODE_USE_SYSTEM_CA: '1', ...extra };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -117,7 +117,7 @@ export function stepObjective(plan, index) {
|
||||
`You are on step ${index + 1} of ${n}: ${s.text}${s.detail ? ` (${s.detail})` : ''}.`,
|
||||
done ? `Already done: ${done}.` : '',
|
||||
plan.briefing ? String(plan.briefing) : '',
|
||||
isViewStep(s.text) ? 'This step is about seeing the result in a browser. Never wait for a program to close. If a person is at this computer, start the browser so the command returns at once (Windows: Start-Process; macOS: open; Linux: xdg-open) and say it is open. If there is no screen, check the page without a visible window: use the headless browser tool if you have one, otherwise read the file and its script for mistakes, and say in one plain sentence what you could and could not check. Then finish the step.' : '',
|
||||
isViewStep(s.text) ? 'This step is about seeing the result. To show a local image, SVG, HTML page or PDF, use the present tool (a browser cannot open file:// addresses). Never wait for a program to close. If a person is at this computer and you start a browser, start it so the command returns at once (Windows: Start-Process; macOS: open; Linux: xdg-open) and say only that you started it, not that the page rendered. If there is no screen, check the page without a visible window: use the headless browser tool if you have one, otherwise read the file and its script for mistakes, and say in one plain sentence what you could and could not check. Then finish the step.' : '',
|
||||
'Do only this step, check that it worked, and then finish the goal.',
|
||||
index + 1 < n ? 'The next steps are handled separately: do not start them.' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Strata's context plugin for the Harness (plain ESM, loaded by absolute path from the profile, like strata-gate and strata-models).
|
||||
*
|
||||
* What it fixes (2026-10-22 hands-on test, Gyro-S at 64K):
|
||||
* 1. Compaction decided from the Harness's own price of the conversation, 4 characters a token. Code, logs and JSON are about twice as
|
||||
* dense, so a chat the engine had read as 59,048 tokens was priced at 31,794 and compaction started far too late. Here the token meter
|
||||
* is calibrated by what the model server reports for each answer (its real prompt size): the measured size is exact, and what was
|
||||
* added since is priced with the density that measurement showed. Automatic compaction and the manual /compact both measure through
|
||||
* the token meter, so both read the same number as the context meter in the app.
|
||||
* 2. The summarising request itself could be bigger than the window ("/compact" sent a 66,507-token prompt to a 65,536 window). The
|
||||
* summary is now asked for in pieces that fit: a message bigger than a piece is cut for the request (the conversation keeps it until
|
||||
* the summary replaces it), the pieces are summarised one after the other, each building on the summary before it, and a piece the
|
||||
* server still refuses is halved and tried again.
|
||||
*
|
||||
* How. It wraps `tokenMeter.measure` and `llm.stream` in place, once each (the same way strata-models wraps the subagent tools). Only a
|
||||
* request the Harness marks `purpose: 'compaction'` is touched in `llm.stream`. Nothing in the Harness is rewritten: a Harness update
|
||||
* that renames either leaves compaction as it was, and the test that runs the real Harness fails.
|
||||
*/
|
||||
import { densityOf, estimateMessage, estimateTools, isContextOverflow, MAX_DENSITY, pressureNow, promptTokensOf, splitMessages, summaryBudget, usageOfEvent, WINDOW_SLACK } from '../context-budget.mjs';
|
||||
|
||||
export const name = 'strata-context';
|
||||
export const inject = ['tokenMeter', 'llm'];
|
||||
|
||||
/** Real prompt measurements per session: { real, estimated } for the last answer the model server reported. */
|
||||
const samples = new WeakMap();
|
||||
const bySessionId = new Map();
|
||||
const wrapped = new WeakSet();
|
||||
/** Densities that made a summary request fail on this server, per session id (the next request starts from a more careful one). */
|
||||
const careful = new Map();
|
||||
|
||||
export function apply(ctx) {
|
||||
const meter = ctx.tokenMeter;
|
||||
const llm = ctx.llm;
|
||||
const log = (level, text) => { try { ctx.logger?.[level]?.(`strata-context: ${text}`); } catch { /* logging never blocks */ } };
|
||||
|
||||
// 1. The measured size of the prompt, from the model server's own report for each answer.
|
||||
if (meter && typeof meter.measure === 'function' && !wrapped.has(meter)) {
|
||||
wrapped.add(meter);
|
||||
const original = meter.measure;
|
||||
meter.measure = function measure(session, requestHeader) {
|
||||
const m = original.call(this, session, requestHeader);
|
||||
const sample = samples.get(session);
|
||||
if (!sample) return m;
|
||||
const now = m.surfaceTokens + estimateTools(requestHeader ?? session.requestHeader?.());
|
||||
const total = Math.max(m.totalTokens, pressureNow({ sample, now }));
|
||||
if (total <= m.totalTokens) return m;
|
||||
const density = densityOf(sample.real, sample.estimated);
|
||||
return { ...m, totalTokens: total, nodes: m.nodes.map(n => ({ ...n, tokens: Math.ceil(n.tokens * density) })) };
|
||||
};
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (event.type !== 'assistant/message') return;
|
||||
const real = promptTokensOf(usageOfEvent(event));
|
||||
if (!(real > 0)) return;
|
||||
try {
|
||||
// The request that was just answered saw the conversation without this answer: its price is the surface before the answer's node.
|
||||
const m = original.call(meter, session);
|
||||
const heuristic = m.nodes.reduce((n, x) => n + (x.seq === event.seq ? 0 : x.heuristicTokens), 0);
|
||||
const estimated = heuristic + estimateTools(session.requestHeader?.());
|
||||
if (!(estimated > 0)) return;
|
||||
const sample = { real, estimated };
|
||||
samples.set(session, sample);
|
||||
if (session.id) bySessionId.set(String(session.id), sample);
|
||||
} catch (e) { log('warn', `could not record a measurement: ${e.message}`); }
|
||||
});
|
||||
}
|
||||
|
||||
// 2. A summary request that fits the window.
|
||||
if (llm && typeof llm.stream === 'function' && !wrapped.has(llm)) {
|
||||
wrapped.add(llm);
|
||||
const original = llm.stream;
|
||||
llm.stream = function stream(options) {
|
||||
if (options?.purpose !== 'compaction') return original.call(this, options);
|
||||
return summaryStream.call(this, options, original);
|
||||
};
|
||||
}
|
||||
|
||||
// The host asks, with /strata-context, how much of the window the chat holds right now: the same measured number compaction decides on
|
||||
// (JSON in the command's text: { tokens, window }). Before a message goes out, and after /compact, so the person is told true figures.
|
||||
ctx.inject(['commands'], (commandCtx) => {
|
||||
commandCtx.commands.register({
|
||||
definitionId: 'strata-context',
|
||||
name: 'strata-context',
|
||||
description: 'Reports how many tokens the conversation holds now and the size of the model window',
|
||||
handler: async (invocation) => {
|
||||
try {
|
||||
const session = invocation.agent.session;
|
||||
const tokens = meter.measure(session).totalTokens;
|
||||
const config = session.requestHeader?.()?.config;
|
||||
let window = 0;
|
||||
if (config) window = (await llm.resolveModelInfo(config.provider, config.model))?.context?.contextWindow ?? 0;
|
||||
return { kind: 'success', text: JSON.stringify({ tokens, window }) };
|
||||
} catch (e) { return { kind: 'error', text: `strata-context: ${e.message}` }; }
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/** A finished stream: its chunks, the text it wrote and why it ended. */
|
||||
async function drain(iterable) {
|
||||
let text = '', finish = null;
|
||||
const chunks = [];
|
||||
for await (const chunk of iterable) {
|
||||
chunks.push(chunk);
|
||||
if (chunk.type === 'text-delta') text += chunk.text ?? '';
|
||||
else if (chunk.type === 'finish') finish = chunk.reason;
|
||||
}
|
||||
return { text, finish, chunks };
|
||||
}
|
||||
const refusedForSize = finish => finish?.kind === 'error' && isContextOverflow(finish.failure ?? finish);
|
||||
|
||||
async function* summaryStream(options, original) {
|
||||
let window = 0;
|
||||
try { window = (await llm.resolveModelInfo(options.provider, options.model, options.signal))?.context?.contextWindow ?? 0; } catch { window = 0; }
|
||||
const messages = options.messages ?? [];
|
||||
const key = String(options.sessionId ?? '');
|
||||
const summaryTokens = options.maxTokens ?? 4096;
|
||||
if (!(window > 0) || messages.length < 2) { yield* original.call(this, options); return; }
|
||||
const own = bySessionId.get(key);
|
||||
let density = Math.max(careful.get(key) ?? 1, own ? densityOf(own.real, own.estimated) : 1);
|
||||
const price = list => list.reduce((n, m) => n + estimateMessage(m), 0) * density;
|
||||
const limit = window - summaryTokens - WINDOW_SLACK;
|
||||
// The whole request fits by this price: ask as the Harness would (the model server's cache of the conversation is reused).
|
||||
if (price(messages) + estimateTools({ tools: options.tools }) * density <= limit) {
|
||||
const first = await drain(original.call(this, options));
|
||||
if (!refusedForSize(first.finish)) { for (const chunk of first.chunks) yield chunk; return; }
|
||||
density = Math.min(MAX_DENSITY, density * 1.6);
|
||||
careful.set(key, density);
|
||||
log('warn', 'the summary request did not fit the window; asking in pieces');
|
||||
}
|
||||
// In pieces. The instruction is the last message; the system prompt, when there is one, the first.
|
||||
const instruction = messages[messages.length - 1];
|
||||
const system = messages[0]?.role === 'system' ? messages[0] : null;
|
||||
const body = messages.slice(system ? 1 : 0, -1);
|
||||
let tools = options.tools;
|
||||
if (tools && estimateTools({ tools }) * density > window / 3) tools = undefined; // a long tool list only describes tools: the summary does not need it
|
||||
const fixed = (system ? estimateMessage(system) : 0) * density + estimateTools({ tools }) * density + estimateMessage(instruction) * density;
|
||||
let budget = summaryBudget({ window, summaryTokens: summaryTokens * 2, systemTokens: fixed, instructionTokens: 0 });
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const runs = splitMessages(body, Math.max(1024, budget), density);
|
||||
log('info', `summarising ${body.length} messages in ${runs.length} piece(s) for a ${window}-token window (density ${density.toFixed(2)}, about ${Math.round(budget)} tokens a piece)`);
|
||||
let failed = null, summary = '';
|
||||
for (let i = 0; i < runs.length && !failed; i++) {
|
||||
options.signal?.throwIfAborted?.();
|
||||
const carried = summary ? [{ role: 'user', content: [{ type: 'text', text: `<compacted-summary>\n${summary}\n</compacted-summary>` }] }] : [];
|
||||
const { tools: _drop, ...rest } = options;
|
||||
const piece = { ...rest, ...(tools ? { tools } : {}), messages: [...(system ? [system] : []), ...carried, ...runs[i], instruction] };
|
||||
const { text, finish, chunks } = await drain(original.call(this, piece));
|
||||
if (refusedForSize(finish)) { failed = finish; break; }
|
||||
// The last piece's answer is the summary the Harness reads; a piece that ended in another error ends the request the same way.
|
||||
if (i === runs.length - 1 || finish?.kind === 'error' || finish?.kind === 'aborted') { for (const chunk of chunks) yield chunk; return; }
|
||||
summary = text;
|
||||
}
|
||||
if (!failed || attempt >= 3) throw Object.assign(new Error(failed?.failure?.message ?? 'the summary request does not fit the window'), { code: 'CONTEXT_WINDOW_EXCEEDED' });
|
||||
density = Math.min(MAX_DENSITY, density * 1.6);
|
||||
careful.set(key, density);
|
||||
budget = Math.max(1024, budget / 2);
|
||||
log('warn', `a piece did not fit; asking again with pieces of about ${Math.round(budget)} tokens`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,8 +68,37 @@ const SAFE_VERSION = /^(node|python|python3|py|npm|pnpm|git|dotnet|cargo|rustc|g
|
||||
/** Anything that can chain, redirect, expand or start something else is not read-only. */
|
||||
const UNSAFE_SYNTAX = /[;&<>`${}()]|\r|\n|--%|\bout-file\b|\bset-content\b|\badd-content\b|\binvoke-|\biex\b|\bstart-|\bnew-|\bremove-|\bcopy-|\bmove-|\brename-|\bclear-|\bset-|\bpush-|\bexpand-|\bsave-|\binstall-|\bcurl\b|\bwget\b|\bsudo\b|\bsh\b|\bbash\b|\bpwsh\b|\bpowershell\b|\bcmd\b|-exec\b|-delete\b|-execdir\b/i;
|
||||
|
||||
/**
|
||||
* The command with every quoted text replaced by a plain stand-in, or null when it cannot be read safely. What is inside quotes is data
|
||||
* (a path like "C:\Users\x\Q&A", a pattern like 'a|b'), so an & ; | ( ) < > or { in it must not decide anything. Two things still can:
|
||||
* inside double quotes a $ or a backtick expands (a variable, a sub-command), and an unterminated quote means the text was not understood.
|
||||
* Either one gives null, and the command asks as before.
|
||||
* @param {string} text
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function maskQuotes(text) {
|
||||
let out = '';
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (c !== '"' && c !== "'") { out += c; continue; }
|
||||
let end = text.indexOf(c, i + 1);
|
||||
// PowerShell writes a quote inside a quoted text by doubling it ('it''s'): the text goes on past the pair.
|
||||
while (end >= 0 && text[end + 1] === c) end = text.indexOf(c, end + 2);
|
||||
if (end < 0) return null;
|
||||
if (c === '"' && /[$`]/.test(text.slice(i + 1, end))) return null;
|
||||
// A quoted text is a whole word. One glued to other characters (--out"put"=f, "a"b) or one that starts with a dash could hide an option from the
|
||||
// word checks below (git diff "--output=f" writes a file), so those are not understood either.
|
||||
const before = text[i - 1], after = text[end + 1];
|
||||
if ((before !== undefined && !/[\s|]/.test(before)) || (after !== undefined && !/[\s|]/.test(after)) || text[i + 1] === '-') return null;
|
||||
out += '"_"';
|
||||
i = end;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a shell command only reads. Conservative by design: one unrecognised word and the answer is no.
|
||||
* Quoted text is data and is looked at after it has been set aside (maskQuotes): Get-ChildItem "C:\Users\x\Q&A" only lists.
|
||||
* @param {string} command
|
||||
* @returns {boolean}
|
||||
*/
|
||||
@@ -77,8 +106,9 @@ export function isReadOnlyShell(command) {
|
||||
const text = String(command ?? '').trim();
|
||||
if (!text || text.length > 600) return false;
|
||||
if (SAFE_VERSION.test(text)) return true;
|
||||
if (UNSAFE_SYNTAX.test(text)) return false;
|
||||
const segments = text.split('|').map(s => s.trim());
|
||||
const masked = maskQuotes(text);
|
||||
if (masked === null || UNSAFE_SYNTAX.test(masked)) return false;
|
||||
const segments = masked.split('|').map(s => s.trim());
|
||||
return segments.every((segment) => {
|
||||
if (!segment) return false;
|
||||
const words = segment.split(/\s+/);
|
||||
@@ -219,8 +249,13 @@ export function apply(ctx, config = {}) {
|
||||
ctx.on('tools/pre-execute', async (exec, next) => {
|
||||
const agent = exec.agent;
|
||||
if (!agent || exec.name === 'run_code') return next();
|
||||
// Fail closed (T4): a session whose preset cannot be read (a reset in progress, a helper whose chat is gone) is treated as Manual, so
|
||||
// anything this file cannot prove is read-only still asks. It used to run unasked.
|
||||
let preset;
|
||||
try { preset = effectivePreset(agent.session); } catch { return next(); }
|
||||
try { preset = effectivePreset(agent.session); } catch (e) {
|
||||
preset = MANUAL_PRESET;
|
||||
try { console.warn(`[strata-gate] could not read the permission mode for this call (${e?.message ?? e}); treating it as Manual`); } catch { /* logging must never decide a permission */ }
|
||||
}
|
||||
// A helper that was set to ask while the chat was in Manual must stop asking the moment the chat leaves Manual,
|
||||
// so a mode change applies to the very next tool call of a running goal and its helpers.
|
||||
if (preset !== MANUAL_PRESET && agent.session.header?.origin === 'subagent' && ctx.approval.overrideOf(agent.session) === 'ask') {
|
||||
|
||||
@@ -42,8 +42,8 @@ export const AGENT_PROMPT = [
|
||||
'You are a helpful assistant that can also work on this computer through your tools. You are running as {{model}}.',
|
||||
'Answer questions directly and briefly; use a tool only when the task needs one. The person reads your replies in a chat: use plain words, not tool names or technical jargon.',
|
||||
'Look before you change: read a file before you edit or overwrite it, and prefer a small edit to a rewrite. Search with the glob and grep tools and read files with the read tool; use the shell for running programs.',
|
||||
'When you start a program or open a folder or file for the person, the command returning without an error means it worked: say plainly that it is open. Do not say you could not confirm it. If a command fails, say what went wrong in one sentence and what you will try next.',
|
||||
'To open a page or a file for the person (a browser, an app, a folder), start it so the command returns at once and never wait for the program to close: on Windows use Start-Process, on macOS open, on Linux xdg-open. Then say plainly that it is open.',
|
||||
'A command that starts a program, folder or page only tells you that it started. Never say a window opened, a page rendered or that you checked how it looks unless a tool confirmed it. If a command fails, say what went wrong in one sentence and what you will try next.',
|
||||
'To show the person an image, SVG, HTML page or PDF from their folders, use the present tool: the app shows it next to the chat. A browser cannot open file:// addresses. If you do start a program, start it so the command returns at once and never wait for it to close: Start-Process on Windows, open on macOS, xdg-open on Linux.',
|
||||
'When the person asks you to ask a subagent, a helper or another model ("ask a subagent", "ask model X"), use the subagent tool and name the model in its model field. Never look for a model with shell commands, and never wait for a helper with sleep: end your turn; its answer arrives as a new message.',
|
||||
'Tokens prefixed with @ are files or folders the person pointed at. A failed or refused action changed nothing; say so, and do not claim work you did not do.',
|
||||
].join('\n\n');
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { standardPreset } from './preset.mjs';
|
||||
import { validMcpName } from '../mcp-names.mjs';
|
||||
import { BUDGETS, isPrivateHost } from './llm-shape.mjs';
|
||||
import { DDG_PROVIDER_ID } from '../web-search-ddg.mjs';
|
||||
|
||||
@@ -55,6 +56,8 @@ export const modeOf = value => (Object.hasOwn(MODE_PRESETS, value) ? value : DEF
|
||||
/** The approval gate plugin (plain ESM, loaded by absolute path). */
|
||||
export const GATE_PLUGIN = fileURLToPath(new URL('./plugins/strata-gate.mjs', import.meta.url));
|
||||
/** The app's web search (DuckDuckGo, or the person's own Exa / Perplexity key), registered with the Harness's web seam. */
|
||||
/** Compaction that measures the real prompt and summarises in pieces that fit the window (plugins/strata-context.mjs). */
|
||||
export const CONTEXT_PLUGIN = fileURLToPath(new URL('./plugins/strata-context.mjs', import.meta.url));
|
||||
export const MODELS_PLUGIN = fileURLToPath(new URL('./plugins/strata-models.mjs', import.meta.url));
|
||||
export const SEARCH_PLUGIN = fileURLToPath(new URL('./plugins/strata-search.mjs', import.meta.url));
|
||||
/** The app's own tool server for the tools a person writes (instructions, commands). */
|
||||
@@ -71,11 +74,17 @@ const NO_TOOLS_SETTINGS = Object.freeze({ off: new Set(), search: { provider: 'b
|
||||
* `mcp__<server>__<tool>`. A command or URL goes into the patch as text; a secret (an env value or a header) never does: the row
|
||||
* carries `!!js process.env.<NAME>` and the value travels in the agent process's environment (returned in `env`).
|
||||
*/
|
||||
export function mcpRows(servers, env) {
|
||||
export function mcpRows(servers, env, skipped = []) {
|
||||
const rows = [];
|
||||
const ids = new Set();
|
||||
let index = 0;
|
||||
for (const [name, server] of Object.entries(servers ?? {})) {
|
||||
if (!server || server.disabled === true || !/^[A-Za-z0-9_-]{1,32}$/.test(name)) continue;
|
||||
if (!server || server.disabled === true) continue; // switched off on purpose: not an error
|
||||
// A server that cannot be wired is REPORTED (T6): the model never sees its tools, and a silent drop looked like "the tool does not work".
|
||||
if (!validMcpName(name)) { skipped.push({ name, why: 'name' }); continue; }
|
||||
if (!server.command && !server.url) { skipped.push({ name, why: 'address' }); continue; }
|
||||
if (ids.has(`mcp-${slug(name)}`)) { skipped.push({ name, why: 'duplicate' }); continue; }
|
||||
ids.add(`mcp-${slug(name)}`);
|
||||
const token = (kind, key, value) => {
|
||||
const variable = `STRATA_MCP_${index}_${kind}_${slug(key).replace(/-/g, '_').toUpperCase()}`;
|
||||
env[variable] = String(value);
|
||||
@@ -91,6 +100,24 @@ export function mcpRows(servers, env) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the chat says when a tool server was left out (one notice per reason). The sentences are interface copy: they are in every language's
|
||||
* catalog (src/locales/tools-<lang>.json), and the {0} is the list of names exactly as the person wrote them.
|
||||
*/
|
||||
const SKIPPED_TEXT = {
|
||||
name: 'These tool servers were left out because their names are not allowed: {0}. A name can have up to 32 letters, numbers, hyphens or underscores.',
|
||||
address: 'These tool servers were left out because they have no program or web address: {0}.',
|
||||
duplicate: 'These tool servers were left out because another one has almost the same name: {0}.',
|
||||
};
|
||||
export function mcpSkippedNotices(skipped) {
|
||||
const out = [];
|
||||
for (const why of Object.keys(SKIPPED_TEXT)) {
|
||||
const names = (skipped ?? []).filter(s => s.why === why).map(s => String(s.name).slice(0, 40));
|
||||
if (names.length) out.push({ code: `mcp-skipped-${why}-${names.join(',')}`.slice(0, 160), message: SKIPPED_TEXT[why].replace('{0}', names.join(', ')) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const slug = value => String(value).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'x';
|
||||
|
||||
/** Identity of one route's wire configuration, secret included by hash only. */
|
||||
@@ -166,7 +193,7 @@ export function outputBudget(context, maxOutput = null) {
|
||||
/** What a local model, or an OpenAI-style server on this network, takes for the thinking level (see the comment where a local route is built). */
|
||||
const THINKING_COMPAT = { supportsStore: false, thinkingFormat: 'chat-template', chatTemplateKwargs: { enable_thinking: { $var: 'thinking.enabled' }, reasoning_effort: { $var: 'thinking.effort', omitWhenOff: true } } };
|
||||
|
||||
export function buildProfile({ local = [], connections = [], gate = true, tools = NO_TOOLS_SETTINGS, skillsDir = null, proxyBase = null, searchMock = null }) {
|
||||
export function buildProfile({ local = [], connections = [], gate = true, tools = NO_TOOLS_SETTINGS, skillsDir = null, proxyBase = null, searchMock = null, defaultLocalId = null }) {
|
||||
const providers = {};
|
||||
const env = {};
|
||||
const routes = [];
|
||||
@@ -202,6 +229,9 @@ export function buildProfile({ local = [], connections = [], gate = true, tools
|
||||
api: 'openai-completions',
|
||||
baseURL: inst.baseUrl,
|
||||
apiKeyEnv: keyEnv,
|
||||
// A long prompt read sends only SSE comments, which pi-ai's OpenAI client drops, so its 300 s stream-idle timer would cut the
|
||||
// request five minutes in (#3623). 60 minutes is a backstop: serve ends a hung engine and the bridge's watchdog ends a lost turn.
|
||||
streamIdleTimeoutMs: 60 * 60_000,
|
||||
// The Strata server reads the thinking level from `chat_template_kwargs` (enable_thinking, reasoning_effort) or from a top-level
|
||||
// reasoning_effort; a top-level `enable_thinking` (pi-ai's `qwen` format) is ignored, so the model went on thinking with the menu on Off.
|
||||
compat: THINKING_COMPAT,
|
||||
@@ -257,9 +287,13 @@ export function buildProfile({ local = [], connections = [], gate = true, tools
|
||||
for (const m of conn.models) routes.push({ route, fp, kind: 'api', connectionId: conn.id, connectionName: conn.name, modelId: m.id, name: m.name || m.id, contextWindow: m.contextMax ?? ASSUMED_API_CONTEXT, ...(shaped ? { shaped } : {}) });
|
||||
});
|
||||
|
||||
const defaultRoute = routes.find(r => r.kind === 'local') ?? null;
|
||||
// The model the chat really runs on is the default instance, not whichever one was loaded first (T5: with two models loaded, the tool servers of the
|
||||
// first one were wired to a chat on the second).
|
||||
const defaultInstance = local.find(i => i.id === defaultLocalId) ?? local[0] ?? null;
|
||||
const defaultRoute = routes.find(r => r.kind === 'local' && r.instanceId === defaultInstance?.id) ?? routes.find(r => r.kind === 'local') ?? null;
|
||||
// One Harness, one set of tool servers: the default local model's.
|
||||
const mcp = mcpRows({ ...(local[0]?.mcpServers ?? {}), ...(tools.servers ?? {}) }, mcpEnv);
|
||||
const mcpSkipped = [];
|
||||
const mcp = mcpRows({ ...(defaultInstance?.mcpServers ?? {}), ...(tools.servers ?? {}) }, mcpEnv, mcpSkipped);
|
||||
Object.assign(env, mcpEnv);
|
||||
// The person's own instruction and command tools come from the app's tool server; a command tool asks in Manual like any connected tool.
|
||||
const own = (kind, on) => on && tools.toolFile ? [{ id: `mcp-strata-${kind}`, name: '@deepseek-ai/dsh-mcp-client', config: { serverName: `strata-${kind}`, transport: 'stdio', command: process.execPath, args: [CUSTOM_TOOLS_SCRIPT, kind, tools.toolFile] } }] : [];
|
||||
@@ -286,6 +320,8 @@ export function buildProfile({ local = [], connections = [], gate = true, tools
|
||||
// Manual asks before a change runs, so the person sees one Approve/Deny card and nothing has happened yet.
|
||||
// Its web question names the search service the person chose (a keyed one only when its key is saved), and the person's switch skips the question.
|
||||
...(gate ? [{ insert: [{ id: 'strata-gate', name: GATE_PLUGIN, config: { web: { provider: SEARCH_NAMES[keyed ?? 'builtin'], skipAsk: tools.webWithoutAsking === true } } }] }] : []),
|
||||
// Compaction decides from what the model server measured, and its summary requests always fit the window (plugins/strata-context.mjs).
|
||||
{ insert: [{ id: 'strata-context', name: CONTEXT_PLUGIN, config: {} }] },
|
||||
// Helper agents on a model the person names: every model the app can reach is allowed, and the plugin makes the choice forgiving (plugins/strata-models.mjs).
|
||||
...(subagentRoutes.length ? [{ id: 'subagent-model-selection-settings', config: { enabled: true, allowedModels: subagentRoutes.map(r => ({ provider: r.provider, model: r.model })) } }] : []),
|
||||
...(subagentRoutes.length ? [{ insert: [{ id: 'strata-models', name: MODELS_PLUGIN, config: { routes: subagentRoutes } }] }] : []),
|
||||
@@ -309,7 +345,7 @@ export function buildProfile({ local = [], connections = [], gate = true, tools
|
||||
// What the agent is offered (tool groups, web search, tool servers, the person's own tools) decides whether a running agent must be
|
||||
// restarted for the next message; the routes are compared separately (a changed context size alone does not restart it).
|
||||
const toolsSignature = createHash('sha256').update(JSON.stringify([mcp, ownRows, webRows, entries.find(e => e.id === 'preset-standard'), env.STRATA_SEARCH_KEY ?? '', mcpEnv])).digest('hex').slice(0, 16);
|
||||
return { patch, env, routes, defaultRoute, signature, toolsSignature, shapeTargets };
|
||||
return { patch, env, routes, defaultRoute, signature, toolsSignature, shapeTargets, mcpSkipped };
|
||||
}
|
||||
|
||||
/** Find the route for a chat's model choice. */
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { GROUP_IDS, TOOL_GROUPS } from './tools-catalog.mjs';
|
||||
import { SHELLS, clampTimeoutSec } from './custom-command-run.mjs';
|
||||
|
||||
export const SEARCH_PROVIDERS = ['builtin', 'exa', 'perplexity', 'deepseek'];
|
||||
const KINDS = ['instructions', 'command', 'server'];
|
||||
@@ -38,6 +39,8 @@ function normalize(raw) {
|
||||
id: String(t.id), name: String(t.name), about: clean(t.about, 300), kind: t.kind, enabled: t.enabled !== false,
|
||||
instructions: clean(t.instructions, 20000), command: clean(t.command, 2000), address: clean(t.address, 2000),
|
||||
args: (Array.isArray(t.args) ? t.args : []).slice(0, 50).map(a => clean(a, 1000)),
|
||||
// a command tool's folder, shell and time allowed (custom-command-run.mjs); '' / 'auto' / 120 when not set
|
||||
cwd: clean(t.cwd, 1000).trim(), shell: SHELLS.includes(t.shell) ? t.shell : 'auto', timeoutSec: clampTimeoutSec(t.timeoutSec),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -49,7 +52,7 @@ function validateCustom(body, existing) {
|
||||
if (!about) throw new Error('Say what the tool does, in a sentence. The assistant reads this to decide when to use it.');
|
||||
const kind = body?.kind;
|
||||
if (!KINDS.includes(kind)) throw new Error('Choose how the tool works.');
|
||||
const out = { name, about, kind, instructions: '', command: '', address: '', args: [] };
|
||||
const out = { name, about, kind, instructions: '', command: '', address: '', args: [], cwd: '', shell: 'auto', timeoutSec: clampTimeoutSec(undefined) };
|
||||
if (kind === 'instructions') {
|
||||
out.instructions = clean(body.instructions, 20000).trim();
|
||||
if (!out.instructions) throw new Error('Write the instructions the assistant should follow when it uses this tool.');
|
||||
@@ -57,6 +60,14 @@ function validateCustom(body, existing) {
|
||||
out.command = clean(body.command, 2000).trim();
|
||||
if (!out.command) throw new Error('Enter the command to run.');
|
||||
if (/[\r\n]/.test(out.command)) throw new Error('Put the command on one line.');
|
||||
// The edit form of Settings does not send these three: an edit keeps what the tool had.
|
||||
const before = existing.find(t => t.id === body.id && t.kind === 'command');
|
||||
out.cwd = body.cwd !== undefined ? clean(body.cwd, 1000).trim() : before?.cwd ?? '';
|
||||
if (/[\r\n]/.test(out.cwd)) throw new Error('Put the folder on one line.');
|
||||
if (body.shell !== undefined && !SHELLS.includes(body.shell)) throw new Error('Choose how the command runs: automatic, cmd, PowerShell, bash or sh.');
|
||||
out.shell = body.shell !== undefined ? body.shell : before?.shell ?? 'auto';
|
||||
if (body.timeoutSec !== undefined && !(Number(body.timeoutSec) >= 1 && Number(body.timeoutSec) <= 3600)) throw new Error('Give the time allowed in seconds, from 1 to 3600.');
|
||||
out.timeoutSec = body.timeoutSec !== undefined ? clampTimeoutSec(body.timeoutSec) : before?.timeoutSec ?? clampTimeoutSec(undefined);
|
||||
} else {
|
||||
out.address = clean(body.address, 2000).trim();
|
||||
if (!out.address) throw new Error('Enter the tool server’s web address, or the program that starts it.');
|
||||
@@ -91,7 +102,7 @@ export function createAgentTools({ read, write, dataDir, browser = () => ({ foun
|
||||
for (const t of s.custom.filter(t => t.enabled)) {
|
||||
const key = t.id;
|
||||
if (t.kind === 'instructions') instructions[key] = { description: t.about, text: t.instructions };
|
||||
else if (t.kind === 'command') commands[key] = { description: t.about, command: t.command };
|
||||
else if (t.kind === 'command') commands[key] = { description: t.about, command: t.command, cwd: t.cwd, shell: t.shell, timeoutSec: t.timeoutSec };
|
||||
}
|
||||
mkdirSync(join(dataDir, 'agent'), { recursive: true });
|
||||
const tmp = `${filePath}.tmp`;
|
||||
@@ -115,6 +126,7 @@ export function createAgentTools({ read, write, dataDir, browser = () => ({ foun
|
||||
id: t.id, name: t.name, about: t.about, kind: t.kind, enabled: t.enabled,
|
||||
summary: t.kind === 'instructions' ? t.instructions.slice(0, 80) : t.kind === 'command' ? t.command : t.address,
|
||||
instructions: t.instructions, command: t.command, address: t.address, args: t.args,
|
||||
...(t.kind === 'command' ? { cwd: t.cwd, shell: t.shell, timeoutSec: t.timeoutSec } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,13 +67,29 @@ export function nvidiaDriverVersion() {
|
||||
/** Where the builds are published; the wrong-make sentence points here. */
|
||||
export const RELEASES_URL = 'https://github.com/Niko1221/Strata/releases';
|
||||
|
||||
/** The PCI vendor ids (`10de`, `1002`, `8086`) of the Windows display adapters; empty off Windows or when the registry cannot be read. */
|
||||
export function windowsDisplayVendors({ platform = process.platform, run = execFileSync } = {}) {
|
||||
const found = new Set();
|
||||
if (platform !== 'win32') return found;
|
||||
try {
|
||||
const out = run('reg', ['query', 'HKLM\\SYSTEM\\CurrentControlSet\\Control\\Class\\{4d36e968-e325-11ce-bfc1-08002be10318}', '/s', '/v', 'MatchingDeviceId'], { encoding: 'utf8', timeout: 8000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
for (const match of String(out).matchAll(/VEN_([0-9A-F]{4})/gi)) found.add(match[1].toLowerCase());
|
||||
} catch { /* no registry access: the tools decide */ }
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* The engine family this PC's graphics card needs ('cuda', 'hip', 'sycl'), or null when it cannot be told. The same order as Control
|
||||
* (NVIDIA first, then AMD, then Intel); STRATA_ENGINE_BACKEND wins. Reads only the driver and (Linux) the PCI vendor of the display adapters.
|
||||
*/
|
||||
export function gpuBackendHere(env = process.env) {
|
||||
export function gpuBackendHere(env = process.env, { vendors = windowsDisplayVendors() } = {}) {
|
||||
const forced = String(env.STRATA_ENGINE_BACKEND ?? '').trim().toLowerCase();
|
||||
if (forced) return ['cuda', 'hip', 'sycl'].includes(forced) ? forced : null;
|
||||
// Windows: the display adapters are the machine's own statement of which cards it has; a leftover nvidia-smi from a card that was
|
||||
// taken out must not outvote them (#3688). Same order as Control: NVIDIA, then AMD, then Intel.
|
||||
if (vendors.size) {
|
||||
for (const [vendor, backend] of [['10de', 'cuda'], ['1002', 'hip'], ['8086', 'sycl']]) if (vendors.has(vendor)) return backend;
|
||||
}
|
||||
if (nvidiaDriverVersion()) return 'cuda';
|
||||
try {
|
||||
const base = '/sys/class/drm';
|
||||
|
||||
@@ -3,6 +3,8 @@ import { spawn } from 'node:child_process';
|
||||
import { probe, which } from './util.mjs';
|
||||
import { classifyRunning, describeConnector, otherTunnelMessage, ownTunnelMessage } from './cloudflared-find.mjs';
|
||||
|
||||
/** A tunnel without a key is allowed (owner decision, 0.2.2); because the link is public the warning is stronger than the local one. */
|
||||
export const NO_KEY_TUNNEL_WARNING = 'No API key is set, so anyone who finds this link can use your model from the internet. Create an API key to protect it.';
|
||||
export function tunnelHostname(value) {
|
||||
const hostname = String(value ?? '').trim().toLowerCase();
|
||||
if (hostname.length > 253 || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(hostname)) throw new Error('Enter a hostname such as ai.example.com, without https:// or a path.');
|
||||
@@ -13,7 +15,7 @@ export function createCloudflare({ origin, originUrl, hasKey, changed = () => {}
|
||||
let value = { status: 'idle', mode: null, url: null, error: null };
|
||||
const state = () => {
|
||||
const info = binary ? connectorInfo() : null;
|
||||
return { ...value, installed: !!binary, originUrl, connector: info ? { path: info.path, version: info.version, source: info.source, label: info.label, summary: describeConnector(info) } : null, external: other?.running ? externalState() : null };
|
||||
return { ...value, warning: value.status === 'connected' && !hasKey() ? NO_KEY_TUNNEL_WARNING : null, installed: !!binary, originUrl, connector: info ? { path: info.path, version: info.version, source: info.source, label: info.label, summary: describeConnector(info) } : null, external: other?.running ? externalState() : null };
|
||||
};
|
||||
const update = patch => { value = { ...value, ...patch }; changed(state()); };
|
||||
function externalState(token = '') {
|
||||
@@ -52,7 +54,6 @@ export function createCloudflare({ origin, originUrl, hasKey, changed = () => {}
|
||||
}
|
||||
async function start(body) {
|
||||
if (busy || ['starting', 'connected', 'stopping'].includes(value.status)) throw new Error('Stop the current tunnel before starting another.');
|
||||
if (!hasKey()) throw new Error('Create a Strata API key before connecting Cloudflare.');
|
||||
if (!['quick', 'domain'].includes(body.mode)) throw new Error('Choose a temporary URL or your own domain.');
|
||||
const hostname = body.mode === 'domain' ? tunnelHostname(body.hostname) : null;
|
||||
const token = body.mode === 'domain' ? String(body.token ?? '').trim() : '';
|
||||
|
||||
@@ -207,6 +207,59 @@ export function createControl({ control = findControl(), env = process.env, run
|
||||
if (result.json?.plan && Array.isArray(result.json.plan.issues)) return result.json.plan;
|
||||
throw failure(result, 'The files could not be checked.');
|
||||
},
|
||||
/** What picking `to` as the models folder means (sizes, free space, which choices are possible, why not): nothing is changed. */
|
||||
async planModelsMove(from, to) {
|
||||
const result = await call(['models', 'move', to, ...(from ? ['--from', from] : []), '--plan', '--output', 'json'], { timeoutMs: 120_000 });
|
||||
if (result.json?.plan) return result.json.plan;
|
||||
throw failure(result, 'The folder could not be checked.');
|
||||
},
|
||||
/**
|
||||
* Move the model files to `to` (copied, checked, then the old copies removed). `onEvent` gets the progress lines; a file at `cancelFile` stops it
|
||||
* safely. Resolves with the summary (done / stopped, what moved, what is left); a failure is a ControlError whose `output` carries the details.
|
||||
*/
|
||||
async moveModels(from, to, { installDir = null, cancelFile = null, onEvent = null } = {}) {
|
||||
const args = ['models', 'move', to, ...(from ? ['--from', from] : []), ...(installDir ? ['--install-dir', installDir] : []), ...(cancelFile ? ['--cancel-file', cancelFile] : []), '--output', 'jsonl'];
|
||||
const result = await call(args, { timeoutMs: 24 * 3600_000, onLine: onEvent ?? (() => {}) });
|
||||
if (result.json?.ok && result.json.event === 'result') return result.json;
|
||||
const error = result.json?.error;
|
||||
throw Object.assign(new ControlError(error?.message ?? (result.stderr.split(/\r?\n/).filter(Boolean).pop() || 'The models could not be moved.'), { exitCode: result.code, output: result.json }), { code: error?.code ?? 'move_failed', details: error?.details ?? '' });
|
||||
},
|
||||
/** A move of the models folder that the last run left unfinished (`strata models move --status`): null, or {state, from, to, bytes}. */
|
||||
async interruptedModelsMove(dataFolder) {
|
||||
const result = await call(['models', 'move', '--status', ...(dataFolder ? ['--from', dataFolder] : []), '--output', 'json'], { timeoutMs: 30_000 });
|
||||
if (result.json?.ok) return result.json.interrupted ?? null;
|
||||
throw failure(result, 'The models folder could not be checked.');
|
||||
},
|
||||
/** "Finish it": the same move again, from where it stopped. Same events and result as moveModels. */
|
||||
async resumeModelsMove(dataFolder, { installDir = null, cancelFile = null, onEvent = null } = {}) {
|
||||
const args = ['models', 'move', '--resume', ...(dataFolder ? ['--from', dataFolder] : []), ...(installDir ? ['--install-dir', installDir] : []), ...(cancelFile ? ['--cancel-file', cancelFile] : []), '--output', 'jsonl'];
|
||||
const result = await call(args, { timeoutMs: 24 * 3600_000, onLine: onEvent ?? (() => {}) });
|
||||
if (result.json?.ok && result.json.event === 'result') return result.json;
|
||||
const error = result.json?.error;
|
||||
throw Object.assign(new ControlError(error?.message ?? (result.stderr.split(/\r?\n/).filter(Boolean).pop() || 'The models could not be moved.'), { exitCode: result.code, output: result.json }), { code: error?.code ?? 'move_failed', details: error?.details ?? '' });
|
||||
},
|
||||
/** "Leave it": keep the old folder, remove the partial copies in the new one. */
|
||||
async leaveModelsMove(dataFolder, { installDir = null } = {}) {
|
||||
const result = await call(['models', 'move', '--leave', ...(dataFolder ? ['--from', dataFolder] : []), ...(installDir ? ['--install-dir', installDir] : []), '--output', 'json'], { timeoutMs: 24 * 3600_000 });
|
||||
if (result.json?.ok) return result.json;
|
||||
throw Object.assign(failure(result, 'The partial copies could not be removed.'), { code: result.json?.error?.code ?? 'leave_failed', details: result.json?.error?.details ?? '' });
|
||||
},
|
||||
/**
|
||||
* The DFlash2 drafter for a model's run config and the drafting choice (`strata drafting plan`): { needed, gb, line, note, drafter }. Control decides
|
||||
* (the table of strata_control/drafting.py); the app never reads it. Nothing is downloaded.
|
||||
*/
|
||||
async draftingPlan({ configPath = null, mode = null, family = null, dataDir = null } = {}) {
|
||||
const result = await call(['drafting', 'plan', ...(configPath ? ['--config', configPath] : []), ...(mode ? ['--mode', mode] : []), ...(family ? ['--family', family] : []), ...(dataDir ? ['--data-dir', dataDir] : []), '--output', 'json'], { timeoutMs: 30_000 });
|
||||
if (result.json?.ok) return result.json;
|
||||
throw failure(result, 'The drafter could not be checked.');
|
||||
},
|
||||
/** Fetch and pack the drafter when the choice would use it and it is not here (`strata drafting fetch --if-needed`); `onEvent` gets {step, done, total}. */
|
||||
async draftingFetch({ configPath, mode = null, onEvent = null, signal = null } = {}) {
|
||||
const args = ['drafting', 'fetch', '--config', configPath, ...(mode ? ['--mode', mode] : []), '--if-needed', '--output', 'jsonl'];
|
||||
const result = await call(args, { timeoutMs: 3 * 3600_000, acquire: true, onLine: onEvent ?? (() => {}), signal });
|
||||
if (result.json?.ok) return result.json;
|
||||
throw Object.assign(new ControlError(result.json?.error?.message ?? (result.stderr.split(/\r?\n/).filter(Boolean).pop() || 'The DFlash2 drafter could not be downloaded.'), { exitCode: result.code, output: result.json }), { code: 'drafting_failed' });
|
||||
},
|
||||
async saveProfile(name, configPath) {
|
||||
const listed = await call(['profile', 'list', '--output', 'json'], { timeoutMs: 20_000 });
|
||||
const known = (listed.json?.profiles ?? []).some(p => p.id === name);
|
||||
|
||||
@@ -182,13 +182,16 @@ export function chooserUnavailable(picked) {
|
||||
return { message: picked.reason, code: 'folder-chooser-unavailable', details: picked.detail };
|
||||
}
|
||||
|
||||
/** A typed path stands in for the dialog: it must be an absolute directory that exists. */
|
||||
export async function typedFolder(value, { isAbsolute }) {
|
||||
const text = typeof value === 'string' ? value.trim() : '';
|
||||
/** A typed path stands in for the dialog: it must be an absolute directory that exists. `fileType` (a RegExp on the name) also lets a
|
||||
* file of that kind through (the model page takes a folder or one .gguf file). Quotes around the path (Windows "Copy as path") go. */
|
||||
export async function typedFolder(value, { isAbsolute, fileType = null }) {
|
||||
let text = typeof value === 'string' ? value.trim() : '';
|
||||
if (text.length > 1 && /^(["'])[\s\S]*\1$/.test(text)) text = text.slice(1, -1).trim();
|
||||
if (!text || text.includes('\0')) return null;
|
||||
if (!isAbsolute(text)) throw new Error('Enter the full path of the folder.');
|
||||
if (!isAbsolute(text)) throw new Error(fileType ? 'Enter the full path of the folder or the file.' : 'Enter the full path of the folder.');
|
||||
let info;
|
||||
try { info = await stat(text); } catch { throw new Error('That folder does not exist.'); }
|
||||
if (!info.isDirectory()) throw new Error('Choose a folder, not a file.');
|
||||
try { info = await stat(text); } catch { throw new Error(fileType ? 'That folder or file does not exist.' : 'That folder does not exist.'); }
|
||||
if (info.isFile() && fileType?.test(text)) return text;
|
||||
if (!info.isDirectory()) throw new Error(fileType ? 'Choose a folder, or a .gguf file.' : 'Choose a folder, not a file.');
|
||||
return cleanChosenPath(text);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* The host's copy of the roles rules it needs to WRITE a model's settings (src/lib/gpu-roles.ts has the screen's, strata_control/gpu_roles.py the
|
||||
* Terminal's and the server's; tests/data/gpu_roles_vectors.json is run against all three).
|
||||
* Roles are { [card number]: 'main' | 'helper' | 'off' }. The engine takes the cards in stage order: Helpers, then the Main card LAST.
|
||||
*/
|
||||
export const ROLES = ['main', 'helper', 'off'];
|
||||
const mem = c => Number(c.vram_gb) || 0;
|
||||
const speed = c => (typeof c.speed === 'number' && c.speed > 0 ? c.speed : 0);
|
||||
const nums = roles => Object.keys(roles).map(Number).sort((a, b) => a - b);
|
||||
const onList = roles => nums(roles).filter(i => roles[i] !== 'off');
|
||||
const shortName = c => { let n = String(c.name || `GPU ${c.index + 1}`); for (const j of ['NVIDIA GeForce ', 'NVIDIA ', 'GeForce ', 'AMD Radeon ', 'AMD ', 'Intel(R) ', 'Intel ']) if (n.startsWith(j)) { n = n.slice(j.length); break; } return n.trim(); };
|
||||
|
||||
export function parseSaved(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
if (!/^\d+$/.test(k.trim()) || !ROLES.includes(String(v).toLowerCase())) return null;
|
||||
out[Number(k)] = String(v).toLowerCase();
|
||||
}
|
||||
if (!Object.keys(out).length || Object.values(out).filter(r => r === 'main').length > 1) return null;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalize(roles, cards) {
|
||||
const cs = new Map(cards.map(c => [c.index, c]));
|
||||
const out = {};
|
||||
for (const i of nums(roles)) if (cs.has(i)) out[i] = ROLES.includes(roles[i]) ? roles[i] : 'off';
|
||||
const list = onList(out), mains = list.filter(i => out[i] === 'main');
|
||||
if (list.length && !mains.length) {
|
||||
const best = [...list].sort((a, b) => speed(cs.get(b)) - speed(cs.get(a)) || Math.round(mem(cs.get(b))) - Math.round(mem(cs.get(a))) || a - b)[0];
|
||||
out[best] = 'main';
|
||||
}
|
||||
for (const extra of mains.slice(1)) out[extra] = 'helper';
|
||||
if (list.length === 1) out[list[0]] = 'main';
|
||||
return out;
|
||||
}
|
||||
|
||||
export function order(roles, cards) {
|
||||
const cs = new Map(cards.map(c => [c.index, c]));
|
||||
const r = normalize(roles, cards);
|
||||
const helpers = nums(r).filter(i => r[i] === 'helper').sort((a, b) => speed(cs.get(a)) - speed(cs.get(b)) || a - b);
|
||||
return [...helpers, ...nums(r).filter(i => r[i] === 'main')];
|
||||
}
|
||||
|
||||
/** "gpu_roles" plus the legacy mirror ("gpu" Main last, "gpu_order": "as_given"); nothing on: "gpu": [] and "gpu_none": true. */
|
||||
export function mirror(roles, cards) {
|
||||
const r = normalize(roles, cards), lst = order(r, cards);
|
||||
const out = { gpu_roles: Object.fromEntries(nums(r).map(i => [String(i), r[i]])), gpu: [], gpu_cards: {} };
|
||||
if (lst.length) { out.gpu = lst.length === 1 ? lst[0] : lst; out.gpu_order = 'as_given'; } else out.gpu_none = true;
|
||||
for (const c of cards) if (c.index in r) out.gpu_cards[String(c.index)] = [shortName(c), c.bus ?? ''].filter(Boolean).join('|');
|
||||
return out;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import { engineProgressMark } from './agent/engine-progress.mjs';
|
||||
import './system-ca.mjs'; // #3722: the operating system's certificates, before any HTTPS request
|
||||
import { createCloudflare } from './cloudflare.mjs';
|
||||
import { createConnectorInstaller } from './cloudflared-install.mjs';
|
||||
import { findRunningConnector } from './cloudflared-find.mjs';
|
||||
@@ -15,6 +17,7 @@ import { materializeSkills, parseSkillFile, skillSlug, writeCreatedSkill } from
|
||||
import { systemFonts } from './fonts.mjs';
|
||||
import { tuningDir } from './platform-paths.mjs';
|
||||
import { createAgentBridge, plainTurnFailure } from './agent/agent-bridge.mjs';
|
||||
import { mcpSkippedNotices } from './agent/profile.mjs';
|
||||
import { createAgentTools } from './agent/tools-settings.mjs';
|
||||
import { withPlainStatus } from './agent/status.mjs';
|
||||
import { findBrowser, findPlaywrightMcp } from './agent/browser-probe.mjs';
|
||||
@@ -55,6 +58,7 @@ import { RequestQueue } from './request-queue.mjs';
|
||||
import { selectSkills, skillInstructions } from './skill-selection.mjs';
|
||||
import { createModelProviders } from './model-providers.mjs';
|
||||
import { createForkTimeskip } from './timeskip-fork.mjs';
|
||||
import { prepareOversized } from './oversize.mjs';
|
||||
import { createChatFolders, chatRootOf, openFile, resolveChatFile, previewFile, rawFile, createAttachments, withAttachments } from './chat-files.mjs';
|
||||
import { createLlmProxy, samplingFromExpert } from './agent/llm-shape.mjs';
|
||||
import { checkUpdates, checkComponentUpdates } from './updates.mjs';
|
||||
@@ -72,6 +76,7 @@ import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { nativeGateway } from './native-gateway.mjs';
|
||||
import { proxyCompletion } from './inference.mjs';
|
||||
import { apiPath } from './api-paths.mjs';
|
||||
import { findFreePort, listenProblem } from './ports.mjs';
|
||||
import { validateChatImages } from './chat-images.mjs';
|
||||
import { createTimeSkip, measureFolder, timeskipRevisionMessage, imageTypeOf } from './timeskip.mjs';
|
||||
|
||||
@@ -173,12 +178,15 @@ const agent = createAgentBridge({
|
||||
// The person's Response settings (thinking budget, Expert sampling) reach the agent's model through this host's own forwarding endpoint.
|
||||
proxyBase: `http://127.0.0.1:${PORT}/api/internal/agent-llm`,
|
||||
expertSampling: () => samplingFromExpert(store.get().expert),
|
||||
// How far a local engine has got (the 2 s /metrics poll), for the agent's stall watchdog: it moves while a long prompt is read.
|
||||
engineProgress: instanceId => engineProgressMark(native.readyInstances().find(i => i.id === instanceId)),
|
||||
onPlan: (chatId, event, plan) => hub.send('plan', { chatId, event, plan }),
|
||||
onQueue: (chatId, queue) => hub.send('queue', { chatId, queue }),
|
||||
dataDir: store.DATA_DIR,
|
||||
componentsDir: componentsDirectory(),
|
||||
// The installer ships the Harness next to the host (<install>/host/server -> <install>/harness).
|
||||
bundledDir: resolve(HERE, '..', '..', 'harness'),
|
||||
defaultLocalId: () => native.defaultInstanceId(),
|
||||
localInstances: () => native.readyInstances().map(i => ({
|
||||
id: i.id, label: i.label, baseUrl: `http://127.0.0.1:${i.port}/v1`, modelId: i.apiModelId,
|
||||
context: i.metrics?.engine?.max_context ?? i.context ?? undefined, key: i.key, vision: i.vision,
|
||||
@@ -269,7 +277,7 @@ function setupState() {
|
||||
flowVersion: 2,
|
||||
completedAt: s.setup.completedAt,
|
||||
history: s.setup.history ?? [],
|
||||
app: { version: appVersion(), modelsDir: store.MODELS_DIR, dataDir: store.DATA_DIR },
|
||||
app: { version: appVersion(), modelsDir: store.MODELS_DIR, dataDir: store.DATA_DIR, modelsDirNotice: store.MODELS_DIR_NOTICE },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -600,8 +608,11 @@ async function runAgentTurn({ res, controller, chatId, body, message, workspaceI
|
||||
const goalLine = /^\/goal(?:\s+([\s\S]*))?$/i.exec(message.trim());
|
||||
const planLine = /^\/plan(?:\s+([\s\S]*))?$/i.exec(message.trim());
|
||||
const researchLine = /^\/research(?:\s+([\s\S]*))?$/i.exec(message.trim());
|
||||
// "/compact" summarises the older messages of this chat now (it used to go to the model as text, which answered it as a question).
|
||||
const compactLine = /^\/compact\s*$/i.test(message.trim());
|
||||
const turnOptions = { chatId, message, workspacePath: cwd, choice, images, mode: typeof body.mode === 'string' ? body.mode : null, thinking: typeof body.thinking === 'string' ? body.thinking : null, thinkingBudget: Number.isFinite(body.thinkingBudget) ? body.thinkingBudget : 0, history: Array.isArray(body.history) ? body.history : [], onFrame: write, signal: controller.signal };
|
||||
const result = typeof body.planId === 'string' && body.message === '/plan start' ? await agent.planGoal({ ...turnOptions, planId: body.planId })
|
||||
: compactLine ? await agent.compactCommand(turnOptions)
|
||||
: researchLine ? await agent.researchCommand({ ...turnOptions, line: researchLine[1] ?? '' })
|
||||
: planLine ? await agent.planCommand({ ...turnOptions, line: planLine[1] ?? '' })
|
||||
: goalLine ? await agent.goalCommand({ ...turnOptions, line: goalLine[1] ?? '' }) : await agent.runTurn(turnOptions);
|
||||
@@ -709,6 +720,7 @@ async function runTurn({ message, history = [], onFrame, signal, client, workspa
|
||||
durationMs: Date.now() - started,
|
||||
ttftMs: firstTokenMs ?? undefined,
|
||||
cachedTokens: result.cachedTokens,
|
||||
tps: result.tps,
|
||||
usageSource: result.usageSource,
|
||||
});
|
||||
if(models.REAL){monitor.setRecordedContext({ ...result, requestId: entry.id, sessionId });monitor.idle();}
|
||||
@@ -908,8 +920,10 @@ const tasks = createTaskService({
|
||||
const taskTimer = setInterval(() => tasks.tick(), 15000);
|
||||
taskTimer.unref();
|
||||
|
||||
const localShare = createLocalShare({ handle: openai, hasKey: () => !!store.get().serving?.apiKey, port: Number(process.env.STRATA_SHARE_PORT ?? PORT + 1), changed: () => serving.setSharing(localShare.state()) });
|
||||
const tunnelPort = Number(process.env.STRATA_TUNNEL_PORT ?? PORT + 2);
|
||||
// The share and tunnel listeners take a free port (the next ones above the host's own) instead of a fixed PORT+1 / PORT+2 that Docker, WSL or Hyper-V may hold or reserve.
|
||||
const sharePort = await findFreePort(Number(process.env.STRATA_SHARE_PORT ?? PORT + 1), '0.0.0.0', { skip: [PORT] });
|
||||
const localShare = createLocalShare({ handle: openai, hasKey: () => !!store.get().serving?.apiKey, port: sharePort, changed: () => serving.setSharing(localShare.state()) });
|
||||
const tunnelPort = await findFreePort(Number(process.env.STRATA_TUNNEL_PORT ?? PORT + 2), '127.0.0.1', { skip: [PORT, sharePort] });
|
||||
const tunnelOrigin = createLocalShare({ handle: openai, hasKey: () => !!store.get().serving?.apiKey, host: '127.0.0.1', port: tunnelPort });
|
||||
const connector = createConnectorInstaller({ directory: join(store.HOME, 'engine', 'cloudflare'), componentDirectory: join(componentsDirectory(), 'cloudflared') });
|
||||
const cloudflare = createCloudflare({ origin: tunnelOrigin, originUrl: `http://127.0.0.1:${tunnelPort}`, hasKey: () => !!store.get().serving?.apiKey, changed: serving.setTunnel, locate: connector.locate, prepareConnector: connector.ensure, connectorInfo: connector.connector, findRunning: findRunningConnector });
|
||||
@@ -962,6 +976,17 @@ async function api(req, res, path, method, url) {
|
||||
if(path==='/api/native'&&method==='GET')return json(res,200,await native.refresh(url.searchParams.get('refresh')==='1'));
|
||||
if(path==='/api/native'&&method==='PATCH')return json(res,200,await native.configure(await readBody(req)));
|
||||
if(path==='/api/native/directory'&&method==='POST')return json(res,200,native.setDownloadDirectory((await readBody(req)).directory));
|
||||
/* Settings > Storage > Models folder: pick (or type) a folder, say what it means, then start the choice. Copying is Control's (strata models move). */
|
||||
if(path==='/api/native/models-folder/plan'&&method==='POST'){
|
||||
const typed=await typedFolder((await readBody(req)).path,{isAbsolute});
|
||||
const picked=typed?{path:typed}:await chooseFolder('Choose a folder for your models');
|
||||
if(picked.unavailable){const u=chooserUnavailable(picked);return error(res,501,u.message,{code:u.code});}
|
||||
if(picked.cancelled)return json(res,200,{cancelled:true});
|
||||
return json(res,200,{plan:await native.planModelsFolder(picked.path)});}
|
||||
if(path==='/api/native/models-folder/start'&&method==='POST'){const body=await readBody(req);const result=await native.startModelsFolder({folder:body.path,choice:body.choice,stopRunning:body.stopRunning===true});return json(res,result.id?202:200,result);}
|
||||
if(path==='/api/native/models-folder/interrupted'&&method==='GET')return json(res,200,{interrupted:await native.interruptedMove()});
|
||||
if(path==='/api/native/models-folder/resume'&&method==='POST'){const body=await readBody(req);return json(res,202,await native.finishInterruptedMove({stopRunning:body.stopRunning===true}));}
|
||||
if(path==='/api/native/models-folder/leave'&&method==='POST')return json(res,200,await native.leaveInterruptedMove());
|
||||
if(path==='/api/native/plan'&&method==='POST')return json(res,200,await native.plan(await readBody(req)));
|
||||
if(path==='/api/native/operations'&&method==='POST'){if(storageChanging)return error(res,409,'Wait for the model folder check to finish.');const body=await readBody(req);return json(res,202,native.operation(body.kind,body.args));}
|
||||
if(path==='/api/native/logs'&&method==='GET')return json(res,200,{logs:native.logs()});
|
||||
@@ -979,7 +1004,7 @@ async function api(req, res, path, method, url) {
|
||||
return json(res,200,{path:file});}
|
||||
if(path==='/api/native/local/preview'&&method==='POST'){
|
||||
/* "Use a local GGUF": pick (or type) the folder, and Control says what is in it. Nothing is copied or changed. */
|
||||
const typed=await typedFolder((await readBody(req)).path,{isAbsolute});
|
||||
const typed=await typedFolder((await readBody(req)).path,{isAbsolute,fileType:/\.gguf$/i});
|
||||
const picked=typed?{path:typed}:await chooseFolder('Choose the folder that holds your GGUF files');
|
||||
if(picked.unavailable){const u=chooserUnavailable(picked);return error(res,501,u.message,{code:u.code});};
|
||||
if(picked.cancelled)return json(res,200,{cancelled:true});
|
||||
@@ -1006,7 +1031,7 @@ async function api(req, res, path, method, url) {
|
||||
if(nativeSettingsFile&&method==='POST')return json(res,200,await native.openSettingsFile(nativeSettingsFile[1]));
|
||||
|
||||
// Credentials are handled only on the host. Responses and events expose a key-set flag.
|
||||
return error(res,404,'Unknown native control action.');}catch(e){return error(res,/Finish|Unload|running|current operation/.test(e.message)?409:400,e.message);}}
|
||||
return error(res,404,'Unknown native control action.');}catch(e){return error(res,/Finish|Unload|running|current operation/.test(e.message)?409:400,e.message,{...(e.code&&typeof e.code==='string'?{code:e.code}:{}),...(e.details?{details:String(e.details)}:{})});}}
|
||||
|
||||
if (path === '/api/model-providers' && method === 'GET') return json(res, 200, withPictureAnswers(modelProviders.list()));
|
||||
if (path === '/api/model-providers/discover' && method === 'POST') {
|
||||
@@ -1702,6 +1727,16 @@ async function api(req, res, path, method, url) {
|
||||
try { const f = rawFile(chatRoot(url.searchParams.get('sessionId') ?? ''), url.searchParams.get('path')); res.writeHead(200, { 'content-type': f.mime, 'content-length': f.bytes.length, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' }); res.end(f.bytes); return undefined; }
|
||||
catch (e) { return error(res, e.code === 'outside' ? 403 : 400, e.message); }
|
||||
}
|
||||
/* A message too large for the model's window, made small enough to send: its text as a file in the chat's folder, or a shortened preview (oversize.mjs). */
|
||||
if (path === '/api/chat/oversize' && method === 'POST') {
|
||||
const body = await readBody(req, 12 * 1024 * 1024);
|
||||
const text = String(body.text ?? '');
|
||||
if (!text.trim()) return error(res, 400, 'text is required');
|
||||
let instance = null;
|
||||
try { instance = native.resolveInstance(body.nativeModelId ?? native.state().defaultId); } catch { /* no local model: the length alone decides */ }
|
||||
try { return json(res, 200, await prepareOversized({ mode: body.mode === 'file' ? 'file' : 'preview', text, root: chatRoot(String(body.sessionId ?? '')), instance })); }
|
||||
catch (e) { return error(res, 400, 'The message could not be prepared. Try a shorter message.'); }
|
||||
}
|
||||
/* "Attach as context": the file is named in the next message of the chat, so the assistant reads it again. */
|
||||
if (path === '/api/chat/attachments' && method === 'GET') return json(res, 200, { files: chatAttachments.list(url.searchParams.get('sessionId') ?? '') });
|
||||
if (path === '/api/chat/attachments' && method === 'POST') {
|
||||
@@ -1919,6 +1954,8 @@ async function api(req, res, path, method, url) {
|
||||
if (chatId && !agentSkip) {
|
||||
const capability = agent.status();
|
||||
if (capability.state !== 'unavailable') {
|
||||
const skipped = mcpSkippedNotices(capability.skippedServers);
|
||||
for (const notice of skipped) res.write(`${JSON.stringify({ type: 'notice', ...notice })}\n`);
|
||||
await runAgentTurn({ res, controller, chatId, body, message, workspaceId, images });
|
||||
res.end();
|
||||
return undefined;
|
||||
@@ -2161,6 +2198,12 @@ async function staticFile(req, res, path) {
|
||||
|
||||
/* ------------------------------------------------------------------ start --- */
|
||||
|
||||
server.on('error', error => {
|
||||
if (server.listening) { console.error(`[strata] ${error.message}`); return; }
|
||||
// a taken or Windows-reserved port: one plain sentence instead of a stack trace
|
||||
console.error(`[strata] ${listenProblem(error, PORT, HOST)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
server.listen(PORT, HOST, () => {
|
||||
if (process.send) process.send({ type: 'strata-ready', port: PORT });
|
||||
// Engines a lost owner left behind (an earlier crash, an older version): ended after a PID + start time + command-line check.
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
import http from 'node:http';
|
||||
import os from 'node:os';
|
||||
import { apiPath } from './api-paths.mjs';
|
||||
import { listenProblem } from './ports.mjs';
|
||||
/** Sharing without a key is allowed (owner decision, 0.2.2): the person is told once, in plain words, and the app keeps offering to make a key. */
|
||||
export const NO_KEY_WARNING = 'No API key is set, so any device on this network can use your model. Create an API key to protect it.';
|
||||
export function createLocalShare({ handle, hasKey, changed = () => {}, host = '0.0.0.0', port = 8792 }) {
|
||||
let listener = null, active = false;
|
||||
const address = () => Object.values(os.networkInterfaces()).flat().find(n => n?.family === 'IPv4' && !n.internal)?.address ?? '127.0.0.1';
|
||||
const state = () => ({ active, url: active ? `http://${host === '127.0.0.1' ? host : address()}:${listener.address().port}/v1` : null });
|
||||
const state = () => ({ active, url: active ? `http://${host === '127.0.0.1' ? host : address()}:${listener.address().port}/v1` : null, warning: active && !hasKey() ? NO_KEY_WARNING : null });
|
||||
async function start() {
|
||||
if (active) return state();
|
||||
if (listener) throw new Error('Sharing is already starting.');
|
||||
if (!hasKey()) throw new Error('Set an API key before sharing with other devices.');
|
||||
listener = http.createServer(async (req, res) => {
|
||||
const path = apiPath(new URL(req.url ?? '/', 'http://localhost').pathname);
|
||||
if (!['/v1/models','/v1/chat/completions','/v1/messages'].includes(path)) { res.writeHead(404); res.end('Not found'); return; }
|
||||
@@ -17,7 +19,7 @@ export function createLocalShare({ handle, hasKey, changed = () => {}, host = '0
|
||||
catch { if (!res.headersSent) res.writeHead(500); res.end('Request failed'); }
|
||||
});
|
||||
try { await new Promise((resolve,reject) => { listener.once('error',reject); listener.listen(port,host,resolve); }); }
|
||||
catch(e) { listener.close(); listener=null; throw e; }
|
||||
catch(e) { listener.close(); listener=null; throw new Error(listenProblem(e, port, host)); }
|
||||
active=true; changed(); return state();
|
||||
}
|
||||
async function stop() {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// The one rule for the name of a tool server (MCP), shared by the Settings editor (src/lib/mcp-names.ts keeps a copy; tests/mcp-rows.test.mjs
|
||||
// checks they agree), the save check (native-settings.mjs) and the agent profile (agent/profile.mjs).
|
||||
//
|
||||
// The model sees every tool as `mcp__<server>__<tool>`, and an OpenAI-style API takes at most 64 characters for that name, so the server's
|
||||
// part is limited to 32 (T6, tools and images review 2026-10-22). Before, the editor and the save check accepted 64 and the profile quietly
|
||||
// dropped everything over 32 (or with a dot): the model never saw the tool and nothing said why.
|
||||
export const MCP_NAME_MAX = 32;
|
||||
export const MCP_NAME_RE = /^[A-Za-z0-9_-]{1,32}$/;
|
||||
export const validMcpName = name => typeof name === 'string' && MCP_NAME_RE.test(name);
|
||||
export const MCP_NAME_HELP = 'Use a simple server name of up to 32 characters: letters, numbers, hyphens or underscores.';
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Which files of a model are missing, in words a person can act on (#3478). Control's model list says which required parts of the run
|
||||
* config are absent (`missing`: part + file name), and the installer's status says which GGUF files and shards it could not find
|
||||
* (`missing_files`, full paths). Together they give the names; the app shows them instead of only "some files are missing".
|
||||
*/
|
||||
import { basename } from 'node:path';
|
||||
|
||||
const PART_WORDS = { engine: 'the engine program', 'tokenizer/vocab.json': 'the tokenizer', 'tokenizer/merges.txt': 'the tokenizer', 'tokenizer/token_type.json': 'the tokenizer' };
|
||||
|
||||
/** Short readable names, no duplicates, most useful first. */
|
||||
export function missingNames(controlParts, installerFiles) {
|
||||
const names = [];
|
||||
const add = text => { const t = String(text ?? '').trim(); if (t && !names.includes(t)) names.push(t); };
|
||||
for (const file of Array.isArray(installerFiles) ? installerFiles : []) add(basename(String(file).split(String.fromCharCode(92)).join('/')));
|
||||
for (const item of Array.isArray(controlParts) ? controlParts : []) {
|
||||
add(PART_WORDS[item?.part] ?? item?.file ?? item?.part);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** The error a load or a tune raises for a model that is not whole. Names are in the sentence (at most four) and all of them in `details`. */
|
||||
export function missingFilesError(model, names) {
|
||||
const list = names ?? [];
|
||||
const shown = list.slice(0, 4).join(', ') + (list.length > 4 ? ` and ${list.length - 4} more` : '');
|
||||
const error = new Error(list.length
|
||||
? `Some model files are missing: ${shown}. Use Download / repair to restore them.`
|
||||
: 'Some model files are missing. Use Download / repair to restore them.');
|
||||
error.details = [list.length ? `Missing for ${model}: ${list.join(', ')}` : `Missing for ${model}: Strata could not tell which files`];
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Where the person's models go: what a picked folder means (0.2.2).
|
||||
*
|
||||
* - A drive on its own (D:\ or /) is never filled with model files: it becomes D:\Strata (the data folder) and D:\Strata\Models (the
|
||||
* model library), which the person is told.
|
||||
* - The data folder is the person's own choice, so it is written into the settings file Strata Control reads (`data_dir`). Control's
|
||||
* installer accepts a data folder that is that setting, whatever the folder is called; for a folder it has not been told about it wants a
|
||||
* name starting with "Strata" (a rule against an AI tool filling an arbitrary folder, tools/strata_mcp.py check_data_dir).
|
||||
* - The link that makes `<data>/models` and `<data>/Models` one folder is only needed where the file system tells them apart; where it cannot be
|
||||
* made (exFAT, FAT, a network share) the models simply go in `<data>/models`.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join, parse, resolve } from 'node:path';
|
||||
|
||||
export const isDriveRoot = path => { const p = resolve(path); return p === parse(p).root; };
|
||||
|
||||
/** The data folder for a picked folder: a drive root gets a `Strata` folder of its own. `{ path, note }`. */
|
||||
export function dataFolderFromPick(picked) {
|
||||
const path = resolve(picked);
|
||||
if (!isDriveRoot(path)) return { path, note: null };
|
||||
const target = join(path, 'Strata');
|
||||
return { path: target, note: `${path} is a whole drive, so the models go in ${target}.` };
|
||||
}
|
||||
|
||||
/** The model library folder for a picked folder: a drive root gets `Strata/Models`. `{ path, note }`. */
|
||||
export function libraryFolderFromPick(picked) {
|
||||
const path = resolve(picked);
|
||||
if (!isDriveRoot(path)) return { path, note: null };
|
||||
const target = join(path, 'Strata', 'Models');
|
||||
return { path: target, note: `${path} is a whole drive, so the models go in ${target}.` };
|
||||
}
|
||||
|
||||
/** Make a folder the person asked for; a refusal of the disk is said in words. */
|
||||
export function makeFolder(path) {
|
||||
try { mkdirSync(path, { recursive: true }); }
|
||||
catch (error) { throw new Error(`Strata could not create ${path} (${error.code === 'EACCES' || error.code === 'EPERM' ? 'access was denied' : error.code === 'ENOSPC' ? 'the drive is full' : error.message}). Choose another folder.`); }
|
||||
return path;
|
||||
}
|
||||
|
||||
/** `models` beside `Models`: a link where the two are different names. A disk that cannot hold the link gets a plain `models` folder instead. */
|
||||
export function alignModels(data, library, { link = symlinkSync } = {}) {
|
||||
const models = join(data, 'models');
|
||||
if (existsSync(models)) return { data, linked: false };
|
||||
try { link(process.platform === 'win32' ? library : 'Models', models, process.platform === 'win32' ? 'junction' : 'dir'); return { data, linked: true }; }
|
||||
catch { /* exFAT, FAT, NFS, a share: no links there */ }
|
||||
try { mkdirSync(models, { recursive: true }); } catch { /* the install says what it cannot write */ }
|
||||
return { data, linked: false };
|
||||
}
|
||||
|
||||
|
||||
function readSettings(file) {
|
||||
try { const parsed = JSON.parse(readFileSync(file, 'utf8').replace(/^/, '')); return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; } catch { return {}; }
|
||||
}
|
||||
|
||||
/** The data folder a settings file names and when it was last changed (`data_dir_at`, seconds; the file's own time for a file that has none). */
|
||||
export function readDataFolder(file) {
|
||||
if (!file) return null;
|
||||
const current = readSettings(file);
|
||||
if (typeof current.data_dir !== 'string' || !current.data_dir) return null;
|
||||
let at = Number(current.data_dir_at);
|
||||
if (!Number.isFinite(at) || at <= 0) { try { at = statSync(file).mtimeMs / 1000; } catch { at = 0; } }
|
||||
return { folder: current.data_dir, at };
|
||||
}
|
||||
|
||||
/** The settings file the Terminal (command-line Strata) keeps: the same place setup.py and strata_control/discovery.py use. */
|
||||
export function terminalSettingsFile(env = process.env, platform = process.platform) {
|
||||
if (env.STRATA_TERMINAL_SETTINGS_FILE) return env.STRATA_TERMINAL_SETTINGS_FILE;
|
||||
if (platform === 'win32') return join(env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'Strata', 'settings.json');
|
||||
return join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'strata', 'settings.json');
|
||||
}
|
||||
|
||||
/** Write the person's data folder into a settings file (merged, atomically) with the time of the change; returns whether it changed. Never
|
||||
* throws: the folder still works for the app, only the Terminal and the installer's own check lose the shared setting. */
|
||||
export function rememberDataFolder(file, folder, at = Date.now() / 1000, touch = false) {
|
||||
if (!file || !folder) return false;
|
||||
try {
|
||||
const current = readSettings(file);
|
||||
if (current.data_dir === folder && !touch) return false;
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
const temp = `${file}.${process.pid}.tmp`;
|
||||
writeFileSync(temp, JSON.stringify({ ...current, data_dir: folder, data_dir_at: at }, null, 1) + '\n', { mode: 0o600 });
|
||||
renameSync(temp, file);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
const same = (a, b) => resolve(a).toLowerCase() === resolve(b).toLowerCase();
|
||||
|
||||
/**
|
||||
* One models folder for the app and the Terminal. The app keeps its choice in its own settings file (`appFile`), the Terminal in `terminalFile`;
|
||||
* whichever was changed last wins and the other file is brought up to date. Nothing is moved. A folder that is gone (a disk that is not
|
||||
* connected) is never adopted. `app` is the app's own answer when its file names none (`{ folder, at }`).
|
||||
* Returns `{ folder, from }`: the shared folder (null when neither names one) and `from` = 'terminal' when the app has to take over the
|
||||
* Terminal's folder, 'app' when the Terminal's file was updated, null when nothing changed.
|
||||
*/
|
||||
export function syncDataFolder({ appFile, terminalFile, app = null, exists = path => existsSync(path) }) {
|
||||
const mine = readDataFolder(appFile) ?? app;
|
||||
const theirs = readDataFolder(terminalFile);
|
||||
const theirsUsable = theirs && exists(theirs.folder);
|
||||
if (!theirsUsable) {
|
||||
if (mine && theirs && !same(mine.folder, theirs.folder)) { rememberDataFolder(terminalFile, mine.folder, mine.at); return { folder: mine.folder, from: 'app' }; }
|
||||
return { folder: mine?.folder ?? null, from: null };
|
||||
}
|
||||
if (!mine || (!same(mine.folder, theirs.folder) && theirs.at > mine.at)) {
|
||||
rememberDataFolder(appFile, theirs.folder, theirs.at);
|
||||
return { folder: theirs.folder, from: 'terminal' };
|
||||
}
|
||||
if (!same(mine.folder, theirs.folder)) { rememberDataFolder(terminalFile, mine.folder, mine.at); return { folder: mine.folder, from: 'app' }; }
|
||||
return { folder: mine.folder, from: null };
|
||||
}
|
||||
@@ -30,6 +30,16 @@ export const PROVIDER_CATALOG = [
|
||||
const PROTOCOLS = ['openai-chat', 'openai-responses', 'anthropic'];
|
||||
const fail = message => { throw new Error(message); };
|
||||
const text = (value, max = 160) => typeof value === 'string' ? value.trim().slice(0, max) : '';
|
||||
/** The answer speed of a model API's reply: the tokens it reports over the time from its first streamed token to the end, never over the whole
|
||||
* wait (that would put the time to the first token, often seconds of prompt reading, into the speed). null when it reported no tokens;
|
||||
* with no usable first-token time (or under a quarter of a second) the whole time is the best there is. */
|
||||
export function answerSpeed(tokensOut, durationMs, firstTokenMs) {
|
||||
if (!Number.isFinite(tokensOut) || tokensOut <= 0) return null;
|
||||
const decodeMs = Number.isFinite(firstTokenMs) ? durationMs - firstTokenMs : NaN;
|
||||
const ms = decodeMs >= 250 ? decodeMs : durationMs;
|
||||
return Number((tokensOut / Math.max(0.001, ms / 1000)).toFixed(1));
|
||||
}
|
||||
|
||||
export function normalizeBaseUrl(value) {
|
||||
let url; try { url = new URL(text(value, 2048)); } catch { fail('Enter a full http:// or https:// API address.'); }
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) fail('Use an HTTP or HTTPS base URL without credentials, a query, or a fragment.');
|
||||
@@ -207,7 +217,7 @@ export function createModelProviders({ directory, changed = () => {}, fetchImpl
|
||||
if (!textLength) fail('The model returned no text. Choose a model that supports text chat.');
|
||||
} finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
|
||||
const durationMs = Date.now() - started;
|
||||
const result = { tokensIn, tokensOut, usageSource: tokensIn !== null && tokensOut !== null ? 'reported' : 'unavailable', durationMs, ttftMs: firstToken, tps: tokensOut !== null ? tokensOut / Math.max(0.001, durationMs / 1000) : null };
|
||||
const result = { tokensIn, tokensOut, usageSource: tokensIn !== null && tokensOut !== null ? 'reported' : 'unavailable', durationMs, ttftMs: firstToken, tps: answerSpeed(tokensOut, durationMs, firstToken) };
|
||||
onFrame({ type: 'done', ...result }); return result;
|
||||
}
|
||||
/** Connections WITH their keys, for the host's own agent bridge only; never serialised to a client. */
|
||||
|
||||
@@ -347,12 +347,14 @@ export function idle(label = 'Waiting for a request') {
|
||||
hubRef?.send('monitor', snapshot());
|
||||
}
|
||||
|
||||
export function record({ tokensIn, tokensOut, durationMs = 0, ttftMs = null, cachedTokens = null }) {
|
||||
export function record({ tokensIn, tokensOut, durationMs = 0, ttftMs = null, cachedTokens = null, tps: answerTps = null }) {
|
||||
if (Number.isFinite(tokensIn)) state.totals.tokensIn += tokensIn;
|
||||
if (Number.isFinite(tokensOut)) state.totals.tokensOut += tokensOut;
|
||||
state.totals.requests += 1;
|
||||
if (Number.isFinite(tokensOut) && durationMs > 0) {
|
||||
const tps = tokensOut / (durationMs / 1000);
|
||||
// The average is of answer speeds (tokens over decode time). A request with no decode figure is left out: dividing by the whole request would
|
||||
// put the prompt reading into it and read lower than the engine and the chat do.
|
||||
if (Number.isFinite(answerTps) && answerTps > 0) {
|
||||
const tps = answerTps;
|
||||
const n = ++state.totals.usageRequests;
|
||||
state.totals.avgTps = round((state.totals.avgTps * (n - 1) + tps) / n, 1);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/** Native Strata control plane. All user actions are explicit; opening the UI only reads state. */
|
||||
import {missingFilesError,missingNames} from './missing-files.mjs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import {chooseParallel,applyParallel,withoutParallel,FALLBACK_NOTE} from './native-parallel.mjs';
|
||||
import { randomBytes, randomUUID, createHash } from 'node:crypto';
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, realpathSync, copyFileSync, renameSync, unlinkSync, chmodSync, statSync, accessSync, constants } from 'node:fs';
|
||||
import { join, resolve, dirname, basename } from 'node:path';
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, realpathSync, copyFileSync, renameSync, rmSync, statfsSync, unlinkSync, chmodSync, statSync, accessSync, constants } from 'node:fs';
|
||||
import { join, resolve, dirname, basename, sep, relative, isAbsolute } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createServer } from 'node:net';
|
||||
import { NATIVE_FIELDS, APP_OWNED_FIELDS, withoutDraftLayer, validateNativeSettings, validateMcpServers, publicMcpServers, publicNativeEnv, restoreNativeEnv } from './native-settings.mjs';
|
||||
@@ -14,12 +15,14 @@ import os from 'node:os';
|
||||
import { runtimeVision } from './native-vision.mjs';
|
||||
import { componentsDirectory, installerEnvironment, controlEnvironment } from './control-runtime.mjs';
|
||||
import { createEngineStep } from './engine-step.mjs';
|
||||
import { dataFolderFromPick, isDriveRoot, makeFolder, rememberDataFolder, syncDataFolder, terminalSettingsFile } from './model-folder.mjs';
|
||||
import { createAutoTune, writeTuneConfig } from './native-autotune.mjs';
|
||||
import { plainError, PHASE_TEXT } from './update-messages.mjs';
|
||||
import { checkRunConfig, describeIssues } from './run-config-check.mjs';
|
||||
import { openTextFile } from './open-text-file.mjs';
|
||||
import { markEngine, engineRefusal } from './engine-version.mjs';
|
||||
import { friendlyModelName } from './model-names.mjs';
|
||||
import { parseSaved as parseRoles, mirror as mirrorRoles } from './gpu-roles.mjs';
|
||||
|
||||
const ROOT=resolve(dirname(fileURLToPath(import.meta.url)),'..');
|
||||
const safeId=value=>typeof value==='string'&&/^[a-z0-9_-]{1,80}$/i.test(value);
|
||||
@@ -51,7 +54,11 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
|
||||
let defaultId=null,timer=null,closing=false,polling=false;
|
||||
const emit=()=>{const value=state();writeFileSync(join(directory,'native-jobs.json'),JSON.stringify(value.jobs),{mode:0o600});changed(value);};
|
||||
const append=(instanceId,line)=>{logs.push({at:new Date().toISOString(),instanceId,line:String(line).slice(0,2000)});if(logs.length>400)logs.shift();};
|
||||
function state(){return {...snapshot,downloadDirectory:preferences.downloadDirectory??(modelsDirectory?modelsDirectory():snapshot.installed?.data_folder),fields:nativeFields(),instances:[...instances.values()].map(({key,configPath,fingerprint,mcpServers,...i})=>structuredClone(i)),defaultId,jobs:[...jobs.values()].slice(-12).map(({process,abort,engineAbort,rawResult,...j})=>j),capabilities:{responses:sourceText().includes('/v1/responses'),updates:!!snapshot.available,rollback:previousEngine(),componentUpdates:packaged&&typeof control.update==='function',calibration:packaged?false:snapshot.hardware?.gpus?.some(g=>/nvidia/i.test(g.name??''))??false}};}
|
||||
function diskSpace(path){try{const s=statfsSync(path);return {free:Number(s.bavail)*Number(s.bsize),total:Number(s.blocks)*Number(s.bsize)};}catch{return null;}}
|
||||
/* Does the engine in this release serve DFlash2? Control knows (strata_control/drafting.py); asked once, false until it answers. The Models screen offers the choice only when true. */
|
||||
let dflash2Served=null;
|
||||
function probeDrafting(){if(!packaged||dflash2Served!==null||typeof control.draftingPlan!=='function')return;dflash2Served=false;control.draftingPlan({mode:'auto'}).then(p=>{dflash2Served=p.serves===true;emit();}).catch(()=>{});}
|
||||
function state(){const folder=preferences.downloadDirectory??(modelsDirectory?modelsDirectory():snapshot.installed?.data_folder),space=folder?diskSpace(folder):null;return {...snapshot,downloadDirectory:folder,downloadFreeBytes:space?.free??null,downloadTotalBytes:space?.total??null,fields:nativeFields(),instances:[...instances.values()].map(({key,configPath,fingerprint,mcpServers,...i})=>structuredClone(i)),defaultId,jobs:[...jobs.values()].slice(-12).map(({process,abort,engineAbort,rawResult,...j})=>j),capabilities:{responses:sourceText().includes('/v1/responses'),updates:!!snapshot.available,rollback:previousEngine(),componentUpdates:packaged&&typeof control.update==='function',calibration:packaged?false:snapshot.hardware?.gpus?.some(g=>/nvidia/i.test(g.name??''))??false,dflash2:dflash2Served===true}};}
|
||||
let sourceCache={path:'',time:0,text:''};
|
||||
function sourceText(){try{const path=join(packaged?control.control.dir:preferences.repo,'serve/server.py'),time=statSync(path).mtimeMs;if(sourceCache.path===path&&sourceCache.time===time)return sourceCache.text;sourceCache={path,time,text:readFileSync(path,'utf8')};return sourceCache.text;}catch{return '';}}
|
||||
function nativeFields(){const source=sourceText();return NATIVE_FIELDS.map(field=>({...field,supported:APP_OWNED_FIELDS.includes(field.id)||field.id.startsWith('vision_')||field.id.startsWith('mcp_')||field.id==='vram_reserve_mib'||source.includes(field.id)}));}
|
||||
@@ -70,8 +77,12 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
|
||||
for(const name of names){const path=join(dir,name),config=readJson(path,null);if(!config||typeof config!=='object')continue;
|
||||
const next={...config,...(config.cwd!==undefined?{cwd:fix(config.cwd)}:{}),...(Array.isArray(config.args)?{args:config.args.map(fix)}:{})};
|
||||
if(JSON.stringify(next)!==JSON.stringify(config)){try{writeModelConfig(path,next);}catch{/* read-only folder: the load reports it */}}}}
|
||||
function installedInfo(info){if(!info)return info;return {...info,models:(info.models??[]).map(entry=>{const cfg=/^strata-[a-z0-9_-]+\.json$/i.test(entry.config??'')?readJson(entry.configPath??join(repo(),entry.config)):{};const at=cfg.args?.indexOf('--native')??-1,nativeFile=at>=0?cfg.args[at+1]:null;const dataFolder=typeof nativeFile==='string'&&basename(dirname(dirname(nativeFile)))==='models'?dirname(dirname(dirname(nativeFile))):info.data_folder;/* A model the person pointed at their own GGUF folder (not <data>/models/<tag>): remembered, so a later load runs setup with that folder again instead of downloading the files (#3199). */const ownGgufDir=typeof nativeFile==='string'&&basename(dirname(dirname(nativeFile)))!=='models'&&existsSync(nativeFile)?dirname(nativeFile):undefined;return {...entry,ownGgufDir,diskGb:entry.diskGb??entry.disk_gb,diskBytes:entry.diskBytes??entry.disk_bytes,sharedBytes:entry.sharedBytes??entry.shared_bytes,removableGb:entry.removableGb??entry.removable_gb,dataFolder,visionPrepared:!!cfg.vision,/* The picture files are prepared when the run config has a vision section: the Models screen then loads straight away instead of running setup again (it used to read 'off' for every model, which sent every load of a picture-capable model through "Apply settings and load", and setup writes the arguments, the draft layer and the reader back each time). */images:cfg.vision?'yes':'off',draftLayer:cfg.mtp!==false,/* Picture reading is ON unless the person turned it off: no saved choice and not loaded is undefined, not false (owner rule: pictures on by default). */visionEnabled:cfg.ui_vision_enabled??(()=>{const running=[...instances.values()].find(i=>i.model===entry.model&&i.status==='ready');return running?!!running.vision:undefined;})(),visionDevice:cfg.ui_vision_device,resourceProfile:cfg.ui_resource_profile,lowRamMode:cfg.args?.includes('--resident-experts')?'resident':cfg.args?.includes('--mmap-experts')?'mmap':'auto'};})};}
|
||||
function installedInfo(info){if(!info)return info;return {...info,models:(info.models??[]).map(entry=>{const cfg=/^strata-[a-z0-9_-]+\.json$/i.test(entry.config??'')?readJson(entry.configPath??join(repo(),entry.config)):{};const at=cfg.args?.indexOf('--native')??-1,nativeFile=at>=0?cfg.args[at+1]:null;const dataFolder=typeof nativeFile==='string'&&basename(dirname(dirname(nativeFile)))==='models'?dirname(dirname(dirname(nativeFile))):info.data_folder;/* A model the person pointed at their own GGUF folder (not <data>/models/<tag>): remembered, so a later load runs setup with that folder again instead of downloading the files (#3199). */const ownGgufDir=typeof nativeFile==='string'&&basename(dirname(dirname(nativeFile)))!=='models'&&existsSync(nativeFile)?dirname(nativeFile):undefined;return {...entry,ownGgufDir,diskGb:entry.diskGb??entry.disk_gb,diskBytes:entry.diskBytes??entry.disk_bytes,sharedBytes:entry.sharedBytes??entry.shared_bytes,removableGb:entry.removableGb??entry.removable_gb,dataFolder,visionPrepared:!!cfg.vision,/* The picture files are prepared when the run config has a vision section: the Models screen then loads straight away instead of running setup again (it used to read 'off' for every model, which sent every load of a picture-capable model through "Apply settings and load", and setup writes the arguments, the draft layer and the reader back each time). */images:cfg.vision?'yes':'off',draftLayer:cfg.mtp!==false,drafting:cfg.drafting??'auto',/* Picture reading is ON unless the person turned it off: no saved choice and not loaded is undefined, not false (owner rule: pictures on by default). */visionEnabled:cfg.ui_vision_enabled??(()=>{const running=[...instances.values()].find(i=>i.model===entry.model&&i.status==='ready');return running?!!running.vision:undefined;})(),visionDevice:cfg.ui_vision_device,resourceProfile:cfg.ui_resource_profile,lowRamMode:cfg.args?.includes('--resident-experts')?'resident':cfg.args?.includes('--mmap-experts')?'mmap':'auto'};})};}
|
||||
function installRoot(){const found=[preferences.installDir].find(d=>d&&existsSync(d));if(found)return realpathSync(found);mkdirSync(defaultInstall(),{recursive:true});return realpathSync(defaultInstall());}
|
||||
/* The settings file Strata Control and the installer read (control-runtime.mjs sets STRATA_SETTINGS_FILE to it): the app's data folder is written there. */
|
||||
function controlSettingsFile(){return process.env.STRATA_SETTINGS_FILE||join(dirname(componentsDirectory()),'app-settings.json');}
|
||||
/* One models folder for the app and the Terminal: the one changed last wins and the other settings file is updated (nothing is moved). */
|
||||
function shareDataFolder(){if(!packaged)return;try{let at=0;if(preferences.downloadDirectory)try{at=statSync(settingsPath).mtimeMs/1000;}catch{/* no file yet */}const found=syncDataFolder({appFile:controlSettingsFile(),terminalFile:terminalSettingsFile(),app:preferences.downloadDirectory?{folder:preferences.downloadDirectory,at}:null});if(found.from==='terminal'&&found.folder&&found.folder!==preferences.downloadDirectory){preferences={...preferences,downloadDirectory:found.folder};writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});}}catch{/* the shared setting is a convenience: never stop the app */}}
|
||||
function repo(){if(packaged)return installRoot();const path=realpathSync(preferences.repo);if(!existsSync(join(path,'setup.py'))||!existsSync(join(path,'tools/strata_mcp.py'))||!existsSync(join(path,'serve/server.py')))throw new Error('Choose the Strata repository folder containing setup.py and tools/strata_mcp.py.');return path;}
|
||||
function python(){return resolvePython({repo:preferences.repo});}
|
||||
function send(method,params,timeout=30000){return new Promise((resolve,reject)=>{const id=++seq;const alarm=setTimeout(()=>{pending.delete(id);reject(new Error('The native engine did not respond in time. Check engine logs.'));},timeout);pending.set(id,{resolve,reject,alarm});child.stdin.write(JSON.stringify({jsonrpc:'2.0',id,method,params})+'\n');});}
|
||||
@@ -99,8 +110,8 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
|
||||
packagedInstall=list.install_dir;
|
||||
const engine=await control.engine();
|
||||
const installer=await mcpCall('strata_status',{hardware:true}).catch(error=>({error:error.message}));
|
||||
const disk=Object.fromEntries((installer.installed?.models??[]).map(m=>[m.model,{diskGb:m.disk_gb,diskBytes:m.disk_bytes,sharedBytes:m.shared_bytes,removableGb:m.removable_gb,partsMissing:m.parts_missing??0}]));
|
||||
const models=(list.models??[]).filter(m=>m.component_status!=='invalid'&&m.source_config).map(m=>({...(disk[m.id]??{}),model:m.id,config:basename(m.source_config),configPath:m.source_config,model_name:m.name,context:m.context_tokens??0,backend:m.backend,ready:m.component_status==='present'&&!(disk[m.id]?.partsMissing),images:'off',port:undefined,last_used:null}));
|
||||
const disk=Object.fromEntries((installer.installed?.models??[]).map(m=>[m.model,{diskGb:m.disk_gb,diskBytes:m.disk_bytes,sharedBytes:m.shared_bytes,removableGb:m.removable_gb,partsMissing:m.parts_missing??0,missingFiles:m.missing_files}]));
|
||||
const models=(list.models??[]).filter(m=>m.component_status!=='invalid'&&m.source_config).map(m=>({...(disk[m.id]??{}),model:m.id,config:basename(m.source_config),configPath:m.source_config,model_name:m.name,context:m.context_tokens??0,backend:m.backend,ready:m.component_status==='present'&&!(disk[m.id]?.partsMissing),missing:missingNames(m.missing,disk[m.id]?.missingFiles),images:'off',port:undefined,last_used:null}));
|
||||
const cores=os.cpus().length;
|
||||
return {summary:engine.usable?`Strata Control is ready; engine ${engine.engine.version} (${engine.engine.platform}-${engine.engine.backend}).`:(engine.notice??'No engine for this PC was found.'),
|
||||
installDir:list.install_dir,engine:{usable:engine.usable,notice:engine.notice,wrongMake:engine.wrongMake===true,version:engine.engine?.version??null,backend:engine.backend,componentsDir:engine.components_dir,previous:engine.previous??null,pinned:engine.pinned??null,updateNotice:engine.update_notice??null},
|
||||
@@ -118,10 +129,11 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
|
||||
/* A read that is not forced never makes the window wait for the (slow) status of Control: it gets the last state at once, and a stale one
|
||||
is refreshed in the background. Only the first read, and a forced one after an operation, waits. */
|
||||
if(refreshing)return !force&&snapshot.available?state():refreshing;if(!force&&Date.now()-lastRead<45000)return state();
|
||||
probeDrafting();
|
||||
refreshing=(async()=>{try{const [catalog,status]=packaged?await (async()=>{const s=await tool('strata_status',{hardware:true});const c=await tool('strata_models').catch(error=>({families:[],contexts:[],recommendation:{family:null,model:null},context_note:'',error:error.message}));return [c,s];})():await Promise.all([tool('strata_models'),tool('strata_status',{hardware:true})]);let visionReader=false;if(packaged){try{const e=await control.engine();const root=e?.engine?.root??(e?.engine?.exe?dirname(e.engine.exe):'');visionReader=!!root&&['strata-vision','strata-vision.exe'].some(n=>existsSync(join(root,n)));}catch{visionReader=false;}}const markImages=c=>c&&Array.isArray(c.families)?{...c,families:c.families.map(f=>({...f,imagesCapable:!!f.images,images:!!f.images&&visionReader,sizes:(f.sizes??[]).map(s=>({...s,imagesCapable:!!s.images,images:!!s.images&&visionReader}))}))}:c;snapshot={available:true,repo:packaged?status.installDir:repo(),version:packaged?(control.version??null):await execute('git',['describe','--tags','--always'],repo()).then(s=>s.trim()).catch(()=>null),summary:status.summary,catalog:markEngine(packaged?markImages(catalog):catalog,status.engine),engineInfo:{version:status.engine?.version??null,source:status.engine?.source??null},visionReader:packaged?visionReader:true,installed:installedInfo(status.installed),hardware:status.hardware,error:null,install:status.install,external:status.running??null,...(packaged?{packaged:true,installDir:status.installDir,engine:status.engine,installerError:status.installerError}:{})};lastRead=Date.now();if(status.install?.running&&![...jobs.values()].some(j=>j.state==='running'))begin('install',j=>watchInstall(j,status.install));}catch(error){snapshot={...snapshot,available:false,error:error.message};lastRead=Date.now();}emit();return state();})().finally(()=>{refreshing=null;});const run=refreshing;if(!force&&snapshot.available){run.catch(()=>{});return state();}return run;
|
||||
}
|
||||
function configure({repository}){if(packaged){if(instances.size||[...jobs.values()].some(j=>j.state==='running'))throw new Error('Unload models and finish operations before changing the installation.');const next=realpathSync(String(repository));if(!statSync(next).isDirectory())throw new Error('Choose the folder that holds your Strata model configurations.');preferences={...preferences,installDir:next};packagedInstall=null;disconnect();writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});lastRead=0;return refresh(true);}if(instances.size||[...jobs.values()].some(j=>j.state==='running'))throw new Error('Unload models and finish operations before changing the installation.');const next=realpathSync(String(repository));const old=preferences;preferences={...preferences,repo:next};try{repo();}catch(e){preferences=old;throw e;}disconnect();writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});lastRead=0;return refresh(true);}
|
||||
function setDownloadDirectory(input){if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish the current operation before changing the folder.');if(typeof input!=='string'||!input.trim()||/[\r\n\0]/.test(input))throw new Error('Choose a model folder.');const path=realpathSync(input.trim());if(!statSync(path).isDirectory())throw new Error('Choose a folder, not a file.');accessSync(path,constants.R_OK|constants.W_OK);preferences={...preferences,downloadDirectory:path};writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});emit();return state();}
|
||||
function setDownloadDirectory(input,{insideJob=false}={}){if(!insideJob&&[...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish the current operation before changing the folder.');if(typeof input!=='string'||!input.trim()||/[\r\n\0]/.test(input))throw new Error('Choose a model folder.');let path;try{path=realpathSync(input.trim());}catch{throw new Error('That folder does not exist. Check the path, or that the drive is connected.');}if(!statSync(path).isDirectory())throw new Error('Choose a folder, not a file.');/* A whole drive (D:\) is not filled with models: D:\Strata is made and used. */if(isDriveRoot(path)){const own=dataFolderFromPick(path).path;makeFolder(own);path=realpathSync(own);}try{accessSync(path,constants.R_OK|constants.W_OK);}catch{throw new Error('Strata cannot write in that folder. Choose a folder you can save files in.');}preferences={...preferences,downloadDirectory:path};/* the same choice for the installer and the Terminal: the Control settings file */if(packaged)rememberDataFolder(controlSettingsFile(),path,Date.now()/1000,true);writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});shareDataFolder();emit();return state();}
|
||||
function recordFile(model){if(!safeId(model))throw new Error('Choose an installed model.');const entry=snapshot.installed?.models?.find(i=>i.model===model);if(!entry)throw new Error('This model is not installed. Download and prepare it first.');const path=realpathSync(entry.configPath??join(repo(),entry.config));if(dirname(path)!==repo()||!/^strata-[a-z0-9_-]+\.json$/i.test(basename(path)))throw new Error('The model configuration is outside the Strata installation.');return {entry,path};}
|
||||
function record(model){const {entry,path}=recordFile(model);return {entry,path,config:readJson(path)};}
|
||||
/* The text file a power user edits by hand: where it is and what is wrong in it (run-config-check.mjs). */
|
||||
@@ -132,8 +144,13 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
|
||||
function settings(model){const {config}=record(model);const values=Object.fromEntries(NATIVE_FIELDS.map(f=>[f.id,config[f.id]??null]));if(typeof values.aliases==='string')values.aliases=values.aliases.split(',').map(v=>v.trim()).filter(Boolean);values.sampling=config.sampling??{};values.args=config.args??[];values.env=publicNativeEnv(config.env??{});return {model,values,mcp:publicMcpServers(config.mcp_servers??config.mcpServers??{}),restartNeeded:true};}
|
||||
function save(model,{values,mcp}){if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish the current operation before saving native settings.');const {path,config}=record(model);if(busy(requests().filter(r=>instances.get(r.instanceId)?.model===model)))throw new Error('Finish this model’s requests before saving its settings.');const patch=validateNativeSettings(values??{}),next={...config};for(const field of nativeFields())if(field.supported===false&&patch[field.id]!=null&&JSON.stringify(patch[field.id])!==JSON.stringify(config[field.id]))throw new Error(`${field.label} needs a newer native Strata installation. Update it in Settings → About.`);for(const [key,value] of Object.entries(patch)){if(key==='env')next.env=restoreNativeEnv(value,config.env??{});else if(value===null)delete next[key];else next[key]=value;}if('vram_reserve_mib' in patch){const base=captureUserArgs(next);const mine=setUserFlag(base,'--vram-reserve-mib',patch.vram_reserve_mib);if(mine.length)next[USER_KEY]=mine;else delete next[USER_KEY];next.args=mine.length&&patch.vram_reserve_mib!==null?mergeUserArgs(next.args??[],mine):setUserFlag(next.args??[],'--vram-reserve-mib',null);delete next.ui_reserve_applied;if(Array.isArray(next.args_written))next.args_written=[...next.args];}if(mcp!==undefined){next.mcp_servers=validateMcpServers(mcp,config.mcp_servers??config.mcpServers??{});delete next.mcpServers;}copyFileSync(path,path+'.bak');chmodSync(path+'.bak',0o600);const temp=path+'.ui-tmp';writeFileSync(temp,JSON.stringify(next,null,2)+'\n',{mode:0o600});renameSync(temp,path);return settings(model);}
|
||||
function previousEngine(){if(packaged)return !!snapshot.engine?.previous;try{const data=snapshot.installed?.data_dir??snapshot.installed?.data_folder;return [join(preferences.repo,'engine/.previous'),data&&join(data,'engine/.previous')].filter(Boolean).some(existsSync)&&readFileSync(join(preferences.repo,'setup.py'),'utf8').includes('--rollback-engine');}catch{return false;}}
|
||||
async function plan(args){await refresh();if(!snapshot.available)throw new Error(snapshot.error);return tool('strata_install',installArgs(args,false));}
|
||||
function installArgs(args,confirm){const allowed=['family','model','context','vision','kv','gpu','gpus','backend','low_ram','port','gguf_dir'];const size=snapshot.catalog?.families?.find(f=>f.family===args.family)?.sizes.find(s=>s.model===args.model);const existing=snapshot.installed?.models?.find(m=>m.model===size?.id);/* A model adopted from the person's own GGUF folder reports that folder's parent as its data folder: it is theirs, not Strata's, so a second preparation never uses it for the packs. */if(args.gguf_dir===undefined&&existing?.ownGgufDir)args={...args,gguf_dir:existing.ownGgufDir};const data=nativeDataDirectory(args.data_dir??(args.gguf_dir?undefined:existing?.dataFolder)??preferences.downloadDirectory??modelsDirectory?.());return {...(data?{data_dir:data}:{}),...Object.fromEntries(allowed.filter(k=>args[k]!==undefined).map(k=>[k,args[k]])),/* One card is "gpu", never "gpus" (the installer wants two or more there: "Gpus='0' is not valid", #3203). */...(/^\d{1,2}$/.test(String(args.gpus??''))?{gpu:Number(args.gpus),gpus:undefined}:{}),/* Picture reading is only downloaded when it was asked for (Read pictures); the default stays the smaller download. */...(packaged&&args.vision===undefined?{vision:'no'}:{}),confirm};}
|
||||
async function plan(args){await refresh();if(!snapshot.available)throw new Error(snapshot.error);const result=await tool('strata_install',installArgs(args,false));
|
||||
/* A chosen drafter that is not on this computer yet is part of the download: its size is listed beside the model's (Control decides; nothing is fetched here). */
|
||||
const choice=args.drafting;if(packaged&&typeof choice==='string'&&choice!=='auto'&&typeof control.draftingPlan==='function'){try{const d=await control.draftingPlan({mode:choice,family:args.family??null,dataDir:snapshot.installed?.data_folder??null});if(d.needed)return {...result,drafting:{needed:true,gb:d.gb,line:d.line}};}catch{/* the model plan still stands */}}
|
||||
return result;}
|
||||
/* The folder the person picked in the first-run chooser can be the one that holds their GGUF files (or a folder inside it). It is theirs, not a Strata data folder: packs are never made in it, and a Strata folder around it (<Strata>/models/<tag>) is used instead (#3507, #3424). */
|
||||
function ownFilesDataFolder(candidate,args){if(!candidate||args.data_dir!==undefined||!args.gguf_dir)return candidate;const same=(a,b)=>{const x=resolve(a),y=resolve(b);return process.platform==='win32'?x.toLowerCase()===y.toLowerCase():x===y;};const inside=(a,b)=>{const rel=relative(resolve(b),resolve(a));return rel===''||(!!rel&&!rel.startsWith('..')&&!isAbsolute(rel));};if(!same(candidate,args.gguf_dir)&&!inside(candidate,args.gguf_dir))return candidate;const models=dirname(resolve(args.gguf_dir)),top=dirname(models);return basename(models).toLowerCase()==='models'&&/strata/i.test(basename(top))?top:undefined;}
|
||||
function installArgs(args,confirm){const allowed=['family','model','context','vision','kv','gpu','gpus','backend','low_ram','port','gguf_dir'];const size=snapshot.catalog?.families?.find(f=>f.family===args.family)?.sizes.find(s=>s.model===args.model);const existing=snapshot.installed?.models?.find(m=>m.model===size?.id);/* A model adopted from the person's own GGUF folder reports that folder's parent as its data folder: it is theirs, not Strata's, so a second preparation never uses it for the packs. */if(args.gguf_dir===undefined&&existing?.ownGgufDir)args={...args,gguf_dir:existing.ownGgufDir};const data=nativeDataDirectory(ownFilesDataFolder(args.data_dir??(args.gguf_dir?undefined:existing?.dataFolder)??preferences.downloadDirectory??modelsDirectory?.(),args));if(data&&packaged&&!args.data_dir)rememberDataFolder(controlSettingsFile(),data);return {...(data?{data_dir:data}:{}),...Object.fromEntries(allowed.filter(k=>args[k]!==undefined).map(k=>[k,args[k]])),/* One card is "gpu", never "gpus" (the installer wants two or more there: "Gpus='0' is not valid", #3203). */...(/^\d{1,2}$/.test(String(args.gpus??''))?{gpu:Number(args.gpus),gpus:undefined}:{}),/* Picture reading is only downloaded when it was asked for (Read pictures); the default stays the smaller download. */...(packaged&&args.vision===undefined?{vision:'no'}:{}),confirm};}
|
||||
|
||||
function begin(kind,run,metadata={}){if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish or cancel the current engine operation first.');const job={id:randomUUID(),kind,state:'running',startedAt:new Date().toISOString(),summary:'Starting…',result:null,error:null,...metadata};for(const [id,old] of jobs)if(jobs.size>=12&&old.state!=='running')jobs.delete(id);jobs.set(job.id,job);emit();void run(job).then(result=>{if(job.state==='running'){Object.assign(job,{state:'done',summary:result?.summary??'Complete',result,finishedAt:new Date().toISOString()});}}).catch(error=>{if(job.state==='running')Object.assign(job,{state:'error',error:error.message,summary:error.message,...(error.details?{details:error.details}:{}),finishedAt:new Date().toISOString()});}).finally(()=>{if(job.kind.startsWith('update')||job.kind==='rollback')disconnect();lastRead=0;emit();void refresh(true);});return state().jobs.find(j=>j.id===job.id);}
|
||||
async function watchInstall(job,current){job.summary=current.step??'Resuming preparation status…';job.result={install:current};while(job.state==='running'){await new Promise(r=>setTimeout(r,1500));const result=await tool('strata_install');job.summary=result.summary;job.result=result;emit();if(!result.install?.running){if(result.install?.exit_code!==0)throw new Error(result.install?.error??'Preparation did not finish. Retry to reuse completed files.');return {summary:'Model preparation complete. Load it from Models.'};}}}
|
||||
@@ -167,7 +184,7 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
|
||||
const note=engineStep?.summaryNote();
|
||||
const finalResult=engineStep?job.result:result;
|
||||
if(note)return {...finalResult,summary:note};
|
||||
if(args.loadAfter){const model=(result.install.family??args.family??'qwen')==='qwen'?String(args.model).toLowerCase():`${args.family}-${String(args.model).toLowerCase()}`;return loadEngine(job,{model,multiple:!!args.multiple,resourceProfile:args.resourceProfile,vision:args.loadVision});}
|
||||
if(args.loadAfter){const model=(result.install.family??args.family??'qwen')==='qwen'?String(args.model).toLowerCase():`${args.family}-${String(args.model).toLowerCase()}`;return loadEngine(job,{model,multiple:!!args.multiple,resourceProfile:args.resourceProfile,vision:args.loadVision,...(typeof args.drafting==='string'?{values:{drafting:args.drafting}}:{})});}
|
||||
return finalResult;
|
||||
}
|
||||
}
|
||||
@@ -201,13 +218,15 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
const resources=args.resourceProfile?resourceSettings({...base0,[USER_KEY]:mine,vision},snapshot.hardware,args.resourceProfile,modelArena(args.model),values0):null;
|
||||
const cfg={...config,...visionPreferences,...(resources?{...resources,ui_resource_profile:args.resourceProfile}:{}),host:'127.0.0.1',open_browser:false,...validateNativeSettings(args.values??{}),vision};
|
||||
/* The app's suggested cards only fill a gap: a model whose settings file already names its GPUs ("gpu": [1, 0], with "gpu_order": "as_given") keeps them (#3256). A card the person picked in the screen always wins. */
|
||||
const namedInFile=Array.isArray(config.gpu)?config.gpu.length>0:config.gpu!==undefined&&config.gpu!==null&&config.gpu!=='';
|
||||
if(args.gpus!==undefined&&!(args.gpusFromRecommendation&&namedInFile)){const detected=new Set((snapshot.hardware?.gpus??[]).map(g=>g.index));if(!Array.isArray(args.gpus)||!args.gpus.length||new Set(args.gpus).size!==args.gpus.length||args.gpus.some(g=>!Number.isInteger(g)||!detected.has(g)))throw new Error('Choose distinct detected GPUs. CPU-only text inference is not supported by this native adapter.');cfg.gpu=args.gpus.length===1?args.gpus[0]:args.gpus;cfg.layer_split=args.gpus.length>1?validateNativeSettings({layer_split:args.layerSplit??'auto'}).layer_split:'auto';if(cfg.layer_split!=='auto'&&cfg.layer_split.split(',').length!==args.gpus.length-1)throw new Error('Choose one layer split point between each pair of GPUs.');}
|
||||
const namedInFile=(Array.isArray(config.gpu)?config.gpu.length>0:config.gpu!==undefined&&config.gpu!==null&&config.gpu!=='')||!!config.gpu_roles;
|
||||
/* Roles chosen in the screen (Main / Helper / Off per card, gpu-roles.mjs): the card list is the roles' mirror - Helpers first, the Main card LAST, Off cards left out - with "gpu_roles" beside it. Every card Off is allowed: the server says plainly what the engine can do. */
|
||||
if(args.gpuRoles!==undefined){const make=snapshot.hardware?.engine_vendor,cards=(snapshot.hardware?.gpus??[]).filter(g=>!make||!g.vendor||g.vendor===make),named=parseRoles(args.gpuRoles);if(!named)throw new Error('Choose Main, Helper or Off for each graphics card, with one Main.');const known=new Set(cards.map(g=>g.index));if(Object.keys(named).some(k=>!known.has(Number(k))))throw new Error('Choose detected graphics cards.');const roles=Object.fromEntries(cards.map(g=>[g.index,named[g.index]??'off'])),m=mirrorRoles(roles,cards);for(const k of ['gpu','gpu_order','gpu_none','gpu_roles','gpu_cards','hip_ordinal'])delete cfg[k];Object.assign(cfg,m);const count=Array.isArray(m.gpu)?m.gpu.length:m.gpu_none?0:1;cfg.layer_split=count>1?validateNativeSettings({layer_split:args.layerSplit??'auto'}).layer_split:'auto';if(cfg.layer_split!=='auto'&&cfg.layer_split.split(',').length!==count-1)throw new Error('Choose one layer split point between each pair of GPUs.');}
|
||||
else if(args.gpus!==undefined&&!(args.gpusFromRecommendation&&namedInFile)){const detected=new Set((snapshot.hardware?.gpus??[]).map(g=>g.index));if(!Array.isArray(args.gpus)||!args.gpus.length||new Set(args.gpus).size!==args.gpus.length||args.gpus.some(g=>!Number.isInteger(g)||!detected.has(g)))throw new Error('Choose distinct detected GPUs. CPU-only text inference is not supported by this native adapter.');cfg.gpu=args.gpus.length===1?args.gpus[0]:args.gpus;for(const k of ['gpu_roles','gpu_cards','gpu_none'])delete cfg[k];delete cfg.hip_ordinal;cfg.layer_split=args.gpus.length>1?validateNativeSettings({layer_split:args.layerSplit??'auto'}).layer_split:'auto';if(cfg.layer_split!=='auto'&&cfg.layer_split.split(',').length!==args.gpus.length-1)throw new Error('Choose one layer split point between each pair of GPUs.');}
|
||||
if(cfg.vram_reserve_mib!==undefined&&cfg.vram_reserve_mib!==null){cfg.args=[...(cfg.args??[])];const at=cfg.args.indexOf('--vram-reserve-mib');if(at>=0)cfg.args.splice(at,2);cfg.args.push('--vram-reserve-mib',String(cfg.vram_reserve_mib));}
|
||||
if(mine.length){cfg.args=mergeUserArgs(cfg.args??[],mine);cfg[USER_KEY]=mine;}else delete cfg[USER_KEY];
|
||||
if(cfg.vision){if(cfg.vision_tokens!=null)cfg.vision={...cfg.vision,max_tokens:cfg.vision_tokens};if(cfg.vision_min_tokens!=null)cfg.vision={...cfg.vision,min_tokens:cfg.vision_min_tokens};}cfg.mcp={...(cfg.mcp??{}),...(cfg.mcp_timeout_s!=null?{timeout_s:cfg.mcp_timeout_s}:{}),...(cfg.mcp_max_rounds!=null?{max_rounds:cfg.mcp_max_rounds}:{}),...(cfg.mcp_max_result_chars!=null?{max_result_chars:cfg.mcp_max_result_chars}:{})};
|
||||
/* The model's own choices win over what setup wrote into the arguments: "Draft layer (MTP)" off leaves --mtp out of every start, and a picture reading that is off leaves --vision out (setup writes both again whenever it runs; the saved settings are applied here, on every load). */
|
||||
if(cfg.mtp===false&&Array.isArray(cfg.args))cfg.args=withoutDraftLayer(cfg.args);
|
||||
if((cfg.mtp===false||cfg.drafting==='off')&&Array.isArray(cfg.args))cfg.args=withoutDraftLayer(cfg.args);
|
||||
if(!cfg.vision&&Array.isArray(cfg.args))cfg.args=cfg.args.filter(a=>a!=='--vision');
|
||||
for(const arg of cfg.args??[])if(arg.endsWith('.gguf')&&!existsSync(resolve(repo(),arg)))throw new Error('An engine argument names a missing GGUF. Restore the saved arguments or repair the files.');
|
||||
/* The app's own choice of request slots (4, fewer where the card has no room) is part of the configuration, so a tune measures the engine a load will start: slots take VRAM from the expert cache. */
|
||||
@@ -218,12 +237,32 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
if(parallelChoice){const applied=applyParallel(cfg,parallelChoice);for(const k of Object.keys(cfg))if(!(k in applied.cfg))delete cfg[k];Object.assign(cfg,applied.cfg);parallelNote=applied.note;}
|
||||
return {cfg,resources,visionPreferences,parallelChoice,parallelNote};
|
||||
}
|
||||
/* The DFlash2 drafter, before a load that uses it: Control says whether the choice needs it (and the file is not here), downloads it pinned and checked, and packs it, with plain progress in the job.
|
||||
Never stops the load: a drafter that cannot be had leaves the MTP running, and the job says so. The choice itself is remembered in the model's settings file. */
|
||||
async function prepareDrafter(job,args,cfg,configFile){
|
||||
const asked=args.values?.drafting;
|
||||
if(asked!==undefined)rememberSetting(configFile,'drafting',asked===null||asked==='auto'?null:asked);
|
||||
if(!packaged||typeof control.draftingFetch!=='function'||!cfg.drafting||cfg.drafting==='auto'||cfg.drafting==='mtp'||cfg.drafting==='off')return;
|
||||
const work=join(directory,'native-instances');mkdirSync(work,{recursive:true});const probe=join(work,'drafting-'+randomUUID()+'.json');
|
||||
try{
|
||||
writeFileSync(probe,JSON.stringify(cfg),{mode:0o600});
|
||||
job.engineAbort??=new AbortController();
|
||||
await control.draftingFetch({configPath:probe,mode:cfg.drafting,signal:job.engineAbort.signal,onEvent:event=>{if(event.event==='progress'&&job.state==='running'){job.summary=event.step;job.details=event.total>1&&event.done<event.total?`${(event.done/1e9).toFixed(2)} of ${(event.total/1e9).toFixed(2)} GB`:undefined;emit();}}});
|
||||
if(job.state==='running'){job.summary='Loading the model…';job.details=undefined;emit();}
|
||||
}catch(error){if(job.state==='running'){job.drafterNote=error.message;job.summary='The DFlash2 drafter could not be downloaded, so the model starts with the MTP. Loading the model…';emit();}}
|
||||
finally{try{rmSync(probe,{force:true});}catch{/* best effort */}}
|
||||
}
|
||||
/* One key of a model's settings file, set or removed (the Drafting choice): written like the Settings screen writes it, to be there for the next load. */
|
||||
function rememberSetting(configFile,key,value){try{const current=readJson(configFile);if(value===null?!(key in current):current[key]===value)return;const next={...current};if(value===null)delete next[key];else next[key]=value;const temp=configFile+'.ui-tmp';writeFileSync(temp,JSON.stringify(next,null,2)+'\n',{mode:0o600});renameSync(temp,configFile);}catch{/* the load goes on with the choice for this start */}}
|
||||
/* Which drafter runs, when it is not the one that was asked for (the engine's own words, "Drafting: MTP (DFlash2 didn't fit)"): under Details of the load. */
|
||||
async function noteDrafting(job,instance,key){let note='';try{const r=await fetcher(`http://127.0.0.1:${instance.port}/metrics`,{headers:{authorization:`Bearer ${key}`},signal:AbortSignal.timeout(2000)});if(r.ok)note=String((await r.json())?.engine?.drafting_note??'');}catch{/* a convenience */}const lines=[job.drafterNote,note].filter(Boolean);if(lines.length)job.details=[job.details,...lines].filter(Boolean).join('\n');}
|
||||
async function loadEngine(job,args){
|
||||
await refresh(true);if(job.state!=='running')return;const {config,entry,path:configFile}=record(args.model);assertSettingsFile(configFile);if(packaged){const engine=await control.engine();if(!engine.usable)throw new Error(ENGINE_NOT_RECOGNISED+(engine.notice?` ${engine.notice}`:''));}if(!entry.ready)throw new Error('Some model files are missing. Use Download / repair to restore them.');
|
||||
await refresh(true);if(job.state!=='running')return;const {config,entry,path:configFile}=record(args.model);assertSettingsFile(configFile);if(packaged){const engine=await control.engine();if(!engine.usable)throw new Error(ENGINE_NOT_RECOGNISED+(engine.notice?` ${engine.notice}`:''));}if(!entry.ready){const error=missingFilesError(args.model,entry.missing);append(args.model,error.details[0]);throw error;}
|
||||
if(externalRunning().length)throw new Error('A Strata server is already running outside this UI. Stop it before loading here, or connect its API in Settings → Models.');
|
||||
if(args.multiple&&instances.size>=8)throw new Error('Unload an instance before adding another. Up to eight native instances can run here.');
|
||||
const {cfg,resources,visionPreferences,parallelChoice,parallelNote:chosenNote}=buildConfig(args,config);
|
||||
let parallelNote=chosenNote;
|
||||
await prepareDrafter(job,args,cfg,configFile);if(job.state!=='running')return;
|
||||
const fingerprint=createHash('sha256').update(JSON.stringify({...cfg,api_key:undefined,port:undefined})).digest('hex');
|
||||
const placement=Array.isArray(cfg.gpu)?cfg.gpu:cfg.gpu===undefined?[]:[cfg.gpu];const matching=[...instances.values()].find(i=>i.model===args.model&&JSON.stringify(i.gpus)===JSON.stringify(placement));
|
||||
if(matching?.status==='ready'&&matching.fingerprint===fingerprint&&(args.multiple||instances.size===1))return {summary:`${matching.label} is already ready.`,instanceId:matching.id};
|
||||
@@ -278,7 +317,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
}
|
||||
const deadline=Date.now()+300000;while(Date.now()<deadline&&job.state==='running'){
|
||||
if(instance.status==='error')throw new Error(instance.error);
|
||||
try{const response=await fetcher(`http://127.0.0.1:${instance.port}/health`,{headers:{authorization:`Bearer ${key}`},signal:AbortSignal.timeout(2000)});if(job.state!=='running'){await unload(id);return;}if(response.ok){instance.status='ready';instance.phase='idle';if(resources||args.vision!==undefined||JSON.stringify(cfg[USER_KEY]??null)!==JSON.stringify(config[USER_KEY]??null))writeModelConfig(record(args.model).path,{...config,...(resources?{...resources,ui_resource_profile:args.resourceProfile}:{}),...visionPreferences,...(cfg[USER_KEY]?{[USER_KEY]:cfg[USER_KEY]}:{})});emit();ensurePoll();instance.parallelNote=parallelNote;return {summary:retried?`${instance.label} is ready, after a second try: ${retried.words}`:`${instance.label} is ready.${parallelNote?' '+parallelNote:''}`,instanceId:id};}}catch{}
|
||||
try{const response=await fetcher(`http://127.0.0.1:${instance.port}/health`,{headers:{authorization:`Bearer ${key}`},signal:AbortSignal.timeout(2000)});if(job.state!=='running'){await unload(id);return;}if(response.ok){instance.status='ready';instance.phase='idle';await noteDrafting(job,instance,key);if(resources||args.vision!==undefined||JSON.stringify(cfg[USER_KEY]??null)!==JSON.stringify(config[USER_KEY]??null))writeModelConfig(record(args.model).path,{...config,...(resources?{...resources,ui_resource_profile:args.resourceProfile}:{}),...visionPreferences,...(cfg[USER_KEY]?{[USER_KEY]:cfg[USER_KEY]}:{})});emit();ensurePoll();instance.parallelNote=parallelNote;return {summary:retried?`${instance.label} is ready, after a second try: ${retried.words}`:`${instance.label} is ready.${parallelNote?' '+parallelNote:''}`,instanceId:id};}}catch{}
|
||||
await new Promise(resolve=>setTimeout(resolve,1200));
|
||||
}
|
||||
if(job.state!=='running'){await unload(id);return;}
|
||||
@@ -372,10 +411,104 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
return autotuner.start(job,job.kind==='recheck'?'recheck':'tune',{configPath:path,presetId:args.presetId,mode:args.mode,use:args.use,goal:room?'room':'speed',leaveRam:room?20:0,allowSmallDifferences:args.allowSmallDifferences===true,name:args.name},{onProgress:p=>{job.summary=p.text??job.summary;emit();}});
|
||||
}
|
||||
function operation(kind,args={}){if(!['install','load','benchmark','tune','autotune','recheck','calibrate','update-engine','update-strata','rollback','remove'].includes(kind))throw new Error('Choose a supported native operation.');if(!snapshot.available)throw new Error(snapshot.error??'Connect the native Strata installation first.');if(kind==='install'||kind==='load'){const refusal=engineRefusal(snapshot.catalog,args.model,snapshot.engineInfo);if(refusal)throw new Error(refusal);}return begin(kind,job=>kind==='install'?install(job,args):kind==='load'?loadEngine(job,args):kind==='benchmark'?benchmark(job,args):kind==='tune'?tune(job,args):kind==='autotune'||kind==='recheck'?autotune(job,args):kind==='remove'?removeModel(job,args):program(job,args),{guided:args.guided===true,model:args.model,...(args.family?{family:args.family}:{})});}
|
||||
async function cancel(id){const job=jobs.get(id);if(!job||job.state!=='running')throw new Error('This operation is no longer running.');if(job.kind==='install'){job.engineAbort?.abort();await tool('strata_install',{cancel:true});}else if(job.abort)job.abort.abort();else if(job.process){if(!['tune','calibrate','autotune','recheck'].includes(job.kind))throw new Error('File updates cannot be cancelled safely while files are being replaced.');job.state='cancelled';await stopProcess(job.process);}job.state='cancelled';job.summary=job.kind==='install'?'Stopped. Completed downloads are kept.':'Stopped.';emit();return state();}
|
||||
/* ---- The models folder card (0.2.2): what a picked folder means, and the three choices. The copying is Control's (strata models move). ---- */
|
||||
const currentModelsFolder=()=>preferences.downloadDirectory??(modelsDirectory?modelsDirectory():snapshot.installed?.data_folder)??null;
|
||||
const moveFile=()=>join(directory,'models-move.cancel');
|
||||
const folderWords=error=>({message:error.message,details:error.details||''});
|
||||
/** A picked folder as the data folder: a whole drive (D:\) becomes D:\Strata, and the person is told (the models then live in D:\Strata\Models). */
|
||||
function pickedModelsFolder(input){
|
||||
if(typeof input!=='string'||!input.trim()||/[\r\n\0]/.test(input))throw new Error('Choose a models folder.');
|
||||
const picked=resolve(input.trim()),target=dataFolderFromPick(picked);
|
||||
return {picked,folder:target.path,wholeDrive:!!target.note,shown:target.note?join(target.path,'Models'):target.path};
|
||||
}
|
||||
async function planModelsFolder(input){
|
||||
if(!packaged)throw new Error('Changing the models folder is part of the Strata app.');
|
||||
const now=currentModelsFolder();if(!now)throw new Error('Strata is still reading your models folder. Try again in a moment.');
|
||||
const pick=pickedModelsFolder(input),plan=await control.planModelsMove(now,pick.folder);
|
||||
return {...plan,...pick,current:now,running:instances.size,busy:[...jobs.values()].some(j=>j.state==='running')};
|
||||
}
|
||||
/* "Start empty": the old models are no longer listed. Their run configs are moved (not deleted) into old-models beside the others; the model files are not touched. */
|
||||
function setAsideConfigs(old){
|
||||
const folder=repo(),kept=join(folder,'old-models'),aside=[];
|
||||
const under=value=>typeof value==='string'&&[ 'models','packs','mtp' ].some(item=>{const base=join(old,item).toLowerCase();const v=value.toLowerCase();return v===base||v.startsWith(base+sep)||v.startsWith(base+'/');});
|
||||
const scan=value=>Array.isArray(value)?value.some(scan):value&&typeof value==='object'?Object.values(value).some(scan):under(value);
|
||||
for(const name of readdirSync(folder)){
|
||||
if(!/^strata-[a-z0-9_-]+\.json$/i.test(name)||name.endsWith('.shared-settings.json'))continue;
|
||||
if(!scan(readJson(join(folder,name))))continue;
|
||||
mkdirSync(kept,{recursive:true});renameSync(join(folder,name),join(kept,name));aside.push(name);
|
||||
}
|
||||
return aside;
|
||||
}
|
||||
async function startModelsFolder({folder:input,choice,stopRunning=false}){
|
||||
if(!packaged)throw new Error('Changing the models folder is part of the Strata app.');
|
||||
if(!['move','keep','empty'].includes(choice))throw new Error('Choose what to do with your models.');
|
||||
if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish or stop the download to change the folder.');
|
||||
const plan=await planModelsFolder(input);
|
||||
if(plan.issue)throw Object.assign(new Error(plan.issue.message),{details:plan.issue.details,code:plan.issue.code});
|
||||
if(!plan.choices[choice].possible)throw Object.assign(new Error(plan.choices[choice].reason||'That choice is not possible here.'),{code:'not_possible'});
|
||||
if(instances.size){
|
||||
if(!stopRunning)throw Object.assign(new Error('A model is running. Stop it first, then change the folder.'),{code:'running'});
|
||||
for(const id of [...instances.keys()])await unload(id);
|
||||
}
|
||||
const old=plan.current,target=plan.folder;
|
||||
if(choice!=='move'){
|
||||
makeFolder(target);
|
||||
const aside=choice==='empty'?setAsideConfigs(old):[];
|
||||
setDownloadDirectory(target);lastRead=0;await refresh(true);
|
||||
return {done:true,choice,folder:target,oldFolder:old,setAside:aside};
|
||||
}
|
||||
return runModelsMove(old,target,(from,to,options)=>control.moveModels(from,to,options));
|
||||
}
|
||||
/* The move as a job (the progress dialog reads it). `run` is moveModels for a new move, resumeModelsMove for "Finish it". */
|
||||
function runModelsMove(old,target,run){
|
||||
rmSync(moveFile(),{force:true});
|
||||
return begin('models-move',async job=>{
|
||||
job.cancelFile=moveFile();job.summary='Starting…';
|
||||
try{
|
||||
const result=await run(old,target,{installDir:repo(),cancelFile:moveFile(),onEvent:event=>{
|
||||
if(event.event!=='progress')return;
|
||||
job.progress={done:event.done,total:event.total,file:event.file,group:event.group,index:event.index,count:event.count,secondsLeft:event.seconds_left||0,sameDrive:!!event.same_drive};
|
||||
job.summary=`${Math.round(event.done/1e8)/10} of ${Math.round(event.total/1e8)/10} GB`;emit();}});
|
||||
const finished={done:!!result.done,stopped:!!result.stopped,moved:result.moved??[],remaining:result.remaining??[],bytes:result.bytes_moved??0,total:result.total_bytes??0,from:old,to:target,files:result.files_moved??0,sameDrive:!!result.same_drive};
|
||||
/* Only a whole move changes the folder the app uses: a stopped move leaves it where it was, and every model works where it is. */
|
||||
if(result.done){setDownloadDirectory(target,{insideJob:true});finished.folder=target;}
|
||||
job.progress=null;
|
||||
return {summary:result.done?'Your models are moved.':'Moving stopped. Nothing was lost.',...finished};
|
||||
}catch(error){
|
||||
const left=error.output;
|
||||
job.result={failed:true,code:error.code,moved:left?.moved??[],remaining:left?.remaining??[],bytes:left?.bytes_moved??0,total:left?.total_bytes??0,from:old,to:target};
|
||||
job.progress=null;
|
||||
throw error;
|
||||
}finally{rmSync(moveFile(),{force:true});}
|
||||
},{family:undefined});
|
||||
}
|
||||
/* "A move was interrupted. Finish it or leave it": the next start finds the unfinished move (Control keeps a note in the models folder in use). */
|
||||
async function interruptedMove(){
|
||||
if(!packaged)return null;
|
||||
const now=currentModelsFolder();if(!now)return null;
|
||||
if([...jobs.values()].some(j=>j.kind==='models-move'&&j.state==='running'))return null;
|
||||
try{return await control.interruptedModelsMove(now);}catch{return null;}
|
||||
}
|
||||
async function finishInterruptedMove({stopRunning=false}={}){
|
||||
const st=await interruptedMove();if(!st)throw new Error('No move was interrupted.');
|
||||
if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish or stop the download to change the folder.');
|
||||
if(instances.size){
|
||||
if(!stopRunning)throw Object.assign(new Error('A model is running. Stop it first, then change the folder.'),{code:'running'});
|
||||
for(const id of [...instances.keys()])await unload(id);
|
||||
}
|
||||
return runModelsMove(st.from,st.to,(from,to,options)=>control.resumeModelsMove(from,options));
|
||||
}
|
||||
async function leaveInterruptedMove(){
|
||||
const st=await interruptedMove();if(!st)throw new Error('No move was interrupted.');
|
||||
if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish or stop the download to change the folder.');
|
||||
const result=await control.leaveModelsMove(st.from,{installDir:repo()});
|
||||
lastRead=0;await refresh(true);
|
||||
return {done:true,folder:st.from,partial:st.to,removed:result.removed??0,restored:result.restored??0};
|
||||
}
|
||||
async function cancel(id){const job=jobs.get(id);if(!job||job.state!=='running')throw new Error('This operation is no longer running.');if(job.kind==='install'){job.engineAbort?.abort();await tool('strata_install',{cancel:true});}else if(job.kind==='models-move'){/* a safe stop: the copy in flight is dropped, files already moved stay */try{writeFileSync(job.cancelFile,'stop');}catch{/* the run ends anyway */}job.summary='Stopping…';emit();return state();}else if(job.abort)job.abort.abort();else if(job.process){if(!['tune','calibrate','autotune','recheck'].includes(job.kind))throw new Error('File updates cannot be cancelled safely while files are being replaced.');job.state='cancelled';await stopProcess(job.process);}job.state='cancelled';job.summary=job.kind==='install'?'Stopped. Completed downloads are kept.':'Stopped.';emit();return state();}
|
||||
async function close(){closing=true;clearInterval(timer);const installing=packaged&&child&&[...jobs.values()].some(j=>j.kind==='install'&&j.state==='running');if(installing)await send('tools/call',{name:'strata_install',arguments:{cancel:true}},15000).catch(()=>{});const operations=[];for(const job of jobs.values()){job.abort?.abort();if(job.process)operations.push(stopProcess(job.process));if(job.state==='running')job.state='cancelled';}await Promise.all(operations);await Promise.all([...instances.keys()].map(id=>unload(id,true).catch(()=>{})));disconnect();}
|
||||
// Remove nonserializable process references from public jobs.
|
||||
const publicState=()=>{const value=state();value.jobs=value.jobs.map(({process,engineAbort,rawResult,...job})=>job);return value;};
|
||||
async function localPreview(path){if(!packaged)throw new Error('Checking local model files is part of the Strata app.');if(typeof path!=='string'||!path.trim()||[...path].some(c=>c.charCodeAt(0)<32))throw new Error('Choose a folder.');return control.importPlan(path.trim());}
|
||||
return {state:publicState,tuneStatus,tuneEffective,tuneOff,tunePresets:{list:()=>autotuner.list(),action:(kind,id,extra)=>autotuner.action(kind,id,extra),undoFor:async args=>autotuner.undoFor(tuneConfig(args).path)},refresh,localPreview,configure,setDownloadDirectory,plan,settings,save,settingsFile,openSettingsFile,operation,cancel,unload,resolveInstance,poll,logs:()=>logs.slice(),benchmarkReport:()=>readJson(join(directory,'native-benchmark.json'),null),setDefault(id){defaultId=resolveInstance(id).id;emit();return publicState();},hasLive:()=>[...instances.values()].some(i=>i.status==='ready'),readyInstances:()=>[...instances.values()].filter(i=>i.status==='ready'),close};
|
||||
return {state:publicState,tuneStatus,tuneEffective,tuneOff,tunePresets:{list:()=>autotuner.list(),action:(kind,id,extra)=>autotuner.action(kind,id,extra),undoFor:async args=>autotuner.undoFor(tuneConfig(args).path)},refresh,localPreview,configure,setDownloadDirectory,planModelsFolder,startModelsFolder,interruptedMove,finishInterruptedMove,leaveInterruptedMove,plan,settings,save,settingsFile,openSettingsFile,operation,cancel,unload,resolveInstance,poll,logs:()=>logs.slice(),benchmarkReport:()=>readJson(join(directory,'native-benchmark.json'),null),setDefault(id){defaultId=resolveInstance(id).id;emit();return publicState();},hasLive:()=>[...instances.values()].some(i=>i.status==='ready'),readyInstances:()=>[...instances.values()].filter(i=>i.status==='ready'),defaultInstanceId:()=>defaultId,close};
|
||||
}
|
||||
@@ -28,9 +28,16 @@ export function defaultReserves(config,hardware,baselineReserve=0){
|
||||
const placement=Array.isArray(config.gpu)?config.gpu:[config.gpu??0];
|
||||
const placed=(hardware?.gpus??[]).filter(g=>placement.includes(g.index)),gpuMemory=placed.map(g=>g.vram_gb).filter(n=>Number.isFinite(n)&&n>0);
|
||||
const headless=placed.length>0&&placed.every(computeOnly); // no display on the card (the desktop is on an integrated GPU): no desktop to keep room for
|
||||
const floor=headless?(config.vision?ENGINE_DEFAULT_RESERVE_MIB:COMPUTE_ONLY_RESERVE_MIB):gpuMemory.length&&Math.min(...gpuMemory)>16?1024:1536;
|
||||
const safety=headless?floor:Math.max(config.vision?2048:floor,baselineReserve);
|
||||
return {work:gpuMemory.length&&!headless?Math.max(safety,Math.floor(Math.min(...gpuMemory)*1024*.2)):safety,performance:safety};
|
||||
/* Only a picture reader that runs on the GPU needs VRAM of its own: the CPU reader (vision.gpu false) took the same 2048 floor and cost a 12 GB card a quarter of its expert cache (0.2.3 tuning, notes-023-tune.md). */
|
||||
const pictures=config.vision?(typeof config.vision==='object'?Boolean(config.vision.gpu):true):false;
|
||||
const floor=headless?(pictures?ENGINE_DEFAULT_RESERVE_MIB:COMPUTE_ONLY_RESERVE_MIB):gpuMemory.length&&Math.min(...gpuMemory)>16?1024:1536;
|
||||
const safety=headless?floor:Math.max(pictures?2048:floor,baselineReserve);
|
||||
/* A card that shares the PC's memory (Strix Halo, other APUs) has no memory of its own to keep a fifth of: that fifth is RAM, and "Leave 20% for my work" already keeps it
|
||||
* through the RAM budget. A fifth of the shared pool here held back 16 GB of expert cache for nothing (#3510); the display floor stays. */
|
||||
const shared=placed.length>0&&placed.some(g=>g.uma===true||g.unified===true);
|
||||
/* "work" keeps a fifth of the card, but on a card of 16 GB or less no more than 1536 MiB: a fifth of 12 GB (2457) cost 20% of the speed against 1536, which already leaves 1.1 GiB of VRAM free after the load (RTX 5070, Gyro-S 64K, 5 pairs; notes-023-tune.md). */
|
||||
const fifth=gpuMemory.length?Math.floor(Math.min(...gpuMemory)*1024*.2):0,cap=gpuMemory.length&&Math.min(...gpuMemory)<=16?1536:Infinity;
|
||||
return {work:gpuMemory.length&&!headless&&!shared?Math.max(safety,Math.min(fifth,cap)):safety,performance:safety};
|
||||
}
|
||||
|
||||
/** The reserve the person asked for (null: nothing) and the default for each profile. A 0.2.0-beta file has the key and the same number in "args" with no note that the
|
||||
@@ -60,7 +67,8 @@ export function resourceSettings(config,hardware,profile,model={},values={}){
|
||||
const cores=Number(hardware?.cpu?.cores)||0;
|
||||
if(profile==='work'&&cores>1){
|
||||
const at=args.indexOf('--pool-workers'),previous=at>=0?Number(args[at+1]):0;
|
||||
const workers=Math.max(1,Math.floor(cores*.8));remove('--pool-workers');
|
||||
/* 80% of the physical cores rounded UP, never more than the cores less the host thread (the engine's own count): floor(0.8 x 6) = 4 ran 8-10% slower than the engine's 5 on a 6-core Ryzen (notes-023-tune.md); 12 logical threads gave 9 in 0.2.1. */
|
||||
const workers=Math.max(1,Math.min(cores-1,Math.ceil(cores*.8)));remove('--pool-workers');
|
||||
args.push('--pool-workers',String(previous>0?Math.min(previous,workers):workers));
|
||||
}
|
||||
// Preserve calibrated worker counts and the installation's safety/vision reserve.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { validMcpName, MCP_NAME_HELP } from './mcp-names.mjs';
|
||||
/** Editable native keys. Unknown config keys and program paths stay intact. */
|
||||
export const NATIVE_FIELDS=[
|
||||
{id:'reasoning_budget_tokens',label:'Thinking token budget',group:'Responses',type:'number',min:0,max:1048576,hint:'Leave blank for the model default.'},
|
||||
@@ -8,6 +9,7 @@ export const NATIVE_FIELDS=[
|
||||
{id:'idle_unload_s',label:'Unload after idle seconds',group:'Serving',type:'number',min:0,max:86400,hint:'Zero keeps the model loaded.'},
|
||||
{id:'lazy_load',label:'Load on first request',group:'Serving',type:'boolean'},
|
||||
{id:'mtp',label:'Draft layer (MTP)',group:'Serving',type:'boolean',hint:'The draft layer guesses several words ahead, which makes answers faster, and costs about 1 GB of graphics memory and some RAM. Turn it off if the model stops while it starts, or the card is small: the expert cache gets that memory back and answers are slower. Default is on.'},
|
||||
{id:'drafting',label:'Drafting',group:'Serving',type:'enum',choices:['auto','mtp','dflash2','off'],hint:'Which drafter guesses words ahead to make answers faster. Auto (recommended) lets Strata pick from what it measured on cards like yours; today that is the MTP. DFlash2 is for Qwen3.8-Flash-Next only and downloads a 1.2 GB drafter once. If DFlash2 cannot run, the MTP is used and Details says why. Off turns the draft layer off.'},
|
||||
{id:'batchMtp',label:'MTP per request',group:'Serving',type:'boolean',hint:'Each concurrent request uses the draft layer. Left alone, Strata turns it on for one graphics card with 24 GB or more and several requests at once, and off otherwise; choose on or off here to decide yourself.'},
|
||||
{id:'parallel',label:'Concurrent requests',group:'Serving',type:'number',min:1,max:8,hint:'More slots need more memory and can slow each response.'},
|
||||
{id:'engine_silence_s',label:'Engine stall timeout (seconds)',group:'Recovery',type:'number',min:10,max:3600},
|
||||
@@ -24,7 +26,7 @@ export const NATIVE_FIELDS=[
|
||||
{id:'mcp_max_result_chars',label:'Maximum tool result characters',group:'Tools',type:'number',min:100,max:1000000},
|
||||
];
|
||||
/** Settings the app itself applies when it starts a model (they are not read from the engine's source, so the engine's text never says it supports them). */
|
||||
export const APP_OWNED_FIELDS=['mtp','batchMtp'];
|
||||
export const APP_OWNED_FIELDS=['mtp','batchMtp','drafting'];
|
||||
/** The engine arguments without the draft layer: --mtp <folder or count>, --spec-min-p <p> (its confidence threshold) and --batch-mtp. --spec stays: prompt lookup still uses its windows. Same rule as strata_control/draft_layer.py. */
|
||||
export function withoutDraftLayer(args){
|
||||
const out=[];const list=(args??[]).map(String);
|
||||
@@ -56,7 +58,9 @@ export function publicMcpServers(servers){return Object.fromEntries(Object.entri
|
||||
export function validateMcpServers(servers,previous={}){
|
||||
if(!servers||typeof servers!=='object'||Array.isArray(servers)||Object.keys(servers).length>30)throw new Error('MCP servers must be an object with at most 30 entries.');
|
||||
const out={};for(const [name,s] of Object.entries(servers)){
|
||||
if(!/^[a-z0-9_-]{1,64}$/i.test(name)||!s||typeof s!=='object'||Array.isArray(s))throw new Error('Give each tool server a simple, unique name.');
|
||||
if(!s||typeof s!=='object'||Array.isArray(s))throw new Error('Give each tool server a simple, unique name.');
|
||||
// a name that was saved before the 32-character rule stays saveable (unrelated settings must not be blocked); the agent reports it as left out
|
||||
if(!validMcpName(name)&&!Object.hasOwn(previous??{},name))throw new Error(MCP_NAME_HELP);
|
||||
if(!!s.command===!!s.url)throw new Error(`${name}: choose a command or HTTP URL.`);
|
||||
for(const key of Object.keys(s))if(!['command','args','env','cwd','url','headers','disabled'].includes(key))throw new Error(`${name}: unsupported property ${key}.`);
|
||||
if(s.command&&(typeof s.command!=='string'||s.command.length>1000||/[\n\r\0]/.test(s.command)))throw new Error(`${name}: choose a valid program.`);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* A message too large for the model's window, made small enough to send (agent/context-fit.mjs decides that it is too large).
|
||||
* The person picks how, on the error card:
|
||||
* file the whole text is saved in the chat's folder and the message names the file; the assistant reads it in parts
|
||||
* preview the start and the end of the text, cut to what the window can hold
|
||||
* Nothing is sent from here: the page sends the returned message as the person's next message.
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { countTokens, oversizedReplacement } from './agent/context-fit.mjs';
|
||||
|
||||
const stamp = d => `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}-${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}${String(d.getSeconds()).padStart(2, '0')}`;
|
||||
|
||||
/**
|
||||
* @param {{mode:'file'|'preview', text:string, root:string|null, instance?:{port:number, key?:string, apiModelId?:string, context?:number, metrics?:any}|null, now?:Date, fetchImpl?:typeof fetch}} input
|
||||
* @returns {Promise<{message:string, mode:string, tokens:number|null, kept:number, path?:string}>}
|
||||
*/
|
||||
export async function prepareOversized({ mode, text, root, instance = null, now = new Date(), fetchImpl = fetch }) {
|
||||
const window = instance?.metrics?.engine?.max_context ?? instance?.context ?? 32768;
|
||||
const tokens = instance ? await countTokens({ baseUrl: `http://127.0.0.1:${instance.port}/v1`, key: instance.key ?? '', model: instance.apiModelId ?? '', text, fetchImpl }) : null;
|
||||
const counted = tokens ?? Math.ceil(text.length / 3); // not countable: three characters a token is on the safe side for code and logs
|
||||
if (mode === 'file') {
|
||||
if (!root) throw new Error('This chat has no folder yet. Choose one first.');
|
||||
mkdirSync(root, { recursive: true });
|
||||
const path = join(root, `pasted-message-${stamp(now)}.txt`);
|
||||
writeFileSync(path, text, { encoding: 'utf8', flag: 'wx' });
|
||||
return { mode, tokens, path, ...oversizedReplacement({ text, mode, tokens: counted, window, path }) };
|
||||
}
|
||||
return { mode: 'preview', tokens, ...oversizedReplacement({ text, mode: 'preview', tokens: counted, window }) };
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Ports the host listens on: a free one for the helpers, and a plain sentence when the main one cannot be taken. */
|
||||
import net from 'node:net';
|
||||
|
||||
/** Why a listen failed, in words a person can act on (Windows keeps whole ranges for Hyper-V, WSL and Docker). */
|
||||
export function listenProblem(error, port, host = '127.0.0.1') {
|
||||
const code = error?.code;
|
||||
if (code === 'EADDRINUSE') return `Port ${port} is already in use. Strata may already be running; close it or the other program, or start Strata with another port (STRATA_PORT).`;
|
||||
if (code === 'EACCES') return `Windows or a firewall does not let Strata use port ${port}. Hyper-V, WSL and Docker keep ranges of ports for themselves (see "netsh int ipv4 show excludedportrange protocol=tcp"). Start Strata with another port (STRATA_PORT), for example 8801.`;
|
||||
if (code === 'EADDRNOTAVAIL') return `${host} is not an address of this computer, so Strata cannot listen on it. Check STRATA_BIND.`;
|
||||
return `Strata cannot listen on ${host}:${port} (${error?.message ?? code}).`;
|
||||
}
|
||||
|
||||
function tryListen(port, host) {
|
||||
return new Promise(resolve => {
|
||||
const probe = net.createServer();
|
||||
probe.once('error', () => resolve(null));
|
||||
probe.listen(port, host, () => { const got = probe.address().port; probe.close(() => resolve(got)); });
|
||||
});
|
||||
}
|
||||
|
||||
/** `preferred` when it can be taken, else the next free port above it (up to `tries` of them), else any free port. Never throws. */
|
||||
export async function findFreePort(preferred, host = '127.0.0.1', { skip = [], tries = 20 } = {}) {
|
||||
for (let offset = 0; offset < tries; offset++) {
|
||||
const port = preferred + offset;
|
||||
if (port > 65535 || skip.includes(port)) continue;
|
||||
const got = await tryListen(port, host);
|
||||
if (got) return got;
|
||||
}
|
||||
return (await tryListen(0, host)) ?? preferred;
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export function patch(body) {
|
||||
const s = getStore();
|
||||
const serving = { ...(s.serving ?? {}) };
|
||||
if (typeof body.otherDevices === 'boolean' && body.otherDevices !== !isLocal()) throw new Error('Change the host listen address and restart to update network access.');
|
||||
if (body.apiKey === null && (!isLocal() || sharing.active || tunnelActive())) throw new Error('Stop local sharing and Cloudflare before removing the API key.');
|
||||
// The key is optional (owner decision, 0.2.2): removing it while sharing or the tunnel runs is allowed; the app warns in plain words (local-share.mjs, cloudflare.mjs).
|
||||
if (body.apiKey === null) serving.apiKey = null;
|
||||
else if (typeof body.apiKey === 'string' && body.apiKey.trim()) serving.apiKey = body.apiKey.trim();
|
||||
else if (body.generateKey) serving.apiKey = `sk-strata-${randomBytes(12).toString('hex')}`;
|
||||
@@ -149,7 +149,8 @@ export function cancel(id) {
|
||||
export function rateSample() {
|
||||
const done = requests.filter((r) => r.state === 'done' && r.tokensOut > 0);
|
||||
if (!done.length) return 0;
|
||||
const tps = done.map((r) => r.tokensOut / Math.max(0.001, r.durationMs / 1000));
|
||||
const tps = done.filter((r) => r.tps > 0).map((r) => r.tps); // answer speeds (decode time), not tokens over the whole request
|
||||
if (!tps.length) return 0;
|
||||
return round(tps.reduce((a, b) => a + b, 0) / tps.length, 1);
|
||||
}
|
||||
|
||||
|
||||
+45
-18
@@ -1,6 +1,7 @@
|
||||
import { migrateSetup } from './setup.mjs';
|
||||
/** Tiny persisted state for the host: one JSON file under STRATA_HOME. */
|
||||
import { accessSync, constants, mkdirSync, readFileSync, writeFileSync, existsSync, realpathSync, readdirSync, statSync, statfsSync, symlinkSync } from 'node:fs';
|
||||
import { alignModels, isDriveRoot, libraryFolderFromPick, makeFolder } from './model-folder.mjs';
|
||||
import { dirname, isAbsolute, join, parse, relative, resolve } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { defaultModelDirectory } from './model-directory-default.mjs';
|
||||
@@ -58,29 +59,53 @@ function read() {
|
||||
|
||||
let state = read();
|
||||
const DEFAULT_MODELS_DIRECTORY=defaultModelDirectory();
|
||||
function initialModelsDirectory() {
|
||||
if(state.modelsDir)return state.modelsDir;
|
||||
const legacy=realpathSync(join(HOME,'models'));
|
||||
// Keep an existing library in place. A new install uses Documents/Strata/Models.
|
||||
/**
|
||||
* The model folder for a new install: the saved one, an existing library inside STRATA_HOME, else Documents/Strata/Models. A Documents folder
|
||||
* that cannot be made (Controlled folder access, a disconnected share, an offline OneDrive known folder) must not stop the host from starting:
|
||||
* the models go to STRATA_HOME/models instead, and `notice` says so in plain words.
|
||||
*/
|
||||
export function pickModelsDirectory({ saved, home, preferred, fixedByEnv = false }) {
|
||||
if (saved) return { directory: saved, notice: '' };
|
||||
const fallback = join(home, 'models');
|
||||
let legacy = fallback;
|
||||
try { legacy = realpathSync(fallback); } catch { /* the folder is made at start; keep the plain path */ }
|
||||
function hasWeights(folder,depth=0){try{return readdirSync(folder,{withFileTypes:true}).some(entry=>entry.isFile()&&/\.gguf(?:\.part|\.partial)?$/i.test(entry.name)||entry.isDirectory()&&depth<4&&hasWeights(join(folder,entry.name),depth+1));}catch{return false;}}
|
||||
if(!process.env.STRATA_MODELS_DIR&&hasWeights(legacy))return legacy;
|
||||
const directory=DEFAULT_MODELS_DIRECTORY;
|
||||
mkdirSync(directory,{recursive:true});
|
||||
return realpathSync(directory);
|
||||
// Keep an existing library in place. A new install uses Documents/Strata/Models.
|
||||
if (!fixedByEnv && hasWeights(legacy)) return { directory: legacy, notice: '' };
|
||||
try {
|
||||
mkdirSync(preferred, { recursive: true });
|
||||
accessSync(preferred, constants.R_OK | constants.W_OK | constants.X_OK);
|
||||
return { directory: realpathSync(preferred), notice: '' };
|
||||
} catch (error) {
|
||||
return { directory: legacy, notice: `Strata could not use ${preferred} for models (${error.code ?? error.message}), so models go to ${legacy}. You can pick another folder in Settings.` };
|
||||
}
|
||||
}
|
||||
export let MODELS_DIR = initialModelsDirectory();
|
||||
const MODELS_PICK = pickModelsDirectory({ saved: state.modelsDir, home: HOME, preferred: DEFAULT_MODELS_DIRECTORY, fixedByEnv: !!process.env.STRATA_MODELS_DIR });
|
||||
/** Plain words for the person when the usual model folder could not be used (empty otherwise). */
|
||||
export const MODELS_DIR_NOTICE = MODELS_PICK.notice;
|
||||
if (MODELS_DIR_NOTICE) console.warn(`[strata] ${MODELS_DIR_NOTICE}`);
|
||||
export let MODELS_DIR = MODELS_PICK.directory;
|
||||
|
||||
/** Native setup writes weights to data_dir/models. Keep its data root one level up. */
|
||||
export function nativeDataDirectory(input) {
|
||||
if(!input||process.env.STRATA_MODELS_DIR||!existsSync(DEFAULT_MODELS_DIRECTORY))return input;
|
||||
const root=realpathSync(DEFAULT_MODELS_DIRECTORY);
|
||||
if(resolve(input)!==resolve(DEFAULT_MODELS_DIRECTORY)&&realpathSync(input)!==root)return input;
|
||||
const data=dirname(root),nativeModels=join(data,'models');
|
||||
// On Windows/macOS case-insensitive volumes, Models already resolves as models.
|
||||
// On Linux, a relative alias keeps the native layout inside the requested folder.
|
||||
if(!existsSync(nativeModels))symlinkSync(process.platform==='win32'?root:'Models',nativeModels,process.platform==='win32'?'junction':'dir');
|
||||
const a=statSync(nativeModels),b=statSync(root);
|
||||
if(realpathSync(nativeModels)!==root&&!(a.ino&&a.ino===b.ino&&a.dev===b.dev))throw new Error('The default model folder conflicts with another library. Choose a different folder.');
|
||||
if(!input||!existsSync(input))return input;
|
||||
if(MODELS_DIR_NOTICE)return input;
|
||||
const root=realpathSync(input),legacy=realpathSync(join(HOME,'models'));
|
||||
// Retained legacy libraries are model folders too, not native data roots.
|
||||
// A named data root satisfies the installer's guard; its alias leaves weights in place.
|
||||
if(root===legacy){
|
||||
const data=join(HOME,'Strata-data'),nativeModels=join(data,'models');
|
||||
mkdirSync(data,{recursive:true});
|
||||
// Unlike a fresh default, a retained library must not fall back to an empty folder if linking fails.
|
||||
if(!existsSync(nativeModels))symlinkSync(process.platform==='win32'?root:relative(data,root),nativeModels,process.platform==='win32'?'junction':'dir');
|
||||
const a=statSync(nativeModels),b=statSync(root);
|
||||
if(realpathSync(nativeModels)!==root&&!(a.ino&&a.ino===b.ino&&a.dev===b.dev))throw new Error('The model folder conflicts with another library. Choose a different folder.');
|
||||
return data;
|
||||
}
|
||||
if(process.env.STRATA_MODELS_DIR||!existsSync(DEFAULT_MODELS_DIRECTORY)||root!==realpathSync(DEFAULT_MODELS_DIRECTORY))return input;
|
||||
const data=dirname(root);
|
||||
// `models` and `Models` are one folder on Windows and macOS; where they are not (Linux) a link makes them one, and where no link can be made (exFAT, a share) models go in `models`.
|
||||
alignModels(data,root);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -95,6 +120,8 @@ export function validateModelsDirectory(input) {
|
||||
if (!statSync(directory).isDirectory()) throw new Error();
|
||||
accessSync(directory, constants.R_OK | constants.W_OK | constants.X_OK);
|
||||
} catch { throw new Error('This folder is unavailable or not writable. Check the path and folder permissions.'); }
|
||||
// A whole drive (D:\) is not filled with models: D:\Strata\Models is made and used.
|
||||
if (isDriveRoot(directory)) directory = realpathSync(makeFolder(libraryFolderFromPick(directory).path));
|
||||
const data = realpathSync(DATA_DIR);
|
||||
const contains = (parent, child) => { const r = relative(parent, child); return !r || !r.startsWith('..') && !isAbsolute(r); };
|
||||
if (directory === parse(directory).root || contains(directory, data) || contains(data, directory)) throw new Error('Choose a separate folder for models, outside Strata’s app data.');
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// The host's own HTTPS downloads (engine releases from GitHub, web search, a cloud model's API) check the server against the operating
|
||||
// system's certificates as well as Node's bundled list (#3722). Node carries its own list of roots; Chrome and curl.exe use the Windows
|
||||
// store, which also holds the roots an antivirus's HTTPS scanning, a company proxy or an OS preview build adds. Nothing is turned off:
|
||||
// the system roots are ADDED to the bundled ones, and a connection that still fails keeps failing.
|
||||
//
|
||||
// Node 24.5 and newer: tls.getCACertificates('system') and tls.setDefaultCACertificates. An older Node (the app bundles 24.x) does nothing here;
|
||||
// the agent process gets NODE_USE_SYSTEM_CA=1 (harness-runtime.mjs) for the same reason.
|
||||
import tlsModule from 'node:tls';
|
||||
|
||||
/** @returns {'system'|'bundled'} what the process trusts after the call */
|
||||
export function useSystemCertificates(tls = tlsModule, env = process.env) {
|
||||
if (env.STRATA_NO_SYSTEM_CA === '1') return 'bundled'; // a way back for a PC where reading the store misbehaves; not a way to skip checks
|
||||
try {
|
||||
if (typeof tls.getCACertificates !== 'function' || typeof tls.setDefaultCACertificates !== 'function') return 'bundled';
|
||||
const system = tls.getCACertificates('system');
|
||||
if (!Array.isArray(system) || !system.length) return 'bundled';
|
||||
tls.setDefaultCACertificates([...new Set([...tls.getCACertificates('bundled'), ...system])]);
|
||||
return 'system';
|
||||
} catch {
|
||||
return 'bundled';
|
||||
}
|
||||
}
|
||||
|
||||
export const trust = useSystemCertificates();
|
||||
@@ -35,7 +35,7 @@ export function plainUpdateError(error, subject = 'engine') {
|
||||
} else if (/another update is already running/i.test(raw)) {
|
||||
message = 'An update is already running.';
|
||||
next = `Wait a moment, then press ${button} in Settings.`;
|
||||
} else if (/test start|did not answer a one-token|did not become ready/i.test(raw)) {
|
||||
} else if (/test start|did not start correctly|did not answer a one-token|did not become ready/i.test(raw)) {
|
||||
message = `The new ${subject} didn't start correctly, so nothing was changed. ${keep}`;
|
||||
} else if (looksOffline(error) || looksOffline(raw)) {
|
||||
message = 'No internet connection.';
|
||||
|
||||
@@ -67,6 +67,7 @@ export async function checkComponentUpdates({ control, appUpdater = null, appVer
|
||||
if (engine.status === 'available') Object.assign(engineView, { status: 'available', message: `A new engine is available (${mib(engine.download_bytes)}).` });
|
||||
else if (engine.status === 'install') Object.assign(engineView, { status: 'available', message: `This PC needs an engine to run models. Press Update engine to download it (${mib(engine.download_bytes)}).` });
|
||||
else if (engine.status === 'current' || engine.status === 'failed-before' || engine.status === 'skipped') Object.assign(engineView, { status: 'current', message: 'The engine is up to date.', details: engine.note });
|
||||
else if (engine.status === 'wrong-make') Object.assign(engineView, { status: 'unavailable', message: engine.note ?? 'Nothing was changed: the update was for another make of graphics card.', details: (engine.reasons ?? []).join('\n') || undefined });
|
||||
else if (engine.status === 'not-in-feed') Object.assign(engineView, { status: 'current', message: 'No engine update is listed right now.' });
|
||||
else Object.assign(engineView, { status: 'unavailable', message: "There isn't an engine for this PC's graphics card yet.", details: (engine.reasons ?? []).join('\n') || undefined });
|
||||
const control_ = report.control ?? {};
|
||||
|
||||
@@ -19,7 +19,7 @@ export function CloudflareDialog({ open, onClose, onCreateKey }: {
|
||||
const steps = mode === 'quick' ? ['Address', 'Connector', 'API key', 'Connect'] : ['Address', 'Connector', 'API key', 'Domain', 'Connect'];
|
||||
const last = steps.length - 1;
|
||||
const installed = !!tunnel?.installed, keyReady = !!serving?.access.apiKeySet, other = tunnel?.external ?? null;
|
||||
const canContinue = step === 1 ? installed : step === 2 ? keyReady : step === 3 && mode === 'domain' ? !!hostname.trim() && !!token.trim() : true;
|
||||
const canContinue = step === 1 ? installed : step === 2 ? true : step === 3 && mode === 'domain' ? !!hostname.trim() && !!token.trim() : true;
|
||||
async function inspect() { setPending(true); setError(''); try {
|
||||
setDetected(await api.get<TunnelState>('/api/serving/tunnel'));
|
||||
}
|
||||
@@ -80,7 +80,7 @@ export function CloudflareDialog({ open, onClose, onCreateKey }: {
|
||||
} }
|
||||
const titles = ['Choose your address', installed ? 'Connector ready' : 'Preparing your connector', 'Protect your connection', ...(mode === 'domain' ? ['Set up your domain'] : []), 'Ready to connect'];
|
||||
return <Dialog open={open} title={tr("Connect with Cloudflare")} onClose={dismiss} actions={<>
|
||||
{tr(active ? <><Btn onClick={dismiss}>{tr("Done")}</Btn><Btn disabled={pending || tunnel?.status === 'stopping'} onClick={() => void stop()}>{tr(tunnel?.status === 'stopping' ? 'Stopping…' : 'Stop tunnel')}</Btn></> : <><Btn disabled={pending} onClick={back}>{tr(step ? 'Back' : 'Cancel')}</Btn>{tr(step === last ? <Btn variant="primary" disabled={pending || !installed || !keyReady || (mode === 'domain' && (!hostname.trim() || !token.trim()))} onClick={() => void connect()}>{tr(pending ? 'Connecting…' : (error || tunnel?.error) ? 'Try again' : mode === 'quick' ? 'Create temporary URL' : 'Connect domain')}</Btn> : <Btn variant="primary" disabled={pending || !canContinue} onClick={next}>{tr("Continue")}<Icon name="chevron" className="wizard-next" size={14}/></Btn>)}</>)}
|
||||
{tr(active ? <><Btn onClick={dismiss}>{tr("Done")}</Btn><Btn disabled={pending || tunnel?.status === 'stopping'} onClick={() => void stop()}>{tr(tunnel?.status === 'stopping' ? 'Stopping…' : 'Stop tunnel')}</Btn></> : <><Btn disabled={pending} onClick={back}>{tr(step ? 'Back' : 'Cancel')}</Btn>{tr(step === last ? <Btn variant="primary" disabled={pending || !installed || (mode === 'domain' && (!hostname.trim() || !token.trim()))} onClick={() => void connect()}>{tr(pending ? 'Connecting…' : (error || tunnel?.error) ? 'Try again' : mode === 'quick' ? 'Create temporary URL' : 'Connect domain')}</Btn> : <Btn variant="primary" disabled={pending || !canContinue} onClick={next}>{tr("Continue")}<Icon name="chevron" className="wizard-next" size={14}/></Btn>)}</>)}
|
||||
</>}>
|
||||
<div className="tunnel-wizard">
|
||||
{tr(!active ? <ol className="tunnel-progress" aria-label={tr("Connection progress")}>{tr(steps.map((label, i) => <li key={label} data-current={step === i || undefined} data-complete={step > i || undefined}><button disabled={i >= step || pending} aria-current={step === i ? 'step' : undefined} onClick={() => { setError(''); setStep(i); }}><span>{tr(step > i ? <Icon name="check" size={13}/> : i + 1)}</span><small>{tr(label)}</small></button></li>))}</ol> : null)}
|
||||
@@ -92,9 +92,9 @@ export function CloudflareDialog({ open, onClose, onCreateKey }: {
|
||||
(e.currentTarget.parentElement?.children[mode === 'quick' ? 1 : 0] as HTMLElement)?.focus();
|
||||
} }} onClick={() => setMode(choice.value)}><Icon name={choice.icon} size={20}/><span><strong>{tr(choice.title)}</strong><small>{tr(choice.detail)}</small></span><span className="tunnel-choice-dot">{tr(mode === choice.value ? <Icon name="check" size={13}/> : null)}</span></button>))}</div></> : null)}
|
||||
{tr(step === 1 ? <div className="tunnel-automatic" data-ready={installed || undefined} aria-live="polite" aria-busy={pending}><span className="tunnel-stage__symbol"><Icon name={installed ? 'check' : error ? 'warning' : 'refresh'} size={28}/></span><p className="small muted">{tr(installed ? 'Your Cloudflare connector is installed and ready.' : error ? 'Automatic setup needs another try.' : 'Strata downloads and sets up the Cloudflare connector for you.')}</p>{tr(installed ? <>{tr(tunnel?.connector ? <p className="xs muted">{tr("Using {0}", [tunnel.connector.summary])}</p> : null)}{tr(other ? <p className="xs muted" role="status">{tr(other.others.length === 1 && !other.own.length ? "cloudflared is already running for another tunnel (PID {0})." : other.own.length ? "Strata’s tunnel for this address is already running (PID {0})." : "cloudflared is already running for other tunnels (PIDs {0}).", [(other.own.length ? other.own : other.others).join(", ")])}</p> : null)}</> : error ? <Btn onClick={() => setRetry(v => v + 1)}>{tr("Retry setup")}</Btn> : <span className="small muted" role="status">{tr("Setting up\u2026")}</span>)}</div> : null)}
|
||||
{tr(step === 2 ? <><p className="small muted">{tr("Only apps with your Strata API key can use this connection.")}</p>{tr(keyReady ? <div className="tunnel-check"><Icon name="shield"/><div><strong>{tr("API key ready")}</strong><span>{tr("Use your existing key in the app you connect.")}</span></div></div> : <div className="tunnel-key-step"><Icon name="shield" size={30}/><p className="small">{tr("Create a key to protect your API. You\u2019ll return here when it\u2019s ready.")}</p><Btn onClick={onCreateKey}>{tr("Create an API key")}</Btn></div>)}</> : null)}
|
||||
{tr(step === 2 ? <><p className="small muted">{tr(keyReady ? "Only apps with your Strata API key can use this connection." : "Without an API key, anyone who finds the link can use your model.")}</p>{tr(keyReady ? <div className="tunnel-check"><Icon name="shield"/><div><strong>{tr("API key ready")}</strong><span>{tr("Use your existing key in the app you connect.")}</span></div></div> : <div className="tunnel-key-step"><Icon name="shield" size={30}/><p className="small">{tr("Create a key to protect your API. You\u2019ll return here when it\u2019s ready. You can also continue without one, but anyone who finds the link can then use your model.")}</p><Btn onClick={onCreateKey}>{tr("Create an API key (recommended)")}</Btn></div>)}</> : null)}
|
||||
{tr(mode === 'domain' && step === 3 ? <><p className="small muted">{tr("Create a tunnel in your ")}<a href="https://dash.cloudflare.com/" target="_blank" rel="noreferrer">{tr("Cloudflare dashboard \u2197")}</a>{tr(". Copy the token from its connector setup.")}</p><label className="input-field"><span>{tr("Tunnel token")}</span><input type="password" className="st-input" autoComplete="off" spellCheck={false} value={token} onChange={e => setToken(e.target.value)} placeholder={tr("Paste only the token after --token")}/></label><label className="input-field"><span>{tr("Your hostname")}</span><input className="st-input" autoComplete="off" spellCheck={false} value={hostname} onChange={e => setHostname(e.target.value)} placeholder={tr("ai.example.com")}/></label><div><p className="small muted">{tr("Add a published application route for this hostname with this service URL:")}</p><div className="install-command"><code>{tunnel?.originUrl ?? 'Loading…'}</code><IconBtn name="copy" title={tr("Copy tunnel service URL")} disabled={!tunnel?.originUrl} onClick={() => void navigator.clipboard.writeText(tunnel!.originUrl)}/></div></div><p className="xs muted">{tr("The token is used for this connection only and is not saved.")}</p></> : null)}
|
||||
{tr(step === last ? <><p className="small muted">{tr(mode === 'quick' ? 'Cloudflare will create your address when you connect.' : 'Start the connector for the domain you configured.')}</p><dl className="tunnel-review"><div><dt>{tr("Address")}</dt><dd>{tr(mode === 'quick' ? 'Free temporary URL' : hostname)}</dd></div><div><dt>{tr("Connector")}</dt><dd><Icon name="check" size={14}/>{tr(tunnel?.connector?.version ? "Installed · cloudflared {0}" : "Installed", tunnel?.connector?.version ? [tunnel.connector.version] : undefined)}</dd></div><div><dt>{tr("Access")}</dt><dd><Icon name="shield" size={14}/>{tr("API key required")}</dd></div></dl><p className="small muted">{tr(mode === 'quick' ? 'The address changes each time. Turn off streaming in your client; replies arrive all at once.' : 'Use this hostname and your Strata API key in your client. Streaming is supported.')}</p><p className="xs muted">{tr("Only the inference API is shared. Your computer and Strata must stay on.")}</p></> : null)}
|
||||
{tr(step === last ? <><p className="small muted">{tr(mode === 'quick' ? 'Cloudflare will create your address when you connect.' : 'Start the connector for the domain you configured.')}</p><dl className="tunnel-review"><div><dt>{tr("Address")}</dt><dd>{tr(mode === 'quick' ? 'Free temporary URL' : hostname)}</dd></div><div><dt>{tr("Connector")}</dt><dd><Icon name="check" size={14}/>{tr(tunnel?.connector?.version ? "Installed · cloudflared {0}" : "Installed", tunnel?.connector?.version ? [tunnel.connector.version] : undefined)}</dd></div><div><dt>{tr("Access")}</dt><dd><Icon name="shield" size={14}/>{tr(keyReady ? "API key required" : "No API key: anyone with the link can use it")}</dd></div></dl><p className="small muted">{tr(mode === 'quick' ? 'The address changes each time. Turn off streaming in your client; replies arrive all at once.' : 'Use this hostname and your Strata API key in your client. Streaming is supported.')}</p><p className="xs muted">{tr("Only the inference API is shared. Your computer and Strata must stay on.")}</p></> : null)}
|
||||
</section>)}
|
||||
{tr(error || tunnel?.error ? <ErrorNotice message={error || tunnel?.error || 'Connection failed'} next={tr("Check your internet connection, then retry.")}/> : null)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { tr } from '../lib/i18n';
|
||||
import { useId, useState } from 'react';
|
||||
import { Btn, Icon } from './ui';
|
||||
import {
|
||||
CPU_ONLY_LINE, NO_CARD_LINE, OFF_AT_OR_BELOW_GB, gpuLabel, noteText, order, recommend, setRole, shortName, vramOn, warnings,
|
||||
type Role, type RoleCard, type Roles, type RoleWarning,
|
||||
} from '../lib/gpu-roles';
|
||||
|
||||
const ROLE_WORD: Record<Role, string> = { main: 'Main', helper: 'Helper', off: 'Off' };
|
||||
const HINT: Record<Role, string> = { main: 'Does the main work', helper: 'Holds part of the model', off: 'Not used' };
|
||||
|
||||
/** The one plain line above the cards, built from translated pieces. */
|
||||
export function recommendationLine(cards: RoleCard[], vendor?: string | null): string {
|
||||
const rec = recommend(cards, vendor);
|
||||
if (rec.main === null) return tr('No graphics card that Strata can use was found.');
|
||||
const byi = new Map(cards.map(c => [c.index, c]));
|
||||
const m = byi.get(rec.main)!;
|
||||
if (cards.length === 1) return tr('Strata will use your {0}.', [shortName(m)]);
|
||||
const why = { same: 'the cards are alike', memory: 'it has more memory', faster: 'it is faster', only: 'it is the only card', none: '' }[rec.why];
|
||||
let s = tr('{0} is Main: {1}.', [shortName(m), tr(why)]);
|
||||
const helpers = Object.keys(rec.roles).map(Number).filter(i => rec.roles[i] === 'helper').map(i => shortName(byi.get(i)!));
|
||||
if (helpers.length) s += ' ' + tr(helpers.length > 1 ? '{0} help.' : '{0} helps.', [helpers.join(', ')]);
|
||||
for (const i of Object.keys(rec.roles).map(Number)) if (rec.roles[i] === 'off') {
|
||||
const code = rec.reasons[i], c = byi.get(i)!;
|
||||
s += ' ' + tr('{0} starts Off: {1}', [shortName(c), code === 'cannot_run' ? tr('Strata cannot run it.') : code === 'igpu' ? tr('built-in graphics.') : tr('{0} GB or less.', [OFF_AT_OR_BELOW_GB])]);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function warningText(w: RoleWarning, cards: RoleCard[]): string {
|
||||
const c = w.card !== null ? cards.find(x => x.index === w.card) : undefined;
|
||||
if (w.code === 'cannot_run') return tr('{0} cannot run Strata.', [c ? shortName(c) : '']);
|
||||
if (w.code === 'igpu_main') return tr('{0} is built-in graphics; it is slow as Main.', [c ? shortName(c) : '']);
|
||||
if (w.code === 'cpu_only') return tr(w.text === CPU_ONLY_LINE ? CPU_ONLY_LINE : NO_CARD_LINE);
|
||||
return tr('Not enough memory for this setup: about {0} GB is read from the drive.', [w.text.match(/about (\d+) GB/)?.[1] ?? '']);
|
||||
}
|
||||
|
||||
export function noteLine(code: string | null, cards: RoleCard[]): string {
|
||||
if (!code) return '';
|
||||
const kind = code.split(':')[0], c = cards.find(x => x.index === Number(code.split(':')[1]));
|
||||
if (kind === 'old_main_helper') return tr('{0} is now a Helper.', [c ? shortName(c) : '']);
|
||||
if (kind === 'promoted') return tr('{0} is now Main.', [c ? shortName(c) : '']);
|
||||
if (kind === 'only_main') return tr('The only card that is on has to be Main.');
|
||||
return noteText(code, cards);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main / Helper / Off for every graphics card (the mockup GpuRoles-mockup.html). One click changes a role; "Back to recommended" restores the
|
||||
* recommendation. Warnings are short and never block Load. `roles` are the roles in force (the recommendation until the person changes one).
|
||||
*/
|
||||
export function GpuRoles({ cards, roles, custom, recommendedRoles, vendor, onChange, onBack, memoryGb, ramGb, spillGb = 0, engineRunsCpuOnly = false, disabled = false, usedBy }: {
|
||||
cards: RoleCard[];
|
||||
roles: Roles;
|
||||
/** The person changed a role: "Your choice", and "Back to recommended" is offered. */
|
||||
custom: boolean;
|
||||
/** The roles Strata recommends (the tags and "Back to recommended" use them); the pure recommendation when not given. */
|
||||
recommendedRoles?: Roles;
|
||||
vendor?: string | null;
|
||||
onChange: (next: Roles) => void;
|
||||
onBack: () => void;
|
||||
memoryGb?: number | null;
|
||||
ramGb?: number | null;
|
||||
/** About how many GB of the model are read from the drive with these cards (0 when everything fits or it is unknown). */
|
||||
spillGb?: number;
|
||||
engineRunsCpuOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
/** Names of the loaded models that already use a card ("In use by ..."). */
|
||||
usedBy?: (card: number) => string[];
|
||||
}) {
|
||||
const titleId = useId();
|
||||
const [note, setNote] = useState('');
|
||||
const rec = { ...recommend(cards, vendor), ...(recommendedRoles ? { roles: recommendedRoles } : {}) };
|
||||
const single = cards.length === 1;
|
||||
const warns = warnings(roles, cards, { spillGb, engineRunsCpuOnly });
|
||||
const cardWarn = (i: number) => warns.filter(w => w.card === i);
|
||||
const globalWarn = warns.filter(w => w.card === null);
|
||||
const lst = order(roles, cards);
|
||||
const change = (idx: number, role: Role) => {
|
||||
const out = setRole(roles, cards, idx, role);
|
||||
setNote(noteLine(out.note, cards));
|
||||
onChange(out.roles);
|
||||
};
|
||||
const back = () => { setNote(''); onBack(); };
|
||||
const fix = (w: RoleWarning) => w.fix && change(w.fix.card, w.fix.role);
|
||||
const fixLabel = (w: RoleWarning) => {
|
||||
if (!w.fix) return '';
|
||||
const target = cards.find(c => c.index === w.fix!.card);
|
||||
return w.fix.role === 'off' ? tr('Turn it off') : tr('Make {0} Main', [target ? shortName(target) : '']);
|
||||
};
|
||||
const have = vramOn(roles, cards) + Math.max(0, (ramGb ?? 0) - 10);
|
||||
return <div className="gpuroles" aria-labelledby={titleId} data-custom={custom}>
|
||||
<div className="gpuroles__head">
|
||||
<span className="native-choice__label" id={titleId}>{tr('Graphics cards')}</span>
|
||||
{custom ? <button type="button" className="gpuroles__link" disabled={disabled} onClick={back}>{tr('Back to recommended')}</button> : null}
|
||||
</div>
|
||||
<div className="gpuroles__reco" data-custom={custom}>
|
||||
<Icon name="bolt" size={18}/>
|
||||
<p><strong>{tr(custom ? 'Your choice.' : 'Recommended.')}</strong> {custom ? tr('You changed the roles. Strata will use exactly these.') : recommendationLine(cards, vendor)}</p>
|
||||
</div>
|
||||
<div className="gpuroles__rows" role="list">
|
||||
{cards.map(c => {
|
||||
const role = roles[c.index] ?? 'off', isRec = rec.roles[c.index] === role, w = cardWarn(c.index), other = usedBy?.(c.index) ?? [];
|
||||
const shared = !!(c.integrated || c.uma || c.unified_gb);
|
||||
const options: Role[] = single ? ['main', 'off'] : ['main', 'helper', 'off'];
|
||||
return <div className="gpuroles__row" role="listitem" data-role={role} key={c.index}>
|
||||
<div className="gpuroles__top">
|
||||
<span className="gpuroles__ico"><Icon name="gpu" size={20}/></span>
|
||||
<span className="gpuroles__name">
|
||||
<strong>{gpuLabel(c)} · {c.name}{isRec ? <span className="gpuroles__tag">{tr('Recommended')}</span> : <span className="gpuroles__tag gpuroles__tag--neutral">{tr('Recommended: {0}', [tr(ROLE_WORD[rec.roles[c.index] ?? 'off'])])}</span>}</strong>
|
||||
<small>{shared ? tr("Uses your computer's memory (built-in graphics)") : tr('{0} GB memory', [Math.round(c.vram_gb)])} · {tr(HINT[role])}</small>
|
||||
</span>
|
||||
<span className="gpuroles__roles" role="radiogroup" aria-label={tr('Role of {0}', [gpuLabel(c)])}>
|
||||
{options.map(r => <button type="button" role="radio" key={r} data-r={r} aria-checked={role === r} disabled={disabled} onClick={() => change(c.index, r)}>{tr(ROLE_WORD[r])}</button>)}
|
||||
</span>
|
||||
</div>
|
||||
{other.length ? <div className="gpuroles__note gpuroles__note--info"><Icon name="info" size={16}/><p>{tr('In use by {0}', [other.join(', ')])}</p></div> : null}
|
||||
{w.map(x => <div className="gpuroles__note gpuroles__note--warn" key={x.code}><Icon name="warning" size={16}/><p>{warningText(x, cards)}</p>{x.fix ? <Btn size="sm" disabled={disabled} onClick={() => fix(x)}>{fixLabel(x)}</Btn> : null}</div>)}
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
<div className="gpuroles__toast" role="status" aria-live="polite">{note ? <div className="gpuroles__note gpuroles__note--info"><Icon name="info" size={16}/><p>{note}</p></div> : null}</div>
|
||||
{globalWarn.map(x => <div className="gpuroles__note gpuroles__note--warn" key={x.code}><Icon name="warning" size={16}/><p>{warningText(x, cards)}</p>{x.code === 'cpu_only' && custom ? <Btn size="sm" disabled={disabled} onClick={back}>{tr('Back to recommended')}</Btn> : null}</div>)}
|
||||
<details className="gpuroles__details">
|
||||
<summary>{tr('Details')}</summary>
|
||||
<div className="gpuroles__body">
|
||||
<dl>
|
||||
<dt>{tr('Main')}</dt><dd>{tr('The card that does the final steps of every answer: it writes the next words, runs the draft-ahead step and checks it, and keeps your conversation. Pick your fastest card with plenty of memory. There is only ever one.')}</dd>
|
||||
<dt>{tr('Helper')}</dt><dd>{tr('Holds a share of the model and its fast memory, so more of it fits on graphics cards instead of being read from your drive. Prompts are read about 20% faster.')}</dd>
|
||||
<dt>{tr('Off')}</dt><dd>{tr('Strata does not touch this card. Good for a card you use for something else, such as a game or your screen.')}</dd>
|
||||
</dl>
|
||||
{memoryGb ? <div className="gpuroles__fit" data-state={lst.length ? (spillGb >= 1 ? 'bad' : 'ok') : 'bad'}><span className="gpuroles__dot" aria-hidden="true"/><span>{lst.length ? (spillGb >= 1 ? tr('The model needs about {0} GB. These cards and your memory give about {1} GB, so about {2} GB would be read from your drive.', [Math.round(memoryGb), Math.round(have), Math.round(spillGb)]) : tr('The model needs about {0} GB. These cards and your memory give about {1} GB. It fits.', [Math.round(memoryGb), Math.round(have)])) : tr('Only your processor and memory are used.')}</span></div> : null}
|
||||
<div className="gpuroles__expert xs muted">{lst.length ? tr('For experts: the engine starts on cards {0} (Main last).', [lst.join(',')]) : null} {custom ? '' : tr('Not saved while you keep the recommendation: Strata picks again if you add or remove a card.')}</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>;
|
||||
}
|
||||
@@ -78,6 +78,19 @@ export function GuidedSetup() {
|
||||
throw e;
|
||||
} });
|
||||
}
|
||||
/* Files the person already has are checked and adopted by the "Use a local GGUF" page, not made the data folder (that downloaded them again, #3507). */
|
||||
async function ownModel(path?: string) {
|
||||
await perform(async () => { leaving.current = true; if (saveTimer.current)
|
||||
clearTimeout(saveTimer.current); try {
|
||||
await persist(step, { manual: true }, true);
|
||||
openSetup(false);
|
||||
navigate('models', 'local', path ? { path } : undefined);
|
||||
}
|
||||
catch (e) {
|
||||
leaving.current = false;
|
||||
throw e;
|
||||
} });
|
||||
}
|
||||
async function finish() {
|
||||
if (leaving.current)
|
||||
return;
|
||||
@@ -192,9 +205,9 @@ export function GuidedSetup() {
|
||||
<OnboardingMemory hardware={hardware} native={native?.hardware}/>{tr(!version || version.needsNewerEngine ? <p className="small muted">{tr("This model needs a newer engine. Update your engine or import a local GGUF for this model.")}</p> : null)}
|
||||
<div className="onboard-version-heading"><strong>{tr("Choose a version")}</strong><span>{tr(version ? `${catalogVersionLabel(version)} selected` : '')}</span></div>
|
||||
<div className="onboard-versions" role="radiogroup" aria-label={tr("Model version")} onKeyDown={radioKeys}>{tr(cards.map(v => versionCard(v)))}</div>
|
||||
{tr(others.length ? <div className="onboard-more-versions"><VersionPicker versions={others} selectedId={version?.id ?? ''} hardware={hardware ? { ram_gb: native?.hardware?.ram_gb ?? hardware.memory.totalGb, vram_gb: Math.max(0, ...(native?.hardware?.gpus ?? []).filter(g => g.usable !== false).map(g => g.vram_gb)) } : null} downloaded={readyIds} showRecommended={false} moreLabel="More versions" onSelect={id => { setSize(id); setPlan(null); }} onOwnGguf={() => setFolderOpen(true)}/></div> : null)}
|
||||
{tr(others.length ? <div className="onboard-more-versions"><VersionPicker versions={others} selectedId={version?.id ?? ''} hardware={hardware ? { ram_gb: native?.hardware?.ram_gb ?? hardware.memory.totalGb, vram_gb: Math.max(0, ...(native?.hardware?.gpus ?? []).filter(g => g.usable !== false).map(g => g.vram_gb)) } : null} downloaded={readyIds} showRecommended={false} moreLabel="More versions" onSelect={id => { setSize(id); setPlan(null); }} onOwnGguf={() => void ownModel()}/></div> : null)}
|
||||
<div className="onboard-folder"><Icon name="folder" size={20}/><div><strong>{tr(directory ? 'Your model folder' : 'Already downloaded the model?')}</strong><small>{tr(directory || "Choose the folder and we'll start your models with Strata.")}</small></div><Btn size="sm" onClick={() => setFolderOpen(v => !v)}>{tr(folderOpen ? 'Close' : directory ? 'Change' : 'Choose folder')}</Btn></div>
|
||||
{tr(folderOpen ? <OnboardingFolder directory={folder} onChoose={path => void perform(async () => { await nativeAction('directory', { directory: path }); setDirectory(path); setFolderOpen(false); setPlan(null); })}/> : null)}
|
||||
{tr(folderOpen ? <OnboardingFolder directory={folder} onChoose={(path, ggufFiles) => ggufFiles > 0 && !/strata/i.test(path.split(/[\\/]/).filter(Boolean).pop() ?? '') ? void ownModel(path) : void perform(async () => { await nativeAction('directory', { directory: path }); setDirectory(path); setFolderOpen(false); setPlan(null); })}/> : null)}
|
||||
</> : <div className="onboard-engine-connect"><Icon name="models" size={24}/><strong>{tr("Connect your Strata installation")}</strong><Btn onClick={() => void skip(false, 'Engine')}>{tr("Connect engine")}</Btn></div>)}
|
||||
</div> : step === 3 ? <div className="onboard-prepare-stage">
|
||||
<section className="onboard-download"><EngineWarning native={native}/><div className="onboard-download-identity"><Icon name="models" size={19}/><strong>{tr(selectedFamily?.title)}</strong><span>{tr(version ? catalogVersionLabel(version) : '')}</span><small>{tr(version?.model)}</small></div><OnboardingArt kind="download" progress={progress?.percent ?? 0} counter={counter}/><div className="onboard-download-info">
|
||||
@@ -228,7 +241,7 @@ function OnboardingError({ message, onFolder }: {
|
||||
}
|
||||
function OnboardingFolder({ directory, onChoose }: {
|
||||
directory: string;
|
||||
onChoose: (path: string) => void;
|
||||
onChoose: (path: string, ggufFiles: number) => void;
|
||||
}) {
|
||||
const [path, setPath] = useState(directory), [folder, setFolder] = useState<LocalModelFolder | null>(null), [pending, setPending] = useState(false), [error, setError] = useState('');
|
||||
async function browse(input: string, initial = false) { setPending(true); setError(''); try {
|
||||
@@ -252,5 +265,5 @@ function OnboardingFolder({ directory, onChoose }: {
|
||||
setPending(false);
|
||||
} }
|
||||
useEffect(() => { void browse(directory, true); }, []);
|
||||
return <section className="onboard-folder-browser" aria-label={tr("Choose your model folder")}><form onSubmit={e => { e.preventDefault(); void browse(path); }}><input className="st-input" aria-label={tr("Existing model folder")} value={path} onChange={e => { setPath(e.target.value); setFolder(null); }} placeholder={tr("Full folder path")} spellCheck={false}/><Btn type="submit" size="sm" disabled={pending || !path.trim()}>{tr(pending ? 'Checking…' : 'Open')}</Btn></form><p>{tr("Choose the Strata data folder that holds your model downloads. Matching files are reused; missing files are prepared automatically.")}</p><div className="onboard-folder-shortcuts">{folder?.shortcuts?.map(item => <Btn key={item.path} size="sm" disabled={pending} onClick={() => void browse(item.path)}>{tr(item.name)}</Btn>)}</div><div className="onboard-folder-files">{tr(folder?.parent ? <button onClick={() => void browse(folder.parent!)}><Icon name="folder" size={16}/>{tr(" Up a folder")}</button> : null)}{folder?.entries.filter(e => e.kind === 'folder').slice(0, 30).map(e => <button key={e.path} onClick={() => void browse(e.path)}><Icon name="folder" size={16}/>{tr(e.name)}<Icon name="chevron" size={12}/></button>)}{tr(folder ? <small>{tr(folder.entries.filter(e => e.kind === 'model').length)}{tr(" GGUF files in this folder. Nested downloads are checked by the installer.")}</small> : null)}</div>{tr(folder ? <Btn variant="primary" size="sm" disabled={pending} onClick={() => onChoose(folder.directory)}>{tr("Use this folder")}</Btn> : null)}{tr(error ? <ErrorNotice message={error}/> : null)}</section>;
|
||||
return <section className="onboard-folder-browser" aria-label={tr("Choose your model folder")}><form onSubmit={e => { e.preventDefault(); void browse(path); }}><input className="st-input" aria-label={tr("Existing model folder")} value={path} onChange={e => { setPath(e.target.value); setFolder(null); }} placeholder={tr("Full folder path")} spellCheck={false}/><Btn type="submit" size="sm" disabled={pending || !path.trim()}>{tr(pending ? 'Checking…' : 'Open')}</Btn></form><p>{tr("Choose the Strata data folder that holds your model downloads. Matching files are reused; missing files are prepared automatically.")}</p><div className="onboard-folder-shortcuts">{folder?.shortcuts?.map(item => <Btn key={item.path} size="sm" disabled={pending} onClick={() => void browse(item.path)}>{tr(item.name)}</Btn>)}</div><div className="onboard-folder-files">{tr(folder?.parent ? <button onClick={() => void browse(folder.parent!)}><Icon name="folder" size={16}/>{tr(" Up a folder")}</button> : null)}{folder?.entries.filter(e => e.kind === 'folder').slice(0, 30).map(e => <button key={e.path} onClick={() => void browse(e.path)}><Icon name="folder" size={16}/>{tr(e.name)}<Icon name="chevron" size={12}/></button>)}{tr(folder ? <small>{tr(folder.entries.filter(e => e.kind === 'model').length)}{tr(" GGUF files in this folder. Nested downloads are checked by the installer.")}</small> : null)}</div>{tr(folder ? <Btn variant="primary" size="sm" disabled={pending} onClick={() => onChoose(folder.directory, folder.entries.filter(e => e.kind === 'model').length)}>{tr("Use this folder")}</Btn> : null)}{tr(error ? <ErrorNotice message={error}/> : null)}</section>;
|
||||
}
|
||||
@@ -5,8 +5,21 @@ import { Btn, IconBtn } from './ui';
|
||||
import { SyntaxCode } from './SyntaxCode';
|
||||
import { MathView } from './MathView';
|
||||
import { displayBlock, hasMath, splitMath } from '../lib/math';
|
||||
function inlineMarks(text: string, onOpenLink?: (url: string) => void): ReactNode {
|
||||
return text.split(/(`[^`]+`|\*\*[^*]+\*\*|\*[^*\n]+\*|\[[^\]]+\]\([^\s)]+\))/g).map((part, i) => {
|
||||
import { imagePlan } from '../lib/markdown-images';
|
||||
function inlineMarks(text: string, onOpenLink?: (url: string) => void, imageSession?: string | null): ReactNode {
|
||||
return text.split(/(!\[[^\]]*\]\([^\s)]+\)|`[^`]+`|\*\*[^*]+\*\*|\*[^*\n]+\*|\[[^\]]+\]\([^\s)]+\))/g).map((part, i) => {
|
||||
const image = part.match(/^!\[([^\]]*)\]\(([^\s)]+)\)$/);
|
||||
if (image) {
|
||||
const plan = imagePlan(image[2], imageSession);
|
||||
if (plan.kind === 'img')
|
||||
return <img key={i} src={plan.src} alt={image[1]} className="md-image" loading="lazy" referrerPolicy="no-referrer"/>;
|
||||
if (plan.kind === 'link')
|
||||
return <a key={i} href={plan.href} target="_blank" rel="noreferrer" onClick={e => { if (onOpenLink) {
|
||||
e.preventDefault();
|
||||
onOpenLink(plan.href);
|
||||
} }}>{image[1] || plan.href}</a>;
|
||||
return <Fragment key={i}>{image[1] || part}</Fragment>;
|
||||
}
|
||||
if (part.startsWith('`') && part.endsWith('`'))
|
||||
return <code key={i} className="md-code">{part.slice(1, -1)}</code>;
|
||||
if (part.startsWith('**') && part.endsWith('**'))
|
||||
@@ -23,21 +36,23 @@ function inlineMarks(text: string, onOpenLink?: (url: string) => void): ReactNod
|
||||
});
|
||||
}
|
||||
/** Text with its inline marks and, outside code spans, its math (#3268). Math is cut out first: a * or _ inside a formula is not emphasis. */
|
||||
function inline(text: string, onOpenLink?: (url: string) => void): ReactNode {
|
||||
function inline(text: string, onOpenLink?: (url: string) => void, imageSession?: string | null): ReactNode {
|
||||
if (!hasMath(text))
|
||||
return inlineMarks(text, onOpenLink);
|
||||
return inlineMarks(text, onOpenLink, imageSession);
|
||||
return text.split(/(`[^`]+`)/g).map((chunk, c) => {
|
||||
if (chunk.length > 1 && chunk.startsWith('`') && chunk.endsWith('`'))
|
||||
return <Fragment key={c}>{inlineMarks(chunk, onOpenLink)}</Fragment>;
|
||||
return <Fragment key={c}>{inlineMarks(chunk, onOpenLink, imageSession)}</Fragment>;
|
||||
return <Fragment key={c}>{splitMath(chunk).map((part, p) => part.kind === 'math'
|
||||
? <MathView key={p} tex={part.tex} display={part.display}/>
|
||||
: <Fragment key={p}>{inlineMarks(part.text, onOpenLink)}</Fragment>)}</Fragment>;
|
||||
: <Fragment key={p}>{inlineMarks(part.text, onOpenLink, imageSession)}</Fragment>)}</Fragment>;
|
||||
});
|
||||
}
|
||||
export function Markdown({ text, onOpenLink, onPreviewHtml }: {
|
||||
export function Markdown({ text, onOpenLink, onPreviewHtml, imageSession }: {
|
||||
text: string;
|
||||
onOpenLink?: (url: string) => void;
|
||||
onPreviewHtml?: (html: string) => void;
|
||||
/** The chat whose folder a picture path in the answer is read from (the host serves only files inside it). */
|
||||
imageSession?: string | null;
|
||||
}) {
|
||||
const nodes: ReactNode[] = [];
|
||||
const lines = text.split('\n');
|
||||
@@ -66,7 +81,7 @@ export function Markdown({ text, onOpenLink, onPreviewHtml }: {
|
||||
const heading = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (heading) {
|
||||
const Tag = heading[1].length <= 2 ? 'h2' : heading[1].length === 3 ? 'h3' : 'h4';
|
||||
nodes.push(<Tag key={i}>{tr(inline(heading[2], onOpenLink))}</Tag>);
|
||||
nodes.push(<Tag key={i}>{tr(inline(heading[2], onOpenLink, imageSession))}</Tag>);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
@@ -90,7 +105,7 @@ export function Markdown({ text, onOpenLink, onPreviewHtml }: {
|
||||
i += 2;
|
||||
while (i < lines.length && lines[i].includes('|'))
|
||||
rows.push(cells(lines[i++]));
|
||||
nodes.push(<div className="md-table-wrap" key={key}><table className="md-table"><thead><tr>{tr(header.map((c, j) => <th key={j}>{tr(inline(c, onOpenLink))}</th>))}</tr></thead><tbody>{tr(rows.map((r, j) => <tr key={j}>{tr(r.map((c, k) => <td key={k}>{tr(inline(c, onOpenLink))}</td>))}</tr>))}</tbody></table></div>);
|
||||
nodes.push(<div className="md-table-wrap" key={key}><table className="md-table"><thead><tr>{tr(header.map((c, j) => <th key={j}>{tr(inline(c, onOpenLink, imageSession))}</th>))}</tr></thead><tbody>{tr(rows.map((r, j) => <tr key={j}>{tr(r.map((c, k) => <td key={k}>{tr(inline(c, onOpenLink, imageSession))}</td>))}</tr>))}</tbody></table></div>);
|
||||
continue;
|
||||
}
|
||||
if (/^>\s?/.test(line)) {
|
||||
@@ -98,7 +113,7 @@ export function Markdown({ text, onOpenLink, onPreviewHtml }: {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
nodes.push(<p key={i}>{tr(inline(line, onOpenLink))}</p>);
|
||||
nodes.push(<p key={i}>{tr(inline(line, onOpenLink, imageSession))}</p>);
|
||||
i++;
|
||||
}
|
||||
return <>{tr(nodes)}</>;
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import { tr, localeTag } from '../lib/i18n';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Bar, Btn, Dialog, Icon } from './ui';
|
||||
import { api, ApiError } from '../lib/client';
|
||||
import { nativeAction, refreshNative, useNative, type NativeJob, type NativeState } from '../lib/native';
|
||||
import { nativeShortName } from '../lib/native-names';
|
||||
import { sizeGb } from '../lib/format';
|
||||
|
||||
type Choice = 'move' | 'keep' | 'empty';
|
||||
interface Plan {
|
||||
ok: boolean;
|
||||
issue: { code: string; message: string; details: string } | null;
|
||||
old: string; new: string; same_drive: boolean;
|
||||
free_bytes: number; models_bytes: number; needed_bytes: number;
|
||||
models: { group: string; name: string; bytes: number }[];
|
||||
choices: Record<Choice, { possible: boolean; reason: string }>;
|
||||
folder: string; picked: string; wholeDrive: boolean; shown: string; current: string; running: number; busy: boolean;
|
||||
}
|
||||
interface Outcome { done?: boolean; stopped?: boolean; failed?: boolean; code?: string; moved?: string[]; remaining?: string[]; bytes?: number; total?: number; from?: string; to?: string; sameDrive?: boolean }
|
||||
|
||||
const gb = (bytes: number) => sizeGb(bytes / 1e9);
|
||||
const driveOf = (path: string) => /^[A-Za-z]:/.test(path) ? path.slice(0, 2) : path;
|
||||
const list = (items: string[]) => { try { return new Intl.ListFormat(localeTag(), { type: 'conjunction' }).format(items); } catch { return items.join(', '); } };
|
||||
|
||||
/** The model a folder name belongs to, in the words the Storage list uses ("Gyro-S"); the draft layer and loose files have no name of their own. */
|
||||
function modelName(group: string, native: NativeState | null) {
|
||||
const hit = native?.installed?.models.find(m => m.model.toLowerCase() === group.toLowerCase());
|
||||
return hit ? nativeShortName(hit.model) : null;
|
||||
}
|
||||
|
||||
function timeLeft(seconds: number) {
|
||||
if (seconds <= 0) return null;
|
||||
if (seconds < 60) return tr('Less than a minute left');
|
||||
const minutes = Math.ceil(seconds / 60);
|
||||
if (minutes <= 1) return tr('About a minute left');
|
||||
if (minutes < 120) return tr('About {0} minutes left', [minutes]);
|
||||
return tr('About {0} hours left', [Math.ceil(minutes / 60)]);
|
||||
}
|
||||
|
||||
function Failure({ message, next, details }: { message: string; next?: string; details?: string }) {
|
||||
return <div className="error-notice" role="alert"><Icon name="error" size={18}/><div><strong>{tr(message)}</strong>{next ? <p>{tr(next)}</p> : null}{details ? <details className="error-detail"><summary className="small">{tr('Details')}</summary><pre className="small muted">{details}</pre></details> : null}</div></div>;
|
||||
}
|
||||
|
||||
/** Settings > Storage > Models folder: the card, the folder dialog with its three choices, the progress of a move and what came of it. */
|
||||
export function ModelsFolderCard({ blocked }: { blocked: boolean }) {
|
||||
const native = useNative();
|
||||
const [plan, setPlan] = useState<Plan | null>(null);
|
||||
const [stage, setStage] = useState<'issue' | 'drive' | 'choose' | null>(null);
|
||||
const [choice, setChoice] = useState<Choice>('move');
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<{ message: string; details?: string } | null>(null);
|
||||
const [typing, setTyping] = useState(false), [typed, setTyped] = useState('');
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [outcome, setOutcome] = useState<{ job: NativeJob } | null>(null);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [stopping, setStopping] = useState(false);
|
||||
const [unfinished, setUnfinished] = useState<{ from: string; to: string } | null>(null);
|
||||
const [resumed, setResumed] = useState<{ from: string; to: string } | null>(null);
|
||||
const folder = native?.downloadDirectory ?? '';
|
||||
const free = native?.downloadFreeBytes ?? null;
|
||||
const usage = native?.installed?.usage?.total_bytes ?? 0;
|
||||
const moving = native?.jobs.find(j => j.kind === 'models-move' && j.state === 'running') ?? null;
|
||||
const finished = jobId ? native?.jobs.find(j => j.id === jobId && j.kind === 'models-move' && j.state !== 'running') : undefined;
|
||||
useEffect(() => { if (finished && !outcome) { setOutcome({ job: finished }); setStopping(false); void refreshNative(true); } }, [finished, outcome]);
|
||||
const total = native?.downloadTotalBytes ?? null;
|
||||
const used = free !== null && total ? Math.min(100, Math.max(1, ((total - free) / total) * 100)) : 0;
|
||||
useEffect(() => { if (moving && !jobId) setJobId(moving.id); }, [moving, jobId]);
|
||||
|
||||
/* A move that a crash or a stop left half done: found again at the next start, with its two choices. */
|
||||
useEffect(() => {
|
||||
if (moving || outcome) return;
|
||||
let live = true;
|
||||
api.get<{ interrupted: { from: string; to: string } | null }>('/api/native/models-folder/interrupted')
|
||||
.then(r => { if (live) setUnfinished(r.interrupted); }).catch(() => { if (live) setUnfinished(null); });
|
||||
return () => { live = false; };
|
||||
}, [moving, outcome, folder]);
|
||||
async function finishMove() {
|
||||
if (!unfinished) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api.post<NativeJob>('/api/native/models-folder/resume', { stopRunning: true });
|
||||
setResumed(unfinished);
|
||||
setUnfinished(null);
|
||||
if (result.id) { setJobId(result.id); setOutcome(null); await refreshNative(true); }
|
||||
}
|
||||
catch (e) { setError({ message: (e as Error).message, details: ((e as ApiError).body as { details?: string } | undefined)?.details }); }
|
||||
finally { setPending(false); }
|
||||
}
|
||||
async function leaveMove() {
|
||||
if (!unfinished) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.post('/api/native/models-folder/leave', {});
|
||||
setNotice(tr('The partial copies were removed. Your models are in {0}.', [unfinished.from]));
|
||||
setUnfinished(null);
|
||||
await refreshNative(true);
|
||||
}
|
||||
catch (e) { setError({ message: (e as Error).message, details: ((e as ApiError).body as { details?: string } | undefined)?.details }); }
|
||||
finally { setPending(false); }
|
||||
}
|
||||
|
||||
function leave() { setPlan(null); setStage(null); setError(null); setTyping(false); }
|
||||
async function pick(path = '') {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
setNotice('');
|
||||
try {
|
||||
const result = await api.post<{ cancelled?: boolean; plan?: Plan }>('/api/native/models-folder/plan', { path });
|
||||
if (result.cancelled || !result.plan) return;
|
||||
const next = result.plan;
|
||||
setTyping(false);
|
||||
setPlan(next);
|
||||
if (next.issue?.code === 'same') { setPlan(null); return; }
|
||||
setChoice(next.choices.move.possible ? 'move' : 'keep');
|
||||
setStage(next.issue ? 'issue' : next.wholeDrive ? 'drive' : 'choose');
|
||||
}
|
||||
catch (e) {
|
||||
if (e instanceof ApiError && e.status === 501) { setTyping(true); setError({ message: e.message }); }
|
||||
else setError({ message: (e as Error).message, details: ((e as ApiError).body as { details?: string } | undefined)?.details });
|
||||
}
|
||||
finally { setPending(false); }
|
||||
}
|
||||
async function start() {
|
||||
if (!plan) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api.post<NativeJob & { done?: boolean; setAside?: string[] }>('/api/native/models-folder/start', { path: plan.picked, choice, stopRunning: true });
|
||||
if (result.id) { setJobId(result.id); setOutcome(null); setStage(null); await refreshNative(true); setPlan(prev => prev); return; }
|
||||
setNotice(choice === 'keep' ? tr('Strata now also uses {0}. New downloads go there, and your current models stay where they are.', [plan.shown])
|
||||
: tr('Strata now uses {0}. Your old model files are still in {1}; Strata does not delete them.', [plan.shown, plan.current]));
|
||||
await refreshNative(true);
|
||||
leave();
|
||||
}
|
||||
catch (e) { setError({ message: (e as Error).message, details: ((e as ApiError).body as { details?: string } | undefined)?.details }); }
|
||||
finally { setPending(false); }
|
||||
}
|
||||
async function stop() {
|
||||
if (!moving) return;
|
||||
setStopping(true);
|
||||
try { await nativeAction(`operations/${moving.id}/cancel`); }
|
||||
catch (e) { setError({ message: (e as Error).message }); }
|
||||
}
|
||||
function closeResult() { setOutcome(null); setJobId(null); setResumed(null); leave(); setNotice(''); }
|
||||
|
||||
const progress = moving?.progress as { done: number; total: number; file: string; group: string; index: number; count: number; secondsLeft: number; sameDrive: boolean } | null | undefined;
|
||||
const percent = progress && progress.total > 0 ? Math.round((progress.done / progress.total) * 100) : 0;
|
||||
const planNames = useMemo(() => (plan?.models ?? []).map(m => ({ ...m, label: modelName(m.group, native) })).filter(m => m.label), [plan, native]);
|
||||
const savedPlan = useMemo(() => plan, [plan]);
|
||||
|
||||
return <>
|
||||
<section className="storage-directory" aria-labelledby="models-folder-title">
|
||||
<div className="storage-directory__heading"><span><Icon name="folder" size={18}/><strong id="models-folder-title">{tr('Models folder')}</strong></span><Btn size="sm" disabled={!folder || blocked || pending || !!moving} onClick={() => void pick()}>{tr(pending && !plan ? 'Waiting for the folder…' : 'Change folder')}</Btn></div>
|
||||
<p className="small muted">{tr('Strata saves the big model files here.')}</p>
|
||||
<code className="storage-directory__path">{folder || tr('Reading model directory…')}</code>
|
||||
{free !== null ? <div className="models-free"><Bar value={used} label={tr('Disk use')}/><div className="models-free__row small"><span><strong>{tr('{0} free', [gb(free)])}</strong> {tr('on this drive')}</span><span className="muted">{tr('Your models use {0}', [gb(usage)])}</span></div></div> : null}
|
||||
{blocked && !moving ? <p className="small muted">{tr('Finish or stop the download to change the folder.')}</p> : null}
|
||||
{typing ? <form className="storage-directory__edit" onSubmit={e => { e.preventDefault(); if (typed.trim()) void pick(typed.trim()); }}>
|
||||
<label htmlFor="models-folder-path" className="small">{tr('Directory path')}</label>
|
||||
<input id="models-folder-path" className="st-input" value={typed} onChange={e => setTyped(e.target.value)} placeholder={tr('Full path to an existing folder')} spellCheck={false} autoFocus/>
|
||||
<div className="row"><Btn variant="primary" type="submit" disabled={pending || !typed.trim()}>{tr('Use this directory')}</Btn></div>
|
||||
</form> : null}
|
||||
{unfinished && !moving ? <div className="models-notice models-notice--info" role="alert"><Icon name="info" size={18}/><div>
|
||||
<strong>{tr('A move was interrupted. Finish it or leave it.')}</strong><br/>
|
||||
{tr('Strata was moving your models from {0} to {1} when it stopped. Nothing is lost.', [unfinished.from, unfinished.to])}
|
||||
<p className="small muted">{tr('Finishing puts all your models in {1}. Leaving it keeps them in {0} and removes the partial copies from {1}.', [unfinished.from, unfinished.to])}</p>
|
||||
<div className="row"><Btn variant="primary" size="sm" disabled={pending || blocked} onClick={() => void finishMove()}>{tr('Finish the move')}</Btn><Btn size="sm" disabled={pending || blocked} onClick={() => void leaveMove()}>{tr('Leave it')}</Btn></div>
|
||||
</div></div> : null}
|
||||
{notice ? <p className="small storage-directory__notice" role="status"><Icon name="check" size={15}/>{notice}</p> : null}
|
||||
{error && !stage ? <Failure message={error.message} details={error.details}/> : null}
|
||||
</section>
|
||||
|
||||
<Dialog className="models-dialog" open={stage === 'issue' && !!plan?.issue} compact title={tr(plan?.issue?.code === 'not_found' ? "That folder isn't there" : "That folder can't be used")} onClose={leave}
|
||||
actions={<>{plan?.issue?.code === 'not_found' ? null : <Btn onClick={leave}>{tr('Cancel')}</Btn>}<Btn variant="primary" onClick={() => { leave(); void pick(); }}>{tr('Choose another folder')}</Btn></>}>
|
||||
{plan?.issue ? <Failure message={plan.issue.message} details={plan.issue.details}
|
||||
next={plan.issue.code === 'not_writable' ? 'Choose a folder you own, for example one in Documents or on another drive.' : plan.issue.code === 'not_found' ? 'Check that the drive is plugged in, then pick the folder again. Your models were not touched.' : undefined}/> : null}
|
||||
</Dialog>
|
||||
|
||||
<Dialog className="models-dialog" open={stage === 'drive' && !!plan} compact title={tr('Use this folder for your models?')} onClose={leave}
|
||||
actions={<><Btn onClick={leave}>{tr('Cancel')}</Btn><Btn variant="primary" onClick={() => setStage('choose')}>{tr('Continue')}</Btn></>}>
|
||||
{plan ? <>
|
||||
<div className="models-picked"><span className="small muted">{tr('New folder')}</span><code className="storage-directory__path">{plan.shown}</code><span className="small muted">{tr('{0} free on this drive', [gb(plan.free_bytes)])}</span></div>
|
||||
<div className="models-notice models-notice--info"><Icon name="info" size={18}/><div>{tr('You picked the whole drive {0}. Strata keeps its files in a folder of their own, so your models go in {1}.', [plan.picked, plan.shown])}</div></div>
|
||||
<p className="small muted">{tr('The three choices come next.')}</p>
|
||||
</> : null}
|
||||
</Dialog>
|
||||
|
||||
<Dialog className="models-dialog" open={stage === 'choose' && !!plan} title={tr('Use this folder for your models?')} onClose={() => { if (!pending) leave(); }}
|
||||
actions={plan ? <>{plan.choices.move.possible ? <Btn disabled={pending} onClick={leave}>{tr('Cancel')}</Btn> : <Btn disabled={pending} onClick={() => { leave(); void pick(); }}>{tr('Choose another folder')}</Btn>}<Btn variant="primary" disabled={pending} onClick={() => void start()}>{tr(pending ? 'Working…' : choice === 'move' ? 'Move my models' : choice === 'keep' ? 'Use both folders' : 'Start empty')}</Btn></> : null}>
|
||||
{plan ? <ChooseBody plan={plan} choice={choice} setChoice={setChoice} names={planNames} error={error}/> : null}
|
||||
</Dialog>
|
||||
|
||||
<Dialog className="models-dialog" open={!!moving && !outcome} compact noClose title={tr('Moving your models')} onClose={() => undefined}
|
||||
actions={<Btn disabled={stopping} onClick={() => void stop()}>{tr(stopping ? 'Stopping…' : 'Stop moving')}</Btn>}>
|
||||
<div className="models-free"><Bar value={percent} label={tr('Progress')}/><div className="models-free__row small"><span><strong>{tr('{0} of {1} GB', [progress ? (progress.done / 1e9 >= 10 ? Math.round(progress.done / 1e9) : (progress.done / 1e9).toFixed(1)) : 0, Math.round((progress?.total ?? 0) / 1e9)])}</strong></span><span className="muted">{progress && !progress.sameDrive ? timeLeft(progress.secondsLeft) : null}</span></div></div>
|
||||
<p className="small">{tr('Keep Strata open. You can keep using other apps. Your models cannot be loaded until the move is done.')}</p>
|
||||
{progress ? <details className="models-details"><summary>{tr('Details')}</summary><ul>
|
||||
<li>{tr('Now copying: {0}, file {1} of {2}', [modelName(progress.group, native) ?? progress.group, progress.index, progress.count])}</li>
|
||||
<li>{tr('From {0} to {1}', [savedPlan?.current ?? resumed?.from ?? '', savedPlan?.folder ?? resumed?.to ?? ''])}</li></ul></details> : null}
|
||||
</Dialog>
|
||||
|
||||
<ResultDialog outcome={outcome?.job ?? null} plan={savedPlan} native={native} onClose={closeResult} onAgain={() => { closeResult(); void pick(); }}/>
|
||||
</>;
|
||||
}
|
||||
|
||||
function ChooseBody({ plan, choice, setChoice, names, error }: { plan: Plan; choice: Choice; setChoice: (c: Choice) => void; names: (Plan['models'][number] & { label: string | null })[]; error: { message: string; details?: string } | null }) {
|
||||
const none = plan.models_bytes === 0;
|
||||
const short = !none && !plan.choices.move.possible;
|
||||
const lacking = Math.max(0, plan.needed_bytes - plan.free_bytes);
|
||||
const options: { id: Choice; title: string; text: string; need: string }[] = [
|
||||
{ id: 'move', title: 'Move my models there', text: none ? 'You have no models to move.' : plan.same_drive ? 'Strata moves your {0} of models to the new folder. This is the same drive, so it takes a moment.' : 'Strata copies your {0} of models to the new folder, checks them, then removes the old copies.',
|
||||
need: none ? '' : plan.same_drive ? 'Needs no extra space.' : 'Needs {0} free in the new folder. You have {1}.' },
|
||||
{ id: 'keep', title: 'Keep them where they are and also use the new folder', text: 'Nothing is copied. Your current models keep working, and new downloads go to the new folder.', need: short ? 'Needs no space now. New downloads must fit in {1}.' : 'Needs no space now.' },
|
||||
{ id: 'empty', title: 'Start empty', text: 'Strata uses the new folder from now on and does not list your current models. Your old files are not deleted.', need: 'Needs no space now. Each model you download later needs its own space.' },
|
||||
];
|
||||
const values = [gb(plan.models_bytes), gb(plan.free_bytes)];
|
||||
const running = plan.running > 0;
|
||||
return <>
|
||||
<div className="models-picked"><span className="small muted">{tr('New folder')}</span><code className="storage-directory__path">{plan.shown}</code><span className="small muted">{tr('{0} free on this drive', [gb(plan.free_bytes)])}</span></div>
|
||||
{short ? <div className="models-notice models-notice--warn" role="alert"><Icon name="warning" size={18}/><div><strong>{tr('Your models need {0}, this drive has {1} free.', [gb(plan.needed_bytes), gb(plan.free_bytes)])}</strong><br/>{tr('Free up about {0} there, pick another folder, or choose one of the other two options.', [gb(lacking)])}</div></div> : null}
|
||||
<fieldset className="models-choices" aria-label={tr('What should happen to your models')}>
|
||||
{options.map(o => { const possible = plan.choices[o.id].possible; return <label className="models-choice" key={o.id} data-disabled={!possible || undefined}>
|
||||
<input type="radio" name="models-folder-choice" checked={choice === o.id} disabled={!possible} onChange={() => setChoice(o.id)}/>
|
||||
<span><strong>{tr(o.title)}</strong>{o.id === 'move' && possible && !none ? <span className="models-choice__tag">{tr('Recommended')}</span> : null}<br/><span className="small">{tr(o.text, o.id === 'move' ? [gb(plan.models_bytes)] : undefined)}</span></span>
|
||||
{o.need ? <span className="models-choice__need">{tr(o.need, o.id === 'move' ? [gb(plan.needed_bytes), gb(plan.free_bytes)] : [values[0], values[1]])}</span> : null}
|
||||
</label>; })}
|
||||
</fieldset>
|
||||
{running ? <p className="small muted">{tr('A running model is stopped first; load it again when the move is done.')}</p> : null}
|
||||
{error ? <Failure message={error.message} details={error.details}/> : null}
|
||||
<details className="models-details"><summary>{tr('Details')}</summary><ul>
|
||||
{names.length ? <li>{tr('{0} would move.', [list(names.map(m => `${m.label}, ${gb(m.bytes)}`))])}</li> : null}
|
||||
{short ? <li>{tr('Free space is measured on the drive that holds {0}.', [plan.shown])}</li> : <>
|
||||
<li>{tr('Old folder: {0}', [plan.current])}</li>
|
||||
<li>{tr('Strata copies one file at a time and checks its size before it removes the old one.')}</li>
|
||||
<li>{tr('You can stop at any time; files already moved stay usable.')}</li></>}
|
||||
</ul></details>
|
||||
</>;
|
||||
}
|
||||
|
||||
function ResultDialog({ outcome, plan, native, onClose, onAgain }: { outcome: NativeJob | null; plan: Plan | null; native: NativeState | null; onClose: () => void; onAgain: () => void }) {
|
||||
if (!outcome) return null;
|
||||
const result = (outcome.result ?? {}) as Outcome;
|
||||
const sized = (groups: string[]) => list(groups.map(g => { const m = plan?.models.find(x => x.group === g); const name = modelName(g, native); return name ? (m ? `${name} (${gb(m.bytes)})` : name) : null; }).filter((x): x is string => !!x));
|
||||
if (outcome.state === 'error') {
|
||||
const full = result.code === 'disk_full';
|
||||
const where = driveOf(result.to ?? '');
|
||||
const some = (result.moved?.length ?? 0) > 0;
|
||||
return <Dialog open compact title={tr(full ? 'The drive ran out of space' : 'The move stopped')} onClose={onClose}
|
||||
actions={<><Btn onClick={onClose}>{tr('Close')}</Btn><Btn variant="primary" onClick={onAgain}>{tr('Choose another folder')}</Btn></>}>
|
||||
<div className="error-notice" role="alert"><Icon name="error" size={18}/><div>
|
||||
<strong>{full ? tr('{0} filled up after {1}, so the move stopped.', [where, gb(result.bytes ?? 0)]) : tr(outcome.error ?? 'The models could not be moved.')}</strong>
|
||||
<p>{full ? (some ? tr('Nothing was lost. Models already moved are in the new folder, the rest are still in {0}, and the half-copied file was removed. Free up space on {1} or choose another folder, then try again.', [result.from ?? '', where])
|
||||
: tr('Nothing was lost. All your models are still in {0}, and the half-copied file was removed. Free up space on {1} or choose another folder, then try again.', [result.from ?? '', where]))
|
||||
: tr('Nothing was lost. Your models that were not moved are still in {0}. Choose another folder to try again.', [result.from ?? ''])}</p>
|
||||
{outcome.details ? <details className="error-detail"><summary className="small">{tr('Details')}</summary><pre className="small muted">{outcome.details}</pre></details> : null}
|
||||
</div></div>
|
||||
</Dialog>;
|
||||
}
|
||||
if (result.stopped) {
|
||||
const moved = sized(result.moved ?? []), left = sized(result.remaining ?? []);
|
||||
return <Dialog open compact title={tr('Moving stopped')} onClose={onClose} actions={<Btn variant="primary" onClick={onClose}>{tr('Close')}</Btn>}>
|
||||
<div className="models-notice models-notice--info"><Icon name="info" size={18}/><div><strong>{tr('Nothing was lost.')}</strong><br/>
|
||||
{moved ? tr('{0} is in the new folder.', [moved]) + ' ' : ''}{left ? tr('{0} is still in the old folder.', [left]) + ' ' : ''}{moved && left ? tr('Both work.') : ''}</div></div>
|
||||
<p className="small muted">{tr('Models in both folders are listed as normal. You can finish moving later with Change folder.')}</p>
|
||||
</Dialog>;
|
||||
}
|
||||
return <Dialog open compact title={tr('Your models are moved')} onClose={onClose} actions={<Btn variant="primary" onClick={onClose}>{tr('Done')}</Btn>}>
|
||||
<div className="models-ok"><span className="models-ok__mark"><Icon name="check" size={20}/></span><div><strong>{(() => { const [before, after] = tr('{0} now live in {1}', [gb(result.total ?? 0), '@@']).split('@@'); return <>{before}<code className="models-path">{plan?.shown ?? result.to ?? ''}</code>{after}</>; })()}</strong>
|
||||
{result.sameDrive ? null : <><br/><span className="small muted">{tr('The old copies were removed and {0} is free again on {1}.', [gb(result.total ?? 0), driveOf(result.from ?? '')])}</span></>}</div></div>
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -1,25 +1,28 @@
|
||||
import { tr } from '../lib/i18n';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Screen } from './layout';
|
||||
import { Btn, ErrorNotice, Icon } from './ui';
|
||||
import { api } from '../lib/client';
|
||||
import { parseLocation } from '../lib/router';
|
||||
import { nativeAction, useNative, type NativeJob } from '../lib/native';
|
||||
import { nativeCatalogModels, catalogVersionLabel } from '../lib/native-catalog';
|
||||
import { sizeGb } from '../lib/format';
|
||||
import { NativeProgress } from './NativeModels';
|
||||
|
||||
interface Preview { path: string; preview: { file_count: number; total_bytes: number; file_set_status: string; issues: string[]; messages?: string[]; projectors?: { name: string; status: string; pinned: boolean; plain: string }[]; reads_pictures?: boolean; candidate_selection: { family: string; model: string } | null } }
|
||||
interface Preview { path: string; preview: { model_dir?: string; plain_issues?: string[]; already_set_up?: { data_dir?: string | null; pack?: string | null } | null; file_count: number; total_bytes: number; file_set_status: string; issues: string[]; messages?: string[]; projectors?: { name: string; status: string; pinned: boolean; plain: string }[]; reads_pictures?: boolean; candidate_selection: { family: string; model: string } | null } }
|
||||
|
||||
/** The words for why a folder of GGUF files cannot be used: a sentence and what to do, the checker's own codes under Details. */
|
||||
function problem(issues: string[]): { message: string; next: string } {
|
||||
const has = (re: RegExp) => issues.some(i => re.test(i));
|
||||
if (has(/incomplete_or_invalid_shard_set|multiple_model_groups/))
|
||||
return { message: 'Some parts of this model are missing, or the folder holds more than one model.', next: 'Put every part of one model in one folder, then check again.' };
|
||||
if (has(/multiple_model_groups/))
|
||||
return { message: 'This folder holds more than one model.', next: 'Type the full path of the .gguf file of the one you want, or choose the folder of that model.' };
|
||||
if (has(/incomplete_or_invalid_shard_set/))
|
||||
return { message: 'Some parts of this model are missing.', next: 'Put every part of one model (all the -0000N-of-0000M files) in one folder, then check again.' };
|
||||
if (has(/unsupported_architecture|unsupported_or_missing_architecture/))
|
||||
return { message: 'This model type isn’t supported by the Strata engine.', next: 'Add it as a Custom model API instead: start it with llama-server, then open Settings > Models > Add model provider > Custom model API.' };
|
||||
if (has(/unrecognized_or_unsupported_model_variant|expert_quantization|tokenizer/))
|
||||
return { message: 'Strata can’t run these files yet.', next: 'Strata runs the Qwen3.8-Flash-Next models. Download one from Models, or choose a folder with its files.' };
|
||||
return { message: 'These files could not be checked.', next: 'Check that the folder holds complete GGUF files, then try again.' };
|
||||
return { message: 'These files could not be checked.', next: 'Check that the folder holds complete GGUF files, then try again. The file named under Details says what is wrong with it.' };
|
||||
}
|
||||
|
||||
/** "Use a local GGUF": choose the folder of a model you already have, Strata checks it, and prepares and loads it without copying
|
||||
@@ -47,13 +50,18 @@ export function NativeLocalModel({ onBack }: { onBack: () => void }) {
|
||||
catch (e) { setError(e instanceof Error ? e.message : String(e)); }
|
||||
finally { setPending(false); }
|
||||
}
|
||||
/* The first-run page hands over the folder the person chose (#3507): it is checked at once, as if they had pressed Check files. */
|
||||
useEffect(() => { const path = parseLocation(window.location.hash).query.get('path'); if (path) { setTyped(path); void check(path); } }, []);
|
||||
/* Files that sit in a Strata data folder (<data>/models/<tag>) with their prepared model data beside them: that folder is used as the data
|
||||
* folder, so the prepared data is not made a second time. A folder the installer would not accept as a data folder is not passed. */
|
||||
const ownDataFolder = (() => { const d = checked?.preview.already_set_up?.pack ? checked.preview.already_set_up.data_dir : null; return d && /strata/i.test(d.split(/[\\/]/).filter(Boolean).pop() ?? '') ? d : null; })();
|
||||
async function prepare() {
|
||||
if (!usable || !selection || !checked)
|
||||
return;
|
||||
setPending(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await nativeAction<NativeJob>('operations', { kind: 'install', args: { family: selection.family, model: selection.model, gguf_dir: checked.path, context: native?.catalog?.recommendation?.context ?? 32768, vision: checked.preview.projectors?.some(p => p.status === 'ok' && p.pinned) && native?.visionReader ? 'yes' : 'no', kv: 'int8', loadAfter: true } });
|
||||
const result = await nativeAction<NativeJob>('operations', { kind: 'install', args: { family: selection.family, model: selection.model, gguf_dir: checked.preview.model_dir ?? checked.path, ...(ownDataFolder ? { data_dir: ownDataFolder } : {}), context: native?.catalog?.recommendation?.context ?? 32768, vision: checked.preview.projectors?.some(p => p.status === 'ok' && p.pinned) && native?.visionReader ? 'yes' : 'no', kv: 'int8', loadAfter: true } });
|
||||
setJobId(result.id);
|
||||
}
|
||||
catch (e) { setError(e instanceof Error ? e.message : String(e)); }
|
||||
@@ -64,8 +72,8 @@ export function NativeLocalModel({ onBack }: { onBack: () => void }) {
|
||||
<div className="native-model-layout">
|
||||
<div className="stack stack--lg">
|
||||
<section className="st-card native-choice"><h2 className="h3">{tr("Use a local GGUF…")}</h2>
|
||||
<p className="small muted">{tr("Already have a supported model on this computer? Choose the folder that holds its files. Strata checks them, prepares the model and loads it. Nothing is copied or downloaded.")}</p>
|
||||
<label className="input-field"><span>{tr("Folder with the GGUF files")}</span><input className="st-input" value={typed} placeholder={tr("Full path to the folder")} spellCheck={false} onChange={e => setTyped(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && typed.trim()) void check(typed); }}/></label>
|
||||
<p className="small muted">{tr("Already have a supported model on this computer? Choose the folder that holds its files, or one of its .gguf files. Strata checks them, prepares the model and loads it. Nothing is copied or downloaded.")}</p>
|
||||
<label className="input-field"><span>{tr("Folder with the GGUF files, or one .gguf file")}</span><input className="st-input" value={typed} placeholder={tr("Full path to the folder or the file")} spellCheck={false} onChange={e => setTyped(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && typed.trim()) void check(typed); }}/></label>
|
||||
<div className="native-settings-actions"><Btn icon="folder" disabled={pending || !!active} onClick={() => void check('')}>{tr("Choose folder")}</Btn><Btn disabled={pending || !typed.trim() || !!active} onClick={() => void check(typed)}>{tr(pending && !jobId ? 'Checking…' : 'Check files')}</Btn></div>
|
||||
</section>
|
||||
{tr(checked ? <section className="st-card native-choice">{tr(usable ? <>
|
||||
@@ -77,6 +85,8 @@ export function NativeLocalModel({ onBack }: { onBack: () => void }) {
|
||||
</> : <>
|
||||
<h2 className="h3">{tr(found!.message)}</h2>
|
||||
<p className="small muted">{tr(found!.next)}</p>
|
||||
{tr(checked.preview.plain_issues?.map(m => <p className="small muted" key={m}>{tr(m)}</p>))}
|
||||
{tr(checked.preview.messages?.map(m => <p className="small muted" key={m}>{tr(m)}</p>))}
|
||||
{tr(checked.preview.issues.length ? <details><summary className="small">{tr("Details")}</summary><pre className="small muted error-detail-text">{checked.preview.issues.join(String.fromCharCode(10))}</pre></details> : null)}
|
||||
</>)}</section> : null)}
|
||||
{tr(active ? <NativeProgress job={active}/> : job?.state === 'error' ? <ErrorNotice message={job.error ?? job.summary} detail={job.details}/> : job?.state === 'done' ? <p className="small" role="status"><Icon name="check" size={15}/> {tr(job.summary)}</p> : null)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { tr } from '../lib/i18n';
|
||||
import { useState } from 'react';
|
||||
import { Btn, Dialog, ErrorNotice, IconBtn, Select } from './ui';
|
||||
import { MCP_NAME_HELP, validMcpName } from '../lib/mcp-names';
|
||||
export function NativeMcpEditor({ text, onChange }: {
|
||||
text: string;
|
||||
onChange: (text: string) => void;
|
||||
@@ -19,8 +20,8 @@ export function NativeMcpEditor({ text, onChange }: {
|
||||
function open(id: string) { const server = servers[id] ?? {}; setEditing(id); setName(id); setTransport(server.url ? 'url' : 'command'); setAddress(server.url ?? server.command ?? ''); setArgs((server.args ?? []).join('\n')); setAdvanced(JSON.stringify(Object.fromEntries(Object.entries(server).filter(([k]) => !['url', 'command', 'args', 'disabled'].includes(k))), null, 2)); setError(''); }
|
||||
function save() {
|
||||
try {
|
||||
if (!/^[a-z0-9_-]{1,64}$/i.test(name))
|
||||
throw new Error('Use a simple server name: letters, numbers, hyphens or underscores.');
|
||||
if (!validMcpName(name))
|
||||
throw new Error(MCP_NAME_HELP);
|
||||
if (!address.trim())
|
||||
throw new Error('Enter the server URL or program.');
|
||||
if (name !== editing && servers[name])
|
||||
|
||||
@@ -4,7 +4,9 @@ import { Screen } from './layout';
|
||||
import { VersionPicker } from './VersionPicker';
|
||||
import { FamilySwitch } from './FamilySwitch';
|
||||
import { PcFitCard } from './PcFitCard';
|
||||
import { fitOn, runCards } from '../lib/native-fit';
|
||||
import { fitOn, memoryNeeded } from '../lib/native-fit';
|
||||
import { GpuRoles } from './GpuRoles';
|
||||
import { fromLegacy, normalize as normalizeRoles, order as roleOrder, recommend as recommendRoles, rows as roleRows, sameRoles, vramOn, type RoleCard, type Roles } from '../lib/gpu-roles';
|
||||
import { Btn, Dialog, ErrorNotice, Icon, Select } from './ui';
|
||||
import { installProgress, installStage, stageText, useDownloadEta } from '../lib/download-eta';
|
||||
import { useKept } from '../lib/kept-state';
|
||||
@@ -18,6 +20,7 @@ import { go, setSettingsSection, selectRemoteModel, pushToast } from '../lib/sto
|
||||
import { nativeAction, useNative, type NativeJob } from '../lib/native';
|
||||
import { tuneEffective } from '../lib/native-tune';
|
||||
import { tunedFields } from '../lib/tune-values';
|
||||
import { draftingChoices as draftingOptions } from '../lib/drafting';
|
||||
import { NativeSettings } from './NativeSettings';
|
||||
import { NativeSettingsFile } from './NativeSettingsFile';
|
||||
import { RunImageModel } from './RunImageModel';
|
||||
@@ -37,9 +40,9 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
// The choices are kept while the page is closed (Models -> Monitor -> Models used to start them over and a person lost track of what was downloading).
|
||||
const [family, setFamily] = useKept('models.family', ''), [size, setSize] = useKept('models.size', ''), [context, setContext] = useKept('models.context', 32768), [vision, setVision] = useKept('models.vision', 'yes');
|
||||
const manual = useStore(s => s.setup?.choices?.onboarding?.manual ?? false);
|
||||
const [expert, setExpert] = useKept('models.expert', manual), [multiple, setMultiple] = useKept('models.multiple', false), [gpus, setGpus] = useKept<number[]>('models.gpus', []), [kv, setKv] = useKept('models.kv', 'int8'), [lowRam, setLowRam] = useKept('models.lowRam', 'auto'), [split, setSplit] = useKept('models.split', 'auto'), [deviceChoice, setDeviceChoice] = useKept('models.device', 'auto');
|
||||
const [cardsKey, setCardsKey] = useKept<string | null>('models.cards', null);
|
||||
const [expert, setExpert] = useKept('models.expert', manual), [multiple, setMultiple] = useKept('models.multiple', false), [roles, setRoles] = useKept<Roles | null>('models.roles', null), [kv, setKv] = useKept('models.kv', 'int8'), [lowRam, setLowRam] = useKept('models.lowRam', 'auto'), [split, setSplit] = useKept('models.split', 'auto');
|
||||
const [resourceProfile, setResourceProfile] = useKept<'work' | 'performance'>('models.profile', 'work');
|
||||
const [drafting, setDrafting] = useState('auto');
|
||||
const [pending, setPending] = useState(false), [error, setError] = useState(''), [plan, setPlan] = useState<Record<string, any> | null>(null), [loadedError, setLoadedError] = useState(''), [tuneOpen, setTuneOpen] = useState(false), [jobId, setJobId] = useKept('models.job', ''), [manage, setManage] = useState(false);
|
||||
const [toastedJob, setToastedJob] = useKept('models.toasted', ''), [appliedSig, setAppliedSig] = useKept('models.applied', '');
|
||||
const presetPending = useRef(false);
|
||||
@@ -53,7 +56,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
// The model that is running is shown as running, with Unload: not offered for loading again.
|
||||
const downloaded = native?.installed?.models ?? [], allSizes = families.flatMap(f => f.sizes);
|
||||
// This computer for the fit figures: the biggest usable graphics card, or the ones chosen under Expert settings.
|
||||
const runGpus = runCards(native?.hardware?.gpus, native?.hardware?.engine_vendor);
|
||||
const engineVendor = native?.hardware?.engine_vendor ?? null, roleCards: RoleCard[] = roleRows(native?.hardware?.gpus as RoleCard[] | undefined, engineVendor);
|
||||
const pcGpus = (native?.hardware?.gpus ?? []).filter(g => g.usable !== false), pcGpu = [...pcGpus].sort((x, y) => y.vram_gb - x.vram_gb)[0] ?? null;
|
||||
const pcRam = native?.hardware?.ram_gb || hardware?.memory.totalGb || null, pcVram = pcGpu?.vram_gb ?? 0;
|
||||
// What the models that stay loaded (Run multiple models) already take: the fit figures are for what is left.
|
||||
@@ -85,20 +88,24 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
// Graphics cards (strata_control/gpu_fit.py: the one rule of the app, the Terminal and setup): the recommended option is pre-selected, the person may pick another
|
||||
// A card a loaded model already uses (a second model on it usually does not fit): said on its row, and not the pre-selected one while another is free.
|
||||
const usedBy = (cards: number[]) => (native?.instances ?? []).filter(i => i.status !== 'error' && i.model !== selectedSize?.id && i.gpus.some(g => cards.includes(g))).map(i => i.label);
|
||||
const cardOptions = selectedSize?.gpu_options && selectedSize.gpu_options.options.length >= 2 ? selectedSize.gpu_options : null;
|
||||
const loadedCards = loadedSelected ? cardOptions?.options.find(o => o.cards.length === loadedSelected.gpus.length && o.cards.every(c => loadedSelected.gpus.includes(c))) : undefined;
|
||||
const activeCards = cardOptions ? (cardOptions.options.find(o => o.key === cardsKey) ?? loadedCards ?? (multiple ? cardOptions.options.find(o => o.recommended && !usedBy(o.cards).length) ?? cardOptions.options.find(o => !usedBy(o.cards).length) : undefined) ?? cardOptions.options.find(o => o.recommended) ?? cardOptions.options[0]) : null;
|
||||
const cardsGpus: number[] = activeCards?.cards ?? [];
|
||||
const chosenCards = expert && gpus.length ? gpus : cardsGpus;
|
||||
const fitHardware = { ram_gb: pcRam ? Math.max(0, pcRam - keptRam) : pcRam, vram_gb: Math.max(0, pcVram - keptVramOn(chosenCards)) };
|
||||
// "(card 1)" in a card name is the number the screens call GPU 2 (Monitor, the model picker, the loaded list): one numbering everywhere.
|
||||
const gpuNumbers = (text: string) => text.replace(/\(card (\d+)\)/g, (_m, n) => `(GPU ${Number(n) + 1})`);
|
||||
// The fit sentence for a card choice, less what the kept models take of RAM and of that card's memory.
|
||||
const lessKept = (words: string, cards: number[]) => { const gone = Math.round(keptRam + keptVramOn(cards)); return gone > 0 ? words.replace(/of the (\d+) GB available/, (_m, n) => `of the ${Math.max(0, Number(n) - gone)} GB available`) : words; };
|
||||
const shownSize = useRef(selectedSize?.id);
|
||||
useEffect(() => { if (shownSize.current !== selectedSize?.id) { shownSize.current = selectedSize?.id; setCardsKey(null); } }, [selectedSize?.id]);
|
||||
const values = useMemo(() => ({ family: selectedSize?.sourceFamily, model: selectedSize?.model, context, vision: selectedSize?.images ? vision : 'no', kv, low_ram: lowRam, resourceProfile, ...(expert && gpus.length === 1 ? { gpu: gpus[0] } : expert && gpus.length ? { gpus: gpus.join(',') } : cardsGpus.length > 1 ? { gpus: cardsGpus.join(',') } : cardsGpus.length === 1 ? { gpu: cardsGpus[0] } : {}), loadAfter: true, multiple: expert && multiple }), [selectedSize?.sourceFamily, selectedSize?.model, context, vision, kv, lowRam, resourceProfile, gpus.join(','), cardsGpus.join(','), expert, multiple]);
|
||||
const downloadValues = { ...values, vision: selectedSize?.images && vision !== 'no' ? 'yes' : 'no', loadVision: selectedSize?.images ? vision : 'no' };
|
||||
// Main / Helper / Off per card (src/lib/gpu-roles.ts, strata_control/gpu_roles.py): the recommendation until the person changes a role; a loaded model shows what it was started with.
|
||||
// With Run multiple models, a card another loaded model already uses starts Off for this one (while a free card exists).
|
||||
const busy = (i: number) => expert && multiple && usedBy([i]).length > 0, freeCards = roleCards.filter(c => !busy(c.index));
|
||||
const recommended = freeCards.length && freeCards.length < roleCards.length ? { ...recommendRoles(freeCards, engineVendor), roles: { ...Object.fromEntries(roleCards.map(c => [c.index, 'off'])), ...recommendRoles(freeCards, engineVendor).roles } as Roles } : recommendRoles(roleCards, engineVendor);
|
||||
const loadedRoles = loadedSelected && roleCards.length > 1 ? fromLegacy(loadedSelected.gpus, roleCards, 'as_given') : null;
|
||||
const effectiveRoles: Roles = roleCards.length > 1 ? (roles ? normalizeRoles(roles, roleCards) : loadedRoles ?? recommended.roles) : recommended.roles;
|
||||
const customRoles = !sameRoles(effectiveRoles, recommended.roles);
|
||||
const changeRoles = (next: Roles) => setRoles(sameRoles(next, recommended.roles) ? null : next);
|
||||
const cardsGpus: number[] = roleCards.length > 1 ? roleOrder(effectiveRoles, roleCards) : [];
|
||||
const chosenCards = cardsGpus;
|
||||
const fitHardware = { ram_gb: pcRam ? Math.max(0, pcRam - keptRam) : pcRam, vram_gb: Math.max(0, (roleCards.length > 1 ? vramOn(effectiveRoles, roleCards) : pcVram) - keptVramOn(chosenCards)) };
|
||||
// A layer split written for another number of cards no longer fits: back to automatic.
|
||||
useEffect(() => { if (cardsGpus.length <= 1 && split !== 'auto') setSplit('auto'); }, [cardsGpus.length]);
|
||||
// Drafting (which drafter guesses ahead): DFlash2 is for Qwen3.8-Flash-Next only (the qwen and gyro families), so no other model is offered it.
|
||||
const draftingChoices = selectedSize ? draftingOptions(selectedSize.sourceFamily ?? selectedFamily?.family, native?.capabilities?.dflash2) : null;
|
||||
const draftingWords: Record<string, string> = { auto: 'Auto (recommended)', mtp: 'MTP', dflash2: 'DFlash2', off: 'Off' };
|
||||
const values = useMemo(() => ({ family: selectedSize?.sourceFamily, model: selectedSize?.model, context, vision: selectedSize?.images ? vision : 'no', kv, low_ram: lowRam, resourceProfile, ...(cardsGpus.length > 1 ? { gpus: cardsGpus.join(',') } : cardsGpus.length === 1 ? { gpu: cardsGpus[0] } : {}), loadAfter: true, multiple: expert && multiple }), [selectedSize?.sourceFamily, selectedSize?.model, context, vision, kv, lowRam, resourceProfile, cardsGpus.join(','), expert, multiple]);
|
||||
const downloadValues = { ...values, ...(draftingChoices ? { drafting } : {}), vision: selectedSize?.images && vision !== 'no' ? 'yes' : 'no', loadVision: selectedSize?.images ? vision : 'no' };
|
||||
useEffect(() => { if (!native?.catalog || family)
|
||||
return; const current = native.instances.find(i => i.id === native.defaultId); const rec = native.catalog.recommendation ?? { family: null, model: null }; const loadedFamily = families.find(f => f.sizes.some(s => s.id === current?.model)); const have = new Set((native.installed?.models ?? []).filter(m => m.ready).map(m => m.model)); const installedFamily = families.find(f => f.sizes.some(s => have.has(s.id))); const f = loadedFamily ?? installedFamily ?? families.find(f => f.family === rec.family || f.sizes.some(s => s.sourceFamily === rec.family)) ?? families[0]; if (f) {
|
||||
setFamily(f.family);
|
||||
@@ -115,7 +122,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
}
|
||||
if (!selectedSize)
|
||||
return;
|
||||
const sig = installed ? ['have', installed.model, installed.ready, installed.context, installed.kv, installed.images, installed.visionEnabled, installed.visionDevice, installed.lowRamMode, installed.resourceProfile].join('|') : `new|${selectedSize.id}`;
|
||||
const sig = installed ? ['have', installed.model, installed.ready, installed.context, installed.kv, installed.images, installed.visionEnabled, installed.visionDevice, installed.lowRamMode, installed.drafting, installed.resourceProfile].join('|') : `new|${selectedSize.id}`;
|
||||
if (sig === appliedSig)
|
||||
return;
|
||||
setAppliedSig(sig);
|
||||
@@ -126,6 +133,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
setVision(installed.visionEnabled === false ? 'no' : installed.visionDevice ?? (installed.images && installed.images !== 'off' ? installed.images : 'yes'));
|
||||
setKv(installed.kv ?? 'int8');
|
||||
setLowRam(installed.lowRamMode ?? 'auto');
|
||||
setDrafting(installed.drafting ?? 'auto');
|
||||
// A model never tuned has no saved choice: keep the page's own (Leave 20%) rather than flipping to Full performance.
|
||||
if (installed.resourceProfile && !loadedSelected?.resourceProfile)
|
||||
setResourceProfile(installed.resourceProfile);
|
||||
@@ -149,7 +157,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
} }
|
||||
async function load() { if (!selectedSize)
|
||||
return; await perform(async () => { if (installed?.ready && context === installed.context && (!selectedSize?.images || vision === 'no' || installed.images && installed.images !== 'off') && (!installed.kv || kv === installed.kv || kv === tunedNow?.fields.kv) && (lowRam === (installed.lowRamMode ?? 'auto') || lowRam === tunedNow?.fields.lowRam)) {
|
||||
const result = await nativeAction<NativeJob>('operations', { kind: 'load', args: { model: selectedSize.id, resourceProfile, vision: selectedSize?.images ? vision : 'no', multiple: expert && multiple, ...(expert && gpus.length ? { gpus, layerSplit: split } : cardsGpus.length ? { gpus: cardsGpus, gpusFromRecommendation: !cardsKey } : {}), values: {} } });
|
||||
const result = await nativeAction<NativeJob>('operations', { kind: 'load', args: { model: selectedSize.id, resourceProfile, vision: selectedSize?.images ? vision : 'no', multiple: expert && multiple, ...(draftingChoices ? { values: { drafting } } : {}), ...(roleCards.length > 1 ? (roles && customRoles ? { gpuRoles: effectiveRoles, layerSplit: expert && cardsGpus.length > 1 ? split : 'auto' } : cardsGpus.length ? { gpus: cardsGpus, gpusFromRecommendation: true, layerSplit: expert && cardsGpus.length > 1 ? split : 'auto' } : {}) : {}) } });
|
||||
setJobId(result.id);
|
||||
}
|
||||
else
|
||||
@@ -165,10 +173,10 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
setPending(false);
|
||||
} }
|
||||
async function download() { await perform(async () => { const result = await nativeAction<NativeJob>('operations', { kind: 'install', args: downloadValues }); setJobId(result.id); setPlan(null); }); }
|
||||
return <Screen plainHeader title={<span className="sr-only">{tr("Models")}</span>} titleDetail={<FamilySwitch families={families} selected={selectedFamily?.family} onPick={f => { setFamily(f.family); setSize((f.sizes.find(s => native?.installed?.models.some(m => m.model === s.id && m.ready)) ?? f.sizes[0])?.id ?? ''); setVision('yes'); setError(''); }}/>} actions={<Btn icon="sliders" aria-pressed={expert} aria-expanded={expert} aria-controls="native-expert-settings" onClick={() => { setExpert(v => !v); setMultiple(false); setGpus([]); setDeviceChoice('auto'); }}>{tr(expert ? 'Close expert mode' : 'Expert mode')}</Btn>}>
|
||||
return <Screen plainHeader title={<span className="sr-only">{tr("Models")}</span>} titleDetail={<FamilySwitch families={families} selected={selectedFamily?.family} onPick={f => { setFamily(f.family); setSize((f.sizes.find(s => native?.installed?.models.some(m => m.model === s.id && m.ready)) ?? f.sizes[0])?.id ?? ''); setVision('yes'); setError(''); }}/>} actions={<Btn icon="sliders" aria-pressed={expert} aria-expanded={expert} aria-controls="native-expert-settings" onClick={() => { setExpert(v => !v); setMultiple(false); }}>{tr(expert ? 'Close expert mode' : 'Expert mode')}</Btn>}>
|
||||
<div className="native-model-layout native-model-layout--fit">
|
||||
<div className="stack">
|
||||
{tr(native?.instances.length && expert ? <section className="st-card native-running"><h2 className="h4">{tr(native.instances.length === 1 ? 'Loaded model' : 'Loaded models')}</h2>{tr(native.instances.map(i => <div key={i.id}><span><strong>{tr(i.label)}</strong><small>{tr(i.gpus.length ? i.gpus.map(g => `GPU ${g + 1}`).join(' + ') : 'Automatic GPU')} · {tr(i.status === 'ready' ? i.phase : i.status)}{tr(native.defaultId === i.id ? ' · Chat default' : '')}</small>{tr(i.error ? <small className="inline-error">{tr(plainFailureText(i.error).message)}</small> : null)}</span>{tr(expert && multiple && native.defaultId !== i.id ? <Btn size="sm" variant="ghost" onClick={() => void loadedAction(`instances/${i.id}/default`)}>{tr("Use in chat")}</Btn> : null)}<Btn size="sm" disabled={pending || !!active} onClick={() => void loadedAction(`instances/${i.id}/unload`)}>{tr("Unload")}</Btn></div>))}{tr(loadedError ? <ErrorNotice message={loadedError} next={tr("Stop or answer the chat that is still using this model, then try again.")}/> : null)}</section> : null)}
|
||||
{tr(native?.instances.length && expert ? <section className="st-card native-running"><h2 className="h4">{tr(native.instances.length === 1 ? 'Loaded model' : 'Loaded models')}</h2>{tr(native.instances.map(i => <div key={i.id}><span><strong>{tr(i.label)}</strong><small>{tr(i.gpus.length ? i.gpus.map((g, n) => `GPU ${g + 1}${i.gpus.length > 1 && n === i.gpus.length - 1 ? ' (Main)' : ''}`).join(' + ') : 'Automatic GPU')} · {tr(i.status === 'ready' ? i.phase : i.status)}{tr(native.defaultId === i.id ? ' · Chat default' : '')}</small>{tr(i.error ? <small className="inline-error">{tr(plainFailureText(i.error).message)}</small> : null)}</span>{tr(expert && multiple && native.defaultId !== i.id ? <Btn size="sm" variant="ghost" onClick={() => void loadedAction(`instances/${i.id}/default`)}>{tr("Use in chat")}</Btn> : null)}<Btn size="sm" disabled={pending || !!active} onClick={() => void loadedAction(`instances/${i.id}/unload`)}>{tr("Unload")}</Btn></div>))}{tr(loadedError ? <ErrorNotice message={loadedError} next={tr("Stop or answer the chat that is still using this model, then try again.")}/> : null)}</section> : null)}
|
||||
<section className="st-card native-choice">{tr(selectedFamily && (!selectedSize || selectedSize.needsNewerEngine) ? <><h2 className="h3">{tr("This model needs a newer engine.")}</h2><p className="small muted">{tr("Update your engine or import a local GGUF for this model.")}</p><Btn onClick={() => { setSettingsSection('Engine'); go('settings'); }}>{tr("Update engine")}</Btn><Btn variant="ghost" onClick={onImport ?? onOtherModels}>{tr("Use a local GGUF\u2026")}</Btn></> : null)}{tr(!selectedFamily ? <><h2 className="h3">{tr("Set up Strata")}</h2><p className="small muted">{tr("Connect your local installation to choose an optimized model.")}</p><Btn variant="primary" onClick={onConnectEngine ?? (() => { setSettingsSection('Engine'); go('settings'); })}>{tr("Connect engine")}</Btn>{tr(native?.error ? <p className="small inline-error">{tr(native.error)}</p> : null)}<Btn variant="ghost" onClick={onOtherModels}>{tr("Other local models")}</Btn></> : null)}
|
||||
{tr(selectedSize ? <>
|
||||
<div className="native-choice__head"><span className="native-choice__label">{tr("Version")}</span><span className="native-choice__tools"><NativePresets values={values} onApply={v => { const f = families.find(f => f.family === v.family || f.sizes.some(s => s.sourceFamily === v.family)); if (!f)
|
||||
@@ -177,9 +185,12 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
{tr(noFit ? <p className="small muted native-spill-note"><Icon name="disk" size={16}/>{tr("May run slower \u00B7 Model data can be read from SSD.")}</p> : null)}
|
||||
<div className="native-choice__row">
|
||||
<div className="native-choice__col"><span className="native-choice__label">{tr("Conversation context")}</span><Select ariaLabel={tr("Conversation context")} value={String(context)} options={contextChoices(native?.catalog?.contexts ?? [8192, 32768, 65536, 131072, 262144], context).map(n => ({ value: String(n), label: n % 1024 === 0 ? `${n / 1024}K` : `${n.toLocaleString()} tokens` }))} onChange={n => setContext(Number(n))}/></div>
|
||||
{tr(draftingChoices ? <div className="native-choice__col"><span className="native-choice__label">{tr("Drafting")}</span><Select ariaLabel={tr("Drafting")} value={drafting} disabled={!!active} options={draftingChoices.map(v => ({ value: v, label: tr(draftingWords[v]) }))} onChange={v => setDrafting(String(v))}/></div> : null)}
|
||||
<div className="native-choice__col"><span className="native-choice__label">{tr("Computer use")}</span><div className="native-resource-choice"><button aria-pressed={resourceProfile === 'work'} data-selected={resourceProfile === 'work'} onClick={() => setResourceProfile('work')}><Icon name="computer" size={20}/><span><strong>{tr("Leave 20% for my work")}</strong><small>{tr("Headroom for your other apps")}</small></span>{tr(resourceProfile === 'work' ? <Icon name="check" size={15}/> : null)}</button><button aria-pressed={resourceProfile === 'performance'} data-selected={resourceProfile === 'performance'} onClick={() => setResourceProfile('performance')}><Icon name="bolt" size={20}/><span><strong>{tr("Full performance")}</strong><small>{tr("Give Strata priority")}</small></span>{tr(resourceProfile === 'performance' ? <Icon name="check" size={15}/> : null)}</button></div></div>
|
||||
</div>
|
||||
{tr(cardOptions ? <div className="native-cards"><span className="native-choice__label">{tr("Graphics cards")}</span><div className="native-cards__list" role="radiogroup" aria-label={tr("Graphics cards")}>{cardOptions.options.map(o => { const on = activeCards?.key === o.key; return <button key={o.key} type="button" role="radio" aria-checked={on} data-selected={on} disabled={!!active} className="native-cards__opt" onClick={() => setCardsKey(o.key)}><span className="native-cards__dot" aria-hidden="true"/><span><strong>{gpuNumbers(o.label)}</strong><small data-warn={o.verdict === 'spills' || o.verdict === 'tight' || usedBy(o.cards).length > 0}>{[lessKept(o.words, o.cards), ...o.notes, ...(usedBy(o.cards).length ? [tr('Already used by {0}', [usedBy(o.cards).join(', ')])] : [])].join('. ')}.</small></span></button>; })}{cardOptions.ignored.length ? <p className="small muted native-cards__note">{`Not used: ${cardOptions.ignored.map(g => `${g.name ?? 'a card'} (${g.why_ignored})`).join('; ')}.`}</p> : null}</div></div> : null)}
|
||||
{tr(loadedSelected?.metrics?.engine?.drafting_note ? <p className="small muted native-spill-note">{tr(String(loadedSelected.metrics.engine.drafting_note))}</p> : null)}
|
||||
{roleCards.length > 1 ? <GpuRoles cards={roleCards} roles={effectiveRoles} custom={customRoles} recommendedRoles={recommended.roles} vendor={engineVendor} disabled={!!active} onChange={changeRoles} onBack={() => setRoles(null)} memoryGb={selectedSize ? memoryNeeded(selectedSize) || null : null} ramGb={pcRam} spillGb={selectedSize ? fitOn(selectedSize, fitHardware).spillGb : 0} usedBy={i => usedBy([i])}/> : null}
|
||||
{tr(roleCards.length > 1 && (native?.hardware?.gpus ?? []).length > roleCards.length ? <p className="small muted native-cards__note">{tr('Not used: {0} (another make of graphics card than this engine drives).', [(native?.hardware?.gpus ?? []).filter(g => !roleCards.some(r => r.index === g.index && r.vendor === g.vendor)).map(g => g.name).join(', ')])}</p> : null)}
|
||||
{tr(selectedSize?.imagesCapable && !selectedSize.images ? <p className="small muted native-spill-note">{tr("This model can read pictures, but the engine installed here does not include the picture reader yet, so Read pictures is off. Update the engine in Settings → About to turn it on.")}</p> : null)}
|
||||
{tr(selectedSize?.images ? <><div className="native-image-reading"><label><input type="checkbox" checked={vision !== 'no'} disabled={!selectedSize?.images || !!active} onChange={e => setVision(e.target.checked ? 'yes' : 'no')}/><span><strong>{tr("Read pictures ")}<small>{tr("On by default")}</small></strong><small>{tr("Understand photos, screenshots and charts. The picture files download automatically. Turn it off to save memory.")}</small></span></label>{tr(expert && vision !== 'no' ? <Select ariaLabel={tr("Image encoder device")} value={vision === 'yes' ? 'yes' : vision} options={[{ value: 'yes', label: 'Automatic' }, { value: 'gpu', label: 'GPU' }, { value: 'cpu', label: 'CPU' }]} onChange={setVision}/> : null)}</div>{tr(vision !== 'no' && installed?.ready && installed.visionPrepared === false ? <div className="native-vision-missing" role="status"><span>{tr("Picture reading needs a few more files that are not on this computer yet.")}</span><Btn size="sm" disabled={pending || !!active} onClick={() => void download()}>{tr("Download picture-reading files")}</Btn></div> : null)}</> : null)}
|
||||
</> : null)}
|
||||
@@ -188,15 +199,15 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
{tr(job?.state === 'error' ? <ErrorNotice message={job.error ?? job.summary} action={tr("Retry")} onRetry={() => void load()}/> : null)}
|
||||
{tr(error ? <ErrorNotice message={error}/> : null)}
|
||||
{selectedSize && installed?.ready ? <NativeTuneBadge model={selectedSize.id} resourceProfile={resourceProfile} vision={selectedSize.images ? vision : 'no'}/> : null}
|
||||
<div className="native-model-actions">{tr(loadedSelected ? <><Btn disabled={pending || !!active} onClick={() => void perform(() => nativeAction(`instances/${loadedSelected.id}/unload`))}>{tr("Unload")}</Btn>{tr(onReady ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <span className="native-ready">{tr("Ready")}</span>)}</> : onReady && native?.instances.some(i => i.status === 'ready') ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <><Btn icon="gauge" disabled={!installed?.ready || !native?.available || !!active || !selectedSize || selectedSize.needsNewerEngine} onClick={() => setTuneOpen(true)}>{tr("Tune model")}</Btn><Btn variant="primary" icon="models" disabled={pending || !native?.available || !!active || !selectedSize || !!selectedSize.needsNewerEngine || expert && deviceChoice === 'split' && gpus.length < 2} onClick={() => void load()}>{tr(pending ? 'Checking…' : installed?.ready ? 'Load model' : 'Download & load')}</Btn></>)}</div>
|
||||
<div className="native-model-actions">{tr(loadedSelected ? <><Btn disabled={pending || !!active} onClick={() => void perform(() => nativeAction(`instances/${loadedSelected.id}/unload`))}>{tr("Unload")}</Btn>{tr(onReady ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <span className="native-ready">{tr("Ready")}</span>)}</> : onReady && native?.instances.some(i => i.status === 'ready') ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <><Btn icon="gauge" disabled={!installed?.ready || !native?.available || !!active || !selectedSize || selectedSize.needsNewerEngine} onClick={() => setTuneOpen(true)}>{tr("Tune model")}</Btn><Btn variant="primary" icon="models" disabled={pending || !native?.available || !!active || !selectedSize || !!selectedSize.needsNewerEngine} onClick={() => void load()}>{tr(pending ? 'Checking…' : installed?.ready ? 'Load model' : 'Download & load')}</Btn></>)}</div>
|
||||
</section>
|
||||
{tr(expert && IMAGE_GENERATION_VISIBLE ? <><details className="native-generation"><summary>{tr("Image generation ")}<span className="muted">{tr("Optional")}</span></summary><RunImageModel library={imageLibrary} disabled={pending || !!active} onChange={() => { }}/></details></> : null)}
|
||||
{tr(expert ? <section id="native-expert-settings" ref={expertSection} className="st-card native-execution"><h2 className="h4">{tr("Expert settings")}</h2>{tr(tunedNow ? <p className="small muted native-tuned-note">{tr("Showing the values of your tuning '{0}'.", [tunedNow.name])}</p> : null)}<label className="native-toggle"><span><strong>{tr("Run multiple models")}</strong><small>{tr("Loading another model keeps the others loaded.")}</small></span><button className="st-toggle" role="switch" aria-label={tr("Run multiple models")} aria-checked={multiple} onClick={() => setMultiple(v => !v)}/></label><h3 className="small strong">{tr("Run on")}</h3><p className="small muted">{tr("Automatic uses the installation\u2019s GPU placement. Choose a GPU, or split this model across several GPUs.")}</p><Select ariaLabel={tr("Run on")} value={deviceChoice} options={[{ value: 'auto', label: 'Automatic' }, ...runGpus.map(g => ({ value: String(g.index), label: `GPU ${g.index + 1} · ${g.name}` })), ...(runGpus.length > 1 ? [{ value: 'split', label: 'Split across GPUs…' }] : [])]} onChange={value => { setDeviceChoice(value); setGpus(value === 'auto' ? [] : value === 'split' ? runGpus.map(g => g.index) : [Number(value)]); }}/>{tr(deviceChoice === 'split' ? <div className="native-gpu-options">{tr(runGpus.map(g => <label key={g.index}><input type="checkbox" checked={gpus.includes(g.index)} onChange={e => setGpus(old => e.target.checked ? [...old, g.index] : old.filter(i => i !== g.index))}/><span>{tr("GPU ")}{tr(g.index + 1)} · {tr(g.name)}<small>{tr(g.vram_gb)}{tr(" GB VRAM")}</small></span></label>))}</div> : null)}{tr(gpus.length > 1 ? <label className="input-field"><span>{tr("Layer split")}</span><input className="st-input" value={split} placeholder={tr("auto or 16,32")} onChange={e => setSplit(e.target.value)}/></label> : null)}<div className="native-field-grid"><label className="input-field"><span>{tr("Context memory")}</span><Select ariaLabel={tr("Native KV format")} value={kv} options={[{ value: 'int8', label: 'INT8' }, { value: 'q4_0', label: 'Q4' }, { value: 'k8v4', label: 'K8 / V4' }]} onChange={setKv}/></label><label className="input-field"><span>{tr("Low-RAM mode")}</span><Select ariaLabel={tr("Native low RAM mode")} value={lowRam} options={['auto', 'resident', 'mmap'].map(v => ({ value: v, label: v === 'auto' ? 'Automatic' : v === 'resident' ? 'Keep experts in RAM' : 'Read experts from disk' }))} onChange={setLowRam}/></label></div>{tr(installed ? <><NativeSettingsFile model={installed.model}/><NativeSettings model={installed.model}/></> : <p className="small muted">{tr("More engine parameters become available after preparing this version.")}</p>)}</section> : null)}
|
||||
{tr(expert ? <section id="native-expert-settings" ref={expertSection} className="st-card native-execution"><h2 className="h4">{tr("Expert settings")}</h2>{tr(tunedNow ? <p className="small muted native-tuned-note">{tr("Showing the values of your tuning '{0}'.", [tunedNow.name])}</p> : null)}<label className="native-toggle"><span><strong>{tr("Run multiple models")}</strong><small>{tr("Loading another model keeps the others loaded.")}</small></span><button className="st-toggle" role="switch" aria-label={tr("Run multiple models")} aria-checked={multiple} onClick={() => setMultiple(v => !v)}/></label>{tr(cardsGpus.length > 1 ? <label className="input-field"><span>{tr("Layer split")}</span><input className="st-input" value={split} placeholder={tr("auto or 16,32")} onChange={e => setSplit(e.target.value)}/></label> : null)}<div className="native-field-grid"><label className="input-field"><span>{tr("Context memory")}</span><Select ariaLabel={tr("Native KV format")} value={kv} options={[{ value: 'int8', label: 'INT8' }, { value: 'q4_0', label: 'Q4' }, { value: 'k8v4', label: 'K8 / V4' }]} onChange={setKv}/></label><label className="input-field"><span>{tr("Low-RAM mode")}</span><Select ariaLabel={tr("Native low RAM mode")} value={lowRam} options={['auto', 'resident', 'mmap'].map(v => ({ value: v, label: v === 'auto' ? 'Automatic' : v === 'resident' ? 'Keep experts in RAM' : 'Read experts from disk' }))} onChange={setLowRam}/></label></div>{tr(installed ? <><NativeSettingsFile model={installed.model}/><NativeSettings model={installed.model}/></> : <p className="small muted">{tr("More engine parameters become available after preparing this version.")}</p>)}</section> : null)}
|
||||
</div>
|
||||
<aside className="stack stack--lg"><PcFitCard gpuName={pcGpu?.name} ramGb={pcRam} vramGb={pcVram} inUse={keptModels.length ? { ramGb: Math.round(keptRam), vramGb: Math.round(keptVramOn(chosenCards)) } : null} fit={selectedSize ? fitOn(selectedSize, fitHardware) : null}/><section className="st-card"><h2 className="h4">{tr("Model files")}</h2><p className="small muted">{tr("Strata prepares all shards, the draft layer and shared files. Completed downloads are reused.")}</p><code className="native-path">{installed?.dataFolder ?? native?.downloadDirectory ?? native?.installed?.data_folder ?? 'Choose an installation'}</code><Btn size="sm" variant="ghost" onClick={() => { setSettingsSection('Storage'); go('settings'); }}>{tr("Storage settings")}</Btn></section><Btn size="sm" variant="ghost" onClick={onOtherModels}>{tr("Other local models\u2026")}</Btn></aside>
|
||||
</div>
|
||||
|
||||
<Dialog open={!!plan} title={tr(installed?.ready ? "Apply settings and load" : "Download and prepare")} compact onClose={() => !pending && setPlan(null)} actions={<><Btn disabled={pending} onClick={() => setPlan(null)}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={pending || !!plan?.plan?.disk_short} onClick={() => void download()}>{tr(installed?.ready ? "Apply & load" : "Download & load")}</Btn></>}><p><strong>{tr(plan?.plan?.title ?? selectedFamily?.title)}</strong>{tr(installed?.ready ? null : <>{tr(" \u00B7 approximately ")}{tr(plan?.plan?.download_gb ?? selectedSize?.download_gb)}{tr(" GB")}</>)}</p>{tr(installed?.ready ? <p className="small muted">{tr("The model files are already on this computer. Nothing will be downloaded; only the settings you chose are applied.")}</p> : null)}{tr(plan?.plan?.disk_short ? <ErrorNotice message={plan.plan.disk_short}/> : null)}{tr(installed?.ready ? <p className="small muted">{tr("Any currently loaded native models will be unloaded first.")}</p> : <><p className="small muted">{tr("Existing files are checked and reused. Any currently loaded native models will be unloaded before preparing files.")}</p><p className="small muted">{tr("Your computer may feel slower while weights load. You can stop the download and resume later.")}</p></>)}{tr(selectedSize?.images ? <p className="small muted">{tr("Vision encoder files are included. Picture reading loads only when enabled.")}</p> : null)}</Dialog>
|
||||
<Dialog open={!!plan} title={tr(installed?.ready ? "Apply settings and load" : "Download and prepare")} compact onClose={() => !pending && setPlan(null)} actions={<><Btn disabled={pending} onClick={() => setPlan(null)}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={pending || !!plan?.plan?.disk_short} onClick={() => void download()}>{tr(installed?.ready ? "Apply & load" : "Download & load")}</Btn></>}><p><strong>{tr(plan?.plan?.title ?? selectedFamily?.title)}</strong>{tr(installed?.ready ? null : <>{tr(" \u00B7 approximately ")}{tr(plan?.plan?.download_gb ?? selectedSize?.download_gb)}{tr(" GB")}</>)}</p>{tr(plan?.drafting?.needed ? <p className="small muted">{tr("Choosing DFlash2 also downloads its drafter: about {0} GB, once. If it does not fit in graphics memory, the model uses the MTP instead.", [plan.drafting.gb])}</p> : null)}{tr(installed?.ready ? <p className="small muted">{tr("The model files are already on this computer. Nothing will be downloaded; only the settings you chose are applied.")}</p> : null)}{tr(plan?.plan?.disk_short ? <ErrorNotice message={plan.plan.disk_short}/> : null)}{tr(installed?.ready ? <p className="small muted">{tr("Any currently loaded native models will be unloaded first.")}</p> : <><p className="small muted">{tr("Existing files are checked and reused. Any currently loaded native models will be unloaded before preparing files.")}</p><p className="small muted">{tr("Your computer may feel slower while weights load. You can stop the download and resume later.")}</p></>)}{tr(selectedSize?.images ? <p className="small muted">{tr("Vision encoder files are included. Picture reading loads only when enabled.")}</p> : null)}</Dialog>
|
||||
<NativeDeleteDialog id={deleting} onClose={() => setDeleting(null)}/>
|
||||
<Dialog open={manage} title={tr("Manage models")} onClose={() => setManage(false)} actions={<Btn onClick={() => setManage(false)}>{tr("Done")}</Btn>}><div className="native-manage">
|
||||
{tr(downloaded.length ? <div className="native-manage-list" role="list">{tr(downloaded.map(m => { const v = allSizes.find(x => x.id === m.model), loaded = native?.instances.some(i => i.model === m.model && i.status === 'ready'); return <div className="native-manage-row" role="listitem" key={m.model} data-selected={selectedSize?.id === m.model || undefined}>
|
||||
@@ -231,4 +242,4 @@ catch (e) {
|
||||
}
|
||||
finally {
|
||||
setStopping(false);
|
||||
} } return <section className="st-card native-progress" aria-live="polite"><div className="section-toolbar"><h2 className="h4">{tr(job.kind === 'install' ? (label ? tr('Preparing {0}', [label]) : 'Preparing your model') : job.kind === 'load' ? 'Loading model' : job.kind === 'autotune' || job.kind === 'recheck' ? 'Tuning your model' : 'Engine operation')}</h2>{tr(job.kind === 'install' || job.kind === 'load' || job.kind === 'autotune' || job.kind === 'recheck' ? <div className="onboard-progress-actions">{tr(eta ? <span className="small muted">{eta}</span> : null)}<Btn size="sm" disabled={stopping} onClick={() => void stop()}>{tr(stopping ? 'Stopping…' : 'Stop')}</Btn></div> : null)}</div><p className="small">{tr(job.kind === 'install' ? (download && /^5\//.test(job.result?.install?.step ?? '') ? stageText(installStage(job.result?.install)) : installStepLabel(job.result?.install?.step)) : job.summary)}</p>{tr(download ? <><progress max={100} value={download.percent ?? 0}/><p className="xs muted">{tr(download.file === 'model files' ? 'Model files' : download.file === 'picture reader' ? 'Picture reader' : download.file)} · {tr(download.percent ?? 0)}%{tr(Number.isFinite(download.done_gb) && Number.isFinite(download.total_gb) ? ` · ${download.done_gb!.toFixed(1)} / ${download.total_gb!.toFixed(1)} GB` : '')}</p></> : null)}<DownloadParts parts={job.result?.install?.parts}/>{tr(detailsText ? <details><summary className="small">{tr("Details")}</summary><pre className="small muted error-detail-text">{detailsText}</pre></details> : null)}{tr(error ? <ErrorNotice message={error}/> : null)}</section>; }
|
||||
} } return <section className="st-card native-progress" aria-live="polite"><div className="section-toolbar"><h2 className="h4">{tr(job.kind === 'install' ? (label ? tr('Preparing {0}', [label]) : 'Preparing your model') : job.kind === 'load' ? 'Loading model' : job.kind === 'autotune' || job.kind === 'recheck' ? 'Tuning your model' : 'Engine operation')}</h2>{tr(job.kind === 'install' || job.kind === 'load' || job.kind === 'autotune' || job.kind === 'recheck' ? <div className="onboard-progress-actions">{tr(eta ? <span className="small muted">{eta}</span> : null)}<Btn size="sm" disabled={stopping} onClick={() => void stop()}>{tr(stopping ? 'Stopping…' : 'Stop')}</Btn></div> : null)}</div><p className="small">{tr(job.kind === 'install' ? (job.result?.install?.label ?? (download && /^5\//.test(job.result?.install?.step ?? '') ? stageText(installStage(job.result?.install)) : installStepLabel(job.result?.install?.step))) : job.summary)}</p>{tr(download ? <><progress max={100} value={download.percent ?? 0}/><p className="xs muted">{tr(download.file === 'model files' ? 'Model files' : download.file === 'picture reader' ? 'Picture reader' : download.file === 'checking files' ? 'Checking files' : download.file === 'MTP draft layer' ? 'Draft layer' : download.file)} · {tr(download.percent ?? 0)}%{tr(Number.isFinite(download.done_gb) && Number.isFinite(download.total_gb) ? ` · ${download.done_gb!.toFixed(1)} / ${download.total_gb!.toFixed(1)} GB` : '')}</p></> : null)}<DownloadParts parts={job.result?.install?.parts}/>{tr(detailsText ? <details><summary className="small">{tr("Details")}</summary><pre className="small muted error-detail-text">{detailsText}</pre></details> : null)}{tr(error ? <ErrorNotice message={error}/> : null)}</section>; }
|
||||
@@ -6,6 +6,7 @@ import { fetchUpdateCheck, publishUpdateCheck, useInstalledVersions, useLastUpda
|
||||
import { nativeAction, useNative, type NativeJob } from '../lib/native';
|
||||
import { Btn, Dialog, ErrorNotice, Select } from './ui';
|
||||
import { NativeProgress } from './NativeModels';
|
||||
import { jobErrorText } from '../lib/native-job-diagnostics';
|
||||
export function NativeBenchmark({ open, onClose }: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -114,7 +115,7 @@ export function NativeOperations() {
|
||||
}
|
||||
return <div className="stack stack--lg native-engine-settings">
|
||||
<section className="st-card"><div className="section-toolbar"><h2 className="h3">{tr("Update component")}</h2>{tr(update === 'available' ? <Btn size="sm" variant="primary" disabled={!!active || (native?.instances.length ?? 0) > 0} onClick={() => setConfirm(true)}>{tr("Update available")}</Btn> : null)}{tr(update !== 'available' ? <Btn size="sm" disabled={update === 'checking'} onClick={() => void checkForUpdates()}>{tr(update === 'checking' ? 'Checking…' : 'Check for updates')}</Btn> : null)}</div><p className="small muted">{tr("Update the engine for this computer. Your models and settings are kept.")}</p>{tr(update === 'current' ? <p className="small muted">{tr("Up to date")}</p> : null)}{tr(checkError ? <p className="small inline-error" role="alert">{tr(checkError)}</p> : null)}{tr(update === 'available' && (native?.instances.length ?? 0) > 0 ? <p className="small muted">{tr("Open Models and unload the running native models first.")}</p> : null)}</section>
|
||||
<section className="st-card"><div className="section-toolbar"><h2 className="h3">{tr("Diagnostics")}</h2><Btn size="sm" onClick={() => setShowLogs(true)}>{tr("View logs")}</Btn></div><p className="small muted">{tr("Downloads, preparation and engine failures include their original messages here.")}</p>{tr(native?.jobs.filter(j => j.state === 'error').slice(-3).map(j => <ErrorNotice key={j.id} message={j.error ?? j.summary} detail={j.details}/>))}</section>
|
||||
<section className="st-card"><div className="section-toolbar"><h2 className="h3">{tr("Diagnostics")}</h2><Btn size="sm" onClick={() => setShowLogs(true)}>{tr("View logs")}</Btn></div><p className="small muted">{tr("Downloads, preparation and engine failures include their original messages here.")}</p>{tr(native?.jobs.filter(j => j.state === 'error').slice(-3).map(j => <ErrorNotice key={j.id} message={jobErrorText(j, native.jobs)} detail={j.details}/>))}</section>
|
||||
<Dialog open={showLogs} wide title={tr("Engine logs")} onClose={() => setShowLogs(false)} actions={<><Btn icon="download" disabled={saving} onClick={() => void saveLogs()}>{tr(saving ? 'Working…' : 'Save logs')}</Btn><Btn variant="primary" onClick={() => setShowLogs(false)}>{tr("Close")}</Btn></>}>
|
||||
<pre className="native-log" aria-label={tr("Engine log")}>{logs.length ? logs.map(l => `${l.at} · ${l.line}`).join(String.fromCharCode(10)) : tr('No engine messages yet.')}</pre>
|
||||
{tr(saved ? <p className="small" role="status">{tr("Saved to {0}", [saved])}</p> : null)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { pickerGroups, pickerVersionState } from '../lib/model-picker';
|
||||
import { sizeGb } from '../lib/format';
|
||||
import { useNative } from '../lib/native';
|
||||
import { NativeDeleteDialog } from './NativeDelete';
|
||||
import { ModelsFolderCard } from './ModelsFolderDialog';
|
||||
import { nativeShortName } from '../lib/native-names';
|
||||
export function SettingsStorage() {
|
||||
const state = useStore(s => s);
|
||||
@@ -77,7 +78,9 @@ export function SettingsStorage() {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
const downloading = !!native?.jobs.some(j => j.state === 'running' && j.kind !== 'models-move') || !!library?.downloads.active || !!state.imageModels?.variants.some(v => v.state === 'downloading');
|
||||
return <>
|
||||
{native?.available && native.downloadDirectory ? <ModelsFolderCard blocked={downloading}/> : null}
|
||||
<section className="storage-directory" aria-labelledby="storage-directory-title">
|
||||
<div className="storage-directory__heading"><span><Icon name="folder" size={18}/><strong id="storage-directory-title">{tr("Model directory")}</strong></span><Btn size="sm" disabled={!currentDirectory || directoryPending || pending} onClick={() => { setDirectory(currentDirectory); setEditing(!editing); setDirectoryError(''); setNotice(''); }}>{tr(editing ? 'Cancel' : 'Change directory')}</Btn></div>
|
||||
<p className="small muted">{tr("Your models are saved here. Use an existing folder to reuse supported downloads.")}</p>
|
||||
@@ -95,6 +98,7 @@ export function SettingsStorage() {
|
||||
<div className="row"><Btn variant="primary" type="submit" disabled={directoryPending || directoryBlocked || !directory.trim()}>{tr(directoryPending ? 'Checking files…' : changing && loadedCount > 0 ? 'Unload and change directory' : changing ? 'Use this directory' : 'Check folder')}</Btn></div>
|
||||
</form> : <Btn size="sm" icon="refresh" disabled={directoryPending || directoryBlocked || !currentDirectory} onClick={() => void saveDirectory(currentDirectory)}>{tr(directoryPending ? 'Checking files…' : 'Check folder')}</Btn>)}
|
||||
{tr(directoryBlocked && !directoryPending ? <p className="small muted">{tr(!lifecycle.allowed ? lifecycle.reason : 'Finish or cancel downloads and loading to change the directory.')}</p> : null)}
|
||||
{tr(state.setup?.app.modelsDirNotice && !notice ? <p className="small storage-directory__notice" role="status"><Icon name="info" size={15}/>{tr(state.setup.app.modelsDirNotice)}</p> : null)}
|
||||
{tr(notice ? <p className="small storage-directory__notice" role="status"><Icon name="check" size={15}/>{tr(notice)}</p> : null)}
|
||||
{tr(directoryError ? <ErrorNotice message={directoryError}/> : null)}
|
||||
</section>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useStore } from '../lib/store';
|
||||
import type { SoftwareUpdate, UpdateCheckResult } from '../lib/types';
|
||||
import { nativeAction, useNative } from '../lib/native';
|
||||
import { NativeProgress } from './NativeModels';
|
||||
import { jobErrorText } from '../lib/native-job-diagnostics';
|
||||
import { appVersionLabel } from '../lib/app-version';
|
||||
import { fetchUpdateCheck, publishUpdateCheck, useInstalledVersions, useLastUpdateCheck } from '../lib/update-check';
|
||||
/** A check from an earlier day shows its date too, because the result is kept between visits. */
|
||||
@@ -89,12 +90,12 @@ export function SettingsUpdates() {
|
||||
<Btn variant="primary" disabled={component !== 'engine' || !native?.capabilities.rollback || !!active || (native?.instances.length ?? 0) > 0} onClick={() => void operate('rollback')}>{tr("Restore previous engine")}</Btn>
|
||||
</div>
|
||||
</Dialog>
|
||||
{tr(active ? <NativeProgress job={active}/> : null)}{tr(native?.jobs.filter(j => j.state === 'error' && ['update-strata', 'update-engine', 'rollback'].includes(j.kind)).slice(-1).map(j => <p role="alert" key={j.id} className="small inline-error">{tr(j.error ?? j.summary)}</p>))}
|
||||
{tr(active ? <NativeProgress job={active}/> : null)}{tr(native?.jobs.filter(j => j.state === 'error' && ['update-strata', 'update-engine', 'rollback'].includes(j.kind)).slice(-1).map(j => <p role="alert" key={j.id} className="small inline-error">{tr(jobErrorText(j, native.jobs))}</p>))}
|
||||
<Dialog open={!!confirm} compact title={tr(confirm === 'update-strata' ? 'Update Strata?' : 'Update engine?')} onClose={() => setConfirm('')} actions={<><Btn onClick={() => setConfirm('')}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={!!active || (native?.instances.length ?? 0) > 0} onClick={() => void operate(confirm)}>{tr("Update")}</Btn></>}><p>{tr("The native installer updates the installation. Model files and settings are kept. Unload native models before updating.")}</p>{tr((native?.instances.length ?? 0) > 0 ? <p className="small muted">{tr("Open Models and unload the running native models first.")}</p> : null)}</Dialog>
|
||||
</>;
|
||||
}
|
||||
|
||||
/** "Strata 0.2.1 · Engine 0.1.46": the app's own version first, the engine's beside it (About). */
|
||||
/** "Strata 0.2.3 · Engine 0.1.48": the app's own version first, the engine's beside it (About). */
|
||||
export function AboutVersions() {
|
||||
const native = useNative();
|
||||
const app = appVersionLabel(useStore(s => s.setup?.app.version), native?.version);
|
||||
|
||||
@@ -50,7 +50,7 @@ export function ChatTelemetry({ session }: {
|
||||
const approx = m.estimated ? tr('≈ ') : tr('');
|
||||
const dur = (n: number) => elapsed(n);
|
||||
return <div className="telemetry xs muted" aria-label={tr("Chat statistics")}>
|
||||
<Chip icon="gauge" label={tr("{0}{1} tok/s{2}", [approx, speed === null ? tr('—') : Math.round(speed), m.streaming ? tr('') : tr(' · last reply')])} live={around(m.streaming ? "{0}{1} tok/s" : "{0}{1} tok/s · last reply", approx, <Num value={speed} format={int}/>)} title={tr("Session statistics")}>{tr(m.estimated ? <p className="xs muted">{tr("Live estimate from response length and elapsed time. Final usage comes from the engine.")}</p> : null)}<Row label={tr("Thinking time")} value={<Num value={m.thinkMs} format={dur}/>}/><Row label={tr("Writing time")} value={<Num value={m.writeMs} format={dur}/>}/><Row label={tr("Tool time")} value={<Num value={m.toolMs} format={dur}/>}/><Row label={tr("Average time to first token")} value={m.ttft === null ? tr('Not reported') : <Num value={m.ttft} format={dur}/>}/><Row label={tr("Latest response speed")} value={speed === null ? tr('Not reported') : <>{<Num value={speed} format={int}/>}{tr(' tokens/s')}</>}/><Row label={tr("Turns / tool calls")} value={<><Num value={m.turns} format={int}/> / <Num value={m.steps} format={int}/></>}/></Chip>
|
||||
<Chip icon="gauge" label={tr("{0}{1} tok/s{2}", [approx, speed === null ? tr('—') : Math.round(speed), m.streaming ? tr('') : tr(' · last reply')])} live={around(m.streaming ? "{0}{1} tok/s" : "{0}{1} tok/s · last reply", approx, <Num value={speed} format={int}/>)} title={tr("Session statistics")}>{tr(m.estimated ? <p className="xs muted">{tr("Live estimate from response length and elapsed time. Final usage comes from the engine.")}</p> : null)}<Row label={tr("Thinking time")} value={<Num value={m.thinkMs} format={dur}/>}/><Row label={tr("Writing time")} value={<Num value={m.writeMs} format={dur}/>}/><Row label={tr("Tool time")} value={<Num value={m.toolMs} format={dur}/>}/><Row label={tr("Average time to first token")} value={m.ttft === null ? tr('Not reported') : <Num value={m.ttft} format={dur}/>}/><Row label={tr("Latest response speed")} value={speed === null ? tr('Not reported') : <>{<Num value={speed} format={int}/>}{tr(' tokens/s')}</>}/><Row label={tr("Prompt reading")} value={m.promptSpeed === null ? tr('Not reported') : <>{<Num value={m.promptSpeed} format={int}/>}{tr(' tokens/s')}</>}/><Row label={tr("Turns / tool calls")} value={<><Num value={m.turns} format={int}/> / <Num value={m.steps} format={int}/></>}/></Chip>
|
||||
<Chip icon="memory" label={tr("{0}{1} total tokens", [approx, compactTokens(m.total)])} live={around("{0}{1} total tokens", approx, <Num value={m.total} format={compactTokens}/>)} title={tr("Token usage")}><Row label={tr("Total in this chat")} value={<Num value={m.total} format={compactTokens}/>}/><Row label={tr("Input tokens")} value={<Num value={m.input} format={compactTokens}/>}/><Row label={tr("Output tokens")} value={<Num value={m.output} format={compactTokens}/>}/></Chip>
|
||||
<Chip visual={<ContextRing percent={m.pct}/>} label={tr(m.pct === null ? '—' : `${Math.round(m.pct)}% of context`)} live={m.pct === null ? undefined : <><Num value={m.pct} format={int}/>{tr('% of context')}</>} title={tr("Context length")}><Row label={tr("Context used")} value={m.pct === null ? tr('Not reported') : <><Num value={m.pct} format={int}/>%</>}/><div className="context-meter" role="meter" aria-label={tr("Context window used")} aria-valuemin={0} aria-valuemax={100} aria-valuenow={m.pct ?? undefined}><span style={{ width: `${m.pct ?? 0}%` }}/></div><Row label={tr("Current context")} value={<Num value={m.used} format={compactTokens}/>}/><Row label={tr("Context limit")} value={<Num value={m.max} format={compactTokens}/>}/><Row label={tr("Space remaining")} value={m.used !== null && m.max !== null ? <Num value={Math.max(0, m.max - m.used)} format={compactTokens}/> : '—'}/></Chip>
|
||||
</div>;
|
||||
|
||||
@@ -384,7 +384,7 @@ export function Select<T extends string>({ value, valueSuffix, options, onChange
|
||||
</>);
|
||||
}
|
||||
/** One modal surface for confirmations and narrow-screen inspectors. */
|
||||
export function Dialog({ open, title, onClose, children, actions, headerActions, compact = false, sheet = false, wide = false, hideTitle = false, className }: {
|
||||
export function Dialog({ open, title, onClose, children, actions, headerActions, compact = false, sheet = false, wide = false, hideTitle = false, className, noClose = false }: {
|
||||
open: boolean;
|
||||
title: ReactNode;
|
||||
onClose: () => void;
|
||||
@@ -396,6 +396,8 @@ export function Dialog({ open, title, onClose, children, actions, headerActions,
|
||||
wide?: boolean;
|
||||
hideTitle?: boolean;
|
||||
className?: string;
|
||||
/** No close button: the person must use the dialog's own buttons (a move that is running). */
|
||||
noClose?: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const id = useId();
|
||||
@@ -418,7 +420,7 @@ export function Dialog({ open, title, onClose, children, actions, headerActions,
|
||||
return createPortal(<div className="dialog-layer">
|
||||
<div className="dialog-scrim" onClick={onClose} aria-hidden="true"/>
|
||||
<section ref={ref} className={cx(`${sheet ? 'dialog-surface dialog-surface--sheet' : 'dialog-surface'}${wide ? ' dialog-surface--wide' : ''}${compact ? ' dialog-surface--compact' : ''}`, className)} role="dialog" aria-modal="true" aria-labelledby={id} tabIndex={-1}>
|
||||
<header className="dialog-head"><h2 className={hideTitle ? 'sr-only' : 'h3'} id={id}>{tr(title)}</h2><div className="dialog-head__actions">{tr(headerActions)}<IconBtn name="x" title={typeof title === 'string' ? tr("Close {0}", [title]) : tr("Close")} onClick={onClose}/></div></header>
|
||||
<header className="dialog-head"><h2 className={hideTitle ? 'sr-only' : 'h3'} id={id}>{tr(title)}</h2><div className="dialog-head__actions">{tr(headerActions)}{noClose ? null : <IconBtn name="x" title={typeof title === 'string' ? tr("Close {0}", [title]) : tr("Close")} onClick={onClose}/>}</div></header>
|
||||
<div className="dialog-body">{tr(children)}</div>
|
||||
{tr(actions ? <footer className="dialog-actions">{tr(actions)}</footer> : null)}
|
||||
</section>
|
||||
@@ -516,18 +518,20 @@ export function MetricGrid({ children, min = 200 }: {
|
||||
</div>);
|
||||
}
|
||||
/** Shared, announced error with a cause and a concrete recovery action. */
|
||||
export function ErrorNotice({ message, next, action, onRetry, detail }: {
|
||||
export function ErrorNotice({ message, next, action, onRetry, detail, secondary }: {
|
||||
message: string;
|
||||
next?: string;
|
||||
action?: string;
|
||||
onRetry?: () => void;
|
||||
detail?: string;
|
||||
/** A second way out, beside the first (a context error offers two). */
|
||||
secondary?: { label: string; onClick: () => void };
|
||||
}) {
|
||||
// A known failure is shown as a sentence and a next step; the original text stays under Details for experts.
|
||||
const plain = plainFailure(message);
|
||||
const known = plain.kind !== 'plain';
|
||||
const nextStep = known ? (plain.next ? tr(plain.next) : next) : next;
|
||||
const raw = detail ?? (known ? plain.detail : undefined);
|
||||
return <div className="error-notice" role="alert"><Icon name="error" size={18}/><div><strong>{tr(known ? plain.message : userError(message))}</strong>{tr(nextStep ? <p>{tr(nextStep)}</p> : null)}{tr(raw ? <details className="error-detail"><summary className="small">{tr("Details")}</summary><pre className="small muted">{raw}</pre></details> : null)}</div>{tr(onRetry ? <Btn size="sm" onClick={onRetry}>{tr(action ?? 'Try again')}</Btn> : null)}</div>;
|
||||
return <div className="error-notice" role="alert"><Icon name="error" size={18}/><div><strong>{tr(known ? plain.message : userError(message))}</strong>{tr(nextStep ? <p>{tr(nextStep)}</p> : null)}{tr(raw ? <details className="error-detail"><summary className="small">{tr("Details")}</summary><pre className="small muted">{raw}</pre></details> : null)}</div>{tr(onRetry ? (secondary ? <div className="row"><Btn size="sm" onClick={onRetry}>{tr(action ?? 'Try again')}</Btn><Btn size="sm" variant="ghost" onClick={secondary.onClick}>{tr(secondary.label)}</Btn></div> : <Btn size="sm" onClick={onRetry}>{tr(action ?? 'Try again')}</Btn>) : null)}</div>;
|
||||
}
|
||||
export function PreviewBadge() { const demo = useStore(s => s.library?.mode === 'simulated'); return demo ? <span className="preview-badge">{tr("Preview build")}</span> : null; }
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The version the person sees as "Strata". It is the DESKTOP APP's version (0.2.1), not that of Strata Control, the tools the app runs
|
||||
* (0.1.45, the version of the Terminal). Showing Control's number as "Strata" made a tester report "Strata 0.1.45" for the 0.2.1 app.
|
||||
* The version the person sees as "Strata". It is the DESKTOP APP's version (0.2.3), not that of Strata Control, the tools the app runs
|
||||
* (0.1.45, the version of the Terminal). Showing Control's number as "Strata" made a tester report "Strata 0.1.45" for the 0.2.3 app.
|
||||
* Without a real app version (a source checkout reports 'dev') the other number is the best there is.
|
||||
*/
|
||||
export function appVersionLabel(app: string | null | undefined, control?: string | null): string | undefined {
|
||||
|
||||
@@ -13,15 +13,21 @@ export function chatMetrics(session:Session|null, monitor:MonitorState|null, con
|
||||
const finished=latest?(latest.contextUsed??latest.inputTokens!+(latest.tokens??0)):null;
|
||||
const liveUsed=active&&Number.isFinite(monitor!.context.usedTokens)&&monitor!.context.usedTokens>0?monitor!.context.usedTokens:null;
|
||||
const live=replies.some(m=>m.streaming)||(active&&['reading','thinking','generating'].includes(monitor!.phase));
|
||||
const used=live?(liveUsed??finished):(finished??liveUsed);
|
||||
// While a turn runs, its own figure comes first: the host reports what the context holds after each model request of the turn. The engine's live
|
||||
// figure belongs to the request in flight (a small one between two tools read as 1%), and `finished` to an earlier reply.
|
||||
const turnUsed=replies.find(m=>m.streaming&&Number.isFinite(m.contextUsed)&&m.contextUsed!>0)?.contextUsed??null;
|
||||
const used=live?(turnUsed??liveUsed??finished):(finished??liveUsed);
|
||||
const max=remote?(replies.at(-1)?.contextMax||null):configuredContext??(active?monitor!.context.maxTokens:latest?.contextMax??null);
|
||||
const streaming=replies.find(m=>m.streaming);
|
||||
const speed=streaming?.tps??[...replies].reverse().find(m=>m.tps!==undefined)?.tps??null;
|
||||
// Answer speed: everything the model generated over its decode time. While a reply streams it is that reply's; after it, the LAST reply's own
|
||||
// (never an older reply's number under the words "last reply").
|
||||
const lastReply=streaming??replies.at(-1);
|
||||
const speed=lastReply?.tps??null,promptSpeed=lastReply?.promptTps??null;
|
||||
const ttfts=replies.flatMap(m=>m.ttftMs===undefined?[]:[m.ttftMs]);
|
||||
const hasUsage=session?.statsAvailable===true;
|
||||
const llmTotal=replies.reduce((n,m)=>n+(m.durationMs??(m.streaming?Math.max(0,now-(Date.parse(m.at)||now)):0)),0),thinkTotal=replies.reduce((n,m)=>n+(m.reasoningMs??(m.streaming&&m.reasoningStartedAt?Math.max(0,now-m.reasoningStartedAt):0)),0);
|
||||
return {streaming:!!streaming,writeMs:Math.max(0,llmTotal-thinkTotal),estimated:streaming?.usageSource==='estimated',hasReplies:replies.length>0,example:!!session?.showcase||replies.some(m=>m.usageSource==='demo'),
|
||||
speed:active&&monitor?.phase==='generating'?monitor.outputTps:speed,
|
||||
speed:active&&['generating','thinking'].includes(monitor?.phase??'')&&monitor!.outputTps>0?monitor!.outputTps:speed,promptSpeed,
|
||||
total:session?.statsAvailable===false?null:hasUsage?session.tokensIn+session.tokensOut+(streaming?.tokens??0)+(streaming?.inputTokens??0):replies.every(m=>m.inputTokens!==undefined&&m.tokens!==undefined)?replies.reduce((n,m)=>n+m.inputTokens!+m.tokens!,0):null,
|
||||
input:hasUsage?session.tokensIn:null,output:hasUsage?session.tokensOut:null,
|
||||
llmMs:replies.reduce((n,m)=>n+(m.durationMs??(m.streaming?Math.max(0,now-(Date.parse(m.at)||now)):0)),0),
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { tr } from './i18n';
|
||||
import { sendOversized, startNewChat, summarizeAndContinue } from './store';
|
||||
|
||||
/**
|
||||
* What the error card of a chat offers when the model's window is the problem. A conversation that no longer fits can be summarised and
|
||||
* carried on, or left for a new chat; a single message that is too large can go as a file the assistant reads in parts, or as a shortened
|
||||
* preview. "Try the message again" would only fail the same way, so the card never says it for these.
|
||||
*/
|
||||
export function contextRecovery(code: string | undefined): {
|
||||
next: string;
|
||||
action?: string;
|
||||
onRetry?: () => void;
|
||||
secondary?: { label: string; onClick: () => void };
|
||||
} {
|
||||
if (code === 'CONTEXT_MESSAGE_TOO_LARGE') {
|
||||
return {
|
||||
next: tr('Attach it as a file for the assistant to read in parts, or send a shortened preview.'),
|
||||
action: 'Attach as a file',
|
||||
onRetry: () => void sendOversized('file'),
|
||||
secondary: { label: 'Send a shortened preview', onClick: () => void sendOversized('preview') },
|
||||
};
|
||||
}
|
||||
if (code === 'CONTEXT_WINDOW_EXCEEDED') {
|
||||
return {
|
||||
next: tr('Summarize it now and continue, or start a new chat.'),
|
||||
action: 'Summarize and continue',
|
||||
onRetry: () => void summarizeAndContinue(),
|
||||
secondary: { label: 'Start a new chat', onClick: () => startNewChat() },
|
||||
};
|
||||
}
|
||||
return { next: tr('Try sending the message again.') };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* The Drafting choices the Models screen offers for a model. DFlash2 is for Qwen3.8-Flash-Next only (the qwen and gyro families) AND only while
|
||||
* the engine serves it: strata_control/drafting.py ENGINE_SERVES_DFLASH2 (Control reports it as `capabilities.dflash2`). Until then the setting
|
||||
* is Auto / MTP / Off; DFlash2 appears by itself when the engine can run it. The Terminal's --drafting flag has it either way, gated with a note.
|
||||
*/
|
||||
export const DRAFTING_FAMILIES = ['qwen', 'gyro'];
|
||||
|
||||
export function draftingChoices(family: string | null | undefined, dflash2Served: boolean | null | undefined): string[] {
|
||||
return DRAFTING_FAMILIES.includes(String(family)) && dflash2Served === true ? ['auto', 'mtp', 'dflash2', 'off'] : ['auto', 'mtp', 'off'];
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Main / Helper / Off for every graphics card. The same rules as strata_control/gpu_roles.py (the Terminal, setup and the server); both run
|
||||
* tests/data/gpu_roles_vectors.json.
|
||||
*
|
||||
* - Main: the card that runs the LAST stage of the layer split (the output head, the draft layer and the check of its guesses). Exactly one.
|
||||
* - Helper: holds a share of the model, so more of it fits on graphics cards instead of being read from the drive.
|
||||
* - Off: Strata does not touch it.
|
||||
*
|
||||
* The engine takes the cards in stage order (`gpu: [helper, ..., main]`, `gpu_order: "as_given"`), so Main is the LAST one.
|
||||
*/
|
||||
export type Role = 'main' | 'helper' | 'off';
|
||||
export const ROLES: Role[] = ['main', 'helper', 'off'];
|
||||
export type Roles = Record<number, Role>;
|
||||
|
||||
export interface RoleCard {
|
||||
index: number;
|
||||
name: string;
|
||||
vram_gb: number;
|
||||
/** SMs x clock (or the AMD equivalent): only the order between cards matters. */
|
||||
speed?: number;
|
||||
usable?: boolean;
|
||||
problem?: string | null;
|
||||
vendor?: string;
|
||||
uma?: boolean;
|
||||
integrated?: boolean;
|
||||
unified_gb?: number;
|
||||
/** PCI bus id when known: used to notice a renumbering. */
|
||||
bus?: string;
|
||||
}
|
||||
|
||||
export const OFF_AT_OR_BELOW_GB = 8;
|
||||
const SAME_SPEED = 1.05;
|
||||
const MEMORY_GUARD_SPEED = 0.5;
|
||||
const MIN_CARD_GB = 4;
|
||||
const SPLIT_CARD_OVERHEAD_GB = 2;
|
||||
export const CPU_ONLY_LINE = 'Performance is degraded on CPU only.';
|
||||
export const NO_CARD_LINE = 'This engine needs a graphics card to start. Turn one on.';
|
||||
|
||||
const mem = (c: RoleCard) => Number(c.vram_gb) || 0;
|
||||
const speed = (c: RoleCard) => (typeof c.speed === 'number' && c.speed > 0 ? c.speed : 0);
|
||||
const shares = (c: RoleCard) => !!(c.integrated || c.uma || c.unified_gb || c.vendor === 'apple');
|
||||
const canRun = (c: RoleCard) => c.usable !== false;
|
||||
const rnd = (n: number) => Math.round(n);
|
||||
|
||||
export function shortName(c: RoleCard): string {
|
||||
let n = String(c.name || `GPU ${c.index + 1}`);
|
||||
for (const junk of ['NVIDIA GeForce ', 'NVIDIA ', 'GeForce ', 'AMD Radeon ', 'AMD ', 'Intel(R) ', 'Intel ']) if (n.startsWith(junk)) { n = n.slice(junk.length); break; }
|
||||
return n.trim();
|
||||
}
|
||||
/** The number the screens show: GPU 1 is card 0. */
|
||||
export const gpuLabel = (c: RoleCard) => `GPU ${c.index + 1}`;
|
||||
|
||||
/** The cards shown as rows: every card the engine's make can name, in card order. */
|
||||
export function rows<T extends RoleCard>(cards: T[] | null | undefined, vendor?: string | null): T[] {
|
||||
const list = cards ?? [];
|
||||
const make = vendor || pickVendor(list);
|
||||
const mine = make ? list.filter(c => !c.vendor || c.vendor === make) : list;
|
||||
return [...mine].sort((a, b) => a.index - b.index);
|
||||
}
|
||||
function pickVendor(cards: RoleCard[]): string | null {
|
||||
const makes = new Set(cards.filter(c => canRun(c) && c.vendor).map(c => c.vendor as string));
|
||||
if (makes.size <= 1) return [...makes][0] ?? null;
|
||||
return makes.has('nvidia') ? 'nvidia' : cards[0]?.vendor ?? null;
|
||||
}
|
||||
|
||||
function eligible(cs: RoleCard[]): RoleCard[] {
|
||||
const real = cs.filter(c => canRun(c) && !shares(c) && mem(c) >= MIN_CARD_GB);
|
||||
return real.length ? real : cs.filter(canRun);
|
||||
}
|
||||
|
||||
export type MainWhy = 'only' | 'faster' | 'same' | 'memory' | 'none';
|
||||
|
||||
export function pickMain(cs: RoleCard[]): { main: RoleCard | null; why: MainWhy } {
|
||||
const pool = eligible(cs);
|
||||
if (!pool.length) return { main: null, why: 'none' };
|
||||
if (pool.length === 1) return { main: pool[0], why: 'only' };
|
||||
const top = Math.max(...pool.map(speed));
|
||||
const group = top > 0 ? pool.filter(c => speed(c) * SAME_SPEED >= top) : [...pool];
|
||||
const byMem = (a: RoleCard, b: RoleCard) => rnd(mem(b)) - rnd(mem(a)) || a.index - b.index;
|
||||
let main = [...group].sort(byMem)[0];
|
||||
let why: MainWhy = group.length === 1 ? 'faster' : 'same';
|
||||
if (group.length > 1 && new Set(group.map(c => `${c.name}|${rnd(mem(c))}`)).size === 1) main = [...group].sort((a, b) => b.index - a.index)[0]; // identical cards: the HIGHEST number is Main
|
||||
if (top <= 0 && new Set(pool.map(c => rnd(mem(c)))).size > 1) why = 'memory';
|
||||
const bigger = pool.filter(c => rnd(mem(c)) > rnd(mem(main)) && (top <= 0 || speed(c) >= MEMORY_GUARD_SPEED * top));
|
||||
if (bigger.length) { main = [...bigger].sort(byMem)[0]; why = 'memory'; }
|
||||
return { main, why };
|
||||
}
|
||||
|
||||
export type OffWhy = 'cannot_run' | 'igpu' | 'small' | 'helper';
|
||||
export interface Recommendation { roles: Roles; main: number | null; why: MainWhy; reasons: Record<number, MainWhy | OffWhy> }
|
||||
|
||||
/** The best card is Main; every other card starts as Helper, EXCEPT a card with 8 GB or less when a bigger card is there (or it has under 4 GB), built-in graphics beside a real card, and a card the engine cannot run: those start Off. */
|
||||
export function recommend(cards: RoleCard[] | null | undefined, vendor?: string | null): Recommendation {
|
||||
const cs = rows(cards, vendor);
|
||||
const { main, why } = pickMain(cs);
|
||||
const roles: Roles = {}, reasons: Recommendation['reasons'] = {};
|
||||
// An 8 GB card is a poor Helper beside a bigger card, but a perfectly good one when 8 GB is all there is: two GTX 1080s are a working
|
||||
// 16 GB pool (#3655). A card with 8 GB or less starts Off only when a bigger usable card is there, or when it is too small to help.
|
||||
const hasBigger = cs.some(c => canRun(c) && !shares(c) && rnd(mem(c)) > OFF_AT_OR_BELOW_GB);
|
||||
for (const c of cs) {
|
||||
const i = c.index;
|
||||
if (main && c === main) { roles[i] = 'main'; reasons[i] = why; }
|
||||
else if (!canRun(c)) { roles[i] = 'off'; reasons[i] = 'cannot_run'; }
|
||||
else if (shares(c) && main) { roles[i] = 'off'; reasons[i] = 'igpu'; }
|
||||
else if (rnd(mem(c)) <= OFF_AT_OR_BELOW_GB && (hasBigger || mem(c) < MIN_CARD_GB)) { roles[i] = 'off'; reasons[i] = 'small'; }
|
||||
else { roles[i] = 'helper'; reasons[i] = 'helper'; }
|
||||
}
|
||||
return { roles, main: main ? main.index : null, why, reasons };
|
||||
}
|
||||
|
||||
const on = (roles: Roles) => Object.keys(roles).map(Number).filter(i => roles[i] !== 'off');
|
||||
const byNumber = (roles: Roles) => Object.keys(roles).map(Number).sort((a, b) => a - b);
|
||||
|
||||
/** Exactly one Main among the cards that are on; a lone card that is on is Main. */
|
||||
export function normalize(roles: Roles, cards: RoleCard[]): Roles {
|
||||
const cs = new Map(cards.map(c => [c.index, c]));
|
||||
const out: Roles = {};
|
||||
for (const i of byNumber(roles)) if (cs.has(i)) out[i] = ROLES.includes(roles[i]) ? roles[i] : 'off';
|
||||
const onList = on(out), mains = onList.filter(i => out[i] === 'main');
|
||||
if (onList.length && !mains.length) {
|
||||
const best = [...onList].sort((a, b) => speed(cs.get(b)!) - speed(cs.get(a)!) || rnd(mem(cs.get(b)!)) - rnd(mem(cs.get(a)!)) || a - b)[0];
|
||||
out[best] = 'main';
|
||||
}
|
||||
for (const extra of mains.slice(1)) out[extra] = 'helper';
|
||||
if (onList.length === 1) out[onList[0]] = 'main';
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface RoleChange { roles: Roles; note: string | null }
|
||||
|
||||
/** One click: the new roles and a code for the quiet line (old_main_helper:<i>, promoted:<i>, only_main). */
|
||||
export function setRole(roles: Roles, cards: RoleCard[], idx: number, role: Role): RoleChange {
|
||||
const cs = new Map(cards.map(c => [c.index, c]));
|
||||
const out: Roles = { ...roles };
|
||||
let note: string | null = null;
|
||||
if (!cs.has(idx) || !ROLES.includes(role)) return { roles: out, note };
|
||||
const ids = byNumber(out);
|
||||
const fastest = (list: number[]) => [...list].sort((a, b) => speed(cs.get(b)!) - speed(cs.get(a)!) || a - b);
|
||||
if (role === 'main') {
|
||||
const old = ids.find(i => i !== idx && out[i] === 'main');
|
||||
out[idx] = 'main';
|
||||
if (old !== undefined) { out[old] = 'helper'; note = `old_main_helper:${old}`; }
|
||||
} else if (role === 'helper') {
|
||||
if (out[idx] === 'main') {
|
||||
const others = fastest(ids.filter(i => i !== idx && out[i] === 'helper'));
|
||||
if (!others.length) return { roles: out, note: 'only_main' };
|
||||
out[idx] = 'helper'; out[others[0]] = 'main'; note = `promoted:${others[0]}`;
|
||||
} else out[idx] = 'helper';
|
||||
} else {
|
||||
const wasMain = out[idx] === 'main';
|
||||
out[idx] = 'off';
|
||||
if (wasMain) {
|
||||
const next = fastest(ids.filter(i => out[i] === 'helper'));
|
||||
if (next.length) { out[next[0]] = 'main'; note = `promoted:${next[0]}`; }
|
||||
}
|
||||
}
|
||||
const onList = on(out);
|
||||
if (onList.length === 1) out[onList[0]] = 'main';
|
||||
else if (onList.length && !onList.some(i => out[i] === 'main')) out[onList[0]] = 'main';
|
||||
return { roles: out, note };
|
||||
}
|
||||
|
||||
/** The engine's card list: Helpers (slowest first, ties by number), then the Main card LAST; Off cards left out. */
|
||||
export function order(roles: Roles, cards: RoleCard[]): number[] {
|
||||
const cs = new Map(cards.map(c => [c.index, c]));
|
||||
const r = normalize(roles, cards);
|
||||
const helpers = byNumber(r).filter(i => r[i] === 'helper').sort((a, b) => speed(cs.get(a)!) - speed(cs.get(b)!) || a - b);
|
||||
return [...helpers, ...byNumber(r).filter(i => r[i] === 'main')];
|
||||
}
|
||||
|
||||
export interface RoleMirror { gpu_roles: Record<string, Role>; gpu: number | number[]; gpu_order?: 'as_given'; gpu_none?: true; gpu_cards: Record<string, string> }
|
||||
/** What a writer stores: "gpu_roles" plus the legacy mirror older engines and the Monitor read. Nothing on: "gpu": [] and "gpu_none": true. */
|
||||
export function mirror(roles: Roles, cards: RoleCard[]): RoleMirror {
|
||||
const r = normalize(roles, cards), lst = order(r, cards);
|
||||
const out: RoleMirror = { gpu_roles: Object.fromEntries(byNumber(r).map(i => [String(i), r[i]])), gpu: [], gpu_cards: {} };
|
||||
if (lst.length) { out.gpu = lst.length === 1 ? lst[0] : lst; out.gpu_order = 'as_given'; } else out.gpu_none = true;
|
||||
for (const c of cards) if (c.index in r) out.gpu_cards[String(c.index)] = [shortName(c), c.bus ?? ''].filter(Boolean).join('|');
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Roles of an old "gpu" setting, for display: the LAST card of the engine's order is Main, earlier ones Helpers, unlisted cards Off. */
|
||||
export function fromLegacy(gpu: unknown, cards: RoleCard[], gpuOrder?: string | null): Roles {
|
||||
const cs = new Map(cards.map(c => [c.index, c]));
|
||||
let items: number[] = Array.isArray(gpu) ? gpu.map(Number) : gpu === null || gpu === undefined || gpu === '' ? [] : String(gpu).split(',').filter(x => x.trim() !== '').map(Number);
|
||||
items = items.filter(i => cs.has(i));
|
||||
if (!items.length) return { ...recommend(cards).roles };
|
||||
if (items.length > 1 && gpuOrder !== 'as_given') {
|
||||
const sp = items.map(i => speed(cs.get(i)!));
|
||||
if (sp.every(x => x > 0) && Math.max(...sp) >= SAME_SPEED * Math.min(...sp)) items = [...items].sort((a, b) => speed(cs.get(a)!) - speed(cs.get(b)!));
|
||||
}
|
||||
const roles: Roles = {};
|
||||
for (const c of cards) roles[c.index] = 'off';
|
||||
for (const i of items.slice(0, -1)) roles[i] = 'helper';
|
||||
roles[items[items.length - 1]] = 'main';
|
||||
return roles;
|
||||
}
|
||||
|
||||
export const sameRoles = (a: Roles, b: Roles) => { const ka = byNumber(a), kb = byNumber(b); return ka.length === kb.length && ka.every((k, n) => k === kb[n] && a[k] === b[k]); };
|
||||
|
||||
export interface RoleWarning { code: 'igpu_main' | 'cannot_run' | 'low_memory' | 'cpu_only'; card: number | null; text: string; fix: { card: number; role: Role } | null }
|
||||
|
||||
/** One short line each, only what a person could not know. Never blocking. */
|
||||
export function warnings(roles: Roles, cards: RoleCard[], opts: { spillGb?: number; engineRunsCpuOnly?: boolean } = {}): RoleWarning[] {
|
||||
const cs = new Map(cards.map(c => [c.index, c]));
|
||||
const out: RoleWarning[] = [];
|
||||
for (const i of byNumber(roles)) {
|
||||
const c = cs.get(i), role = roles[i];
|
||||
if (!c || role === 'off') continue;
|
||||
if (!canRun(c)) out.push({ code: 'cannot_run', card: i, text: `${shortName(c)} cannot run Strata.`, fix: { card: i, role: 'off' } });
|
||||
else if (role === 'main' && shares(c) && cards.some(x => !shares(x) && canRun(x))) {
|
||||
const real = cards.filter(x => !shares(x) && canRun(x)).sort((a, b) => speed(b) - speed(a) || mem(b) - mem(a) || a.index - b.index)[0];
|
||||
out.push({ code: 'igpu_main', card: i, text: `${shortName(c)} is built-in graphics; it is slow as Main.`, fix: { card: real.index, role: 'main' } });
|
||||
}
|
||||
}
|
||||
if (!on(roles).length) out.push({ code: 'cpu_only', card: null, text: opts.engineRunsCpuOnly ? CPU_ONLY_LINE : NO_CARD_LINE, fix: null });
|
||||
else if ((opts.spillGb ?? 0) >= 1) out.push({ code: 'low_memory', card: null, text: `Not enough memory for this setup: about ${Math.round(opts.spillGb!)} GB is read from the drive.`, fix: null });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The graphics memory the cards that are on give for the model (an extra card costs its own dense layers); 0 when all are off. */
|
||||
export function vramOn(roles: Roles, cards: RoleCard[]): number {
|
||||
const cs = new Map(cards.map(c => [c.index, c]));
|
||||
const onCards = on(roles).map(i => cs.get(i)).filter((c): c is RoleCard => !!c && !shares(c));
|
||||
return Math.max(0, onCards.reduce((n, c) => n + mem(c), 0) - SPLIT_CARD_OVERHEAD_GB * Math.max(0, onCards.length - 1));
|
||||
}
|
||||
|
||||
export function noteText(code: string | null, cards: RoleCard[]): string {
|
||||
if (!code) return '';
|
||||
const [kind, arg] = code.split(':');
|
||||
const c = arg !== undefined ? cards.find(x => x.index === Number(arg)) : undefined;
|
||||
const name = c ? shortName(c) : 'That card';
|
||||
if (kind === 'old_main_helper') return `${name} is now a Helper.`;
|
||||
if (kind === 'promoted') return `${name} is now Main.`;
|
||||
if (kind === 'only_main') return 'The only card that is on has to be Main.';
|
||||
return '';
|
||||
}
|
||||
|
||||
/** The one plain line above the cards. */
|
||||
export function sentence(cards: RoleCard[] | null | undefined, vendor?: string | null): string {
|
||||
const cs = rows(cards, vendor), rec = recommend(cards, vendor);
|
||||
if (rec.main === null) return 'No graphics card that Strata can use was found.';
|
||||
const byi = new Map(cs.map(c => [c.index, c]));
|
||||
const m = byi.get(rec.main)!;
|
||||
if (cs.length === 1) return `Strata will use your ${shortName(m)}.`;
|
||||
const why = { same: 'the cards are alike', memory: 'it has more memory', faster: 'it is faster', only: 'it is the only card', none: '' }[rec.why];
|
||||
let s = `${shortName(m)} is Main: ${why}.`;
|
||||
const helpers = byNumber(rec.roles).filter(i => rec.roles[i] === 'helper').map(i => shortName(byi.get(i)!));
|
||||
if (helpers.length) s += ` ${helpers.join(' and ')} ${helpers.length > 1 ? 'help' : 'helps'}.`;
|
||||
for (const i of byNumber(rec.roles)) if (rec.roles[i] === 'off') {
|
||||
const code = rec.reasons[i], c = byi.get(i)!;
|
||||
s += ` ${shortName(c)} starts Off: ${code === 'cannot_run' ? 'Strata cannot run it.' : code === 'igpu' ? 'built-in graphics.' : `${OFF_AT_OR_BELOW_GB} GB or less.`}`;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** `Main: GPU 2 RTX 4070 Ti. Helper: GPU 1 RTX 3060.` */
|
||||
export function describe(roles: Roles, cards: RoleCard[]): string {
|
||||
const cs = new Map(cards.map(c => [c.index, c]));
|
||||
const parts: string[] = [];
|
||||
for (const [role, word] of [['main', 'Main'], ['helper', 'Helper'], ['off', 'Off']] as const) {
|
||||
const names = byNumber(roles).filter(i => roles[i] === role).map(i => (cs.has(i) ? `GPU ${i + 1} ${shortName(cs.get(i)!)}` : `GPU ${i + 1}`));
|
||||
if (names.length) parts.push(`${word}: ${names.join(', ')}.`);
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/** A saved "gpu_roles" object -> roles; null when it is not one. */
|
||||
export function parseSaved(value: unknown): Roles | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const out: Roles = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (!/^\d+$/.test(k.trim()) || !ROLES.includes(String(v).toLowerCase() as Role)) return null;
|
||||
out[Number(k)] = String(v).toLowerCase() as Role;
|
||||
}
|
||||
if (!Object.keys(out).length || Object.values(out).filter(r => r === 'main').length > 1) return null;
|
||||
return out;
|
||||
}
|
||||
@@ -7,6 +7,14 @@ import tune_es from '../locales/tune-es.json';
|
||||
import tune_ru from '../locales/tune-ru.json';
|
||||
import tune_pt from '../locales/tune-pt.json';
|
||||
import tune_ko from '../locales/tune-ko.json';
|
||||
import tools_en from '../locales/tools-en.json';
|
||||
import tools_zh from '../locales/tools-zh.json';
|
||||
import tools_ja from '../locales/tools-ja.json';
|
||||
import tools_de from '../locales/tools-de.json';
|
||||
import tools_es from '../locales/tools-es.json';
|
||||
import tools_ru from '../locales/tools-ru.json';
|
||||
import tools_pt from '../locales/tools-pt.json';
|
||||
import tools_ko from '../locales/tools-ko.json';
|
||||
import base_zh from '../locales/zh.json';
|
||||
import base_ja from '../locales/ja.json';
|
||||
import base_de from '../locales/de.json';
|
||||
@@ -18,8 +26,9 @@ import base_ko from '../locales/ko.json';
|
||||
export const LANGUAGES=[{value:'en',label:'English'},{value:'zh',label:'中文(简体)'},{value:'ja',label:'日本語'},{value:'de',label:'Deutsch'},{value:'es',label:'Español'},{value:'ru',label:'Русский'},{value:'pt',label:'Português'},{value:'ko',label:'한국어'}] as const;
|
||||
export type Language=typeof LANGUAGES[number]['value'];
|
||||
/* The Tune model dialog's strings live in tune-<lang>.json (apps/host/scripts/make-tune-locales.py) so other interface work does not collide with them. */
|
||||
const english:Record<string,string>={...baseEnglish,...tune_en};
|
||||
const [zh,ja,de,es,ru,pt,ko]=[{...base_zh,...tune_zh},{...base_ja,...tune_ja},{...base_de,...tune_de},{...base_es,...tune_es},{...base_ru,...tune_ru},{...base_pt,...tune_pt},{...base_ko,...tune_ko}] as Record<string,string>[];
|
||||
/* The tool-server, picture and agent-result strings of the tools and images fixes live in tools-<lang>.json for the same reason. */
|
||||
const english:Record<string,string>={...baseEnglish,...tune_en,...tools_en};
|
||||
const [zh,ja,de,es,ru,pt,ko]=[{...base_zh,...tune_zh,...tools_zh},{...base_ja,...tune_ja,...tools_ja},{...base_de,...tune_de,...tools_de},{...base_es,...tune_es,...tools_es},{...base_ru,...tune_ru,...tools_ru},{...base_pt,...tune_pt,...tools_pt},{...base_ko,...tune_ko,...tools_ko}] as Record<string,string>[];
|
||||
const catalogs:Record<Language,Record<string,string>>={en:english,zh,ja,de,es,ru,pt,ko};
|
||||
const entities:Record<string,string>={amp:'&',lt:'<',gt:'>',quot:'"',apos:"'",nbsp:'\u00a0'};
|
||||
const decode=(text:string)=>text.replace(/&(#x[\da-f]+|#\d+|amp|lt|gt|quot|apos|nbsp);/gi,(whole,key:string)=>key[0]==='#'?String.fromCodePoint(key[1].toLowerCase()==='x'?parseInt(key.slice(2),16):parseInt(key.slice(1),10)):entities[key]??whole);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { rawUrlOf } from './deliverables';
|
||||
|
||||
/**
|
||||
* Which picture addresses a model's Markdown may put in an <img> (tools and images review O3, 2026-10-22).
|
||||
*
|
||||
* An answer can carry ``. It is shown only when the picture is already on this computer or inside the message:
|
||||
* - a data: address of a PNG, JPEG, WebP or GIF (never SVG: an SVG can carry script and styles);
|
||||
* - the host's own /api/files/raw address (a file inside the chat's folder, served by the host and by nobody else);
|
||||
* - a path without a scheme, which is read as a file of the chat's folder through that same address.
|
||||
* Nothing is fetched from the internet by default (an address in the answer would tell its owner that the answer was read, and
|
||||
* which IP read it): an http(s) picture stays a link the person can open. file:, javascript:, blob: and every other scheme show as text.
|
||||
*/
|
||||
export type ImagePlan =
|
||||
| { kind: 'img'; src: string }
|
||||
| { kind: 'link'; href: string }
|
||||
| { kind: 'text' };
|
||||
|
||||
const DATA_IMAGE = /^data:image\/(?:png|jpe?g|webp|gif);base64,[A-Za-z0-9+/]+={0,2}$/i;
|
||||
const SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
||||
|
||||
export function imagePlan(src: string, sessionId?: string | null): ImagePlan {
|
||||
const value = src.trim();
|
||||
if (!value) return { kind: 'text' };
|
||||
if (DATA_IMAGE.test(value)) return { kind: 'img', src: value };
|
||||
if (/^https?:\/\//i.test(value)) return { kind: 'link', href: value };
|
||||
if (value.startsWith('/api/files/raw?')) return { kind: 'img', src: value };
|
||||
// a drive letter (C:\x.png, C:/x.png) is a path, not a scheme
|
||||
if (SCHEME.test(value) && !/^[a-z]:[\\/]/i.test(value)) return { kind: 'text' };
|
||||
if (value.startsWith('//') || value.startsWith('\\\\')) return { kind: 'text' }; // another computer's share
|
||||
if (!sessionId) return { kind: 'text' };
|
||||
return { kind: 'img', src: rawUrlOf(sessionId, value) };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** The one rule for the name of a tool server. A copy of server/mcp-names.mjs (tests/mcp-rows.test.mjs checks that they agree). */
|
||||
export const MCP_NAME_MAX = 32;
|
||||
export const MCP_NAME_RE = /^[A-Za-z0-9_-]{1,32}$/;
|
||||
export const validMcpName = (name: string) => MCP_NAME_RE.test(name);
|
||||
export const MCP_NAME_HELP = 'Use a simple server name of up to 32 characters: letters, numbers, hyphens or underscores.';
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { NativeJob } from './native';
|
||||
|
||||
/** What the host says about a job a restart cut short. */
|
||||
export const INTERRUPTED = 'The host restarted before completion. Check diagnostics, then retry; completed files are kept.';
|
||||
const updateKinds = new Set(['update-strata', 'update-engine']);
|
||||
|
||||
/**
|
||||
* A later finished update of the same component settles the restart interruption of an earlier one (ported from #3578 by medking82).
|
||||
* The records are never changed: a running retry, another component or an ordinary failure stays a plain failure.
|
||||
*/
|
||||
export function recoveredInterruptions(jobs: readonly NativeJob[]): Set<string> {
|
||||
const time = (job: NativeJob) => Date.parse(job.startedAt);
|
||||
return new Set(jobs.filter(job => job.state === 'error' && updateKinds.has(job.kind) && job.error === INTERRUPTED && Number.isFinite(time(job))
|
||||
&& jobs.some(later => later.state === 'done' && later.kind === job.kind && Number.isFinite(time(later)) && time(later) > time(job))).map(job => job.id));
|
||||
}
|
||||
|
||||
/** The text of a failed job's alert. The alert stays red; an interruption a later update has settled says so in plain words. */
|
||||
export function jobErrorText(job: NativeJob, jobs: readonly NativeJob[]): string {
|
||||
return recoveredInterruptions(jobs).has(job.id)
|
||||
? 'This update was cut short when the app restarted. A later update finished, so nothing needs doing.'
|
||||
: job.error ?? job.summary;
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export interface NativeState {
|
||||
/** The picture reader (strata-vision) is installed with the engine, so models marked for pictures can read them here. */
|
||||
/** Strata Control's view of the engine for this computer (the app): usable false = none found or recognised; `notice` says why in plain words. */
|
||||
engine?:{usable:boolean;notice?:string|null;wrongMake?:boolean;version?:string|null};
|
||||
visionReader?: boolean;available:boolean;repo:string;downloadDirectory?:string;version?:string|null;summary:string;error:string|null;catalog:{families:NativeFamily[];contexts:number[];recommendation:{family:string|null;model:string|null;context?:number;why?:string};context_note:string}|null;installed:{models:{model:string;config:string;model_name:string;ready:boolean;/** The picture-reading files are on this computer (notes-split-agent8.md, item 8). */visionPrepared?:boolean;context?:number;kv?:string;images?:string;visionEnabled?:boolean;visionDevice?:string;lowRamMode?:string;resourceProfile?:'work'|'performance';dataFolder?:string;diskGb?:number;diskBytes?:number;/** Files this model shares with another (the picture reader, the draft layer): counted once, in `usage`. */sharedBytes?:number;/** What Delete frees: files in Strata's own folders that no other model uses. */removableGb?:number;missing_files?:string[]}[];/** What all models take on disk, from their real files, shared files counted once. */usage?:{total_bytes:number;total_gb:number;shared_bytes:number;shared_gb:number;models:Record<string,{bytes:number;gb:number}>}|null;data_folder:string;engine:{version:string|null}}|null;hardware:{ram_gb:number;engine_vendor?:string;gpus:{index:number;name:string;vram_gb:number;usable:boolean;vendor?:string;uma?:boolean;integrated?:boolean;unified_gb?:number;problem?:string|null}[]}|null;fields:NativeField[];instances:NativeInstance[];defaultId:string|null;jobs:NativeJob[];capabilities:{responses?:boolean;updates:boolean;rollback:boolean;calibration:boolean;componentUpdates?:boolean}}
|
||||
visionReader?: boolean;available:boolean;repo:string;downloadDirectory?:string;/** Free bytes on the drive that holds the models folder. */downloadFreeBytes?:number|null;downloadTotalBytes?:number|null;version?:string|null;summary:string;error:string|null;catalog:{families:NativeFamily[];contexts:number[];recommendation:{family:string|null;model:string|null;context?:number;why?:string};context_note:string}|null;installed:{models:{model:string;config:string;model_name:string;ready:boolean;/** The picture-reading files are on this computer (notes-split-agent8.md, item 8). */visionPrepared?:boolean;context?:number;kv?:string;images?:string;visionEnabled?:boolean;visionDevice?:string;lowRamMode?:string;resourceProfile?:'work'|'performance';/** The Drafting choice saved with the model: auto | mtp | dflash2 | off. */drafting?:string;dataFolder?:string;diskGb?:number;diskBytes?:number;/** Files this model shares with another (the picture reader, the draft layer): counted once, in `usage`. */sharedBytes?:number;/** What Delete frees: files in Strata's own folders that no other model uses. */removableGb?:number;missing_files?:string[]}[];/** What all models take on disk, from their real files, shared files counted once. */usage?:{total_bytes:number;total_gb:number;shared_bytes:number;shared_gb:number;models:Record<string,{bytes:number;gb:number}>}|null;data_folder:string;engine:{version:string|null}}|null;hardware:{ram_gb:number;engine_vendor?:string;gpus:{index:number;name:string;vram_gb:number;usable:boolean;/** SMs x clock: only the order between cards matters (strata_control/gpu_roles.py). */speed?:number;vendor?:string;uma?:boolean;integrated?:boolean;unified_gb?:number;problem?:string|null}[]}|null;fields:NativeField[];instances:NativeInstance[];defaultId:string|null;jobs:NativeJob[];capabilities:{/** The engine serves DFlash2 (Control says): the Drafting choice then offers it. */dflash2?:boolean;responses?:boolean;updates:boolean;rollback:boolean;calibration:boolean;componentUpdates?:boolean}}
|
||||
let state:NativeState|null=null;
|
||||
const listeners=new Set<()=>void>();
|
||||
function put(value:NativeState){if(state&&JSON.stringify(state)===JSON.stringify(value))return;state=value;listeners.forEach(fn=>fn());}
|
||||
|
||||
@@ -8,13 +8,15 @@ import { tr } from './i18n';
|
||||
|
||||
export interface PlainFailure { message: string; next?: string; detail?: string; kind: string }
|
||||
|
||||
interface Rule { kind: string; match: RegExp; message: string; next?: string; detail?: (m: RegExpMatchArray, raw: string) => string | undefined }
|
||||
interface Rule { kind: string; match: RegExp; message: string | ((m: RegExpMatchArray) => string); next?: string; detail?: (m: RegExpMatchArray, raw: string) => string | undefined }
|
||||
|
||||
const MEMORY = 'The model stopped while starting, most likely because the PC ran low on memory.';
|
||||
const MEMORY_NEXT = 'Close other programs and try again, or choose “Leave 20% for my work”. In Expert mode you can also turn on the low-RAM option, so part of the model is read from the SSD.';
|
||||
|
||||
/** Order matters: the first rule that matches wins. */
|
||||
const RULES: Rule[] = [
|
||||
// The host names the files it could not find (#3478). The sentence with the names is a template in every language: tr() fills it.
|
||||
{ kind: 'model-files-missing', match: /Some model files are missing: (.+?)\. Use Download/i, message: m => `These model files are missing: ${m[1]}.`, next: 'Press Download & load again to fetch them. Files that are already there are kept. If the same files are listed again, open Details and share them when you ask for help.' },
|
||||
{ kind: 'tunnel-own-running', match: /Strata’s tunnel for this address is already running \(PID/i, message: 'Strata’s tunnel for this address is already running.', next: 'Stop it first, or use it as it is, then connect again here.' },
|
||||
{ kind: 'gpu-shared', match: /GPU \d+ is already used by .*, so this model did not fit there/i, message: 'Another model on the same graphics card left no room for this one.', next: 'Load it on a different GPU, or unload the other model first.' },
|
||||
{ kind: 'gpu-no-code', match: /none of this computer.s graphics cards has code in this Strata engine|not an architecture this Strata engine was compiled for/i, message: 'This Strata engine has no code for the graphics card it tried to use.', next: 'Pick another card under Run on, or open Details to see which cards were found and what the engine is built for.' },
|
||||
@@ -34,6 +36,7 @@ const RULES: Rule[] = [
|
||||
{ kind: 'blocked', match: /0xC0000022|3221225506|\bEACCES\b|\bEPERM\b|operation not permitted/i, message: 'Windows or your security software stopped the engine from starting.', next: 'Allow Strata in your security software, then try again.' },
|
||||
{ kind: 'file-missing', match: /\bENOENT\b|no such file or directory|cannot find the (?:file|path)|0x80070002|exit code 127|file not found/i, message: 'Strata could not find a file it needs to start the engine.', next: 'Reinstall Strata or press Repair.' },
|
||||
{ kind: 'memory', match: /out of memory|memory guard|\boom\b|\bkilled\b|exit code 137|cannot allocate memory|not enough memory|bad_alloc|pinned-memory-exhausted|mapped staging|cudaErrorMemoryAllocation|insufficient memory/i, message: MEMORY, next: MEMORY_NEXT },
|
||||
{ kind: 'previous-session', match: /lost host child is still present|retry after it exits/i, message: 'The last Strata session is still closing down.', next: 'Wait a moment and try again. If it keeps happening, restart the computer.' },
|
||||
{ kind: 'start-failed', match: /server_exited|engine stopped|exited before|died before/i, message: 'The model stopped while starting.', next: 'Try again. If it keeps happening, open Details and share them when you ask for help.' },
|
||||
{ kind: 'slow-load', match: /loading took more than/i, message: 'Loading is taking too long.', next: 'The PC may be short on memory. Close other programs and try again.' },
|
||||
{ kind: 'engine-missing', match: /no engine for this pc|engine component|never downloads an engine|engine .*not installed|no strata engine/i, message: 'Strata’s engine for this computer isn’t installed yet.', next: 'It is added when you download a model. Download your model again to add it.' },
|
||||
@@ -50,7 +53,7 @@ export function plainFailure(raw: string | null | undefined): PlainFailure {
|
||||
for (const rule of RULES) {
|
||||
const m = text.match(rule.match);
|
||||
if (!m) continue;
|
||||
return { kind: rule.kind, message: rule.message, next: rule.next, detail: rule.detail ? rule.detail(m, text) : text };
|
||||
return { kind: rule.kind, message: typeof rule.message === 'function' ? rule.message(m) : rule.message, next: rule.next, detail: rule.detail ? rule.detail(m, text) : text };
|
||||
}
|
||||
return { kind: 'plain', message: text.replace(/^./, c => c.toUpperCase()) };
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ function normalise(loc: Location): void {
|
||||
* Navigate, morphing between screens when the browser can.
|
||||
*
|
||||
* A view transition needs the DOM update to happen inside its callback, and
|
||||
* React commits asynchronously — so we wait two frames before resolving. It is
|
||||
* React commits asynchronously — so we wait for the commit (never for a frame: see afterRouteUpdate). It is
|
||||
* skipped while a response is streaming (the transcript is being appended to on
|
||||
* every token, and snapshotting it mid-stream would fight the SSE updates) and
|
||||
* when the user has asked for reduced motion.
|
||||
@@ -100,6 +100,34 @@ export function isWebKitGtk(ua: string = navigator.userAgent): boolean {
|
||||
return /AppleWebKit/.test(ua) && /Linux|X11/.test(ua) && !/Chrome|Chromium|CriOS|Edg|OPR|Android/.test(ua);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve once the new route has been drawn into the page, or after a short deadline, whichever comes first.
|
||||
*
|
||||
* The update callback of a view transition must not wait for requestAnimationFrame: while the callback is pending the browser holds
|
||||
* rendering (Chromium and Firefox both do), so a frame callback never runs, and every click waits for the browser's own guard,
|
||||
* about 4 s in Chromium and the WebView2 window, about 5 s in Firefox (#3673). A DOM change after the hash flipped means React has
|
||||
* committed the new screen; the deadline covers a route that changes nothing visible.
|
||||
*/
|
||||
export function afterRouteUpdate(done: () => void, timeoutMs = 150): void {
|
||||
let settled = false;
|
||||
let observer: MutationObserver | undefined;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
observer?.disconnect();
|
||||
done();
|
||||
};
|
||||
try {
|
||||
if (typeof MutationObserver === 'function' && typeof document !== 'undefined' && document.body) {
|
||||
observer = new MutationObserver(() => setTimeout(finish, 0));
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
} catch {
|
||||
/* the deadline alone is enough */
|
||||
}
|
||||
setTimeout(finish, timeoutMs);
|
||||
}
|
||||
|
||||
function navigateHash(next: string, morph = true) {
|
||||
if (window.location.hash === next) return;
|
||||
|
||||
@@ -121,7 +149,7 @@ function navigateHash(next: string, morph = true) {
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
window.location.hash = next;
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
afterRouteUpdate(resolve);
|
||||
}),
|
||||
);
|
||||
// A transition is *expected* to be skipped when one is already running (rapid
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { Session } from './types';
|
||||
|
||||
/**
|
||||
* Saving the chat list without hurting the page (#3604, #3603).
|
||||
*
|
||||
* Every streamed token used to stringify every chat and write the lot to localStorage. On a long history that is
|
||||
* megabytes of string work per token, and the WebView2 renderer grew to gigabytes before it died (the window goes
|
||||
* white). Three rules fix it, and the stored format is unchanged, so older builds and the flow scripts still read it:
|
||||
*
|
||||
* 1. Coalesce: the newest state wins and at most one write happens per `delayMs`. The caller flushes when a turn ends
|
||||
* or the page goes away.
|
||||
* 2. Only the chats that changed are serialised again. A chat is replaced by a new object when it changes (the store
|
||||
* never mutates), so the previous JSON of every other chat is reused by object identity.
|
||||
* 3. The stored copy is capped: a huge tool result or an old chat that no longer fits is left out of the saved copy,
|
||||
* never out of memory. The saved list is a cache, not the source of truth.
|
||||
*/
|
||||
|
||||
/** Longest text kept for one field in the saved copy. */
|
||||
export const MAX_FIELD_CHARS = 200_000;
|
||||
/** Longest saved copy of one chat (JSON characters). A longer chat is shortened from its oldest messages. */
|
||||
export const MAX_SESSION_CHARS = 1_500_000;
|
||||
/** Longest saved chat list (JSON characters). localStorage allows about 5M; keep a margin for everything else. */
|
||||
export const MAX_TOTAL_CHARS = 3_500_000;
|
||||
|
||||
type Json = unknown;
|
||||
|
||||
function clipFields(value: Json, depth = 0): Json {
|
||||
if (typeof value === 'string') return value.length > MAX_FIELD_CHARS ? `${value.slice(0, MAX_FIELD_CHARS)}\n[shortened in the saved copy]` : value;
|
||||
if (depth > 6 || value === null || typeof value !== 'object') return value;
|
||||
if (Array.isArray(value)) return value.map(v => clipFields(v, depth + 1));
|
||||
const out: Record<string, Json> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, Json>)) out[k] = clipFields(v, depth + 1);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The JSON text kept for one chat: whole when it fits, otherwise clipped and then shortened from the oldest message. */
|
||||
export function serializeSession(session: Session): string {
|
||||
const whole = JSON.stringify(session);
|
||||
if (whole.length <= MAX_SESSION_CHARS) return whole;
|
||||
let copy = clipFields(session) as Session;
|
||||
let text = JSON.stringify(copy);
|
||||
if (text.length <= MAX_SESSION_CHARS) return text;
|
||||
let messages = copy.messages ?? [];
|
||||
// Drop the oldest messages in halves until it fits; always keep the latest two.
|
||||
while (text.length > MAX_SESSION_CHARS && messages.length > 2) {
|
||||
messages = messages.slice(Math.max(1, Math.floor(messages.length / 2)));
|
||||
copy = { ...copy, messages };
|
||||
text = JSON.stringify(copy);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export interface SessionWriterOptions {
|
||||
/** Where the list is saved (the storage write). Receives the finished JSON text. */
|
||||
write: (json: string) => void;
|
||||
delayMs?: number;
|
||||
maxTotalChars?: number;
|
||||
/** Counts a serialisation of one chat; for tests. */
|
||||
onSerialize?: (session: Session) => void;
|
||||
}
|
||||
|
||||
export function createSessionWriter(options: SessionWriterOptions) {
|
||||
const delayMs = options.delayMs ?? 1_000;
|
||||
const maxTotal = options.maxTotalChars ?? MAX_TOTAL_CHARS;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pending: Session[] | null = null;
|
||||
// Chat object -> its JSON. WeakMap: a replaced chat is forgotten with its object.
|
||||
const cache = new WeakMap<Session, string>();
|
||||
|
||||
const textOf = (s: Session): string => {
|
||||
let text = cache.get(s);
|
||||
if (text === undefined) {
|
||||
options.onSerialize?.(s);
|
||||
text = serializeSession(s);
|
||||
cache.set(s, text);
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
if (timer !== null) { clearTimeout(timer); timer = null; }
|
||||
const sessions = pending;
|
||||
pending = null;
|
||||
if (!sessions) return;
|
||||
const parts = sessions.map(s => ({ s, text: textOf(s) }));
|
||||
let total = parts.reduce((n, p) => n + p.text.length + 1, 2);
|
||||
if (total > maxTotal) {
|
||||
// Leave the oldest unpinned chats out of the saved copy until the rest fits. The newest chat is always kept.
|
||||
const order = parts
|
||||
.map((p, i) => ({ i, p }))
|
||||
.sort((a, b) => Number(!!a.p.s.pinned) - Number(!!b.p.s.pinned) || (a.p.s.updatedAt < b.p.s.updatedAt ? -1 : 1));
|
||||
const drop = new Set<number>();
|
||||
for (const { i, p } of order) {
|
||||
if (total <= maxTotal || drop.size >= parts.length - 1) break;
|
||||
drop.add(i);
|
||||
total -= p.text.length + 1;
|
||||
}
|
||||
options.write(`[${parts.filter((_, i) => !drop.has(i)).map(p => p.text).join(',')}]`);
|
||||
return;
|
||||
}
|
||||
options.write(`[${parts.map(p => p.text).join(',')}]`);
|
||||
};
|
||||
|
||||
const schedule = (sessions: Session[]) => {
|
||||
pending = sessions;
|
||||
if (timer === null) timer = setTimeout(flush, delayMs);
|
||||
};
|
||||
|
||||
return { schedule, flush, hasPending: () => pending !== null };
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { noModelNotice } from './no-model';
|
||||
import { taskHandoverMessage, taskHandoverTitle, chatHandoverMessage, chatHandoverTitle } from './task-handover';
|
||||
import type { Task } from './tasks';
|
||||
import { postWithFolderFallback } from './folder-pick';
|
||||
import { createSessionWriter } from './session-persist';
|
||||
import { setTranslationLanguage, tr, validLanguage, type Language } from './i18n';
|
||||
/**
|
||||
* Strata app store.
|
||||
@@ -1008,12 +1009,33 @@ export async function scopeSkill(id: string, workspaceId: string | null) {
|
||||
|
||||
/* ---------------------------------------------------------------- sessions */
|
||||
|
||||
function persistSessions(sessions: Session[]) {
|
||||
try {
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(sessions));
|
||||
} catch {
|
||||
/* quota — sessions are a cache, not the source of truth */
|
||||
}
|
||||
const sessionWriter = createSessionWriter({
|
||||
write: (json) => {
|
||||
try {
|
||||
localStorage.setItem(SESSION_KEY, json);
|
||||
} catch {
|
||||
/* quota — sessions are a cache, not the source of truth */
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Save the chat list. A one-off change (new, rename, delete, pin) is written now; `soon` is for the streaming
|
||||
* path, where a write per token grew the renderer to gigabytes (#3604, #3603): those are coalesced to one a second.
|
||||
*/
|
||||
function persistSessions(sessions: Session[], soon = false) {
|
||||
sessionWriter.schedule(sessions);
|
||||
if (!soon) sessionWriter.flush();
|
||||
}
|
||||
|
||||
/** Write the newest chat list now: a turn ended, or the page is going away. */
|
||||
export function flushSessions() {
|
||||
sessionWriter.flush();
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && typeof document !== 'undefined' && window.addEventListener) {
|
||||
window.addEventListener('pagehide', flushSessions);
|
||||
document.addEventListener?.('visibilitychange', () => { if (document.visibilityState === 'hidden') flushSessions(); });
|
||||
}
|
||||
|
||||
export function newSession(): Session {
|
||||
@@ -1120,7 +1142,7 @@ export function renameSession(id: string, title: string) {
|
||||
|
||||
function patchSession(id: string, fn: (s: Session) => Session) {
|
||||
const sessions = state.sessions.map((s) => (s.id === id ? fn(s) : s));
|
||||
persistSessions(sessions);
|
||||
persistSessions(sessions, true);
|
||||
set({ sessions });
|
||||
}
|
||||
|
||||
@@ -1366,6 +1388,55 @@ export async function retryLast(messageId?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The open chat's last question and the answer that failed under it (null when the chat does not end that way). */
|
||||
function failedExchange(): { prompt: string; sessionId: string } | null {
|
||||
const sessionId = state.activeSession;
|
||||
const chat = state.sessions.find(s => s.id === sessionId);
|
||||
if (!sessionId || !chat || state.streaming) return null;
|
||||
const reply = chat.messages[chat.messages.length - 1], ask = chat.messages[chat.messages.length - 2];
|
||||
return reply?.role === 'assistant' && reply.error && ask?.role === 'user' ? { prompt: ask.content, sessionId } : null;
|
||||
}
|
||||
/** Takes the failed question and its error out of the transcript, so asking again does not show the question twice. */
|
||||
function dropFailedExchange(sessionId: string) {
|
||||
patchSession(sessionId, s => ({ ...s, messages: s.messages.slice(0, -2) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* The error card of a conversation that no longer fits the model's window: summarise the older messages (the same as typing /compact), then
|
||||
* ask the assistant to answer. The refused question is already in the assistant's conversation (it is the newest message, and a summary keeps
|
||||
* the newest message as it is), so it is not sent a second time: a large one twice would not fit either.
|
||||
*/
|
||||
export async function summarizeAndContinue() {
|
||||
if (!failedExchange()) return;
|
||||
await sendMessage('/compact');
|
||||
await sendMessage('Please answer my last message now.');
|
||||
}
|
||||
|
||||
/**
|
||||
* The error card of a message that is too large to send: the host makes it small enough (its text as a file in the chat's folder that the
|
||||
* assistant reads in parts, or a shortened preview), and that is sent in its place.
|
||||
*/
|
||||
export async function sendOversized(mode: 'file' | 'preview') {
|
||||
const failed = failedExchange();
|
||||
if (!failed) return;
|
||||
let reply: { message?: string };
|
||||
try {
|
||||
reply = await api.post<{ message?: string }>('/api/chat/oversize', { sessionId: failed.sessionId, mode, text: failed.prompt, nativeModelId: nativeState()?.instances.length ? nativeState()?.defaultId : undefined });
|
||||
} catch {
|
||||
pushToast('error', 'The message could not be prepared. Try a shorter message.');
|
||||
return;
|
||||
}
|
||||
if (!reply.message) return;
|
||||
dropFailedExchange(failed.sessionId);
|
||||
await sendMessage(reply.message);
|
||||
}
|
||||
|
||||
/** A new, empty chat, opened. */
|
||||
export function startNewChat() {
|
||||
const session = newSession();
|
||||
go('chat', session.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forks a conversation at a message, leaving the original untouched.
|
||||
*
|
||||
@@ -1731,10 +1802,17 @@ export async function sendMessage(text: string, options: { fromLoop?: boolean; g
|
||||
...m,
|
||||
tokens: typeof frame.tokensOut === 'number' ? frame.tokensOut : m.tokens,
|
||||
tps: typeof frame.tps === 'number' ? frame.tps : m.tps,
|
||||
promptTps: typeof frame.promptTps === 'number' ? frame.promptTps : m.promptTps,
|
||||
}));
|
||||
} else if (type === 'context') {
|
||||
// What the context holds after each model request of an agent turn: the meter follows the turn instead of waiting for its end.
|
||||
const used = Number(frame.contextTokens), max = Number(frame.contextMax);
|
||||
if (Number.isFinite(used) && used > 0) patchAssistant((m) => ({ ...m, contextUsed: used, ...(Number.isFinite(max) && max > 0 ? { contextMax: max } : {}) }));
|
||||
} else if (type === 'error') {
|
||||
clearStatus(sessionId);
|
||||
patchAssistant((m) => ({ ...m, error: plainFailure(String(frame.message ?? 'Engine error')).message, streaming: false }));
|
||||
// A sentence the host wrote for the person (it carries its catalog key) is shown in their language as it is; anything else is made plain here.
|
||||
const said = typeof frame.key === 'string' ? tr(frame.key, Array.isArray(frame.values) ? frame.values : undefined) : plainFailure(String(frame.message ?? 'Engine error')).message;
|
||||
patchAssistant((m) => ({ ...m, error: said, streaming: false, ...(typeof frame.code === 'string' ? { errorCode: frame.code } : {}), ...(typeof frame.details === 'string' && frame.details ? { errorDetail: frame.details } : {}) }));
|
||||
} else if (type === 'done') {
|
||||
clearStatus(sessionId);
|
||||
patchSession(sessionId, (sess) => ({
|
||||
@@ -1752,8 +1830,11 @@ export async function sendMessage(text: string, options: { fromLoop?: boolean; g
|
||||
inputTokens: typeof frame.tokensIn === 'number' ? frame.tokensIn : undefined,
|
||||
contextUsed: typeof frame.contextTokens === 'number' ? frame.contextTokens : undefined,
|
||||
usageSource: frame.usageSource === 'reported' || frame.usageSource === 'demo' ? frame.usageSource : 'unavailable',
|
||||
tps: typeof frame.tps === 'number' ? frame.tps : undefined,
|
||||
tps: typeof frame.tps === 'number' ? frame.tps : m.tps,
|
||||
promptTps: typeof frame.promptTps === 'number' ? frame.promptTps : m.promptTps,
|
||||
}));
|
||||
// The final text is in `state`: write it once now instead of waiting for the coalescing timer.
|
||||
flushSessions();
|
||||
}
|
||||
},
|
||||
turnAbort.signal,
|
||||
@@ -1770,6 +1851,8 @@ export async function sendMessage(text: string, options: { fromLoop?: boolean; g
|
||||
patchAssistant((m) => ({ ...m, streaming: false }));
|
||||
}
|
||||
} finally {
|
||||
// A cancelled or failed turn is still worth keeping.
|
||||
flushSessions();
|
||||
clearStatus(sessionId);
|
||||
markTurn(sessionId, null);
|
||||
const next = releasedMessages.get(sessionId)?.shift();
|
||||
|
||||
@@ -223,11 +223,11 @@ export interface ServeRequest {
|
||||
finishReason?: string | null;
|
||||
}
|
||||
|
||||
export interface TunnelState { status: 'idle' | 'starting' | 'connected' | 'stopping' | 'error'; mode: 'quick' | 'domain' | null; url: string | null; error: string | null; installed: boolean; originUrl: string; connector?: { path: string; version: string | null; source: string; label: string; summary: string } | null; external?: { kind: 'service' | 'process'; others: number[]; own: number[]; message: string } | null; }
|
||||
export interface TunnelState { status: 'idle' | 'starting' | 'connected' | 'stopping' | 'error'; mode: 'quick' | 'domain' | null; url: string | null; error: string | null; warning?: string | null; installed: boolean; originUrl: string; connector?: { path: string; version: string | null; source: string; label: string; summary: string } | null; external?: { kind: 'service' | 'process'; others: number[]; own: number[]; message: string } | null; }
|
||||
|
||||
export interface ServingState {
|
||||
tunnel?: TunnelState;
|
||||
sharing?: { active: boolean; url: string | null };
|
||||
sharing?: { active: boolean; url: string | null; warning?: string | null };
|
||||
revision?: number;
|
||||
running: boolean;
|
||||
baseUrl: string;
|
||||
@@ -355,8 +355,13 @@ export interface ChatMessage {
|
||||
reasoningOpen?: boolean;
|
||||
tokens?: number;
|
||||
tps?: number;
|
||||
promptTps?: number;
|
||||
durationMs?: number;
|
||||
error?: string;
|
||||
/** What went wrong, as a code the page acts on (a context error offers its own recovery buttons). */
|
||||
errorCode?: string;
|
||||
/** The engine's own text of the error, kept behind Details. */
|
||||
errorDetail?: string;
|
||||
tools?: ToolCall[];
|
||||
}
|
||||
|
||||
@@ -494,7 +499,7 @@ export interface SetupState {
|
||||
step: number;
|
||||
completedAt?: string;
|
||||
history: { step: number; at: string }[];
|
||||
app: { version: string; modelsDir: string; dataDir: string };
|
||||
app: { version: string; modelsDir: string; dataDir: string; modelsDirNotice?: string };
|
||||
}
|
||||
|
||||
export interface EngineEvent {
|
||||
|
||||
@@ -7,8 +7,8 @@ import type { UpdateCheckResult } from './types';
|
||||
*/
|
||||
const KEY = 'strata.update-check.v1';
|
||||
|
||||
interface Stored { result: UpdateCheckResult; appVersion: string | null; engineVersion: string | null }
|
||||
export interface InstalledVersions { appVersion?: string | null; engineVersion?: string | null }
|
||||
interface Stored { result: UpdateCheckResult; appVersion: string | null; engineVersion: string | null; controlVersion?: string | null }
|
||||
export interface InstalledVersions { appVersion?: string | null; engineVersion?: string | null; controlVersion?: string | null }
|
||||
|
||||
const same = (a: string | null | undefined, b: string | null | undefined) => (a ?? null) === (b ?? null);
|
||||
|
||||
@@ -16,13 +16,15 @@ export function readUpdateCache(now: InstalledVersions): UpdateCheckResult | nul
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(KEY) ?? 'null') as Stored | null;
|
||||
if (!stored?.result?.strata?.status || !stored.result.engine?.status || !stored.result.checkedAt) return null;
|
||||
if (!same(stored.appVersion, now.appVersion) || !same(stored.engineVersion, now.engineVersion)) return null;
|
||||
// Still loading (no app version yet): show nothing rather than a result that may belong to the version before an update.
|
||||
if (!now.appVersion) return null;
|
||||
if (!same(stored.appVersion, now.appVersion) || !same(stored.engineVersion, now.engineVersion) || !same(stored.controlVersion, now.controlVersion)) return null;
|
||||
return stored.result;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function saveUpdateCache(result: UpdateCheckResult, now: InstalledVersions) {
|
||||
try { localStorage.setItem(KEY, JSON.stringify({ result, appVersion: now.appVersion ?? null, engineVersion: now.engineVersion ?? null } satisfies Stored)); } catch { /* Not remembering a result is harmless. */ }
|
||||
try { localStorage.setItem(KEY, JSON.stringify({ result, appVersion: now.appVersion ?? null, engineVersion: now.engineVersion ?? null, controlVersion: now.controlVersion ?? null } satisfies Stored)); } catch { /* Not remembering a result is harmless. */ }
|
||||
}
|
||||
|
||||
export function clearUpdateCache() {
|
||||
|
||||
@@ -10,7 +10,7 @@ export function useInstalledVersions(): InstalledVersions {
|
||||
const native = useNative();
|
||||
const engineVersion = useStore(s => s.engine?.engine.version);
|
||||
const appVersion = appVersionLabel(useStore(s => s.setup?.app.version), native?.version);
|
||||
return { appVersion: appVersion ?? null, engineVersion: (native?.available ? native.installed?.engine?.version : engineVersion) ?? null };
|
||||
return { appVersion: appVersion ?? null, engineVersion: (native?.available ? native.installed?.engine?.version : engineVersion) ?? null, controlVersion: native?.version ?? null };
|
||||
}
|
||||
|
||||
/** The one place the app asks the host to check for updates. Called only from a button press. */
|
||||
|
||||
@@ -3346,5 +3346,185 @@
|
||||
"s8257": "alle {0} Min.",
|
||||
"s8258": "Diese Strata-Engine hat keinen Code für die Grafikkarte, die sie verwenden wollte.",
|
||||
"s8259": "Wähle unter „Ausführen auf“ eine andere Karte, oder öffne die Details: Dort steht, welche Karten gefunden wurden und wofür die Engine gebaut ist.",
|
||||
"s8260": "Dieser Schalter bewirkt in der App nichts. Webseiten beliebiger Seiten können die unter „API-Server“ gezeigte API schon aufrufen. Um nur bestimmte Seiten zuzulassen oder den eigenen Server der Engine (Port 8080) zu nutzen, tragen Sie \"cors_origins\": [\"https://ihre.seite\"] in die Konfigurationsdatei des Modells ein."
|
||||
"s8260": "Dieser Schalter bewirkt in der App nichts. Webseiten beliebiger Seiten können die unter „API-Server“ gezeigte API schon aufrufen. Um nur bestimmte Seiten zuzulassen oder den eigenen Server der Engine (Port 8080) zu nutzen, tragen Sie \"cors_origins\": [\"https://ihre.seite\"] in die Konfigurationsdatei des Modells ein.",
|
||||
"s8301": "API-Schlüssel erstellen (empfohlen)",
|
||||
"s8302": "Ohne API-Schlüssel kann jedes Gerät in diesem Netzwerk Ihr Modell nutzen.",
|
||||
"s8303": "Es wird nur die Inferenz-API freigegeben. Clients brauchen Ihren API-Schlüssel, sofern einer gesetzt ist. Nutzen Sie ein vertrauenswürdiges lokales Netzwerk; diese Verbindung verwendet HTTP.",
|
||||
"s8304": "Erstellen Sie einen Schlüssel, um Ihre API zu schützen. Danach kehren Sie hierher zurück. Sie können auch ohne Schlüssel fortfahren, dann kann aber jeder, der den Link findet, Ihr Modell nutzen.",
|
||||
"s8305": "Sie haben schon ein unterstütztes Modell auf diesem Computer? Wählen Sie den Ordner mit den Dateien oder eine der .gguf-Dateien. Strata prüft sie, bereitet das Modell vor und lädt es. Es wird nichts kopiert oder heruntergeladen.",
|
||||
"s8306": "Ordner mit den GGUF-Dateien oder eine .gguf-Datei",
|
||||
"s8307": "Vollständiger Pfad zum Ordner oder zur Datei",
|
||||
"s8308": "Ohne API-Schlüssel kann jeder, der diese Adresse erreicht, Ihr Modell nutzen.",
|
||||
"s8309": "Strata konnte {0} nicht für Modelle verwenden ({1}); die Modelle werden daher in {2} abgelegt. Sie können in den Einstellungen einen anderen Ordner wählen.",
|
||||
"s8310": "Port {0} wird bereits verwendet. Strata läuft vielleicht schon; schließen Sie es oder das andere Programm, oder starten Sie Strata mit einem anderen Port (STRATA_PORT).",
|
||||
"s8311": "Windows oder eine Firewall erlaubt Strata nicht, Port {0} zu verwenden. Hyper-V, WSL und Docker reservieren Portbereiche für sich (siehe \"netsh int ipv4 show excludedportrange protocol=tcp\"). Starten Sie Strata mit einem anderen Port (STRATA_PORT), zum Beispiel 8801.",
|
||||
"s8312": "{0} ist keine Adresse dieses Computers, daher kann Strata dort nicht lauschen. Prüfen Sie STRATA_BIND.",
|
||||
"s8313": "Strata kann auf {0}:{1} nicht lauschen ({2}).",
|
||||
"s8339": "Modellordner",
|
||||
"s8340": "Strata speichert die großen Modelldateien hier.",
|
||||
"s8341": "Ordner ändern",
|
||||
"s8342": "Warte auf den Ordner …",
|
||||
"s8343": "Speicherplatz belegt",
|
||||
"s8344": "{0} frei",
|
||||
"s8345": "auf diesem Laufwerk",
|
||||
"s8346": "Ihre Modelle belegen {0}",
|
||||
"s8347": "Beenden oder stoppen Sie den Download, um den Ordner zu ändern.",
|
||||
"s8348": "Diesen Ordner gibt es nicht",
|
||||
"s8349": "Dieser Ordner kann nicht verwendet werden",
|
||||
"s8350": "Strata kann in {0} keine Dateien speichern.",
|
||||
"s8351": "Strata kann {0} nicht finden.",
|
||||
"s8352": "Wählen Sie einen Ordner außerhalb des aktuellen.",
|
||||
"s8353": "Wählen Sie einen Ordner, der Ihnen gehört, zum Beispiel in Dokumente oder auf einem anderen Laufwerk.",
|
||||
"s8354": "Prüfen Sie, ob das Laufwerk angeschlossen ist, und wählen Sie den Ordner erneut. Ihre Modelle wurden nicht verändert.",
|
||||
"s8355": "Diesen Ordner für Ihre Modelle verwenden?",
|
||||
"s8356": "Neuer Ordner",
|
||||
"s8357": "{0} frei auf diesem Laufwerk",
|
||||
"s8358": "Sie haben das ganze Laufwerk {0} gewählt. Strata legt seine Dateien in einem eigenen Ordner ab, Ihre Modelle liegen daher in {1}.",
|
||||
"s8359": "Als Nächstes folgen die drei Möglichkeiten.",
|
||||
"s8360": "Meine Modelle verschieben",
|
||||
"s8361": "Beide Ordner verwenden",
|
||||
"s8362": "Leer beginnen",
|
||||
"s8363": "Ihre Modelle werden verschoben",
|
||||
"s8364": "Verschieben stoppen",
|
||||
"s8365": "{0} von {1} GB",
|
||||
"s8366": "Weniger als eine Minute übrig",
|
||||
"s8367": "Etwa eine Minute übrig",
|
||||
"s8368": "Etwa {0} Minuten übrig",
|
||||
"s8369": "Etwa {0} Stunden übrig",
|
||||
"s8370": "Lassen Sie Strata geöffnet. Andere Apps können Sie weiter benutzen. Ihre Modelle lassen sich erst laden, wenn das Verschieben fertig ist.",
|
||||
"s8371": "Kopiert gerade: {0}, Datei {1} von {2}",
|
||||
"s8372": "Von {0} nach {1}",
|
||||
"s8373": "Meine Modelle dorthin verschieben",
|
||||
"s8374": "Sie haben keine Modelle zum Verschieben.",
|
||||
"s8375": "Strata verschiebt Ihre {0} Modelle in den neuen Ordner. Es ist dasselbe Laufwerk, das geht also schnell.",
|
||||
"s8376": "Strata kopiert Ihre {0} Modelle in den neuen Ordner, prüft sie und entfernt dann die alten Kopien.",
|
||||
"s8377": "Braucht keinen zusätzlichen Platz.",
|
||||
"s8378": "Benötigt {0} freien Platz im neuen Ordner. Verfügbar: {1}.",
|
||||
"s8379": "Am bisherigen Ort lassen und zusätzlich den neuen Ordner verwenden",
|
||||
"s8380": "Es wird nichts kopiert. Ihre aktuellen Modelle funktionieren weiter, neue Downloads landen im neuen Ordner.",
|
||||
"s8381": "Braucht jetzt keinen Platz. Neue Downloads müssen in {1} passen.",
|
||||
"s8382": "Braucht jetzt keinen Platz.",
|
||||
"s8383": "Strata verwendet ab jetzt den neuen Ordner und zeigt Ihre aktuellen Modelle nicht mehr an. Ihre alten Dateien werden nicht gelöscht.",
|
||||
"s8384": "Braucht jetzt keinen Platz. Jedes später geladene Modell braucht eigenen Platz.",
|
||||
"s8385": "Ihre Modelle brauchen {0}, auf diesem Laufwerk sind {1} frei.",
|
||||
"s8386": "Geben Sie dort etwa {0} frei, wählen Sie einen anderen Ordner oder nehmen Sie eine der anderen beiden Möglichkeiten.",
|
||||
"s8387": "Was soll mit Ihren Modellen geschehen",
|
||||
"s8388": "Ein laufendes Modell wird zuerst gestoppt; laden Sie es nach dem Verschieben erneut.",
|
||||
"s8389": "{0} würden verschoben.",
|
||||
"s8390": "Der freie Platz wird auf dem Laufwerk gemessen, auf dem {0} liegt.",
|
||||
"s8391": "Alter Ordner: {0}",
|
||||
"s8392": "Strata kopiert eine Datei nach der anderen und prüft ihre Größe, bevor die alte entfernt wird.",
|
||||
"s8393": "Sie können jederzeit stoppen; bereits verschobene Dateien bleiben nutzbar.",
|
||||
"s8394": "Auf dem Laufwerk ist der Platz ausgegangen",
|
||||
"s8395": "Das Verschieben wurde gestoppt",
|
||||
"s8396": "{0} war nach {1} voll, deshalb wurde das Verschieben gestoppt.",
|
||||
"s8397": "Die Modelle konnten nicht verschoben werden.",
|
||||
"s8398": "Es ging nichts verloren. Bereits verschobene Modelle liegen im neuen Ordner, die übrigen noch in {0}, und die halb kopierte Datei wurde entfernt. Schaffen Sie Platz auf {1} oder wählen Sie einen anderen Ordner und versuchen Sie es erneut.",
|
||||
"s8399": "Es ging nichts verloren. Alle Ihre Modelle liegen noch in {0}, und die halb kopierte Datei wurde entfernt. Schaffen Sie Platz auf {1} oder wählen Sie einen anderen Ordner und versuchen Sie es erneut.",
|
||||
"s8400": "Es ging nichts verloren. Die nicht verschobenen Modelle liegen noch in {0}. Wählen Sie einen anderen Ordner, um es erneut zu versuchen.",
|
||||
"s8401": "Verschieben gestoppt",
|
||||
"s8402": "Es ging nichts verloren.",
|
||||
"s8403": "{0} liegt im neuen Ordner.",
|
||||
"s8404": "{0} liegt noch im alten Ordner.",
|
||||
"s8405": "Beide funktionieren.",
|
||||
"s8406": "Modelle in beiden Ordnern werden wie gewohnt angezeigt. Das Verschieben können Sie später mit „Ordner ändern“ beenden.",
|
||||
"s8407": "Ihre Modelle wurden verschoben",
|
||||
"s8408": "{0} liegen jetzt in {1}",
|
||||
"s8409": "Die alten Kopien wurden entfernt, auf {1} sind wieder {0} frei.",
|
||||
"s8410": "Strata verwendet jetzt auch {0}. Neue Downloads landen dort, Ihre aktuellen Modelle bleiben, wo sie sind.",
|
||||
"s8411": "Strata verwendet jetzt {0}. Ihre alten Modelldateien liegen noch in {1}; Strata löscht sie nicht.",
|
||||
"s8412": "Das Ändern des Modellordners ist Teil der Strata-App.",
|
||||
"s8413": "Ein Modell läuft. Stoppen Sie es zuerst und ändern Sie dann den Ordner.",
|
||||
"s8414": "Wählen Sie einen Modellordner.",
|
||||
"s8415": "Wählen Sie, was mit Ihren Modellen geschehen soll.",
|
||||
"s8416": "Diese Möglichkeit gibt es hier nicht.",
|
||||
"s8417": "Ein Verschieben wurde unterbrochen. Beenden Sie es oder lassen Sie es sein.",
|
||||
"s8418": "Strata hat Ihre Modelle von {0} nach {1} verschoben, als es angehalten wurde. Es ist nichts verloren.",
|
||||
"s8419": "Verschieben beenden",
|
||||
"s8420": "Belassen",
|
||||
"s8421": "Beenden legt alle Ihre Modelle in {1} ab. Belassen lässt sie in {0} und entfernt die unvollständigen Kopien aus {1}.",
|
||||
"s8422": "Die unvollständigen Kopien wurden entfernt. Ihre Modelle liegen in {0}.",
|
||||
"s8601": "Grafikkarten",
|
||||
"s8602": "Haupt",
|
||||
"s8603": "Helfer",
|
||||
"s8604": "Ihre Wahl.",
|
||||
"s8605": "Empfohlen.",
|
||||
"s8606": "Sie haben die Rollen geändert. Strata verwendet genau diese.",
|
||||
"s8607": "Empfohlen: {0}",
|
||||
"s8608": "Rolle von {0}",
|
||||
"s8609": "{0} GB Speicher",
|
||||
"s8610": "Nutzt den Arbeitsspeicher Ihres Computers (integrierte Grafik)",
|
||||
"s8611": "Macht die Hauptarbeit",
|
||||
"s8612": "Hält einen Teil des Modells",
|
||||
"s8613": "Nicht genutzt",
|
||||
"s8614": "Wird von {0} verwendet",
|
||||
"s8615": "Ausschalten",
|
||||
"s8616": "{0} zur Hauptkarte machen",
|
||||
"s8617": "{0} kann Strata nicht ausführen.",
|
||||
"s8618": "{0} ist integrierte Grafik; als Hauptkarte ist sie langsam.",
|
||||
"s8619": "Nur mit der CPU ist die Leistung eingeschränkt.",
|
||||
"s8620": "Diese Engine braucht zum Start eine Grafikkarte. Schalten Sie eine ein.",
|
||||
"s8621": "Nicht genug Speicher für diese Einstellung: etwa {0} GB werden vom Laufwerk gelesen.",
|
||||
"s8622": "{0} ist jetzt ein Helfer.",
|
||||
"s8623": "{0} ist jetzt die Hauptkarte.",
|
||||
"s8624": "Die einzige eingeschaltete Karte muss die Hauptkarte sein.",
|
||||
"s8625": "Es wurde keine Grafikkarte gefunden, die Strata nutzen kann.",
|
||||
"s8626": "Strata verwendet Ihre {0}.",
|
||||
"s8627": "{0} ist die Hauptkarte: {1}.",
|
||||
"s8628": "die Karten sind gleich",
|
||||
"s8629": "sie hat mehr Speicher",
|
||||
"s8630": "sie ist schneller",
|
||||
"s8631": "sie ist die einzige Karte",
|
||||
"s8632": "{0} helfen.",
|
||||
"s8633": "{0} hilft.",
|
||||
"s8634": "{0} startet ausgeschaltet: {1}",
|
||||
"s8635": "Strata kann sie nicht ausführen.",
|
||||
"s8636": "integrierte Grafik.",
|
||||
"s8637": "{0} GB oder weniger.",
|
||||
"s8638": "Die Karte, die die letzten Schritte jeder Antwort übernimmt: Sie schreibt die nächsten Wörter, führt den Vorausschau-Schritt aus, prüft ihn und hält Ihr Gespräch. Wählen Sie Ihre schnellste Karte mit viel Speicher. Es gibt immer nur eine.",
|
||||
"s8639": "Hält einen Teil des Modells und seines schnellen Speichers, sodass mehr davon auf den Grafikkarten Platz findet statt vom Laufwerk gelesen zu werden. Prompts werden etwa 20 % schneller gelesen.",
|
||||
"s8640": "Strata rührt diese Karte nicht an. Gut für eine Karte, die Sie anderweitig nutzen, etwa für ein Spiel oder Ihren Bildschirm.",
|
||||
"s8641": "Das Modell braucht etwa {0} GB. Diese Karten und Ihr Arbeitsspeicher bieten etwa {1} GB. Es passt.",
|
||||
"s8642": "Das Modell braucht etwa {0} GB. Diese Karten und Ihr Arbeitsspeicher bieten etwa {1} GB, daher würden etwa {2} GB vom Laufwerk gelesen.",
|
||||
"s8643": "Nur Ihr Prozessor und Arbeitsspeicher werden verwendet.",
|
||||
"s8644": "Für Experten: Die Engine startet auf den Karten {0} (Hauptkarte zuletzt).",
|
||||
"s8645": "Wird nicht gespeichert, solange Sie die Empfehlung behalten: Strata wählt neu, wenn Sie eine Karte hinzufügen oder entfernen.",
|
||||
"s8646": "Nicht genutzt: {0} (anderer Grafikkarten-Hersteller als der, den diese Engine ansteuert).",
|
||||
"s9001": "Diese Nachricht ist etwa {0} Tokens lang, und dieses Modell kann etwa {1} auf einmal lesen. Sie ist zu groß, um so gesendet zu werden.",
|
||||
"s9002": "Hängen Sie sie als Datei an, die der Assistent in Teilen liest, oder senden Sie eine gekürzte Vorschau.",
|
||||
"s9003": "Diese Unterhaltung passt nicht mehr in das Fenster des Modells, und das Zusammenfassen der älteren Nachrichten hat nicht genug Platz geschaffen.",
|
||||
"s9004": "Fassen Sie sie jetzt zusammen und machen Sie weiter, oder beginnen Sie einen neuen Chat.",
|
||||
"s9005": "Frühere Nachrichten werden zusammengefasst …",
|
||||
"s9006": "Ältere Nachrichten wurden zusammengefasst, um Platz zu schaffen. Der Chat war zu {0} % voll und ist jetzt zu {1} % voll.",
|
||||
"s9007": "Ältere Nachrichten wurden zusammengefasst, um Platz zu schaffen. Der Chat war zu {0} % voll.",
|
||||
"s9008": "Ältere Nachrichten wurden zusammengefasst, um Platz zu schaffen.",
|
||||
"s9009": "Dieser Chat ist zu {0} % voll, aber es gibt nichts Älteres zusammenzufassen: Die neueste Nachricht füllt ihn. Beginnen Sie einen neuen Chat oder senden Sie eine kürzere Nachricht.",
|
||||
"s9010": "Es gibt noch nichts Älteres zusammenzufassen. Alles in diesem Chat braucht der Assistent noch.",
|
||||
"s9011": "Es gibt noch nichts zusammenzufassen. In diesem Chat hat noch keine Unterhaltung mit dem Assistenten begonnen.",
|
||||
"s9012": "Die älteren Nachrichten konnten gerade nicht zusammengefasst werden. Versuchen Sie es gleich noch einmal oder beginnen Sie einen neuen Chat.",
|
||||
"s9013": "In diesem Chat läuft noch etwas. Halten Sie ihn an oder warten Sie, bis er fertig ist, und versuchen Sie es dann erneut.",
|
||||
"s9014": "Als Datei anhängen",
|
||||
"s9015": "Gekürzte Vorschau senden",
|
||||
"s9016": "Zusammenfassen und weitermachen",
|
||||
"s9017": "Neuen Chat beginnen",
|
||||
"s9018": "Die Nachricht konnte nicht vorbereitet werden. Versuchen Sie es mit einer kürzeren Nachricht.",
|
||||
"s9019": "Die letzte Strata-Sitzung wird noch beendet.",
|
||||
"s9020": "Warten Sie einen Moment und versuchen Sie es erneut. Wenn es wieder passiert, starten Sie den Computer neu.",
|
||||
"s9021": "Vorausraten",
|
||||
"s9022": "Automatisch (empfohlen)",
|
||||
"s9023": "MTP",
|
||||
"s9024": "DFlash2",
|
||||
"s9025": "Vorausraten: MTP (DFlash2 hat nicht hineingepasst)",
|
||||
"s9026": "Vorausraten: MTP (DFlash2 ist in diesem Modus nicht verfügbar)",
|
||||
"s9027": "Vorausraten: MTP (DFlash2 gibt es nur für Qwen3.8-Flash-Next)",
|
||||
"s9028": "Vorausraten: MTP (der DFlash2-Entwurfsbaustein ist noch nicht heruntergeladen)",
|
||||
"s9029": "Wenn Sie DFlash2 wählen, wird auch sein Entwurfsbaustein heruntergeladen: etwa {0} GB, einmalig. Passt er nicht in den Grafikspeicher, nutzt das Modell stattdessen MTP.",
|
||||
"s9030": "DFlash2-Entwurfsbaustein wird heruntergeladen …",
|
||||
"s9031": "DFlash2-Entwurfsbaustein wird für die Engine vorbereitet …",
|
||||
"s9032": "Der DFlash2-Entwurfsbaustein ist bereit.",
|
||||
"s9033": "Der DFlash2-Entwurfsbaustein konnte nicht heruntergeladen werden, deshalb startet das Modell mit MTP. Das Modell wird geladen …",
|
||||
"s9034": "Das Modell wird geladen …",
|
||||
"s9035": "Diese Modelldateien fehlen: {0}.",
|
||||
"s9036": "Drücken Sie noch einmal auf „Herunterladen und laden“, um sie zu holen. Vorhandene Dateien bleiben erhalten. Werden dieselben Dateien wieder aufgelistet, öffnen Sie die Details und teilen Sie sie mit, wenn Sie um Hilfe bitten.",
|
||||
"s9037": "Dieses Update wurde beim Neustart der App unterbrochen. Ein späteres Update ist abgeschlossen, Sie müssen nichts tun."
|
||||
}
|
||||
@@ -3346,5 +3346,185 @@
|
||||
"s8257": "every {0} min",
|
||||
"s8258": "This Strata engine has no code for the graphics card it tried to use.",
|
||||
"s8259": "Pick another card under Run on, or open Details to see which cards were found and what the engine is built for.",
|
||||
"s8260": "This switch does nothing in the app. Web pages from any site can already call the API shown under Serving. To allow only some sites, or to use the engine’s own server (port 8080), add \"cors_origins\": [\"https://your.site\"] to the model’s config file."
|
||||
"s8260": "This switch does nothing in the app. Web pages from any site can already call the API shown under Serving. To allow only some sites, or to use the engine’s own server (port 8080), add \"cors_origins\": [\"https://your.site\"] to the model’s config file.",
|
||||
"s8301": "Create an API key (recommended)",
|
||||
"s8302": "Without an API key, any device on this network can use your model.",
|
||||
"s8303": "Only the inference API is shared. Clients need your API key when one is set. Use a trusted local network; this connection uses HTTP.",
|
||||
"s8304": "Create a key to protect your API. You’ll return here when it’s ready. You can also continue without one, but anyone who finds the link can then use your model.",
|
||||
"s8305": "Already have a supported model on this computer? Choose the folder that holds its files, or one of its .gguf files. Strata checks them, prepares the model and loads it. Nothing is copied or downloaded.",
|
||||
"s8306": "Folder with the GGUF files, or one .gguf file",
|
||||
"s8307": "Full path to the folder or the file",
|
||||
"s8308": "Without an API key, anyone who can reach this address can use your model.",
|
||||
"s8309": "Strata could not use {0} for models ({1}), so models go to {2}. You can pick another folder in Settings.",
|
||||
"s8310": "Port {0} is already in use. Strata may already be running; close it or the other program, or start Strata with another port (STRATA_PORT).",
|
||||
"s8311": "Windows or a firewall does not let Strata use port {0}. Hyper-V, WSL and Docker keep ranges of ports for themselves (see \"netsh int ipv4 show excludedportrange protocol=tcp\"). Start Strata with another port (STRATA_PORT), for example 8801.",
|
||||
"s8312": "{0} is not an address of this computer, so Strata cannot listen on it. Check STRATA_BIND.",
|
||||
"s8313": "Strata cannot listen on {0}:{1} ({2}).",
|
||||
"s8339": "Models folder",
|
||||
"s8340": "Strata saves the big model files here.",
|
||||
"s8341": "Change folder",
|
||||
"s8342": "Waiting for the folder…",
|
||||
"s8343": "Disk use",
|
||||
"s8344": "{0} free",
|
||||
"s8345": "on this drive",
|
||||
"s8346": "Your models use {0}",
|
||||
"s8347": "Finish or stop the download to change the folder.",
|
||||
"s8348": "That folder isn't there",
|
||||
"s8349": "That folder can't be used",
|
||||
"s8350": "Strata can't save files in {0}.",
|
||||
"s8351": "Strata can't find {0}.",
|
||||
"s8352": "Pick a folder outside the current one.",
|
||||
"s8353": "Choose a folder you own, for example one in Documents or on another drive.",
|
||||
"s8354": "Check that the drive is plugged in, then pick the folder again. Your models were not touched.",
|
||||
"s8355": "Use this folder for your models?",
|
||||
"s8356": "New folder",
|
||||
"s8357": "{0} free on this drive",
|
||||
"s8358": "You picked the whole drive {0}. Strata keeps its files in a folder of their own, so your models go in {1}.",
|
||||
"s8359": "The three choices come next.",
|
||||
"s8360": "Move my models",
|
||||
"s8361": "Use both folders",
|
||||
"s8362": "Start empty",
|
||||
"s8363": "Moving your models",
|
||||
"s8364": "Stop moving",
|
||||
"s8365": "{0} of {1} GB",
|
||||
"s8366": "Less than a minute left",
|
||||
"s8367": "About a minute left",
|
||||
"s8368": "About {0} minutes left",
|
||||
"s8369": "About {0} hours left",
|
||||
"s8370": "Keep Strata open. You can keep using other apps. Your models cannot be loaded until the move is done.",
|
||||
"s8371": "Now copying: {0}, file {1} of {2}",
|
||||
"s8372": "From {0} to {1}",
|
||||
"s8373": "Move my models there",
|
||||
"s8374": "You have no models to move.",
|
||||
"s8375": "Strata moves your {0} of models to the new folder. This is the same drive, so it takes a moment.",
|
||||
"s8376": "Strata copies your {0} of models to the new folder, checks them, then removes the old copies.",
|
||||
"s8377": "Needs no extra space.",
|
||||
"s8378": "Needs {0} free in the new folder. You have {1}.",
|
||||
"s8379": "Keep them where they are and also use the new folder",
|
||||
"s8380": "Nothing is copied. Your current models keep working, and new downloads go to the new folder.",
|
||||
"s8381": "Needs no space now. New downloads must fit in {1}.",
|
||||
"s8382": "Needs no space now.",
|
||||
"s8383": "Strata uses the new folder from now on and does not list your current models. Your old files are not deleted.",
|
||||
"s8384": "Needs no space now. Each model you download later needs its own space.",
|
||||
"s8385": "Your models need {0}, this drive has {1} free.",
|
||||
"s8386": "Free up about {0} there, pick another folder, or choose one of the other two options.",
|
||||
"s8387": "What should happen to your models",
|
||||
"s8388": "A running model is stopped first; load it again when the move is done.",
|
||||
"s8389": "{0} would move.",
|
||||
"s8390": "Free space is measured on the drive that holds {0}.",
|
||||
"s8391": "Old folder: {0}",
|
||||
"s8392": "Strata copies one file at a time and checks its size before it removes the old one.",
|
||||
"s8393": "You can stop at any time; files already moved stay usable.",
|
||||
"s8394": "The drive ran out of space",
|
||||
"s8395": "The move stopped",
|
||||
"s8396": "{0} filled up after {1}, so the move stopped.",
|
||||
"s8397": "The models could not be moved.",
|
||||
"s8398": "Nothing was lost. Models already moved are in the new folder, the rest are still in {0}, and the half-copied file was removed. Free up space on {1} or choose another folder, then try again.",
|
||||
"s8399": "Nothing was lost. All your models are still in {0}, and the half-copied file was removed. Free up space on {1} or choose another folder, then try again.",
|
||||
"s8400": "Nothing was lost. Your models that were not moved are still in {0}. Choose another folder to try again.",
|
||||
"s8401": "Moving stopped",
|
||||
"s8402": "Nothing was lost.",
|
||||
"s8403": "{0} is in the new folder.",
|
||||
"s8404": "{0} is still in the old folder.",
|
||||
"s8405": "Both work.",
|
||||
"s8406": "Models in both folders are listed as normal. You can finish moving later with Change folder.",
|
||||
"s8407": "Your models are moved",
|
||||
"s8408": "{0} now live in {1}",
|
||||
"s8409": "The old copies were removed and {0} is free again on {1}.",
|
||||
"s8410": "Strata now also uses {0}. New downloads go there, and your current models stay where they are.",
|
||||
"s8411": "Strata now uses {0}. Your old model files are still in {1}; Strata does not delete them.",
|
||||
"s8412": "Changing the models folder is part of the Strata app.",
|
||||
"s8413": "A model is running. Stop it first, then change the folder.",
|
||||
"s8414": "Choose a models folder.",
|
||||
"s8415": "Choose what to do with your models.",
|
||||
"s8416": "That choice is not possible here.",
|
||||
"s8417": "A move was interrupted. Finish it or leave it.",
|
||||
"s8418": "Strata was moving your models from {0} to {1} when it stopped. Nothing is lost.",
|
||||
"s8419": "Finish the move",
|
||||
"s8420": "Leave it",
|
||||
"s8421": "Finishing puts all your models in {1}. Leaving it keeps them in {0} and removes the partial copies from {1}.",
|
||||
"s8422": "The partial copies were removed. Your models are in {0}.",
|
||||
"s8601": "Graphics cards",
|
||||
"s8602": "Main",
|
||||
"s8603": "Helper",
|
||||
"s8604": "Your choice.",
|
||||
"s8605": "Recommended.",
|
||||
"s8606": "You changed the roles. Strata will use exactly these.",
|
||||
"s8607": "Recommended: {0}",
|
||||
"s8608": "Role of {0}",
|
||||
"s8609": "{0} GB memory",
|
||||
"s8610": "Uses your computer's memory (built-in graphics)",
|
||||
"s8611": "Does the main work",
|
||||
"s8612": "Holds part of the model",
|
||||
"s8613": "Not used",
|
||||
"s8614": "In use by {0}",
|
||||
"s8615": "Turn it off",
|
||||
"s8616": "Make {0} Main",
|
||||
"s8617": "{0} cannot run Strata.",
|
||||
"s8618": "{0} is built-in graphics; it is slow as Main.",
|
||||
"s8619": "Performance is degraded on CPU only.",
|
||||
"s8620": "This engine needs a graphics card to start. Turn one on.",
|
||||
"s8621": "Not enough memory for this setup: about {0} GB is read from the drive.",
|
||||
"s8622": "{0} is now a Helper.",
|
||||
"s8623": "{0} is now Main.",
|
||||
"s8624": "The only card that is on has to be Main.",
|
||||
"s8625": "No graphics card that Strata can use was found.",
|
||||
"s8626": "Strata will use your {0}.",
|
||||
"s8627": "{0} is Main: {1}.",
|
||||
"s8628": "the cards are alike",
|
||||
"s8629": "it has more memory",
|
||||
"s8630": "it is faster",
|
||||
"s8631": "it is the only card",
|
||||
"s8632": "{0} help.",
|
||||
"s8633": "{0} helps.",
|
||||
"s8634": "{0} starts Off: {1}",
|
||||
"s8635": "Strata cannot run it.",
|
||||
"s8636": "built-in graphics.",
|
||||
"s8637": "{0} GB or less.",
|
||||
"s8638": "The card that does the final steps of every answer: it writes the next words, runs the draft-ahead step and checks it, and keeps your conversation. Pick your fastest card with plenty of memory. There is only ever one.",
|
||||
"s8639": "Holds a share of the model and its fast memory, so more of it fits on graphics cards instead of being read from your drive. Prompts are read about 20% faster.",
|
||||
"s8640": "Strata does not touch this card. Good for a card you use for something else, such as a game or your screen.",
|
||||
"s8641": "The model needs about {0} GB. These cards and your memory give about {1} GB. It fits.",
|
||||
"s8642": "The model needs about {0} GB. These cards and your memory give about {1} GB, so about {2} GB would be read from your drive.",
|
||||
"s8643": "Only your processor and memory are used.",
|
||||
"s8644": "For experts: the engine starts on cards {0} (Main last).",
|
||||
"s8645": "Not saved while you keep the recommendation: Strata picks again if you add or remove a card.",
|
||||
"s8646": "Not used: {0} (another make of graphics card than this engine drives).",
|
||||
"s9001": "This message is about {0} tokens long, and this model can read about {1} at a time. It is too large to send as it is.",
|
||||
"s9002": "Attach it as a file for the assistant to read in parts, or send a shortened preview.",
|
||||
"s9003": "This conversation no longer fits the model’s window, and summarizing the older messages did not make enough room.",
|
||||
"s9004": "Summarize it now and continue, or start a new chat.",
|
||||
"s9005": "Summarizing earlier messages…",
|
||||
"s9006": "Older messages were summarized to make room. The chat was {0}% full and is now {1}% full.",
|
||||
"s9007": "Older messages were summarized to make room. The chat was {0}% full.",
|
||||
"s9008": "Older messages were summarized to make room.",
|
||||
"s9009": "This chat is {0}% full, but there is nothing older to summarize: the newest message is what fills it. Start a new chat, or send a shorter message.",
|
||||
"s9010": "There is nothing older to summarize yet. Everything in this chat is still needed by the assistant.",
|
||||
"s9011": "There is nothing to summarize yet. This chat has not started a conversation with the assistant.",
|
||||
"s9012": "The older messages could not be summarized right now. Try again in a moment, or start a new chat.",
|
||||
"s9013": "This chat is still working. Stop it or wait for it to finish, then try again.",
|
||||
"s9014": "Attach as a file",
|
||||
"s9015": "Send a shortened preview",
|
||||
"s9016": "Summarize and continue",
|
||||
"s9017": "Start a new chat",
|
||||
"s9018": "The message could not be prepared. Try a shorter message.",
|
||||
"s9019": "The last Strata session is still closing down.",
|
||||
"s9020": "Wait a moment and try again. If it keeps happening, restart the computer.",
|
||||
"s9021": "Drafting",
|
||||
"s9022": "Auto (recommended)",
|
||||
"s9023": "MTP",
|
||||
"s9024": "DFlash2",
|
||||
"s9025": "Drafting: MTP (DFlash2 didn't fit)",
|
||||
"s9026": "Drafting: MTP (DFlash2 isn't available in this mode)",
|
||||
"s9027": "Drafting: MTP (DFlash2 is for Qwen3.8-Flash-Next only)",
|
||||
"s9028": "Drafting: MTP (the DFlash2 drafter isn't downloaded yet)",
|
||||
"s9029": "Choosing DFlash2 also downloads its drafter: about {0} GB, once. If it does not fit in graphics memory, the model uses the MTP instead.",
|
||||
"s9030": "Downloading the DFlash2 drafter…",
|
||||
"s9031": "Packing the DFlash2 drafter for the engine…",
|
||||
"s9032": "The DFlash2 drafter is ready.",
|
||||
"s9033": "The DFlash2 drafter could not be downloaded, so the model starts with the MTP. Loading the model…",
|
||||
"s9034": "Loading the model…",
|
||||
"s9035": "These model files are missing: {0}.",
|
||||
"s9036": "Press Download & load again to fetch them. Files that are already there are kept. If the same files are listed again, open Details and share them when you ask for help.",
|
||||
"s9037": "This update was cut short when the app restarted. A later update finished, so nothing needs doing."
|
||||
}
|
||||
@@ -3346,5 +3346,185 @@
|
||||
"s8257": "cada {0} min",
|
||||
"s8258": "Este motor de Strata no tiene código para la tarjeta gráfica que intentó usar.",
|
||||
"s8259": "Elige otra tarjeta en «Ejecutar en», o abre los detalles para ver qué tarjetas se encontraron y para cuáles está compilado el motor.",
|
||||
"s8260": "Este interruptor no hace nada en la app. Las páginas web de cualquier sitio ya pueden llamar a la API que se muestra en Servicio. Para permitir solo algunos sitios, o para usar el servidor propio del motor (puerto 8080), añada \"cors_origins\": [\"https://su.sitio\"] al archivo de configuración del modelo."
|
||||
"s8260": "Este interruptor no hace nada en la app. Las páginas web de cualquier sitio ya pueden llamar a la API que se muestra en Servicio. Para permitir solo algunos sitios, o para usar el servidor propio del motor (puerto 8080), añada \"cors_origins\": [\"https://su.sitio\"] al archivo de configuración del modelo.",
|
||||
"s8301": "Crear una clave de API (recomendado)",
|
||||
"s8302": "Sin una clave de API, cualquier dispositivo de esta red puede usar tu modelo.",
|
||||
"s8303": "Solo se comparte la API de inferencia. Los clientes necesitan tu clave de API si hay una. Usa una red local de confianza; esta conexión usa HTTP.",
|
||||
"s8304": "Crea una clave para proteger tu API. Volverás aquí cuando esté lista. También puedes continuar sin ella, pero cualquiera que encuentre el enlace podrá usar tu modelo.",
|
||||
"s8305": "¿Ya tienes un modelo compatible en este equipo? Elige la carpeta con sus archivos o uno de sus archivos .gguf. Strata los comprueba, prepara el modelo y lo carga. No se copia ni se descarga nada.",
|
||||
"s8306": "Carpeta con los archivos GGUF, o un archivo .gguf",
|
||||
"s8307": "Ruta completa de la carpeta o del archivo",
|
||||
"s8308": "Sin una clave de API, cualquiera que pueda llegar a esta dirección podrá usar tu modelo.",
|
||||
"s8309": "Strata no pudo usar {0} para los modelos ({1}), así que los modelos van a {2}. Puedes elegir otra carpeta en Ajustes.",
|
||||
"s8310": "El puerto {0} ya está en uso. Puede que Strata ya se esté ejecutando; ciérralo o cierra el otro programa, o inicia Strata con otro puerto (STRATA_PORT).",
|
||||
"s8311": "Windows o un cortafuegos no deja que Strata use el puerto {0}. Hyper-V, WSL y Docker reservan rangos de puertos para sí mismos (consulta \"netsh int ipv4 show excludedportrange protocol=tcp\"). Inicia Strata con otro puerto (STRATA_PORT), por ejemplo 8801.",
|
||||
"s8312": "{0} no es una dirección de este equipo, así que Strata no puede escuchar en ella. Revisa STRATA_BIND.",
|
||||
"s8313": "Strata no puede escuchar en {0}:{1} ({2}).",
|
||||
"s8339": "Carpeta de modelos",
|
||||
"s8340": "Strata guarda aquí los archivos grandes de los modelos.",
|
||||
"s8341": "Cambiar carpeta",
|
||||
"s8342": "Esperando la carpeta…",
|
||||
"s8343": "Uso del disco",
|
||||
"s8344": "{0} libres",
|
||||
"s8345": "en esta unidad",
|
||||
"s8346": "Tus modelos ocupan {0}",
|
||||
"s8347": "Termina o detén la descarga para cambiar la carpeta.",
|
||||
"s8348": "Esa carpeta no existe",
|
||||
"s8349": "No se puede usar esa carpeta",
|
||||
"s8350": "Strata no puede guardar archivos en {0}.",
|
||||
"s8351": "Strata no encuentra {0}.",
|
||||
"s8352": "Elige una carpeta fuera de la actual.",
|
||||
"s8353": "Elige una carpeta tuya, por ejemplo una en Documentos o en otra unidad.",
|
||||
"s8354": "Comprueba que la unidad esté conectada y vuelve a elegir la carpeta. Tus modelos no se han tocado.",
|
||||
"s8355": "¿Usar esta carpeta para tus modelos?",
|
||||
"s8356": "Carpeta nueva",
|
||||
"s8357": "{0} libres en esta unidad",
|
||||
"s8358": "Has elegido toda la unidad {0}. Strata guarda sus archivos en una carpeta propia, así que tus modelos irán en {1}.",
|
||||
"s8359": "A continuación verás las tres opciones.",
|
||||
"s8360": "Mover mis modelos",
|
||||
"s8361": "Usar ambas carpetas",
|
||||
"s8362": "Empezar vacío",
|
||||
"s8363": "Moviendo tus modelos",
|
||||
"s8364": "Detener el traslado",
|
||||
"s8365": "{0} de {1} GB",
|
||||
"s8366": "Queda menos de un minuto",
|
||||
"s8367": "Queda aproximadamente un minuto",
|
||||
"s8368": "Quedan aproximadamente {0} minutos",
|
||||
"s8369": "Quedan aproximadamente {0} horas",
|
||||
"s8370": "Mantén Strata abierto. Puedes seguir usando otras aplicaciones. Tus modelos no se podrán cargar hasta que termine el traslado.",
|
||||
"s8371": "Copiando ahora: {0}, archivo {1} de {2}",
|
||||
"s8372": "De {0} a {1}",
|
||||
"s8373": "Mover mis modelos allí",
|
||||
"s8374": "No tienes modelos que mover.",
|
||||
"s8375": "Strata mueve tus {0} de modelos a la carpeta nueva. Es la misma unidad, así que tarda un instante.",
|
||||
"s8376": "Strata copia tus {0} de modelos a la carpeta nueva, los comprueba y después elimina las copias antiguas.",
|
||||
"s8377": "No necesita espacio adicional.",
|
||||
"s8378": "Necesita {0} libres en la carpeta nueva. Tienes {1}.",
|
||||
"s8379": "Dejarlos donde están y usar también la carpeta nueva",
|
||||
"s8380": "No se copia nada. Tus modelos actuales siguen funcionando y las descargas nuevas van a la carpeta nueva.",
|
||||
"s8381": "No necesita espacio ahora. Las descargas nuevas deben caber en {1}.",
|
||||
"s8382": "No necesita espacio ahora.",
|
||||
"s8383": "Strata usará la carpeta nueva a partir de ahora y no mostrará tus modelos actuales. Tus archivos antiguos no se borran.",
|
||||
"s8384": "No necesita espacio ahora. Cada modelo que descargues después necesitará su propio espacio.",
|
||||
"s8385": "Tus modelos necesitan {0} y esta unidad tiene {1} libres.",
|
||||
"s8386": "Libera unos {0} allí, elige otra carpeta o escoge una de las otras dos opciones.",
|
||||
"s8387": "Qué debe pasar con tus modelos",
|
||||
"s8388": "Un modelo en ejecución se detiene antes; cárgalo de nuevo cuando termine el traslado.",
|
||||
"s8389": "Se moverían {0}.",
|
||||
"s8390": "El espacio libre se mide en la unidad que contiene {0}.",
|
||||
"s8391": "Carpeta anterior: {0}",
|
||||
"s8392": "Strata copia un archivo cada vez y comprueba su tamaño antes de eliminar el antiguo.",
|
||||
"s8393": "Puedes detenerlo en cualquier momento; los archivos ya movidos siguen siendo utilizables.",
|
||||
"s8394": "La unidad se quedó sin espacio",
|
||||
"s8395": "El traslado se detuvo",
|
||||
"s8396": "{0} se llenó tras {1}, así que el traslado se detuvo.",
|
||||
"s8397": "No se pudieron mover los modelos.",
|
||||
"s8398": "No se perdió nada. Los modelos ya movidos están en la carpeta nueva, el resto sigue en {0} y se eliminó el archivo copiado a medias. Libera espacio en {1} o elige otra carpeta y vuelve a intentarlo.",
|
||||
"s8399": "No se perdió nada. Todos tus modelos siguen en {0} y se eliminó el archivo copiado a medias. Libera espacio en {1} o elige otra carpeta y vuelve a intentarlo.",
|
||||
"s8400": "No se perdió nada. Los modelos que no se movieron siguen en {0}. Elige otra carpeta para volver a intentarlo.",
|
||||
"s8401": "Traslado detenido",
|
||||
"s8402": "No se perdió nada.",
|
||||
"s8403": "{0} está en la carpeta nueva.",
|
||||
"s8404": "{0} sigue en la carpeta anterior.",
|
||||
"s8405": "Ambos funcionan.",
|
||||
"s8406": "Los modelos de ambas carpetas aparecen como siempre. Puedes terminar el traslado más tarde con «Cambiar carpeta».",
|
||||
"s8407": "Tus modelos se han movido",
|
||||
"s8408": "{0} están ahora en {1}",
|
||||
"s8409": "Se eliminaron las copias antiguas y vuelven a estar libres {0} en {1}.",
|
||||
"s8410": "Strata ahora también usa {0}. Las descargas nuevas van allí y tus modelos actuales se quedan donde están.",
|
||||
"s8411": "Strata ahora usa {0}. Tus archivos de modelos antiguos siguen en {1}; Strata no los borra.",
|
||||
"s8412": "Cambiar la carpeta de modelos forma parte de la aplicación Strata.",
|
||||
"s8413": "Hay un modelo en ejecución. Deténlo primero y después cambia la carpeta.",
|
||||
"s8414": "Elige una carpeta de modelos.",
|
||||
"s8415": "Elige qué hacer con tus modelos.",
|
||||
"s8416": "Esa opción no es posible aquí.",
|
||||
"s8417": "Se interrumpió un traslado. Termínelo o déjelo como estaba.",
|
||||
"s8418": "Strata estaba trasladando sus modelos de {0} a {1} cuando se detuvo. No se ha perdido nada.",
|
||||
"s8419": "Terminar el traslado",
|
||||
"s8420": "Dejarlo",
|
||||
"s8421": "Terminar pone todos sus modelos en {1}. Dejarlo los mantiene en {0} y elimina las copias parciales de {1}.",
|
||||
"s8422": "Se eliminaron las copias parciales. Sus modelos están en {0}.",
|
||||
"s8601": "Tarjetas gráficas",
|
||||
"s8602": "Principal",
|
||||
"s8603": "Ayudante",
|
||||
"s8604": "Tu elección.",
|
||||
"s8605": "Recomendado.",
|
||||
"s8606": "Cambiaste los roles. Strata usará exactamente estos.",
|
||||
"s8607": "Recomendado: {0}",
|
||||
"s8608": "Rol de {0}",
|
||||
"s8609": "{0} GB de memoria",
|
||||
"s8610": "Usa la memoria de tu equipo (gráficos integrados)",
|
||||
"s8611": "Hace el trabajo principal",
|
||||
"s8612": "Guarda parte del modelo",
|
||||
"s8613": "No se usa",
|
||||
"s8614": "En uso por {0}",
|
||||
"s8615": "Apagarla",
|
||||
"s8616": "Hacer principal a {0}",
|
||||
"s8617": "{0} no puede ejecutar Strata.",
|
||||
"s8618": "{0} son gráficos integrados; como principal es lenta.",
|
||||
"s8619": "El rendimiento se reduce al usar solo la CPU.",
|
||||
"s8620": "Este motor necesita una tarjeta gráfica para arrancar. Activa una.",
|
||||
"s8621": "Memoria insuficiente para esta configuración: unos {0} GB se leen del disco.",
|
||||
"s8622": "{0} ahora es ayudante.",
|
||||
"s8623": "{0} ahora es la principal.",
|
||||
"s8624": "La única tarjeta activada tiene que ser la principal.",
|
||||
"s8625": "No se encontró ninguna tarjeta gráfica que Strata pueda usar.",
|
||||
"s8626": "Strata usará tu {0}.",
|
||||
"s8627": "{0} es la principal: {1}.",
|
||||
"s8628": "las tarjetas son iguales",
|
||||
"s8629": "tiene más memoria",
|
||||
"s8630": "es más rápida",
|
||||
"s8631": "es la única tarjeta",
|
||||
"s8632": "{0} ayudan.",
|
||||
"s8633": "{0} ayuda.",
|
||||
"s8634": "{0} empieza apagada: {1}",
|
||||
"s8635": "Strata no puede ejecutarla.",
|
||||
"s8636": "gráficos integrados.",
|
||||
"s8637": "{0} GB o menos.",
|
||||
"s8638": "La tarjeta que hace los pasos finales de cada respuesta: escribe las siguientes palabras, ejecuta el paso de adelanto y lo comprueba, y guarda tu conversación. Elige tu tarjeta más rápida con mucha memoria. Solo hay una.",
|
||||
"s8639": "Guarda una parte del modelo y de su memoria rápida, así cabe más en las tarjetas gráficas en lugar de leerse del disco. Los prompts se leen un 20 % más rápido.",
|
||||
"s8640": "Strata no toca esta tarjeta. Útil para una tarjeta que usas para otra cosa, como un juego o tu pantalla.",
|
||||
"s8641": "El modelo necesita unos {0} GB. Estas tarjetas y tu memoria suman unos {1} GB. Cabe.",
|
||||
"s8642": "El modelo necesita unos {0} GB. Estas tarjetas y tu memoria suman unos {1} GB, así que unos {2} GB se leerían del disco.",
|
||||
"s8643": "Solo se usan tu procesador y tu memoria.",
|
||||
"s8644": "Para expertos: el motor arranca en las tarjetas {0} (la principal al final).",
|
||||
"s8645": "No se guarda mientras mantengas la recomendación: Strata elige de nuevo si añades o quitas una tarjeta.",
|
||||
"s8646": "No se usa: {0} (otra marca de tarjeta gráfica distinta de la que maneja este motor).",
|
||||
"s9001": "Este mensaje tiene unos {0} tokens y este modelo puede leer unos {1} a la vez. Es demasiado grande para enviarlo tal cual.",
|
||||
"s9002": "Adjúntalo como archivo para que el asistente lo lea por partes, o envía una vista previa acortada.",
|
||||
"s9003": "Esta conversación ya no cabe en la ventana del modelo, y resumir los mensajes antiguos no dejó espacio suficiente.",
|
||||
"s9004": "Resúmela ahora y sigue, o empieza un chat nuevo.",
|
||||
"s9005": "Resumiendo mensajes anteriores…",
|
||||
"s9006": "Se resumieron los mensajes antiguos para hacer sitio. El chat estaba al {0} % y ahora está al {1} %.",
|
||||
"s9007": "Se resumieron los mensajes antiguos para hacer sitio. El chat estaba al {0} %.",
|
||||
"s9008": "Se resumieron los mensajes antiguos para hacer sitio.",
|
||||
"s9009": "Este chat está al {0} %, pero no hay nada antiguo que resumir: el mensaje más reciente es lo que lo llena. Empieza un chat nuevo o envía un mensaje más corto.",
|
||||
"s9010": "Todavía no hay nada antiguo que resumir. El asistente aún necesita todo lo que hay en este chat.",
|
||||
"s9011": "Todavía no hay nada que resumir. Este chat aún no ha empezado una conversación con el asistente.",
|
||||
"s9012": "No se pudieron resumir los mensajes antiguos ahora mismo. Inténtalo de nuevo en un momento o empieza un chat nuevo.",
|
||||
"s9013": "Este chat sigue trabajando. Deténlo o espera a que termine y vuelve a intentarlo.",
|
||||
"s9014": "Adjuntar como archivo",
|
||||
"s9015": "Enviar una vista previa acortada",
|
||||
"s9016": "Resumir y continuar",
|
||||
"s9017": "Empezar un chat nuevo",
|
||||
"s9018": "No se pudo preparar el mensaje. Prueba con un mensaje más corto.",
|
||||
"s9019": "La última sesión de Strata todavía se está cerrando.",
|
||||
"s9020": "Espera un momento y vuelve a intentarlo. Si sigue pasando, reinicia el ordenador.",
|
||||
"s9021": "Predicción",
|
||||
"s9022": "Automático (recomendado)",
|
||||
"s9023": "MTP",
|
||||
"s9024": "DFlash2",
|
||||
"s9025": "Predicción: MTP (DFlash2 no cabía)",
|
||||
"s9026": "Predicción: MTP (DFlash2 no está disponible en este modo)",
|
||||
"s9027": "Predicción: MTP (DFlash2 es solo para Qwen3.8-Flash-Next)",
|
||||
"s9028": "Predicción: MTP (el predictor de DFlash2 aún no está descargado)",
|
||||
"s9029": "Elegir DFlash2 también descarga su predictor: unos {0} GB, una sola vez. Si no cabe en la memoria gráfica, el modelo usa MTP.",
|
||||
"s9030": "Descargando el predictor de DFlash2…",
|
||||
"s9031": "Preparando el predictor de DFlash2 para el motor…",
|
||||
"s9032": "El predictor de DFlash2 está listo.",
|
||||
"s9033": "No se pudo descargar el predictor de DFlash2, así que el modelo arranca con MTP. Cargando el modelo…",
|
||||
"s9034": "Cargando el modelo…",
|
||||
"s9035": "Faltan estos archivos del modelo: {0}.",
|
||||
"s9036": "Pulse de nuevo «Descargar y cargar» para obtenerlos. Los archivos que ya están se conservan. Si vuelven a aparecer los mismos archivos, abra los detalles y compártalos cuando pida ayuda.",
|
||||
"s9037": "Esta actualización se interrumpió cuando la aplicación se reinició. Una actualización posterior terminó, así que no hay nada que hacer."
|
||||
}
|
||||
@@ -3346,5 +3346,185 @@
|
||||
"s8257": "{0}分ごと",
|
||||
"s8258": "この Strata エンジンには、使おうとしたグラフィックスカード用のコードが含まれていません。",
|
||||
"s8259": "「実行先」で別のカードを選ぶか、「詳細」を開いて、見つかったカードとエンジンの対応状況を確認してください。",
|
||||
"s8260": "このスイッチはアプリでは何もしません。どのサイトのWebページも、「配信」に表示されるAPIをすでに呼び出せます。一部のサイトだけを許可したい場合や、エンジン自身のサーバー(ポート8080)を使う場合は、モデルの設定ファイルに \"cors_origins\": [\"https://your.site\"] を追加してください。"
|
||||
"s8260": "このスイッチはアプリでは何もしません。どのサイトのWebページも、「配信」に表示されるAPIをすでに呼び出せます。一部のサイトだけを許可したい場合や、エンジン自身のサーバー(ポート8080)を使う場合は、モデルの設定ファイルに \"cors_origins\": [\"https://your.site\"] を追加してください。",
|
||||
"s8301": "APIキーを作成する(推奨)",
|
||||
"s8302": "APIキーがないと、このネットワーク上のどの機器でもモデルを使えます。",
|
||||
"s8303": "共有されるのは推論APIだけです。キーを設定している場合、クライアントにはAPIキーが必要です。信頼できるローカルネットワークで使ってください。この接続はHTTPを使います。",
|
||||
"s8304": "APIを守るキーを作成します。準備ができたらここに戻ります。キーなしで続けることもできますが、その場合はリンクを見つけた人なら誰でもモデルを使えます。",
|
||||
"s8305": "対応モデルがすでにこのコンピューターにありますか?ファイルがあるフォルダー、または.ggufファイルを1つ選んでください。Strataがファイルを確認し、モデルを準備して読み込みます。コピーやダウンロードは行いません。",
|
||||
"s8306": "GGUFファイルのフォルダー、または.ggufファイル1つ",
|
||||
"s8307": "フォルダーまたはファイルのフルパス",
|
||||
"s8308": "APIキーがないと、このアドレスに接続できる人なら誰でもモデルを使えます。",
|
||||
"s8309": "Strataは{0}をモデルの保存先として使えなかったため({1})、モデルは{2}に保存されます。別のフォルダーは設定で選べます。",
|
||||
"s8310": "ポート{0}はすでに使われています。Strataがすでに起動しているかもしれません。それか別のプログラムを閉じるか、別のポート(STRATA_PORT)でStrataを起動してください。",
|
||||
"s8311": "Windowsまたはファイアウォールが、Strataによるポート{0}の使用を許可していません。Hyper-V、WSL、Dockerは一部のポート範囲を自分用に確保します(\"netsh int ipv4 show excludedportrange protocol=tcp\"で確認できます)。別のポート(STRATA_PORT)、たとえば8801でStrataを起動してください。",
|
||||
"s8312": "{0}はこのコンピューターのアドレスではないため、Strataはそこで待ち受けできません。STRATA_BINDを確認してください。",
|
||||
"s8313": "Strataは{0}:{1}で待ち受けできません({2})。",
|
||||
"s8339": "モデルフォルダ",
|
||||
"s8340": "Strata は大きなモデルファイルをここに保存します。",
|
||||
"s8341": "フォルダを変更",
|
||||
"s8342": "フォルダを待っています…",
|
||||
"s8343": "ディスクの使用量",
|
||||
"s8344": "空き {0}",
|
||||
"s8345": "(このドライブ)",
|
||||
"s8346": "モデルの使用量: {0}",
|
||||
"s8347": "フォルダを変更するには、ダウンロードを完了するか停止してください。",
|
||||
"s8348": "そのフォルダは見つかりません",
|
||||
"s8349": "そのフォルダは使えません",
|
||||
"s8350": "Strata は {0} にファイルを保存できません。",
|
||||
"s8351": "Strata は {0} を見つけられません。",
|
||||
"s8352": "現在のフォルダの外にあるフォルダを選んでください。",
|
||||
"s8353": "ご自分のフォルダ(ドキュメント内や別のドライブ上など)を選んでください。",
|
||||
"s8354": "ドライブが接続されているか確認してから、もう一度フォルダを選んでください。モデルには手を加えていません。",
|
||||
"s8355": "このフォルダをモデル用に使いますか?",
|
||||
"s8356": "新しいフォルダ",
|
||||
"s8357": "このドライブの空き: {0}",
|
||||
"s8358": "ドライブ全体 {0} を選びました。Strata は専用のフォルダにファイルを置くため、モデルは {1} に保存されます。",
|
||||
"s8359": "次に 3 つの選択肢が表示されます。",
|
||||
"s8360": "モデルを移動する",
|
||||
"s8361": "両方のフォルダを使う",
|
||||
"s8362": "空の状態で始める",
|
||||
"s8363": "モデルを移動しています",
|
||||
"s8364": "移動を停止",
|
||||
"s8365": "{0} / {1} GB",
|
||||
"s8366": "残り 1 分未満",
|
||||
"s8367": "残り約 1 分",
|
||||
"s8368": "残り約 {0} 分",
|
||||
"s8369": "残り約 {0} 時間",
|
||||
"s8370": "Strata を開いたままにしてください。ほかのアプリは使い続けられます。移動が終わるまでモデルは読み込めません。",
|
||||
"s8371": "コピー中: {0}、ファイル {1} / {2}",
|
||||
"s8372": "{0} から {1} へ",
|
||||
"s8373": "モデルをそこへ移動する",
|
||||
"s8374": "移動するモデルがありません。",
|
||||
"s8375": "Strata はモデル {0} を新しいフォルダへ移動します。同じドライブなので、すぐに終わります。",
|
||||
"s8376": "Strata はモデル {0} を新しいフォルダへコピーし、確認してから古いコピーを削除します。",
|
||||
"s8377": "追加の空き容量は不要です。",
|
||||
"s8378": "新しいフォルダに {0} の空きが必要です。現在の空き: {1}。",
|
||||
"s8379": "今の場所に残し、新しいフォルダも使う",
|
||||
"s8380": "コピーは行いません。今あるモデルはそのまま使え、新しいダウンロードは新しいフォルダに保存されます。",
|
||||
"s8381": "今は空き容量は不要です。新しいダウンロードは {1} に収まる必要があります。",
|
||||
"s8382": "今は空き容量は不要です。",
|
||||
"s8383": "今後は新しいフォルダを使い、今のモデルは一覧に表示しません。古いファイルは削除されません。",
|
||||
"s8384": "今は空き容量は不要です。あとでダウンロードするモデルごとに、その分の空きが必要です。",
|
||||
"s8385": "モデルには {0} が必要ですが、このドライブの空きは {1} です。",
|
||||
"s8386": "そこで約 {0} を空けるか、別のフォルダを選ぶか、ほかの 2 つの選択肢のどちらかを選んでください。",
|
||||
"s8387": "モデルをどう扱うか",
|
||||
"s8388": "実行中のモデルは先に停止されます。移動が終わったらもう一度読み込んでください。",
|
||||
"s8389": "移動対象: {0}。",
|
||||
"s8390": "空き容量は {0} があるドライブで測定しています。",
|
||||
"s8391": "元のフォルダ: {0}",
|
||||
"s8392": "Strata は 1 ファイルずつコピーし、サイズを確認してから古いファイルを削除します。",
|
||||
"s8393": "いつでも停止できます。移動済みのファイルはそのまま使えます。",
|
||||
"s8394": "ドライブの空き容量が足りなくなりました",
|
||||
"s8395": "移動が停止しました",
|
||||
"s8396": "{1} を移動したところで {0} がいっぱいになったため、移動を停止しました。",
|
||||
"s8397": "モデルを移動できませんでした。",
|
||||
"s8398": "失われたものはありません。移動済みのモデルは新しいフォルダにあり、残りは {0} にあります。途中までコピーされたファイルは削除しました。{1} の空きを増やすか別のフォルダを選んで、もう一度お試しください。",
|
||||
"s8399": "失われたものはありません。モデルはすべて {0} に残っており、途中までコピーされたファイルは削除しました。{1} の空きを増やすか別のフォルダを選んで、もう一度お試しください。",
|
||||
"s8400": "失われたものはありません。移動されなかったモデルは {0} に残っています。別のフォルダを選んで、もう一度お試しください。",
|
||||
"s8401": "移動を停止しました",
|
||||
"s8402": "失われたものはありません。",
|
||||
"s8403": "{0} は新しいフォルダにあります。",
|
||||
"s8404": "{0} は元のフォルダに残っています。",
|
||||
"s8405": "どちらも使えます。",
|
||||
"s8406": "どちらのフォルダのモデルも通常どおり一覧に表示されます。移動の続きは、あとで「フォルダを変更」から行えます。",
|
||||
"s8407": "モデルを移動しました",
|
||||
"s8408": "{0} が {1} に移動しました",
|
||||
"s8409": "古いコピーを削除し、{1} の空きが {0} 増えました。",
|
||||
"s8410": "Strata は {0} も使うようになりました。新しいダウンロードはそこに保存され、今のモデルはそのままの場所に残ります。",
|
||||
"s8411": "Strata は {0} を使うようになりました。古いモデルファイルは {1} に残っており、Strata が削除することはありません。",
|
||||
"s8412": "モデルフォルダの変更は Strata アプリの機能です。",
|
||||
"s8413": "モデルが実行中です。先に停止してから、フォルダを変更してください。",
|
||||
"s8414": "モデルフォルダを選んでください。",
|
||||
"s8415": "モデルをどうするか選んでください。",
|
||||
"s8416": "この選択肢はここでは使えません。",
|
||||
"s8417": "移動が中断されました。完了するか、中止してください。",
|
||||
"s8418": "Strata がモデルを {0} から {1} へ移動している途中で止まりました。失われたものはありません。",
|
||||
"s8419": "移動を完了する",
|
||||
"s8420": "中止する",
|
||||
"s8421": "完了すると、すべてのモデルが {1} に入ります。中止すると、モデルは {0} に残り、{1} の不完全なコピーが削除されます。",
|
||||
"s8422": "不完全なコピーを削除しました。モデルは {0} にあります。",
|
||||
"s8601": "グラフィックカード",
|
||||
"s8602": "メイン",
|
||||
"s8603": "ヘルパー",
|
||||
"s8604": "あなたの選択です。",
|
||||
"s8605": "おすすめです。",
|
||||
"s8606": "役割を変更しました。Strata はこのとおりに使います。",
|
||||
"s8607": "おすすめ:{0}",
|
||||
"s8608": "{0} の役割",
|
||||
"s8609": "メモリ {0} GB",
|
||||
"s8610": "コンピューター本体のメモリを使います(内蔵グラフィックス)",
|
||||
"s8611": "主な処理を担当",
|
||||
"s8612": "モデルの一部を保持",
|
||||
"s8613": "使用しません",
|
||||
"s8614": "{0} が使用中",
|
||||
"s8615": "オフにする",
|
||||
"s8616": "{0} をメインにする",
|
||||
"s8617": "{0} では Strata を実行できません。",
|
||||
"s8618": "{0} は内蔵グラフィックスで、メインにすると遅くなります。",
|
||||
"s8619": "CPU のみの場合、性能が低下します。",
|
||||
"s8620": "このエンジンの起動にはグラフィックカードが必要です。1 枚オンにしてください。",
|
||||
"s8621": "この設定ではメモリが足りません。約 {0} GB をドライブから読み込みます。",
|
||||
"s8622": "{0} はヘルパーになりました。",
|
||||
"s8623": "{0} がメインになりました。",
|
||||
"s8624": "オンになっている唯一のカードはメインでなければなりません。",
|
||||
"s8625": "Strata が使えるグラフィックカードが見つかりません。",
|
||||
"s8626": "Strata は {0} を使います。",
|
||||
"s8627": "{0} がメインです:{1}。",
|
||||
"s8628": "カードが同じため",
|
||||
"s8629": "メモリが多いため",
|
||||
"s8630": "より高速なため",
|
||||
"s8631": "唯一のカードのため",
|
||||
"s8632": "{0} がサポートします。",
|
||||
"s8633": "{0} がサポートします。",
|
||||
"s8634": "{0} は最初オフです:{1}",
|
||||
"s8635": "Strata では実行できません。",
|
||||
"s8636": "内蔵グラフィックスです。",
|
||||
"s8637": "{0} GB 以下です。",
|
||||
"s8638": "回答の最後の工程を担当するカードです。次の単語を書き、先読みの工程を実行して確認し、会話を保持します。メモリに余裕のある一番速いカードを選んでください。常に 1 枚だけです。",
|
||||
"s8639": "モデルの一部と高速メモリを保持し、ドライブから読み込む代わりにより多くをグラフィックカード上に置けます。プロンプトの読み込みが約 20% 速くなります。",
|
||||
"s8640": "Strata はこのカードに触れません。ゲームや画面表示など、別の用途に使うカードに向いています。",
|
||||
"s8641": "モデルには約 {0} GB が必要です。これらのカードとメモリで約 {1} GB あり、収まります。",
|
||||
"s8642": "モデルには約 {0} GB が必要です。これらのカードとメモリは約 {1} GB のため、約 {2} GB がドライブから読み込まれます。",
|
||||
"s8643": "プロセッサとメモリのみを使います。",
|
||||
"s8644": "上級者向け:エンジンはカード {0} で起動します(メインが最後)。",
|
||||
"s8645": "おすすめのままの間は保存されません。カードを追加・取り外すと Strata が選び直します。",
|
||||
"s8646": "未使用:{0}(このエンジンが対応するのとは別メーカーのカードです)。",
|
||||
"s9001": "このメッセージは約{0}トークンで、このモデルは一度に約{1}トークンまで読めます。このまま送るには大きすぎます。",
|
||||
"s9002": "ファイルとして添付してアシスタントに分けて読んでもらうか、短くしたプレビューを送ってください。",
|
||||
"s9003": "この会話はモデルのウィンドウに収まらなくなり、古いメッセージを要約しても十分な空きができませんでした。",
|
||||
"s9004": "今すぐ要約して続けるか、新しいチャットを始めてください。",
|
||||
"s9005": "これまでのメッセージを要約しています…",
|
||||
"s9006": "空きを作るために古いメッセージを要約しました。チャットは{0}%埋まっていましたが、現在は{1}%です。",
|
||||
"s9007": "空きを作るために古いメッセージを要約しました。チャットは{0}%埋まっていました。",
|
||||
"s9008": "空きを作るために古いメッセージを要約しました。",
|
||||
"s9009": "このチャットは{0}%埋まっていますが、要約できる古いメッセージはありません。最新のメッセージで埋まっています。新しいチャットを始めるか、もっと短いメッセージを送ってください。",
|
||||
"s9010": "まだ要約できる古いメッセージはありません。このチャットの内容はどれもアシスタントがまだ必要としています。",
|
||||
"s9011": "まだ要約するものがありません。このチャットではアシスタントとの会話が始まっていません。",
|
||||
"s9012": "今は古いメッセージを要約できませんでした。しばらくしてからもう一度試すか、新しいチャットを始めてください。",
|
||||
"s9013": "このチャットはまだ作業中です。停止するか、終わるのを待ってからもう一度試してください。",
|
||||
"s9014": "ファイルとして添付",
|
||||
"s9015": "短くしたプレビューを送る",
|
||||
"s9016": "要約して続ける",
|
||||
"s9017": "新しいチャットを始める",
|
||||
"s9018": "メッセージを準備できませんでした。もっと短いメッセージを試してください。",
|
||||
"s9019": "前回の Strata セッションがまだ終了処理中です。",
|
||||
"s9020": "しばらく待ってからもう一度試してください。繰り返し起こる場合はコンピューターを再起動してください。",
|
||||
"s9021": "先読み",
|
||||
"s9022": "自動(おすすめ)",
|
||||
"s9023": "MTP",
|
||||
"s9024": "DFlash2",
|
||||
"s9025": "先読み:MTP(DFlash2 は収まりませんでした)",
|
||||
"s9026": "先読み:MTP(このモードでは DFlash2 を使えません)",
|
||||
"s9027": "先読み:MTP(DFlash2 は Qwen3.8-Flash-Next 専用です)",
|
||||
"s9028": "先読み:MTP(DFlash2 のドラフトモデルはまだダウンロードされていません)",
|
||||
"s9029": "DFlash2 を選ぶと、そのドラフトモデルも一度だけダウンロードします(約 {0} GB)。グラフィックメモリに収まらない場合は、代わりに MTP を使います。",
|
||||
"s9030": "DFlash2 のドラフトモデルをダウンロードしています…",
|
||||
"s9031": "DFlash2 のドラフトモデルをエンジン用に準備しています…",
|
||||
"s9032": "DFlash2 のドラフトモデルの準備ができました。",
|
||||
"s9033": "DFlash2 のドラフトモデルをダウンロードできなかったため、MTP で起動します。モデルを読み込んでいます…",
|
||||
"s9034": "モデルを読み込んでいます…",
|
||||
"s9035": "次のモデルファイルが見つかりません: {0}。",
|
||||
"s9036": "もう一度「ダウンロードして読み込む」を押すと取得します。すでにあるファイルはそのまま残ります。同じファイルがまた表示される場合は、詳細を開き、助けを求めるときに共有してください。",
|
||||
"s9037": "このアップデートは、アプリの再起動で中断されました。その後のアップデートが完了しているため、対応は不要です。"
|
||||
}
|
||||
@@ -3346,5 +3346,185 @@
|
||||
"s8257": "{0}분마다",
|
||||
"s8258": "이 Strata 엔진에는 사용하려던 그래픽 카드용 코드가 없습니다.",
|
||||
"s8259": "“실행 장치”에서 다른 카드를 고르거나, “세부 정보”에서 찾은 카드와 엔진이 지원하는 카드를 확인하세요.",
|
||||
"s8260": "이 스위치는 앱에서는 아무 일도 하지 않습니다. 어느 사이트의 웹 페이지든 ‘API 서비스’에 표시된 API를 이미 호출할 수 있습니다. 일부 사이트만 허용하거나 엔진의 자체 서버(포트 8080)를 쓰려면 모델의 설정 파일에 \"cors_origins\": [\"https://your.site\"] 를 추가하세요."
|
||||
"s8260": "이 스위치는 앱에서는 아무 일도 하지 않습니다. 어느 사이트의 웹 페이지든 ‘API 서비스’에 표시된 API를 이미 호출할 수 있습니다. 일부 사이트만 허용하거나 엔진의 자체 서버(포트 8080)를 쓰려면 모델의 설정 파일에 \"cors_origins\": [\"https://your.site\"] 를 추가하세요.",
|
||||
"s8301": "API 키 만들기(권장)",
|
||||
"s8302": "API 키가 없으면 이 네트워크의 모든 기기가 모델을 사용할 수 있습니다.",
|
||||
"s8303": "추론 API만 공유됩니다. 키를 설정한 경우 클라이언트에 API 키가 필요합니다. 신뢰할 수 있는 로컬 네트워크에서 사용하세요. 이 연결은 HTTP를 사용합니다.",
|
||||
"s8304": "API를 보호할 키를 만드세요. 준비되면 이 화면으로 돌아옵니다. 키 없이 계속할 수도 있지만, 그러면 링크를 찾은 누구나 모델을 사용할 수 있습니다.",
|
||||
"s8305": "이 컴퓨터에 이미 지원되는 모델이 있나요? 파일이 있는 폴더나 .gguf 파일 하나를 선택하세요. Strata가 파일을 확인하고 모델을 준비해 불러옵니다. 복사하거나 다운로드하지 않습니다.",
|
||||
"s8306": "GGUF 파일이 있는 폴더 또는 .gguf 파일 하나",
|
||||
"s8307": "폴더 또는 파일의 전체 경로",
|
||||
"s8308": "API 키가 없으면 이 주소에 접속할 수 있는 누구나 모델을 사용할 수 있습니다.",
|
||||
"s8309": "Strata가 {0}을(를) 모델 폴더로 사용할 수 없어({1}) 모델을 {2}에 저장합니다. 설정에서 다른 폴더를 고를 수 있습니다.",
|
||||
"s8310": "포트 {0}을(를) 이미 사용 중입니다. Strata가 이미 실행 중일 수 있습니다. 해당 프로그램을 닫거나 다른 포트(STRATA_PORT)로 Strata를 시작하세요.",
|
||||
"s8311": "Windows 또는 방화벽이 Strata의 포트 {0} 사용을 허용하지 않습니다. Hyper-V, WSL, Docker는 일부 포트 범위를 자체적으로 예약합니다(\"netsh int ipv4 show excludedportrange protocol=tcp\" 참고). 다른 포트(STRATA_PORT), 예를 들어 8801로 Strata를 시작하세요.",
|
||||
"s8312": "{0}은(는) 이 컴퓨터의 주소가 아니어서 Strata가 해당 주소에서 연결을 받을 수 없습니다. STRATA_BIND를 확인하세요.",
|
||||
"s8313": "Strata가 {0}:{1}에서 연결을 받을 수 없습니다({2}).",
|
||||
"s8339": "모델 폴더",
|
||||
"s8340": "Strata는 큰 모델 파일을 여기에 저장합니다.",
|
||||
"s8341": "폴더 변경",
|
||||
"s8342": "폴더를 기다리는 중…",
|
||||
"s8343": "디스크 사용량",
|
||||
"s8344": "{0} 여유",
|
||||
"s8345": "(이 드라이브)",
|
||||
"s8346": "모델이 차지하는 공간: {0}",
|
||||
"s8347": "폴더를 바꾸려면 다운로드를 끝내거나 중지하세요.",
|
||||
"s8348": "해당 폴더가 없습니다",
|
||||
"s8349": "해당 폴더는 사용할 수 없습니다",
|
||||
"s8350": "Strata가 {0}에 파일을 저장할 수 없습니다.",
|
||||
"s8351": "Strata가 {0}을(를) 찾을 수 없습니다.",
|
||||
"s8352": "현재 폴더 밖에 있는 폴더를 선택하세요.",
|
||||
"s8353": "내 소유의 폴더(예: 문서 폴더나 다른 드라이브)를 선택하세요.",
|
||||
"s8354": "드라이브가 연결되어 있는지 확인한 뒤 폴더를 다시 선택하세요. 모델은 그대로입니다.",
|
||||
"s8355": "이 폴더를 모델용으로 사용할까요?",
|
||||
"s8356": "새 폴더",
|
||||
"s8357": "이 드라이브의 여유 공간: {0}",
|
||||
"s8358": "드라이브 전체 {0}을(를) 선택했습니다. Strata는 자체 폴더에 파일을 보관하므로 모델은 {1}에 저장됩니다.",
|
||||
"s8359": "다음으로 세 가지 선택지가 나옵니다.",
|
||||
"s8360": "내 모델 이동",
|
||||
"s8361": "두 폴더 모두 사용",
|
||||
"s8362": "빈 상태로 시작",
|
||||
"s8363": "모델을 이동하는 중",
|
||||
"s8364": "이동 중지",
|
||||
"s8365": "{0} / {1} GB",
|
||||
"s8366": "1분 미만 남음",
|
||||
"s8367": "약 1분 남음",
|
||||
"s8368": "약 {0}분 남음",
|
||||
"s8369": "약 {0}시간 남음",
|
||||
"s8370": "Strata를 열어 두세요. 다른 앱은 계속 사용할 수 있습니다. 이동이 끝날 때까지 모델을 불러올 수 없습니다.",
|
||||
"s8371": "복사 중: {0}, 파일 {1}/{2}",
|
||||
"s8372": "{0}에서 {1}(으)로",
|
||||
"s8373": "내 모델을 그쪽으로 이동",
|
||||
"s8374": "이동할 모델이 없습니다.",
|
||||
"s8375": "Strata가 모델 {0}을(를) 새 폴더로 옮깁니다. 같은 드라이브라 금방 끝납니다.",
|
||||
"s8376": "Strata가 모델 {0}을(를) 새 폴더로 복사하고 확인한 뒤 기존 사본을 삭제합니다.",
|
||||
"s8377": "추가 공간이 필요 없습니다.",
|
||||
"s8378": "새 폴더에 {0}의 여유 공간이 필요합니다. 현재 {1}.",
|
||||
"s8379": "지금 위치에 두고 새 폴더도 함께 사용",
|
||||
"s8380": "아무것도 복사하지 않습니다. 현재 모델은 그대로 동작하고, 새로 받는 모델은 새 폴더에 저장됩니다.",
|
||||
"s8381": "지금은 공간이 필요 없습니다. 새 다운로드는 {1} 안에 들어가야 합니다.",
|
||||
"s8382": "지금은 공간이 필요 없습니다.",
|
||||
"s8383": "앞으로 새 폴더를 사용하며 현재 모델은 목록에 표시하지 않습니다. 기존 파일은 삭제되지 않습니다.",
|
||||
"s8384": "지금은 공간이 필요 없습니다. 나중에 받는 모델마다 각자의 공간이 필요합니다.",
|
||||
"s8385": "모델에 {0}이(가) 필요한데 이 드라이브의 여유 공간은 {1}입니다.",
|
||||
"s8386": "그곳에서 약 {0}을(를) 비우거나, 다른 폴더를 선택하거나, 나머지 두 가지 중 하나를 고르세요.",
|
||||
"s8387": "모델을 어떻게 할까요",
|
||||
"s8388": "실행 중인 모델은 먼저 중지됩니다. 이동이 끝나면 다시 불러오세요.",
|
||||
"s8389": "{0}이(가) 이동됩니다.",
|
||||
"s8390": "여유 공간은 {0}이(가) 있는 드라이브를 기준으로 측정합니다.",
|
||||
"s8391": "기존 폴더: {0}",
|
||||
"s8392": "Strata는 파일을 하나씩 복사하고, 크기를 확인한 뒤에 기존 파일을 삭제합니다.",
|
||||
"s8393": "언제든 중지할 수 있으며, 이미 이동한 파일은 계속 사용할 수 있습니다.",
|
||||
"s8394": "드라이브의 공간이 부족합니다",
|
||||
"s8395": "이동이 중지되었습니다",
|
||||
"s8396": "{1}을(를) 옮긴 뒤 {0}이(가) 가득 차서 이동을 중지했습니다.",
|
||||
"s8397": "모델을 이동하지 못했습니다.",
|
||||
"s8398": "잃어버린 것은 없습니다. 이미 이동한 모델은 새 폴더에 있고 나머지는 {0}에 그대로 있으며, 반쯤 복사된 파일은 삭제했습니다. {1}의 공간을 확보하거나 다른 폴더를 선택한 뒤 다시 시도하세요.",
|
||||
"s8399": "잃어버린 것은 없습니다. 모든 모델이 {0}에 그대로 있으며, 반쯤 복사된 파일은 삭제했습니다. {1}의 공간을 확보하거나 다른 폴더를 선택한 뒤 다시 시도하세요.",
|
||||
"s8400": "잃어버린 것은 없습니다. 이동하지 못한 모델은 {0}에 그대로 있습니다. 다른 폴더를 선택해 다시 시도하세요.",
|
||||
"s8401": "이동 중지됨",
|
||||
"s8402": "잃어버린 것은 없습니다.",
|
||||
"s8403": "{0}은(는) 새 폴더에 있습니다.",
|
||||
"s8404": "{0}은(는) 아직 기존 폴더에 있습니다.",
|
||||
"s8405": "둘 다 사용할 수 있습니다.",
|
||||
"s8406": "두 폴더의 모델 모두 평소처럼 목록에 표시됩니다. 나머지 이동은 나중에 '폴더 변경'으로 마칠 수 있습니다.",
|
||||
"s8407": "모델 이동이 끝났습니다",
|
||||
"s8408": "{0}이(가) 이제 {1}에 있습니다",
|
||||
"s8409": "기존 사본을 삭제했으며 {1}에서 {0}이(가) 다시 확보되었습니다.",
|
||||
"s8410": "이제 Strata가 {0}도 사용합니다. 새 다운로드는 그곳에 저장되고 현재 모델은 그대로 있습니다.",
|
||||
"s8411": "이제 Strata가 {0}을(를) 사용합니다. 기존 모델 파일은 {1}에 그대로 있으며 Strata가 삭제하지 않습니다.",
|
||||
"s8412": "모델 폴더 변경은 Strata 앱의 기능입니다.",
|
||||
"s8413": "모델이 실행 중입니다. 먼저 중지한 뒤 폴더를 변경하세요.",
|
||||
"s8414": "모델 폴더를 선택하세요.",
|
||||
"s8415": "모델을 어떻게 할지 선택하세요.",
|
||||
"s8416": "여기서는 해당 선택지를 사용할 수 없습니다.",
|
||||
"s8417": "이동이 중단되었습니다. 마무리하거나 그대로 두세요.",
|
||||
"s8418": "Strata가 모델을 {0}에서 {1}(으)로 옮기던 중 멈췄습니다. 잃어버린 것은 없습니다.",
|
||||
"s8419": "이동 마무리",
|
||||
"s8420": "그대로 두기",
|
||||
"s8421": "마무리하면 모든 모델이 {1}에 들어갑니다. 그대로 두면 모델은 {0}에 남고 {1}의 불완전한 복사본이 삭제됩니다.",
|
||||
"s8422": "불완전한 복사본을 삭제했습니다. 모델은 {0}에 있습니다.",
|
||||
"s8601": "그래픽 카드",
|
||||
"s8602": "메인",
|
||||
"s8603": "헬퍼",
|
||||
"s8604": "직접 고른 설정입니다.",
|
||||
"s8605": "추천 설정입니다.",
|
||||
"s8606": "역할을 변경했습니다. Strata는 이 설정을 그대로 사용합니다.",
|
||||
"s8607": "추천: {0}",
|
||||
"s8608": "{0}의 역할",
|
||||
"s8609": "메모리 {0} GB",
|
||||
"s8610": "컴퓨터 본체 메모리를 사용합니다 (내장 그래픽)",
|
||||
"s8611": "주요 작업을 처리합니다",
|
||||
"s8612": "모델의 일부를 담당합니다",
|
||||
"s8613": "사용 안 함",
|
||||
"s8614": "{0}에서 사용 중",
|
||||
"s8615": "끄기",
|
||||
"s8616": "{0}을(를) 메인으로 지정",
|
||||
"s8617": "{0}에서는 Strata를 실행할 수 없습니다.",
|
||||
"s8618": "{0}은(는) 내장 그래픽이라 메인으로 쓰면 느립니다.",
|
||||
"s8619": "CPU만 사용하면 성능이 떨어집니다.",
|
||||
"s8620": "이 엔진은 시작하려면 그래픽 카드가 필요합니다. 하나를 켜세요.",
|
||||
"s8621": "이 설정에는 메모리가 부족합니다. 약 {0} GB를 드라이브에서 읽습니다.",
|
||||
"s8622": "{0}이(가) 이제 헬퍼입니다.",
|
||||
"s8623": "{0}이(가) 이제 메인입니다.",
|
||||
"s8624": "켜져 있는 유일한 카드는 메인이어야 합니다.",
|
||||
"s8625": "Strata가 사용할 수 있는 그래픽 카드를 찾지 못했습니다.",
|
||||
"s8626": "Strata가 {0}을(를) 사용합니다.",
|
||||
"s8627": "{0}이(가) 메인입니다: {1}.",
|
||||
"s8628": "카드가 같습니다",
|
||||
"s8629": "메모리가 더 많습니다",
|
||||
"s8630": "더 빠릅니다",
|
||||
"s8631": "유일한 카드입니다",
|
||||
"s8632": "{0}이(가) 돕습니다.",
|
||||
"s8633": "{0}이(가) 돕습니다.",
|
||||
"s8634": "{0}은(는) 기본적으로 꺼집니다: {1}",
|
||||
"s8635": "Strata에서 실행할 수 없습니다.",
|
||||
"s8636": "내장 그래픽입니다.",
|
||||
"s8637": "{0} GB 이하입니다.",
|
||||
"s8638": "모든 답변의 마지막 단계를 맡는 카드입니다. 다음 단어를 쓰고, 미리 계산하는 단계를 실행해 확인하며, 대화를 보관합니다. 메모리가 넉넉한 가장 빠른 카드를 고르세요. 항상 하나뿐입니다.",
|
||||
"s8639": "모델의 일부와 빠른 메모리를 담당해, 더 많은 부분이 드라이브가 아니라 그래픽 카드에 올라갑니다. 프롬프트를 약 20% 더 빠르게 읽습니다.",
|
||||
"s8640": "Strata가 이 카드를 건드리지 않습니다. 게임이나 화면 출력 등 다른 용도로 쓰는 카드에 알맞습니다.",
|
||||
"s8641": "모델에는 약 {0} GB가 필요합니다. 이 카드들과 메모리는 약 {1} GB를 제공하므로 충분합니다.",
|
||||
"s8642": "모델에는 약 {0} GB가 필요합니다. 이 카드들과 메모리는 약 {1} GB를 제공하므로 약 {2} GB를 드라이브에서 읽게 됩니다.",
|
||||
"s8643": "프로세서와 메모리만 사용합니다.",
|
||||
"s8644": "전문가용: 엔진은 카드 {0}에서 시작합니다 (메인이 마지막).",
|
||||
"s8645": "추천 설정을 유지하는 동안은 저장되지 않습니다. 카드를 추가하거나 빼면 Strata가 다시 고릅니다.",
|
||||
"s8646": "사용 안 함: {0} (이 엔진이 다루는 것과 다른 제조사의 그래픽 카드입니다).",
|
||||
"s9001": "이 메시지는 약 {0}토큰이고, 이 모델은 한 번에 약 {1}토큰까지 읽을 수 있습니다. 그대로 보내기에는 너무 큽니다.",
|
||||
"s9002": "파일로 첨부해 어시스턴트가 나눠 읽게 하거나, 줄인 미리보기를 보내세요.",
|
||||
"s9003": "이 대화는 더 이상 모델의 창에 맞지 않으며, 오래된 메시지를 요약해도 충분한 공간이 나오지 않았습니다.",
|
||||
"s9004": "지금 요약하고 계속하거나, 새 채팅을 시작하세요.",
|
||||
"s9005": "이전 메시지를 요약하는 중…",
|
||||
"s9006": "공간을 만들기 위해 오래된 메시지를 요약했습니다. 채팅이 {0}% 찼던 것이 지금은 {1}%입니다.",
|
||||
"s9007": "공간을 만들기 위해 오래된 메시지를 요약했습니다. 채팅이 {0}% 찼습니다.",
|
||||
"s9008": "공간을 만들기 위해 오래된 메시지를 요약했습니다.",
|
||||
"s9009": "이 채팅은 {0}% 찼지만 요약할 오래된 메시지가 없습니다. 가장 최근 메시지가 공간을 차지하고 있습니다. 새 채팅을 시작하거나 더 짧은 메시지를 보내세요.",
|
||||
"s9010": "아직 요약할 오래된 메시지가 없습니다. 이 채팅의 내용은 어시스턴트가 아직 모두 필요로 합니다.",
|
||||
"s9011": "아직 요약할 내용이 없습니다. 이 채팅은 아직 어시스턴트와 대화를 시작하지 않았습니다.",
|
||||
"s9012": "지금은 오래된 메시지를 요약할 수 없었습니다. 잠시 후 다시 시도하거나 새 채팅을 시작하세요.",
|
||||
"s9013": "이 채팅은 아직 작업 중입니다. 중지하거나 끝날 때까지 기다린 뒤 다시 시도하세요.",
|
||||
"s9014": "파일로 첨부",
|
||||
"s9015": "줄인 미리보기 보내기",
|
||||
"s9016": "요약하고 계속하기",
|
||||
"s9017": "새 채팅 시작",
|
||||
"s9018": "메시지를 준비하지 못했습니다. 더 짧은 메시지를 시도해 보세요.",
|
||||
"s9019": "지난 Strata 세션이 아직 종료되는 중입니다.",
|
||||
"s9020": "잠시 기다렸다가 다시 시도하세요. 계속 반복되면 컴퓨터를 다시 시작하세요.",
|
||||
"s9021": "초안 예측",
|
||||
"s9022": "자동(권장)",
|
||||
"s9023": "MTP",
|
||||
"s9024": "DFlash2",
|
||||
"s9025": "초안 예측: MTP(DFlash2가 들어가지 않았습니다)",
|
||||
"s9026": "초안 예측: MTP(이 모드에서는 DFlash2를 쓸 수 없습니다)",
|
||||
"s9027": "초안 예측: MTP(DFlash2는 Qwen3.8-Flash-Next 전용입니다)",
|
||||
"s9028": "초안 예측: MTP(DFlash2 초안 모델이 아직 내려받아지지 않았습니다)",
|
||||
"s9029": "DFlash2를 고르면 초안 모델도 한 번 내려받습니다(약 {0}GB). 그래픽 메모리에 들어가지 않으면 모델은 대신 MTP를 씁니다.",
|
||||
"s9030": "DFlash2 초안 모델을 내려받는 중…",
|
||||
"s9031": "엔진용 DFlash2 초안 모델을 준비하는 중…",
|
||||
"s9032": "DFlash2 초안 모델이 준비되었습니다.",
|
||||
"s9033": "DFlash2 초안 모델을 내려받지 못해 모델을 MTP로 시작합니다. 모델을 불러오는 중…",
|
||||
"s9034": "모델을 불러오는 중…",
|
||||
"s9035": "다음 모델 파일이 없습니다: {0}.",
|
||||
"s9036": "'다운로드 및 불러오기'를 다시 누르면 가져옵니다. 이미 있는 파일은 그대로 둡니다. 같은 파일이 다시 표시되면 자세히 보기를 열어 도움을 요청할 때 함께 알려 주세요.",
|
||||
"s9037": "이 업데이트는 앱이 다시 시작되면서 중단되었습니다. 이후 업데이트가 완료되었으므로 할 일은 없습니다."
|
||||
}
|
||||
@@ -3346,5 +3346,185 @@
|
||||
"s8257": "a cada {0} min",
|
||||
"s8258": "Este motor do Strata não tem código para a placa de vídeo que tentou usar.",
|
||||
"s8259": "Escolha outra placa em «Executar em» ou abra os detalhes para ver quais placas foram encontradas e para quais o motor foi compilado.",
|
||||
"s8260": "Este botão não faz nada no app. Páginas web de qualquer site já podem chamar a API mostrada em API. Para permitir só alguns sites, ou para usar o servidor próprio do motor (porta 8080), adicione \"cors_origins\": [\"https://seu.site\"] ao arquivo de configuração do modelo."
|
||||
"s8260": "Este botão não faz nada no app. Páginas web de qualquer site já podem chamar a API mostrada em API. Para permitir só alguns sites, ou para usar o servidor próprio do motor (porta 8080), adicione \"cors_origins\": [\"https://seu.site\"] ao arquivo de configuração do modelo.",
|
||||
"s8301": "Criar uma chave de API (recomendado)",
|
||||
"s8302": "Sem uma chave de API, qualquer dispositivo desta rede pode usar o seu modelo.",
|
||||
"s8303": "Apenas a API de inferência é compartilhada. Os clientes precisam da sua chave de API quando houver uma. Use uma rede local confiável; esta conexão usa HTTP.",
|
||||
"s8304": "Crie uma chave para proteger a sua API. Você voltará aqui quando ela estiver pronta. Também pode continuar sem ela, mas qualquer pessoa que encontrar o link poderá usar o seu modelo.",
|
||||
"s8305": "Já tem um modelo compatível neste computador? Escolha a pasta com os arquivos ou um dos arquivos .gguf. O Strata verifica os arquivos, prepara o modelo e o carrega. Nada é copiado nem baixado.",
|
||||
"s8306": "Pasta com os arquivos GGUF ou um arquivo .gguf",
|
||||
"s8307": "Caminho completo da pasta ou do arquivo",
|
||||
"s8308": "Sem uma chave de API, qualquer pessoa que consiga acessar este endereço pode usar o seu modelo.",
|
||||
"s8309": "O Strata não conseguiu usar {0} para os modelos ({1}), então os modelos vão para {2}. Você pode escolher outra pasta em Configurações.",
|
||||
"s8310": "A porta {0} já está em uso. O Strata pode já estar em execução; feche-o ou o outro programa, ou inicie o Strata com outra porta (STRATA_PORT).",
|
||||
"s8311": "O Windows ou um firewall não deixa o Strata usar a porta {0}. Hyper-V, WSL e Docker reservam faixas de portas para si (veja \"netsh int ipv4 show excludedportrange protocol=tcp\"). Inicie o Strata com outra porta (STRATA_PORT), por exemplo 8801.",
|
||||
"s8312": "{0} não é um endereço deste computador, então o Strata não pode escutar nele. Verifique STRATA_BIND.",
|
||||
"s8313": "O Strata não consegue escutar em {0}:{1} ({2}).",
|
||||
"s8339": "Pasta de modelos",
|
||||
"s8340": "O Strata guarda aqui os arquivos grandes dos modelos.",
|
||||
"s8341": "Alterar pasta",
|
||||
"s8342": "Aguardando a pasta…",
|
||||
"s8343": "Uso do disco",
|
||||
"s8344": "{0} livres",
|
||||
"s8345": "nesta unidade",
|
||||
"s8346": "Seus modelos ocupam {0}",
|
||||
"s8347": "Conclua ou pare o download para alterar a pasta.",
|
||||
"s8348": "Essa pasta não existe",
|
||||
"s8349": "Não é possível usar essa pasta",
|
||||
"s8350": "O Strata não consegue salvar arquivos em {0}.",
|
||||
"s8351": "O Strata não encontra {0}.",
|
||||
"s8352": "Escolha uma pasta fora da atual.",
|
||||
"s8353": "Escolha uma pasta sua, por exemplo em Documentos ou em outra unidade.",
|
||||
"s8354": "Verifique se a unidade está conectada e escolha a pasta de novo. Seus modelos não foram alterados.",
|
||||
"s8355": "Usar esta pasta para seus modelos?",
|
||||
"s8356": "Pasta nova",
|
||||
"s8357": "{0} livres nesta unidade",
|
||||
"s8358": "Você escolheu a unidade inteira {0}. O Strata guarda seus arquivos em uma pasta própria, então seus modelos ficam em {1}.",
|
||||
"s8359": "Em seguida, você verá as três opções.",
|
||||
"s8360": "Mover meus modelos",
|
||||
"s8361": "Usar as duas pastas",
|
||||
"s8362": "Começar vazio",
|
||||
"s8363": "Movendo seus modelos",
|
||||
"s8364": "Parar a movimentação",
|
||||
"s8365": "{0} de {1} GB",
|
||||
"s8366": "Menos de um minuto restante",
|
||||
"s8367": "Cerca de um minuto restante",
|
||||
"s8368": "Cerca de {0} minutos restantes",
|
||||
"s8369": "Cerca de {0} horas restantes",
|
||||
"s8370": "Mantenha o Strata aberto. Você pode continuar usando outros aplicativos. Seus modelos só poderão ser carregados quando a movimentação terminar.",
|
||||
"s8371": "Copiando agora: {0}, arquivo {1} de {2}",
|
||||
"s8372": "De {0} para {1}",
|
||||
"s8373": "Mover meus modelos para lá",
|
||||
"s8374": "Você não tem modelos para mover.",
|
||||
"s8375": "O Strata move seus {0} de modelos para a pasta nova. É a mesma unidade, então leva só um instante.",
|
||||
"s8376": "O Strata copia seus {0} de modelos para a pasta nova, confere os arquivos e depois remove as cópias antigas.",
|
||||
"s8377": "Não precisa de espaço extra.",
|
||||
"s8378": "Precisa de {0} livres na pasta nova. Você tem {1}.",
|
||||
"s8379": "Mantê-los onde estão e usar também a pasta nova",
|
||||
"s8380": "Nada é copiado. Seus modelos atuais continuam funcionando e os novos downloads vão para a pasta nova.",
|
||||
"s8381": "Não precisa de espaço agora. Os novos downloads precisam caber em {1}.",
|
||||
"s8382": "Não precisa de espaço agora.",
|
||||
"s8383": "O Strata passa a usar a pasta nova e não lista seus modelos atuais. Seus arquivos antigos não são apagados.",
|
||||
"s8384": "Não precisa de espaço agora. Cada modelo baixado depois precisa do próprio espaço.",
|
||||
"s8385": "Seus modelos precisam de {0}, e esta unidade tem {1} livres.",
|
||||
"s8386": "Libere cerca de {0} lá, escolha outra pasta ou use uma das outras duas opções.",
|
||||
"s8387": "O que deve acontecer com seus modelos",
|
||||
"s8388": "Um modelo em execução é parado antes; carregue-o de novo quando a movimentação terminar.",
|
||||
"s8389": "{0} seriam movidos.",
|
||||
"s8390": "O espaço livre é medido na unidade que contém {0}.",
|
||||
"s8391": "Pasta antiga: {0}",
|
||||
"s8392": "O Strata copia um arquivo por vez e confere o tamanho antes de remover o antigo.",
|
||||
"s8393": "Você pode parar a qualquer momento; os arquivos já movidos continuam utilizáveis.",
|
||||
"s8394": "A unidade ficou sem espaço",
|
||||
"s8395": "A movimentação parou",
|
||||
"s8396": "{0} ficou cheia após {1}, então a movimentação parou.",
|
||||
"s8397": "Não foi possível mover os modelos.",
|
||||
"s8398": "Nada foi perdido. Os modelos já movidos estão na pasta nova, o restante continua em {0} e o arquivo copiado pela metade foi removido. Libere espaço em {1} ou escolha outra pasta e tente de novo.",
|
||||
"s8399": "Nada foi perdido. Todos os seus modelos continuam em {0} e o arquivo copiado pela metade foi removido. Libere espaço em {1} ou escolha outra pasta e tente de novo.",
|
||||
"s8400": "Nada foi perdido. Os modelos que não foram movidos continuam em {0}. Escolha outra pasta para tentar de novo.",
|
||||
"s8401": "Movimentação interrompida",
|
||||
"s8402": "Nada foi perdido.",
|
||||
"s8403": "{0} está na pasta nova.",
|
||||
"s8404": "{0} ainda está na pasta antiga.",
|
||||
"s8405": "Ambos funcionam.",
|
||||
"s8406": "Os modelos das duas pastas aparecem normalmente. Você pode concluir a movimentação depois, em “Alterar pasta”.",
|
||||
"s8407": "Seus modelos foram movidos",
|
||||
"s8408": "{0} agora estão em {1}",
|
||||
"s8409": "As cópias antigas foram removidas e {0} voltaram a ficar livres em {1}.",
|
||||
"s8410": "O Strata agora também usa {0}. Os novos downloads vão para lá, e seus modelos atuais ficam onde estão.",
|
||||
"s8411": "O Strata agora usa {0}. Seus arquivos de modelo antigos continuam em {1}; o Strata não os apaga.",
|
||||
"s8412": "Alterar a pasta de modelos faz parte do aplicativo Strata.",
|
||||
"s8413": "Há um modelo em execução. Pare-o primeiro e depois altere a pasta.",
|
||||
"s8414": "Escolha uma pasta de modelos.",
|
||||
"s8415": "Escolha o que fazer com seus modelos.",
|
||||
"s8416": "Essa opção não é possível aqui.",
|
||||
"s8417": "Uma mudança foi interrompida. Termine-a ou deixe como estava.",
|
||||
"s8418": "O Strata estava mudando seus modelos de {0} para {1} quando parou. Nada foi perdido.",
|
||||
"s8419": "Terminar a mudança",
|
||||
"s8420": "Deixar como está",
|
||||
"s8421": "Terminar coloca todos os seus modelos em {1}. Deixar como está mantém os modelos em {0} e remove as cópias parciais de {1}.",
|
||||
"s8422": "As cópias parciais foram removidas. Seus modelos estão em {0}.",
|
||||
"s8601": "Placas de vídeo",
|
||||
"s8602": "Principal",
|
||||
"s8603": "Auxiliar",
|
||||
"s8604": "Sua escolha.",
|
||||
"s8605": "Recomendado.",
|
||||
"s8606": "Você mudou as funções. O Strata usará exatamente estas.",
|
||||
"s8607": "Recomendado: {0}",
|
||||
"s8608": "Função de {0}",
|
||||
"s8609": "{0} GB de memória",
|
||||
"s8610": "Usa a memória do seu computador (gráficos integrados)",
|
||||
"s8611": "Faz o trabalho principal",
|
||||
"s8612": "Guarda parte do modelo",
|
||||
"s8613": "Não usada",
|
||||
"s8614": "Em uso por {0}",
|
||||
"s8615": "Desligar",
|
||||
"s8616": "Tornar {0} a principal",
|
||||
"s8617": "{0} não consegue executar o Strata.",
|
||||
"s8618": "{0} é um gráfico integrado; como principal é lenta.",
|
||||
"s8619": "O desempenho fica reduzido usando apenas a CPU.",
|
||||
"s8620": "Este motor precisa de uma placa de vídeo para iniciar. Ative uma.",
|
||||
"s8621": "Memória insuficiente para esta configuração: cerca de {0} GB são lidos do disco.",
|
||||
"s8622": "{0} agora é auxiliar.",
|
||||
"s8623": "{0} agora é a principal.",
|
||||
"s8624": "A única placa ligada tem de ser a principal.",
|
||||
"s8625": "Nenhuma placa de vídeo que o Strata possa usar foi encontrada.",
|
||||
"s8626": "O Strata usará a sua {0}.",
|
||||
"s8627": "{0} é a principal: {1}.",
|
||||
"s8628": "as placas são iguais",
|
||||
"s8629": "tem mais memória",
|
||||
"s8630": "é mais rápida",
|
||||
"s8631": "é a única placa",
|
||||
"s8632": "{0} ajudam.",
|
||||
"s8633": "{0} ajuda.",
|
||||
"s8634": "{0} começa desligada: {1}",
|
||||
"s8635": "O Strata não consegue executá-la.",
|
||||
"s8636": "gráficos integrados.",
|
||||
"s8637": "{0} GB ou menos.",
|
||||
"s8638": "A placa que faz os passos finais de cada resposta: escreve as próximas palavras, executa o passo de antecipação e o verifica, e guarda a sua conversa. Escolha a placa mais rápida com bastante memória. Há sempre só uma.",
|
||||
"s8639": "Guarda uma parte do modelo e da sua memória rápida, para que mais caiba nas placas de vídeo em vez de ser lido do disco. Os prompts são lidos cerca de 20% mais rápido.",
|
||||
"s8640": "O Strata não mexe nesta placa. Bom para uma placa que você usa para outra coisa, como um jogo ou a tela.",
|
||||
"s8641": "O modelo precisa de cerca de {0} GB. Estas placas e a sua memória dão cerca de {1} GB. Cabe.",
|
||||
"s8642": "O modelo precisa de cerca de {0} GB. Estas placas e a sua memória dão cerca de {1} GB, por isso cerca de {2} GB seriam lidos do disco.",
|
||||
"s8643": "Apenas o processador e a memória são usados.",
|
||||
"s8644": "Para especialistas: o motor inicia nas placas {0} (a principal por último).",
|
||||
"s8645": "Não é guardado enquanto mantiver a recomendação: o Strata escolhe de novo se adicionar ou remover uma placa.",
|
||||
"s8646": "Não usada: {0} (outra marca de placa de vídeo diferente da que este motor controla).",
|
||||
"s9001": "Esta mensagem tem cerca de {0} tokens e este modelo consegue ler cerca de {1} de cada vez. É grande demais para ser enviada assim.",
|
||||
"s9002": "Anexe-a como arquivo para o assistente ler por partes, ou envie uma prévia reduzida.",
|
||||
"s9003": "Esta conversa já não cabe na janela do modelo, e resumir as mensagens mais antigas não liberou espaço suficiente.",
|
||||
"s9004": "Resuma agora e continue, ou comece um novo chat.",
|
||||
"s9005": "Resumindo as mensagens anteriores…",
|
||||
"s9006": "As mensagens antigas foram resumidas para liberar espaço. O chat estava {0}% cheio e agora está {1}% cheio.",
|
||||
"s9007": "As mensagens antigas foram resumidas para liberar espaço. O chat estava {0}% cheio.",
|
||||
"s9008": "As mensagens antigas foram resumidas para liberar espaço.",
|
||||
"s9009": "Este chat está {0}% cheio, mas não há nada antigo para resumir: a mensagem mais recente é o que o enche. Comece um novo chat ou envie uma mensagem mais curta.",
|
||||
"s9010": "Ainda não há nada antigo para resumir. O assistente ainda precisa de tudo o que está neste chat.",
|
||||
"s9011": "Ainda não há nada para resumir. Este chat ainda não iniciou uma conversa com o assistente.",
|
||||
"s9012": "Não foi possível resumir as mensagens antigas agora. Tente novamente em instantes ou comece um novo chat.",
|
||||
"s9013": "Este chat ainda está trabalhando. Pare-o ou espere terminar e tente novamente.",
|
||||
"s9014": "Anexar como arquivo",
|
||||
"s9015": "Enviar uma prévia reduzida",
|
||||
"s9016": "Resumir e continuar",
|
||||
"s9017": "Começar um novo chat",
|
||||
"s9018": "Não foi possível preparar a mensagem. Tente uma mensagem mais curta.",
|
||||
"s9019": "A última sessão do Strata ainda está sendo encerrada.",
|
||||
"s9020": "Aguarde um momento e tente novamente. Se continuar acontecendo, reinicie o computador.",
|
||||
"s9021": "Previsão",
|
||||
"s9022": "Automático (recomendado)",
|
||||
"s9023": "MTP",
|
||||
"s9024": "DFlash2",
|
||||
"s9025": "Previsão: MTP (o DFlash2 não coube)",
|
||||
"s9026": "Previsão: MTP (o DFlash2 não está disponível neste modo)",
|
||||
"s9027": "Previsão: MTP (o DFlash2 é só para o Qwen3.8-Flash-Next)",
|
||||
"s9028": "Previsão: MTP (o preditor do DFlash2 ainda não foi baixado)",
|
||||
"s9029": "Escolher o DFlash2 também baixa o preditor dele: cerca de {0} GB, uma vez. Se não couber na memória gráfica, o modelo usa o MTP.",
|
||||
"s9030": "Baixando o preditor do DFlash2…",
|
||||
"s9031": "Preparando o preditor do DFlash2 para o motor…",
|
||||
"s9032": "O preditor do DFlash2 está pronto.",
|
||||
"s9033": "Não foi possível baixar o preditor do DFlash2, então o modelo inicia com o MTP. Carregando o modelo…",
|
||||
"s9034": "Carregando o modelo…",
|
||||
"s9035": "Estes arquivos do modelo estão faltando: {0}.",
|
||||
"s9036": "Pressione Baixar e carregar de novo para buscá-los. Os arquivos que já estão aí são mantidos. Se os mesmos arquivos aparecerem de novo, abra os Detalhes e compartilhe-os ao pedir ajuda.",
|
||||
"s9037": "Esta atualização foi interrompida quando o aplicativo reiniciou. Uma atualização posterior terminou, então não há nada a fazer."
|
||||
}
|
||||
@@ -3346,5 +3346,185 @@
|
||||
"s8257": "каждые {0} мин",
|
||||
"s8258": "В этом движке Strata нет кода для видеокарты, которую он попытался использовать.",
|
||||
"s8259": "Выберите другую карту в разделе «Запускать на» или откройте «Подробности»: там указано, какие карты найдены и для каких собран движок.",
|
||||
"s8260": "Этот переключатель в приложении ничего не делает. Веб-страницы любого сайта уже могут обращаться к API, который показан в «API». Чтобы разрешить только некоторые сайты или использовать собственный сервер движка (порт 8080), добавьте \"cors_origins\": [\"https://your.site\"] в файл настроек модели."
|
||||
"s8260": "Этот переключатель в приложении ничего не делает. Веб-страницы любого сайта уже могут обращаться к API, который показан в «API». Чтобы разрешить только некоторые сайты или использовать собственный сервер движка (порт 8080), добавьте \"cors_origins\": [\"https://your.site\"] в файл настроек модели.",
|
||||
"s8301": "Создать ключ API (рекомендуется)",
|
||||
"s8302": "Без ключа API любое устройство в этой сети сможет пользоваться вашей моделью.",
|
||||
"s8303": "Доступен только API инференса. Если ключ задан, клиентам нужен ваш ключ API. Используйте доверенную локальную сеть: это соединение работает по HTTP.",
|
||||
"s8304": "Создайте ключ, чтобы защитить API. Когда он будет готов, вы вернётесь сюда. Можно продолжить и без ключа, но тогда любой, кто найдёт ссылку, сможет пользоваться вашей моделью.",
|
||||
"s8305": "Уже есть поддерживаемая модель на этом компьютере? Выберите папку с её файлами или один из файлов .gguf. Strata проверит их, подготовит модель и загрузит её. Ничего не копируется и не скачивается.",
|
||||
"s8306": "Папка с файлами GGUF или один файл .gguf",
|
||||
"s8307": "Полный путь к папке или файлу",
|
||||
"s8308": "Без ключа API любой, кто может обратиться к этому адресу, сможет пользоваться вашей моделью.",
|
||||
"s8309": "Strata не смогла использовать {0} для моделей ({1}), поэтому модели сохраняются в {2}. Другую папку можно выбрать в настройках.",
|
||||
"s8310": "Порт {0} уже занят. Возможно, Strata уже запущена; закройте её или другую программу либо запустите Strata с другим портом (STRATA_PORT).",
|
||||
"s8311": "Windows или брандмауэр не разрешает Strata использовать порт {0}. Hyper-V, WSL и Docker резервируют для себя диапазоны портов (см. \"netsh int ipv4 show excludedportrange protocol=tcp\"). Запустите Strata с другим портом (STRATA_PORT), например 8801.",
|
||||
"s8312": "{0} не является адресом этого компьютера, поэтому Strata не может его прослушивать. Проверьте STRATA_BIND.",
|
||||
"s8313": "Strata не может прослушивать {0}:{1} ({2}).",
|
||||
"s8339": "Папка моделей",
|
||||
"s8340": "Strata хранит здесь большие файлы моделей.",
|
||||
"s8341": "Сменить папку",
|
||||
"s8342": "Ждём выбор папки…",
|
||||
"s8343": "Занято на диске",
|
||||
"s8344": "свободно {0}",
|
||||
"s8345": "на этом диске",
|
||||
"s8346": "Ваши модели занимают {0}",
|
||||
"s8347": "Чтобы сменить папку, дождитесь конца загрузки или остановите её.",
|
||||
"s8348": "Такой папки нет",
|
||||
"s8349": "Эту папку использовать нельзя",
|
||||
"s8350": "Strata не может сохранять файлы в {0}.",
|
||||
"s8351": "Strata не находит {0}.",
|
||||
"s8352": "Выберите папку вне текущей.",
|
||||
"s8353": "Выберите свою папку, например в «Документах» или на другом диске.",
|
||||
"s8354": "Проверьте, что диск подключён, и выберите папку снова. Ваши модели не затронуты.",
|
||||
"s8355": "Использовать эту папку для моделей?",
|
||||
"s8356": "Новая папка",
|
||||
"s8357": "Свободно на этом диске: {0}",
|
||||
"s8358": "Вы выбрали весь диск {0}. Strata хранит свои файлы в отдельной папке, поэтому модели будут в {1}.",
|
||||
"s8359": "Дальше будут три варианта.",
|
||||
"s8360": "Переместить мои модели",
|
||||
"s8361": "Использовать обе папки",
|
||||
"s8362": "Начать с пустой",
|
||||
"s8363": "Перемещение моделей",
|
||||
"s8364": "Остановить перемещение",
|
||||
"s8365": "{0} из {1} ГБ",
|
||||
"s8366": "Осталось меньше минуты",
|
||||
"s8367": "Осталось около минуты",
|
||||
"s8368": "Осталось около {0} мин.",
|
||||
"s8369": "Осталось около {0} ч.",
|
||||
"s8370": "Не закрывайте Strata. Другими приложениями можно пользоваться. Пока перемещение не закончится, модели загрузить нельзя.",
|
||||
"s8371": "Сейчас копируется: {0}, файл {1} из {2}",
|
||||
"s8372": "Из {0} в {1}",
|
||||
"s8373": "Переместить мои модели туда",
|
||||
"s8374": "Нет моделей для перемещения.",
|
||||
"s8375": "Strata переместит ваши модели ({0}) в новую папку. Это тот же диск, поэтому это займёт мгновение.",
|
||||
"s8376": "Strata скопирует ваши модели ({0}) в новую папку, проверит их и удалит старые копии.",
|
||||
"s8377": "Дополнительное место не нужно.",
|
||||
"s8378": "В новой папке нужно {0} свободного места. Сейчас доступно {1}.",
|
||||
"s8379": "Оставить на месте и дополнительно использовать новую папку",
|
||||
"s8380": "Ничего не копируется. Текущие модели продолжают работать, а новые загрузки идут в новую папку.",
|
||||
"s8381": "Сейчас место не нужно. Новые загрузки должны поместиться в {1}.",
|
||||
"s8382": "Сейчас место не нужно.",
|
||||
"s8383": "Отныне Strata использует новую папку и не показывает текущие модели. Старые файлы не удаляются.",
|
||||
"s8384": "Сейчас место не нужно. Каждой модели, которую вы загрузите позже, понадобится своё место.",
|
||||
"s8385": "Вашим моделям нужно {0}, а на этом диске свободно {1}.",
|
||||
"s8386": "Освободите там около {0}, выберите другую папку или один из двух других вариантов.",
|
||||
"s8387": "Что сделать с вашими моделями",
|
||||
"s8388": "Работающая модель сначала останавливается; загрузите её снова после перемещения.",
|
||||
"s8389": "Будет перемещено: {0}.",
|
||||
"s8390": "Свободное место измеряется на диске, где находится {0}.",
|
||||
"s8391": "Старая папка: {0}",
|
||||
"s8392": "Strata копирует файлы по одному и проверяет размер, прежде чем удалить старый.",
|
||||
"s8393": "Остановить можно в любой момент; уже перемещённые файлы остаются пригодными.",
|
||||
"s8394": "На диске закончилось место",
|
||||
"s8395": "Перемещение остановлено",
|
||||
"s8396": "Диск {0} заполнился после {1}, поэтому перемещение остановлено.",
|
||||
"s8397": "Не удалось переместить модели.",
|
||||
"s8398": "Ничего не потеряно. Уже перемещённые модели в новой папке, остальные по-прежнему в {0}, а наполовину скопированный файл удалён. Освободите место на {1} или выберите другую папку и повторите попытку.",
|
||||
"s8399": "Ничего не потеряно. Все ваши модели по-прежнему в {0}, а наполовину скопированный файл удалён. Освободите место на {1} или выберите другую папку и повторите попытку.",
|
||||
"s8400": "Ничего не потеряно. Неперемещённые модели по-прежнему в {0}. Выберите другую папку и повторите попытку.",
|
||||
"s8401": "Перемещение остановлено",
|
||||
"s8402": "Ничего не потеряно.",
|
||||
"s8403": "{0}: в новой папке.",
|
||||
"s8404": "{0}: всё ещё в старой папке.",
|
||||
"s8405": "Работают оба.",
|
||||
"s8406": "Модели из обеих папок отображаются как обычно. Закончить перемещение можно позже через «Сменить папку».",
|
||||
"s8407": "Модели перемещены",
|
||||
"s8408": "{0} теперь находятся в {1}",
|
||||
"s8409": "Старые копии удалены, на {1} снова свободно {0}.",
|
||||
"s8410": "Теперь Strata использует и {0}. Новые загрузки идут туда, а текущие модели остаются на месте.",
|
||||
"s8411": "Теперь Strata использует {0}. Старые файлы моделей остаются в {1}; Strata их не удаляет.",
|
||||
"s8412": "Смена папки моделей доступна в приложении Strata.",
|
||||
"s8413": "Модель запущена. Сначала остановите её, затем смените папку.",
|
||||
"s8414": "Выберите папку моделей.",
|
||||
"s8415": "Выберите, что сделать с моделями.",
|
||||
"s8416": "Этот вариант здесь невозможен.",
|
||||
"s8417": "Перенос был прерван. Завершите его или оставьте как есть.",
|
||||
"s8418": "Strata переносил ваши модели из {0} в {1}, когда остановился. Ничего не потеряно.",
|
||||
"s8419": "Завершить перенос",
|
||||
"s8420": "Оставить как есть",
|
||||
"s8421": "Если завершить, все модели окажутся в {1}. Если оставить, модели останутся в {0}, а неполные копии в {1} будут удалены.",
|
||||
"s8422": "Неполные копии удалены. Ваши модели находятся в {0}.",
|
||||
"s8601": "Видеокарты",
|
||||
"s8602": "Основная",
|
||||
"s8603": "Помощник",
|
||||
"s8604": "Ваш выбор.",
|
||||
"s8605": "Рекомендуется.",
|
||||
"s8606": "Вы изменили роли. Strata будет использовать именно их.",
|
||||
"s8607": "Рекомендуется: {0}",
|
||||
"s8608": "Роль {0}",
|
||||
"s8609": "{0} ГБ памяти",
|
||||
"s8610": "Использует память компьютера (встроенная графика)",
|
||||
"s8611": "Выполняет основную работу",
|
||||
"s8612": "Хранит часть модели",
|
||||
"s8613": "Не используется",
|
||||
"s8614": "Используется: {0}",
|
||||
"s8615": "Выключить",
|
||||
"s8616": "Сделать {0} основной",
|
||||
"s8617": "{0} не может запускать Strata.",
|
||||
"s8618": "{0} — встроенная графика; в роли основной она медленная.",
|
||||
"s8619": "Только на процессоре производительность снижена.",
|
||||
"s8620": "Для запуска этому движку нужна видеокарта. Включите одну.",
|
||||
"s8621": "Для этой настройки не хватает памяти: около {0} ГБ читается с диска.",
|
||||
"s8622": "{0} теперь помощник.",
|
||||
"s8623": "{0} теперь основная.",
|
||||
"s8624": "Единственная включённая карта должна быть основной.",
|
||||
"s8625": "Не найдено видеокарт, которые может использовать Strata.",
|
||||
"s8626": "Strata будет использовать ваш {0}.",
|
||||
"s8627": "{0} — основная: {1}.",
|
||||
"s8628": "карты одинаковые",
|
||||
"s8629": "у неё больше памяти",
|
||||
"s8630": "она быстрее",
|
||||
"s8631": "это единственная карта",
|
||||
"s8632": "{0} помогают.",
|
||||
"s8633": "{0} помогает.",
|
||||
"s8634": "{0} по умолчанию выключена: {1}",
|
||||
"s8635": "Strata не может её запустить.",
|
||||
"s8636": "встроенная графика.",
|
||||
"s8637": "{0} ГБ или меньше.",
|
||||
"s8638": "Карта, выполняющая последние шаги каждого ответа: пишет следующие слова, запускает шаг опережающего черновика и проверяет его, а также хранит вашу беседу. Выберите самую быструю карту с большим объёмом памяти. Основная карта всегда одна.",
|
||||
"s8639": "Хранит часть модели и её быстрой памяти, поэтому больше помещается на видеокартах и меньше читается с диска. Запросы читаются примерно на 20 % быстрее.",
|
||||
"s8640": "Strata не трогает эту карту. Подходит для карты, занятой другим, например игрой или выводом на экран.",
|
||||
"s8641": "Модели нужно около {0} ГБ. Эти карты и ваша память дают около {1} ГБ. Помещается.",
|
||||
"s8642": "Модели нужно около {0} ГБ. Эти карты и ваша память дают около {1} ГБ, поэтому около {2} ГБ придётся читать с диска.",
|
||||
"s8643": "Используются только процессор и память.",
|
||||
"s8644": "Для опытных: движок запускается на картах {0} (основная — последняя).",
|
||||
"s8645": "Не сохраняется, пока вы оставляете рекомендацию: Strata выберет заново, если вы добавите или уберёте карту.",
|
||||
"s8646": "Не используется: {0} (другой производитель видеокарт, чем тот, с которым работает этот движок).",
|
||||
"s9001": "Это сообщение состоит примерно из {0} токенов, а эта модель за раз читает примерно {1}. Оно слишком большое, чтобы отправить его как есть.",
|
||||
"s9002": "Прикрепите его как файл, чтобы ассистент прочитал его по частям, или отправьте сокращённый предпросмотр.",
|
||||
"s9003": "Этот разговор больше не помещается в окно модели, а краткое изложение старых сообщений не освободило достаточно места.",
|
||||
"s9004": "Сжать его сейчас и продолжить или начать новый чат.",
|
||||
"s9005": "Кратко пересказываю предыдущие сообщения…",
|
||||
"s9006": "Старые сообщения были кратко пересказаны, чтобы освободить место. Чат был заполнен на {0}%, а теперь на {1}%.",
|
||||
"s9007": "Старые сообщения были кратко пересказаны, чтобы освободить место. Чат был заполнен на {0}%.",
|
||||
"s9008": "Старые сообщения были кратко пересказаны, чтобы освободить место.",
|
||||
"s9009": "Этот чат заполнен на {0}%, но пересказывать старые сообщения нечего: место занимает самое новое сообщение. Начните новый чат или отправьте сообщение покороче.",
|
||||
"s9010": "Пока нечего пересказывать. Всё, что есть в этом чате, ассистенту ещё нужно.",
|
||||
"s9011": "Пока нечего пересказывать. В этом чате ещё не начался разговор с ассистентом.",
|
||||
"s9012": "Старые сообщения сейчас не удалось кратко пересказать. Попробуйте ещё раз через минуту или начните новый чат.",
|
||||
"s9013": "Этот чат ещё работает. Остановите его или дождитесь окончания и попробуйте снова.",
|
||||
"s9014": "Прикрепить как файл",
|
||||
"s9015": "Отправить сокращённый предпросмотр",
|
||||
"s9016": "Сжать и продолжить",
|
||||
"s9017": "Начать новый чат",
|
||||
"s9018": "Не удалось подготовить сообщение. Попробуйте сообщение покороче.",
|
||||
"s9019": "Прошлый сеанс Strata ещё завершается.",
|
||||
"s9020": "Подождите немного и попробуйте снова. Если это повторяется, перезагрузите компьютер.",
|
||||
"s9021": "Предсказание",
|
||||
"s9022": "Авто (рекомендуется)",
|
||||
"s9023": "MTP",
|
||||
"s9024": "DFlash2",
|
||||
"s9025": "Предсказание: MTP (DFlash2 не поместился)",
|
||||
"s9026": "Предсказание: MTP (DFlash2 недоступен в этом режиме)",
|
||||
"s9027": "Предсказание: MTP (DFlash2 только для Qwen3.8-Flash-Next)",
|
||||
"s9028": "Предсказание: MTP (черновая модель DFlash2 ещё не скачана)",
|
||||
"s9029": "Если выбрать DFlash2, будет также скачана его черновая модель: около {0} ГБ, один раз. Если она не поместится в видеопамять, модель будет работать с MTP.",
|
||||
"s9030": "Скачивается черновая модель DFlash2…",
|
||||
"s9031": "Черновая модель DFlash2 готовится для движка…",
|
||||
"s9032": "Черновая модель DFlash2 готова.",
|
||||
"s9033": "Не удалось скачать черновую модель DFlash2, поэтому модель запускается с MTP. Загружается модель…",
|
||||
"s9034": "Загружается модель…",
|
||||
"s9035": "Не хватает этих файлов модели: {0}.",
|
||||
"s9036": "Нажмите «Скачать и загрузить» ещё раз, чтобы получить их. Уже имеющиеся файлы сохраняются. Если те же файлы снова появятся в списке, откройте «Подробности» и приложите их, когда будете просить о помощи.",
|
||||
"s9037": "Это обновление прервалось при перезапуске приложения. Более позднее обновление завершилось, так что делать ничего не нужно."
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"tl01": "Verwende einen einfachen Servernamen mit höchstens 32 Zeichen: Buchstaben, Zahlen, Bindestriche oder Unterstriche.",
|
||||
"tl02": "Diese Tool-Server wurden weggelassen, weil ihre Namen nicht erlaubt sind: {0}. Ein Name darf bis zu 32 Buchstaben, Zahlen, Bindestriche oder Unterstriche enthalten.",
|
||||
"tl03": "Diese Tool-Server wurden weggelassen, weil sie kein Programm und keine Webadresse haben: {0}.",
|
||||
"tl04": "Diese Tool-Server wurden weggelassen, weil ein anderer fast den gleichen Namen hat: {0}."
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"tl01": "Use a simple server name of up to 32 characters: letters, numbers, hyphens or underscores.",
|
||||
"tl02": "These tool servers were left out because their names are not allowed: {0}. A name can have up to 32 letters, numbers, hyphens or underscores.",
|
||||
"tl03": "These tool servers were left out because they have no program or web address: {0}.",
|
||||
"tl04": "These tool servers were left out because another one has almost the same name: {0}."
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"tl01": "Usa un nombre de servidor sencillo de hasta 32 caracteres: letras, números, guiones o guiones bajos.",
|
||||
"tl02": "Estos servidores de herramientas se han omitido porque sus nombres no están permitidos: {0}. Un nombre puede tener hasta 32 letras, números, guiones o guiones bajos.",
|
||||
"tl03": "Estos servidores de herramientas se han omitido porque no tienen programa ni dirección web: {0}.",
|
||||
"tl04": "Estos servidores de herramientas se han omitido porque otro tiene casi el mismo nombre: {0}."
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"tl01": "サーバー名は32文字以内で、英字、数字、ハイフン、アンダースコアを使ってください。",
|
||||
"tl02": "名前が使えないため、次のツールサーバーは除外されました: {0}。名前には、英字、数字、ハイフン、アンダースコアを32文字まで使えます。",
|
||||
"tl03": "プログラムまたはWebアドレスがないため、次のツールサーバーは除外されました: {0}。",
|
||||
"tl04": "ほぼ同じ名前の別のサーバーがあるため、次のツールサーバーは除外されました: {0}。"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"tl01": "32자 이내의 간단한 서버 이름을 사용하세요. 영문자, 숫자, 하이픈, 밑줄을 쓸 수 있습니다.",
|
||||
"tl02": "이름을 사용할 수 없어 다음 도구 서버를 제외했습니다: {0}. 이름은 영문자, 숫자, 하이픈, 밑줄로 최대 32자까지 지을 수 있습니다.",
|
||||
"tl03": "프로그램이나 웹 주소가 없어 다음 도구 서버를 제외했습니다: {0}.",
|
||||
"tl04": "이름이 거의 같은 다른 서버가 있어 다음 도구 서버를 제외했습니다: {0}."
|
||||
}
|
||||
Loaded 100 of 685 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user