mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-07 14:21:06 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffa80d7313 | ||
|
|
974074c549 | ||
|
|
3482b3dff8 | ||
|
|
4045621eba | ||
|
|
904b2a723f | ||
|
|
b1eee01074 | ||
|
|
f457ed7b98 | ||
|
|
c5ab5d4003 | ||
|
|
0659c12582 | ||
|
|
e6c0cd41d3 | ||
|
|
434c6221b6 | ||
|
|
006f0ff258 | ||
|
|
45ae55c038 | ||
|
|
82a028f96e | ||
|
|
607d015590 | ||
|
|
e483f7835c | ||
|
|
63422c2443 | ||
|
|
742863adf3 | ||
|
|
0bab1534be | ||
|
|
45c71cf9c3 | ||
|
|
fca8b48c00 | ||
|
|
2e00d75f99 | ||
|
|
fbc5c8e1d9 | ||
|
|
200f639432 | ||
|
|
1f7640a8e5 | ||
|
|
379e893f84 | ||
|
|
6174eed4ac | ||
|
|
6456e3f242 | ||
|
|
770a9b8dc8 | ||
|
|
6ca50487ef | ||
|
|
079d7a6244 | ||
|
|
541eb00c91 | ||
|
|
43bc45f893 | ||
|
|
cd35829bb8 | ||
|
|
0e3b06f03f | ||
|
|
ed619c8341 | ||
|
|
d040f0b660 | ||
|
|
65d0b031d6 | ||
|
|
50af790125 | ||
|
|
89515548bd | ||
|
|
5514024c47 | ||
|
|
9bc2a65c0a | ||
|
|
b1043775d9 | ||
|
|
c75ab76550 | ||
|
|
95901fe8a4 | ||
|
|
e3563ff3dd | ||
|
|
d889208a0e | ||
|
|
8dd8f11a81 | ||
|
|
9ecdfa0df7 | ||
|
|
7ccfdb833c | ||
|
|
dc54f94ad9 | ||
|
|
c3c76f0ad0 | ||
|
|
076a195b79 | ||
|
|
06698c6746 | ||
|
|
7b57fbaacd | ||
|
|
09c76c323d | ||
|
|
9d03dba69b | ||
|
|
3096e0d0af | ||
|
|
9d1ee9b191 | ||
|
|
7bf446b528 | ||
|
|
df8c28e879 | ||
|
|
2e7db84bc0 | ||
|
|
a4b4e06fa7 | ||
|
|
f31abf7bd2 | ||
|
|
aa471de5d6 | ||
|
|
be7d719e0a | ||
|
|
72a0cc1d5e | ||
|
|
984f977d66 | ||
|
|
9666d1e760 | ||
|
|
da20fc301d | ||
|
|
b90ee03815 | ||
|
|
6527e33c7c | ||
|
|
ecf7424b53 | ||
|
|
21d53d9812 | ||
|
|
d78aa598c6 | ||
|
|
58169905bb | ||
|
|
4a6fd9b0e1 | ||
|
|
42a9c25aa5 | ||
|
|
1063d6fa48 | ||
|
|
6467ec76aa | ||
|
|
892cdbad7f | ||
|
|
9ed67ba318 | ||
|
|
7a6e77a625 | ||
|
|
418982072b | ||
|
|
e3ca02ec09 | ||
|
|
a0247cdee8 | ||
|
|
6dd725980e | ||
|
|
837909fdd1 | ||
|
|
f3a43da42a | ||
|
|
e4a68ec6fe | ||
|
|
fd818c7bd2 | ||
|
|
6cafbe2c74 | ||
|
|
e52ae14ab0 | ||
|
|
5f82379cf6 | ||
|
|
83cadd32fe | ||
|
|
950038a0a8 | ||
|
|
b0bbba5f8b | ||
|
|
202bb94ae6 | ||
|
|
3227bd2187 | ||
|
|
09859d0bfe | ||
|
|
8cb6e82dea | ||
|
|
4d27740022 | ||
|
|
a47e5634ee | ||
|
|
e4be9e50c1 | ||
|
|
523b78e215 | ||
|
|
2c1771dce2 | ||
|
|
db4b445ba1 | ||
|
|
3dbd3b0538 | ||
|
|
d4c8ca1f65 | ||
|
|
242549e432 | ||
|
|
2a1042877d | ||
|
|
371a28ed11 | ||
|
|
296e6780fa | ||
|
|
f93c5019f9 | ||
|
|
e074b8e530 | ||
|
|
a134650e27 | ||
|
|
71120303b1 | ||
|
|
ab7baa5b17 | ||
|
|
ebac5686f9 | ||
|
|
f32c3c4614 | ||
|
|
4043138e1e | ||
|
|
f4561cbe02 | ||
|
|
52e791e097 | ||
|
|
d55138e95a | ||
|
|
a2123a73a0 | ||
|
|
bbfa7c85cf | ||
|
|
b1d9ebb4c2 | ||
|
|
3342e06444 | ||
|
|
ea2aed12cd | ||
|
|
568127b7a0 | ||
|
|
c64702b724 | ||
|
|
33bbd39084 | ||
|
|
d0bc95ec5e | ||
|
|
6acd50efe4 | ||
|
|
abd9b4581d | ||
|
|
7d4fcefe98 | ||
|
|
15fb6ffe3f | ||
|
|
e8a6afd5f6 | ||
|
|
4a93c5428c | ||
|
|
c0380157df | ||
|
|
ac1965edea | ||
|
|
ec1b3b273b | ||
|
|
168325418a | ||
|
|
575caededc | ||
|
|
fe57a0cb52 | ||
|
|
df642fbf77 | ||
|
|
ee21447ead | ||
|
|
3dfb046bf1 | ||
|
|
b5abd7792f | ||
|
|
f7e24459b7 | ||
|
|
d86c6eeee0 | ||
|
|
a2f602398f | ||
|
|
f81f1326f4 | ||
|
|
4fb068a8c1 | ||
|
|
a1cc285fa1 | ||
|
|
0116b0049a | ||
|
|
299fb68eab | ||
|
|
71e26e68f6 | ||
|
|
8f9e705c67 | ||
|
|
8394297b26 | ||
|
|
b353ce7d4b | ||
|
|
3e7b8343b2 | ||
|
|
80e7c27eb7 | ||
|
|
96b29327ff | ||
|
|
79a5ad0429 | ||
|
|
903badd906 | ||
|
|
1ba2312176 | ||
|
|
db865e9b3d | ||
|
|
7da32daf97 | ||
|
|
b19ca19ac4 | ||
|
|
3f455f9a68 | ||
|
|
8ec2c8a918 | ||
|
|
aaa7a8e494 | ||
|
|
48d05d0735 | ||
|
|
d12b0508a5 | ||
|
|
dcad09339e | ||
|
|
3eb55adffd | ||
|
|
c14eb72d1c | ||
|
|
62e861c995 | ||
|
|
d0d31a4191 | ||
|
|
b1f4c92961 | ||
|
|
4adc047dc8 | ||
|
|
b509b2fef5 | ||
|
|
78316c8df4 | ||
|
|
d8867ef616 | ||
|
|
35d4781f1c | ||
|
|
53a48dbe5f | ||
|
|
96b009d2ef | ||
|
|
541a2fac2d | ||
|
|
e5de74282e | ||
|
|
067771d800 | ||
|
|
2678c9732e | ||
|
|
56f93d8ee0 | ||
|
|
ef7668e596 | ||
|
|
1bdda21126 | ||
|
|
d292672844 | ||
|
|
c683fbf0ef | ||
|
|
6140ce1f3b | ||
|
|
b49e1ffd6b | ||
|
|
ee108afef5 | ||
|
|
b537586e6b | ||
|
|
fd59c9c39b | ||
|
|
4d4a59642b | ||
|
|
fc453c3d82 | ||
|
|
fd3839172c | ||
|
|
73a470dafc | ||
|
|
5123338339 | ||
|
|
f9e14bdf8e | ||
|
|
90cb0c0f5a | ||
|
|
09ec63b884 | ||
|
|
7b5d1a38da | ||
|
|
2d5a577037 | ||
|
|
4e7866f91b | ||
|
|
5a73435898 | ||
|
|
d59ab12fc5 | ||
|
|
d0dcf65435 | ||
|
|
29d55bd718 | ||
|
|
2f608aac30 | ||
|
|
1b25b55444 | ||
|
|
28db88e56f | ||
|
|
e8abcadeef | ||
|
|
a9df88afc9 | ||
|
|
e04ee998f2 | ||
|
|
ac23de4c4d | ||
|
|
72cf167e3b | ||
|
|
d826b02d5b | ||
|
|
b1de678f33 | ||
|
|
96f5227763 | ||
|
|
5074caee06 | ||
|
|
6de7c8e315 | ||
|
|
1acdf93401 | ||
|
|
f3face9402 | ||
|
|
ee455af7fe | ||
|
|
f47df12624 | ||
|
|
17c6c4cd80 | ||
|
|
77b4bc42dc | ||
|
|
cc2dc69184 | ||
|
|
07fb0e7113 | ||
|
|
9a35255008 | ||
|
|
e47ad761fd | ||
|
|
79dc463760 | ||
|
|
86cbd533a4 | ||
|
|
3f3b6895e1 | ||
|
|
13edeb7f89 | ||
|
|
3d08a095ff | ||
|
|
43543e9b07 | ||
|
|
058f65322b | ||
|
|
84f16f7a27 | ||
|
|
a6fa0fc869 | ||
|
|
46f84d4646 | ||
|
|
c2b9218c76 | ||
|
|
2fb096b938 | ||
|
|
2f13eb5224 | ||
|
|
cd9c7e7b57 | ||
|
|
96f0307aa0 | ||
|
|
7e25613634 | ||
|
|
f70368b996 | ||
|
|
415b43f95e | ||
|
|
285fa48b94 | ||
|
|
5a258235c6 | ||
|
|
cf73cc4cb5 | ||
|
|
fea8a3e633 | ||
|
|
12c2d740c6 | ||
|
|
909ab78af8 | ||
|
|
857d0e3fe9 | ||
|
|
ea2b0d9c63 | ||
|
|
857e516621 | ||
|
|
a6fd5cfe42 | ||
|
|
dc9b2a407c | ||
|
|
9d642b9eb9 | ||
|
|
1c2aba90fa | ||
|
|
962816b409 | ||
|
|
5a048c6c09 | ||
|
|
626f9ef009 | ||
|
|
681e509516 | ||
|
|
7dea06f75d | ||
|
|
0c87b96f8c | ||
|
|
5eb33fe4f3 | ||
|
|
166dcf0b11 | ||
|
|
bb2649ee8b | ||
|
|
1ada0f6202 | ||
|
|
09c407d8f4 | ||
|
|
8bccfe281e | ||
|
|
d4ab9edc3f | ||
|
|
34c0ab47b2 | ||
|
|
2ad5867efb | ||
|
|
0941f1fe83 | ||
|
|
d6a5a7837e | ||
|
|
37e7b738f5 | ||
|
|
1d36e65a14 | ||
|
|
5bb21e170a | ||
|
|
488b67a1a9 | ||
|
|
29e8a47ac9 | ||
|
|
9f0794e993 | ||
|
|
21df2aa30d | ||
|
|
11e02ea876 | ||
|
|
4938fcc9f0 | ||
|
|
05e276aa8c | ||
|
|
505dd93ed6 | ||
|
|
9e10513ad2 | ||
|
|
f74bceeb6a | ||
|
|
43c40ecb19 | ||
|
|
fcba411c41 | ||
|
|
ff913c6bf0 | ||
|
|
28f94d6890 | ||
|
|
9aea047eea | ||
|
|
333e113d53 | ||
|
|
b8668d0385 | ||
|
|
c021f48d76 | ||
|
|
93970e0a27 | ||
|
|
16f513ad78 | ||
|
|
93e43af9e1 | ||
|
|
adf7e54127 | ||
|
|
4019121f24 | ||
|
|
f5e2e7a86e | ||
|
|
0c09456693 | ||
|
|
8dbdcf1b20 | ||
|
|
85dd085241 | ||
|
|
8752fc37ed | ||
|
|
909aca3806 | ||
|
|
f88fd15e8b | ||
|
|
d7d2ffab97 | ||
|
|
68baeabc54 | ||
|
|
601906b4de | ||
|
|
d42341d797 | ||
|
|
de19caff76 | ||
|
|
6dbf88c784 | ||
|
|
d97d130902 | ||
|
|
31f0e8647c | ||
|
|
32c37513e7 | ||
|
|
d29a6640e4 | ||
|
|
776fb9dbf5 | ||
|
|
944f06edba | ||
|
|
bd7e44c90d | ||
|
|
3165c2e8a5 | ||
|
|
8550019663 | ||
|
|
5ab569aa47 | ||
|
|
b541d7ad66 | ||
|
|
0038dbfd44 | ||
|
|
18aacf5dd0 | ||
|
|
5d4183edfa | ||
|
|
01dd40aa8f | ||
|
|
20c617a8ca | ||
|
|
c738d5bf34 | ||
|
|
fbaff162bc | ||
|
|
8d6351f55f | ||
|
|
04a007771c | ||
|
|
e22a48d866 | ||
|
|
e60e03762e | ||
|
|
53f85fa815 | ||
|
|
943f29853a | ||
|
|
0627b1ec63 | ||
|
|
eb87544a58 | ||
|
|
2b9c2980e2 | ||
|
|
b2365cecf2 | ||
|
|
811267e19f | ||
|
|
27a3e056ab | ||
|
|
f7ada7119a | ||
|
|
3a3fd4391b | ||
|
|
bef28201ff | ||
|
|
95597c2dae | ||
|
|
e394c48572 | ||
|
|
a84eebb513 | ||
|
|
5f24b96dbd | ||
|
|
4deb89ef72 | ||
|
|
a000bec3ce | ||
|
|
0ffb1d567c | ||
|
|
c70e049954 | ||
|
|
052c092856 | ||
|
|
ddd88c9f85 | ||
|
|
e5354698f9 | ||
|
|
0e8e4d4c61 | ||
|
|
f19d364637 | ||
|
|
f8f4da07df | ||
|
|
beb98760be | ||
|
|
26dc2a81c4 | ||
|
|
4ab81c7968 | ||
|
|
692b2573ea | ||
|
|
686c0d42c7 | ||
|
|
611ff41d41 | ||
|
|
a29977bd2a | ||
|
|
e4d2bccd77 | ||
|
|
1ebae96167 | ||
|
|
067b743385 | ||
|
|
e6ef7eed63 | ||
|
|
d615a9d643 | ||
|
|
3b1d9ae360 | ||
|
|
687527d7a3 | ||
|
|
ea2e791d8b | ||
|
|
dc96ad7033 | ||
|
|
89aa30476f | ||
|
|
88a79a7efd | ||
|
|
0f7159ca35 | ||
|
|
4faa0cf0ee | ||
|
|
d2c11867c0 | ||
|
|
386d2e4416 | ||
|
|
142d5c6195 | ||
|
|
c25a2de200 | ||
|
|
211ca210fb | ||
|
|
b63a82e011 | ||
|
|
e47848a319 | ||
|
|
0a8499b0a3 | ||
|
|
a44bcd4eb5 | ||
|
|
a8e07c1b52 | ||
|
|
6f686536e5 | ||
|
|
f009913c6d | ||
|
|
b3dbb2c67c | ||
|
|
63ae1eb3cd | ||
|
|
87610346d6 | ||
|
|
7f36ac9b2e | ||
|
|
ea6fcea1d8 | ||
|
|
63a9b7d2ee | ||
|
|
13658e773a | ||
|
|
243fdb213c | ||
|
|
58aeca07da | ||
|
|
d92b6350ed | ||
|
|
75d7427789 | ||
|
|
098154961d | ||
|
|
295953de95 | ||
|
|
e6bb877df7 | ||
|
|
5dea0d2abd | ||
|
|
b5c237678a | ||
|
|
334e11b794 | ||
|
|
ab583b6ad5 | ||
|
|
7d4feb2e8a | ||
|
|
02996cf733 | ||
|
|
54ef8615bb | ||
|
|
da8c7f4a4f | ||
|
|
ce3e621e34 | ||
|
|
fbdcd5cbea | ||
|
|
2ff9783ac6 | ||
|
|
682c21d641 | ||
|
|
8c6c0a8a9f | ||
|
|
424a2af019 | ||
|
|
7a87440629 | ||
|
|
2b32d0ff0b | ||
|
|
690f8b234a | ||
|
|
9df71d5980 | ||
|
|
d7e50a515b | ||
|
|
0d2ee8defe | ||
|
|
4bc7e35e41 | ||
|
|
af8b47d45f | ||
|
|
67eb5f1f57 | ||
|
|
34c0f7dae7 | ||
|
|
14c15d67a0 | ||
|
|
ab6dfa03d6 | ||
|
|
3410d5520d | ||
|
|
8e8e3cafad | ||
|
|
02c367d5ce | ||
|
|
cc703b5e48 | ||
|
|
daed630ed5 | ||
|
|
c24a6836aa | ||
|
|
6360274c8d | ||
|
|
de856c7b8c | ||
|
|
ea9a58d8a9 | ||
|
|
0e2f24b6ad | ||
|
|
34f90e919c | ||
|
|
9e75dc6cf5 | ||
|
|
af1cfe99cb | ||
|
|
e208427797 | ||
|
|
d10d67b005 | ||
|
|
394af4cd0f | ||
|
|
5e46c34159 | ||
|
|
f94a27da60 | ||
|
|
2970fe74a3 | ||
|
|
c31f1fa31b | ||
|
|
98d4f2033e | ||
|
|
899935faed | ||
|
|
10ce6ae508 | ||
|
|
b25c0c323d | ||
|
|
0b244c0346 | ||
|
|
2e8fb221cf | ||
|
|
a17b79a7e8 | ||
|
|
01f804ea18 | ||
|
|
9d642d1f71 | ||
|
|
3fe5edd315 | ||
|
|
87b2a61251 | ||
|
|
62db0bf10f | ||
|
|
5438023a6a | ||
|
|
2716defb83 | ||
|
|
1306424beb | ||
|
|
4c5a554912 | ||
|
|
d93547aed5 | ||
|
|
82eaeb4221 | ||
|
|
66545a497a | ||
|
|
64bf690800 | ||
|
|
55ab701e1b | ||
|
|
5206a299f8 | ||
|
|
b130965ca6 | ||
|
|
b3e73dbfb6 | ||
|
|
2b4d43bac2 | ||
|
|
161eb97fc6 | ||
|
|
eb8917247a | ||
|
|
4a7bb1d06d | ||
|
|
b9e3e2b86a | ||
|
|
8e7ed68181 | ||
|
|
8fffe81d4b | ||
|
|
3277bfcd71 | ||
|
|
7737bca3eb | ||
|
|
27de07ceff | ||
|
|
3304347bde | ||
|
|
9d58393dbd | ||
|
|
db1bfaaae1 | ||
|
|
606f6272dc | ||
|
|
f7bd10209f | ||
|
|
97b3d614fc | ||
|
|
c940106485 | ||
|
|
03627326dd | ||
|
|
3b3f3b6bd0 | ||
|
|
1f8ca8de1e | ||
|
|
15165e170b | ||
|
|
55ae7814d8 | ||
|
|
1db96cda5f | ||
|
|
3eb91676df | ||
|
|
7a498a05b4 | ||
|
|
3cc47f0297 | ||
|
|
fbf6e1c753 | ||
|
|
5a41f3b391 | ||
|
|
b3ff140d68 | ||
|
|
0922b91b64 | ||
|
|
1b1a28e77e | ||
|
|
5273fb667d | ||
|
|
d34c9a31ef | ||
|
|
8dcda377a0 | ||
|
|
d17c52e7d0 | ||
|
|
ce2098dcc9 | ||
|
|
dc1bc4c34d | ||
|
|
94edf723ca | ||
|
|
0dfa2d9b19 | ||
|
|
2473978007 | ||
|
|
6bd2565594 | ||
|
|
1ab4c2ce46 | ||
|
|
d2068bde56 | ||
|
|
3410a85881 | ||
|
|
de83080059 | ||
|
|
0a02ee4108 | ||
|
|
f6eed469e7 | ||
|
|
8b1cae731c | ||
|
|
3949737f1a | ||
|
|
14a85eca91 | ||
|
|
d4d0c30da5 | ||
|
|
e6d81d7a2a | ||
|
|
93b35ee84c | ||
|
|
cb49530dd6 | ||
|
|
c60833cc38 | ||
|
|
711bba9cc9 | ||
|
|
8422d864c8 | ||
|
|
beae52dfc5 | ||
|
|
0ceb8ba68e | ||
|
|
007fbd1ed2 | ||
|
|
0f31e635a3 | ||
|
|
8eb3b6a8c2 | ||
|
|
b35bbfc2ee | ||
|
|
eaae5a865c | ||
|
|
1b2825712d | ||
|
|
d4d7f164e9 | ||
|
|
76f12eb225 | ||
|
|
656fcfae13 | ||
|
|
566e1c7c10 | ||
|
|
7d94b1d5b9 | ||
|
|
9567238476 | ||
|
|
0f2778ae22 | ||
|
|
2d47b3e96b | ||
|
|
2c2893081b | ||
|
|
c0988d5531 | ||
|
|
eeb21d0e4f | ||
|
|
a9a2939fe9 | ||
|
|
5957d10f75 | ||
|
|
1405970281 | ||
|
|
2f4b422968 | ||
|
|
51f9e432b9 | ||
|
|
7d8c2a965b | ||
|
|
a75409dc57 | ||
|
|
656ddfd050 | ||
|
|
8218055ad3 | ||
|
|
cb7491f02d | ||
|
|
91411e17f8 | ||
|
|
cab56e7af8 | ||
|
|
4473c386a5 | ||
|
|
de0608b443 | ||
|
|
445baf12e4 | ||
|
|
3cb03ed32c | ||
|
|
2506046a60 | ||
|
|
9197e13a4b | ||
|
|
2784dfdcc4 | ||
|
|
04e9da601f | ||
|
|
d8801f628c | ||
|
|
09982fff66 | ||
|
|
93af8275dc | ||
|
|
009491d561 | ||
|
|
cc7857135b | ||
|
|
87ec8fd83e | ||
|
|
d6912d7744 | ||
|
|
31c3a16f4f | ||
|
|
094132ca77 | ||
|
|
8ce20293e5 | ||
|
|
c49eb5d194 | ||
|
|
de4d21e6f8 | ||
|
|
3ae0fbfad1 | ||
|
|
c4082ce8c4 | ||
|
|
40da04d49a | ||
|
|
6667ac43d4 | ||
|
|
081d1c04d2 | ||
|
|
36d94496fa | ||
|
|
06ebf2e4d6 | ||
|
|
12a1f2c545 | ||
|
|
08b20b104c | ||
|
|
381d70902d | ||
|
|
ed76b12f9d | ||
|
|
2d381fcf51 | ||
|
|
5c55046d72 | ||
|
|
97e5a6aeca | ||
|
|
adbe6b9b95 | ||
|
|
e1b27d54b0 | ||
|
|
3e2598a835 | ||
|
|
3ce5193eb4 | ||
|
|
9469a0c264 | ||
|
|
96a85f7fe8 | ||
|
|
21c14feb86 | ||
|
|
618cd7a4b6 | ||
|
|
9abe0d6c45 | ||
|
|
da1339d547 | ||
|
|
af6ba4c607 | ||
|
|
1b9a1b235a | ||
|
|
dbf6a6bb62 | ||
|
|
5a13c34f1b | ||
|
|
b8c69334bc | ||
|
|
d01b181751 | ||
|
|
b0b942fa9e | ||
|
|
b89bf5947d | ||
|
|
5c0259e20c | ||
|
|
c9efd49202 | ||
|
|
89b648a65e | ||
|
|
6d8fbf4194 | ||
|
|
c59f766a0a | ||
|
|
abd5c8cb8a | ||
|
|
788e372792 | ||
|
|
ecb2fc4502 | ||
|
|
f6e3042a61 | ||
|
|
679961662d | ||
|
|
44bca4099f | ||
|
|
35057071c1 | ||
|
|
6a8752f9bb | ||
|
|
9efac55f51 | ||
|
|
2fba107658 | ||
|
|
b19b38aff1 | ||
|
|
dde320ffaf | ||
|
|
b0fde18b16 | ||
|
|
caef1e879a | ||
|
|
4f0fafbc8f | ||
|
|
d88531297c | ||
|
|
17a572f3e3 | ||
|
|
e809e67ecf | ||
|
|
6a07e8ad72 | ||
|
|
5f19bf5b32 | ||
|
|
b2c3847fc0 | ||
|
|
9f86e95a3c | ||
|
|
e9d9ed8bb3 | ||
|
|
8becec7d5c | ||
|
|
b3b47b108b | ||
|
|
cda6463e8e | ||
|
|
169d5d6a9e | ||
|
|
ab0ed4feab | ||
|
|
ed3de732cc | ||
|
|
b3c3909147 | ||
|
|
35ca7ec773 | ||
|
|
69896982ae | ||
|
|
896818a3de | ||
|
|
86c94204a1 | ||
|
|
939e1acbae | ||
|
|
61b39f08b7 | ||
|
|
27060866eb | ||
|
|
3da4517962 | ||
|
|
a5112326a0 | ||
|
|
866681a889 | ||
|
|
45f0e48725 | ||
|
|
050741096b | ||
|
|
49b880c01f | ||
|
|
e9b4bf0055 | ||
|
|
499a68dba6 | ||
|
|
331ef6890d | ||
|
|
3ce04cd88d | ||
|
|
c85cfc7cc2 | ||
|
|
ca89975ec1 | ||
|
|
27f3137493 | ||
|
|
29c2d574f8 | ||
|
|
f080938676 | ||
|
|
8ea5361bfc | ||
|
|
908a4dee7b | ||
|
|
7d4465f75d | ||
|
|
2bfd207d33 | ||
|
|
45a38347a5 | ||
|
|
54eff48756 | ||
|
|
490b4b0fad | ||
|
|
0be5d73f00 | ||
|
|
c62d5e3a15 | ||
|
|
7b96b21cf9 | ||
|
|
7aa9a584aa | ||
|
|
987ebd442f | ||
|
|
c17b458c4f | ||
|
|
70f1f90d75 | ||
|
|
e4031fc4d0 | ||
|
|
dec8f1366c | ||
|
|
6f7f763ce5 | ||
|
|
f93ec8a924 | ||
|
|
9edd253c25 | ||
|
|
1daf05cb02 | ||
|
|
a280f5bbd0 | ||
|
|
ec723c27e8 | ||
|
|
5322f1c2bd | ||
|
|
c28b5141a0 | ||
|
|
216252f034 | ||
|
|
abe5e67923 | ||
|
|
3ec28f5cc0 | ||
|
|
e6d9221305 | ||
|
|
6e53c7f8d6 | ||
|
|
4cf0d8cd38 | ||
|
|
33bbbca442 | ||
|
|
38f56619cc | ||
|
|
1a26492064 | ||
|
|
bee469c462 | ||
|
|
6a7d750578 | ||
|
|
417765b8d8 | ||
|
|
5c25767fcd | ||
|
|
ea5dbffa7f | ||
|
|
a3a1103c13 | ||
|
|
ba3d28b7e3 | ||
|
|
0dba618d34 | ||
|
|
5ff7da4a70 | ||
|
|
1b6c67122a | ||
|
|
7851a8dab6 | ||
|
|
4148c51257 | ||
|
|
bd3c5cd4cb | ||
|
|
99866a47a2 | ||
|
|
73e0a3b6ed | ||
|
|
9144da7b1a | ||
|
|
a833fb46f3 | ||
|
|
32806f82aa | ||
|
|
bc694233bf | ||
|
|
dd3f9ec82f | ||
|
|
47cbb76690 | ||
|
|
ab083a693f | ||
|
|
ad4755c249 | ||
|
|
b60fbb54f7 | ||
|
|
8f13b8c4ad | ||
|
|
0b1808f072 | ||
|
|
cfa7f737cc | ||
|
|
b3de587e03 | ||
|
|
7f6bc33081 | ||
|
|
1ead709790 | ||
|
|
dd63f88fdd | ||
|
|
2a9ccfe3ea | ||
|
|
dd471d3140 | ||
|
|
4a75fa2b62 | ||
|
|
3282c61343 | ||
|
|
209dcadb3f | ||
|
|
e66a445892 | ||
|
|
68718c33fa | ||
|
|
ea52e55cd7 | ||
|
|
08e8770c6e | ||
|
|
7146d6983f | ||
|
|
01ab6b51bb | ||
|
|
957038a1cd | ||
|
|
d340dc8dd1 | ||
|
|
ffaa6ad78c | ||
|
|
4006305476 | ||
|
|
97e6f1fe19 | ||
|
|
33bd74182b | ||
|
|
4d12701181 | ||
|
|
aed382885e | ||
|
|
274257630b | ||
|
|
d96873fc56 | ||
|
|
86bed1c92f | ||
|
|
203df526f1 | ||
|
|
b6fc410ee5 | ||
|
|
0a76d62e68 | ||
|
|
c0e245c8be | ||
|
|
16e08b50cd | ||
|
|
1fb3e9a326 | ||
|
|
1f8f2fa8ad | ||
|
|
3a76d5eaf6 | ||
|
|
01ef876562 | ||
|
|
4746bfb165 | ||
|
|
0b1fbf4012 | ||
|
|
6923421a61 | ||
|
|
09dc5c52b3 | ||
|
|
5eaee81a43 | ||
|
|
d936db6547 | ||
|
|
ac3e0e17b5 | ||
|
|
b3bc477512 | ||
|
|
db729163ce | ||
|
|
19b5f873f2 | ||
|
|
0122eceb4c | ||
|
|
8401c21f9d | ||
|
|
9c2121cf5c | ||
|
|
1cfffa2ede | ||
|
|
a83252a7b7 | ||
|
|
d8056fbe00 | ||
|
|
6fb110e557 | ||
|
|
28c4543e48 | ||
|
|
e1e7e32680 | ||
|
|
94fc01c6fd | ||
|
|
b0ba459514 | ||
|
|
2fef430202 | ||
|
|
7a9245b6c0 | ||
|
|
368bcb7a26 | ||
|
|
5ad0135c00 | ||
|
|
cdb5470c70 | ||
|
|
87970c8ae0 | ||
|
|
777d11f8a3 | ||
|
|
dcc79c5230 | ||
|
|
db0606b4f8 | ||
|
|
4bfd66686e | ||
|
|
5c00af8a1f | ||
|
|
78859bbcb1 | ||
|
|
9b5965c3c1 | ||
|
|
836dd2d86e | ||
|
|
6d7255f205 | ||
|
|
97bbd76c92 | ||
|
|
36e798f4c5 | ||
|
|
1510e6d6d2 | ||
|
|
4772f0cd9a | ||
|
|
7aafac422c | ||
|
|
62b042e843 | ||
|
|
9d8337844e | ||
|
|
714438ec19 | ||
|
|
d3af2d42ad | ||
|
|
1a865fb3db | ||
|
|
a0e7f5160d | ||
|
|
dc149ef003 | ||
|
|
9f1d0ad041 | ||
|
|
1064ff7783 | ||
|
|
301a09f100 | ||
|
|
4f88a2fa8b | ||
|
|
1f05fdc283 | ||
|
|
a8fba1f5b7 | ||
|
|
2f0d6a1c15 | ||
|
|
42aee7a349 | ||
|
|
3782008944 | ||
|
|
05cd12e7a2 | ||
|
|
0c94851835 | ||
|
|
21f950b4b4 | ||
|
|
3ccfd5aed3 | ||
|
|
51567de1a6 | ||
|
|
f0a34fa9bf | ||
|
|
84aa2d7d57 | ||
|
|
1d9b4c48e9 | ||
|
|
2072510a47 | ||
|
|
0ea3e455fd | ||
|
|
a9f154e82b | ||
|
|
766b54afb1 | ||
|
|
faa1be268c | ||
|
|
47a1047750 | ||
|
|
ee05114846 | ||
|
|
d8c94b45c8 | ||
|
|
236adb3987 | ||
|
|
3484572236 | ||
|
|
e0cc4e859e | ||
|
|
cea3057e2c | ||
|
|
5e9995d296 | ||
|
|
095464152c | ||
|
|
2cf3fd181f | ||
|
|
06f212aefe | ||
|
|
4d9d9cefd3 | ||
|
|
f34ab1c8f5 | ||
|
|
280885fda0 | ||
|
|
ae2657a19d | ||
|
|
d4f15adf87 | ||
|
|
dbdec0bb9e | ||
|
|
d7cb41ba87 | ||
|
|
c4e975cc7a | ||
|
|
c15a90b28c | ||
|
|
b2dbd9420d | ||
|
|
c697e859e9 | ||
|
|
2ea46ded73 | ||
|
|
20bba22af7 | ||
|
|
1493781e91 |
No files matched your search
@@ -0,0 +1,68 @@
|
||||
name: AMD split engine report
|
||||
description: Tell us whether the all-cards split engine works on your AMD cards (it prints a line asking for this on cards Strata has not run it on)
|
||||
title: "[Engine] AMD split engine: <your card> x <count>"
|
||||
labels: ["engine", "amd-split-report"]
|
||||
body:
|
||||
- type: markdown
|
||||
value: |
|
||||
Strata runs the all-cards split engine by itself on Radeon AI PRO R9700 / RX 9070 (gfx1201). On other AMD cards (RX 9060, RX 7000, the 780M and similar) it only starts when you ask for it with `strata launch --split-engine on`, and it prints one line asking you to report whether it works. We cannot test those cards, so your report is how we learn.
|
||||
|
||||
Write only what you saw. A report that says "it did not start" is as useful as one that says "it works".
|
||||
- type: input
|
||||
id: card
|
||||
attributes:
|
||||
label: Card model and count
|
||||
description: For example "2 x RX 7900 XTX 24 GB" or "3 x RX 9060 XT 16 GB"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: arch
|
||||
attributes:
|
||||
label: Architecture (the gfx name)
|
||||
description: From the engine's line, or `rocminfo | grep gfx`. For example gfx1100.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: rocm
|
||||
attributes:
|
||||
label: ROCm / driver version
|
||||
description: Strata's engine package brings its own ROCm libraries; the kernel driver is yours (`uname -r`, `amd-smi version`).
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: model
|
||||
attributes:
|
||||
label: Model and quantization
|
||||
description: The model Strata loaded, for example Qwen3.8-Flash-Next UD-Q4_K_XL or the IQ3_S set.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: selftest
|
||||
attributes:
|
||||
label: The self-test line
|
||||
description: Paste the line from the engine's log that starts "The split engine's self-test" (passed or failed). The log is in the run folder shown at launch; `strata launch` also prints it.
|
||||
render: text
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: result
|
||||
attributes:
|
||||
label: What happened
|
||||
options:
|
||||
- It started and the answers look right
|
||||
- It started and the answers look wrong
|
||||
- The self-test failed, so the normal layer split started
|
||||
- It failed to start (not the self-test)
|
||||
- It started and crashed or hung later
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: speed
|
||||
attributes:
|
||||
label: Speed, tok/s (the split engine, then the layer split with `--split-engine off` if you tried both)
|
||||
description: For example "decode 61 tok/s, layer split 48 tok/s, one user".
|
||||
- type: textarea
|
||||
id: more
|
||||
attributes:
|
||||
label: Anything else
|
||||
description: Wrong answers (the prompt and what you got), crashes (the last lines of the engine log), or what you changed.
|
||||
@@ -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.8 · engine 0.1.54"
|
||||
placeholder: "Strata 0.2.10 · engine 0.1.56"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AGENTS.md
|
||||
|
||||
Version 0.2.8, engine 0.1.54. The public model families are DeepSeekV4.1-Flash-1017,
|
||||
Version 0.2.10, engine 0.1.56. The public model families are DeepSeekV4.1-Flash-1017,
|
||||
Qwen3.8-Flash-Next and Qwen3.6-35B-A3B. DeepSeek V4.1-Flash runs in the app and in the Terminal
|
||||
(`strata launch --family deepseek41 --model ist-das-2.00bpw`; sets in
|
||||
[docs/MODELS.md](docs/MODELS.md#deepseek-v41-flash)). Read **[docs/AI_REFERENCE.md](docs/AI_REFERENCE.md)** for model availability, APIs, hardware limits and
|
||||
@@ -15,7 +15,7 @@ Strata 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. One installer per system ([latest release](https://github.com/Niko1221/Strata/releases/latest)):
|
||||
`Strata-0.2.8-windows-x64-setup.exe`, `Strata-0.2.8-linux-x86_64.AppImage`, `Strata-0.2.8-macos-arm64.dmg`.
|
||||
`Strata-0.2.9-windows-x64-setup.exe`, `Strata-0.2.9-linux-x86_64.AppImage`, `Strata-0.2.9-macos-arm64.dmg`.
|
||||
The Windows and Linux installers carry no engine: the app downloads the engine for the graphics card on first
|
||||
start, or installs a file the user already has. The Mac dmg keeps its small Metal engine inside.
|
||||
- **The Terminal** (`strata.py`, `strata_cli/`, `strata_control/`): the `strata` command. It sets up a model, serves
|
||||
|
||||
+204
-20
@@ -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.54 LANGUAGES CXX)
|
||||
project(strata VERSION 0.1.56 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)
|
||||
@@ -92,6 +92,13 @@ if(STRATA_HIP_GFX906)
|
||||
endif()
|
||||
option(STRATA_ENABLE_HIP "Build the HIP targets for wave32 AMD GPUs (gfx1100, gfx1201; gfx1012/gfx1030 unvalidated)" OFF)
|
||||
option(STRATA_PREFILL_MMQ "Build the optional GGML MMQ prefill path for HIP" OFF)
|
||||
# The community-made AMD changes (include/strata/core/amd_community.hpp): on by default. This option is the build half of the one
|
||||
# switch STRATA_AMD_COMMUNITY_TUNING: OFF compiles the portable signed-byte dot for gfx1010 again (the SDWA sequence of #4855).
|
||||
# The rocBLAS tables are switched at run time (STRATA_AMD_COMMUNITY_TUNING=0).
|
||||
option(STRATA_AMD_COMMUNITY_TUNING "Compile the community-made gfx1010 SDWA dot product (the runtime switch of the same name controls the gfx1030 rocBLAS tables)" ON)
|
||||
if(NOT STRATA_AMD_COMMUNITY_TUNING)
|
||||
add_compile_definitions(STRATA_AMD_COMMUNITY_DOT_OFF=1)
|
||||
endif()
|
||||
# The rows one verify / batch window holds (include/strata/kernels/window_limits.hpp): 8, the 0.2.7 window, or 16 (four pipelined
|
||||
# groups of 16 slots = 64 slots on a four-card split, docs/BATCHING.md). Only the CUDA and HIP engines are built from
|
||||
# these sources with a wide window; the SYCL and Metal builds never read this variable and keep 8. A window of up to
|
||||
@@ -99,10 +106,10 @@ option(STRATA_PREFILL_MMQ "Build the optional GGML MMQ prefill path for HIP" OFF
|
||||
# the HIP engines (a HIP code object loads on first use, so the extra kernels cost nothing until a wide window forms)
|
||||
# and 8 for CUDA, which loads every kernel at start (CUDA_MODULE_LOADING=EAGER: the wide kernels would take VRAM from
|
||||
# the expert cache of every card); -DSTRATA_WINDOW_MAX_T=16 builds the wide CUDA engine.
|
||||
set(STRATA_WINDOW_MAX_T "auto" CACHE STRING "rows of a verify / batch window: 8, 16 or auto (HIP 16, CUDA 8)")
|
||||
set_property(CACHE STRATA_WINDOW_MAX_T PROPERTY STRINGS auto 8 16)
|
||||
if(NOT STRATA_WINDOW_MAX_T STREQUAL "auto" AND NOT STRATA_WINDOW_MAX_T STREQUAL "8" AND NOT STRATA_WINDOW_MAX_T STREQUAL "16")
|
||||
message(FATAL_ERROR "STRATA_WINDOW_MAX_T must be auto, 8 or 16 (got '${STRATA_WINDOW_MAX_T}')")
|
||||
set(STRATA_WINDOW_MAX_T "auto" CACHE STRING "rows of a verify / batch window: 8, 12, 16 or auto (HIP 16, CUDA 8)")
|
||||
set_property(CACHE STRATA_WINDOW_MAX_T PROPERTY STRINGS auto 8 12 16)
|
||||
if(NOT STRATA_WINDOW_MAX_T STREQUAL "auto" AND NOT STRATA_WINDOW_MAX_T STREQUAL "8" AND NOT STRATA_WINDOW_MAX_T STREQUAL "12" AND NOT STRATA_WINDOW_MAX_T STREQUAL "16")
|
||||
message(FATAL_ERROR "STRATA_WINDOW_MAX_T must be auto, 8, 12 or 16 (got '${STRATA_WINDOW_MAX_T}')")
|
||||
endif()
|
||||
if(STRATA_ENABLE_HIP OR STRATA_ENABLE_CUDA)
|
||||
if(STRATA_WINDOW_MAX_T STREQUAL "auto")
|
||||
@@ -391,10 +398,22 @@ if(STRATA_BUILD_TESTS)
|
||||
target_include_directories(share_rules_test PRIVATE include)
|
||||
add_test(NAME share_rules_test COMMAND share_rules_test)
|
||||
|
||||
# STRATA_PREFILL_ROUTED_STREAM: the prompt stream's issuer walk and route gate, on a simulated link and consumer
|
||||
# (header-only, no GPU)
|
||||
add_executable(routed_stream_test tests/prefill/routed_stream_test.cpp)
|
||||
target_include_directories(routed_stream_test PRIVATE include)
|
||||
target_link_libraries(routed_stream_test PRIVATE Threads::Threads)
|
||||
add_test(NAME routed_stream_test COMMAND routed_stream_test)
|
||||
|
||||
# the Windows HIP auto prefill chunk cap (#1630) and the commit-limit verdict (#1607): header-only, no GPU
|
||||
add_executable(host_limits_test tests/platform/host_limits_test.cpp)
|
||||
target_include_directories(host_limits_test PRIVATE include)
|
||||
add_test(NAME host_limits_test COMMAND host_limits_test)
|
||||
# #4733: an engine started at Low I/O priority goes back to Normal (Windows; else skips)
|
||||
add_executable(io_priority_test tests/platform/io_priority_test.cpp src/platform/io_priority.cpp)
|
||||
target_include_directories(io_priority_test PRIVATE include)
|
||||
add_test(NAME io_priority_test COMMAND io_priority_test)
|
||||
set_tests_properties(io_priority_test PROPERTIES SKIP_RETURN_CODE 77)
|
||||
endif()
|
||||
|
||||
# ---- strata_plan ----------------------------------------------------------
|
||||
@@ -410,7 +429,7 @@ target_link_libraries(strata-plan PRIVATE strata_plan)
|
||||
# ---- strata_core (GPU runtime) ---------------------------------------------
|
||||
# Only built when a GPU backend is on, so the Phase 1 targets keep working without a toolkit.
|
||||
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
|
||||
add_library(strata_core STATIC src/core/device.cu src/core/pinned.cu src/platform/memory.cpp src/platform/io_priority.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
|
||||
@@ -849,6 +868,12 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
|
||||
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_q4_parity EXCLUDE_FROM_ALL src/core/dflash2_q4_parity.cpp)
|
||||
target_include_directories(dflash2_q4_parity PRIVATE ${_strata_gpu_include_directories})
|
||||
target_link_libraries(dflash2_q4_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
|
||||
add_executable(dflash2_ring_test EXCLUDE_FROM_ALL src/core/dflash2_ring_test.cpp) # the drafter's ring log and rebuild against an uninterrupted update sequence
|
||||
target_include_directories(dflash2_ring_test PRIVATE ${_strata_gpu_include_directories})
|
||||
target_link_libraries(dflash2_ring_test 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})
|
||||
@@ -917,7 +942,7 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
|
||||
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)
|
||||
src/dsv41/ds4_tq.cu src/dsv41/ds4_tq_cpu.cpp src/dsv41/ds4_gg_cpu.cpp src/dsv41/ds4_packinfo.cpp src/platform/io_priority.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)
|
||||
@@ -952,7 +977,31 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
|
||||
${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)
|
||||
# the PCIe lane's cost model on this CPU (Project Maya #312's tests/ds4_lane_bench): no GPU, no model
|
||||
add_executable(ds4_lane_bench tests/dsv41/ds4_lane_bench.cpp src/dsv41/ds4_gguf_cpu.cpp src/dsv41/ds4_iq_cpu.cpp
|
||||
src/dsv41/ds4_tq_cpu.cpp src/dsv41/ds4_gg_cpu.cpp)
|
||||
target_include_directories(ds4_lane_bench PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/third_party/ggml)
|
||||
target_link_libraries(ds4_lane_bench PRIVATE strata_dsv41_kernels strata_kernels_cpu Threads::Threads)
|
||||
target_compile_definitions(ds4_lane_bench PRIVATE STRATA_DSV41_TQ_CPU=1)
|
||||
endif()
|
||||
# the GGUF kernels against a double-precision reference, the PCIe lane's streamed experts against the VRAM ones' bit for
|
||||
# bit (--lane), and the packed KV caches against the BF16 rows (needs a GPU, no model; built by name)
|
||||
add_executable(ds4_gguf_test EXCLUDE_FROM_ALL tests/dsv41/ds4_gguf_test.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_kernels.cu src/dsv41/ds4_tq.cu
|
||||
src/dsv41/ds4_tq_cpu.cpp src/dsv41/ds4_gg_cpu.cpp)
|
||||
target_include_directories(ds4_gguf_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${_strata_gpu_include_directories}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/third_party/ggml ${CMAKE_CURRENT_SOURCE_DIR}/src/prefill)
|
||||
target_link_libraries(ds4_gguf_test PRIVATE strata_prefill strata_dsv41_kernels ${_strata_gpu_runtime_target} Threads::Threads)
|
||||
if(STRATA_NATIVE_EXPERTS)
|
||||
target_link_libraries(ds4_gguf_test PRIVATE strata_kernels_cpu)
|
||||
target_compile_definitions(ds4_gguf_test PRIVATE STRATA_DSV41_TQ_CPU=1)
|
||||
endif()
|
||||
# the router on a non-finite gate input (valid, distinct picks; the same picks as the host's top-k when finite) and its
|
||||
# launch times; needs a GPU, no model; built by name
|
||||
add_executable(ds4_route_guard_test EXCLUDE_FROM_ALL tests/dsv41/ds4_route_guard_test.cu src/dsv41/ds4_kernels.cu)
|
||||
target_include_directories(ds4_route_guard_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${_strata_gpu_include_directories})
|
||||
target_link_libraries(ds4_route_guard_test PRIVATE strata_dsv41_kernels ${_strata_gpu_runtime_target} Threads::Threads)
|
||||
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
|
||||
@@ -995,13 +1044,44 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
|
||||
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 dense weights (ds4_iq.cu dequant_rows / mv / f16_rows, ds4_gguf.cu rows / mv, the chunk GEMM) against a CPU reference for every
|
||||
# dense type and shape, no ggml, no model: tests/dsv41/dq_dense_test.cpp (run_dq_test.sh runs it a process a type and kernel)
|
||||
set_source_files_properties(tests/dsv41/dq_dense_test.cpp PROPERTIES LANGUAGE ${_ds4_test_lang})
|
||||
add_executable(ds4_dq_test tests/dsv41/dq_dense_test.cpp src/dsv41/ds4_iq.cu src/dsv41/ds4_tq.cu src/dsv41/ds4_gguf.cu)
|
||||
target_include_directories(ds4_dq_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${_strata_gpu_include_directories}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/third_party/ggml ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41)
|
||||
target_link_libraries(ds4_dq_test PRIVATE strata_prefill ${_strata_gpu_runtime_target} ${_strata_gpu_blas_target} Threads::Threads)
|
||||
target_compile_definitions(ds4_dq_test PRIVATE DQ_TEST_GGUF=1 DQ_TEST_GEMM=1)
|
||||
if(STRATA_ENABLE_HIP)
|
||||
target_compile_options(ds4_dq_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)
|
||||
# restore points (ds4_marks.hpp, from Project Maya #639): which point a prompt goes back to, which ones a read keeps
|
||||
add_executable(ds4_marks_test tests/dsv41/marks_test.cpp)
|
||||
target_include_directories(ds4_marks_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41)
|
||||
target_compile_features(ds4_marks_test PRIVATE cxx_std_17)
|
||||
# the staging ring's slots by the measured host link (ds4_ring_link.hpp, from Project Maya #609)
|
||||
add_executable(ds4_ring_link_test tests/dsv41/ring_link_test.cpp)
|
||||
target_include_directories(ds4_ring_link_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41)
|
||||
target_compile_features(ds4_ring_link_test PRIVATE cxx_std_17)
|
||||
# the serve loop's event queue (ds4_evt.hpp, from Project Maya #587)
|
||||
add_executable(ds4_evt_test tests/dsv41/evt_test.cpp)
|
||||
target_include_directories(ds4_evt_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41)
|
||||
target_compile_features(ds4_evt_test PRIVATE cxx_std_17)
|
||||
find_package(Threads)
|
||||
if(Threads_FOUND)
|
||||
target_link_libraries(ds4_evt_test PRIVATE Threads::Threads)
|
||||
endif()
|
||||
add_executable(ds4_experts_rule_test tests/dsv41/experts_rule_test.cpp)
|
||||
target_include_directories(ds4_experts_rule_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41)
|
||||
target_compile_features(ds4_experts_rule_test PRIVATE cxx_std_17)
|
||||
# the timing pick's per-size-class flow correction (ds4_path_policy.hpp, from Project Maya #612)
|
||||
add_executable(ds4_path_policy_test tests/dsv41/path_policy_test.cpp)
|
||||
target_include_directories(ds4_path_policy_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41)
|
||||
target_compile_features(ds4_path_policy_test PRIVATE cxx_std_17)
|
||||
add_executable(ds4_pick_rule_test tests/dsv41/pick_rule_test.cpp)
|
||||
target_include_directories(ds4_pick_rule_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41)
|
||||
target_compile_features(ds4_pick_rule_test PRIVATE cxx_std_17)
|
||||
@@ -1028,6 +1108,20 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
|
||||
target_link_libraries(hip_q2_zero PRIVATE strata_kernels strata_hip_runtime)
|
||||
add_test(NAME hip_q2_zero COMMAND hip_q2_zero)
|
||||
list(APPEND _strata_hip_tests hip_q2_zero)
|
||||
# the QSA layer's fused norm + rope (rms_rope_rows, STRATA_QSA_FUSE_NR) against split_q + rms_rows + rope, bit for bit over the
|
||||
# whole output; and the gfx11 prompt GEMMs' full-output coverage (every element written, sampled rows against FP64).
|
||||
# GPU, synthetic, no model; exit 77 without a gfx11 device.
|
||||
add_executable(hip_qsa_nr_parity src/prefill/qsa_nr_parity.cu)
|
||||
target_link_libraries(hip_qsa_nr_parity PRIVATE strata_prefill strata_hip_runtime)
|
||||
add_test(NAME hip_qsa_nr_parity COMMAND hip_qsa_nr_parity)
|
||||
set_tests_properties(hip_qsa_nr_parity PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 300)
|
||||
list(APPEND _strata_hip_tests hip_qsa_nr_parity)
|
||||
add_executable(hip_pf_cover_test src/prefill/pf_cover_test.cu)
|
||||
target_include_directories(hip_pf_cover_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/prefill)
|
||||
target_link_libraries(hip_pf_cover_test PRIVATE strata_prefill strata_hip_runtime)
|
||||
add_test(NAME hip_pf_cover_test COMMAND hip_pf_cover_test)
|
||||
set_tests_properties(hip_pf_cover_test PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 900)
|
||||
list(APPEND _strata_hip_tests hip_pf_cover_test)
|
||||
add_executable(hip_expert_cache_staging tests/hip/expert_cache_staging.cpp)
|
||||
target_link_libraries(hip_expert_cache_staging PRIVATE strata_engine)
|
||||
add_test(NAME hip_expert_cache_staging COMMAND hip_expert_cache_staging)
|
||||
@@ -1076,6 +1170,10 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
|
||||
add_test(NAME hip_gdn_chunked_off COMMAND hip_gdn_chunked --off)
|
||||
add_test(NAME hip_gdn_chunked_default COMMAND hip_gdn_chunked --default)
|
||||
list(APPEND _strata_hip_tests hip_gdn_chunked hip_gdn_chunked_off hip_gdn_chunked_default)
|
||||
# GDN prompt stage bench (gates + conv + recurrence, per-piece times and output/state hashes at 4K/16K/32K); a measurement
|
||||
# tool, run by hand with the STRATA_GDN_* knobs under test - no add_test
|
||||
add_executable(hip_gdn_stage_bench tests/hip/gdn_stage_bench.cpp)
|
||||
target_link_libraries(hip_gdn_stage_bench PRIVATE strata_prefill strata_hip_runtime)
|
||||
# #313: the RDNA3 / RDNA3.5 WMMA dense GEMMs (opt-in STRATA_WMMA_GEMM=1) against a reference
|
||||
add_executable(hip_prefill_wmma_gemm_parity tests/hip/prefill_wmma_gemm_parity.cpp)
|
||||
target_include_directories(hip_prefill_wmma_gemm_parity PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/prefill")
|
||||
@@ -1098,6 +1196,9 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
|
||||
add_test(NAME hip_router_fast COMMAND hip_router_fast)
|
||||
list(APPEND _strata_hip_tests hip_router_fast)
|
||||
# S6: the RDNA4 matrix-core prompt attention (STRATA_HIP_WMMA) against the FP32 kernel and FP64; skips off gfx12
|
||||
# the prompt attention on a recorded selection (STRATA_QSA_DUMP), for A/B of kernel variants (STRATA_QSA_UNION=1)
|
||||
add_executable(hip_pa_bench tests/cuda/pa_bench.cpp)
|
||||
target_link_libraries(hip_pa_bench PRIVATE strata_kernels strata_hip_runtime)
|
||||
add_executable(hip_prompt_attn_wmma src/kernels/qsa_prompt_attn_parity.cpp)
|
||||
target_link_libraries(hip_prompt_attn_wmma PRIVATE strata_kernels strata_hip_runtime)
|
||||
add_test(NAME hip_prompt_attn_wmma COMMAND hip_prompt_attn_wmma 32768 2048 3)
|
||||
@@ -1278,6 +1379,20 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
|
||||
# "not on this card" for the tc variant
|
||||
add_test(NAME gdn_rec_parity_selftest_frag COMMAND gdn_rec_parity --selftest-frag)
|
||||
set_tests_properties(gdn_rec_parity_selftest_frag PROPERTIES SKIP_RETURN_CODE 77)
|
||||
# the production-linked GDN parity / benchmark harness (no kernel copies: it calls gdn_gates, gdn_conv and
|
||||
# gdn_recurrence of strata_prefill through the GdnProbe of include/strata/prefill/gdn_probe.hpp, against an FP64
|
||||
# oracle from tests/gdn/gdn_fixture.hpp). Usage: docs/GDN_CONTRACT.md. The smoke test is small and CPU-cheap.
|
||||
execute_process(COMMAND git -C ${CMAKE_CURRENT_SOURCE_DIR} rev-parse --short=9 HEAD OUTPUT_VARIABLE _gdn_sha
|
||||
OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET)
|
||||
if(NOT _gdn_sha)
|
||||
set(_gdn_sha unknown)
|
||||
endif()
|
||||
add_executable(gdn_harness src/prefill/gdn_harness.cu)
|
||||
target_include_directories(gdn_harness PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/tests/gdn)
|
||||
target_compile_definitions(gdn_harness PRIVATE GDN_HARNESS_SHA="${_gdn_sha}")
|
||||
target_link_libraries(gdn_harness PRIVATE strata_prefill)
|
||||
add_test(NAME gdn_harness_smoke COMMAND gdn_harness --T 400 --chunk 160 --cuts 37 --paths auto,keyhead,pipe --ref-heads 0,17)
|
||||
set_tests_properties(gdn_harness_smoke PROPERTIES TIMEOUT 300)
|
||||
# the prompt path's BF16 products against cuBLAS's own BF16 GEMM, within fp32 rounding (sm_7x runs them on the
|
||||
# FP16 tensor cores; GPU, synthetic, no model; --bench adds timings)
|
||||
add_executable(gemm_bf16_parity src/prefill/gemm_bf16_parity.cu)
|
||||
@@ -1307,6 +1422,12 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
|
||||
target_link_libraries(lm_kernels_parity PRIVATE strata_prefill )
|
||||
add_test(NAME lm_kernels_parity COMMAND lm_kernels_parity)
|
||||
set_tests_properties(lm_kernels_parity PROPERTIES SKIP_RETURN_CODE 77)
|
||||
# hc_q8_to_bf16 on the stream it is given: the layer-major prompt walk expanded a STRATA_HC_REQ8
|
||||
# hyper-connection projection on the default stream while its GEMMs read another. Synthetic, no model.
|
||||
add_executable(hc_q8_stream_test tests/cuda/hc_q8_stream_test.cu)
|
||||
target_link_libraries(hc_q8_stream_test PRIVATE strata_prefill )
|
||||
add_test(NAME hc_q8_stream COMMAND hc_q8_stream_test)
|
||||
set_tests_properties(hc_q8_stream PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 300)
|
||||
endif()
|
||||
# native_expert_grouped's group stride and fused SwiGLU + q8_1, bitwise vs the launches before them (GPU, synthetic,
|
||||
# no model; --bench adds v1/new timings of a window's 48 calls in a graph)
|
||||
@@ -1341,13 +1462,20 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
|
||||
target_include_directories(shared_expert_parity PRIVATE ${_strata_gpu_include_directories})
|
||||
target_link_libraries(shared_expert_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
|
||||
add_test(NAME shared_expert_parity COMMAND shared_expert_parity --selftest)
|
||||
# the split / batched verify-window kernels (from eddoursul's fork), bitwise vs the kernels they replace; CUDA only
|
||||
if(STRATA_ENABLE_CUDA)
|
||||
# the split / batched verify-window kernels (from eddoursul's fork), bitwise vs the kernels they replace
|
||||
if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
|
||||
add_executable(verify_parity src/kernels/verify_parity.cpp)
|
||||
target_include_directories(verify_parity PRIVATE ${_strata_gpu_include_directories})
|
||||
target_link_libraries(verify_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
|
||||
add_test(NAME verify_parity COMMAND verify_parity --selftest)
|
||||
endif()
|
||||
# BW-DENSE: the v3 read's weight-prefetch variants against the plain v3 read, bit for bit, and their cold-weight time
|
||||
add_executable(gr_v3_screen src/kernels/gr_v3_screen.cpp)
|
||||
target_include_directories(gr_v3_screen PRIVATE ${_strata_gpu_include_directories})
|
||||
target_link_libraries(gr_v3_screen PRIVATE strata_kernels ${_strata_gpu_runtime_target})
|
||||
add_executable(mmvf_unr_hash src/kernels/mmvf_unr_hash.cpp)
|
||||
target_include_directories(mmvf_unr_hash PRIVATE ${_strata_gpu_include_directories})
|
||||
target_link_libraries(mmvf_unr_hash PRIVATE strata_kernels ${_strata_gpu_runtime_target})
|
||||
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})
|
||||
@@ -1626,6 +1754,12 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
|
||||
target_link_libraries(pin_budget_test PRIVATE strata_core ${_strata_gpu_runtime_target})
|
||||
add_test(NAME pin_budget_test COMMAND pin_budget_test)
|
||||
|
||||
# #3431: the expert arena's registered ranges keep their GPU aliases clear of the process's own memory (Windows HIP; else skips)
|
||||
add_executable(pinned_alias_test src/core/pinned_alias_test.cpp)
|
||||
target_link_libraries(pinned_alias_test PRIVATE strata_core ${_strata_gpu_runtime_target})
|
||||
add_test(NAME pinned_alias_test COMMAND pinned_alias_test)
|
||||
set_tests_properties(pinned_alias_test PROPERTIES SKIP_RETURN_CODE 77)
|
||||
|
||||
# #3350 / #3470: the conversation copy streams and events are pooled across short-lived threads (needs a device, else skips)
|
||||
add_executable(conversation_copy_streams_test src/core/conversation_copy_streams_test.cpp)
|
||||
target_include_directories(conversation_copy_streams_test PRIVATE include)
|
||||
@@ -1638,6 +1772,13 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
|
||||
target_link_libraries(pinned_shared_test PRIVATE strata_core ${_strata_gpu_runtime_target})
|
||||
add_test(NAME pinned_shared_test COMMAND pinned_shared_test)
|
||||
endif()
|
||||
if(WIN32) # the replicas' shared expert arena as a named section (src/core/pinned.cu): two arenas, a child process, the last handle
|
||||
add_executable(pinned_shared_win_test src/core/pinned_shared_win_test.cpp)
|
||||
target_include_directories(pinned_shared_win_test PRIVATE include)
|
||||
target_link_libraries(pinned_shared_win_test PRIVATE strata_core ${_strata_gpu_runtime_target})
|
||||
add_test(NAME pinned_shared_win_test COMMAND pinned_shared_win_test)
|
||||
set_tests_properties(pinned_shared_win_test PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 180)
|
||||
endif()
|
||||
|
||||
# Plan v0.3 P2: the n-gram table read straight from the SSD. The selftest needs only the CPU and a temp file.
|
||||
add_executable(ple_reader_test src/ngram/ple_reader_test.cpp)
|
||||
@@ -1725,6 +1866,18 @@ 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)
|
||||
# STRATA_AMD_COMMUNITY_TUNING: the switch turns each community-made AMD change off (header only; run from the source tree)
|
||||
add_executable(amd_community_test src/core/amd_community_test.cpp)
|
||||
target_include_directories(amd_community_test PRIVATE include)
|
||||
target_compile_definitions(amd_community_test PRIVATE EXPECT_DOT_BUILT=1)
|
||||
add_test(NAME amd_community_test COMMAND amd_community_test WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
add_executable(amd_community_test_dot_off src/core/amd_community_test.cpp) # the -DSTRATA_AMD_COMMUNITY_TUNING=OFF build
|
||||
target_include_directories(amd_community_test_dot_off PRIVATE include)
|
||||
target_compile_definitions(amd_community_test_dot_off PRIVATE EXPECT_DOT_BUILT=0 STRATA_AMD_COMMUNITY_DOT_OFF=1)
|
||||
add_test(NAME amd_community_test_dot_off COMMAND amd_community_test_dot_off WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
add_executable(gfx12_extra_test src/core/gfx12_extra_test.cpp src/core/arch_defaults.cpp) # gfx12 extra defaults and their off switches
|
||||
target_include_directories(gfx12_extra_test PRIVATE include)
|
||||
add_test(NAME gfx12_extra_test COMMAND gfx12_extra_test)
|
||||
add_executable(nv_defaults_test src/core/nv_defaults_test.cpp src/core/nv_defaults.cpp) # the sm_121 (GB10) table and its two off switches
|
||||
target_include_directories(nv_defaults_test PRIVATE include)
|
||||
add_test(NAME nv_defaults_test COMMAND nv_defaults_test)
|
||||
@@ -1746,6 +1899,12 @@ 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(window_rule_test src/spec/window_rule_test.cpp) # which rule picks the window / whose q judges it, by the active draft source
|
||||
target_include_directories(window_rule_test PRIVATE include)
|
||||
add_test(NAME window_rule_test COMMAND window_rule_test)
|
||||
add_executable(draft_vocab_test src/spec/draft_vocab_test.cpp) # the DFlash2-alone start's draft-vocabulary choice (a build's packaged default subset; the error elsewhere)
|
||||
target_include_directories(draft_vocab_test PRIVATE include)
|
||||
add_test(NAME draft_vocab_test COMMAND draft_vocab_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)
|
||||
@@ -1770,6 +1929,16 @@ if(STRATA_BUILD_TESTS OR STRATA_BUILD_CONVERSATION_TESTS)
|
||||
add_executable(window_limits_test src/core/window_limits_test.cpp)
|
||||
target_include_directories(window_limits_test PRIVATE include)
|
||||
add_test(NAME window_limits_test COMMAND window_limits_test)
|
||||
# #4677: the accepted rows a request commits are bounded to what it can emit (header-only, CPU; 2,736 cases against the old output loop)
|
||||
add_executable(serve_stop_bound_test tests/core/serve_stop_bound_test.cpp)
|
||||
target_include_directories(serve_stop_bound_test PRIVATE include)
|
||||
target_compile_features(serve_stop_bound_test PRIVATE cxx_std_17)
|
||||
add_test(NAME serve_stop_bound_test COMMAND serve_stop_bound_test)
|
||||
# the --lookup-max-t default (12 on the 8-row CUDA serve, off on Metal, HIP and every other case; header-only, CPU)
|
||||
add_executable(lookup_default_test tests/core/lookup_default_test.cpp)
|
||||
target_include_directories(lookup_default_test PRIVATE include)
|
||||
target_compile_features(lookup_default_test PRIVATE cxx_std_17)
|
||||
add_test(NAME lookup_default_test COMMAND lookup_default_test)
|
||||
# which window-size graph is freed when the next one does not fit (#3702; header-only, CPU)
|
||||
add_executable(window_evict_test src/core/window_evict_test.cpp)
|
||||
target_include_directories(window_evict_test PRIVATE include)
|
||||
@@ -1782,6 +1951,14 @@ if(STRATA_BUILD_TESTS OR STRATA_BUILD_CONVERSATION_TESTS)
|
||||
add_executable(coupled_draft_test src/core/coupled_draft_test.cpp)
|
||||
target_include_directories(coupled_draft_test PRIVATE include)
|
||||
add_test(NAME coupled_draft_test COMMAND coupled_draft_test)
|
||||
# #4590: the penalty window is the current reply by default, STRATA_PENALTY_PROMPT=1 the old prompt + reply window (header-only, CPU)
|
||||
add_executable(penalty_window_test src/kernels/penalty_window_test.cpp)
|
||||
target_include_directories(penalty_window_test PRIVATE include)
|
||||
add_test(NAME penalty_window_test COMMAND penalty_window_test)
|
||||
# the penalty-history rows of a batch window, per slot (header-only, CPU)
|
||||
add_executable(slot_history_test src/kernels/slot_history_test.cpp)
|
||||
target_include_directories(slot_history_test PRIVATE include)
|
||||
add_test(NAME slot_history_test COMMAND slot_history_test)
|
||||
# probabilistic draft acceptance (STRATA_SPEC_PROB): rejection sampling preserves p - chi-square over 2M trials per case (CPU)
|
||||
add_executable(spec_prob_test src/core/spec_prob_test.cpp)
|
||||
target_include_directories(spec_prob_test PRIVATE include)
|
||||
@@ -2336,10 +2513,12 @@ if(STRATA_NATIVE_EXPERTS)
|
||||
set_tests_properties(prefill_mmq_kquant_test PROPERTIES TIMEOUT 120 ENVIRONMENT STRATA_MMQ_KQ=1)
|
||||
endif()
|
||||
# the NVFP4 expert products (w4a8 / w4a4, STRATA_PREFILL_NVFP4) against a double-precision reference
|
||||
add_executable(mmq_nvfp4_parity src/prefill/mmq_nvfp4_parity.cpp)
|
||||
target_link_libraries(mmq_nvfp4_parity PRIVATE strata_mmq ggml-base CUDA::cudart)
|
||||
if(NOT MSVC) # its FP16 rounding helper uses the F16C intrinsics (GCC/Clang need the target flag)
|
||||
target_compile_options(mmq_nvfp4_parity PRIVATE $<$<COMPILE_LANGUAGE:CXX>:-mf16c>)
|
||||
if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|AMD64|amd64|i[3-6]86)$") # its FP16 rounding helper is the x86 F16C intrinsics: not built on aarch64
|
||||
add_executable(mmq_nvfp4_parity src/prefill/mmq_nvfp4_parity.cpp)
|
||||
target_link_libraries(mmq_nvfp4_parity PRIVATE strata_mmq ggml-base CUDA::cudart)
|
||||
if(NOT MSVC) # GCC/Clang need the target flag
|
||||
target_compile_options(mmq_nvfp4_parity PRIVATE $<$<COMPILE_LANGUAGE:CXX>:-mf16c>)
|
||||
endif()
|
||||
endif()
|
||||
# the routed-expert products (MMQ) timed at the prompt path's shapes (16 experts a launch)
|
||||
add_executable(mmq_expert_bench src/prefill/mmq_expert_bench.cpp)
|
||||
@@ -2476,13 +2655,11 @@ if(STRATA_NATIVE_EXPERTS)
|
||||
add_executable(q8_gemv_bench src/kernels/q8_gemv_bench.cpp)
|
||||
target_include_directories(q8_gemv_bench PRIVATE ${_strata_gpu_include_directories})
|
||||
target_link_libraries(q8_gemv_bench PRIVATE strata_kernels ${_strata_gpu_runtime_target})
|
||||
if(STRATA_ENABLE_CUDA)
|
||||
# EXPDEC (docs/EXPERTDEC.md): the grouped experts' default kernels against STRATA_EXPDEC=1 at T = 1..4, bit for bit
|
||||
# and in microseconds, on synthetic blobs read cold from DRAM
|
||||
add_executable(expdec_bench EXCLUDE_FROM_ALL src/kernels/expdec_bench.cpp)
|
||||
target_include_directories(expdec_bench PRIVATE ${_strata_gpu_include_directories})
|
||||
target_link_libraries(expdec_bench PRIVATE strata_kernels ${_strata_gpu_runtime_target})
|
||||
endif()
|
||||
# EXPDEC (docs/EXPERTDEC.md): the grouped experts' default kernels against STRATA_EXPDEC=1 at T = 1..4, bit for bit
|
||||
# and in microseconds, on synthetic blobs read cold from DRAM
|
||||
add_executable(expdec_bench EXCLUDE_FROM_ALL src/kernels/expdec_bench.cpp)
|
||||
target_include_directories(expdec_bench PRIVATE ${_strata_gpu_include_directories})
|
||||
target_link_libraries(expdec_bench PRIVATE strata_kernels ${_strata_gpu_runtime_target})
|
||||
if(STRATA_BUILD_TESTS AND STRATA_ENABLE_CUDA)
|
||||
# Unsloth UD-Q4_K_XL's three expert pairs (and Q8_0 alone, its embedding) on synthetic ggml-quantized weights
|
||||
# at 2560/640: GPU and CPU experts against the float reference, GPU dequantizers against to_float.
|
||||
@@ -2764,3 +2941,10 @@ if(STRATA_BUILD_DSV41 AND NOT STRATA_ENABLE_CUDA AND NOT STRATA_ENABLE_HIP AND N
|
||||
target_compile_definitions(ds4_gg_bench PRIVATE STRATA_DSV41_TQ_CPU=1)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# ---- strata-split: the opt-in engine that splits every layer across 2-4 NVIDIA cards (src/split, from sFNX, MIT, John Fink) ----
|
||||
# OFF by default, Linux + CUDA only, no other target depends on it; see src/split/CMakeLists.txt and docs/SPLIT_ENGINE.md.
|
||||
option(STRATA_BUILD_SPLIT "Build strata-split: sFNX's split-every-layer engine, host-memory exchange (Linux, CUDA or HIP gfx1201, 2-4 cards)" OFF)
|
||||
if(STRATA_BUILD_SPLIT)
|
||||
add_subdirectory(src/split)
|
||||
endif()
|
||||
+2
-2
@@ -56,7 +56,7 @@ Wir empfehlen **zuerst ISTA-DASLab**, danach **Unsloth** und andere Anbieter, so
|
||||
|
||||
In der **Desktop-App** und im **Terminal**. Was nicht in den Arbeitsspeicher passt, wird von deiner SSD gelesen. Darum brauchst du viel freien Speicherplatz.
|
||||
|
||||
| Set (IST-DAS) | Download | Freier Speicherplatz nötig |
|
||||
| Set (ISTA-DAS) | Download | Freier Speicherplatz nötig |
|
||||
| --- | ---: | ---: |
|
||||
| 1.75 bpw | 239 GB | etwa 245 GB |
|
||||
| 2.00 bpw | 257 GB | etwa 265 GB |
|
||||
@@ -95,7 +95,7 @@ Unsicher, was passt? [Wähle ein Modell für deinen Computer](docs/MODELS.md#pic
|
||||
|
||||
Installiere die App, wähle ein Modell und leg mit dem Chat los. Es gibt einen Installer pro System. Beim ersten Start lädt die App die Engine für deine Grafikkarte herunter. [Hilfe zum Installer](docs/INSTALL.md#the-app).
|
||||
|
||||
<sub>Dateien: `Strata-0.2.8-windows-x64-setup.exe` · `Strata-0.2.8-linux-x86_64.AppImage` · `Strata-0.2.8-macos-arm64.dmg`</sub>
|
||||
<sub>Dateien: `Strata-0.2.9-windows-x64-setup.exe` · `Strata-0.2.9-linux-x86_64.AppImage` · `Strata-0.2.9-macos-arm64.dmg`</sub>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/media/app-chat.png" width="900" alt="Strata-Desktop-App mit Chat und Live-Codevorschau nebeneinander">
|
||||
|
||||
+2
-2
@@ -56,7 +56,7 @@ Recomendamos **primero ISTA-DASLab**, después **Unsloth** y otros proveedores,
|
||||
|
||||
En la **aplicación de escritorio** y en el **Terminal**. Lo que no cabe en la memoria se lee desde tu SSD, así que hace falta mucho espacio libre en disco.
|
||||
|
||||
| Conjunto (IST-DAS) | Descarga | Espacio libre necesario |
|
||||
| Conjunto (ISTA-DAS) | Descarga | Espacio libre necesario |
|
||||
| --- | ---: | ---: |
|
||||
| 1.75 bpw | 239 GB | unos 245 GB |
|
||||
| 2.00 bpw | 257 GB | unos 265 GB |
|
||||
@@ -95,7 +95,7 @@ Medido con el conjunto de 2.00 bpw en Strata 0.2.5. «Salida» es la velocidad d
|
||||
|
||||
Instala la aplicación, elige un modelo y empieza a chatear. Hay un instalador por sistema. En el primer arranque, la aplicación descarga el motor para tu tarjeta gráfica. [Ayuda con el instalador](docs/INSTALL.md#the-app).
|
||||
|
||||
<sub>Archivos: `Strata-0.2.8-windows-x64-setup.exe` · `Strata-0.2.8-linux-x86_64.AppImage` · `Strata-0.2.8-macos-arm64.dmg`</sub>
|
||||
<sub>Archivos: `Strata-0.2.9-windows-x64-setup.exe` · `Strata-0.2.9-linux-x86_64.AppImage` · `Strata-0.2.9-macos-arm64.dmg`</sub>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/media/app-chat.png" width="900" alt="Aplicación de escritorio de Strata con un chat y una vista previa del código en vivo, una junto a otra">
|
||||
|
||||
+2
-2
@@ -56,7 +56,7 @@ Nous recommandons **d'abord ISTA-DASLab**, puis **Unsloth** et d'autres fourniss
|
||||
|
||||
Dans l'**application de bureau** et dans le **Terminal**. Ce qui ne tient pas en mémoire est lu depuis votre SSD : il faut donc beaucoup d'espace disque libre.
|
||||
|
||||
| Jeu (IST-DAS) | Téléchargement | Espace disque libre nécessaire |
|
||||
| Jeu (ISTA-DAS) | Téléchargement | Espace disque libre nécessaire |
|
||||
| --- | ---: | ---: |
|
||||
| 1.75 bpw | 239 GB | environ 245 GB |
|
||||
| 2.00 bpw | 257 GB | environ 265 GB |
|
||||
@@ -95,7 +95,7 @@ Vous ne savez pas ce qui tient ? [Choisir un modèle pour votre ordinateur](docs
|
||||
|
||||
Installez l'application, choisissez un modèle et commencez à discuter. Il y a un seul installateur par système. Au premier lancement, l'application télécharge le moteur de votre carte graphique. [Aide pour l'installateur](docs/INSTALL.md#the-app).
|
||||
|
||||
<sub>Fichiers: `Strata-0.2.8-windows-x64-setup.exe` · `Strata-0.2.8-linux-x86_64.AppImage` · `Strata-0.2.8-macos-arm64.dmg`</sub>
|
||||
<sub>Fichiers: `Strata-0.2.9-windows-x64-setup.exe` · `Strata-0.2.9-linux-x86_64.AppImage` · `Strata-0.2.9-macos-arm64.dmg`</sub>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/media/app-chat.png" width="900" alt="Application de bureau Strata avec une discussion et un aperçu du code en direct côte à côte">
|
||||
|
||||
+2
-2
@@ -56,7 +56,7 @@
|
||||
|
||||
**デスクトップアプリ**と**ターミナル**で使えます。メモリに収まらない分は SSD から読み込むため、空きディスク容量がたくさん必要です。
|
||||
|
||||
| セット(IST-DAS) | ダウンロード | 必要な空き容量 |
|
||||
| セット(ISTA-DAS) | ダウンロード | 必要な空き容量 |
|
||||
| --- | ---: | ---: |
|
||||
| 1.75 bpw | 239 GB | 約245 GB |
|
||||
| 2.00 bpw | 257 GB | 約265 GB |
|
||||
@@ -95,7 +95,7 @@ Strata 0.2.5 で 2.00 bpw のセットを使って測定しました。「出力
|
||||
|
||||
アプリをインストールし、モデルを選んで、チャットを始めましょう。インストーラーはシステムごとに1つです。初回起動時に、アプリがお使いのグラフィックスカード用のエンジンをダウンロードします。[インストーラーのヘルプ](docs/INSTALL.md#the-app)。
|
||||
|
||||
<sub>ファイル: `Strata-0.2.8-windows-x64-setup.exe` · `Strata-0.2.8-linux-x86_64.AppImage` · `Strata-0.2.8-macos-arm64.dmg`</sub>
|
||||
<sub>ファイル: `Strata-0.2.9-windows-x64-setup.exe` · `Strata-0.2.9-linux-x86_64.AppImage` · `Strata-0.2.9-macos-arm64.dmg`</sub>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/media/app-chat.png" width="900" alt="チャットとライブのコードプレビューを並べて表示した Strata デスクトップアプリ">
|
||||
|
||||
@@ -57,11 +57,12 @@ We recommend **ISTA-DASLab first**, then **Unsloth** and other providers, where
|
||||
In the **desktop app** and the **Terminal**. What does not fit in memory is read from your SSD, so it needs a lot of free
|
||||
disk space.
|
||||
|
||||
| Set (IST-DAS) | Download | Free disk needed |
|
||||
| Set (ISTA-DAS) | Download | Free disk needed |
|
||||
| --- | ---: | ---: |
|
||||
| 1.75 bpw | 239 GB | about 245 GB |
|
||||
| 2.00 bpw | 257 GB | about 265 GB |
|
||||
| 2.25 bpw | 274 GB | about 280 GB |
|
||||
| ISTA-DAS Coder (half of the experts removed) | 191 GB | about 197 GB |
|
||||
|
||||
Runs on NVIDIA RTX 30, 40 and 50 series (Windows and Linux), AMD Radeon AI PRO R9700 (Linux), AMD Ryzen AI Max — Strix Halo
|
||||
(Windows), and Apple Silicon Macs with 64 GB or more, text only ([Mac](docs/DEEPSEEK_MAC.md)). Setup downloads the files and the DeepSeek engine, then starts the model.
|
||||
@@ -99,7 +100,7 @@ Not sure what fits? [Choose a model for your computer](docs/MODELS.md#pick-by-me
|
||||
Install the app, choose a model, and start chatting. There is one installer for each system. On first start the app
|
||||
downloads the engine for your graphics card. [Help with the installer](docs/INSTALL.md#the-app).
|
||||
|
||||
<sub>Files: `Strata-0.2.8-windows-x64-setup.exe` · `Strata-0.2.8-linux-x86_64.AppImage` · `Strata-0.2.8-macos-arm64.dmg`</sub>
|
||||
<sub>Files: `Strata-0.2.9-windows-x64-setup.exe` · `Strata-0.2.9-linux-x86_64.AppImage` · `Strata-0.2.9-macos-arm64.dmg`</sub>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/media/app-chat.png" width="900" alt="Strata desktop app with a chat and live code preview side by side">
|
||||
|
||||
+2
-2
@@ -57,7 +57,7 @@ Najpierw polecamy **ISTA-DASLab**, potem **Unsloth** i innych dostawców — o i
|
||||
W **aplikacji na komputer** i w **Terminalu**. To, co nie mieści się w pamięci, jest czytane z dysku SSD, więc potrzebujesz
|
||||
dużo wolnego miejsca na dysku.
|
||||
|
||||
| Zestaw (IST-DAS) | Do pobrania | Potrzebne wolne miejsce |
|
||||
| Zestaw (ISTA-DAS) | Do pobrania | Potrzebne wolne miejsce |
|
||||
| --- | ---: | ---: |
|
||||
| 1.75 bpw | 239 GB | około 245 GB |
|
||||
| 2.00 bpw | 257 GB | około 265 GB |
|
||||
@@ -99,7 +99,7 @@ Nie wiesz, co się zmieści? [Wybierz model do swojego komputera](docs/MODELS.md
|
||||
Zainstaluj aplikację, wybierz model i zacznij rozmawiać. Dla każdego systemu jest jeden instalator. Przy pierwszym
|
||||
uruchomieniu aplikacja pobiera silnik dla twojej karty graficznej. [Pomoc z instalatorem](docs/INSTALL.md#the-app).
|
||||
|
||||
<sub>Pliki: `Strata-0.2.8-windows-x64-setup.exe` · `Strata-0.2.8-linux-x86_64.AppImage` · `Strata-0.2.8-macos-arm64.dmg`</sub>
|
||||
<sub>Pliki: `Strata-0.2.9-windows-x64-setup.exe` · `Strata-0.2.9-linux-x86_64.AppImage` · `Strata-0.2.9-macos-arm64.dmg`</sub>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/media/app-chat.png" width="900" alt="Aplikacja Strata na komputer: czat i podgląd kodu na żywo obok siebie">
|
||||
|
||||
+2
-2
@@ -56,7 +56,7 @@ Recomendamos **primeiro a ISTA-DASLab**, depois a **Unsloth** e outros provedore
|
||||
|
||||
No **aplicativo de desktop** e no **Terminal**. O que não cabe na memória é lido do seu SSD, por isso é preciso muito espaço livre em disco.
|
||||
|
||||
| Conjunto (IST-DAS) | Download | Espaço livre necessário |
|
||||
| Conjunto (ISTA-DAS) | Download | Espaço livre necessário |
|
||||
| --- | ---: | ---: |
|
||||
| 1.75 bpw | 239 GB | cerca de 245 GB |
|
||||
| 2.00 bpw | 257 GB | cerca de 265 GB |
|
||||
@@ -95,7 +95,7 @@ Não sabe o que cabe? [Escolha um modelo para o seu computador](docs/MODELS.md#p
|
||||
|
||||
Instale o aplicativo, escolha um modelo e comece a conversar. Há um instalador por sistema. Na primeira vez que abre, o aplicativo baixa o motor da sua placa de vídeo. [Ajuda com o instalador](docs/INSTALL.md#the-app).
|
||||
|
||||
<sub>Arquivos: `Strata-0.2.8-windows-x64-setup.exe` · `Strata-0.2.8-linux-x86_64.AppImage` · `Strata-0.2.8-macos-arm64.dmg`</sub>
|
||||
<sub>Arquivos: `Strata-0.2.9-windows-x64-setup.exe` · `Strata-0.2.9-linux-x86_64.AppImage` · `Strata-0.2.9-macos-arm64.dmg`</sub>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/media/app-chat.png" width="900" alt="Aplicativo de desktop do Strata com um chat e uma prévia do código ao vivo lado a lado">
|
||||
|
||||
+2
-2
@@ -56,7 +56,7 @@
|
||||
|
||||
可在**桌面应用**和**终端**中使用。内存放不下的部分会从 SSD 读取,所以需要大量空闲磁盘空间。
|
||||
|
||||
| 版本(IST-DAS) | 下载大小 | 所需空闲磁盘 |
|
||||
| 版本(ISTA-DAS) | 下载大小 | 所需空闲磁盘 |
|
||||
| --- | ---: | ---: |
|
||||
| 1.75 bpw | 239 GB | 约245 GB |
|
||||
| 2.00 bpw | 257 GB | 约265 GB |
|
||||
@@ -95,7 +95,7 @@ strata launch --family deepseek41 --model ist-das-2.00bpw --plan
|
||||
|
||||
安装应用,选择模型,就可以开始聊天。每个系统只有一个安装程序。首次启动时,应用会下载适合你显卡的引擎。[安装程序帮助](docs/INSTALL.md#the-app)。
|
||||
|
||||
<sub>文件: `Strata-0.2.8-windows-x64-setup.exe` · `Strata-0.2.8-linux-x86_64.AppImage` · `Strata-0.2.8-macos-arm64.dmg`</sub>
|
||||
<sub>文件: `Strata-0.2.9-windows-x64-setup.exe` · `Strata-0.2.9-linux-x86_64.AppImage` · `Strata-0.2.9-macos-arm64.dmg`</sub>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/media/app-chat.png" width="900" alt="Strata 桌面应用,聊天和实时代码预览并排显示">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "strata-desktop",
|
||||
"productName": "Strata",
|
||||
"version": "0.2.8",
|
||||
"version": "0.2.10",
|
||||
"private": true,
|
||||
"description": "Strata Desktop - local AI, tuned to one model.",
|
||||
"scripts": {
|
||||
|
||||
Generated
+1
-1
@@ -3625,7 +3625,7 @@ checksum = "ff7589a1859b09232522bb9edf076ca7fcf14ded96685d3fa629db73023ffbb7"
|
||||
|
||||
[[package]]
|
||||
name = "strata-desktop"
|
||||
version = "0.2.8"
|
||||
version = "0.2.10"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"serde",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "strata-desktop"
|
||||
version = "0.2.8"
|
||||
version = "0.2.10"
|
||||
description = "Strata Desktop — local AI, tuned to one model."
|
||||
edition = "2021"
|
||||
rust-version = "1.77"
|
||||
|
||||
@@ -6,7 +6,7 @@ Implemented 14 October 2026 in the React/Node app under `backups/strata`.
|
||||
|
||||
The default is one model at a time. First launch uses the visual `GuidedSetup` funnel: **Welcome → Your model → Prepare → Make it yours → Chat**. Welcome has animated vector artwork and one expert skip at the bottom. Model selection offers friendly version cards, context, RAM/VRAM inventory with an aggregate total, and an existing-folder browser. Unified CPU/GPU memory counts once. Memory verdicts are guidance: **May run slower** does not block selection or Continue. There is no compatibility alternatives panel or picture-reading question at this stage. The preparation screen confirms the download size before **Download model**, then shows engine-reported progress alongside a clickable Chat / Files / Your setup tour. Vision-capable families always prepare their mmproj/encoder files. Preparation does not load the model automatically.
|
||||
|
||||
After preparation, users choose **Leave 20% for my work** or **Full performance**, and choose whether to **Read pictures when this model loads** (off initially). The optional longer native calibration measures engine settings for approximately 5–10 minutes. **Tune & start chatting** applies the resource and vision preferences, loads one model, performs a short benchmark, and completes setup only when that model is healthy. Chat opens automatically with starter prompts for a new user; selecting a prompt fills the composer without sending it. Progress, choices and operation IDs survive reloads. Finish later preserves the current stage and keeps completed files available.
|
||||
After preparation, users choose **Leave room for my work** or **Full performance**, and choose whether to **Read pictures when this model loads** (off initially). The optional longer native calibration measures engine settings for approximately 5–10 minutes. **Tune & start chatting** applies the resource and vision preferences, loads one model, performs a short benchmark, and completes setup only when that model is healthy. Chat opens automatically with starter prompts for a new user; selecting a prompt fills the composer without sending it. Progress, choices and operation IDs survive reloads. Finish later preserves the current stage and keeps completed files available.
|
||||
|
||||
Normal Models shows Version, optional picture reading, context, Computer use and Load model. The resource preference can be changed here after onboarding. Image generation is a separate collapsed option. The selected model and context remain visible in the toolbar; saved setups and Manage models are available there. Local GGUF import is a secondary entry in Version. Imported models retain the existing metadata/shard validation and reference their files without copying them.
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Strata — local AI, tuned to one model</title>
|
||||
<title>Strata</title>
|
||||
<meta name="description" content="Strata runs one local model, tuned to your hardware." />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#f9fafb" />
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "strata",
|
||||
"version": "0.2.8",
|
||||
"version": "0.2.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "strata",
|
||||
"version": "0.2.8",
|
||||
"version": "0.2.10",
|
||||
"dependencies": {
|
||||
"html-to-image": "^1.11.13",
|
||||
"katex": "^0.19.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "strata",
|
||||
"private": true,
|
||||
"version": "0.2.8",
|
||||
"version": "0.2.10",
|
||||
"type": "module",
|
||||
"description": "Strata \u2014 local AI, tuned to one model. Web shell + engine host.",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* AGENT-LOST end to end: a long single-file write through the real agent bridge, the real Harness and a real engine.
|
||||
*
|
||||
* STRATA_HARNESS_DIR=<harness folder> node scripts/agent-lost-e2e.mjs [pagoda|long|cut ...]
|
||||
*
|
||||
* Scenarios (default: all)
|
||||
* pagoda the owner's own prompt ("Write me a 100 voxel pagoda in one html file with garden."), thinking Medium, bypass mode
|
||||
* long a deterministic long write: one file of N numbered lines (E2E_LINES, default 1800, about 22K tokens), thinking Off
|
||||
* mcp Strata's own tool loop (serve runs an MCP tool itself): a call that finishes, and a call the reply is cut inside
|
||||
* raw the model server alone: a reply cut inside a tool call, as the client receives it
|
||||
* cut the same long write with the Harness told a small window (E2E_CUT_WINDOW, default 24000), so the reply budget is
|
||||
* about 7K tokens and the reply is cut inside the tool call: the turn must say so in words and the agent must carry on
|
||||
*
|
||||
* A model server that is already running is used when E2E_BASE_URL is set (nothing is started or stopped). Otherwise this
|
||||
* starts serve/server.py as its own child with the engine named below and stops it by PID.
|
||||
*
|
||||
* Environment: E2E_ENGINE (strata executable), E2E_PACK, E2E_GGUF1, E2E_GGUF2, E2E_MTP, E2E_PROFILE, E2E_LIB (library folder),
|
||||
* E2E_PORT (18590), E2E_WINDOW (65536), E2E_OUT (result folder), E2E_PYTHON.
|
||||
*
|
||||
* It prints one table and writes <E2E_OUT>/result.json with the frames summary of each scenario.
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createAgentBridge } from '../server/agent/agent-bridge.mjs';
|
||||
|
||||
const REPO = join(import.meta.dirname, '..', '..', '..');
|
||||
const env = (name, fallback) => process.env[name] ?? fallback;
|
||||
const HOME = process.env.HOME ?? '';
|
||||
const PORT = Number(env('E2E_PORT', '18590'));
|
||||
const WINDOW = Number(env('E2E_WINDOW', '65536'));
|
||||
const EXISTING = process.env.E2E_BASE_URL ?? '';
|
||||
const PYTHON = env('E2E_PYTHON', 'python3');
|
||||
const OUT = env('E2E_OUT', mkdtempSync(join(tmpdir(), 'strata-agent-lost-')));
|
||||
const LINES = Number(env('E2E_LINES', '1100'));
|
||||
const REPEAT = Number(env('E2E_REPEAT', '1'));
|
||||
const TURN_S = Number(env('E2E_TURN_S', '900'));
|
||||
const wanted = process.argv.slice(2);
|
||||
const want = id => wanted.length === 0 || wanted.includes(id);
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
if (!process.env.STRATA_HARNESS_DIR) { console.error('STRATA_HARNESS_DIR is not set'); process.exit(2); }
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
let server = null, bridge = null;
|
||||
const finish = async (code) => {
|
||||
try { await bridge?.close(); } catch { /* going away */ }
|
||||
if (server?.pid) { try { process.kill(-server.pid, 'SIGTERM'); } catch { try { process.kill(server.pid, 'SIGTERM'); } catch { /* gone */ } } }
|
||||
await sleep(1500);
|
||||
console.log(`\nresult folder: ${OUT}`);
|
||||
process.exit(code);
|
||||
};
|
||||
process.on('SIGINT', () => finish(130));
|
||||
process.on('SIGTERM', () => finish(143));
|
||||
|
||||
if (!EXISTING) {
|
||||
const pack = env('E2E_PACK', join(HOME, '026q/pack-iq3s'));
|
||||
const g1 = env('E2E_GGUF1', join(HOME, 'models/IQ3_S/Qwen3.8-Flash-Next-GSQ-RCO-IQ3_S-00001-of-00002.gguf'));
|
||||
const g2 = env('E2E_GGUF2', join(HOME, 'models/IQ3_S/Qwen3.8-Flash-Next-GSQ-RCO-IQ3_S-00002-of-00002.gguf'));
|
||||
const mtp = env('E2E_MTP', join(HOME, 'pr1426/mtp-rt'));
|
||||
const profile = env('E2E_PROFILE', join(HOME, 'bwm/cnt3/data/expert-profile.bin'));
|
||||
const exe = env('E2E_ENGINE', join(HOME, 'agentlost/eng/strata'));
|
||||
const lib = env('E2E_LIB', join(HOME, 'agentlost/eng'));
|
||||
for (const f of [pack, g1, g2, mtp, exe]) if (!existsSync(f)) { console.error(`missing: ${f}`); process.exit(2); }
|
||||
const config = join(OUT, 'server.json');
|
||||
writeFileSync(config, JSON.stringify({
|
||||
exe, args: ['--pack', pack, '--native', g1, '--ple-gguf', g2, ...(existsSync(profile) ? ['--expert-profile', profile] : []),
|
||||
'--prefill', 'auto', '--spec-min-p', '0.5', '--mtp', mtp, '--max-context', String(WINDOW), '--kv', 'int8', '--expert-cache', 'auto', '--spec', '4',
|
||||
...(process.env.E2E_ENGINE_ARGS ? process.env.E2E_ENGINE_ARGS.split(' ').filter(Boolean) : [])],
|
||||
cwd: REPO, tokenizer: join(pack, 'tokenizer'), model_name: 'qwen3.8-flash-next-iq3_s', log: join(OUT, 'engine.log'), lib_dirs: [lib], port: PORT, gpu: 0,
|
||||
fit_max_tokens: true,
|
||||
// Strata's own tool loop (serve/server.py run_with_mcp): one small MCP server, offered only to requests that ask for it (strata_mcp).
|
||||
mcp_servers: { fake: { command: PYTHON, args: [join(REPO, 'serve', 'mcp_fake_server.py')] } },
|
||||
}, null, 2));
|
||||
server = spawn(PYTHON, ['serve/server.py', '--engine', 'strata', '--config', config, '--port', String(PORT), '--no-api-key', '--ready-file', join(OUT, 'ready.json')], {
|
||||
cwd: REPO, env: { ...process.env, LD_LIBRARY_PATH: `${lib}:${process.env.LD_LIBRARY_PATH ?? ''}`, STRATA_DATA_DIR: join(OUT, 'data'), PYTHONPATH: `${join(REPO, 'tools')}:${REPO}` }, stdio: ['ignore', 'pipe', 'pipe'], detached: true,
|
||||
});
|
||||
const sink = [];
|
||||
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; writeFileSync(join(OUT, 'server.log'), sink.join('')); });
|
||||
}
|
||||
|
||||
const base = EXISTING || `http://127.0.0.1:${PORT}`;
|
||||
let ready = false;
|
||||
for (let i = 0; i < 360 && !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);
|
||||
}
|
||||
if (!ready) { console.error('the engine did not become ready'); await finish(4); }
|
||||
const model = (await (await fetch(`${base}/v1/models`)).json()).data[0].id;
|
||||
console.log(`engine ready: ${model}`);
|
||||
|
||||
const rows = [];
|
||||
const note = (id, ok, detail) => { rows.push({ id, ok, detail }); console.log(`${ok ? 'PASS' : 'FAIL'} ${id} ${detail}`); };
|
||||
|
||||
/** The bridge for a window as the host would tell it (the engine's own window stays WINDOW). */
|
||||
function makeBridge(window, dir) {
|
||||
mkdirSync(join(OUT, dir), { recursive: true });
|
||||
return createAgentBridge({
|
||||
dataDir: join(OUT, dir), env: process.env, startTimeoutMs: 180_000, log: l => console.log(' bridge:', l),
|
||||
localInstances: () => [{ id: 'e2e', label: 'iq3_s', baseUrl: `${base}/v1`, modelId: model, context: window, slots: 1, key: '' }], connections: () => [],
|
||||
});
|
||||
}
|
||||
bridge = makeBridge(WINDOW, 'agent');
|
||||
|
||||
/** One agent turn; every frame is kept in a compact timeline so the live data path can be read afterwards. */
|
||||
async function turn(name, message, { thinking = 'Medium', mode = 'bypass', chatId = name } = {}) {
|
||||
const workspace = join(OUT, 'ws-' + name);
|
||||
mkdirSync(workspace, { recursive: true });
|
||||
const frames = [];
|
||||
const t0 = Date.now();
|
||||
let timedOut = false;
|
||||
const stopTimer = setTimeout(() => { timedOut = true; void bridge.stop(chatId).catch(() => {}); }, TURN_S * 1000);
|
||||
await bridge.runTurn({ chatId, message, workspacePath: workspace, mode, thinking, onFrame: f => frames.push({ at: Date.now() - t0, f }) });
|
||||
clearTimeout(stopTimer);
|
||||
const seconds = (Date.now() - t0) / 1000;
|
||||
const byType = {};
|
||||
for (const { f } of frames) byType[f.type] = (byType[f.type] ?? 0) + 1;
|
||||
const done = frames.map(x => x.f).filter(f => f.type === 'done').at(-1) ?? null;
|
||||
const error = frames.map(x => x.f).filter(f => f.type === 'error').at(-1) ?? null;
|
||||
const prepare = frames.map(x => x.f).filter(f => f.type === 'status' && (f.phase === 'preparing' || f.code === 'preparing-tool'));
|
||||
const cards = frames.map(x => x.f).filter(f => f.type === 'agent-tool').map(f => f.tool);
|
||||
const text = frames.map(x => x.f).filter(f => f.type === 'delta').map(f => f.text).join('');
|
||||
const files = readdirSync(workspace).map(n => ({ name: n, bytes: statSync(join(workspace, n)).size }));
|
||||
const cutNotes = frames.map(x => x.f).filter(f => f.type === 'delta' && f.noteReason === 'cut').map(f => f.text.trim());
|
||||
const drafts = frames.map(x => x.f).filter(f => f.type === 'agent-tool-draft').map(f => f.draft);
|
||||
const writes = drafts.filter(d => d.name === 'write' && d.bytes > 0);
|
||||
const draftStats = { frames: drafts.length, writeFrames: writes.length, lastWrite: writes.filter(d => !d.done).at(-1) ? { target: writes.filter(d => !d.done).at(-1).target, bytes: writes.filter(d => !d.done).at(-1).bytes, lines: writes.filter(d => !d.done).at(-1).lines } : null, previewSample: writes.filter(d => !d.done).at(-1)?.preview.slice(-80) ?? null };
|
||||
const writesDone = cards.filter(c => c.name === 'write' && c.state === 'done').length;
|
||||
const summary = { name, seconds, timedOut, writesDone, cutNotes, draftStats, byType, done, error, files, cards: cards.map(c => ({ name: c.name, state: c.state, target: c.target })), prepareStatuses: prepare.length, firstPrepare: prepare[0] ?? null, lastPrepare: prepare.at(-1) ?? null, text: text.slice(-1200) };
|
||||
writeFileSync(join(OUT, `turn-${name}.json`), JSON.stringify({ summary, frames: frames.slice(0, 4000) }, null, 1));
|
||||
return { ...summary, frames, workspace };
|
||||
}
|
||||
|
||||
const longPrompt = `Create the file big.txt in the current folder with the write tool, in ONE call. It must hold exactly ${LINES} lines. Line N is "N: " followed by one short true sentence about a different city of the world (a new city on every line, no repeats, no pattern). Write every line out yourself, do not use a script or a loop, and do not shorten it.`;
|
||||
|
||||
if (want('raw')) {
|
||||
// What the model server itself sends when a reply is cut inside a tool call (no Harness): the stream of tool_calls pieces, then finish_reason "length".
|
||||
const body = { model, stream: true, max_tokens: Number(env('E2E_RAW_CAP', '700')), temperature: 0.6,
|
||||
chat_template_kwargs: { enable_thinking: false },
|
||||
tools: [{ type: 'function', function: { name: 'write', description: 'Write a file.', parameters: { type: 'object', properties: { file_path: { type: 'string' }, content: { type: 'string' } }, required: ['file_path', 'content'] } } }],
|
||||
messages: [{ role: 'user', content: longPrompt }] };
|
||||
const res = await fetch(`${base}/v1/chat/completions`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const text = await res.text();
|
||||
const events = text.split(String.fromCharCode(10)).filter(l => l.startsWith('data: {')).map(l => JSON.parse(l.slice(6)));
|
||||
let args = '', name = '', content = '', finish = null, pieces = 0, ids = new Set();
|
||||
for (const e of events) for (const c of e.choices ?? []) {
|
||||
for (const tc of c.delta?.tool_calls ?? []) { pieces += 1; if (tc.id) ids.add(tc.id); if (tc.function?.name) name = tc.function.name; args += tc.function?.arguments ?? ''; }
|
||||
content += c.delta?.content ?? '';
|
||||
if (c.finish_reason) finish = c.finish_reason;
|
||||
}
|
||||
const usage = events.map(e => e.usage).filter(Boolean).at(-1);
|
||||
writeFileSync(join(OUT, 'raw-sse.txt'), text);
|
||||
note('raw: a reply cut inside a tool call reaches the client as pieces of the call and finish_reason length', finish === 'length' && name === 'write' && pieces > 5, `finish ${finish}; tool ${name}; ${pieces} call pieces, ${args.length} argument characters, closed JSON ${(() => { try { JSON.parse(args); return true; } catch { return false; } })()}; plain content ${JSON.stringify(content.slice(0, 80))}; ids ${ids.size}; usage ${JSON.stringify(usage)}`);
|
||||
}
|
||||
|
||||
if (want('mcp')) {
|
||||
// Strata's own tool loop: the server runs the MCP tool itself. A call that finishes is run; a call the reply is cut inside is announced and never run.
|
||||
const ask = async (content, max_tokens) => {
|
||||
const res = await fetch(`${base}/v1/chat/completions`, { method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ model, stream: true, strata_mcp: true, max_tokens, temperature: 0.6, chat_template_kwargs: { enable_thinking: false }, messages: [{ role: 'user', content }] }) });
|
||||
const text = await res.text();
|
||||
const events = text.split(String.fromCharCode(10)).filter(l => l.startsWith('data: {')).map(l => JSON.parse(l.slice(6)));
|
||||
const mcp = events.map(e => e.strata_mcp).filter(Boolean);
|
||||
const finish = events.flatMap(e => e.choices ?? []).map(c => c.finish_reason).filter(Boolean).at(-1);
|
||||
const said = events.flatMap(e => e.choices ?? []).map(c => c.delta?.content ?? '').join('');
|
||||
return { mcp, finish, said };
|
||||
};
|
||||
const ok = await ask('Use the fake__echo tool to echo the text "hello from the tool", then tell me what it returned.', 600);
|
||||
note('mcp: a short call is run by the server and answered', ok.mcp.some(m => m.event === 'result' && m.ok) && ok.finish === 'stop', `events ${JSON.stringify(ok.mcp.map(m => m.event))}; finish ${ok.finish}; said ${JSON.stringify(ok.said.slice(-120))}`);
|
||||
const cut = await ask('Use the fake__echo tool once. Its text must be the sentence "the quick brown fox jumps over the lazy dog" written out 300 times in a row, separated by spaces.', 300);
|
||||
note('mcp: a call the reply is cut inside is announced and never run (#211), and the stream says length', cut.mcp.some(m => m.event === 'start') && !cut.mcp.some(m => m.event === 'call') && cut.finish === 'length', `events ${JSON.stringify(cut.mcp.map(m => m.event))}; finish ${cut.finish}; said ${JSON.stringify(cut.said.slice(0, 100))}`);
|
||||
}
|
||||
|
||||
if (want('pagoda')) {
|
||||
const t = await turn('pagoda', 'Write me a 100 voxel pagoda in one html file with garden.');
|
||||
const html = t.files.find(f => /\.html?$/i.test(f.name));
|
||||
console.log(` pagoda: ${t.seconds.toFixed(0)} s, frames ${JSON.stringify(t.byType)}, done ${JSON.stringify(t.done && { finishReason: t.done.finishReason, tokensOut: t.done.tokensOut })}, files ${JSON.stringify(t.files)}`);
|
||||
note('pagoda: a file was written or the turn says why not', !!html || /cut|too long|ran out|limit/i.test(t.text), `file ${html ? html.name + ' ' + html.bytes + ' B' : 'none'}; finish ${t.done?.finishReason}; text tail: ${JSON.stringify(t.text.slice(-240))}`);
|
||||
}
|
||||
if (want('long')) for (let rep = 1; rep <= REPEAT; rep++) {
|
||||
const t = await turn(`long${rep}`, longPrompt, { thinking: 'Off' });
|
||||
const f = t.files.find(x => x.name === 'big.txt');
|
||||
const lines = f ? readFileSync(join(t.workspace, 'big.txt'), 'utf8').split(String.fromCharCode(10)).filter(Boolean).length : 0;
|
||||
console.log(` long${rep}: ${t.seconds.toFixed(0)} s, frames ${JSON.stringify(t.byType)}, done ${JSON.stringify(t.done && { finishReason: t.done.finishReason, tokensOut: t.done.tokensOut })}, draft ${JSON.stringify(t.draftStats)}, cut notes ${JSON.stringify(t.cutNotes)}`);
|
||||
note(`long${rep}: big.txt written (${LINES} lines asked)`, lines >= LINES * 0.95 || t.writesDone > 0 || t.cutNotes.length > 0 && !!f, `file ${f ? f.bytes + ' B, ' + lines + ' lines' : 'none'}; finish ${t.done?.finishReason}; tokens out ${t.done?.tokensOut}; cards ${JSON.stringify(t.cards.filter(c => c.state !== 'running').map(c => c.name))}; writes done ${t.writesDone}; cut notes ${t.cutNotes.length}; draft frames ${t.draftStats.frames}`);
|
||||
note(`long${rep}: the turn did not end silently`, !!f || t.cutNotes.length > 0 || t.text.trim().length > 0, `text tail ${JSON.stringify(t.text.slice(-160))}`);
|
||||
}
|
||||
|
||||
if (want('cut')) {
|
||||
// A small window told to the Harness (E2E_CUT_WINDOW, default 24000; the engine's own stays WINDOW) makes the reply budget about 7K tokens (raised to
|
||||
// 12K by the fix), so the first reply is cut inside the tool call, as the pagoda's was at 19,660.
|
||||
await bridge.close();
|
||||
bridge = makeBridge(Number(env('E2E_CUT_WINDOW', '24000')), 'agent-cut');
|
||||
const t = await turn('cut', longPrompt, { thinking: 'Off' });
|
||||
const f = t.files.find(x => x.name === 'big.txt');
|
||||
const lines = f ? readFileSync(join(t.workspace, 'big.txt'), 'utf8').split(String.fromCharCode(10)).filter(Boolean).length : 0;
|
||||
console.log(` cut: ${t.seconds.toFixed(0)} s, frames ${JSON.stringify(t.byType)}, done ${JSON.stringify(t.done && { finishReason: t.done.finishReason })}, files ${JSON.stringify(t.files)}, cut notes ${JSON.stringify(t.cutNotes)}, draft ${JSON.stringify(t.draftStats)}`);
|
||||
note('cut: the chat said the reply was cut, in words', t.cutNotes.length > 0 && t.cutNotes.every(n => /length limit/.test(n)), JSON.stringify(t.cutNotes));
|
||||
note('cut: the agent went on and the file exists', !!f, `file ${f ? f.bytes + ' B, ' + lines + ' lines' : 'none'}; finish ${t.done?.finishReason}`);
|
||||
}
|
||||
|
||||
writeFileSync(join(OUT, 'result.json'), JSON.stringify(rows, null, 2));
|
||||
console.log('\n' + rows.map(r => `${r.ok ? 'PASS' : 'FAIL'} ${r.id}`).join('\n'));
|
||||
await finish(rows.some(r => !r.ok) ? 1 : 0);
|
||||
@@ -16,10 +16,15 @@ import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const APP = join(ROOT, 'src/styles/app.css');
|
||||
const STYLE_DIR = join(ROOT, 'src/styles');
|
||||
const KIT_SRC = join(ROOT, '..', 'ui');
|
||||
const KIT_COPY = join(ROOT, 'src/styles/kit');
|
||||
|
||||
/** The app's own stylesheets, in cascade order: the last one wins. */
|
||||
const APP_SHEETS = ['app.css', 'liquid.css']
|
||||
.map((name) => ({ name, css: existsSync(join(STYLE_DIR, name)) ? readFileSync(join(STYLE_DIR, name), 'utf8') : null }))
|
||||
.filter((sheet) => sheet.css !== null);
|
||||
|
||||
const KIT_SCALES = {
|
||||
radius: new Set(['8px', '14px', '20px', '24px', '9999px', '999px', '50%', '6px', '10px', '12px', '15px', '16px', '2px', '1.5px', 'inherit', '0']),
|
||||
fontSize: new Set(['11px', '12px', '13px', '14px', '15px', '16px', '17px', '18px', '19px', '22px', '24px', '30px', '32px', '40px', 'inherit']),
|
||||
@@ -66,9 +71,12 @@ const BLUR_ALLOWED = new Set([
|
||||
'.menu',
|
||||
'.telepop',
|
||||
'.scrim',
|
||||
".scrim[data-open='true']", // the same scrim, once it is showing
|
||||
'.dialog-scrim', // the sheet's scrim: it frosts the whole working screen
|
||||
'.config-savebar', // floating commit controls
|
||||
'.mobile-bar', // compact navigation controls
|
||||
'.st-toast--toned',
|
||||
'.nav', // the navigation layer over the ambient depth layer
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -83,6 +91,7 @@ const ELEVATION_ALLOWED = new Set([
|
||||
'.app[data-nav-open=\'true\'] > .nav', // dismissible navigation sheet
|
||||
'.st-tab[aria-checked=\'true\']', // selected segmented control
|
||||
'.nav-new', // primary action affordance
|
||||
'.nav-new.st-btn--primary', // the same affordance, with its accent recipe
|
||||
'.st-drawer.sheet',
|
||||
'.slashmenu',
|
||||
'.select__pop',
|
||||
@@ -95,9 +104,12 @@ const ELEVATION_ALLOWED = new Set([
|
||||
// The jump-to-latest pill sits above a scrolling transcript, so it genuinely
|
||||
// overlaps moving content — the same test the other entries pass.
|
||||
'.jumplatest',
|
||||
'.quote-selection', // the Quote button that floats beside selected words (#4785)
|
||||
'.st-btn--primary', // the accent fill's own lift
|
||||
'.st-slider::-webkit-slider-thumb', // a control affordance: the thumb needs to read against its track
|
||||
'.st-toggle::after', // same: the knob needs to read against its track
|
||||
'.family-switch button[aria-selected="true"]', // a selected segmented control, like .st-tab
|
||||
'.native-choice__col .native-resource-choice button[data-selected="true"]', // same
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -110,25 +122,61 @@ const CAPSULE_CONTROLS = ['.st-btn', '.st-input', '.st-tab', '.navitem', '.navse
|
||||
/** Literal colours that are deliberate and documented. */
|
||||
const DOCUMENTED_LITERALS = ['#05261b', '#12c68e', '#0a7a55'];
|
||||
|
||||
/**
|
||||
* The only selector allowed to paint a gradient: the single ambient depth layer.
|
||||
*
|
||||
* The flat-surface rule exists so surfaces read as paper rather than decoration.
|
||||
* Depth was the one thing it could not express, and the navigation material has
|
||||
* nothing to sample over a flat fill. A fixed, two-field wash on the page — too
|
||||
* quiet to read as a gradient at ~1.04:1 — is the smallest thing that makes the
|
||||
* glass mean anything. It is body-only, it never moves, and both reduced-
|
||||
* transparency gates remove it. See src/styles/liquid.css §1.
|
||||
*/
|
||||
const AMBIENT_SELECTORS = new Set(['body']);
|
||||
|
||||
const failures = [];
|
||||
const notes = [];
|
||||
const fail = (file, line, message) => failures.push(`${file}:${line} ${message}`);
|
||||
|
||||
/* ------------------------------------------------------------ app.css ---- */
|
||||
/* ------------------------------------------------------------ app sheets -- */
|
||||
|
||||
const css = readFileSync(APP, 'utf8');
|
||||
const lines = css.split('\n');
|
||||
let selector = '(top level)';
|
||||
let inKeyframes = false;
|
||||
/**
|
||||
* Scan one app stylesheet for the design-system, motion and material rules.
|
||||
*
|
||||
* Run over every sheet in the cascade, not only app.css: a rule appended in a
|
||||
* later file ships just the same, and the "audit palette" block that broke AA on
|
||||
* the primary button survived precisely because it had never been scanned.
|
||||
*/
|
||||
function scanStylesheet(file, css) {
|
||||
const lines = css.split('\n');
|
||||
let selector = '(top level)';
|
||||
let inKeyframes = false;
|
||||
|
||||
lines.forEach((raw, i) => {
|
||||
const line = raw.replace(/\/\*.*?\*\//g, '');
|
||||
const n = i + 1;
|
||||
lines.forEach((raw, i) => {
|
||||
const line = raw.replace(/\/\*.*?\*\//g, '');
|
||||
const n = i + 1;
|
||||
|
||||
const sel = line.match(/^([^@{}\s][^{}]*)\{$/);
|
||||
// Selectors are usually indented inside a media query or a nested block, so
|
||||
// the line is trimmed before it is recognised. Without this every indented
|
||||
// rule was attributed to "(top level)", and the material and elevation
|
||||
// allowlists — which match on the selector — rejected most of the file.
|
||||
const sel = line.trim().match(/^([^@{}\s][^{}]*)\{$/);
|
||||
if (sel) selector = sel[1].trim();
|
||||
if (/@keyframes/.test(line)) inKeyframes = true;
|
||||
if (inKeyframes && /^\}/.test(line)) inKeyframes = false;
|
||||
// A whole rule on one line (`a, b { … }`) never ends with `{`, so it used to
|
||||
// be attributed to whatever rule ran before it — which is why a shadow on a
|
||||
// segmented control was reported against `.menu .response-stats`. The rule's
|
||||
// own selector is used for its declarations.
|
||||
let selectorForLine = selector;
|
||||
if (!sel && !inKeyframes) {
|
||||
const inline = line.trim().match(/^([^@{}][^{}]*?)\s*\{/);
|
||||
if (inline && /\}/.test(line)) selectorForLine = inline[1].trim();
|
||||
}
|
||||
// A one-line `@keyframes name { … }` opens and closes on the same line, so it
|
||||
// must not switch the tracker on: it never sees a closing brace at column 0,
|
||||
// and every rule after it was then treated as part of an animation — which
|
||||
// disabled the one-line selector attribution above.
|
||||
if (/@keyframes/.test(line) && !/\}/.test(line)) inKeyframes = true;
|
||||
if (inKeyframes && /^\}/.test(raw)) inKeyframes = false;
|
||||
|
||||
for (const [, prop, rawValue] of line.matchAll(/([a-z-]+)\s*:\s*([^;]+);/g)) {
|
||||
const value = rawValue.trim();
|
||||
@@ -137,24 +185,24 @@ lines.forEach((raw, i) => {
|
||||
if (prop === 'border-radius') {
|
||||
for (const part of value.split(/\s+/)) {
|
||||
if (!KIT_SCALES.radius.has(part) && !part.startsWith('var(') && !part.includes('%')) {
|
||||
fail('app.css', n, `off-scale radius "${part}" in ${selector}`);
|
||||
fail(file, n, `off-scale radius "${part}" in ${selectorForLine}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (prop === 'font-size' && !value.startsWith('var(') && !value.includes('clamp') && !KIT_SCALES.fontSize.has(value)) {
|
||||
fail('app.css', n, `off-scale font-size "${value}" in ${selector}`);
|
||||
fail(file, n, `off-scale font-size "${value}" in ${selectorForLine}`);
|
||||
}
|
||||
if (prop === 'box-shadow' && !KIT_SCALES.shadow.some((re) => re.test(value))) {
|
||||
fail('app.css', n, `shadow is not a kit token: "${value}" in ${selector}`);
|
||||
fail(file, n, `shadow is not a kit token: "${value}" in ${selectorForLine}`);
|
||||
}
|
||||
if (/gradient|radial|conic/.test(value) && prop.startsWith('background')) {
|
||||
fail('app.css', n, `gradient in ${selector} — the system is flat`);
|
||||
if (/gradient|radial|conic/.test(value) && prop.startsWith('background') && !AMBIENT_SELECTORS.has(selector)) {
|
||||
fail(file, n, `gradient in ${selectorForLine} — the system is flat`);
|
||||
}
|
||||
if (/^(color|background|background-color|border-color)$/.test(prop)) {
|
||||
const literals = value.match(/#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)/g);
|
||||
if (literals && !value.includes('data:image') && !value.includes('currentColor')
|
||||
&& !DOCUMENTED_LITERALS.some((d) => value.includes(d))) {
|
||||
fail('app.css', n, `literal colour "${value}" in ${selector} — use a token`);
|
||||
fail(file, n, `literal colour "${value}" in ${selectorForLine} — use a token`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,57 +211,70 @@ lines.forEach((raw, i) => {
|
||||
// Values must be a --st-material-* token: that is what collapses the blur
|
||||
// under reduced transparency, so gating cannot be forgotten.
|
||||
if (prop === 'backdrop-filter' || prop === '-webkit-backdrop-filter') {
|
||||
if (!/^var\(--st-material-(thin|regular|thick)\)$/.test(value) && !(selector === '.scrim' && value === 'none !important')) {
|
||||
fail('app.css', n, `blur must use a --st-material-* token (it carries the gating): "${value}"`);
|
||||
if (!/^var\(--st-material-(thin|regular|thick)\)( !important)?$/.test(value) && !(selector === '.scrim' && value === 'none !important')) {
|
||||
fail(file, n, `blur must use a --st-material-* token (it carries the gating): "${value}"`);
|
||||
}
|
||||
if (!BLUR_ALLOWED.has(selector)) {
|
||||
fail('app.css', n, `material on "${selector}", which is not an approved surface — it does not overlay moving content`);
|
||||
if (!BLUR_ALLOWED.has(selectorForLine)) {
|
||||
fail(file, n, `material on "${selectorForLine}", which is not an approved surface — it does not overlay moving content`);
|
||||
}
|
||||
}
|
||||
if (prop === 'box-shadow' && /var\(--st-sh-(card|bar|pop)\)/.test(value) && !ELEVATION_ALLOWED.has(selector)) {
|
||||
fail('app.css', n, `elevation shadow on "${selector}" — shadows are reserved for surfaces that overlap content`);
|
||||
if (prop === 'box-shadow' && /var\(--st-sh-(card|bar|pop)\)/.test(value) && !ELEVATION_ALLOWED.has(selectorForLine)) {
|
||||
fail(file, n, `elevation shadow on "${selectorForLine}" — shadows are reserved for surfaces that overlap content`);
|
||||
}
|
||||
|
||||
if (prop === 'transition') {
|
||||
if (/^\d/.test(value) || /(^|[\s,])\d+m?s\b/.test(value)) {
|
||||
fail('app.css', n, `raw duration in transition — use a --st-t-* token: "${value}"`);
|
||||
fail(file, n, `raw duration in transition — use a --st-t-* token: "${value}"`);
|
||||
}
|
||||
for (const part of value.split(',')) {
|
||||
const prop0 = part.trim().split(/\s+/)[0];
|
||||
if (LAYOUT_PROPS.includes(prop0)) {
|
||||
fail('app.css', n, `transition on layout property "${prop0}" in ${selector}`);
|
||||
fail(file, n, `transition on layout property "${prop0}" in ${selectorForLine}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (prop === 'transition-property') {
|
||||
for (const p of value.split(',').map((v) => v.trim())) {
|
||||
if (LAYOUT_PROPS.includes(p)) fail('app.css', n, `transition on layout property "${p}" in ${selector}`);
|
||||
if (LAYOUT_PROPS.includes(p)) fail(file, n, `transition on layout property "${p}" in ${selectorForLine}`);
|
||||
}
|
||||
}
|
||||
if (prop === 'animation' || prop === 'animation-name') {
|
||||
const name = value.match(/^(st-[a-z-]+)/)?.[1];
|
||||
const infinite = /infinite/.test(value);
|
||||
if (infinite && name && !INFINITE_OK.has(name)) {
|
||||
fail('app.css', n, `looping animation "${name}" is not on the allowlist — nothing loops while idle`);
|
||||
fail(file, n, `looping animation "${name}" is not on the allowlist — nothing loops while idle`);
|
||||
}
|
||||
const duration = value.match(/\b(\d+(?:\.\d+)?m?s)\b/)?.[1];
|
||||
if (duration && !LOOP_DURATION_OK.has(duration)) {
|
||||
fail('app.css', n, `raw animation duration "${duration}" in ${selector} — use a --st-t-* token`);
|
||||
fail(file, n, `raw animation duration "${duration}" in ${selectorForLine} — use a --st-t-* token`);
|
||||
}
|
||||
}
|
||||
if (prop === 'animation-duration' && /^\d/.test(value) && !LOOP_DURATION_OK.has(value)) {
|
||||
fail('app.css', n, `raw animation-duration "${value}" in ${selector}`);
|
||||
fail(file, n, `raw animation-duration "${value}" in ${selectorForLine}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const sheet of APP_SHEETS) scanStylesheet(sheet.name, sheet.css);
|
||||
|
||||
const css = APP_SHEETS.find((sheet) => sheet.name === 'app.css')?.css ?? '';
|
||||
const allAppCss = APP_SHEETS.map((sheet) => sheet.css).join('\n');
|
||||
|
||||
/* ------------------------------------------------- kit sync + motion scale -- */
|
||||
|
||||
for (const file of ['tokens.css', 'components.css']) {
|
||||
const a = join(KIT_SRC, file);
|
||||
const b = join(KIT_COPY, file);
|
||||
if (!existsSync(a) || !existsSync(b)) {
|
||||
fail('kit', 0, `${file} missing on one side`);
|
||||
// Some checkouts of this branch do not carry the design-kit source (apps/ui),
|
||||
// only the synced copy. There is nothing to compare against then, so the check
|
||||
// reports that instead of failing every run with a condition no one can fix.
|
||||
if (!existsSync(a)) {
|
||||
notes.push(`kit copy: ${file} has no source in this checkout — drift check skipped`);
|
||||
continue;
|
||||
}
|
||||
if (!existsSync(b)) {
|
||||
fail('kit', 0, `${file} missing from src/styles/kit — run npm run sync:kit`);
|
||||
continue;
|
||||
}
|
||||
// The sync rewrites @font-face URLs to absolute; everything else must match.
|
||||
@@ -223,10 +284,11 @@ for (const file of ['tokens.css', 'components.css']) {
|
||||
}
|
||||
}
|
||||
|
||||
// Which selectors have been given the capsule radius.
|
||||
// Which selectors have been given the capsule radius. Checked across the whole
|
||||
// cascade: a control is a capsule wherever the rule that says so lives.
|
||||
const capsuled = new Set();
|
||||
{
|
||||
const rules = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
const rules = allAppCss.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
for (const [, sel, body] of rules.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
||||
if (!/border-radius:\s*var\(--st-r-pill\)/.test(body)) continue;
|
||||
for (const part of sel.split(',')) capsuled.add(part.trim());
|
||||
@@ -263,7 +325,7 @@ const referenced = new Set();
|
||||
for (const file of [...readdirSync(join(ROOT, 'src'), { recursive: true })].filter((f) => /\.tsx$/.test(String(f)))) {
|
||||
const full = join(ROOT, 'src', String(file));
|
||||
if (!statSync(full).isFile()) continue;
|
||||
for (const m of readFileSync(full, 'utf8').matchAll(/name="([a-z-]+)"/g)) referenced.add(m[1]);
|
||||
for (const m of readFileSync(full, 'utf8').matchAll(/<(?:Icon|IconBtn)\s[^>]*?\bname="([a-z-]+)"/g)) referenced.add(m[1]);
|
||||
}
|
||||
for (const name of referenced) {
|
||||
if (!symbols.has(name)) fail('src', 0, `icon "${name}" is referenced but not in the sprite — run npm run sync:kit`);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Where the Chromium binary the probes drive actually lives.
|
||||
*
|
||||
* The probes launched a fixed macOS cache path, so on Windows and Linux they
|
||||
* printed "No cached Chromium found" and every runtime gate (contrast, measure,
|
||||
* luminance, motion) was skipped rather than run. Resolution is now a single
|
||||
* place with the same order everywhere:
|
||||
*
|
||||
* 1. STRATA_CHROME — an explicit override, for a CI image or a second Chrome
|
||||
* 2. the cached Playwright build (macOS, the original platforms)
|
||||
* 3. an installed browser: Chrome, Edge, Chromium
|
||||
*
|
||||
* `requireChromium` exits with the harness message the scripts already used, so
|
||||
* a machine with no browser at all still fails loudly instead of silently
|
||||
* passing a gate.
|
||||
*/
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
const PLAYWRIGHT_MAC = [
|
||||
join(homedir(), 'Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
|
||||
join(homedir(), 'Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell'),
|
||||
];
|
||||
|
||||
const INSTALLED = [
|
||||
// Windows
|
||||
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
|
||||
'C:/Program Files/Microsoft/Edge/Application/msedge.exe',
|
||||
// macOS
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
||||
// Linux
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/google-chrome-stable',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
];
|
||||
|
||||
export function findChromium() {
|
||||
const candidates = [process.env.STRATA_CHROME, ...PLAYWRIGHT_MAC, ...INSTALLED].filter(Boolean);
|
||||
return candidates.find((path) => existsSync(path)) ?? null;
|
||||
}
|
||||
|
||||
export function requireChromium() {
|
||||
const path = findChromium();
|
||||
if (!path) {
|
||||
console.error('No Chromium found. Set STRATA_CHROME, or install Chrome, Edge or Chromium.');
|
||||
process.exit(2);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Throwaway: serves the demo host unchanged, except POST /api/chat/stream, which streams a long scripted reply (NDJSON frames like the real host).
|
||||
import http from 'node:http';
|
||||
const UP = Number(process.env.UP ?? 18791), PORT = Number(process.env.PORT ?? 18790);
|
||||
const TOK = Number(process.env.REPLY_TOKENS ?? 12000), REASON = Number(process.env.REASON_TOKENS ?? 3000), RATE = Number(process.env.RATE ?? 400);
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
const para = i => `### Section ${i}\n\nThis is paragraph ${i} with **bold**, *italic*, \`inline code\` and a link [x](https://example.com/${i}). The measured value is about ${i} units and the results agree with the earlier runs, which is what we expected from the model.
|
||||
|
||||
More prose follows here so that a paragraph looks like real writing and not like a stress pattern.\n\n- item one of ${i}\n- item two of ${i}\n\n\`\`\`python\ndef f_${i}(a, b):\n total = a + b * ${i}\n return total\n\`\`\`\n\n| a | b |\n|---|---|\n| ${i} | ${i * 2} |\n\n`;
|
||||
http.createServer(async (req, res) => {
|
||||
if (req.method === 'POST' && req.url.startsWith('/api/chat/stream')) {
|
||||
let raw = ''; for await (const c of req) raw += c;
|
||||
res.writeHead(200, { 'content-type': 'application/x-ndjson' });
|
||||
const w = o => res.write(JSON.stringify(o) + '\n');
|
||||
w({ type: 'start', model: 'Mock', contextMax: 131072, requestId: 'r' + Date.now() });
|
||||
let sent = 0, i = 0;
|
||||
const per = Math.max(1, Math.round(RATE / 50));
|
||||
while (sent < REASON) { w({ type: 'reasoning', text: 'thinking about step ' + (i++) + ' of the problem. '.repeat(1) }); sent += 9; if (sent % per < 9) await sleep(20); }
|
||||
sent = 0; i = 0;
|
||||
while (sent < TOK) { const p = para(i++); for (let k = 0; k < p.length; k += 24) { w({ type: 'delta', text: p.slice(k, k + 24) }); sent += 6; if ((sent / 6) % Math.max(1, Math.round(per / 2)) === 0) await sleep(10); } w({ type: 'stats', tokensOut: sent, tps: 90 }); }
|
||||
w({ type: 'done', tokensIn: 100, tokensOut: sent, tps: 90, durationMs: 1000, usageSource: 'engine' });
|
||||
return res.end();
|
||||
}
|
||||
const up = http.request({ host: '127.0.0.1', port: UP, path: req.url, method: req.method, headers: req.headers }, r => { res.writeHead(r.statusCode, r.headers); r.pipe(res); });
|
||||
up.on('error', () => { res.writeHead(502); res.end(); });
|
||||
req.pipe(up);
|
||||
}).listen(PORT, '127.0.0.1', () => console.log('proxy on', PORT));
|
||||
@@ -0,0 +1,57 @@
|
||||
// Measures the chat page's heap, DOM size and main-thread time per streamed reply. Needs a throwaway host (STRATA_REAL_ENGINE=0, port 18791), scripts/chat-growth-proxy.mjs (port 18790) and Chrome.
|
||||
import { chromium } from 'playwright-core';
|
||||
import { deflateSync } from 'node:zlib';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { execSync } from 'node:child_process';
|
||||
const BASE = process.env.BASE ?? 'http://127.0.0.1:18790';
|
||||
const TURNS = Number(process.env.TURNS ?? 6), PICS = Number(process.env.PICS ?? 3), MON = process.env.MON !== '0';
|
||||
const crc = (() => { const t = new Int32Array(256).map((_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c; }); return b => { let c = -1; for (const x of b) c = t[(c ^ x) & 255] ^ (c >>> 8); return (c ^ -1) >>> 0; }; })();
|
||||
function png(w, h) {
|
||||
const chunk = (type, data) => { const len = Buffer.alloc(4); len.writeUInt32BE(data.length); const body = Buffer.concat([Buffer.from(type), data]); const c = Buffer.alloc(4); c.writeUInt32BE(crc(body)); return Buffer.concat([len, body, c]); };
|
||||
const raw = Buffer.alloc((w * 3 + 1) * h); for (let y = 0; y < h; y++) { raw[y * (w * 3 + 1)] = 0; randomBytes(w * 3).copy(raw, y * (w * 3 + 1) + 1); }
|
||||
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
|
||||
return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw, { level: 1 })), chunk('IEND', Buffer.alloc(0))]);
|
||||
}
|
||||
const browser = await chromium.launch({ executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe', headless: true, args: ['--enable-precise-memory-info', '--js-flags=--expose-gc'] });
|
||||
const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
const cdp = await ctx.newCDPSession(page);
|
||||
await cdp.send('Performance.enable');
|
||||
await fetch(BASE + '/api/model-providers', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind: 'custom', provider: null, name: 'Mock model', baseUrl: 'http://127.0.0.1:18898/v1', protocol: 'openai-chat', apiKey: '', models: [{ id: 'mock-model', name: 'Mock model' }] }) });
|
||||
await page.goto(BASE + '/'); await page.waitForTimeout(1500);
|
||||
const skip = page.getByText('Go straight to the app'); await skip.waitFor({ timeout: 10000 }).catch(() => {}); if (await skip.count()) { await skip.click(); await page.waitForTimeout(800); }
|
||||
async function metrics(tag) {
|
||||
await cdp.send('HeapProfiler.collectGarbage').catch(() => {});
|
||||
const m = Object.fromEntries((await cdp.send('Performance.getMetrics')).metrics.map(x => [x.name, x.value]));
|
||||
const dom = await cdp.send('Memory.getDOMCounters').catch(() => ({}));
|
||||
const sess = await page.evaluate(() => { try { return (localStorage.getItem('strata.sessions.v1') ?? '').length; } catch { return -1; } });
|
||||
console.log(`${tag.padEnd(22)} heap=${(m.JSHeapUsedSize / 1048576).toFixed(0)}MB total=${(m.JSHeapTotalSize / 1048576).toFixed(0)}MB nodes=${dom.nodes ?? m.Nodes} listeners=${dom.jsEventListeners ?? '-'} docs=${m.Documents} ls=${(sess / 1e6).toFixed(2)}M`);
|
||||
return m.JSHeapUsedSize;
|
||||
}
|
||||
await page.locator('.chat-model-picker').first().click(); await page.locator('.model-picker-api').first().click(); await page.waitForTimeout(500);
|
||||
await metrics('start');
|
||||
const ta = page.locator('.composer textarea');
|
||||
for (let t = 1; t <= TURNS; t++) {
|
||||
if (t <= PICS) {
|
||||
const b64 = png(900, 900).toString('base64');
|
||||
await page.evaluate(b64 => { const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0)); const d = new DataTransfer(); d.items.add(new File([bytes], 'image.png', { type: 'image/png' })); document.querySelector('.composer textarea').dispatchEvent(new ClipboardEvent('paste', { clipboardData: d, bubbles: true, cancelable: true })); }, b64);
|
||||
await page.waitForTimeout(600);
|
||||
}
|
||||
await ta.fill('turn ' + t + ' write a long answer');
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
const t0 = Date.now();
|
||||
let peak = 0; const cpu0 = (await cdp.send('Performance.getMetrics')).metrics.find(x => x.name === 'TaskDuration').value;
|
||||
for (;;) {
|
||||
await page.waitForTimeout(2000);
|
||||
const m = Object.fromEntries((await cdp.send('Performance.getMetrics')).metrics.map(x => [x.name, x.value]));
|
||||
peak = Math.max(peak, m.JSHeapUsedSize);
|
||||
const busy = await page.getByRole('button', { name: /Stop/ }).count();
|
||||
if (!busy || Date.now() - t0 > 240000) break;
|
||||
}
|
||||
const cpu1 = (await cdp.send('Performance.getMetrics')).metrics.find(x => x.name === 'TaskDuration').value;
|
||||
console.log(` turn ${t}: ${(Date.now() - t0) / 1000}s peak-heap=${(peak / 1048576).toFixed(0)}MB main-thread-busy=${(cpu1 - cpu0).toFixed(1)}s`);
|
||||
await metrics('after turn ' + t);
|
||||
}
|
||||
if (MON) { await page.getByText('Monitor', { exact: true }).first().click().catch(() => console.log('no monitor link')); await page.waitForTimeout(25000); await metrics('monitor 25s'); }
|
||||
try { console.log(execSync('powershell -NoProfile -c "(Get-Process chrome | Measure-Object WorkingSet64 -Sum).Sum/1MB"').toString().trim(), 'MB chrome working set total'); } catch {}
|
||||
await browser.close();
|
||||
@@ -19,13 +19,11 @@ import { chromium } from 'playwright-core';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { requireChromium } from './browser.mjs';
|
||||
|
||||
const BASE = process.env.STRATA_URL ?? 'http://localhost:5199';
|
||||
|
||||
const executablePath = [
|
||||
join(homedir(), 'Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
|
||||
join(homedir(), 'Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell'),
|
||||
].find((p) => existsSync(p));
|
||||
const executablePath = requireChromium();
|
||||
|
||||
if (!executablePath) {
|
||||
console.error('No cached Chromium found. Run `npm run shot -- x.png <url>` once to confirm the harness.');
|
||||
|
||||
@@ -7,11 +7,9 @@ import { chromium } from 'playwright-core';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { requireChromium } from './browser.mjs';
|
||||
|
||||
const executablePath = [
|
||||
join(homedir(), 'Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
|
||||
join(homedir(), 'Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell'),
|
||||
].find((p) => existsSync(p));
|
||||
const executablePath = requireChromium();
|
||||
|
||||
const BASE = process.env.STRATA_URL ?? 'http://localhost:5199';
|
||||
const flow = process.argv[2] ?? 'chat';
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* LOAD-PICS end to end: the Models page of a running host, driven in a real Chrome (DevTools protocol, no packages), pressing "Load model" on a model that is
|
||||
* downloaded, with Read pictures on and the picture files not on the computer.
|
||||
*
|
||||
* HOST_URL=http://127.0.0.1:18700 CHROME=/usr/bin/google-chrome OUT=/tmp/lp-out node scripts/load-pictures-e2e.mjs
|
||||
*
|
||||
* It looks at the page, presses the button, and writes down once per half second what the page shows (the progress card's words and bar, any dialog, the job's kind
|
||||
* and state), until the job ends or LIMIT_S passes. It writes <OUT>/timeline.json and screenshots; the files on disk are checked by the shell script around it.
|
||||
*
|
||||
* Environment: HOST_URL (required), CHROME (default /usr/bin/google-chrome), OUT (default ./lp-out), LIMIT_S (default 900), CDP_PORT (default 9333),
|
||||
* MODEL (the version's name on the page, default "High quality"), BUTTON (default "Load model").
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const HOST = process.env.HOST_URL;
|
||||
if (!HOST) { console.error('HOST_URL is required'); process.exit(2); }
|
||||
const CHROME = process.env.CHROME ?? '/usr/bin/google-chrome';
|
||||
const OUT = process.env.OUT ?? './lp-out';
|
||||
const LIMIT = Number(process.env.LIMIT_S ?? 900) * 1000;
|
||||
const PORT = Number(process.env.CDP_PORT ?? 9333);
|
||||
const BUTTON = process.env.BUTTON ?? 'Load model';
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
const profile = mkdtempSync(join(tmpdir(), 'lp-chrome-'));
|
||||
const chrome = spawn(CHROME, ['--headless=new', '--no-sandbox', '--disable-gpu', `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`, '--window-size=1400,1100', 'about:blank'], { stdio: 'ignore' });
|
||||
const finish = code => { try { chrome.kill('SIGTERM'); } catch { /* gone */ } setTimeout(() => { try { rmSync(profile, { recursive: true, force: true }); } catch { /* best effort */ } process.exit(code); }, 800); };
|
||||
process.on('SIGINT', () => finish(130)); process.on('SIGTERM', () => finish(143));
|
||||
|
||||
async function target() {
|
||||
for (let i = 0; i < 80; i++) {
|
||||
try { const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json(); const page = list.find(t => t.type === 'page'); if (page) return page.webSocketDebuggerUrl; } catch { /* not up yet */ }
|
||||
await sleep(250);
|
||||
}
|
||||
throw new Error('Chrome did not start');
|
||||
}
|
||||
const ws = new WebSocket(await target());
|
||||
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = () => reject(new Error('DevTools socket')); });
|
||||
let seq = 0; const waiting = new Map();
|
||||
ws.onmessage = event => { const message = JSON.parse(event.data); const w = waiting.get(message.id); if (w) { waiting.delete(message.id); message.error ? w.reject(new Error(message.error.message)) : w.resolve(message.result); } };
|
||||
const send = (method, params = {}) => new Promise((resolve, reject) => { const id = ++seq; waiting.set(id, { resolve, reject }); ws.send(JSON.stringify({ id, method, params })); });
|
||||
const run = async expression => { const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.text + ' ' + (r.exceptionDetails.exception?.description ?? '')); return r.result.value; };
|
||||
const shot = async name => { const r = await send('Page.captureScreenshot', { format: 'png' }); writeFileSync(join(OUT, `${name}.png`), Buffer.from(r.data, 'base64')); };
|
||||
|
||||
try {
|
||||
await send('Page.enable');
|
||||
await send('Page.navigate', { url: `${HOST}/#/models` });
|
||||
// the page is up when the Load button is on it and enabled
|
||||
const ready = `(() => { const b = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === ${JSON.stringify(BUTTON)}); return !!b && !b.disabled; })()`;
|
||||
let up = false;
|
||||
for (let i = 0; i < 240 && !up; i++) { up = await run(ready).catch(() => false); if (!up) await sleep(500); }
|
||||
if (!up) { await shot('never-ready'); throw new Error(`The "${BUTTON}" button never came up`); }
|
||||
const before = await run(`(() => { const t = document.body.innerText; const box = document.querySelector('.native-image-reading input[type=checkbox]'); return { readPicturesOn: !!box && box.checked, offersFiles: t.includes('Picture reading needs a few more files'), downloadButton: [...document.querySelectorAll('button')].some(b => b.textContent.trim() === 'Download picture-reading files'), modelFiles: document.querySelector('.native-path')?.textContent ?? null, shownModel: document.querySelector('.native-version')?.innerText?.slice(0, 200) ?? null }; })()`);
|
||||
await shot('1-before');
|
||||
const baseDialogs = await run("document.querySelectorAll('[role=dialog], dialog[open]').length"); // dialogs the page always has (closed ones the app keeps mounted)
|
||||
const clicked = await run(`(() => { const b = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === ${JSON.stringify(BUTTON)}); b.click(); return true; })()`);
|
||||
const started = Date.now();
|
||||
const timeline = []; let last = '', shots = 0, ended = null;
|
||||
while (Date.now() - started < LIMIT) {
|
||||
const seen = await run(`(async () => {
|
||||
const card = document.querySelector('.native-progress');
|
||||
const state = await (await fetch('/api/native')).json();
|
||||
const job = [...(state.jobs ?? [])].reverse()[0] ?? null;
|
||||
return { card: card ? card.innerText.replace(/\\s+/g, ' ').trim() : null, dialogs: document.querySelectorAll('[role=dialog], dialog[open]').length - ${baseDialogs},
|
||||
job: job && { startedAt: job.startedAt, kind: job.kind, state: job.state, summary: job.summary, error: job.error, details: job.details ?? null, bar: job.result?.install?.download ?? null },
|
||||
instance: (state.instances ?? []).map(i => ({ status: i.status, vision: i.vision, note: i.picturesNote ?? null, gpus: i.gpus })),
|
||||
note: [...document.querySelectorAll('.native-spill-note')].map(n => n.innerText.trim()), error: [...document.querySelectorAll('.inline-error, [role=alert]')].map(n => n.innerText.trim()).filter(Boolean) };
|
||||
})()`);
|
||||
const key = JSON.stringify([seen.card, seen.dialogs, seen.job?.kind, seen.job?.state, seen.job?.summary, seen.instance, seen.note, seen.error, Math.floor((seen.job?.bar?.percent ?? 0) / 25)]);
|
||||
if (key !== last) { last = key; timeline.push({ t: +((Date.now() - started) / 1000).toFixed(1), ...seen }); if (seen.card && shots < 2 && seen.job?.state === 'running') await shot(`2-running-${++shots}`); }
|
||||
if (seen.job && Date.parse(seen.job.startedAt) >= started - 2000 && seen.job.state !== 'running') { ended = seen.job.state; break; }
|
||||
await sleep(500);
|
||||
}
|
||||
await sleep(1500);
|
||||
await shot('3-after');
|
||||
const after = await run(`(() => ({ text: [...document.querySelectorAll('.native-spill-note, .native-ready, .toast, [role=status]')].map(n => n.innerText.trim()).filter(Boolean), buttons: [...document.querySelectorAll('.native-model-actions button')].map(b => b.textContent.trim()), pictureOffer: document.body.innerText.includes('Picture reading needs a few more files') }))()`);
|
||||
const posted = await run("fetch('/__fixture/posted').then(r => r.ok ? r.json() : null).catch(() => null)"); // only the UI-preview fixture keeps what the page posted
|
||||
writeFileSync(join(OUT, 'timeline.json'), JSON.stringify({ clicked, posted, before, ended, seconds: +((Date.now() - started) / 1000).toFixed(1), timeline, after }, null, 1));
|
||||
console.log(JSON.stringify({ ended, seconds: +((Date.now() - started) / 1000).toFixed(1), steps: timeline.length, before, after }));
|
||||
finish(ended === 'done' ? 0 : 1);
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
writeFileSync(join(OUT, 'error.txt'), String(error.stack ?? error));
|
||||
finish(2);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { chromium } from 'playwright-core';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { requireChromium } from './browser.mjs';
|
||||
|
||||
const BASE = process.env.STRATA_URL ?? 'http://localhost:5199';
|
||||
/** Anything at or above this projected area is "large". Roughly a 64×64 block. */
|
||||
@@ -43,10 +44,7 @@ const MIN_THICKNESS = 32;
|
||||
/** Ceiling for the relative luminance of a large surface, in dark mode. */
|
||||
const CEILING = 0.15;
|
||||
|
||||
const executablePath = [
|
||||
join(homedir(), 'Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
|
||||
join(homedir(), 'Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell'),
|
||||
].find((p) => existsSync(p));
|
||||
const executablePath = requireChromium();
|
||||
|
||||
if (!executablePath) {
|
||||
console.error('No cached Chromium found.');
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Writes src/locales/df2-<lang>.json: the strings of the Drafting choice that came with the DFlash2 option (branch f-0291-df2-option): the plain-words
|
||||
paragraph under the dropdown, the three lines about the person's graphics card, the "not on this card" and "no internet" lines, and the SSD line of the
|
||||
This computer panel.
|
||||
Run from apps/host: python scripts/make-df2-locales.py
|
||||
Every entry names all nine languages; the keys are df01.. in the order of S. df2-en.json is the English source. Placeholders ({0}) must be kept in every
|
||||
language. A separate file keeps this work from colliding with the other catalogs (the same reason pick-, tune-, tools-, fix-, gh-, ui- and engine- exist).
|
||||
The English lines of df03-df07 and df09 are also written in strata_control/drafting.py and strata_control/drafting_fetch.py; tests/drafting.test.mjs keeps the two the same."""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
LANGS = ["en", "zh", "ja", "de", "es", "ru", "pt", "ko", "fr"]
|
||||
S = []
|
||||
|
||||
|
||||
def add(en, zh, ja, de, es, ru, pt, ko, fr):
|
||||
S.append(dict(en=en, zh=zh, ja=ja, de=de, es=es, ru=ru, pt=pt, ko=ko, fr=fr))
|
||||
|
||||
|
||||
add("MTP is the default. DFlash2 is another way to guess the next words ahead, for Qwen3.8-Flash-Next only. On an RTX PRO 5000 it was 7.8% faster than MTP "
|
||||
"on 19 short prompts and 1.1% faster on long thinking, and faster on 21 of 23 prompts. On the short prompts the answers were identical.",
|
||||
"默认使用 MTP。DFlash2 是另一种提前猜测后续词语的方式,仅适用于 Qwen3.8-Flash-Next。在 RTX PRO 5000 上,它在 19 个短提示上比 MTP 快 7.8%,在长篇思考上快 1.1%,"
|
||||
"并且在 23 个提示中有 21 个更快。在短提示上,回答完全相同。",
|
||||
"既定では MTP を使います。DFlash2 は次の語を先読みするもう一つの方法で、Qwen3.8-Flash-Next 専用です。RTX PRO 5000 では、短いプロンプト 19 件で MTP より 7.8% 速く、"
|
||||
"長い思考では 1.1% 速く、23 件中 21 件で速くなりました。短いプロンプトでは回答が同一でした。",
|
||||
"MTP ist die Voreinstellung. DFlash2 ist eine andere Art, die nächsten Wörter vorauszuraten, nur für Qwen3.8-Flash-Next. Auf einer RTX PRO 5000 war es bei 19 kurzen "
|
||||
"Prompts 7,8 % schneller als MTP, bei langem Nachdenken 1,1 % schneller und bei 21 von 23 Prompts schneller. Bei den kurzen Prompts waren die Antworten identisch.",
|
||||
"MTP es la opción predeterminada. DFlash2 es otra forma de adivinar las siguientes palabras por adelantado, solo para Qwen3.8-Flash-Next. En una RTX PRO 5000 fue un 7,8 % "
|
||||
"más rápido que MTP en 19 prompts cortos y un 1,1 % más rápido en razonamientos largos, y más rápido en 21 de 23 prompts. En los prompts cortos las respuestas fueron idénticas.",
|
||||
"По умолчанию используется MTP. DFlash2 — другой способ угадывать следующие слова наперёд, только для Qwen3.8-Flash-Next. На RTX PRO 5000 он был на 7,8 % быстрее MTP "
|
||||
"на 19 коротких запросах и на 1,1 % быстрее на длинных рассуждениях, а на 21 из 23 запросов — быстрее. На коротких запросах ответы были идентичны.",
|
||||
"MTP é o padrão. DFlash2 é outra forma de adivinhar as próximas palavras antecipadamente, só para o Qwen3.8-Flash-Next. Numa RTX PRO 5000 foi 7,8 % mais rápido que o MTP "
|
||||
"em 19 prompts curtos e 1,1 % mais rápido em raciocínios longos, e mais rápido em 21 de 23 prompts. Nos prompts curtos as respostas foram idênticas.",
|
||||
"기본값은 MTP입니다. DFlash2는 다음 단어를 미리 추측하는 또 다른 방식이며 Qwen3.8-Flash-Next 전용입니다. RTX PRO 5000에서 짧은 프롬프트 19개에서는 MTP보다 7.8% 빨랐고, "
|
||||
"긴 사고 과정에서는 1.1% 빨랐으며, 23개 중 21개 프롬프트에서 더 빨랐습니다. 짧은 프롬프트에서는 답변이 동일했습니다.",
|
||||
"MTP est le réglage par défaut. DFlash2 est une autre façon de deviner à l'avance les mots suivants, uniquement pour Qwen3.8-Flash-Next. Sur une RTX PRO 5000, il a été 7,8 % "
|
||||
"plus rapide que MTP sur 19 prompts courts et 1,1 % plus rapide en réflexion longue, et plus rapide sur 21 prompts sur 23. Sur les prompts courts, les réponses étaient identiques.")
|
||||
add("It downloads about 1.2 GB once, works with one graphics card and one request at a time, and uses MTP otherwise.",
|
||||
"它会一次性下载约 1.2 GB,仅在使用一张显卡且一次处理一个请求时有效,其他情况下使用 MTP。",
|
||||
"約 1.2 GB を一度だけダウンロードします。グラフィックスカード 1 枚で一度に 1 件のリクエストを処理するときに動作し、それ以外では MTP を使います。",
|
||||
"Es lädt einmalig etwa 1,2 GB herunter, funktioniert mit einer Grafikkarte und einer Anfrage zur Zeit und nutzt sonst MTP.",
|
||||
"Descarga unos 1,2 GB una sola vez, funciona con una tarjeta gráfica y una solicitud a la vez, y en los demás casos usa MTP.",
|
||||
"Один раз скачивает около 1,2 ГБ, работает с одной видеокартой и одним запросом за раз, в остальных случаях используется MTP.",
|
||||
"Baixa cerca de 1,2 GB uma única vez, funciona com uma placa de vídeo e um pedido de cada vez, e nos outros casos usa o MTP.",
|
||||
"약 1.2 GB를 한 번만 내려받으며, 그래픽 카드 한 장과 한 번에 하나의 요청에서 작동하고, 그 외에는 MTP를 사용합니다.",
|
||||
"Il télécharge environ 1,2 Go une seule fois, fonctionne avec une carte graphique et une requête à la fois, et utilise MTP sinon.")
|
||||
add("On graphics cards like yours, DFlash2 was faster than MTP on some kinds of text and slower on others in our tests.",
|
||||
"在与您类似的显卡上,我们的测试中 DFlash2 在某些类型的文本上比 MTP 快,在另一些上更慢。",
|
||||
"お使いのようなグラフィックスカードでは、私たちのテストで DFlash2 は MTP より速い種類の文章と遅い種類の文章がありました。",
|
||||
"Auf Grafikkarten wie Ihrer war DFlash2 in unseren Tests bei manchen Textarten schneller als MTP und bei anderen langsamer.",
|
||||
"En tarjetas gráficas como la suya, en nuestras pruebas DFlash2 fue más rápido que MTP con algunos tipos de texto y más lento con otros.",
|
||||
"На видеокартах, подобных вашей, в наших тестах DFlash2 был быстрее MTP на одних видах текста и медленнее на других.",
|
||||
"Em placas de vídeo como a sua, nos nossos testes o DFlash2 foi mais rápido que o MTP em alguns tipos de texto e mais lento em outros.",
|
||||
"고객님과 비슷한 그래픽 카드에서 저희 테스트 결과 DFlash2는 일부 유형의 글에서는 MTP보다 빨랐고 다른 유형에서는 더 느렸습니다.",
|
||||
"Sur des cartes graphiques comme la vôtre, DFlash2 a été plus rapide que MTP sur certains types de texte et plus lent sur d'autres lors de nos tests.")
|
||||
add("On graphics cards like yours, DFlash2 was slower than MTP in our tests.",
|
||||
"在与您类似的显卡上,我们的测试中 DFlash2 比 MTP 更慢。",
|
||||
"お使いのようなグラフィックスカードでは、私たちのテストで DFlash2 は MTP より遅くなりました。",
|
||||
"Auf Grafikkarten wie Ihrer war DFlash2 in unseren Tests langsamer als MTP.",
|
||||
"En tarjetas gráficas como la suya, en nuestras pruebas DFlash2 fue más lento que MTP.",
|
||||
"На видеокартах, подобных вашей, в наших тестах DFlash2 был медленнее MTP.",
|
||||
"Em placas de vídeo como a sua, nos nossos testes o DFlash2 foi mais lento que o MTP.",
|
||||
"고객님과 비슷한 그래픽 카드에서 저희 테스트 결과 DFlash2는 MTP보다 느렸습니다.",
|
||||
"Sur des cartes graphiques comme la vôtre, DFlash2 a été plus lent que MTP lors de nos tests.")
|
||||
add("DFlash2 has not been tested on your graphics card yet.",
|
||||
"DFlash2 尚未在您的显卡上测试过。",
|
||||
"DFlash2 はまだお使いのグラフィックスカードでテストされていません。",
|
||||
"DFlash2 wurde auf Ihrer Grafikkarte noch nicht getestet.",
|
||||
"DFlash2 todavía no se ha probado en su tarjeta gráfica.",
|
||||
"DFlash2 ещё не проверялся на вашей видеокарте.",
|
||||
"O DFlash2 ainda não foi testado na sua placa de vídeo.",
|
||||
"DFlash2는 아직 고객님의 그래픽 카드에서 테스트되지 않았습니다.",
|
||||
"DFlash2 n'a pas encore été testé sur votre carte graphique.")
|
||||
add("Drafting: MTP (DFlash2 isn't available on this graphics card)",
|
||||
"预测方式:MTP(DFlash2 不适用于这张显卡)",
|
||||
"先読み:MTP(DFlash2 はこのグラフィックスカードでは使えません)",
|
||||
"Vorausraten: MTP (DFlash2 ist auf dieser Grafikkarte nicht verfügbar)",
|
||||
"Predicción: MTP (DFlash2 no está disponible en esta tarjeta gráfica)",
|
||||
"Упреждающая генерация: MTP (DFlash2 недоступен на этой видеокарте)",
|
||||
"Previsão: MTP (o DFlash2 não está disponível nesta placa de vídeo)",
|
||||
"미리 추측: MTP (이 그래픽 카드에서는 DFlash2를 사용할 수 없습니다)",
|
||||
"Anticipation : MTP (DFlash2 n'est pas disponible sur cette carte graphique)")
|
||||
add("There is no internet connection, so the DFlash2 drafter can't be downloaded now.",
|
||||
"没有网络连接,因此现在无法下载 DFlash2 草稿模型。",
|
||||
"インターネットに接続されていないため、今は DFlash2 のドラフターをダウンロードできません。",
|
||||
"Es besteht keine Internetverbindung, deshalb kann der DFlash2-Drafter jetzt nicht heruntergeladen werden.",
|
||||
"No hay conexión a internet, así que ahora no se puede descargar el borrador de DFlash2.",
|
||||
"Нет подключения к интернету, поэтому сейчас нельзя скачать черновую модель DFlash2.",
|
||||
"Não há ligação à internet, por isso não é possível transferir agora o rascunhador do DFlash2.",
|
||||
"인터넷에 연결되어 있지 않아 지금은 DFlash2 드래프터를 내려받을 수 없습니다.",
|
||||
"Il n'y a pas de connexion internet, le drafter DFlash2 ne peut donc pas être téléchargé pour le moment.")
|
||||
add("SSD: not needed",
|
||||
"SSD:不需要", "SSD:不要", "SSD: nicht nötig", "SSD: no hace falta", "SSD: не нужен", "SSD: não é necessário", "SSD: 필요 없음", "SSD : pas nécessaire")
|
||||
|
||||
add("DFlash2 answers one request at a time, so Strata set this model to one request at a time.",
|
||||
"DFlash2 一次只处理一个请求,因此 Strata 将此模型设置为一次一个请求。",
|
||||
"DFlash2 は一度に 1 件のリクエストしか処理しないため、Strata はこのモデルを一度に 1 件に設定しました。",
|
||||
"DFlash2 beantwortet eine Anfrage nach der anderen, deshalb hat Strata dieses Modell auf eine Anfrage zur Zeit gestellt.",
|
||||
"DFlash2 responde una solicitud a la vez, así que Strata configuró este modelo para una solicitud a la vez.",
|
||||
"DFlash2 отвечает на один запрос за раз, поэтому Strata настроила эту модель на один запрос за раз.",
|
||||
"O DFlash2 responde a um pedido de cada vez, por isso o Strata configurou este modelo para um pedido de cada vez.",
|
||||
"DFlash2는 한 번에 하나의 요청만 처리하므로 Strata가 이 모델을 한 번에 하나의 요청으로 설정했습니다.",
|
||||
"DFlash2 répond à une requête à la fois, Strata a donc réglé ce modèle sur une requête à la fois.")
|
||||
|
||||
add("Drafting: off (no drafter is downloaded yet)",
|
||||
"预测草稿:关闭(尚未下载任何草稿模型)",
|
||||
"先読み:オフ(ドラフトモデルがまだダウンロードされていません)",
|
||||
"Vorausraten: aus (noch kein Entwurfsbaustein heruntergeladen)",
|
||||
"Predicción: desactivada (aún no hay ningún predictor descargado)",
|
||||
"Предсказание: выключено (черновая модель ещё не скачана)",
|
||||
"Previsão: desativada (nenhum preditor foi baixado ainda)",
|
||||
"초안 예측: 꺼짐(초안 모델이 아직 내려받아지지 않았습니다)",
|
||||
"Anticipation : désactivée (aucun module d'anticipation n'est encore téléchargé)")
|
||||
add("Drafting: DFlash2 (the MTP drafter isn't downloaded yet)",
|
||||
"预测草稿:DFlash2(MTP 草稿模型尚未下载)",
|
||||
"先読み:DFlash2(MTP のドラフトモデルはまだダウンロードされていません)",
|
||||
"Vorausraten: DFlash2 (der MTP-Entwurfsbaustein ist noch nicht heruntergeladen)",
|
||||
"Predicción: DFlash2 (el predictor de MTP aún no está descargado)",
|
||||
"Предсказание: DFlash2 (черновая модель MTP ещё не скачана)",
|
||||
"Previsão: DFlash2 (o preditor do MTP ainda não foi baixado)",
|
||||
"초안 예측: DFlash2(MTP 초안 모델이 아직 내려받아지지 않았습니다)",
|
||||
"Anticipation : DFlash2 (le module d'anticipation MTP n'est pas encore téléchargé)")
|
||||
add("Choosing DFlash2 downloads its drafter: about {0} GB, once.",
|
||||
"选择 DFlash2 会下载它的草稿模型:约 {0} GB,只下载一次。",
|
||||
"DFlash2 を選ぶと、そのドラフトモデルを一度だけダウンロードします(約 {0} GB)。",
|
||||
"Wenn Sie DFlash2 wählen, wird sein Entwurfsbaustein heruntergeladen: etwa {0} GB, einmalig.",
|
||||
"Elegir DFlash2 descarga su predictor: unos {0} GB, una sola vez.",
|
||||
"Если выбрать DFlash2, будет скачана его черновая модель: около {0} ГБ, один раз.",
|
||||
"Escolher o DFlash2 baixa o preditor dele: cerca de {0} GB, uma vez.",
|
||||
"DFlash2를 고르면 그 초안 모델을 한 번 내려받습니다(약 {0}GB).",
|
||||
"Choisir DFlash2 télécharge son module d'anticipation : environ {0} Go, une seule fois.")
|
||||
add("Choosing MTP downloads its drafter: about {0} GB, once.",
|
||||
"选择 MTP 会下载它的草稿模型:约 {0} GB,只下载一次。",
|
||||
"MTP を選ぶと、そのドラフトモデルを一度だけダウンロードします(約 {0} GB)。",
|
||||
"Wenn Sie MTP wählen, wird sein Entwurfsbaustein heruntergeladen: etwa {0} GB, einmalig.",
|
||||
"Elegir MTP descarga su predictor: unos {0} GB, una sola vez.",
|
||||
"Если выбрать MTP, будет скачана его черновая модель: около {0} ГБ, один раз.",
|
||||
"Escolher o MTP baixa o preditor dele: cerca de {0} GB, uma vez.",
|
||||
"MTP를 고르면 그 초안 모델을 한 번 내려받습니다(약 {0}GB).",
|
||||
"Choisir MTP télécharge son module d'anticipation : environ {0} Go, une seule fois.")
|
||||
add("The DFlash2 drafter could not be downloaded, so the model starts without a drafter. Loading the model…",
|
||||
"无法下载 DFlash2 草稿模型,因此模型将不使用草稿模型启动。正在加载模型…",
|
||||
"DFlash2 のドラフトモデルをダウンロードできなかったため、ドラフトモデルなしで起動します。モデルを読み込んでいます…",
|
||||
"Der DFlash2-Entwurfsbaustein konnte nicht heruntergeladen werden, deshalb startet das Modell ohne Entwurfsbaustein. Das Modell wird geladen …",
|
||||
"No se pudo descargar el predictor de DFlash2, así que el modelo arranca sin predictor. Cargando el modelo…",
|
||||
"Не удалось скачать черновую модель DFlash2, поэтому модель запускается без черновой модели. Загружается модель…",
|
||||
"Não foi possível baixar o preditor do DFlash2, então o modelo inicia sem preditor. Carregando o modelo…",
|
||||
"DFlash2 초안 모델을 내려받지 못해 초안 모델 없이 시작합니다. 모델을 불러오는 중…",
|
||||
"Le module d'anticipation DFlash2 n'a pas pu être téléchargé, le modèle démarre donc sans module d'anticipation. Chargement du modèle…")
|
||||
add("Downloading the MTP drafter…",
|
||||
"正在下载 MTP 草稿模型…",
|
||||
"MTP のドラフトモデルをダウンロードしています…",
|
||||
"MTP-Entwurfsbaustein wird heruntergeladen …",
|
||||
"Descargando el predictor de MTP…",
|
||||
"Скачивается черновая модель MTP…",
|
||||
"Baixando o preditor do MTP…",
|
||||
"MTP 초안 모델을 내려받는 중…",
|
||||
"Téléchargement du module d'anticipation MTP…")
|
||||
|
||||
add("The MTP drafter could not be downloaded.",
|
||||
"无法下载 MTP 草稿模型。",
|
||||
"MTP のドラフトモデルをダウンロードできませんでした。",
|
||||
"Der MTP-Entwurfsbaustein konnte nicht heruntergeladen werden.",
|
||||
"No se pudo descargar el predictor de MTP.",
|
||||
"Не удалось скачать черновую модель MTP.",
|
||||
"Não foi possível baixar o preditor do MTP.",
|
||||
"MTP 초안 모델을 내려받지 못했습니다.",
|
||||
"Le module d'anticipation MTP n'a pas pu être téléchargé.")
|
||||
add("Drafting: MTP (DFlash2 didn't start)",
|
||||
"预测草稿:MTP(DFlash2 未能启动)",
|
||||
"先読み:MTP(DFlash2 は起動しませんでした)",
|
||||
"Vorausraten: MTP (DFlash2 ist nicht gestartet)",
|
||||
"Predicción: MTP (DFlash2 no arrancó)",
|
||||
"Предсказание: MTP (DFlash2 не запустился)",
|
||||
"Previsão: MTP (o DFlash2 não iniciou)",
|
||||
"초안 예측: MTP(DFlash2가 시작되지 않았습니다)",
|
||||
"Anticipation : MTP (DFlash2 n'a pas démarré)")
|
||||
here = Path(__file__).resolve().parent.parent / "src" / "locales"
|
||||
NBSP = " "
|
||||
|
||||
|
||||
def french(text):
|
||||
text = re.sub(r"(?<=[A-Za-zÀ-ſ])'(?=[A-Za-zÀ-ſ{])", "’", text)
|
||||
text = re.sub(r"(?<=\S) ([:;?!])", NBSP + r"\1", text)
|
||||
text = re.sub(r"(?<=[\d}]) %", NBSP + "%", text)
|
||||
return text
|
||||
|
||||
|
||||
for lang in LANGS:
|
||||
data = {}
|
||||
for i, entry in enumerate(S):
|
||||
text = entry[lang]
|
||||
assert sorted(re.findall(r"\{\d+\}", entry["en"])) == sorted(re.findall(r"\{\d+\}", text)), (lang, entry["en"], text)
|
||||
data[f"df{i + 1:02d}"] = french(text) if lang == "fr" else text
|
||||
with (here / f"df2-{lang}.json").open("w", encoding="utf-8", newline="\n") as handle:
|
||||
handle.write(json.dumps(data, ensure_ascii=False, indent=1) + "\n")
|
||||
print("written", len(S), "strings in", len(LANGS), "languages")
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Writes src/locales/engine-<lang>.json: the strings of Settings > Engine (the list of installed engines: switch, delete) and the words that now point to
|
||||
About for updating (branch f-0291-engine-tab). Run from apps/host: python scripts/make-engine-locales.py
|
||||
Every entry names all nine languages; the keys are et01.. in the order of S. engine-en.json is the English source. Placeholders ({0}, {1}) must be kept in
|
||||
every language. A separate file keeps this work from colliding with the other catalogs (the same reason tune-, tools-, fix-, gh- and ui- exist)."""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
LANGS = ["en", "zh", "ja", "de", "es", "ru", "pt", "ko", "fr"]
|
||||
S = []
|
||||
|
||||
|
||||
def add(en, zh, ja, de, es, ru, pt, ko, fr):
|
||||
S.append(dict(en=en, zh=zh, ja=ja, de=de, es=es, ru=ru, pt=pt, ko=ko, fr=fr))
|
||||
|
||||
|
||||
# ---- the page
|
||||
add("Installed engines",
|
||||
"已安装的引擎", "インストール済みのエンジン", "Installierte Engines", "Motores instalados", "Установленные движки", "Motores instalados", "설치된 엔진", "Moteurs installés")
|
||||
add("Switch to an older engine to test something, then switch back to the newest. Updating to the latest is in About.",
|
||||
"切换到较旧的引擎来测试某些内容,然后再切换回最新的。更新到最新版本请前往“关于”。",
|
||||
"古いエンジンに切り替えて試し、終わったら最新のエンジンに戻せます。最新版への更新は「情報」で行います。",
|
||||
"Wechsle zu einer älteren Engine, um etwas zu testen, und danach zurück zur neuesten. Das Update auf die neueste Version findest du unter „Über“.",
|
||||
"Cambia a un motor anterior para probar algo y vuelve después al más reciente. La actualización a la última versión está en «Acerca de».",
|
||||
"Переключитесь на более старый движок, чтобы что-то проверить, а потом вернитесь на самый новый. Обновление до последней версии — в разделе «О программе».",
|
||||
"Troque para um motor mais antigo para testar algo e depois volte ao mais recente. A atualização para a versão mais recente fica em «Sobre».",
|
||||
"이전 엔진으로 바꿔 테스트한 뒤 다시 최신 엔진으로 돌아올 수 있습니다. 최신 버전으로의 업데이트는 '정보'에서 합니다.",
|
||||
"Passez à un moteur plus ancien pour tester quelque chose, puis revenez au plus récent. La mise à jour vers la dernière version se trouve dans « À propos ».")
|
||||
add("Engines can be switched and deleted in the installed Strata app.",
|
||||
"可以在已安装的 Strata 应用中切换和删除引擎。", "エンジンの切り替えと削除は、インストール済みの Strata アプリで行えます。",
|
||||
"Engines lassen sich in der installierten Strata-App wechseln und löschen.", "Los motores se pueden cambiar y eliminar en la aplicación Strata instalada.",
|
||||
"Переключать и удалять движки можно в установленном приложении Strata.", "Os motores podem ser trocados e excluídos no aplicativo Strata instalado.",
|
||||
"엔진 전환과 삭제는 설치된 Strata 앱에서 할 수 있습니다.", "Les moteurs peuvent être changés et supprimés dans l'application Strata installée.")
|
||||
add("The installed engines couldn't be read. Try again in a moment.",
|
||||
"无法读取已安装的引擎。请稍后重试。", "インストール済みのエンジンを読み取れませんでした。しばらくしてからもう一度お試しください。",
|
||||
"Die installierten Engines konnten nicht gelesen werden. Versuche es gleich noch einmal.", "No se pudieron leer los motores instalados. Inténtalo de nuevo en un momento.",
|
||||
"Не удалось прочитать список установленных движков. Попробуйте ещё раз через минуту.", "Não foi possível ler os motores instalados. Tente novamente em instantes.",
|
||||
"설치된 엔진을 읽지 못했습니다. 잠시 후 다시 시도하세요.", "Les moteurs installés n'ont pas pu être lus. Réessayez dans un instant.")
|
||||
add("No engine is installed on this computer yet. Update in About downloads the right one.",
|
||||
"此电脑上还没有安装引擎。在“关于”中点击更新即可下载合适的引擎。", "このコンピューターにはまだエンジンがありません。「情報」で更新すると、合ったものがダウンロードされます。",
|
||||
"Auf diesem Computer ist noch keine Engine installiert. Das Update unter „Über“ lädt die passende herunter.",
|
||||
"Todavía no hay ningún motor instalado en este equipo. Actualizar en «Acerca de» descarga el adecuado.",
|
||||
"На этом компьютере ещё не установлен движок. Обновление в разделе «О программе» скачает подходящий.",
|
||||
"Ainda não há nenhum motor instalado neste computador. Atualizar em «Sobre» baixa o adequado.",
|
||||
"이 컴퓨터에는 아직 설치된 엔진이 없습니다. '정보'에서 업데이트하면 알맞은 엔진을 내려받습니다.",
|
||||
"Aucun moteur n'est encore installé sur cet ordinateur. La mise à jour dans « À propos » télécharge le bon.")
|
||||
add("Engines take {0} on this computer.",
|
||||
"引擎在此电脑上共占用 {0}。", "エンジンはこのコンピューターで {0} を使っています。", "Die Engines belegen auf diesem Computer {0}.", "Los motores ocupan {0} en este equipo.",
|
||||
"Движки занимают на этом компьютере {0}.", "Os motores ocupam {0} neste computador.", "엔진이 이 컴퓨터에서 {0}을(를) 차지합니다.", "Les moteurs occupent {0} sur cet ordinateur.")
|
||||
add("Newest", "最新", "最新", "Neueste", "Más reciente", "Новейший", "Mais recente", "최신", "Le plus récent")
|
||||
add("Use this", "使用此引擎", "これを使う", "Diese verwenden", "Usar este", "Использовать", "Usar este", "이 엔진 사용", "Utiliser celui-ci")
|
||||
add("Qwen and other models",
|
||||
"Qwen 及其他模型", "Qwen とその他のモデル", "Qwen und andere Modelle", "Qwen y otros modelos", "Qwen и другие модели", "Qwen e outros modelos", "Qwen 및 기타 모델", "Qwen et autres modèles")
|
||||
add("A running program is using this engine. Unload your models first.",
|
||||
"有程序正在使用此引擎。请先卸载你的模型。", "実行中のプログラムがこのエンジンを使っています。先にモデルをアンロードしてください。",
|
||||
"Ein laufendes Programm benutzt diese Engine. Entlade zuerst deine Modelle.", "Un programa en ejecución está usando este motor. Descarga primero tus modelos.",
|
||||
"Этот движок используется работающей программой. Сначала выгрузите свои модели.", "Um programa em execução está usando este motor. Descarregue seus modelos primeiro.",
|
||||
"실행 중인 프로그램이 이 엔진을 사용하고 있습니다. 먼저 모델을 언로드하세요.", "Un programme en cours utilise ce moteur. Déchargez d'abord vos modèles.")
|
||||
add("Engine {0} is now in use.",
|
||||
"现在使用引擎 {0}。", "エンジン {0} を使用中です。", "Engine {0} wird jetzt verwendet.", "Ahora se usa el motor {0}.", "Теперь используется движок {0}.", "O motor {0} está em uso agora.",
|
||||
"이제 엔진 {0}을(를) 사용합니다.", "Le moteur {0} est maintenant utilisé.")
|
||||
add("Engine {0} was deleted.",
|
||||
"引擎 {0} 已删除。", "エンジン {0} を削除しました。", "Engine {0} wurde gelöscht.", "Se eliminó el motor {0}.", "Движок {0} удалён.", "O motor {0} foi excluído.",
|
||||
"엔진 {0}을(를) 삭제했습니다.", "Le moteur {0} a été supprimé.")
|
||||
add("Use engine {0}?",
|
||||
"使用引擎 {0}?", "エンジン {0} を使いますか?", "Engine {0} verwenden?", "¿Usar el motor {0}?", "Использовать движок {0}?", "Usar o motor {0}?", "엔진 {0}을(를) 사용할까요?", "Utiliser le moteur {0} ?")
|
||||
add("Use engine {0}",
|
||||
"使用引擎 {0}", "エンジン {0} を使う", "Engine {0} verwenden", "Usar el motor {0}", "Использовать движок {0}", "Usar o motor {0}", "엔진 {0} 사용", "Utiliser le moteur {0}")
|
||||
add("Models you start from now on run on engine {0}. Your models and settings stay as they are. You can switch back at any time.",
|
||||
"从现在起启动的模型将使用引擎 {0}。你的模型和设置保持不变。你可以随时切换回来。",
|
||||
"これから起動するモデルはエンジン {0} で動きます。モデルと設定はそのままです。いつでも元に戻せます。",
|
||||
"Modelle, die du ab jetzt startest, laufen mit Engine {0}. Deine Modelle und Einstellungen bleiben unverändert. Du kannst jederzeit zurückwechseln.",
|
||||
"Los modelos que inicies a partir de ahora usarán el motor {0}. Tus modelos y ajustes no cambian. Puedes volver cuando quieras.",
|
||||
"Модели, которые вы запустите с этого момента, будут работать на движке {0}. Ваши модели и настройки останутся как есть. Вернуться можно в любой момент.",
|
||||
"Os modelos que você iniciar a partir de agora usarão o motor {0}. Seus modelos e ajustes continuam como estão. Você pode voltar quando quiser.",
|
||||
"지금부터 시작하는 모델은 엔진 {0}에서 실행됩니다. 모델과 설정은 그대로 유지됩니다. 언제든 다시 바꿀 수 있습니다.",
|
||||
"Les modèles que vous démarrez à partir de maintenant utilisent le moteur {0}. Vos modèles et réglages restent inchangés. Vous pouvez revenir en arrière à tout moment.")
|
||||
add("These models need a newer engine and may not start on it: {0}.",
|
||||
"这些模型需要更新的引擎,在此引擎上可能无法启动:{0}。", "次のモデルはより新しいエンジンが必要で、このエンジンでは起動しない可能性があります:{0}。",
|
||||
"Diese Modelle brauchen eine neuere Engine und starten damit eventuell nicht: {0}.", "Estos modelos necesitan un motor más reciente y puede que no arranquen con este: {0}.",
|
||||
"Этим моделям нужен более новый движок, и на этом они могут не запуститься: {0}.", "Estes modelos precisam de um motor mais novo e podem não iniciar com este: {0}.",
|
||||
"다음 모델은 더 새로운 엔진이 필요해 이 엔진에서는 시작되지 않을 수 있습니다: {0}.", "Ces modèles demandent un moteur plus récent et pourraient ne pas démarrer avec celui-ci : {0}.")
|
||||
add("A model is running. Unload it in Models first, then switch.",
|
||||
"有模型正在运行。请先在“模型”中卸载它,然后再切换。", "モデルが実行中です。先に「モデル」でアンロードしてから切り替えてください。",
|
||||
"Ein Modell läuft. Entlade es zuerst unter „Modelle“ und wechsle dann.", "Hay un modelo en ejecución. Descárgalo primero en «Modelos» y luego cambia.",
|
||||
"Работает модель. Сначала выгрузите её в разделе «Модели», затем переключитесь.", "Há um modelo em execução. Descarregue-o primeiro em «Modelos» e depois troque.",
|
||||
"모델이 실행 중입니다. 먼저 '모델'에서 언로드한 다음 전환하세요.", "Un modèle est en cours d'exécution. Déchargez-le d'abord dans « Modèles », puis changez de moteur.")
|
||||
add("Delete engine {0}?",
|
||||
"删除引擎 {0}?", "エンジン {0} を削除しますか?", "Engine {0} löschen?", "¿Eliminar el motor {0}?", "Удалить движок {0}?", "Excluir o motor {0}?", "엔진 {0}을(를) 삭제할까요?", "Supprimer le moteur {0} ?")
|
||||
add("Keep engine", "保留引擎", "エンジンを残す", "Engine behalten", "Conservar motor", "Оставить движок", "Manter motor", "엔진 유지", "Garder le moteur")
|
||||
add("Delete engine", "删除引擎", "エンジンを削除", "Engine löschen", "Eliminar motor", "Удалить движок", "Excluir motor", "엔진 삭제", "Supprimer le moteur")
|
||||
add("This frees {0}. Your models and settings are kept.",
|
||||
"这将释放 {0}。你的模型和设置会保留。", "{0} が空きます。モデルと設定はそのまま残ります。", "Das gibt {0} frei. Deine Modelle und Einstellungen bleiben erhalten.",
|
||||
"Esto libera {0}. Tus modelos y ajustes se conservan.", "Это освободит {0}. Ваши модели и настройки сохранятся.", "Isso libera {0}. Seus modelos e ajustes são mantidos.",
|
||||
"{0}의 공간이 확보됩니다. 모델과 설정은 그대로 유지됩니다.", "Cela libère {0}. Vos modèles et réglages sont conservés.")
|
||||
add("The newest engine can be downloaded again with Update in About.",
|
||||
"最新的引擎可以在“关于”中通过更新重新下载。", "最新のエンジンは、「情報」の更新でもう一度ダウンロードできます。",
|
||||
"Die neueste Engine lässt sich über das Update unter „Über“ erneut herunterladen.", "El motor más reciente se puede volver a descargar con Actualizar en «Acerca de».",
|
||||
"Новейший движок можно скачать снова через обновление в разделе «О программе».", "O motor mais recente pode ser baixado de novo com Atualizar em «Sobre».",
|
||||
"최신 엔진은 '정보'의 업데이트로 다시 내려받을 수 있습니다.", "Le moteur le plus récent peut être retéléchargé avec la mise à jour dans « À propos ».")
|
||||
add("Strata can only download the newest engine, so a deleted older engine can't be brought back.",
|
||||
"Strata 只能下载最新的引擎,所以删除后无法找回较旧的引擎。", "Strata がダウンロードできるのは最新のエンジンだけなので、削除した古いエンジンは元に戻せません。",
|
||||
"Strata kann nur die neueste Engine herunterladen. Eine gelöschte ältere Engine lässt sich deshalb nicht wiederherstellen.",
|
||||
"Strata solo puede descargar el motor más reciente, así que un motor anterior eliminado no se puede recuperar.",
|
||||
"Strata может скачать только новейший движок, поэтому удалённый старый движок вернуть нельзя.",
|
||||
"O Strata só pode baixar o motor mais recente, então um motor antigo excluído não pode ser recuperado.",
|
||||
"Strata는 최신 엔진만 내려받을 수 있으므로, 삭제한 이전 엔진은 되돌릴 수 없습니다.",
|
||||
"Strata ne peut télécharger que le moteur le plus récent : un ancien moteur supprimé ne peut donc pas être récupéré.")
|
||||
|
||||
# ---- why an engine cannot be used on this computer
|
||||
add("This engine was made for a different operating system.",
|
||||
"此引擎是为其他操作系统制作的。", "このエンジンは別のオペレーティングシステム向けです。", "Diese Engine wurde für ein anderes Betriebssystem gebaut.",
|
||||
"Este motor se creó para otro sistema operativo.", "Этот движок создан для другой операционной системы.", "Este motor foi feito para outro sistema operacional.",
|
||||
"이 엔진은 다른 운영체제용입니다.", "Ce moteur a été conçu pour un autre système d'exploitation.")
|
||||
add("This engine was made for a different kind of graphics card than this computer has.",
|
||||
"此引擎是为与这台电脑不同类型的显卡制作的。", "このエンジンは、このコンピューターとは別の種類のグラフィックスカード向けです。",
|
||||
"Diese Engine wurde für eine andere Art von Grafikkarte gebaut als die in diesem Computer.", "Este motor se creó para un tipo de tarjeta gráfica distinto del de este equipo.",
|
||||
"Этот движок создан для другого типа видеокарты, чем в этом компьютере.", "Este motor foi feito para um tipo de placa de vídeo diferente da deste computador.",
|
||||
"이 엔진은 이 컴퓨터와 다른 종류의 그래픽 카드용입니다.", "Ce moteur a été conçu pour un autre type de carte graphique que celle de cet ordinateur.")
|
||||
add("This engine needs Strata {0} or newer.",
|
||||
"此引擎需要 Strata {0} 或更新版本。", "このエンジンには Strata {0} 以降が必要です。", "Diese Engine braucht Strata {0} oder neuer.", "Este motor necesita Strata {0} o posterior.",
|
||||
"Для этого движка нужна Strata {0} или новее.", "Este motor precisa do Strata {0} ou mais recente.", "이 엔진에는 Strata {0} 이상이 필요합니다.", "Ce moteur nécessite Strata {0} ou plus récent.")
|
||||
add("This engine doesn't support this computer's graphics card.",
|
||||
"此引擎不支持这台电脑的显卡。", "このエンジンは、このコンピューターのグラフィックスカードに対応していません。", "Diese Engine unterstützt die Grafikkarte dieses Computers nicht.",
|
||||
"Este motor no admite la tarjeta gráfica de este equipo.", "Этот движок не поддерживает видеокарту этого компьютера.", "Este motor não oferece suporte à placa de vídeo deste computador.",
|
||||
"이 엔진은 이 컴퓨터의 그래픽 카드를 지원하지 않습니다.", "Ce moteur ne prend pas en charge la carte graphique de cet ordinateur.")
|
||||
add("This engine needs NVIDIA driver {0} or newer. This computer has {1}.",
|
||||
"此引擎需要 NVIDIA 驱动 {0} 或更新版本。这台电脑的驱动是 {1}。", "このエンジンには NVIDIA ドライバー {0} 以降が必要です。このコンピューターは {1} です。",
|
||||
"Diese Engine braucht den NVIDIA-Treiber {0} oder neuer. Dieser Computer hat {1}.", "Este motor necesita el controlador NVIDIA {0} o posterior. Este equipo tiene {1}.",
|
||||
"Для этого движка нужен драйвер NVIDIA {0} или новее. На этом компьютере {1}.", "Este motor precisa do driver NVIDIA {0} ou mais recente. Este computador tem o {1}.",
|
||||
"이 엔진에는 NVIDIA 드라이버 {0} 이상이 필요합니다. 이 컴퓨터는 {1}입니다.", "Ce moteur nécessite le pilote NVIDIA {0} ou plus récent. Cet ordinateur a le {1}.")
|
||||
add("Some of this engine's files are missing or changed, so it can't be started.",
|
||||
"此引擎的部分文件缺失或已被更改,因此无法启动。", "このエンジンのファイルの一部が見つからないか変更されているため、起動できません。",
|
||||
"Einige Dateien dieser Engine fehlen oder wurden verändert, deshalb lässt sie sich nicht starten.", "Faltan archivos de este motor o han cambiado, así que no se puede iniciar.",
|
||||
"Часть файлов этого движка отсутствует или изменена, поэтому его нельзя запустить.", "Alguns arquivos deste motor estão ausentes ou foram alterados, então ele não pode ser iniciado.",
|
||||
"이 엔진의 일부 파일이 없거나 변경되어 시작할 수 없습니다.", "Certains fichiers de ce moteur sont absents ou modifiés : il ne peut pas démarrer.")
|
||||
add("This engine's description is missing or damaged, so it can't be started.",
|
||||
"此引擎的说明文件缺失或已损坏,因此无法启动。", "このエンジンの説明ファイルが見つからないか壊れているため、起動できません。",
|
||||
"Die Beschreibung dieser Engine fehlt oder ist beschädigt, deshalb lässt sie sich nicht starten.", "Falta la descripción de este motor o está dañada, así que no se puede iniciar.",
|
||||
"Описание этого движка отсутствует или повреждено, поэтому его нельзя запустить.", "A descrição deste motor está ausente ou danificada, então ele não pode ser iniciado.",
|
||||
"이 엔진의 설명 파일이 없거나 손상되어 시작할 수 없습니다.", "La description de ce moteur est absente ou endommagée : il ne peut pas démarrer.")
|
||||
|
||||
# ---- updating moved to About: the words that sent people to Settings > Engine
|
||||
add("Strata didn’t recognise an engine for this computer. The model will download, but it may not run until the right engine is installed (Settings → About).",
|
||||
"Strata 未能识别这台电脑的引擎。模型会下载,但在安装合适的引擎之前可能无法运行(设置 → 关于)。",
|
||||
"Strata はこのコンピューター用のエンジンを認識できませんでした。モデルはダウンロードされますが、合ったエンジンをインストールするまで動かない可能性があります(設定 → 情報)。",
|
||||
"Strata hat für diesen Computer keine Engine erkannt. Das Modell wird heruntergeladen, läuft aber möglicherweise erst, wenn die passende Engine installiert ist (Einstellungen → Über).",
|
||||
"Strata no reconoció ningún motor para este equipo. El modelo se descargará, pero puede que no funcione hasta que se instale el motor adecuado (Ajustes → Acerca de).",
|
||||
"Strata не распознала движок для этого компьютера. Модель скачается, но может не запуститься, пока не установлен подходящий движок (Настройки → О программе).",
|
||||
"O Strata não reconheceu um motor para este computador. O modelo será baixado, mas pode não funcionar até que o motor certo seja instalado (Configurações → Sobre).",
|
||||
"Strata가 이 컴퓨터용 엔진을 인식하지 못했습니다. 모델은 내려받지만, 알맞은 엔진이 설치되기 전에는 실행되지 않을 수 있습니다(설정 → 정보).",
|
||||
"Strata n’a pas reconnu de moteur pour cet ordinateur. Le modèle sera téléchargé, mais il risque de ne pas fonctionner tant que le bon moteur n’est pas installé (Réglages → À propos).")
|
||||
add("Strata didn’t recognise an engine for this computer. The model is downloaded, but it can’t run until the right engine is installed. Open Settings → About.",
|
||||
"Strata 未能识别这台电脑的引擎。模型已下载,但在安装合适的引擎之前无法运行。请打开 设置 → 关于。",
|
||||
"Strata はこのコンピューター用のエンジンを認識できませんでした。モデルはダウンロード済みですが、合ったエンジンをインストールするまで動きません。設定 → 情報 を開いてください。",
|
||||
"Strata hat für diesen Computer keine Engine erkannt. Das Modell ist heruntergeladen, läuft aber erst, wenn die passende Engine installiert ist. Öffne Einstellungen → Über.",
|
||||
"Strata no reconoció ningún motor para este equipo. El modelo está descargado, pero no puede funcionar hasta que se instale el motor adecuado. Abre Ajustes → Acerca de.",
|
||||
"Strata не распознала движок для этого компьютера. Модель скачана, но не запустится, пока не установлен подходящий движок. Откройте Настройки → О программе.",
|
||||
"O Strata não reconheceu um motor para este computador. O modelo foi baixado, mas não pode funcionar até que o motor certo seja instalado. Abra Configurações → Sobre.",
|
||||
"Strata가 이 컴퓨터용 엔진을 인식하지 못했습니다. 모델은 내려받았지만, 알맞은 엔진이 설치되기 전에는 실행할 수 없습니다. 설정 → 정보를 여세요.",
|
||||
"Strata n’a pas reconnu de moteur pour cet ordinateur. Le modèle est téléchargé, mais il ne peut pas fonctionner tant que le bon moteur n’est pas installé. Ouvrez Réglages → À propos.")
|
||||
add("Update the engine (Settings, About), then try again.",
|
||||
"请更新引擎(设置,关于),然后重试。", "エンジンを更新して(設定、情報)、もう一度お試しください。", "Aktualisiere die Engine (Einstellungen, Über) und versuche es dann erneut.",
|
||||
"Actualiza el motor (Ajustes, Acerca de) y vuelve a intentarlo.", "Обновите движок (Настройки, О программе) и попробуйте снова.", "Atualize o motor (Configurações, Sobre) e tente novamente.",
|
||||
"엔진을 업데이트(설정, 정보)한 뒤 다시 시도하세요.", "Mettez le moteur à jour (Réglages, À propos), puis réessayez.")
|
||||
|
||||
# ---- answers of the host (shown as they are, so each one is a catalog entry)
|
||||
add("A model is running, so nothing was changed. Unload the model in Models, then try again.",
|
||||
"有模型正在运行,所以没有做任何更改。请先在“模型”中卸载模型,然后重试。", "モデルが実行中のため、何も変更していません。「モデル」でモデルをアンロードしてから、もう一度お試しください。",
|
||||
"Ein Modell läuft, deshalb wurde nichts geändert. Entlade das Modell unter „Modelle“ und versuche es dann erneut.", "Hay un modelo en ejecución, así que no se cambió nada. Descarga el modelo en «Modelos» y vuelve a intentarlo.",
|
||||
"Работает модель, поэтому ничего не изменено. Выгрузите модель в разделе «Модели» и попробуйте снова.", "Há um modelo em execução, então nada foi alterado. Descarregue o modelo em «Modelos» e tente novamente.",
|
||||
"모델이 실행 중이라 아무것도 바뀌지 않았습니다. '모델'에서 모델을 언로드한 다음 다시 시도하세요.", "Un modèle est en cours d'exécution, rien n'a donc été modifié. Déchargez le modèle dans « Modèles », puis réessayez.")
|
||||
add("This is the engine in use. Switch to another one first, then delete it.",
|
||||
"这是正在使用的引擎。请先切换到另一个引擎,再删除它。", "これは使用中のエンジンです。先に別のエンジンに切り替えてから削除してください。",
|
||||
"Das ist die verwendete Engine. Wechsle zuerst zu einer anderen und lösche sie dann.", "Este es el motor en uso. Cambia primero a otro y luego elimínalo.",
|
||||
"Это используемый движок. Сначала переключитесь на другой, затем удалите его.", "Este é o motor em uso. Troque primeiro para outro e depois exclua-o.",
|
||||
"사용 중인 엔진입니다. 먼저 다른 엔진으로 전환한 다음 삭제하세요.", "C'est le moteur utilisé. Passez d'abord à un autre, puis supprimez-le.")
|
||||
add("A program is still using this engine, so it was not deleted. Unload your models and try again.",
|
||||
"仍有程序在使用此引擎,所以没有删除。请卸载你的模型后重试。", "プログラムがまだこのエンジンを使っているため、削除しませんでした。モデルをアンロードしてから、もう一度お試しください。",
|
||||
"Ein Programm benutzt diese Engine noch, deshalb wurde sie nicht gelöscht. Entlade deine Modelle und versuche es erneut.", "Un programa todavía usa este motor, así que no se eliminó. Descarga tus modelos y vuelve a intentarlo.",
|
||||
"Этот движок ещё используется программой, поэтому он не удалён. Выгрузите модели и попробуйте снова.", "Um programa ainda está usando este motor, então ele não foi excluído. Descarregue seus modelos e tente novamente.",
|
||||
"프로그램이 아직 이 엔진을 사용하고 있어 삭제하지 않았습니다. 모델을 언로드한 다음 다시 시도하세요.", "Un programme utilise encore ce moteur : il n'a pas été supprimé. Déchargez vos modèles, puis réessayez.")
|
||||
add("That engine is already gone. The list has been refreshed.",
|
||||
"该引擎已经不存在。列表已刷新。", "そのエンジンはすでにありません。一覧を更新しました。", "Diese Engine ist schon nicht mehr da. Die Liste wurde aktualisiert.",
|
||||
"Ese motor ya no está. La lista se ha actualizado.", "Этого движка уже нет. Список обновлён.", "Esse motor já não existe. A lista foi atualizada.", "해당 엔진은 이미 없습니다. 목록을 새로 고쳤습니다.", "Ce moteur n'existe déjà plus. La liste a été actualisée.")
|
||||
add("An update is running. Wait for it to finish, then try again.",
|
||||
"正在进行更新。请等它完成后再试。", "更新を実行中です。終わるまで待ってから、もう一度お試しください。", "Ein Update läuft. Warte, bis es fertig ist, und versuche es dann erneut.",
|
||||
"Hay una actualización en curso. Espera a que termine y vuelve a intentarlo.", "Идёт обновление. Дождитесь его окончания и попробуйте снова.", "Uma atualização está em andamento. Aguarde o fim e tente novamente.",
|
||||
"업데이트가 진행 중입니다. 끝날 때까지 기다린 다음 다시 시도하세요.", "Une mise à jour est en cours. Attendez qu'elle se termine, puis réessayez.")
|
||||
add("This engine can't run on this computer, so nothing was changed.",
|
||||
"此引擎无法在这台电脑上运行,所以没有做任何更改。", "このエンジンはこのコンピューターでは動かないため、何も変更していません。", "Diese Engine läuft auf diesem Computer nicht, deshalb wurde nichts geändert.",
|
||||
"Este motor no puede funcionar en este equipo, así que no se cambió nada.", "Этот движок не может работать на этом компьютере, поэтому ничего не изменено.", "Este motor não pode funcionar neste computador, então nada foi alterado.",
|
||||
"이 엔진은 이 컴퓨터에서 실행할 수 없어 아무것도 바뀌지 않았습니다.", "Ce moteur ne peut pas fonctionner sur cet ordinateur : rien n'a été modifié.")
|
||||
add("The engine wasn't deleted. Nothing else was changed.",
|
||||
"引擎没有被删除。其他内容均未更改。", "エンジンは削除されませんでした。ほかには何も変更していません。", "Die Engine wurde nicht gelöscht. Sonst wurde nichts geändert.",
|
||||
"El motor no se eliminó. No se cambió nada más.", "Движок не удалён. Больше ничего не изменено.", "O motor não foi excluído. Nada mais foi alterado.", "엔진이 삭제되지 않았습니다. 다른 것은 바뀌지 않았습니다.", "Le moteur n'a pas été supprimé. Rien d'autre n'a été modifié.")
|
||||
add("The engine wasn't switched. Nothing was changed.",
|
||||
"引擎没有被切换。没有做任何更改。", "エンジンは切り替わりませんでした。何も変更していません。", "Die Engine wurde nicht gewechselt. Es wurde nichts geändert.",
|
||||
"No se cambió el motor. No se modificó nada.", "Движок не переключён. Ничего не изменено.", "O motor não foi trocado. Nada foi alterado.", "엔진이 전환되지 않았습니다. 아무것도 바뀌지 않았습니다.", "Le moteur n'a pas été changé. Rien n'a été modifié.")
|
||||
add("Choose an engine from the list.",
|
||||
"请从列表中选择一个引擎。", "一覧からエンジンを選んでください。", "Wähle eine Engine aus der Liste.", "Elige un motor de la lista.", "Выберите движок из списка.", "Escolha um motor da lista.", "목록에서 엔진을 선택하세요.", "Choisissez un moteur dans la liste.")
|
||||
add("A newer engine is already on this computer. Update switches to it; nothing is downloaded.",
|
||||
"这台电脑上已经有更新的引擎。点击更新会切换到它,不会下载任何内容。", "このコンピューターにはすでに新しいエンジンがあります。更新するとそれに切り替わり、ダウンロードはされません。",
|
||||
"Auf diesem Computer ist schon eine neuere Engine. Das Update wechselt zu ihr; es wird nichts heruntergeladen.", "Este equipo ya tiene un motor más reciente. Actualizar cambia a él; no se descarga nada.",
|
||||
"На этом компьютере уже есть более новый движок. Обновление переключит на него; ничего не скачивается.", "Este computador já tem um motor mais novo. Atualizar troca para ele; nada é baixado.",
|
||||
"이 컴퓨터에 이미 더 새로운 엔진이 있습니다. 업데이트하면 그 엔진으로 전환되며, 내려받는 것은 없습니다.", "Cet ordinateur a déjà un moteur plus récent. La mise à jour bascule dessus ; rien n'est téléchargé.")
|
||||
|
||||
here = Path(__file__).resolve().parent.parent / "src" / "locales"
|
||||
NBSP = " "
|
||||
|
||||
|
||||
def french(text):
|
||||
text = re.sub(r"(?<=[A-Za-zÀ-ſ])'(?=[A-Za-zÀ-ſ{])", "’", text)
|
||||
text = re.sub(r"(?<=\S) ([:;?!])", NBSP + r"\1", text)
|
||||
text = re.sub(r"(?<=[\d}]) %", NBSP + "%", text)
|
||||
return text
|
||||
|
||||
|
||||
for lang in LANGS:
|
||||
data = {}
|
||||
for i, entry in enumerate(S):
|
||||
text = entry[lang]
|
||||
assert sorted(re.findall(r"\{\d+\}", entry["en"])) == sorted(re.findall(r"\{\d+\}", text)), (lang, entry["en"], text)
|
||||
data[f"et{i + 1:02d}"] = french(text) if lang == "fr" else text
|
||||
(here / f"engine-{lang}.json").write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
|
||||
print("written", len(S), "strings in", len(LANGS), "languages")
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Writes src/locales/gui-<lang>.json: the strings of the GUI feedback round 2 (branch f-0291-gui-fb2): the new name and hint of the "Leave room for my work"
|
||||
choice (it keeps 5-8 GB of memory free; it was "Leave 20% for my work"), the memory-error sentence that names it, and the words of the compact files block
|
||||
("Delivered to", "Working folder", "{0} files").
|
||||
Run from apps/host: python scripts/make-gui-locales.py
|
||||
Every entry names all nine languages; the keys are gf01.. in the order of S. gui-en.json is the English source. Placeholders ({0}) must be kept in every language.
|
||||
A separate file keeps this work from colliding with the other catalogs (the same reason pick-, tune-, tools-, fix-, gh-, ui-, engine- and df2- exist)."""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
LANGS = ["en", "zh", "ja", "de", "es", "ru", "pt", "ko", "fr"]
|
||||
S = []
|
||||
|
||||
|
||||
def add(en, zh, ja, de, es, ru, pt, ko, fr):
|
||||
S.append(dict(en=en, zh=zh, ja=ja, de=de, es=es, ru=ru, pt=pt, ko=ko, fr=fr))
|
||||
|
||||
|
||||
add("Leave room for my work",
|
||||
"为我的工作留出空间", "作業用の余裕を残す", "Platz für meine Arbeit lassen", "Dejar espacio para mi trabajo",
|
||||
"Оставить запас для моей работы", "Deixar espaço para o meu trabalho", "내 작업을 위한 여유 남기기", "Garder de la place pour mon travail")
|
||||
add("Keeps 5-8 GB of memory free for other programs.",
|
||||
"为其他程序保留 5-8 GB 空闲内存。", "他のプログラム用に 5~8 GB のメモリを空けておきます。", "Hält 5-8 GB Arbeitsspeicher für andere Programme frei.",
|
||||
"Mantiene libres 5-8 GB de memoria para otros programas.", "Оставляет 5-8 ГБ памяти свободными для других программ.",
|
||||
"Mantém 5-8 GB de memória livres para outros programas.", "다른 프로그램을 위해 메모리 5-8 GB를 비워 둡니다.", "Garde 5-8 Go de mémoire libres pour les autres programmes.")
|
||||
add("Close other programs and try again, or choose “Leave room 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.",
|
||||
"请关闭其他程序后重试,或选择“为我的工作留出空间”。在专家模式下,你还可以开启低内存选项,让部分模型从 SSD 读取。",
|
||||
"他のプログラムを閉じてもう一度試すか、「作業用の余裕を残す」を選んでください。エキスパートモードでは低 RAM オプションをオンにして、モデルの一部を SSD から読み込むこともできます。",
|
||||
"Schließen Sie andere Programme und versuchen Sie es erneut, oder wählen Sie „Platz für meine Arbeit lassen“. Im Expertenmodus können Sie außerdem die Option für wenig Arbeitsspeicher aktivieren, sodass ein Teil des Modells von der SSD gelesen wird.",
|
||||
"Cierre otros programas e inténtelo de nuevo, o elija «Dejar espacio para mi trabajo». En el modo experto también puede activar la opción de poca RAM, para que una parte del modelo se lea desde el SSD.",
|
||||
"Закройте другие программы и повторите попытку или выберите «Оставить запас для моей работы». В экспертном режиме можно также включить режим малого объёма ОЗУ, и часть модели будет читаться с SSD.",
|
||||
"Feche outros programas e tente novamente, ou escolha “Deixar espaço para o meu trabalho”. No modo especialista, você também pode ativar a opção de pouca RAM, para que parte do modelo seja lida do SSD.",
|
||||
"다른 프로그램을 닫고 다시 시도하거나 “내 작업을 위한 여유 남기기”를 선택하세요. 전문가 모드에서는 저RAM 옵션을 켜서 모델의 일부를 SSD에서 읽게 할 수도 있습니다.",
|
||||
"Fermez les autres programmes et réessayez, ou choisissez « Garder de la place pour mon travail ». En mode expert, vous pouvez aussi activer l'option de faible RAM : une partie du modèle est alors lue depuis le SSD.")
|
||||
add("Delivered to",
|
||||
"已交付到", "保存先", "Geliefert nach", "Entregado en", "Результат в", "Entregue em", "전달 위치", "Livré dans")
|
||||
add("Working folder",
|
||||
"工作文件夹", "作業フォルダー", "Arbeitsordner", "Carpeta de trabajo", "Рабочая папка", "Pasta de trabalho", "작업 폴더", "Dossier de travail")
|
||||
add("{0} files",
|
||||
"{0} 个文件", "{0} 個のファイル", "{0} Dateien", "{0} archivos", "Файлов: {0}", "{0} arquivos", "파일 {0}개", "{0} fichiers")
|
||||
here = Path(__file__).resolve().parent.parent / "src" / "locales"
|
||||
NBSP = " "
|
||||
|
||||
|
||||
def french(text):
|
||||
text = re.sub(r"(?<=[A-Za-zÀ-ſ])'(?=[A-Za-zÀ-ſ{])", "’", text)
|
||||
text = re.sub(r"(?<=\S) ([:;?!])", NBSP + r"\1", text)
|
||||
text = re.sub(r"(?<=[\d}]) %", NBSP + "%", text)
|
||||
return text
|
||||
|
||||
|
||||
for lang in LANGS:
|
||||
data = {}
|
||||
for i, entry in enumerate(S):
|
||||
text = entry[lang]
|
||||
assert sorted(re.findall(r"\{\d+\}", entry["en"])) == sorted(re.findall(r"\{\d+\}", text)), (lang, entry["en"], text)
|
||||
data[f"gf{i + 1:02d}"] = french(text) if lang == "fr" else text
|
||||
with (here / f"gui-{lang}.json").open("w", encoding="utf-8", newline="\n") as handle:
|
||||
handle.write(json.dumps(data, ensure_ascii=False, indent=1) + "\n")
|
||||
print("written", len(S), "strings in", len(LANGS), "languages")
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Writes src/locales/pick-<lang>.json: the strings of the model version list (VersionPicker), the code-block preview button and the This computer panel (branch f-0291-ui-fixes2).
|
||||
Run from apps/host: python scripts/make-pick-locales.py
|
||||
Every entry names all nine languages; the keys are vp01.. in the order of S. pick-en.json is the English source. Placeholders ({0}, {1}) must be kept
|
||||
in every language. A separate file keeps this work from colliding with the other catalogs (the same reason tune-, tools-, fix-, gh-, ui- and engine- exist)."""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
LANGS = ["en", "zh", "ja", "de", "es", "ru", "pt", "ko", "fr"]
|
||||
S = []
|
||||
|
||||
|
||||
def add(en, zh, ja, de, es, ru, pt, ko, fr):
|
||||
S.append(dict(en=en, zh=zh, ja=ja, de=de, es=es, ru=ru, pt=pt, ko=ko, fr=fr))
|
||||
|
||||
|
||||
add("{0} download",
|
||||
"下载 {0}", "ダウンロード {0}", "{0} Download", "Descarga de {0}", "Загрузка {0}", "Download de {0}", "다운로드 {0}", "Téléchargement de {0}")
|
||||
add("Needs {0} of memory",
|
||||
"需要 {0} 内存", "{0} のメモリーが必要", "Braucht {0} Speicher", "Necesita {0} de memoria", "Нужно {0} памяти", "Precisa de {0} de memória", "메모리 {0} 필요", "Nécessite {0} de mémoire")
|
||||
add("Needs {0} of memory and about {1} GB of disk",
|
||||
"需要 {0} 内存和约 {1} GB 磁盘空间", "{0} のメモリーと約 {1} GB のディスク容量が必要", "Braucht {0} Speicher und etwa {1} GB Festplattenplatz", "Necesita {0} de memoria y unos {1} GB de disco",
|
||||
"Нужно {0} памяти и около {1} ГБ на диске", "Precisa de {0} de memória e cerca de {1} GB de disco", "메모리 {0}와 디스크 약 {1} GB 필요", "Nécessite {0} de mémoire et environ {1} Go de disque")
|
||||
add("Fits",
|
||||
"可以运行", "問題なく動きます", "Passt", "Cabe", "Помещается", "Cabe", "문제없이 실행됩니다", "Tient")
|
||||
add("Fits with SSD streaming",
|
||||
"可以运行,部分内容从 SSD 读取", "SSD から読み込みながら動きます", "Passt mit SSD-Streaming", "Cabe con streaming desde el SSD", "Помещается с потоковой подгрузкой с SSD", "Cabe com streaming do SSD", "SSD 스트리밍으로 실행됩니다", "Tient avec streaming depuis le SSD")
|
||||
add("Doesn't fit",
|
||||
"放不下", "収まりません", "Passt nicht", "No cabe", "Не помещается", "Não cabe", "맞지 않습니다", "Ne tient pas")
|
||||
add("Show details",
|
||||
"显示详情", "詳細を表示", "Details anzeigen", "Mostrar detalles", "Показать подробности", "Mostrar detalhes", "자세히 보기", "Afficher les détails")
|
||||
add("Hide details",
|
||||
"隐藏详情", "詳細を隠す", "Details ausblenden", "Ocultar detalles", "Скрыть подробности", "Ocultar detalhes", "자세히 숨기기", "Masquer les détails")
|
||||
add("{0} RAM + VRAM",
|
||||
"{0} 内存 + 显存", "{0} の RAM + VRAM", "{0} RAM + VRAM", "{0} de RAM + VRAM", "{0} RAM + VRAM", "{0} de RAM + VRAM", "RAM + VRAM {0}", "{0} de RAM + VRAM")
|
||||
|
||||
add("Close preview",
|
||||
"关闭预览", "プレビューを閉じる", "Vorschau schließen", "Cerrar vista previa", "Закрыть предпросмотр", "Fechar pré-visualização", "미리보기 닫기", "Fermer l'aperçu")
|
||||
|
||||
add("Free right now",
|
||||
"当前可用", "現在の空き", "Gerade frei", "Libre ahora", "Свободно сейчас", "Livre agora", "지금 비어 있음", "Libre en ce moment")
|
||||
add("{0} free",
|
||||
"可用 {0}", "空き {0}", "{0} frei", "{0} libres", "свободно {0}", "{0} livres", "{0} 사용 가능", "{0} libres")
|
||||
add("{0}: {1} free of {2}",
|
||||
"{0}:{2} 中有 {1} 可用", "{0}:{2} のうち {1} が空き", "{0}: {1} frei von {2}", "{0}: {1} libres de {2}", "{0}: свободно {1} из {2}", "{0}: {1} livres de {2}", "{0}: {2} 중 {1} 사용 가능", "{0} : {1} libres sur {2}")
|
||||
add("It takes about",
|
||||
"它大约占用", "使用量の目安", "Es belegt etwa", "Ocupa aproximadamente", "Занимает примерно", "Ocupa cerca de", "대략 사용하는 양", "Il occupe environ")
|
||||
add("Nothing. It fits in memory.",
|
||||
"没有。它完全放得进内存。", "ありません。すべてメモリーに収まっています。", "Nichts. Es passt in den Speicher.", "Nada. Cabe en la memoria.", "Ничего. Всё помещается в память.", "Nada. Cabe na memória.", "없음. 메모리에 모두 들어갑니다.", "Rien. Tout tient en mémoire.")
|
||||
add("Reading from your SSD now: {0}",
|
||||
"此刻正从 SSD 读取:{0}", "現在 SSD から読み込み中:{0}", "Gerade vom SSD gelesen: {0}", "Leyendo del SSD ahora: {0}", "Сейчас читается с SSD: {0}", "Lendo do SSD agora: {0}", "지금 SSD에서 읽는 중: {0}", "Lecture depuis le SSD en cours : {0}")
|
||||
add("This version would take about {0} of graphics memory and {1} of RAM.",
|
||||
"这个版本大约会占用 {0} 显存和 {1} 内存。", "このバージョンはグラフィックスメモリーを約 {0}、RAM を約 {1} 使います。", "Diese Version würde etwa {0} Grafikspeicher und {1} RAM belegen.",
|
||||
"Esta versión ocuparía aproximadamente {0} de memoria gráfica y {1} de RAM.", "Эта версия займёт примерно {0} видеопамяти и {1} оперативной памяти.", "Esta versão ocuparia cerca de {0} de memória gráfica e {1} de RAM.",
|
||||
"이 버전은 그래픽 메모리 약 {0}와 RAM 약 {1}을 사용합니다.", "Cette version occuperait environ {0} de mémoire graphique et {1} de RAM.")
|
||||
|
||||
here = Path(__file__).resolve().parent.parent / "src" / "locales"
|
||||
NBSP = " "
|
||||
|
||||
|
||||
def french(text):
|
||||
text = re.sub(r"(?<=[A-Za-zÀ-ſ])'(?=[A-Za-zÀ-ſ{])", "’", text)
|
||||
text = re.sub(r"(?<=\S) ([:;?!])", NBSP + r"\1", text)
|
||||
text = re.sub(r"(?<=[\d}]) %", NBSP + "%", text)
|
||||
return text
|
||||
|
||||
|
||||
for lang in LANGS:
|
||||
data = {}
|
||||
for i, entry in enumerate(S):
|
||||
text = entry[lang]
|
||||
assert sorted(re.findall(r"\{\d+\}", entry["en"])) == sorted(re.findall(r"\{\d+\}", text)), (lang, entry["en"], text)
|
||||
data[f"vp{i + 1:02d}"] = french(text) if lang == "fr" else text
|
||||
(here / f"pick-{lang}.json").write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
|
||||
print("written", len(S), "strings in", len(LANGS), "languages")
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Writes src/locales/r3-<lang>.json: the strings added by the owner's GUI feedback round 3 (branch f-0291-ui-r3).
|
||||
Run from apps/host: python scripts/make-r3-locales.py
|
||||
Every entry names all nine languages; the keys are r3_01.. in the order of S. r3-en.json is the English source. Placeholders ({0}, {1}) must be kept in every
|
||||
language. A separate file keeps this work from colliding with the other catalogs (the same reason pick-, upd-, df2- and the others exist)."""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
LANGS = ["en", "zh", "ja", "de", "es", "ru", "pt", "ko", "fr"]
|
||||
S = []
|
||||
|
||||
|
||||
def add(en, zh, ja, de, es, ru, pt, ko, fr):
|
||||
S.append(dict(en=en, zh=zh, ja=ja, de=de, es=es, ru=ru, pt=pt, ko=ko, fr=fr))
|
||||
|
||||
|
||||
# 2: the Read pictures row while the installed engine has no picture reader (a tooltip, not a paragraph)
|
||||
add("Needs an engine update",
|
||||
"需要更新引擎", "エンジンの更新が必要です", "Braucht ein Engine-Update", "Necesita actualizar el motor", "Нужно обновить движок", "Precisa de uma atualização do motor", "엔진 업데이트가 필요합니다", "Nécessite une mise à jour du moteur")
|
||||
|
||||
# 5: the one word the version list writes for a version that does not fully fit in memory
|
||||
add("SSD streaming · will run slower",
|
||||
"SSD 流式读取 · 运行会更慢", "SSD ストリーミング · 動作は遅くなります", "SSD-Streaming · läuft langsamer", "Streaming desde el SSD · irá más lento", "Потоковая подгрузка с SSD · будет работать медленнее",
|
||||
"Streaming do SSD · vai rodar mais devagar", "SSD 스트리밍 · 더 느리게 실행됩니다", "Streaming depuis le SSD · plus lent")
|
||||
|
||||
# 6: the warning under the conversation context when a long context leaves little graphics memory for the model (nothing is refused or lowered)
|
||||
add("This much context takes about {0} GB of graphics memory and leaves little room for the model on this card. Strata will try it; if it does not start, choose a shorter context.",
|
||||
"这么长的上下文大约占用 {0} GB 显存,留给模型的空间很少。Strata 会尝试运行;如果无法启动,请选择更短的上下文。",
|
||||
"このコンテキストはグラフィックスメモリーを約 {0} GB 使い、このカードではモデルに残る余裕がわずかです。Strata は試しますが、起動しない場合はより短いコンテキストを選んでください。",
|
||||
"So viel Kontext belegt etwa {0} GB Grafikspeicher und lässt auf dieser Karte wenig Platz für das Modell. Strata versucht es; startet es nicht, wähle einen kürzeren Kontext.",
|
||||
"Este contexto ocupa unos {0} GB de memoria gráfica y deja poco espacio para el modelo en esta tarjeta. Strata lo intentará; si no arranca, elige un contexto más corto.",
|
||||
"Такой контекст занимает около {0} ГБ видеопамяти и оставляет модели мало места на этой карте. Strata попробует запустить; если не получится, выберите контекст короче.",
|
||||
"Esse contexto ocupa cerca de {0} GB de memória gráfica e deixa pouco espaço para o modelo nesta placa. O Strata vai tentar; se não iniciar, escolha um contexto menor.",
|
||||
"이 정도의 컨텍스트는 그래픽 메모리를 약 {0} GB 사용하며 이 카드에서는 모델이 쓸 공간이 거의 남지 않습니다. Strata가 시도하지만 시작되지 않으면 더 짧은 컨텍스트를 선택하세요.",
|
||||
"Ce contexte occupe environ {0} Go de mémoire graphique et laisse peu de place au modèle sur cette carte. Strata va essayer ; s'il ne démarre pas, choisissez un contexte plus court.")
|
||||
|
||||
# 9: a chat that is working while the person is elsewhere (the ring on its row, and the model chip's line)
|
||||
add("Working · {0}",
|
||||
"进行中 · {0}", "作業中 · {0}", "In Arbeit · {0}", "Trabajando · {0}", "В работе · {0}", "Trabalhando · {0}", "작업 중 · {0}", "En cours de travail · {0}")
|
||||
add("{0} chats working",
|
||||
"{0} 个对话进行中", "{0} 件のチャットが作業中", "{0} Chats in Arbeit", "{0} chats trabajando", "В работе чатов: {0}", "{0} chats trabalhando", "대화 {0}개 작업 중", "{0} conversations en cours")
|
||||
|
||||
# 10: the size line of a file being written
|
||||
add("{0} lines",
|
||||
"{0} 行", "{0} 行", "{0} Zeilen", "{0} líneas", "{0} строк", "{0} linhas", "{0}줄", "{0} lignes")
|
||||
|
||||
#@@MORE@@
|
||||
|
||||
here = Path(__file__).resolve().parent.parent / "src" / "locales"
|
||||
NBSP = " "
|
||||
|
||||
|
||||
def french(text):
|
||||
text = re.sub(r"(?<=[A-Za-zÀ-ſ])'(?=[A-Za-zÀ-ſ{])", "’", text)
|
||||
text = re.sub(r"(?<=\S) ([:;?!])", NBSP + r"\1", text)
|
||||
text = re.sub(r"(?<=[\d}]) %", NBSP + "%", text)
|
||||
return text
|
||||
|
||||
|
||||
for lang in LANGS:
|
||||
data = {}
|
||||
for i, entry in enumerate(S):
|
||||
text = entry[lang]
|
||||
assert sorted(re.findall(r"\{\d+\}", entry["en"])) == sorted(re.findall(r"\{\d+\}", text)), (lang, entry["en"], text)
|
||||
data[f"r3_{i + 1:02d}"] = french(text) if lang == "fr" else text
|
||||
(here / f"r3-{lang}.json").write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
|
||||
print("written", len(S), "strings in", len(LANGS), "languages")
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Writes src/locales/ui-<lang>.json: the interface strings of the 0.2.10 interface changes (branch f-0291-ui).
|
||||
Run from apps/host: python scripts/make-ui-locales.py
|
||||
Every entry names all nine languages; the keys are ui01.. in the order of S. ui-en.json is the English source. Placeholders ({0}, {1}) must be
|
||||
kept in every language. A separate file keeps this work from colliding with the other catalogs (the same reason tune-, tools-, fix- and gh- exist)."""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
LANGS = ["en", "zh", "ja", "de", "es", "ru", "pt", "ko", "fr"]
|
||||
S = []
|
||||
|
||||
|
||||
def add(**kw):
|
||||
assert sorted(kw) == sorted(LANGS), sorted(set(LANGS) ^ set(kw))
|
||||
S.append(kw)
|
||||
|
||||
|
||||
# #4725 the approval stays in view
|
||||
add(en="Waiting for your answer",
|
||||
zh="等待你的回答", ja="あなたの返答を待っています", de="Wartet auf deine Antwort", es="Esperando tu respuesta",
|
||||
ru="Ждёт вашего ответа", pt="Aguardando a sua resposta", ko="답변을 기다리는 중", fr="En attente de votre réponse")
|
||||
add(en="Waiting for your answer below",
|
||||
zh="等待你在下方回答", ja="下で返答をお待ちしています", de="Wartet unten auf deine Antwort", es="Esperando tu respuesta abajo",
|
||||
ru="Ждёт вашего ответа ниже", pt="Aguardando a sua resposta abaixo", ko="아래에서 답변을 기다리는 중", fr="En attente de votre réponse ci-dessous")
|
||||
|
||||
# #4804 why Start tuning is greyed out
|
||||
add(en="Strata is still checking this model for saved tunings. Start tuning works as soon as that is done.",
|
||||
zh="Strata 仍在检查此模型是否有已保存的调优。检查完成后即可开始调优。",
|
||||
ja="Strata がこのモデルの保存済みチューニングを確認しています。確認が終わりしだい、チューニングを開始できます。",
|
||||
de="Strata prüft dieses Modell noch auf gespeicherte Abstimmungen. Sobald das fertig ist, kannst du die Abstimmung starten.",
|
||||
es="Strata todavía está comprobando si este modelo tiene ajustes guardados. Podrás empezar el ajuste en cuanto termine.",
|
||||
ru="Strata ещё проверяет, есть ли у этой модели сохранённые настройки. Как только проверка закончится, можно будет запустить настройку.",
|
||||
pt="O Strata ainda está verificando se este modelo tem ajustes salvos. Assim que terminar, você poderá iniciar o ajuste.",
|
||||
ko="Strata가 이 모델의 저장된 최적화를 확인하는 중입니다. 확인이 끝나면 바로 최적화를 시작할 수 있습니다.",
|
||||
fr="Strata vérifie encore si ce modèle a des optimisations enregistrées. Dès que c’est fini, vous pourrez lancer l’optimisation.")
|
||||
add(en="Strata could not read the tuning details for this model, so tuning cannot start. Close this window and open it again.",
|
||||
zh="Strata 无法读取此模型的调优信息,因此无法开始调优。请关闭此窗口后重新打开。",
|
||||
ja="Strata がこのモデルのチューニング情報を読み取れなかったため、チューニングを開始できません。このウィンドウを閉じて、もう一度開いてください。",
|
||||
de="Strata konnte die Abstimmungsdaten dieses Modells nicht lesen, deshalb lässt sich die Abstimmung nicht starten. Schließe dieses Fenster und öffne es erneut.",
|
||||
es="Strata no pudo leer los datos de ajuste de este modelo, así que el ajuste no puede empezar. Cierra esta ventana y ábrela de nuevo.",
|
||||
ru="Strata не смогла прочитать данные настройки этой модели, поэтому настройку нельзя запустить. Закройте это окно и откройте его снова.",
|
||||
pt="O Strata não conseguiu ler os dados de ajuste deste modelo, então o ajuste não pode começar. Feche esta janela e abra-a novamente.",
|
||||
ko="Strata가 이 모델의 최적화 정보를 읽지 못해 최적화를 시작할 수 없습니다. 이 창을 닫았다가 다시 여세요.",
|
||||
fr="Strata n’a pas pu lire les données d’optimisation de ce modèle, l’optimisation ne peut donc pas démarrer. Fermez cette fenêtre et rouvrez-la.")
|
||||
add(en="Checking for saved tunings\u2026",
|
||||
zh="正在检查已保存的调优\u2026", ja="保存済みチューニングを確認しています\u2026", de="Gespeicherte Abstimmungen werden geprüft\u2026", es="Comprobando los ajustes guardados\u2026",
|
||||
ru="Проверяем сохранённые настройки\u2026", pt="Verificando os ajustes salvos\u2026", ko="저장된 최적화를 확인하는 중\u2026", fr="Vérification des optimisations enregistrées\u2026")
|
||||
|
||||
# #4802 the progress card
|
||||
add(en="Still working for {0}. You don't need to do anything.",
|
||||
zh="仍在处理,已用时 {0}。你无需做任何操作。",
|
||||
ja="処理中です({0}経過)。操作は必要ありません。",
|
||||
de="Läuft noch seit {0}. Du musst nichts tun.",
|
||||
es="Sigue trabajando desde hace {0}. No tienes que hacer nada.",
|
||||
ru="Всё ещё работает: {0}. От вас ничего не требуется.",
|
||||
pt="Ainda trabalhando há {0}. Você não precisa fazer nada.",
|
||||
ko="{0}째 작업 중입니다. 따로 할 일은 없습니다.",
|
||||
fr="Toujours en cours depuis {0}. Vous n'avez rien à faire.")
|
||||
add(en="The buttons below wait until this finishes.",
|
||||
zh="下方的按钮会在此操作完成后恢复可用。",
|
||||
ja="この処理が終わるまで、下のボタンは使えません。",
|
||||
de="Die Schaltflächen unten warten, bis das fertig ist.",
|
||||
es="Los botones de abajo esperan a que esto termine.",
|
||||
ru="Кнопки ниже подождут, пока это не закончится.",
|
||||
pt="Os botões abaixo esperam até isto terminar.",
|
||||
ko="아래 버튼은 이 작업이 끝날 때까지 기다립니다.",
|
||||
fr="Les boutons ci-dessous attendent la fin de cette opération.")
|
||||
|
||||
# #4798 two addresses
|
||||
add(en="API base URL on this computer",
|
||||
zh="此电脑上的 API 基础地址", ja="このパソコンでの API ベース URL", de="API-Basis-URL auf diesem Computer", es="URL base de la API en este equipo",
|
||||
ru="Базовый URL API на этом компьютере", pt="URL base da API neste computador", ko="이 컴퓨터의 API 기본 URL", fr="URL de base de l'API sur cet ordinateur")
|
||||
add(en="Address for other devices",
|
||||
zh="其他设备使用的地址", ja="他のデバイス用のアドレス", de="Adresse für andere Geräte", es="Dirección para otros dispositivos",
|
||||
ru="Адрес для других устройств", pt="Endereço para outros dispositivos", ko="다른 기기용 주소", fr="Adresse pour les autres appareils")
|
||||
add(en="Other devices use this address. It has its own port, separate from the one Strata's window uses, because only the model's API is shared on the network, not the app.",
|
||||
zh="其他设备使用此地址。它有自己的端口,与 Strata 窗口使用的端口分开,因为网络上只共享模型的 API,而不是应用本身。",
|
||||
ja="他のデバイスはこのアドレスを使います。ネットワークで共有されるのはモデルの API だけでアプリ本体ではないため、Strata のウィンドウが使うポートとは別のポートを使います。",
|
||||
de="Andere Geräte verwenden diese Adresse. Sie hat einen eigenen Port, getrennt von dem des Strata-Fensters, weil im Netzwerk nur die API des Modells freigegeben wird, nicht die App.",
|
||||
es="Los demás dispositivos usan esta dirección. Tiene su propio puerto, distinto del que usa la ventana de Strata, porque en la red solo se comparte la API del modelo, no la aplicación.",
|
||||
ru="Другие устройства используют этот адрес. У него свой порт, отдельный от порта окна Strata, потому что в сети доступен только API модели, а не само приложение.",
|
||||
pt="Os outros dispositivos usam este endereço. Ele tem a própria porta, separada da que a janela do Strata usa, porque só a API do modelo é compartilhada na rede, não o aplicativo.",
|
||||
ko="다른 기기는 이 주소를 사용합니다. 네트워크에는 앱이 아니라 모델의 API만 공유되므로, Strata 창이 쓰는 포트와는 별도의 포트를 씁니다.",
|
||||
fr="Les autres appareils utilisent cette adresse. Elle a son propre port, distinct de celui de la fenêtre de Strata, car seule l'API du modèle est partagée sur le réseau, pas l'application.")
|
||||
|
||||
# #3723 Context memory and the out-of-graphics-memory reason
|
||||
add(en="INT8 is the standard: good quality and a medium amount of memory.",
|
||||
zh="INT8 是标准选项:质量好,内存占用适中。",
|
||||
ja="INT8 は標準の設定です。品質が良く、メモリ使用量は中程度です。",
|
||||
de="INT8 ist der Standard: gute Qualität und mittlerer Speicherbedarf.",
|
||||
es="INT8 es el estándar: buena calidad y un uso de memoria medio.",
|
||||
ru="INT8 — стандартный вариант: хорошее качество и средний расход памяти.",
|
||||
pt="INT8 é o padrão: boa qualidade e uso médio de memória.",
|
||||
ko="INT8은 표준 설정입니다. 품질이 좋고 메모리 사용량은 보통입니다.",
|
||||
fr="INT8 est le réglage standard : bonne qualité et une consommation de mémoire moyenne.")
|
||||
add(en="Q4 uses about half the memory of INT8, but is less precise on very long documents.",
|
||||
zh="Q4 占用的内存约为 INT8 的一半,但在很长的文档上精度较低。",
|
||||
ja="Q4 は INT8 の約半分のメモリで済みますが、非常に長い文書では精度が少し下がります。",
|
||||
de="Q4 braucht etwa halb so viel Speicher wie INT8, ist aber bei sehr langen Dokumenten weniger genau.",
|
||||
es="Q4 usa aproximadamente la mitad de memoria que INT8, pero es menos preciso con documentos muy largos.",
|
||||
ru="Q4 занимает примерно вдвое меньше памяти, чем INT8, но менее точен на очень длинных документах.",
|
||||
pt="Q4 usa cerca de metade da memória do INT8, mas é menos preciso em documentos muito longos.",
|
||||
ko="Q4는 INT8의 약 절반 메모리를 쓰지만, 아주 긴 문서에서는 정밀도가 떨어집니다.",
|
||||
fr="Q4 utilise environ moitié moins de mémoire que INT8, mais il est moins précis sur de très longs documents.")
|
||||
add(en="K8 / V4 uses about a quarter less memory than INT8 and is more precise than Q4. Prompts are 2 to 5% slower.",
|
||||
zh="K8 / V4 比 INT8 约少占用四分之一的内存,精度高于 Q4。处理提示词会慢 2% 到 5%。",
|
||||
ja="K8 / V4 は INT8 より約4分の1少ないメモリで済み、Q4 より精度が高い設定です。プロンプトの処理は 2〜5% 遅くなります。",
|
||||
de="K8 / V4 braucht etwa ein Viertel weniger Speicher als INT8 und ist genauer als Q4. Prompts sind 2 bis 5 % langsamer.",
|
||||
es="K8 / V4 usa aproximadamente una cuarta parte menos de memoria que INT8 y es más preciso que Q4. Los prompts son entre un 2 y un 5 % más lentos.",
|
||||
ru="K8 / V4 занимает примерно на четверть меньше памяти, чем INT8, и точнее, чем Q4. Запросы обрабатываются на 2–5 % медленнее.",
|
||||
pt="K8 / V4 usa cerca de um quarto a menos de memória que o INT8 e é mais preciso que o Q4. Os prompts ficam de 2 a 5 % mais lentos.",
|
||||
ko="K8 / V4는 INT8보다 메모리를 약 4분의 1 적게 쓰고 Q4보다 정밀합니다. 프롬프트 처리는 2~5% 느려집니다.",
|
||||
fr="K8 / V4 utilise environ un quart de mémoire en moins que INT8 et est plus précis que Q4. Les prompts sont 2 à 5 % plus lents.")
|
||||
add(en="The model did not fit in your graphics card’s memory.",
|
||||
zh="模型放不进你的显卡显存。",
|
||||
ja="モデルがグラフィックスカードのメモリに収まりませんでした。",
|
||||
de="Das Modell hat nicht in den Speicher deiner Grafikkarte gepasst.",
|
||||
es="El modelo no cabía en la memoria de tu tarjeta gráfica.",
|
||||
ru="Модель не поместилась в память вашей видеокарты.",
|
||||
pt="O modelo não coube na memória da sua placa de vídeo.",
|
||||
ko="모델이 그래픽 카드 메모리에 들어가지 않았습니다.",
|
||||
fr="Le modèle ne tenait pas dans la mémoire de votre carte graphique.")
|
||||
add(en="Lower the conversation context, or set Context memory to Q4 in Expert settings, then try again.",
|
||||
zh="请调低对话上下文,或在专家设置中把“上下文内存”设为 Q4,然后重试。",
|
||||
ja="会話のコンテキストを小さくするか、上級設定で「コンテキストメモリー」を Q4 にして、もう一度お試しください。",
|
||||
de="Verkleinere den Gesprächskontext oder stelle in den Experteneinstellungen „Kontextspeicher“ auf Q4 und versuche es erneut.",
|
||||
es="Reduce el contexto de la conversación o pon «Memoria de contexto» en Q4 en Ajustes expertos, y vuelve a intentarlo.",
|
||||
ru="Уменьшите контекст разговора или установите «Память контекста» в Q4 в разделе «Экспертные настройки» и повторите попытку.",
|
||||
pt="Reduza o contexto da conversa ou defina «Memória de contexto» como Q4 em Configurações avançadas e tente de novo.",
|
||||
ko="대화 컨텍스트를 줄이거나 전문가 설정에서 ‘컨텍스트 메모리’를 Q4로 바꾼 뒤 다시 시도하세요.",
|
||||
fr="Réduisez le contexte de la conversation, ou réglez « Mémoire de contexte » sur Q4 dans Réglages experts, puis réessayez.")
|
||||
|
||||
# #4749 a message for a helper
|
||||
add(en="Message this helper",
|
||||
zh="给这个助手发消息", ja="このヘルパーにメッセージを送る", de="Diesem Helfer schreiben", es="Escribir a este ayudante",
|
||||
ru="Написать этому помощнику", pt="Enviar mensagem a este ajudante", ko="이 도우미에게 메시지 보내기", fr="Écrire à cet assistant")
|
||||
add(en="Message to {0}",
|
||||
zh="发给 {0} 的消息", ja="{0} へのメッセージ", de="Nachricht an {0}", es="Mensaje para {0}",
|
||||
ru="Сообщение для {0}", pt="Mensagem para {0}", ko="{0}에게 보낼 메시지", fr="Message pour {0}")
|
||||
add(en="Write to {0}\u2026",
|
||||
zh="写给 {0}\u2026", ja="{0} に書く\u2026", de="An {0} schreiben\u2026", es="Escribe a {0}\u2026",
|
||||
ru="Напишите {0}\u2026", pt="Escreva para {0}\u2026", ko="{0}에게 쓰기\u2026", fr="Écrire à {0}\u2026")
|
||||
add(en="Sent to {0}.",
|
||||
zh="已发给 {0}。", ja="{0} に送りました。", de="An {0} gesendet.", es="Enviado a {0}.",
|
||||
ru="Отправлено: {0}.", pt="Enviado para {0}.", ko="{0}에게 보냈습니다.", fr="Envoyé à {0}.")
|
||||
|
||||
# #4783 a message while an answer is being written
|
||||
add(en="While an answer is being written",
|
||||
zh="回答生成期间", ja="回答の作成中", de="Während eine Antwort geschrieben wird", es="Mientras se escribe una respuesta",
|
||||
ru="Пока пишется ответ", pt="Enquanto uma resposta é escrita", ko="답변을 작성하는 동안", fr="Pendant qu'une réponse s'écrit")
|
||||
add(en="What Enter does when you send a message while the assistant is still answering.",
|
||||
zh="助手仍在回答时,你发送消息后按回车会发生什么。", ja="アシスタントがまだ回答している間にメッセージを送ったとき、Enter で何が起こるかを選びます。",
|
||||
de="Was die Eingabetaste bewirkt, wenn du eine Nachricht sendest, während der Assistent noch antwortet.",
|
||||
es="Qué hace Intro cuando envías un mensaje mientras el asistente todavía está respondiendo.",
|
||||
ru="Что делает Enter, когда вы отправляете сообщение, пока ассистент ещё отвечает.",
|
||||
pt="O que o Enter faz quando você envia uma mensagem enquanto o assistente ainda está respondendo.",
|
||||
ko="어시스턴트가 아직 답하는 중에 메시지를 보낼 때 Enter가 하는 일입니다.",
|
||||
fr="Ce que fait Entrée quand vous envoyez un message pendant que l'assistant répond encore.")
|
||||
add(en="Wait for the answer",
|
||||
zh="等回答完成", ja="回答が終わるまで待つ", de="Auf die Antwort warten", es="Esperar la respuesta",
|
||||
ru="Дождаться ответа", pt="Esperar a resposta", ko="답변이 끝날 때까지 기다리기", fr="Attendre la réponse")
|
||||
add(en="Send after the answer",
|
||||
zh="回答完成后再发送", ja="回答が終わってから送る", de="Nach der Antwort senden", es="Enviar después de la respuesta",
|
||||
ru="Отправить после ответа", pt="Enviar depois da resposta", ko="답변이 끝난 뒤 보내기", fr="Envoyer après la réponse")
|
||||
add(en="Interrupt the answer",
|
||||
zh="打断回答", ja="回答を中断する", de="Die Antwort unterbrechen", es="Interrumpir la respuesta",
|
||||
ru="Прервать ответ", pt="Interromper a resposta", ko="답변 중단하기", fr="Interrompre la réponse")
|
||||
add(en="Enter sends it when the answer is done.",
|
||||
zh="回答完成后,按回车即可发送。", ja="回答が終わったら Enter で送信されます。", de="Die Eingabetaste sendet sie, sobald die Antwort fertig ist.",
|
||||
es="Intro lo envía cuando termina la respuesta.", ru="Enter отправит сообщение, когда ответ закончится.",
|
||||
pt="O Enter envia quando a resposta terminar.", ko="답변이 끝나면 Enter로 보냅니다.", fr="Entrée l'envoie quand la réponse est terminée.")
|
||||
add(en="Enter stops the answer here and sends it now.",
|
||||
zh="按回车会在此处停止回答并立即发送。", ja="Enter で回答をここで止めて、すぐに送信します。", de="Die Eingabetaste stoppt die Antwort hier und sendet die Nachricht sofort.",
|
||||
es="Intro detiene la respuesta aquí y lo envía ahora.", ru="Enter остановит ответ здесь и сразу отправит сообщение.",
|
||||
pt="O Enter para a resposta aqui e envia agora.", ko="Enter를 누르면 여기서 답변을 멈추고 바로 보냅니다.", fr="Entrée arrête la réponse ici et l'envoie tout de suite.")
|
||||
add(en="Waiting to send",
|
||||
zh="等待发送", ja="送信待ち", de="Wartet auf das Senden", es="Esperando para enviar",
|
||||
ru="Ждёт отправки", pt="Aguardando envio", ko="보내기 대기 중", fr="En attente d'envoi")
|
||||
add(en="Take back",
|
||||
zh="撤回", ja="取り消す", de="Zurücknehmen", es="Retirar",
|
||||
ru="Забрать обратно", pt="Desfazer", ko="되돌리기", fr="Reprendre")
|
||||
|
||||
# #4785 quoting
|
||||
add(en="Quote",
|
||||
zh="引用", ja="引用", de="Zitieren", es="Citar",
|
||||
ru="Цитировать", pt="Citar", ko="인용", fr="Citer")
|
||||
add(en="Quoting the assistant",
|
||||
zh="引用助手的话", ja="アシスタントの発言を引用", de="Zitat des Assistenten", es="Citando al asistente",
|
||||
ru="Цитата ассистента", pt="Citando o assistente", ko="어시스턴트의 말을 인용", fr="Citation de l'assistant")
|
||||
add(en="Quoting you",
|
||||
zh="引用你的话", ja="あなたの発言を引用", de="Zitat von dir", es="Citándote a ti",
|
||||
ru="Ваша цитата", pt="Citando você", ko="내 말을 인용", fr="Citation de vous")
|
||||
add(en="Remove this quote",
|
||||
zh="移除这条引用", ja="この引用を外す", de="Dieses Zitat entfernen", es="Quitar esta cita",
|
||||
ru="Убрать эту цитату", pt="Remover esta citação", ko="이 인용 제거", fr="Retirer cette citation")
|
||||
add(en="Go to the quoted message",
|
||||
zh="跳到被引用的消息", ja="引用元のメッセージへ移動", de="Zur zitierten Nachricht springen", es="Ir al mensaje citado",
|
||||
ru="Перейти к цитируемому сообщению", pt="Ir para a mensagem citada", ko="인용한 메시지로 이동", fr="Aller au message cité")
|
||||
add(en="Quoted",
|
||||
zh="引用", ja="引用", de="Zitiert", es="Citado",
|
||||
ru="Цитата", pt="Citado", ko="인용됨", fr="Cité")
|
||||
add(en="Show or hide the rest of the quote",
|
||||
zh="显示或隐藏引用的其余部分", ja="引用の続きを表示または非表示", de="Rest des Zitats ein- oder ausblenden", es="Mostrar u ocultar el resto de la cita",
|
||||
ru="Показать или скрыть остальную часть цитаты", pt="Mostrar ou ocultar o resto da citação", ko="인용의 나머지 부분 보기 또는 숨기기", fr="Afficher ou masquer la suite de la citation")
|
||||
|
||||
# #4244 the agent's own questions
|
||||
add(en="Skip",
|
||||
zh="跳过", ja="スキップ", de="Überspringen", es="Omitir",
|
||||
ru="Пропустить", pt="Pular", ko="건너뛰기", fr="Passer")
|
||||
add(en="The assistant has a question for you",
|
||||
zh="助手有一个问题要问你", ja="アシスタントからの質問があります", de="Der Assistent hat eine Frage an dich", es="El asistente tiene una pregunta para ti",
|
||||
ru="У ассистента есть вопрос к вам", pt="O assistente tem uma pergunta para você", ko="어시스턴트가 질문이 있습니다", fr="L'assistant a une question pour vous")
|
||||
add(en="Type your own answer",
|
||||
zh="输入你自己的回答", ja="自分の答えを入力", de="Eigene Antwort eingeben", es="Escribe tu propia respuesta",
|
||||
ru="Введите свой ответ", pt="Digite a sua própria resposta", ko="직접 답변 입력", fr="Saisissez votre propre réponse")
|
||||
add(en="Send answer",
|
||||
zh="发送回答", ja="回答を送る", de="Antwort senden", es="Enviar respuesta",
|
||||
ru="Отправить ответ", pt="Enviar resposta", ko="답변 보내기", fr="Envoyer la réponse")
|
||||
add(en="Questions for you",
|
||||
zh="向你提问", ja="あなたへの質問", de="Fragen an dich", es="Preguntas para ti",
|
||||
ru="Вопросы к вам", pt="Perguntas para você", ko="내게 하는 질문", fr="Questions pour vous")
|
||||
add(en="Lets the assistant stop and ask you a question, with answers to pick from, when it needs your decision.",
|
||||
zh="当助手需要你做决定时,可以暂停并向你提问,并给出可选的答案。", ja="アシスタントがあなたの判断を必要とするとき、選択肢つきで質問できるようにします。",
|
||||
de="Der Assistent kann anhalten und dir eine Frage mit Antworten zur Auswahl stellen, wenn er deine Entscheidung braucht.",
|
||||
es="Permite que el asistente se detenga y te haga una pregunta, con respuestas para elegir, cuando necesita tu decisión.",
|
||||
ru="Позволяет ассистенту остановиться и задать вам вопрос с вариантами ответа, когда ему нужно ваше решение.",
|
||||
pt="Permite que o assistente pare e faça uma pergunta, com respostas para escolher, quando precisa da sua decisão.",
|
||||
ko="어시스턴트가 결정이 필요할 때 멈추고 고를 수 있는 답과 함께 질문할 수 있게 합니다.",
|
||||
fr="Permet à l'assistant de s'arrêter et de vous poser une question, avec des réponses à choisir, quand il a besoin de votre décision.")
|
||||
|
||||
here = Path(__file__).resolve().parent.parent / "src" / "locales"
|
||||
NBSP = " "
|
||||
|
||||
|
||||
def french(text):
|
||||
text = re.sub(r"(?<=[A-Za-zÀ-ſ])'(?=[A-Za-zÀ-ſ{])", "’", text)
|
||||
text = re.sub(r"(?<=\S) ([:;?!])", NBSP + r"\1", text)
|
||||
text = re.sub(r"(?<=[\d}]) %", NBSP + "%", text)
|
||||
return text
|
||||
|
||||
|
||||
for lang in LANGS:
|
||||
data = {}
|
||||
for i, entry in enumerate(S):
|
||||
text = entry[lang]
|
||||
assert sorted(re.findall(r"\{\d+\}", entry["en"])) == sorted(re.findall(r"\{\d+\}", text)), (lang, entry["en"], text)
|
||||
data[f"ui{i + 1:02d}"] = french(text) if lang == "fr" else text
|
||||
(here / f"ui-{lang}.json").write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
|
||||
print("written", len(S), "strings in", len(LANGS), "languages")
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Writes src/locales/upd-<lang>.json: the strings of the engine update beside a loaded model (the dialog of Settings > About, branch f-0291-ui-final).
|
||||
Run from apps/host: python scripts/make-update-locales.py
|
||||
Every entry names all nine languages; the keys are upd01.. in the order of S. upd-en.json is the English source. Placeholders ({0}) must be kept in every
|
||||
language. A separate file keeps this work from colliding with the other catalogs (the same reason pick-, tune-, tools-, fix-, gh-, ui-, engine- and df2- exist)."""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
LANGS = ["en", "zh", "ja", "de", "es", "ru", "pt", "ko", "fr"]
|
||||
S = []
|
||||
|
||||
|
||||
def add(en, zh, ja, de, es, ru, pt, ko, fr):
|
||||
S.append(dict(en=en, zh=zh, ja=ja, de=de, es=es, ru=ru, pt=pt, ko=ko, fr=fr))
|
||||
|
||||
|
||||
add("To update the engine, Strata has to stop the model that is loaded: {0}.",
|
||||
"要更新引擎,Strata 必须先停止当前已加载的模型:{0}。",
|
||||
"エンジンを更新するには、読み込み中のモデルを Strata が停止する必要があります:{0}。",
|
||||
"Um die Engine zu aktualisieren, muss Strata das geladene Modell anhalten: {0}.",
|
||||
"Para actualizar el motor, Strata tiene que detener el modelo cargado: {0}.",
|
||||
"Чтобы обновить движок, Strata должна остановить загруженную модель: {0}.",
|
||||
"Para atualizar o motor, o Strata precisa parar o modelo carregado: {0}.",
|
||||
"엔진을 업데이트하려면 Strata가 불러온 모델을 중지해야 합니다: {0}.",
|
||||
"Pour mettre à jour le moteur, Strata doit arrêter le modèle chargé : {0}.")
|
||||
add("The new engine downloads first while the model keeps answering. Then the model stops (answers in progress finish first), the engine is updated, and you can start the model again with the same settings. Your models and settings are kept.",
|
||||
"新引擎会先下载,此时模型仍可继续回答。然后模型停止(正在进行的回答会先完成),引擎随即更新,之后你可以用相同的设置再次启动该模型。你的模型和设置都会保留。",
|
||||
"まず新しいエンジンをダウンロードします。その間、モデルは応答を続けます。その後モデルが停止し(進行中の回答は先に完了します)、エンジンが更新され、同じ設定でモデルをもう一度起動できます。モデルと設定はそのまま残ります。",
|
||||
"Zuerst wird die neue Engine heruntergeladen, während das Modell weiter antwortet. Danach hält das Modell an (laufende Antworten werden zuerst beendet), die Engine wird aktualisiert, und Sie können das Modell mit denselben Einstellungen wieder starten. Ihre Modelle und Einstellungen bleiben erhalten.",
|
||||
"Primero se descarga el motor nuevo mientras el modelo sigue respondiendo. Después el modelo se detiene (las respuestas en curso terminan antes), se actualiza el motor y puedes volver a iniciar el modelo con los mismos ajustes. Tus modelos y ajustes se conservan.",
|
||||
"Сначала новый движок скачивается, пока модель продолжает отвечать. Затем модель останавливается (ответы в процессе сначала завершаются), движок обновляется, и вы можете снова запустить модель с теми же настройками. Ваши модели и настройки сохраняются.",
|
||||
"Primeiro o novo motor é baixado enquanto o modelo continua a responder. Depois o modelo para (as respostas em curso terminam antes), o motor é atualizado e pode iniciar o modelo de novo com as mesmas definições. Os seus modelos e definições são mantidos.",
|
||||
"먼저 모델이 계속 응답하는 동안 새 엔진을 다운로드합니다. 그다음 모델이 중지되고(진행 중인 답변은 먼저 끝납니다) 엔진이 업데이트되며, 같은 설정으로 모델을 다시 시작할 수 있습니다. 모델과 설정은 그대로 유지됩니다.",
|
||||
"Le nouveau moteur est d'abord téléchargé pendant que le modèle continue de répondre. Ensuite le modèle s'arrête (les réponses en cours se terminent d'abord), le moteur est mis à jour, et vous pouvez redémarrer le modèle avec les mêmes réglages. Vos modèles et réglages sont conservés.")
|
||||
add("Stop and update", "停止并更新", "停止して更新", "Anhalten und aktualisieren", "Detener y actualizar", "Остановить и обновить", "Parar e atualizar", "중지하고 업데이트", "Arrêter et mettre à jour")
|
||||
add("Getting the new engine ready. {0} keeps working until it is ready.",
|
||||
"正在准备新引擎。在准备好之前,{0} 会继续工作。",
|
||||
"新しいエンジンを準備しています。準備ができるまで {0} は動き続けます。",
|
||||
"Die neue Engine wird vorbereitet. {0} arbeitet weiter, bis sie bereit ist.",
|
||||
"Preparando el motor nuevo. {0} sigue funcionando hasta que esté listo.",
|
||||
"Готовим новый движок. {0} продолжает работать, пока он не будет готов.",
|
||||
"A preparar o novo motor. {0} continua a funcionar até estar pronto.",
|
||||
"새 엔진을 준비하는 중입니다. 준비될 때까지 {0}은(는) 계속 작동합니다.",
|
||||
"Préparation du nouveau moteur. {0} continue de fonctionner jusqu'à ce qu'il soit prêt.")
|
||||
add("Stopping {0}. Answers in progress finish first.",
|
||||
"正在停止 {0}。正在进行的回答会先完成。",
|
||||
"{0} を停止しています。進行中の回答は先に完了します。",
|
||||
"{0} wird angehalten. Laufende Antworten werden zuerst beendet.",
|
||||
"Deteniendo {0}. Las respuestas en curso terminan antes.",
|
||||
"Останавливаем {0}. Ответы в процессе сначала завершаются.",
|
||||
"A parar {0}. As respostas em curso terminam antes.",
|
||||
"{0}을(를) 중지하는 중입니다. 진행 중인 답변은 먼저 끝납니다.",
|
||||
"Arrêt de {0}. Les réponses en cours se terminent d'abord.")
|
||||
add("{0} is stopped. Updating the engine…",
|
||||
"{0} 已停止。正在更新引擎…",
|
||||
"{0} を停止しました。エンジンを更新しています…",
|
||||
"{0} ist angehalten. Die Engine wird aktualisiert…",
|
||||
"{0} está detenido. Actualizando el motor…",
|
||||
"{0} остановлена. Обновляем движок…",
|
||||
"{0} está parado. A atualizar o motor…",
|
||||
"{0}이(가) 중지되었습니다. 엔진을 업데이트하는 중입니다…",
|
||||
"{0} est arrêté. Mise à jour du moteur…")
|
||||
add("Updating the engine", "正在更新引擎", "エンジンを更新しています", "Engine wird aktualisiert", "Actualizando el motor", "Обновление движка", "A atualizar o motor", "엔진 업데이트 중", "Mise à jour du moteur")
|
||||
add("The engine is updated", "引擎已更新", "エンジンを更新しました", "Die Engine ist aktualisiert", "El motor está actualizado", "Движок обновлён", "O motor está atualizado", "엔진이 업데이트되었습니다", "Le moteur est à jour")
|
||||
add("The engine was not updated", "引擎未更新", "エンジンは更新されませんでした", "Die Engine wurde nicht aktualisiert", "El motor no se actualizó", "Движок не обновлён", "O motor não foi atualizado", "엔진이 업데이트되지 않았습니다", "Le moteur n'a pas été mis à jour")
|
||||
add("The engine is updated. Start {0} again with the same settings?",
|
||||
"引擎已更新。要用相同的设置再次启动 {0} 吗?",
|
||||
"エンジンを更新しました。同じ設定で {0} をもう一度起動しますか?",
|
||||
"Die Engine ist aktualisiert. {0} mit denselben Einstellungen wieder starten?",
|
||||
"El motor está actualizado. ¿Iniciar {0} de nuevo con los mismos ajustes?",
|
||||
"Движок обновлён. Запустить {0} снова с теми же настройками?",
|
||||
"O motor está atualizado. Iniciar {0} de novo com as mesmas definições?",
|
||||
"엔진이 업데이트되었습니다. 같은 설정으로 {0}을(를) 다시 시작할까요?",
|
||||
"Le moteur est à jour. Redémarrer {0} avec les mêmes réglages ?")
|
||||
add("The update didn't finish. Your old engine is unchanged and still works. Start {0} again on it?",
|
||||
"更新没有完成。你原来的引擎没有改动,仍然可用。要在它上面再次启动 {0} 吗?",
|
||||
"更新は完了しませんでした。以前のエンジンは変更されておらず、そのまま使えます。そのエンジンで {0} をもう一度起動しますか?",
|
||||
"Die Aktualisierung wurde nicht abgeschlossen. Ihre alte Engine ist unverändert und funktioniert weiter. {0} damit wieder starten?",
|
||||
"La actualización no terminó. Tu motor anterior no cambió y sigue funcionando. ¿Iniciar {0} de nuevo con él?",
|
||||
"Обновление не завершилось. Прежний движок не изменён и работает. Запустить {0} снова на нём?",
|
||||
"A atualização não terminou. O seu motor anterior não foi alterado e continua a funcionar. Iniciar {0} de novo com ele?",
|
||||
"업데이트가 끝나지 않았습니다. 기존 엔진은 그대로이며 계속 작동합니다. 기존 엔진으로 {0}을(를) 다시 시작할까요?",
|
||||
"La mise à jour ne s'est pas terminée. Votre ancien moteur est inchangé et fonctionne toujours. Redémarrer {0} avec lui ?")
|
||||
add("Not now", "暂不", "今はしない", "Jetzt nicht", "Ahora no", "Не сейчас", "Agora não", "나중에", "Pas maintenant")
|
||||
add("Starting {0}. Loading can take a few minutes.",
|
||||
"正在启动 {0}。加载可能需要几分钟。",
|
||||
"{0} を起動しています。読み込みには数分かかることがあります。",
|
||||
"{0} wird gestartet. Das Laden kann einige Minuten dauern.",
|
||||
"Iniciando {0}. La carga puede tardar unos minutos.",
|
||||
"Запускаем {0}. Загрузка может занять несколько минут.",
|
||||
"A iniciar {0}. O carregamento pode demorar alguns minutos.",
|
||||
"{0}을(를) 시작하는 중입니다. 불러오는 데 몇 분이 걸릴 수 있습니다.",
|
||||
"Démarrage de {0}. Le chargement peut prendre quelques minutes.")
|
||||
add("Starting {0} again", "正在再次启动 {0}", "{0} をもう一度起動しています", "{0} wird wieder gestartet", "Iniciando {0} de nuevo", "Снова запускаем {0}", "A iniciar {0} de novo", "{0}을(를) 다시 시작하는 중", "Redémarrage de {0}")
|
||||
add("The model did not start again", "模型没有重新启动", "モデルを再起動できませんでした", "Das Modell wurde nicht wieder gestartet", "El modelo no se inició de nuevo", "Модель не запустилась снова", "O modelo não voltou a iniciar", "모델이 다시 시작되지 않았습니다", "Le modèle n'a pas redémarré")
|
||||
add("You can load it again from Models.", "你可以在“模型”中再次加载它。", "「モデル」からもう一度読み込めます。", "Sie können es unter „Modelle“ erneut laden.", "Puedes cargarlo de nuevo desde Modelos.", "Вы можете загрузить её снова в разделе «Модели».", "Pode carregá-lo de novo em Modelos.", "모델 화면에서 다시 불러올 수 있습니다.", "Vous pouvez le recharger depuis Modèles.")
|
||||
add("A model that was not started from this app is running, so nothing was changed. Stop it, then update the engine again.",
|
||||
"有一个不是从此应用启动的模型正在运行,因此没有做任何更改。请先停止它,然后再次更新引擎。",
|
||||
"このアプリから起動していないモデルが動作しているため、何も変更していません。それを停止してから、もう一度エンジンを更新してください。",
|
||||
"Ein Modell, das nicht aus dieser App gestartet wurde, läuft noch, daher wurde nichts geändert. Halten Sie es an und aktualisieren Sie die Engine dann erneut.",
|
||||
"Hay un modelo en ejecución que no se inició desde esta aplicación, así que no se cambió nada. Deténlo y vuelve a actualizar el motor.",
|
||||
"Работает модель, запущенная не из этого приложения, поэтому ничего не изменено. Остановите её и обновите движок снова.",
|
||||
"Está em execução um modelo que não foi iniciado a partir desta aplicação, por isso nada foi alterado. Pare-o e atualize o motor de novo.",
|
||||
"이 앱에서 시작하지 않은 모델이 실행 중이어서 아무것도 변경하지 않았습니다. 그 모델을 중지한 다음 엔진을 다시 업데이트하세요.",
|
||||
"Un modèle qui n'a pas été démarré depuis cette application est en cours d'exécution, rien n'a donc été modifié. Arrêtez-le, puis mettez à nouveau le moteur à jour.")
|
||||
|
||||
here = Path(__file__).resolve().parent.parent / "src" / "locales"
|
||||
NBSP = " "
|
||||
|
||||
|
||||
def french(text):
|
||||
text = re.sub(r"(?<=[A-Za-zÀ-ſ])'(?=[A-Za-zÀ-ſ{])", "’", text)
|
||||
text = re.sub(r"(?<=\S) ([:;?!])", NBSP + r"\1", text)
|
||||
return text
|
||||
|
||||
|
||||
for lang in LANGS:
|
||||
data = {}
|
||||
for i, entry in enumerate(S):
|
||||
text = entry[lang]
|
||||
assert sorted(re.findall(r"\{\d+\}", entry["en"])) == sorted(re.findall(r"\{\d+\}", text)), (lang, entry["en"], text)
|
||||
data[f"upd{i + 1:02d}"] = french(text) if lang == "fr" else text
|
||||
with (here / f"upd-{lang}.json").open("w", encoding="utf-8", newline="\n") as handle:
|
||||
handle.write(json.dumps(data, ensure_ascii=False, indent=1) + "\n")
|
||||
print("written", len(S), "strings in", len(LANGS), "languages")
|
||||
@@ -20,15 +20,13 @@ import { chromium } from 'playwright-core';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { requireChromium } from './browser.mjs';
|
||||
|
||||
const BASE = process.env.STRATA_URL ?? 'http://localhost:5199';
|
||||
const MIN = 45;
|
||||
const MAX = 75;
|
||||
|
||||
const executablePath = [
|
||||
join(homedir(), 'Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
|
||||
join(homedir(), 'Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell'),
|
||||
].find((p) => existsSync(p));
|
||||
const executablePath = requireChromium();
|
||||
|
||||
if (!executablePath) {
|
||||
console.error('No cached Chromium found.');
|
||||
|
||||
@@ -21,6 +21,7 @@ import { promisify } from 'node:util';
|
||||
import { existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { requireChromium } from './browser.mjs';
|
||||
|
||||
const run = promisify(execFile);
|
||||
const BASE = process.env.STRATA_URL ?? 'http://localhost:5199';
|
||||
@@ -28,10 +29,7 @@ const ROOT = resolve(process.env.STRATA_SHOTS ?? '.shots');
|
||||
const TMP = join(ROOT, 'motion-frames');
|
||||
mkdirSync(TMP, { recursive: true });
|
||||
|
||||
const executablePath = [
|
||||
join(homedir(), 'Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
|
||||
join(homedir(), 'Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell'),
|
||||
].find((p) => existsSync(p));
|
||||
const executablePath = requireChromium();
|
||||
|
||||
const ffmpeg = [
|
||||
join(homedir(), 'Library/Caches/ms-playwright/ffmpeg-1011/ffmpeg-mac'),
|
||||
|
||||
@@ -20,14 +20,12 @@ import { chromium } from 'playwright-core';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { requireChromium } from './browser.mjs';
|
||||
|
||||
const BASE = process.env.STRATA_URL ?? 'http://localhost:5199';
|
||||
const BUDGET = 1.2;
|
||||
|
||||
const executablePath = [
|
||||
join(homedir(), 'Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
|
||||
join(homedir(), 'Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell'),
|
||||
].find((p) => existsSync(p));
|
||||
const executablePath = requireChromium();
|
||||
|
||||
if (!executablePath) {
|
||||
console.error('No cached Chromium found. Run `npm run shot -- x.png <url>` once to confirm the harness.');
|
||||
|
||||
@@ -0,0 +1,793 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Drives the built app in a headless Chrome against a real host with a clean temporary profile (no engine, no model), for the 0.2.10
|
||||
* interface changes (branch f-0291-ui). Each case seeds the chat history it needs into the page's local storage, so the real screens
|
||||
* draw real data without a model.
|
||||
*
|
||||
* npx vite build && node scripts/ui-do-drive.mjs <shots-dir> [case ...]
|
||||
*
|
||||
* Never point it at a real install: the host it starts uses a fresh STRATA_HOME in the temp folder and a random port.
|
||||
* Light and dark, at a normal and a narrow window. Prints "ok" / "FAIL" lines and exits 1 on a failure.
|
||||
*/
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { createServer } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { startScriptedWorker } from '../tests/helpers/scripted-worker.mjs';
|
||||
|
||||
const HOST_DIR = resolve(import.meta.dirname, '..');
|
||||
const SHOTS = resolve(process.argv[2] ?? 'ui-shots');
|
||||
const ONLY = process.argv.slice(3);
|
||||
mkdirSync(SHOTS, { recursive: true });
|
||||
const { chromium } = await import(pathToFileURL(join(HOST_DIR, 'node_modules/playwright-core/index.mjs')).href);
|
||||
const CHROME = process.env.CHROME ?? ['C:/Program Files/Google/Chrome/Application/chrome.exe', '/usr/bin/google-chrome'].find(existsSync);
|
||||
const freePort = () => new Promise(r => { const s = createServer(); s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => r(p)); }); });
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
let failed = false;
|
||||
export const check = (ok, label, extra = '') => { console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${extra ? ' ' + extra : ''}`); if (!ok) failed = true; };
|
||||
|
||||
const home = mkdtempSync(join(tmpdir(), 'strata-ui-do-'));
|
||||
const ws = join(home, 'workspace'); mkdirSync(ws, { recursive: true });
|
||||
const port = await freePort();
|
||||
const env = { ...process.env, STRATA_HOME: home, STRATA_PORT: String(port), STRATA_WORKSPACE: ws, STRATA_REAL_ENGINE: '0', STRATA_COMPONENTS_DIR: join(home, 'components'), STRATA_SETTINGS_FILE: join(home, 'app-settings.json'), LOCALAPPDATA: join(home, 'Local'), APPDATA: join(home, 'Roaming'), XDG_DATA_HOME: join(home, 'xdg-data'), XDG_CONFIG_HOME: join(home, 'xdg-config'), XDG_STATE_HOME: join(home, 'xdg-state') };
|
||||
delete env.STRATA_HARNESS_DIR;
|
||||
const child = spawn(process.execPath, [join(HOST_DIR, 'server', 'index.mjs')], { cwd: HOST_DIR, env, stdio: 'ignore', windowsHide: true });
|
||||
export const base = `http://127.0.0.1:${port}`;
|
||||
for (let i = 0; i < 100; i++) { try { if ((await fetch(`${base}/api/health`)).ok) break; } catch { /* starting */ } await sleep(200); }
|
||||
// A saved API connection to a scripted model gives the app a model to talk to, so it stays on the chat screen (no model loaded would send it to Models).
|
||||
const worker = await startScriptedWorker({ model: 'cloud-model' });
|
||||
const saved = await (await fetch(`${base}/api/model-providers`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind: 'custom', name: 'Scripted API', baseUrl: worker.url, protocol: 'openai-chat', apiKey: 'sk-ui-do-test-0123456789', models: [{ id: 'cloud-model', name: 'Cloud model', contextMax: 64000 }] }) })).json();
|
||||
const remoteModel = { connectionId: saved.connections?.[0]?.id, modelId: 'cloud-model' };
|
||||
check(!!remoteModel.connectionId, 'a scripted API model is connected');
|
||||
// "Go straight to the app": the first-run screens are not what these cases look at.
|
||||
await fetch(`${base}/api/setup/step`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ step: 1, dismissed: true, flowVersion: 2 }) });
|
||||
const browser = await chromium.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
|
||||
|
||||
const iso = (n = 0) => new Date(Date.UTC(2026, 9, 31, 9, 0, n)).toISOString();
|
||||
export const user = (id, content, n = 0) => ({ id, role: 'user', content, at: iso(n) });
|
||||
export const reply = (id, content, extra = {}) => ({ id, role: 'assistant', content, at: iso(5), tokens: 120, tps: 40, durationMs: 3000, ...extra });
|
||||
export const chat = (id, title, messages) => ({ id, title, defaultTitle: false, updatedAt: iso(9), createdAt: iso(0), messages, workspace: ws, tokensIn: 0, tokensOut: 0, preferences: { useTools: true, mode: 'manual', remoteModel } });
|
||||
|
||||
/** A page with the given chats already in local storage. `prefs` are merged into the saved preferences. */
|
||||
export async function open(scheme, width, height, sessions, { hash = '#/chat', prefs = {}, origin = base, before = null, locale = 'en-US', keepSetup = false } = {}) {
|
||||
const context = await browser.newContext({ viewport: { width, height }, colorScheme: scheme, locale, permissions: ['clipboard-read', 'clipboard-write'] });
|
||||
const page = await context.newPage();
|
||||
pages.push(page);
|
||||
const problems = [];
|
||||
page.on('pageerror', e => problems.push(e.message));
|
||||
page.problems = problems;
|
||||
await page.addInitScript(({ sessions, prefs }) => {
|
||||
try {
|
||||
if (sessionStorage.getItem('ui-do-seeded')) return;
|
||||
sessionStorage.setItem('ui-do-seeded', '1');
|
||||
if (sessions) localStorage.setItem('strata.sessions.v1', JSON.stringify(sessions));
|
||||
if (Object.keys(prefs).length) localStorage.setItem('strata.prefs.v1', JSON.stringify(prefs));
|
||||
} catch { /* storage refused */ }
|
||||
}, { sessions, prefs });
|
||||
if (before) await before(page);
|
||||
await page.goto(`${origin}/${hash}`);
|
||||
await page.waitForTimeout(1800);
|
||||
const skip = page.getByText('Go straight to the app');
|
||||
if (!keepSetup && await skip.count()) { await skip.click(); await page.waitForTimeout(600); }
|
||||
return page;
|
||||
}
|
||||
export const shot = (page, name) => page.screenshot({ path: join(SHOTS, `${name}.png`) });
|
||||
export const sizes = { wide: [1280, 860], narrow: [390, 800] };
|
||||
|
||||
export const cases = {};
|
||||
const pages = [];
|
||||
|
||||
/* ------------------------------------------------------------------------------------------------------------------- #4725 */
|
||||
const longReply = Array.from({ length: 70 }, (_, i) => `Paragraph ${i + 1}. This is part of a long answer from the assistant that goes on for many screens before it asks to change a file in the project.`).join('\n\n');
|
||||
const askTool = (id, state = 'proposed', file = 'notes.txt') => ({ id, name: 'write', args: { file_path: file }, preview: `write ${file}\n\na\nb\nc`, effect: 'Creates a file.', scope: 'write', verb: 'Create a file', target: file, state, ask: { id: 'create-file', params: [file], why: 'replaces-if-exists' }, live: false });
|
||||
const plainTool = (id, state = 'proposed') => ({ id, name: 'bash', args: {}, preview: 'npm install left-pad', effect: 'Runs a command on your computer.', scope: 'shell', verb: 'Run a command', target: 'npm install left-pad', state });
|
||||
cases.approval = async () => {
|
||||
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
|
||||
const tag = `${scheme}-${sizeName}`;
|
||||
const sessions = [chat('s1', 'Long reply', [user('u1', 'Please write notes.txt'), reply('a1', longReply, { tools: [askTool('t1')] })])];
|
||||
const page = await open(scheme, w, h, sessions);
|
||||
const dock = page.locator('.approvaldock');
|
||||
await dock.waitFor({ timeout: 15000 });
|
||||
const view = page.viewportSize();
|
||||
const inView = async sel => { const b = await page.locator(sel).first().boundingBox(); return !!b && b.y >= 0 && b.y + b.height <= view.height + 1 && b.x >= 0 && b.x + b.width <= view.width + 1; };
|
||||
check(await page.locator('.askcard').count() === 1, `${tag}: one Approve/Deny card on the page`);
|
||||
check(await page.getByRole('button', { name: 'Approve' }).count() === 1 && await page.getByRole('button', { name: 'Deny' }).count() === 1, `${tag}: one Approve and one Deny`);
|
||||
check(await inView('.approvaldock'), `${tag}: the card is inside the window at the end of the reply`);
|
||||
const scroller = page.locator('.main__body.chat');
|
||||
const atEnd = () => scroller.evaluate(el => el.scrollHeight - el.scrollTop - el.clientHeight);
|
||||
check(await atEnd() < 4, `${tag}: the reply is still scrolled to its end`, String(await atEnd()));
|
||||
check(await page.locator('.approval-wait').count() === 1, `${tag}: the reply says it waits for an answer`);
|
||||
await shot(page, `4725-${tag}-end`);
|
||||
await scroller.evaluate(el => { el.scrollTop = 0; });
|
||||
await page.waitForTimeout(300);
|
||||
check(await inView('.approvaldock'), `${tag}: the card stays in the window when the reader is at the top`);
|
||||
const composerTop = await page.locator('.composer .st-composer').boundingBox();
|
||||
const dockBox = await dock.boundingBox();
|
||||
check(dockBox.y + dockBox.height <= composerTop.y, `${tag}: the card sits above the message box`);
|
||||
check((await page.evaluate(() => document.documentElement.scrollWidth)) <= w, `${tag}: no sideways scroll`);
|
||||
await shot(page, `4725-${tag}-top`);
|
||||
await page.getByRole('button', { name: 'Approve' }).click();
|
||||
await page.waitForTimeout(500);
|
||||
check(await page.locator('.approvaldock').count() === 0, `${tag}: the card goes away after Approve`);
|
||||
check(await page.locator('.approval-wait').count() === 0, `${tag}: and so does the waiting line`);
|
||||
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
|
||||
await page.context().close();
|
||||
}
|
||||
// Two waiting at once, one in each card style; Deny works from the dock.
|
||||
const sessions = [chat('s1', 'Two waiting', [user('u1', 'Do two things'), reply('a1', longReply.slice(0, 1500), { tools: [askTool('t1'), plainTool('t2')] })])];
|
||||
const page = await open('light', 1280, 860, sessions);
|
||||
await page.locator('.approvaldock').waitFor({ timeout: 15000 });
|
||||
check(await page.locator('.approvaldock > section').count() === 2, 'two waiting: both cards are in the dock');
|
||||
await shot(page, '4725-two-waiting');
|
||||
await page.getByRole('button', { name: 'Deny' }).first().click();
|
||||
await page.waitForTimeout(400);
|
||||
check(await page.locator('.approvaldock > section').count() === 1, 'two waiting: Deny on the first leaves the second');
|
||||
await page.context().close();
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------------------------------------------------------- #4748 */
|
||||
const codeReply = 'Here is a command.\n\n```bash\nnpm install left-pad\n```\n\nThat is all.';
|
||||
cases.copy = async () => {
|
||||
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
|
||||
const tag = `${scheme}-${sizeName}`;
|
||||
const sessions = [chat('s1', 'Copy', [user('u1', 'Show me a command'), reply('a1', codeReply), user('u2', 'Thanks'), reply('a2', 'You are welcome. Second reply.')])];
|
||||
const page = await open(scheme, w, h, sessions);
|
||||
await page.locator('.st-msg--assistant').last().waitFor({ timeout: 15000 });
|
||||
const flag = async loc => loc.evaluate(el => ({ copied: el.hasAttribute('data-copied'), icon: el.querySelector('use')?.getAttribute('href') ?? '' }));
|
||||
const read = () => page.evaluate(() => navigator.clipboard.readText());
|
||||
// The last reply's copy button (an icon in the action row).
|
||||
const copyResponse = page.getByRole('button', { name: 'Copy response' });
|
||||
await copyResponse.last().click();
|
||||
await page.mouse.move(2, 2);
|
||||
await page.waitForTimeout(250);
|
||||
let f = await flag(copyResponse.last());
|
||||
check(f.copied && f.icon.endsWith('i-check'), `${tag}: Copy response turns into a check mark`, JSON.stringify(f));
|
||||
check((await read()) === 'You are welcome. Second reply.', `${tag}: the reply is on the clipboard`);
|
||||
const row = page.locator('.st-msg--assistant').last().locator('.st-msg__actions');
|
||||
check(await row.evaluate(el => getComputedStyle(el).opacity) === '1', `${tag}: the action row stays visible while it says so, mouse away`);
|
||||
check(/Copied/.test(await page.locator('.st-msg--assistant').last().locator('[role=status]').first().innerText()), `${tag}: a screen reader hears Copied`);
|
||||
await shot(page, `4748-${tag}-reply`);
|
||||
await page.waitForTimeout(2200);
|
||||
f = await flag(copyResponse.last());
|
||||
check(!f.copied && f.icon.endsWith('i-copy'), `${tag}: after about two seconds it is a Copy button again`, JSON.stringify(f));
|
||||
// The code block.
|
||||
const code = page.locator('.st-code__head').getByRole('button', { name: 'Copy code' });
|
||||
await code.click();
|
||||
await page.waitForTimeout(250);
|
||||
f = await flag(code);
|
||||
check(f.copied && f.icon.endsWith('i-check'), `${tag}: the code block's Copy turns into a check mark`, JSON.stringify(f));
|
||||
check((await read()) === 'npm install left-pad', `${tag}: the code is on the clipboard`);
|
||||
await shot(page, `4748-${tag}-code`);
|
||||
await page.waitForTimeout(2200);
|
||||
// The menu item of an earlier reply: says Copied, then the menu closes.
|
||||
await page.locator('.st-msg--assistant').first().hover();
|
||||
await page.locator('.st-msg--assistant').first().getByRole('button', { name: 'More response actions' }).click();
|
||||
const item = page.getByRole('menuitem', { name: 'Copy response' });
|
||||
await item.click();
|
||||
await page.waitForTimeout(250);
|
||||
check(/Copied/.test(await page.locator('.menu [data-copied]').innerText()), `${tag}: the menu item says Copied`);
|
||||
check((await read()).startsWith('Here is a command'), `${tag}: the earlier reply is on the clipboard`);
|
||||
await shot(page, `4748-${tag}-menu`);
|
||||
await page.waitForTimeout(1300);
|
||||
check(await page.locator('.menu').count() === 0, `${tag}: and the menu closes by itself`);
|
||||
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
|
||||
await page.context().close();
|
||||
}
|
||||
// A refused clipboard: nothing says Copied on the button, and the person is told in a toast.
|
||||
{
|
||||
const page = await open('light', 1280, 860, [chat('s1', 'Refused', [user('u1', 'Hi'), reply('a1', 'Hello there.')])]);
|
||||
await page.locator('.st-msg--assistant').last().waitFor({ timeout: 15000 });
|
||||
await page.evaluate(() => { Object.defineProperty(navigator, 'clipboard', { value: { writeText: () => Promise.reject(new Error('denied')) }, configurable: true }); document.execCommand = () => false; });
|
||||
const b = page.getByRole('button', { name: 'Copy response' }).last();
|
||||
await b.click();
|
||||
await page.waitForTimeout(400);
|
||||
check(await b.evaluate(el => !el.hasAttribute('data-copied')), 'refused clipboard: the button does not claim success');
|
||||
check(/Couldn.t copy/.test(await page.locator('.st-toast').first().innerText()), 'refused clipboard: a toast says it did not work');
|
||||
await page.context().close();
|
||||
}
|
||||
// The Serving screen: the address and the request template.
|
||||
for (const scheme of ['light', 'dark']) {
|
||||
const page = await open(scheme, 1280, 860, [chat('s1', 'Serving', [user('u1', 'Hi'), reply('a1', 'Hello there.')])], { hash: '#/serving' });
|
||||
const c = page.getByRole('button', { name: 'Copy API base URL' });
|
||||
await c.waitFor({ timeout: 15000 });
|
||||
await c.click();
|
||||
await page.waitForTimeout(250);
|
||||
check(await c.evaluate(el => el.hasAttribute('data-copied') && (el.querySelector('use')?.getAttribute('href') ?? '').endsWith('i-check')), `${scheme}: Serving, Copy API base URL turns into a check mark`);
|
||||
check(/^http:\/\/127\.0\.0\.1:\d+\/v1$/.test(await page.evaluate(() => navigator.clipboard.readText())), `${scheme}: Serving, the address is on the clipboard`);
|
||||
await shot(page, `4748-serving-${scheme}`);
|
||||
await page.getByRole('button', { name: 'Connection guide' }).click();
|
||||
const t = page.getByRole('button', { name: 'Copy request template' });
|
||||
await t.click();
|
||||
await page.waitForTimeout(250);
|
||||
check(await t.evaluate(el => el.hasAttribute('data-copied')), `${scheme}: Serving, Copy request template turns into a check mark`);
|
||||
check((await page.evaluate(() => navigator.clipboard.readText())).startsWith('curl http'), `${scheme}: Serving, the template is on the clipboard`);
|
||||
await page.context().close();
|
||||
}
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------------------------------------------------------- #4804 */
|
||||
let tuneHostInfo = null;
|
||||
async function tuneHost() {
|
||||
if (tuneHostInfo) return tuneHostInfo;
|
||||
const tport = await freePort();
|
||||
const proc = spawn(process.execPath, [join(HOST_DIR, 'tests/fixtures/tune-ui-host.mjs')], { cwd: HOST_DIR, env: { ...process.env, TUNE_UI_PORT: String(tport), TUNE_UPSTREAM: base, TUNE_SHOT_DIR: SHOTS }, stdio: 'ignore', windowsHide: true });
|
||||
const origin = `http://127.0.0.1:${tport}`;
|
||||
for (let i = 0; i < 100; i++) { try { if ((await fetch(`${origin}/api/native`)).ok) break; } catch { /* starting */ } await sleep(200); }
|
||||
tuneHostInfo = { origin, proc, mode: m => fetch(`${origin}/__fixture/status-mode`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: m }) }) };
|
||||
return tuneHostInfo;
|
||||
}
|
||||
cases.tune = async () => {
|
||||
const t = await tuneHost();
|
||||
const sessions = [chat('s1', 'Tune', [user('u1', 'Hi'), reply('a1', 'Hello there.')])];
|
||||
const openDialog = async (scheme, w, h, mode, inject = false) => {
|
||||
await t.mode(mode);
|
||||
const page = await open(scheme, w, h, sessions, { hash: '#/models', origin: t.origin });
|
||||
if (inject) await page.route(/\/api\/native(\?.*)?$/, async route => {
|
||||
if (!page.injectJob) return route.continue();
|
||||
const r = await route.fetch(); const j = await r.json();
|
||||
j.jobs = [...(j.jobs ?? []), { id: 'job-x', kind: 'download', state: 'running', startedAt: new Date().toISOString(), summary: 'Downloading the model', error: null, result: null, progress: { text: 'Downloading', percent: 12 } }];
|
||||
return route.fulfill({ response: r, json: j });
|
||||
});
|
||||
const tune = page.getByRole('button', { name: 'Tune model' });
|
||||
await tune.waitFor({ timeout: 20000 });
|
||||
for (let i = 0; i < 40 && await tune.isDisabled(); i++) await page.waitForTimeout(250);
|
||||
await tune.click();
|
||||
await page.getByRole('dialog').waitFor({ timeout: 10000 });
|
||||
return page;
|
||||
};
|
||||
const start = page => page.getByRole('button', { name: 'Start tuning' });
|
||||
// The sentence must be on screen without scrolling the dialog, beside the button (above it when the window is narrow).
|
||||
const seen = async (page, w, h) => {
|
||||
const s = await page.locator('.tune-why').boundingBox(), b = await start(page).boundingBox();
|
||||
return !!s && !!b && s.y >= 0 && s.y + s.height <= h && s.x >= 0 && s.x + s.width <= w && Math.abs((s.y + s.height / 2) - (b.y + b.height / 2)) < 110;
|
||||
};
|
||||
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
|
||||
const tag = `${scheme}-${sizeName}`;
|
||||
// 1. The saved tunings are still being read: greyed out, with the reason, and not "No saved tuning".
|
||||
let page = await openDialog(scheme, w, h, 'slow');
|
||||
await page.waitForTimeout(1200);
|
||||
check(await start(page).isDisabled(), `${tag}: loading: Start tuning is greyed out`);
|
||||
let why = await page.locator('.tune-why').innerText().catch(() => '');
|
||||
check(/still checking this model for saved tunings/.test(why), `${tag}: loading: one sentence says why`, why);
|
||||
check(await page.locator('.tune-why').count() === 1, `${tag}: loading: only one such sentence`);
|
||||
check(!/No saved tuning for these choices yet/.test(await page.getByRole('dialog').innerText()), `${tag}: loading: it does not claim there is no saved tuning`);
|
||||
check(await seen(page, w, h), `${tag}: loading: the sentence is on screen next to the button`);
|
||||
await shot(page, `4804-${tag}-loading`);
|
||||
await page.context().close();
|
||||
// 2. Reading them failed.
|
||||
page = await openDialog(scheme, w, h, 'fail');
|
||||
await page.waitForTimeout(1200);
|
||||
check(await start(page).isDisabled(), `${tag}: unreadable: Start tuning is greyed out`);
|
||||
why = await page.locator('.tune-why').innerText().catch(() => '');
|
||||
check(/could not read the tuning details/.test(why), `${tag}: unreadable: one sentence says why`, why);
|
||||
check(await seen(page, w, h), `${tag}: unreadable: the sentence is on screen next to the button`);
|
||||
check(!/No saved tuning/.test(await page.getByRole('dialog').innerText()), `${tag}: unreadable: it does not claim there is no saved tuning`);
|
||||
await shot(page, `4804-${tag}-unreadable`);
|
||||
await page.context().close();
|
||||
// 3. Another operation is running.
|
||||
page = await openDialog(scheme, w, h, 'ok', true);
|
||||
await page.waitForTimeout(800);
|
||||
check(await start(page).isEnabled() && await page.locator('.tune-why').count() === 0, `${tag}: ready: Start tuning works and shows no sentence`);
|
||||
page.injectJob = true;
|
||||
await page.waitForTimeout(3500);
|
||||
check(await start(page).isDisabled(), `${tag}: another operation: Start tuning is greyed out`);
|
||||
why = await page.locator('.tune-why').innerText().catch(() => '');
|
||||
check(/Finish the current operation first/.test(why), `${tag}: another operation: one sentence says why`, why);
|
||||
check((await page.evaluate(() => document.documentElement.scrollWidth)) <= w, `${tag}: no sideways scroll`);
|
||||
check(await seen(page, w, h), `${tag}: other: the sentence is on screen next to the button`);
|
||||
await shot(page, `4804-${tag}-other`);
|
||||
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
|
||||
await page.context().close();
|
||||
}
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------------------------------------------------------- #4721 */
|
||||
cases.links = async () => {
|
||||
const sessions = () => [chat('s1', 'Links', [user('u1', 'Show links'), reply('a1', `Read [the docs](https://example.com/docs) or open [the logo](${base}/icons/sprite.svg).`)])];
|
||||
for (const scheme of ['light', 'dark']) {
|
||||
// A browser tab: the browser opens the address in a new tab; a file stays in the side panel.
|
||||
let page = await open(scheme, 1280, 860, sessions());
|
||||
await page.getByRole('link', { name: 'the docs' }).waitFor({ timeout: 15000 });
|
||||
await page.evaluate(() => { window.__opened = []; window.open = (...a) => { window.__opened.push(a); return null; }; });
|
||||
await page.getByRole('link', { name: 'the docs' }).click();
|
||||
await page.waitForTimeout(300);
|
||||
let opened = await page.evaluate(() => window.__opened);
|
||||
check(JSON.stringify(opened) === JSON.stringify([['https://example.com/docs', '_blank', 'noopener,noreferrer']]), `${scheme}: a browser tab: a plain click on a web address opens it in a new tab`, JSON.stringify(opened));
|
||||
check(await page.locator('.artifact-panel').count() === 0, `${scheme}: a browser tab: and no side panel opens`);
|
||||
await page.getByRole('link', { name: 'the logo' }).click();
|
||||
await page.locator('.artifact-panel').waitFor({ timeout: 8000 });
|
||||
check((await page.evaluate(() => window.__opened)).length === 1, `${scheme}: a file link keeps the side panel and opens no tab`);
|
||||
await shot(page, `4721-${scheme}-file`);
|
||||
await page.context().close();
|
||||
// The app window: the host hands the address to the default browser; window.open is not used.
|
||||
const posts = [];
|
||||
page = await open(scheme, 1280, 860, sessions(), { before: async p => {
|
||||
await p.route('**/api/app/mode', r => r.fulfill({ json: { mode: 'window', reason: null, canQuit: true, appUpdate: 'unavailable' } }));
|
||||
await p.route('**/api/artifacts/open', r => { posts.push(r.request().postDataJSON()); return r.fulfill({ json: { ok: true } }); });
|
||||
} });
|
||||
await page.getByRole('link', { name: 'the docs' }).waitFor({ timeout: 15000 });
|
||||
await page.evaluate(() => { window.__opened = []; window.open = (...a) => { window.__opened.push(a); return null; }; });
|
||||
await page.getByRole('link', { name: 'the docs' }).click();
|
||||
await page.waitForTimeout(400);
|
||||
check(JSON.stringify(posts) === JSON.stringify([{ action: 'open', url: 'https://example.com/docs' }]), `${scheme}: the app window: the host is asked to open it in the default browser`, JSON.stringify(posts));
|
||||
check((await page.evaluate(() => window.__opened)).length === 0 && await page.locator('.artifact-panel').count() === 0, `${scheme}: the app window: nothing else happens`);
|
||||
check(page.problems.length === 0, `${scheme}: no page error`, page.problems.join('; '));
|
||||
await page.context().close();
|
||||
}
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------------------------------------------------------- #4802 */
|
||||
cases.progress = async () => {
|
||||
const t = await tuneHost();
|
||||
await t.mode('ok');
|
||||
const sessions = [chat('s1', 'Progress', [user('u1', 'Hi'), reply('a1', 'Hello there.')])];
|
||||
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
|
||||
const tag = `${scheme}-${sizeName}`;
|
||||
const page = await open(scheme, w, h, sessions, { hash: '#/models', origin: t.origin });
|
||||
const started = new Date(Date.now() - (3 * 60 + 12) * 1000).toISOString();
|
||||
await page.route(/\/api\/native(\?.*)?$/, async route => {
|
||||
const r = await route.fetch(); const j = await r.json();
|
||||
j.jobs = [...(j.jobs ?? []), { id: 'job-x', kind: 'install', model: 'iq2_xs', state: 'running', startedAt: started, summary: 'Preparing', error: null, result: { install: { step: '6/7', label: 'Preparing the model data for the engine (one time)', parts: [{ label: 'Model files', done_gb: 0.6, total_gb: 0.6, state: 'done' }], download: { file: 'model files', done_gb: 0.6, total_gb: 0.6, remaining_gb: 0, percent: 99 } } }, progress: null }];
|
||||
return route.fulfill({ response: r, json: j });
|
||||
});
|
||||
await page.reload();
|
||||
const card = page.locator('.native-progress');
|
||||
await card.waitFor({ timeout: 20000 });
|
||||
await page.waitForTimeout(1500);
|
||||
check(await card.locator('.spinner').count() === 1, `${tag}: a spinner shows that it is working`);
|
||||
const alive = () => card.locator('.native-progress__alive').innerText();
|
||||
const a1 = await alive();
|
||||
check(/^Still working for 3m \d+s\. You don't need to do anything\.$/.test(a1), `${tag}: it says how long and that nothing is needed`, a1);
|
||||
await page.waitForTimeout(2200);
|
||||
const a2 = await alive();
|
||||
check(a1 !== a2, `${tag}: the time keeps counting`, `${a1} -> ${a2}`);
|
||||
check(/The buttons below wait until this finishes/.test(await page.locator('.native-wait-note').innerText()), `${tag}: a sentence says why the buttons are greyed out`);
|
||||
const border = await card.evaluate(el => getComputedStyle(el).borderTopColor);
|
||||
const other = await page.locator('.st-card:not(.native-progress)').first().evaluate(el => getComputedStyle(el).borderTopColor);
|
||||
check(border !== other, `${tag}: the card has its own border colour`, `${border} vs ${other}`);
|
||||
check((await page.evaluate(() => document.documentElement.scrollWidth)) <= w, `${tag}: no sideways scroll`);
|
||||
await card.scrollIntoViewIfNeeded();
|
||||
await shot(page, `4802-${tag}`);
|
||||
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
|
||||
await page.context().close();
|
||||
}
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------------------------------------------------------- #4798 */
|
||||
cases.serving = async () => {
|
||||
const sessions = [chat('s1', 'Serving', [user('u1', 'Hi'), reply('a1', 'Hello there.')])];
|
||||
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
|
||||
const tag = `${scheme}-${sizeName}`;
|
||||
// Sharing off: one address, no extra line. Sharing on: the app's own address and the one for other devices, with one sentence.
|
||||
for (const sharing of [false, true]) {
|
||||
const page = await open(scheme, w, h, sessions, { hash: '#/serving', before: async p => {
|
||||
if (!sharing) return;
|
||||
await p.route(/\/api\/serving$/, async route => {
|
||||
if (route.request().method() !== 'GET') return route.continue();
|
||||
const r = await route.fetch(); const j = await r.json();
|
||||
return route.fulfill({ response: r, json: { ...j, localUrl: 'http://127.0.0.1:8079/v1', baseUrl: 'http://100.76.61.73:8080/v1', sharing: { active: true, url: 'http://100.76.61.73:8080/v1' } } });
|
||||
});
|
||||
} });
|
||||
await page.getByText('API base URL', { exact: false }).first().waitFor({ timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
const text = await page.locator('.serving-overview').innerText();
|
||||
if (sharing) {
|
||||
check(/API base URL on this computer/.test(text) && text.includes('http://127.0.0.1:8079/v1'), `${tag}: sharing on: the app's own address is shown`);
|
||||
check(/Address for other devices/.test(text) && text.includes('http://100.76.61.73:8080/v1'), `${tag}: sharing on: and the address for other devices`);
|
||||
check(await page.locator('.serving-two-addresses').count() === 1 && /its own port/.test(await page.locator('.serving-two-addresses').innerText()), `${tag}: sharing on: one sentence says why they differ`);
|
||||
const t = page.getByRole('button', { name: 'Copy local network URL' });
|
||||
await t.click(); await page.waitForTimeout(250);
|
||||
check((await page.evaluate(() => navigator.clipboard.readText())) === 'http://100.76.61.73:8080/v1', `${tag}: sharing on: the copy button gives the network address`);
|
||||
check((await page.evaluate(() => document.documentElement.scrollWidth)) <= w, `${tag}: sharing on: no sideways scroll`);
|
||||
await page.locator('.serving-overview').scrollIntoViewIfNeeded();
|
||||
await shot(page, `4798-${tag}-sharing`);
|
||||
} else {
|
||||
check(!/Address for other devices/.test(text) && await page.locator('.serving-two-addresses').count() === 0, `${tag}: sharing off: one address, no extra line`);
|
||||
}
|
||||
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
|
||||
await page.context().close();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------------------------------------------------------- #4722 */
|
||||
cases.width = async () => {
|
||||
const text = '用中文写一段九十字左右的段落,说明本地模型为什么适合处理私人文件,并且不需要把任何内容上传到网络上去,这样可以让人放心。\n\n- 第一条要点同样写得比较长,用来观察列表项在气泡里到底会排到多宽才换行,看看它是否与上面的段落一样。\n- 第二条要点也一样长,看看两条要点是不是排得整齐,不会比段落更宽也不会更窄。\n\nAnd an English paragraph that is long enough to wrap onto at least two lines so that the measure of the prose can be read off it by the test.\n\n1. A numbered point in English that is also long enough to wrap onto a second line in the same bubble as the paragraph above it.\n2. Another numbered point.\n\n| Name | Value |\n| --- | --- |\n| a long cell that is wide | another long cell that is wide too |\n\n```bash\nnpm install a-very-long-package-name another-very-long-package-name yet-another-long-package-name --save-dev --legacy-peer-deps\n```';
|
||||
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
|
||||
const tag = `${scheme}-${sizeName}`;
|
||||
const page = await open(scheme, w, h, [chat('s1', 'Width', [user('u1', 'Write'), reply('a1', text)])]);
|
||||
await page.locator('.st-bubble ul').first().waitFor({ timeout: 15000 });
|
||||
const m = await page.evaluate(() => {
|
||||
const b = document.querySelector('.st-msg--assistant .st-bubble'), r = el => Math.round(el.getBoundingClientRect().width);
|
||||
return { bubble: r(b), p: r(b.querySelector('p')), ul: r(b.querySelector('ul')), ol: r(b.querySelector('ol')), table: r(b.querySelector('.md-table-wrap')), code: r(b.querySelector('.st-code')) };
|
||||
});
|
||||
const note = JSON.stringify(m);
|
||||
if (w > 700) {
|
||||
check(Math.abs(m.ul - m.p) <= 2 && Math.abs(m.ol - m.p) <= 2, `${tag}: bullets and numbers have the paragraph's width`, note);
|
||||
check(m.table > m.p + 20 && m.code > m.p + 20, `${tag}: the table and the code block keep the full width`, note);
|
||||
} else {
|
||||
check(Math.abs(m.ul - m.p) <= 2 && m.p <= m.bubble, `${tag}: narrow window: everything fits the column`, note);
|
||||
}
|
||||
check((await page.evaluate(() => document.documentElement.scrollWidth)) <= w, `${tag}: no sideways scroll`);
|
||||
await shot(page, `4722-${tag}`);
|
||||
await page.context().close();
|
||||
}
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------------------------------------------------------- #3723 */
|
||||
cases.firstrun = async () => {
|
||||
const setupStep = dismissed => fetch(`${base}/api/setup/step`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ step: 1, dismissed, flowVersion: 2 }) });
|
||||
await setupStep(false);
|
||||
// A computer set to a language Strata does not have: English.
|
||||
const page = await open('light', 1280, 860, null, { hash: '#/setup', locale: 'sv-SE', keepSetup: true });
|
||||
await page.locator('.onboard-language').waitFor({ timeout: 20000 });
|
||||
check(/English/.test(await page.locator('.onboard-language').innerText()), 'Swedish computer: English');
|
||||
await page.context().close();
|
||||
// A first start (nothing saved) on a computer set to Chinese opens in Chinese, with a language choice on the first screen.
|
||||
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
|
||||
const tag = `${scheme}-${sizeName}`;
|
||||
const page = await open(scheme, w, h, null, { hash: '#/setup', locale: 'zh-CN', keepSetup: true });
|
||||
await page.locator('.onboard-language').waitFor({ timeout: 20000 });
|
||||
await page.waitForTimeout(500);
|
||||
const pick = page.locator('.onboard-language [role=combobox], .onboard-language button').first();
|
||||
const shown = await pick.innerText();
|
||||
check(/中文/.test(shown), `${tag}: a computer set to Chinese starts in Chinese`, shown);
|
||||
check(/[\u4e00-\u9fff]/.test(await page.locator('.guided-onboarding h1').first().innerText()), `${tag}: the first screen is in Chinese`);
|
||||
const box = await page.locator('.onboard-language').boundingBox();
|
||||
check(!!box && box.x >= 0 && box.x + box.width <= w && box.y >= 0, `${tag}: the choice is on screen`, JSON.stringify(box));
|
||||
const brand = await page.locator('.guided-onboarding .onboard-header .brand').first().boundingBox();
|
||||
check(!brand || box.x >= brand.x + brand.width - 1 || box.x + box.width <= brand.x + 1 || box.y >= brand.y + brand.height || box.y + box.height <= brand.y, `${tag}: the choice does not cover the logo`, JSON.stringify({ brand, box }));
|
||||
await shot(page, `3723-${tag}-first-zh`);
|
||||
// Choosing German changes the screen at once, and it is kept.
|
||||
await pick.click();
|
||||
await page.getByRole('option', { name: 'Deutsch' }).click();
|
||||
await page.waitForTimeout(500);
|
||||
check(/[A-Za-zäöü]/.test(await page.locator('.guided-onboarding h1').first().innerText()) && !/[\u4e00-\u9fff]/.test(await page.locator('.guided-onboarding h1').first().innerText()), `${tag}: choosing Deutsch changes the first screen`);
|
||||
await shot(page, `3723-${tag}-first-de`);
|
||||
await page.reload();
|
||||
await page.locator('.onboard-language').waitFor({ timeout: 20000 });
|
||||
check(/Deutsch/.test(await page.locator('.onboard-language').innerText()), `${tag}: the choice is kept after a restart, whatever the computer says`);
|
||||
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
|
||||
await page.context().close();
|
||||
}
|
||||
await setupStep(true);
|
||||
};
|
||||
|
||||
cases.kv = async () => {
|
||||
const t = await tuneHost();
|
||||
await t.mode('ok');
|
||||
const sessions = [chat('s1', 'KV', [user('u1', 'Hi'), reply('a1', 'Hello there.')])];
|
||||
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
|
||||
const tag = `${scheme}-${sizeName}`;
|
||||
const page = await open(scheme, w, h, sessions, { hash: '#/models', origin: t.origin });
|
||||
await page.route(/\/api\/native(\?.*)?$/, async route => {
|
||||
const r = await route.fetch(); const j = await r.json();
|
||||
j.jobs = [...(j.jobs ?? []), { id: 'job-f', kind: 'load', model: 'iq2_xs', state: 'error', startedAt: new Date().toISOString(), summary: 'Load failed', error: "server_exited (exit code 1): EngineDied: the engine exited before it was ready | the engine process ended: exit code 1: \nthe engine log's last lines:\n strata generate: expert cache auto: 1.92 GiB free, 2048 MiB reserved -> 0 slots\n strata generate: no VRAM is left for the expert cache: it needs at least 332 slots (737 MiB), about 1000 MiB more than this card has free.", result: null, progress: null }];
|
||||
return route.fulfill({ response: r, json: j });
|
||||
});
|
||||
await page.reload();
|
||||
const expert = page.getByRole('button', { name: 'Expert mode' });
|
||||
await expert.waitFor({ timeout: 20000 });
|
||||
await page.waitForTimeout(1500);
|
||||
await expert.click();
|
||||
const sel = page.getByRole('combobox', { name: 'Native KV format' });
|
||||
if (!await sel.isVisible().catch(() => false)) { await page.waitForTimeout(800); if (!await sel.isVisible().catch(() => false)) await page.getByRole('button', { name: /expert mode/i }).first().click(); }
|
||||
await sel.waitFor({ timeout: 10000 });
|
||||
const hint = page.locator('.native-kv-hint');
|
||||
check(/INT8 is the standard/.test(await hint.innerText()), `${tag}: Context memory: one line says what the chosen format is`);
|
||||
await sel.click();
|
||||
check(await page.getByRole('option').count() === 3, `${tag}: Context memory: three options`);
|
||||
const titles = await page.locator('.select__option-label').evaluateAll(els => els.map(e => e.getAttribute('title')));
|
||||
check(titles.length === 3 && titles.every(x => x && x.length > 20), `${tag}: Context memory: each option carries its line`, JSON.stringify(titles));
|
||||
await page.getByRole('option', { name: 'Q4' }).click();
|
||||
await page.waitForTimeout(300);
|
||||
check(/Q4 uses about half the memory/.test(await hint.innerText()), `${tag}: Context memory: the line follows the choice`);
|
||||
await hint.scrollIntoViewIfNeeded();
|
||||
await shot(page, `3723-${tag}-kv`);
|
||||
const notice = page.locator('.error-notice');
|
||||
await notice.first().waitFor({ timeout: 10000 });
|
||||
const said = await notice.first().innerText();
|
||||
check(/did not fit in your graphics card.s memory/.test(said) && /Context memory to Q4/.test(said), `${tag}: a start that ran out of graphics memory says so in plain words`, said.replace(/\n+/g, ' | ').slice(0, 200));
|
||||
await notice.first().scrollIntoViewIfNeeded();
|
||||
await shot(page, `3723-${tag}-vram`);
|
||||
check((await page.evaluate(() => document.documentElement.scrollWidth)) <= w, `${tag}: no sideways scroll`);
|
||||
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
|
||||
await page.context().close();
|
||||
}
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------------------------------------------------------- #4749 */
|
||||
cases.helper = async () => {
|
||||
const agents = [
|
||||
{ id: 'child-1', name: 'Check the tests', description: 'All 12 tests pass.', status: 'completed', durationMs: 42000, tokens: 1800, updatedAt: Date.now() },
|
||||
{ id: 'child-2', name: 'Write the notes', description: 'Could not write notes.txt', status: 'failed', durationMs: 9000, tokens: 400, updatedAt: Date.now() },
|
||||
];
|
||||
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
|
||||
const tag = `${scheme}-${sizeName}`;
|
||||
const sessions = [{ ...chat('s1', 'Helpers', [user('u1', 'Do two things'), reply('a1', 'Both helpers are on it.')]), agents }];
|
||||
const posts = []; let refuse = false;
|
||||
const page = await open(scheme, w, h, sessions, { before: async p => {
|
||||
await p.route('**/api/chat/helper', async r => { posts.push(r.request().postDataJSON()); if (refuse) return r.fulfill({ status: 409, json: { error: 'This helper cannot take a message any more. Ask the assistant in the chat to start a new one.' } }); return r.fulfill({ json: { ok: true, messageId: 'm1' } }); });
|
||||
} });
|
||||
await page.getByRole('button', { name: /Subagents/ }).first().click();
|
||||
const rows = page.locator('.subagent-row');
|
||||
await rows.first().waitFor({ timeout: 15000 });
|
||||
// The finished helper: queue.
|
||||
await rows.nth(0).getByRole('button', { name: 'Message this helper' }).click();
|
||||
const input = rows.nth(0).getByRole('textbox', { name: 'Message to Check the tests' });
|
||||
await input.fill('use ISO dates in the file you just wrote');
|
||||
await shot(page, `4749-${tag}-writing`);
|
||||
await input.press('Enter');
|
||||
await rows.nth(0).getByRole('status').waitFor({ timeout: 5000 });
|
||||
check(JSON.stringify(posts[0]) === JSON.stringify({ sessionId: 's1', helperId: 'child-1', message: 'use ISO dates in the file you just wrote', delivery: 'queue' }), `${tag}: a finished helper gets the message as a new turn (queue)`, JSON.stringify(posts[0]));
|
||||
check(/Sent to Check the tests\./.test(await rows.nth(0).innerText()) && await input.inputValue() === '', `${tag}: the row says it was sent and the box is empty again`);
|
||||
// A second helper (a failed one is listed too).
|
||||
await rows.nth(1).getByRole('button', { name: 'Message this helper' }).click();
|
||||
await rows.nth(1).getByRole('textbox').fill('also add the date');
|
||||
await rows.nth(1).getByRole('button', { name: 'Send' }).click();
|
||||
await rows.nth(1).getByRole('status').waitFor({ timeout: 5000 });
|
||||
check(posts[1]?.delivery === 'queue' && posts[1]?.helperId === 'child-2', `${tag}: the message goes to the helper that was chosen`, JSON.stringify(posts[1]));
|
||||
// A refusal says so in plain words.
|
||||
refuse = true;
|
||||
await rows.nth(0).getByRole('textbox').fill('one more thing');
|
||||
await rows.nth(0).getByRole('button', { name: 'Send' }).click();
|
||||
await rows.nth(0).getByRole('alert').waitFor({ timeout: 5000 });
|
||||
check(/cannot take a message any more/.test(await rows.nth(0).getByRole('alert').innerText()), `${tag}: a refused message says why in plain words`);
|
||||
check((await page.evaluate(() => document.documentElement.scrollWidth)) <= w, `${tag}: no sideways scroll`);
|
||||
await shot(page, `4749-${tag}-sent`);
|
||||
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
|
||||
await page.context().close();
|
||||
}
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------------------------------------------------------- #4783 */
|
||||
cases.busy = async () => {
|
||||
const fresh = (scheme, w, h, whileBusy) => open(scheme, w, h, [chat('s1', 'Busy', [])], { prefs: whileBusy ? { whileBusy } : {} });
|
||||
const send = async (page, text) => { await page.locator('textarea').first().fill(text); await page.keyboard.press('Enter'); };
|
||||
const lastReply = page => page.locator('.st-msg--assistant .st-bubble').last();
|
||||
for (const scheme of ['light', 'dark']) {
|
||||
const w = 1280, h = 860, tag = scheme;
|
||||
// Wait (the default): Enter while it answers sends nothing, the message stays in the box, and a note says why.
|
||||
let page = await fresh(scheme, w, h, null);
|
||||
await send(page, 'SCRIPT:slow 80');
|
||||
await page.locator('.st-msg--assistant').first().waitFor({ timeout: 15000 });
|
||||
await page.locator('textarea').first().fill('my second message');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(600);
|
||||
check(await page.locator('textarea').first().inputValue() === 'my second message', `${tag}: Wait: the message stays in the box`);
|
||||
check(await page.locator('.parked').count() === 0, `${tag}: Wait: nothing is parked`);
|
||||
await page.getByText('The assistant is still answering').first().waitFor({ timeout: 4000 }).catch(() => {});
|
||||
check(await page.getByText('The assistant is still answering').count() > 0, `${tag}: Wait: a note says the assistant is still answering`);
|
||||
await page.context().close();
|
||||
// Send after: it waits (shown), and goes when the answer is done, after the whole first answer.
|
||||
page = await fresh(scheme, w, h, 'queue');
|
||||
await send(page, 'SCRIPT:slow 80');
|
||||
await page.locator('.st-msg--assistant').first().waitFor({ timeout: 15000 });
|
||||
await page.locator('textarea').first().fill('SCRIPT:echo the second answer');
|
||||
check(/Enter sends it when the answer is done/.test(await page.locator('.composer__busy-hint').innerText()), `${tag}: Send after: the hint says what Enter will do`);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.locator('.parked').waitFor({ timeout: 3000 });
|
||||
check(/Waiting to send/.test(await page.locator('.parked').innerText()) && /second answer/.test(await page.locator('.parked').innerText()), `${tag}: Send after: the waiting message is shown`);
|
||||
if (scheme === 'light') await shot(page, `4783-${tag}-waiting`);
|
||||
await page.locator('.st-msg--assistant').nth(1).waitFor({ timeout: 30000 });
|
||||
await page.waitForFunction(() => document.querySelectorAll('.parked').length === 0, null, { timeout: 15000 });
|
||||
const firstText = await page.locator('.st-msg--assistant .st-bubble').first().innerText();
|
||||
check(/w79/.test(firstText), `${tag}: Send after: the first answer was written to its end`, firstText.slice(-30));
|
||||
check(/the second answer/.test(await lastReply(page).innerText()), `${tag}: Send after: then the second message was answered`);
|
||||
await page.context().close();
|
||||
// Interrupt: the answer stops where it is, what is written stays, and the message goes at once.
|
||||
page = await fresh(scheme, w, h, 'interject');
|
||||
await send(page, 'SCRIPT:slow 600');
|
||||
await page.locator('.st-msg--assistant').first().waitFor({ timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
await page.locator('textarea').first().fill('SCRIPT:echo interrupted and answered');
|
||||
check(/Enter stops the answer here and sends it now/.test(await page.locator('.composer__busy-hint').innerText()), `${tag}: Interrupt: the hint says what Enter will do`);
|
||||
await page.keyboard.press('Enter');
|
||||
const t0 = Date.now();
|
||||
await page.locator('.st-msg--assistant').nth(1).waitFor({ timeout: 30000 });
|
||||
check(Date.now() - t0 < 12000, `${tag}: Interrupt: the new message was answered long before the first answer would have ended`, `${Date.now() - t0} ms`);
|
||||
const cut = await page.locator('.st-msg--assistant .st-bubble').first().innerText();
|
||||
check(cut.includes('w0') && !cut.includes('w599'), `${tag}: Interrupt: the first answer stopped where it was and stays`, `${cut.length} characters`);
|
||||
await page.waitForFunction(() => /interrupted and answered/.test([...document.querySelectorAll('.st-msg--assistant .st-bubble')].at(-1)?.innerText ?? ''), null, { timeout: 15000 }).catch(() => {});
|
||||
check(/interrupted and answered/.test(await lastReply(page).innerText()), `${tag}: Interrupt: the second message was answered`);
|
||||
if (scheme === 'light') await shot(page, `4783-${tag}-interrupted`);
|
||||
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
|
||||
await page.context().close();
|
||||
}
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------------------------------------------------------- #4785 */
|
||||
cases.quote = async () => {
|
||||
const long = 'First point: local models keep your files on your own computer. Second point: nothing has to be uploaded to a service. Third point: you can work without a network connection.';
|
||||
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
|
||||
const tag = `${scheme}-${sizeName}`;
|
||||
const page = await open(scheme, w, h, [chat('s1', 'Quote', [user('u1', 'Why run a model locally?'), reply('a1', long), user('u2', 'Thanks, one more question'), reply('a2', 'Ask away.')])]);
|
||||
await page.locator('.st-msg--assistant').first().waitFor({ timeout: 15000 });
|
||||
// A whole turn, from the message menu.
|
||||
await page.locator('.st-msg--assistant').first().hover();
|
||||
await page.locator('.st-msg--assistant').first().getByRole('button', { name: 'More response actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Quote' }).click();
|
||||
await page.locator('.quote').first().waitFor({ timeout: 5000 });
|
||||
check(/Quoting the assistant/.test(await page.locator('.quote').first().innerText()) && /First point/.test(await page.locator('.quote').first().innerText()), `${tag}: Quote in the message menu puts the turn above the box`);
|
||||
// Just a passage: select the second sentence and quote it.
|
||||
await page.evaluate(() => {
|
||||
const p = document.querySelector('.st-msg--assistant .st-bubble p');
|
||||
const node = p.firstChild; const at = node.textContent.indexOf('Second point'); const end = node.textContent.indexOf('Third point');
|
||||
const r = document.createRange(); r.setStart(node, at); r.setEnd(node, end);
|
||||
const s = window.getSelection(); s.removeAllRanges(); s.addRange(r);
|
||||
});
|
||||
await page.locator('.quote-selection').waitFor({ timeout: 5000 });
|
||||
const sbox = await page.locator('.quote-selection').boundingBox();
|
||||
check(!!sbox && sbox.x >= 0 && sbox.y >= 0 && sbox.x + sbox.width <= w, `${tag}: a Quote button appears by the selected words, on screen`, JSON.stringify(sbox));
|
||||
await page.locator('.quote-selection').click();
|
||||
check(await page.locator('.quote').count() === 2 && /^Second point: nothing has to be uploaded to a service\.$/.test((await page.locator('.quote .quote__text').nth(1).innerText()).trim()), `${tag}: the second quote is just the selected passage, listed after the first`);
|
||||
check(await page.locator('.quote-selection').count() === 0, `${tag}: the button goes away`);
|
||||
await shot(page, `4785-${tag}-quotes`);
|
||||
// Remove one, quote it again, jump to the message.
|
||||
await page.locator('.quote').first().getByRole('button', { name: 'Remove this quote' }).click();
|
||||
check(await page.locator('.quote').count() === 1 && /Second point/.test(await page.locator('.quote').first().innerText()), `${tag}: one quote can be removed without touching the others`);
|
||||
await page.locator('.quote__body').first().click();
|
||||
await page.waitForTimeout(400);
|
||||
// Send an answer: the model gets the fixed form, the transcript shows the quote apart from the reply.
|
||||
await page.locator('textarea').first().fill('SCRIPT:echo thanks for the quote');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.locator('.sent-quote').first().waitFor({ timeout: 15000 });
|
||||
await page.waitForTimeout(300);
|
||||
const sent = await page.locator('.st-msg--user').last().innerText();
|
||||
check(/Second point: nothing has to be uploaded/.test(sent) && /SCRIPT:echo thanks for the quote/.test(sent) && !/Quoting:/.test(sent) && !/「/.test(sent), `${tag}: the sent message shows the quote apart from the reply, without the markup`, sent.replace(/\n+/g, ' | ').slice(0, 160));
|
||||
const got = worker.requests.at(-1)?.raw ?? '';
|
||||
check(got.includes('Quoting:\\n「Second point: nothing has to be uploaded to a service.」\\n→ Reply: SCRIPT:echo thanks for the quote'), `${tag}: the model receives the quote in its fixed form`, got.slice(-220));
|
||||
check(await page.locator('.quote').count() === 0, `${tag}: the quotes are cleared after sending`);
|
||||
await page.locator('.sent-quote').first().click();
|
||||
check(await page.locator('.sent-quotes[data-open]').count() === 1, `${tag}: a click opens the rest of a sent quote`);
|
||||
await shot(page, `4785-${tag}-sent`);
|
||||
check((await page.evaluate(() => document.documentElement.scrollWidth)) <= w, `${tag}: no sideways scroll`);
|
||||
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
|
||||
await page.context().close();
|
||||
}
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------------------------------------------------------- #4244 */
|
||||
cases.ask = async () => {
|
||||
const t = await tuneHost();
|
||||
await t.mode('ok');
|
||||
const asked = () => fetch(`${t.origin}/__fixture/asked`).then(r => r.json());
|
||||
await fetch(`${t.origin}/__fixture/ask`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ on: true }) });
|
||||
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
|
||||
const tag = `${scheme}-${sizeName}`;
|
||||
const before = (await asked()).length;
|
||||
const page = await open(scheme, w, h, [chat('s1', 'Ask', [])], { origin: t.origin });
|
||||
await page.locator('textarea').first().fill('Build me a page');
|
||||
await page.keyboard.press('Enter');
|
||||
const card = page.locator('.askq');
|
||||
await card.waitFor({ timeout: 15000 });
|
||||
const text = await card.innerText();
|
||||
check(/Which colour should the page be\?/.test(text) && /Which extras do you want\?/.test(text) && /Warm and strong/.test(text) && /It sets the whole theme/.test(text), `${tag}: the question, its detail and the option descriptions are shown`);
|
||||
check(await page.locator('.askcard').count() === 1 && await page.locator('.approvaldock').count() === 1, `${tag}: it is one card in the dock above the box`);
|
||||
const send = page.getByRole('button', { name: 'Send answer' });
|
||||
check(await send.isDisabled(), `${tag}: Send answer waits until every question has an answer`);
|
||||
await page.getByRole('radio', { name: /Red/ }).click();
|
||||
await page.getByRole('radio', { name: /Blue/ }).click();
|
||||
check(await page.getByRole('radio', { name: /Red/ }).getAttribute('aria-checked') === 'false' && await page.getByRole('radio', { name: /Blue/ }).getAttribute('aria-checked') === 'true', `${tag}: a single-select question takes one choice`);
|
||||
await page.getByRole('checkbox', { name: 'Dark mode' }).click();
|
||||
await page.getByRole('checkbox', { name: 'Animations' }).click();
|
||||
check(await send.isEnabled(), `${tag}: Send answer works once every question has an answer`);
|
||||
await page.waitForTimeout(300);
|
||||
const sb = await send.boundingBox();
|
||||
const top = await page.evaluate(({ x, y }) => { const e = document.elementFromPoint(x, y); return e?.closest('button')?.textContent ?? ''; }, { x: sb.x + sb.width / 2, y: sb.y + sb.height / 2 });
|
||||
check(sb.y >= 0 && sb.y + sb.height <= h && /Send answer/.test(top), `${tag}: the answer buttons are in view and reachable without scrolling the card`, JSON.stringify(sb));
|
||||
await shot(page, `4244-${tag}`);
|
||||
check((await page.evaluate(() => document.documentElement.scrollWidth)) <= w, `${tag}: no sideways scroll`);
|
||||
const box = await page.locator('.approvaldock').boundingBox();
|
||||
check(!!box && box.y >= 0 && box.y + box.height <= h + 1, `${tag}: the dock with the card is inside the window`, JSON.stringify(box));
|
||||
await send.click();
|
||||
await page.waitForFunction(() => document.querySelectorAll('.askq').length === 0, null, { timeout: 8000 });
|
||||
const got = (await asked()).slice(before);
|
||||
check(JSON.stringify(got.at(-1)) === JSON.stringify({ sessionId: 's1', callId: 'call-ask-1', answers: [{ id: 'q1', selected: ['Blue'] }, { id: 'q2', selected: ['Dark mode', 'Animations'] }] }), `${tag}: the answer goes to the host as chosen`, JSON.stringify(got.at(-1)));
|
||||
check(await page.locator('.askq').count() === 0, `${tag}: the card goes away, and the turn ends`);
|
||||
// The person's own words, and a skip.
|
||||
await page.locator('textarea').first().fill('Again');
|
||||
await page.keyboard.press('Enter');
|
||||
await card.waitFor({ timeout: 15000 });
|
||||
await page.getByRole('textbox', { name: 'Type your own answer' }).first().fill('Green, like the logo');
|
||||
await page.getByRole('checkbox', { name: 'Animations' }).click();
|
||||
await send.click();
|
||||
await page.waitForFunction(() => document.querySelectorAll('.askq').length === 0, null, { timeout: 8000 });
|
||||
const own = (await asked()).slice(before).at(-1);
|
||||
check(JSON.stringify(own?.answers) === JSON.stringify([{ id: 'q1', selected: [], custom: 'Green, like the logo' }, { id: 'q2', selected: ['Animations'] }]), `${tag}: the person's own words are sent as such`, JSON.stringify(own));
|
||||
await page.locator('textarea').first().fill('Once more');
|
||||
await page.keyboard.press('Enter');
|
||||
await card.waitFor({ timeout: 15000 });
|
||||
await page.getByRole('button', { name: 'Skip' }).click();
|
||||
await page.waitForFunction(() => document.querySelectorAll('.askq').length === 0, null, { timeout: 8000 });
|
||||
check((await asked()).slice(before).at(-1)?.skip === true, `${tag}: Skip is sent as a skip`);
|
||||
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
|
||||
await page.context().close();
|
||||
}
|
||||
// A /loop runs without anyone there: the question is skipped at once, no card, and the loop is not left waiting.
|
||||
{
|
||||
const before = (await asked()).length;
|
||||
const page = await open('light', 1280, 860, [chat('s1', 'Loop', [])], { origin: t.origin });
|
||||
await page.locator('textarea').first().fill('/loop 10m Build me a page stop after 1 answer');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(async () => (await (await fetch('/__fixture/asked')).json()).length > 0, null, { timeout: 15000 }).catch(() => {});
|
||||
const got = (await asked()).slice(before).at(-1);
|
||||
check(got?.skip === true && got?.callId === 'call-ask-1', 'a loop: the question is skipped at once', JSON.stringify(got));
|
||||
check(await page.locator('.askq').count() === 0, 'a loop: no question card is shown');
|
||||
await page.context().close();
|
||||
}
|
||||
await fetch(`${t.origin}/__fixture/ask`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ on: false }) });
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------------------------------------------------------- #3818 (a probe, no fix) */
|
||||
cases.select = async () => {
|
||||
const paras = Array.from({ length: 40 }, (_, i) => `Paragraph ${i + 1}. This is a sentence of a long answer that goes on for a while so that the reply overflows the window.`).join('\n\n');
|
||||
const pick = async (page, fromText, toText) => {
|
||||
const a = await page.getByText(fromText, { exact: false }).first().boundingBox(), b = await page.getByText(toText, { exact: false }).first().boundingBox();
|
||||
await page.mouse.move(a.x + 2, a.y + a.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(b.x + b.width - 4, b.y + b.height / 2, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
return page.evaluate(() => window.getSelection().toString());
|
||||
};
|
||||
const page = await open('light', 1280, 860, [chat('s1', 'Select', [user('u1', 'Write a lot'), reply('a1', paras)])]);
|
||||
await page.locator('.st-bubble p').first().waitFor({ timeout: 15000 });
|
||||
await page.locator('.main__body.chat').evaluate(el => { el.scrollTop = el.scrollHeight; });
|
||||
await page.waitForTimeout(400);
|
||||
// After the reply has finished: a drag across several paragraphs.
|
||||
const visible = await page.evaluate(() => [...document.querySelectorAll('.st-bubble p')].filter(p => { const r = p.getBoundingClientRect(); return r.top > 100 && r.bottom < innerHeight - 220; }).map(p => p.textContent.slice(0, 12)));
|
||||
const first = visible[0], last = visible.at(-1);
|
||||
const got = await pick(page, first, last);
|
||||
check(got.includes(first) && got.includes(last) && got.split('\n').filter(Boolean).length >= visible.length, `finished reply: a drag across ${visible.length} paragraphs selects all of them`, `${got.length} characters`);
|
||||
await page.context().close();
|
||||
// While a reply streams: select a passage of the older reply, and see whether the selection and the view stay.
|
||||
const page2 = await open('light', 1280, 860, [chat('s1', 'Select', [user('u1', 'Write a lot'), reply('a1', paras)])]);
|
||||
await page2.locator('.st-bubble p').first().waitFor({ timeout: 15000 });
|
||||
await page2.locator('textarea').first().fill('SCRIPT:slow 400');
|
||||
await page2.keyboard.press('Enter');
|
||||
await page2.waitForTimeout(1500);
|
||||
const v2 = await page2.evaluate(() => [...document.querySelector('.st-msg--assistant').querySelectorAll('.st-bubble p')].filter(p => { const r = p.getBoundingClientRect(); return r.top > 100 && r.bottom < innerHeight - 220; }).map(p => p.textContent.slice(0, 12)));
|
||||
if (v2.length >= 2) {
|
||||
// The selection is made in code here (a drag moves with the layout and made this probe flaky): words of the older reply, as a drag would select them.
|
||||
const sel0 = await page2.evaluate(([a, b]) => {
|
||||
const ps = [...document.querySelector('.st-msg--assistant').querySelectorAll('.st-bubble p')], from = ps.find(p => p.textContent.startsWith(a)), to = ps.find(p => p.textContent.startsWith(b));
|
||||
const r = document.createRange(); r.setStart(from.firstChild, 0); r.setEnd(to.lastChild, to.lastChild.textContent.length);
|
||||
const s = window.getSelection(); s.removeAllRanges(); s.addRange(r); return s.toString();
|
||||
}, [v2[0], v2.at(-1)]);
|
||||
const top0 = await page2.locator('.main__body.chat').evaluate(el => el.scrollTop);
|
||||
await page2.waitForTimeout(3000);
|
||||
const sel1 = await page2.evaluate(() => window.getSelection().toString());
|
||||
const top1 = await page2.locator('.main__body.chat').evaluate(el => el.scrollTop);
|
||||
console.log(`INFO streaming: selection ${sel0.length} -> ${sel1.length} characters, scrollTop ${Math.round(top0)} -> ${Math.round(top1)}`);
|
||||
console.log(`INFO before: ${JSON.stringify(sel0.slice(0, 40))} ... ${JSON.stringify(sel0.slice(-40))}
|
||||
INFO after: ${JSON.stringify(sel1.slice(0, 40))} ... ${JSON.stringify(sel1.slice(-40))}`);
|
||||
check(sel1 === sel0, 'streaming: a selection in the older reply stays as it was while the new reply is written (a selection inside the paragraph that is being written does not: React replaces its text node on every token)', `${sel0.length} -> ${sel1.length}`);
|
||||
check(Math.abs(top1 - top0) < 40, 'streaming: the view is not pulled away from the selection', `${Math.round(top0)} -> ${Math.round(top1)}`);
|
||||
} else console.log('INFO streaming: the older reply was not on screen, nothing to select');
|
||||
await page2.context().close();
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------------------------------------------------------ run */
|
||||
let ran = 0;
|
||||
for (const [name, fn] of Object.entries(cases)) {
|
||||
if (ONLY.length && !ONLY.includes(name)) continue;
|
||||
console.log(`--- ${name}`);
|
||||
ran += 1;
|
||||
try { await fn(); } catch (e) {
|
||||
check(false, `${name} threw`, String(e?.stack ?? e).split('\n').slice(0, 4).join(' | '));
|
||||
const last = pages.at(-1);
|
||||
if (last && !last.isClosed()) {
|
||||
await last.screenshot({ path: join(SHOTS, `debug-${name}.png`) }).catch(() => {});
|
||||
console.log('PAGE TEXT:', ((await last.evaluate(() => document.body.innerText).catch(() => '')) || '').slice(0, 600).replace(/\n+/g, ' | '));
|
||||
console.log('PAGE ERRORS:', last.problems?.join('; '));
|
||||
}
|
||||
}
|
||||
}
|
||||
check(ran > 0, 'at least one case ran');
|
||||
await browser.close();
|
||||
await worker.close();
|
||||
for (const proc of [child, tuneHostInfo?.proc]) if (proc) { if (process.platform === 'win32') spawnSync('taskkill', ['/PID', String(proc.pid), '/T', '/F'], { windowsHide: true }); else proc.kill('SIGTERM'); }
|
||||
try { rmSync(home, { recursive: true, force: true, maxRetries: 3 }); } catch { /* best effort */ }
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -101,7 +101,7 @@ records the patch identity, the dependency version and the before/after SHA-256.
|
||||
Source and already-installed Harness copies are not changed.
|
||||
|
||||
- A local route is reasoning-capable (`reasoning: true`) with `compat.thinkingFormat: 'chat-template'`: the request carries `chat_template_kwargs {enable_thinking, reasoning_effort}`, which the Strata server reads (a top-level `enable_thinking` is ignored by it, which is why pi-ai's `qwen` format left the model thinking). The chat's Reasoning menu travels as `thinking` in the stream body; the bridge sends it with `session/selectModel` (`reasoningEffort`) whenever the model or the level changed; a level a route does not have is dropped (never an error). API routes keep the provider default (the menu is disabled for them in the UI).
|
||||
- `localOutputBudget(context)` is 30 percent of the window, at least 4096 and at most 32768 (9,830 tokens at 32K, 19,660 at 64K). The Harness's automatic compaction reads it as the room a reply reserves. pi-ai sends the window minus the prompt. App-loaded models get `fit_max_tokens: true` so the engine clamps instead of answering 400. Sampling is not set here: the Harness sends none, so the server's own defaults apply.
|
||||
- `localOutputBudget(context)` is 30 percent of the window, at least 4096 and at most 32768 (9,830 tokens at 32K, 19,660 at 64K). The Harness's automatic compaction reads it as the room a reply reserves (the request header), and it is NOT the ceiling of a request: `plugins/strata-context.mjs` raises what the adapter sends to half the window, at most 64K tokens (`reply-cut.mjs` `replyCap`; pi-ai clamps it to the window minus the prompt), because a thinking model that writes a whole file in one call was cut inside the call at 19,660 and lost it (AGENT-LOST). A reply that is cut anyway is announced in the chat, the agent is asked to go on in smaller pieces (twice at most), and the file being written is streamed to the page as `agent-tool-draft` frames (name, size, lines, tail of the text). App-loaded models get `fit_max_tokens: true` so the engine clamps instead of answering 400. Sampling is not set here: the Harness sends none, so the server's own defaults apply.
|
||||
|
||||
## Tasks
|
||||
|
||||
|
||||
@@ -30,6 +30,11 @@ 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 { cutNotice, cutNudge } from './reply-cut.mjs';
|
||||
|
||||
/** How often one turn asks the agent to carry on after a reply the length limit cut (each time in smaller pieces). */
|
||||
export const MAX_CUT_RETRIES = 2;
|
||||
import { buildAnswer, parseQuestions } from './user-questions.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';
|
||||
|
||||
@@ -157,6 +162,8 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
const queues = new Map();
|
||||
const queueView = chatId => (queues.get(chatId) ?? []).map(({ id, text, at, deliverAt }) => ({ id, text, at, deliverAt }));
|
||||
const pending = new Map(); // `${sessionId}:${callId}` -> { eventId, sessionId, callId, at }
|
||||
const questions = new Map(); // `${sessionId}:${callId}` -> the agent's own question waiting for the person: { eventId, sessionId, callId, items, at, clientId } (#4244)
|
||||
const chatOfSession = sessionId => Object.keys(sessionMap).find(id => sessionMap[id]?.sessionId === sessionId) ?? null;
|
||||
const idleWaiters = new Set();
|
||||
|
||||
const chat = id => { let c = chats.get(id); if (!c) { c = { id, turn: null, route: null, routeGeneration: -1 }; chats.set(id, c); } return c; };
|
||||
@@ -275,10 +282,23 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
if (!r.callId) return;
|
||||
pending.set(`${value.agentId}:${r.callId}`, { eventId: value.eventId, sessionId: value.agentId, callId: r.callId, tool: r.toolName, reason: r.reason ?? null, at: Date.now(), clientId: runtime.clientId });
|
||||
} else if (value.type === 'waterfall' && value.event === 'user-questions/request') {
|
||||
// The app has no surface for the agent's own questions yet. Declaring that nobody can answer makes
|
||||
// the tool return an error the model reads and carries on from, instead of a turn that waits forever.
|
||||
void runtime.gw.answerEvent({ clientId: runtime.clientId, eventId: value.eventId, outcome: 'next' }).catch(() => {});
|
||||
// The agent of a chat that is running in front of the person asks the person: the question goes to the page (the question card above the message box) and the
|
||||
// Harness waits for the answer. Anything else (a helper's question, a chat that is stopping, a request with nothing to show) is declared
|
||||
// unanswerable, which makes the tool return an error the model reads and carries on from, instead of a turn that waits forever.
|
||||
const r = value.request ?? {};
|
||||
const chatId = chatOfSession(value.agentId);
|
||||
const turn = chatId ? chats.get(chatId)?.turn : null;
|
||||
const items = parseQuestions(r.questions);
|
||||
// A goal or a plan step runs without anyone there: a question would stall it, so it is declined at once (the model reads "could not be answered" and goes on).
|
||||
if (!turn || turn.finished || turn.stopping || turn.goalRun || turn.plan || !turn.announceQuestion || !items.length) {
|
||||
void runtime.gw.answerEvent({ clientId: runtime.clientId, eventId: value.eventId, outcome: 'next' }).catch(() => {});
|
||||
} else {
|
||||
const callId = String(r.wait?.callId ?? value.eventId);
|
||||
questions.set(`${value.agentId}:${callId}`, { eventId: value.eventId, sessionId: value.agentId, callId, items, at: Date.now(), clientId: runtime.clientId });
|
||||
turn.announceQuestion({ type: 'agent-question', callId, questions: items });
|
||||
}
|
||||
} else if (value.type === 'cancel') {
|
||||
for (const [k, v] of questions) if (v.eventId === value.eventId) { questions.delete(k); chats.get(chatOfSession(v.sessionId))?.turn?.announceQuestion?.({ type: 'agent-question-end', callId: v.callId }); }
|
||||
for (const [k, v] of pending) if (v.eventId === value.eventId) pending.delete(k);
|
||||
}
|
||||
}
|
||||
@@ -471,6 +491,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
async function finishTurn(failure) {
|
||||
if (turn.finished) return;
|
||||
turn.finished = true;
|
||||
for (const [k, v] of questions) if (v.sessionId === turn.sessionId) questions.delete(k);
|
||||
clearTimeout(turn.settleTimer);
|
||||
clearInterval(turn.watchdog);
|
||||
turn.follow?.close();
|
||||
@@ -484,7 +505,15 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
try { extra.push(...settlePlan(failure)); } catch { /* a plan frame must never break the end of a turn */ }
|
||||
// The turn guard: a turn the app stopped, or one that ended with no words, always ends with one plain message (a plan card carries it itself).
|
||||
let noteReason = turn.guard ?? null;
|
||||
if (!noteReason && guard && !failure && !turn.stopping && !turn.goalRun) {
|
||||
// A reply the length limit cut ends with a sentence that says so (and what was lost), never with silence (AGENT-LOST).
|
||||
const cutEnd = !failure && !turn.stopping && projector.ended?.kind === 'max-tokens';
|
||||
if (cutEnd && !noteReason) {
|
||||
const note = cutNotice({ ...projector.cut(), retrying: false });
|
||||
extra.push({ type: 'delta', own: true, text: `
|
||||
|
||||
${note.text}`, key: note.key, values: note.values, noteReason: 'cut' });
|
||||
}
|
||||
if (!noteReason && !cutEnd && guard && !failure && !turn.stopping && !turn.goalRun) {
|
||||
noteReason = guard.silent({ ended: projector.ended, wrote: projector.emittedAny === true, acted: projector.tools.size > 0, contextTokens: projector.contextTokens });
|
||||
}
|
||||
if (noteReason && !extra.some(f => f.type === 'plan' && f.event === 'proposed')) { const note = guardNote(noteReason); if (note) extra.push(note); }
|
||||
@@ -601,6 +630,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
case 'turn/start':
|
||||
turn.parentOpen = true;
|
||||
if (turn.research) turn.research.waitingStart = false;
|
||||
turn.cutWaiting = false;
|
||||
clearTimeout(turn.settleTimer);
|
||||
// After Stop, a helper's report would start a new turn that nobody asked for.
|
||||
if (turn.stopping) void runtime.gw.rpc('session/cancel', { request: { sessionId } }).catch(() => {});
|
||||
@@ -752,6 +782,24 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
const current = await goalGet();
|
||||
if (turn.finished || turn.parentOpen) return;
|
||||
if (current && current.phase === 'active' && current.activation === 'armed') { turn.settleTimer = after(700, armSettle); return; }
|
||||
// A reply the length limit cut (AGENT-LOST): the agent lost what it was writing. Say so in the chat and ask it to carry on in smaller pieces, twice at most.
|
||||
if (turn.cutWaiting) { turn.settleTimer = after(300, armSettle); return; }
|
||||
if (projector.ended?.kind === 'max-tokens' && !turn.goalRun && !turn.stopping && (turn.cuts ?? 0) < MAX_CUT_RETRIES) {
|
||||
const cut = projector.cut();
|
||||
turn.cuts = (turn.cuts ?? 0) + 1;
|
||||
const note = cutNotice({ ...cut, retrying: true });
|
||||
emit({ type: 'delta', own: true, text: `
|
||||
|
||||
${note.text}
|
||||
|
||||
`, key: note.key, values: note.values, noteReason: 'cut' });
|
||||
for (const f of projector.clearCut()) emit(f);
|
||||
turn.cutWaiting = true;
|
||||
try { await runtime.gw.rpc('session/prompt', { request: { requestId: `cut-${randomUUID()}`, sessionId, mode: 'queue', content: [{ type: 'text', text: cutNudge(cut) }] } }); }
|
||||
catch { turn.cutWaiting = false; finishTurn(null); return; }
|
||||
turn.settleTimer = after(500, armSettle);
|
||||
return;
|
||||
}
|
||||
// Research: gather (search and read pages), then write the answer in the fixed shape. A step that did not open pages is sent back once.
|
||||
if (turn.research && !turn.stopping) {
|
||||
const r = turn.research;
|
||||
@@ -794,6 +842,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
}
|
||||
turn.announceGoal = announceGoal;
|
||||
turn.announceQueue = frame => emit(frame);
|
||||
turn.announceQuestion = frame => emit(frame);
|
||||
|
||||
const snapshotTimer = {};
|
||||
await Promise.race([turn.snapshot.promise, new Promise((_, rej) => { snapshotTimer.t = after(20_000, () => rej(new Error('the session stream did not open'))); })])
|
||||
@@ -1096,6 +1145,47 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
delete sessionMap[chatId]; persist();
|
||||
}
|
||||
|
||||
// -- the agent's own questions ------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The person's answer to a question the agent asked (#4244), or a skip. The answer is checked against the questions that were asked, then sent
|
||||
* to the Harness as the result of the waiting `user-questions/request`; a skip declares the question unanswerable, as before, and the model
|
||||
* carries on.
|
||||
*/
|
||||
async function answerQuestion({ chatId, callId, answers, skip = false }) {
|
||||
const sessionId = sessionMap[chatId]?.sessionId;
|
||||
const key = `${sessionId}:${callId}`;
|
||||
const entry = questions.get(key);
|
||||
if (!entry || !rt?.hp.running) throw new AgentUnavailable('That question was already answered, or the assistant has gone on.', 'question-gone');
|
||||
const outcome = skip ? 'next' : buildAnswer(entry.items, answers);
|
||||
await rt.gw.answerEvent({ clientId: entry.clientId ?? rt.clientId, eventId: entry.eventId, outcome });
|
||||
questions.delete(key);
|
||||
return { ok: true, skipped: !!skip };
|
||||
}
|
||||
|
||||
// -- a message for a helper ---------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The person's own words for one helper (#4749), through the Harness's `subagents/prompt`: it delivers one browser-authored message to a
|
||||
* continuable child through its live parent. "steer" reaches a working helper at its next step; "queue" gives a helper that has finished
|
||||
* (or is between steps) a new turn. The Harness refuses a helper that cannot take messages (a one-shot one) and a parent that is not live;
|
||||
* either comes back as a plain sentence.
|
||||
*/
|
||||
async function messageHelper({ chatId, helperId, text, delivery = 'queue' }) {
|
||||
const clean = String(text ?? '').trim().slice(0, 8000);
|
||||
if (!clean) throw new AgentUnavailable('Type a few words for the helper.', 'helper-empty');
|
||||
if (!/^[\w.:@-]{1,160}$/.test(String(helperId ?? ''))) throw new AgentUnavailable('That helper is not known.', 'helper-unknown');
|
||||
const parent = sessionMap[chatId]?.sessionId;
|
||||
if (!parent || !rt?.hp.running) throw new AgentUnavailable('The assistant is not running in this chat, so it cannot pass a message on. Send a message in the chat first.', 'helper-idle');
|
||||
try {
|
||||
const receipt = await rt.gw.rpc('subagents/prompt', { request: { requestId: `helper-${randomUUID()}`, parentSessionId: parent, childSessionId: String(helperId), mode: 'continuable', delivery: delivery === 'steer' ? 'steer' : 'queue', content: [{ type: 'text', text: clean }] } });
|
||||
return { ok: true, messageId: receipt?.messageId ?? null, delivery: delivery === 'steer' ? 'steer' : 'queue' };
|
||||
} catch (e) {
|
||||
log(`agent · message for a helper refused: ${String(e?.message ?? e).slice(0, 200)}`);
|
||||
throw new AgentUnavailable(/continuable|one-shot|not.*(found|live|open)|unavailable|closed|ended/i.test(String(e?.message ?? '')) ? 'This helper cannot take a message any more. Ask the assistant in the chat to start a new one.' : 'The message could not be delivered right now. Try again in a moment.', 'helper-refused');
|
||||
}
|
||||
}
|
||||
|
||||
// -- messages typed while a goal runs ----------------------------------------------------------------------
|
||||
|
||||
/** Queue a message for the running goal. It is delivered between steps, or handed back to the page when the goal ends or pauses. */
|
||||
@@ -1333,7 +1423,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 { 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 }) };
|
||||
return { compactCommand, messageHelper, answerQuestion, 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. */
|
||||
|
||||
@@ -160,3 +160,15 @@ export function isContextOverflow(error) {
|
||||
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);
|
||||
}
|
||||
|
||||
/** The Harness asks for "concise English engineering prose", so a Chinese or Russian chat came back summarised in English (#4750). One sentence is added to the
|
||||
* summary request's last message: write it in the language the person used. Paths, commands and identifiers stay exact. Messages are not changed in place. */
|
||||
export const SUMMARY_LANGUAGE_RULE = 'Write the summary in the language the person used in this conversation (English only when they wrote English). The instruction above is in English; that does not change the language of the summary. Keep file paths, commands, error strings, identifiers and numbers exactly as they appear.';
|
||||
export function withSummaryLanguage(messages) {
|
||||
if (!Array.isArray(messages) || !messages.length) return messages;
|
||||
const last = messages[messages.length - 1];
|
||||
if (!last || last.role !== 'user') return messages;
|
||||
const content = typeof last.content === 'string' ? last.content + ' ' + SUMMARY_LANGUAGE_RULE
|
||||
: Array.isArray(last.content) ? [...last.content, { type: 'text', text: SUMMARY_LANGUAGE_RULE }] : null;
|
||||
return content === null ? messages : [...messages.slice(0, -1), { ...last, content }];
|
||||
}
|
||||
@@ -11,6 +11,7 @@ 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';
|
||||
import { ToolDraft, cutCalls } from './reply-cut.mjs';
|
||||
|
||||
const SHELL = new Set(['pwsh', 'bash', 'job_kill', 'job_output', 'job_list']);
|
||||
const WRITE = new Set(['write', 'edit']);
|
||||
@@ -222,7 +223,8 @@ export class TurnProjector {
|
||||
this.asked = new Map(); // approval id -> callId
|
||||
this.onStatus = null; // (update) => void: what the assistant is doing now (status.mjs); set by the bridge
|
||||
this.reasoning = new TokenCount();
|
||||
this.composing = new Map(); // callId -> { name, head, bytes }: a tool call the model is still writing
|
||||
this.composing = new Map(); // callId -> ToolDraft: a tool call the model is still writing (still here at a cut reply = it was lost)
|
||||
this.lastOutput = 0; // tokens the LAST model request produced
|
||||
this.running = []; // callIds of tools that are running, oldest first
|
||||
}
|
||||
|
||||
@@ -289,19 +291,20 @@ export class TurnProjector {
|
||||
out.push({ type: 'reasoning', text: c.text });
|
||||
} else if (c.type === 'tool-call-delta' && c.id) {
|
||||
// The model is writing a tool call. For a big file this is the long silent part: nothing else arrives until it is finished.
|
||||
const call = this.composing.get(c.id) ?? { name: c.name ?? '', head: '', bytes: 0 };
|
||||
const call = this.composing.get(c.id) ?? new ToolDraft(c.id, c.name ?? '');
|
||||
if (c.name) call.name = c.name;
|
||||
const piece = String(c.argumentsDelta ?? '');
|
||||
// Writing a tool call (a whole file, say) is decoding too: its tokens are in the engine's output count, so its time belongs in the speed.
|
||||
this.#produced(f.attemptId, piece, out);
|
||||
call.bytes += piece.length;
|
||||
if (call.head.length < 4000) call.head += piece;
|
||||
call.add(piece);
|
||||
this.composing.set(c.id, call);
|
||||
if (call.name && !NO_CARD.has(call.name)) {
|
||||
const raw = (/"(?:file_path|path|filePath)"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(call.head) ?? /"url"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(call.head))?.[1];
|
||||
let target = raw; try { if (raw !== undefined) target = JSON.parse('"' + raw + '"'); } catch { /* a half-written escape: show it as it is */ }
|
||||
const target = call.target;
|
||||
const args = target ? { file_path: target, url: target } : {};
|
||||
this.#status(toolUpdate('preparing', 'preparing-tool', call.name, args, { bytes: call.bytes }, { args: { tool: call.name, verb: toolVerb(call.name, 'running'), target: target ?? '', bytes: call.bytes } }));
|
||||
// The live data path of a file being written (AGENT-LOST): the name, a growing size and the tail of the text, at most five times a second.
|
||||
const now = Date.now();
|
||||
if (now - call.at >= 200) { call.at = now; out.push(call.frame({ scope: toolScope(call.name), verb: toolVerb(call.name, 'running') })); }
|
||||
}
|
||||
} else if (c.type === 'usage' && c.usage) {
|
||||
this.usageSeen = true;
|
||||
@@ -310,6 +313,7 @@ export class TurnProjector {
|
||||
const prompt = (Number(c.usage.inputTokens) || 0) + (Number(c.usage.cacheReadTokens) || 0) + (Number(c.usage.cacheWriteTokens) || 0);
|
||||
this.tokensIn += prompt;
|
||||
this.tokensOut += Number(c.usage.outputTokens) || 0;
|
||||
this.lastOutput = Number(c.usage.outputTokens) || 0;
|
||||
this.contextTokens = prompt + (Number(c.usage.outputTokens) || 0);
|
||||
out.push({ type: 'context', contextTokens: this.contextTokens, contextMax: this.window || undefined });
|
||||
}
|
||||
@@ -333,7 +337,9 @@ export class TurnProjector {
|
||||
const args = parseArguments(d.arguments);
|
||||
const card = { ...presentTool({ callId: d.callId, name: d.name, args }), state: 'running' };
|
||||
this.tools.set(d.callId, { card, calledAt: e.time });
|
||||
const draft = this.composing.get(d.callId);
|
||||
this.composing.delete(d.callId);
|
||||
if (draft && draft.bytes > 0 && !NO_CARD.has(d.name)) out.push(draft.frame({ done: true }));
|
||||
this.running.push(d.callId);
|
||||
this.#status(toolUpdate('tool', 'tool', d.name, args, {}, { args: { tool: d.name, verb: card.verb, target: card.target } }));
|
||||
out.push({ type: 'agent-tool', tool: card });
|
||||
@@ -419,8 +425,29 @@ export class TurnProjector {
|
||||
return this.#card(callId, { state: 'denied', result: outcome === 'rejected' ? 'Denied. Nothing ran.' : outcome === 'cancelled' ? 'Cancelled. Nothing ran.' : 'No one could answer this request, so nothing ran.' });
|
||||
}
|
||||
|
||||
/** A reply the model could not finish (the turn ended on the length limit): what it was writing, so nothing is lost without a word. */
|
||||
cut() {
|
||||
return { tokens: this.lastOutput, calls: cutCalls(this.composing.values()) };
|
||||
}
|
||||
|
||||
/** The agent was asked to carry on: the calls it lost are not pending any more. */
|
||||
clearCut() { const frames = this.#dropDrafts(); this.ended = null; return frames; }
|
||||
|
||||
/** Frames that take the writing cards of calls that will never be whole off the page (the reply was cut, or the turn ended). */
|
||||
#dropDrafts() {
|
||||
const frames = [...this.composing.values()].filter(d => d.bytes > 0 && !NO_CARD.has(d.name)).map(d => d.frame({ done: true, lost: true }));
|
||||
this.composing.clear();
|
||||
return frames;
|
||||
}
|
||||
|
||||
/** The terminal frames for a finished turn. */
|
||||
finish({ model, contextMax, engine = null } = {}) {
|
||||
finish(options = {}) {
|
||||
const cutInfo = this.ended?.kind === 'max-tokens' ? this.cut() : null;
|
||||
const dropped = this.#dropDrafts();
|
||||
return [...dropped, ...this.#finished(options, cutInfo)];
|
||||
}
|
||||
|
||||
#finished({ model, contextMax, engine = null } = {}, cutInfo = null) {
|
||||
const reason = this.ended ?? { kind: 'interrupted' };
|
||||
const durationMs = Date.now() - this.startedAt;
|
||||
const usage = this.usageSeen
|
||||
@@ -441,7 +468,7 @@ export class TurnProjector {
|
||||
case 'completed':
|
||||
return [{ type: 'done', ...base, ...usage, finishReason: 'stop' }];
|
||||
case 'max-tokens':
|
||||
return [{ type: 'done', ...base, ...usage, finishReason: 'length' }];
|
||||
return [{ type: 'done', ...base, ...usage, finishReason: 'length', cut: cutInfo }];
|
||||
case 'aborted':
|
||||
return [{ type: 'done', ...base, ...usage, finishReason: 'aborted', cancelled: true }];
|
||||
case 'error':
|
||||
|
||||
@@ -16,9 +16,13 @@
|
||||
* 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';
|
||||
import { replyCap } from '../reply-cut.mjs';
|
||||
import { densityOf, estimateMessage, estimateTools, isContextOverflow, MAX_DENSITY, pressureNow, promptTokensOf, splitMessages, summaryBudget, usageOfEvent, WINDOW_SLACK, withSummaryLanguage } from '../context-budget.mjs';
|
||||
|
||||
export const name = 'strata-context';
|
||||
|
||||
/** The routes the host writes for models on this computer (profile.mjs): `strata-local`, `strata-local-<id>`, and their `~s<budget>` copies. */
|
||||
const isLocalRoute = provider => typeof provider === 'string' && provider.startsWith('strata-local');
|
||||
export const inject = ['tokenMeter', 'llm'];
|
||||
|
||||
/** Real prompt measurements per session: { real, estimated } for the last answer the model server reported. */
|
||||
@@ -74,6 +78,47 @@ export function apply(ctx) {
|
||||
};
|
||||
}
|
||||
|
||||
// 3. A reply that has room to finish (AGENT-LOST, 11-01). The output budget in the profile is what the Harness's compaction sets aside for a
|
||||
// reply (30 percent of the window; it reads it from the request header), and the same number was also the ceiling of every request: a thinking
|
||||
// model that wrote a 15K-token file after 9K tokens of thinking was cut inside the tool call at 19,660, the unfinished call was dropped and the
|
||||
// turn ended with no file and no word. The header (and so compaction) stays as it is; the request that goes to the model server may produce up
|
||||
// to half the window, at most 64K tokens (replyCap), and pi-ai still clamps it to what the window has left after the prompt. The agent loop
|
||||
// dispatches through the adapter's `streamWithSnapshot`, after the loop has checked the request against its header, so that is where the ceiling
|
||||
// is raised, once per adapter, the first time a local route is prepared. A Harness that renames it leaves the ceiling as it was (the test that
|
||||
// runs the real Harness fails).
|
||||
if (llm && typeof llm.prepareCall === 'function' && !wrapped.has(llm.prepareCall)) {
|
||||
const original = llm.prepareCall;
|
||||
const raised = new WeakSet();
|
||||
const prepare = async function prepareCall(config, signal, configure) {
|
||||
try { raiseReplyCeiling(config?.provider, raised); } catch (e) { log('warn', `could not raise the reply ceiling: ${e.message}`); }
|
||||
return original.call(this, config, signal, configure);
|
||||
};
|
||||
wrapped.add(prepare);
|
||||
llm.prepareCall = prepare;
|
||||
}
|
||||
function raiseReplyCeiling(provider, raised) {
|
||||
if (!isLocalRoute(provider)) return;
|
||||
const adapter = llm.adapters?.get?.(provider)?.adapter;
|
||||
if (!adapter || raised.has(adapter) || typeof adapter.streamWithSnapshot !== 'function' || typeof adapter.modelOf !== 'function') return;
|
||||
raised.add(adapter);
|
||||
const stream = adapter.streamWithSnapshot;
|
||||
adapter.streamWithSnapshot = function streamWithSnapshot(options, snapshot) {
|
||||
let next = options;
|
||||
try {
|
||||
if (isLocalRoute(options?.provider)) {
|
||||
const model = this.modelOf(snapshot, options.provider, options.model);
|
||||
const asked = options.maxTokens ?? model.maxTokens ?? 0;
|
||||
// Only the agent's own reply budget is raised: a summary or a title asks for far less and keeps what it asked.
|
||||
if (model.contextWindow > 0 && asked >= Math.floor(model.contextWindow * 0.25)) {
|
||||
const cap = replyCap(model.contextWindow, asked);
|
||||
if (cap > asked) next = { ...options, maxTokens: cap };
|
||||
}
|
||||
}
|
||||
} catch { next = options; }
|
||||
return stream.call(this, next, snapshot);
|
||||
};
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
@@ -108,6 +153,7 @@ export function apply(ctx) {
|
||||
const refusedForSize = finish => finish?.kind === 'error' && isContextOverflow(finish.failure ?? finish);
|
||||
|
||||
async function* summaryStream(options, original) {
|
||||
options = { ...options, messages: withSummaryLanguage(options.messages) }; // #4750: the summary follows the conversation's language
|
||||
let window = 0;
|
||||
try { window = (await llm.resolveModelInfo(options.provider, options.model, options.signal))?.context?.contextWindow ?? 0; } catch { window = 0; }
|
||||
const messages = options.messages ?? [];
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
* reminder tools would run behind its back.
|
||||
* - `web_search` removed (tool-web with `search:false`): every search backend the Harness has needs a service key
|
||||
* the app does not have, so the tool could only fail. `web_fetch` stays. (`searchProvider` can switch it back on.)
|
||||
* - `tool-ask-user` removed: the app has no place to show the agent's own questions, so the tool could only fail;
|
||||
* the agent asks in plain text instead.
|
||||
* - `tool-ask-user` stays (it is the group "Questions for you" in Settings -> Tools): the app shows the agent's own questions in a card above
|
||||
* the message box and sends the answer back (#4244). Switched off, the tool is not offered and the agent asks in plain text.
|
||||
* - the `planning` group (Harness plan mode and `exit_plan_mode`) removed: the app's Plan mode is the permission
|
||||
* preset (nothing can change); the Harness's plan mode needs an interactive review the app does not have.
|
||||
* - `tool-bash` / `tool-pwsh`: exactly one, by operating system (the Harness chooses the same at load).
|
||||
@@ -29,7 +29,7 @@ export const STANDARD_PLUGIN_IDS = [
|
||||
'tool-ask-user', 'tool-todo', 'tool-web', 'present', 'tool-plugin-manager',
|
||||
];
|
||||
/** What we leave out of it. */
|
||||
export const REMOVED_PLUGIN_IDS = ['tool-schedule', 'tool-ask-user', 'planning'];
|
||||
export const REMOVED_PLUGIN_IDS = ['tool-schedule', 'planning'];
|
||||
/** Plugins removed from inside the `delegation` group (the group stays: helper agents are a feature). */
|
||||
export const REMOVED_DELEGATION_IDS = [];
|
||||
|
||||
@@ -48,6 +48,24 @@ export const AGENT_PROMPT = [
|
||||
'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');
|
||||
|
||||
/**
|
||||
* The one sentence that says which computer this is. The Harness's own runtime context is off (see `includeRuntimeContext` below), so without it
|
||||
* a model on Windows spent its first steps on `ls`, `uname` and `wc` before trying PowerShell commands (and some ran out of steps first).
|
||||
*/
|
||||
export const OS_LINES = {
|
||||
win32: 'This computer runs Windows and the shell tool is PowerShell: uname, wc and grep do not exist here; use Get-ChildItem, Get-Content, Select-String and Measure-Object. There is no need to check which system this is.',
|
||||
darwin: 'This computer runs macOS and the shell tool is a Unix shell. There is no need to check which system this is.',
|
||||
linux: 'This computer runs Linux and the shell tool is a Unix shell. There is no need to check which system this is.',
|
||||
};
|
||||
|
||||
/** AGENT_PROMPT with the sentence for `platform` after its first line. */
|
||||
export function agentPromptFor(platform = process.platform) {
|
||||
const line = OS_LINES[platform];
|
||||
if (!line) return AGENT_PROMPT;
|
||||
const [first, ...rest] = AGENT_PROMPT.split('\n\n');
|
||||
return [first, line, ...rest].join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{platform?: string, search?: boolean}} [options]
|
||||
* @returns {object} the `config` of the `preset-standard` row
|
||||
@@ -55,7 +73,7 @@ export const AGENT_PROMPT = [
|
||||
export function standardPreset({ platform = process.platform, search = false, off = new Set(), skillsDir = null, compaction = [] } = {}) {
|
||||
const windows = platform === 'win32';
|
||||
const plugins = [
|
||||
{ id: 'persona', name: '@deepseek-ai/dsh-persona', config: { prefix: AGENT_PROMPT, complete: true, includeRuntimeContext: false } },
|
||||
{ id: 'persona', name: '@deepseek-ai/dsh-persona', config: { prefix: agentPromptFor(platform), complete: true, includeRuntimeContext: false } },
|
||||
{ id: 'agent-instructions', name: '@deepseek-ai/dsh-agent-instructions', config: { maxBytes: 65536 } },
|
||||
{ id: 'time-context', name: '@deepseek-ai/dsh-time-context' },
|
||||
windows
|
||||
@@ -88,6 +106,7 @@ export function standardPreset({ platform = process.platform, search = false, of
|
||||
{ id: 'tool-workflow', name: '@deepseek-ai/dsh-tool-workflow' },
|
||||
],
|
||||
},
|
||||
{ id: 'tool-ask-user', name: '@deepseek-ai/dsh-tool-ask-user' },
|
||||
{ id: 'tool-todo', name: '@deepseek-ai/dsh-tool-todo', config: { allowParallelInProgress: true } },
|
||||
{ id: 'tool-web', name: '@deepseek-ai/dsh-tool-web', config: { fetch: !off.has('fetch'), search: search && !off.has('search'), searchTimeoutMs: 60000 } },
|
||||
{ id: 'present', name: '@deepseek-ai/dsh-tool-present' },
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* A reply the model could not finish (AGENT-LOST, 11-01): pure helpers, no I/O.
|
||||
*
|
||||
* A model reply has a ceiling (the "output budget"). A thinking model that then writes a whole file in one tool call can spend all of it
|
||||
* before the call is closed. The Harness drops a tool call that was not closed (its arguments are half a JSON text), the turn ends with
|
||||
* the reason `max-tokens`, and until now the person saw nothing: no file, no card, no sentence. Here:
|
||||
*
|
||||
* - `replyCap(window)` how much one reply may produce (the request's ceiling), much larger than the budget the Harness reserves
|
||||
* for compaction (profile.mjs `localOutputBudget`): the Harness still clamps it to what the window has left.
|
||||
* - `ToolDraft` what a tool call that is being written looks like so far: its name, the file it is for, a size, a tail of the text.
|
||||
* - `cutNotice(...)` the plain sentence for the chat, and `cutNudge(...)` the message that lets the agent carry on in smaller pieces.
|
||||
*/
|
||||
|
||||
/** Half the window, at most 64K tokens, and never less than the budget compaction reserves. The Harness clamps it to the room left (window minus prompt). */
|
||||
export function replyCap(window, reserved = 0) {
|
||||
const w = Number.isFinite(window) && window > 0 ? Math.floor(window) : 32768;
|
||||
return Math.max(Math.floor(reserved) || 0, Math.min(Math.floor(w / 2), 65536));
|
||||
}
|
||||
|
||||
const ESCAPES = { n: '\n', t: '\t', r: '', '"': '"', '\\': '\\', '/': '/', b: '', f: '' };
|
||||
|
||||
/** A tail of a JSON string body, read back to text: `\n` becomes a newline, `\"` a quote. A half-written escape at the end is dropped. */
|
||||
export function unescapeJsonTail(raw) {
|
||||
let text = String(raw ?? '');
|
||||
const cut = /\\(u[0-9a-fA-F]{0,3})?$/.exec(text);
|
||||
if (cut) text = text.slice(0, cut.index);
|
||||
return text.replace(/\\(u[0-9a-fA-F]{4}|[nrtbf"\\/])/g, (_, e) => (e[0] === 'u' ? String.fromCharCode(parseInt(e.slice(1), 16)) : ESCAPES[e] ?? e));
|
||||
}
|
||||
|
||||
const TARGET = /"(?:file_path|path|filePath)"\s*:\s*"((?:[^"\\]|\\.)*)"/;
|
||||
const URL_ARG = /"url"\s*:\s*"((?:[^"\\]|\\.)*)"/;
|
||||
/** The long text argument of the common writing tools; the tail of the arguments is previewed after the first one of these. */
|
||||
const BODY = /"(?:content|new_string|new_str|text|code|body|command|file_text)"\s*:\s*"/;
|
||||
|
||||
/** One tool call being written: the model's streamed arguments, kept small. */
|
||||
export class ToolDraft {
|
||||
constructor(id, name = '') {
|
||||
this.id = id; this.name = name;
|
||||
this.bytes = 0; this.lines = 0;
|
||||
this.head = ''; // the first 4,000 characters: the body argument usually starts here
|
||||
this.where = ''; // the file (or address) the call is for, once seen: a model may write it before or after the text
|
||||
this.carry = ''; // the last 300 characters, so a name split between two pieces is still seen
|
||||
this.tail = ''; // the last 2,400 characters: what is being written now
|
||||
this.at = 0; // when a frame was last sent for it
|
||||
}
|
||||
|
||||
add(piece) {
|
||||
const text = String(piece ?? '');
|
||||
if (!text) return;
|
||||
this.bytes += text.length;
|
||||
for (let i = text.indexOf('\\n'); i >= 0; i = text.indexOf('\\n', i + 2)) this.lines += 1;
|
||||
if (this.head.length < 4000) this.head += text.slice(0, 4000 - this.head.length);
|
||||
this.tail = (this.tail + text).slice(-2400);
|
||||
if (!this.where) {
|
||||
const seen = this.carry + text;
|
||||
const raw = (TARGET.exec(seen) ?? URL_ARG.exec(seen))?.[1];
|
||||
if (raw !== undefined) { try { this.where = JSON.parse(`"${raw}"`); } catch { this.where = unescapeJsonTail(raw); } }
|
||||
this.carry = seen.slice(-300);
|
||||
}
|
||||
}
|
||||
|
||||
get target() { return this.where; }
|
||||
|
||||
/** The text of the body so far, only its last `max` characters; empty until the body argument has started. */
|
||||
preview(max = 1600) {
|
||||
const start = BODY.exec(this.head);
|
||||
if (!start) return '';
|
||||
const bodyAt = start.index + start[0].length; // where the body begins, counted in streamed characters
|
||||
if (bodyAt >= this.bytes) return '';
|
||||
const tailAt = this.bytes - this.tail.length; // where the kept tail begins
|
||||
const body = unescapeJsonTail(this.tail.slice(Math.max(0, bodyAt - tailAt)));
|
||||
return body.length > max ? body.slice(-max) : body;
|
||||
}
|
||||
|
||||
/** The frame for the page. */
|
||||
frame({ done = false, ...extra } = {}) {
|
||||
return { type: 'agent-tool-draft', draft: { id: this.id, name: this.name, target: this.target, bytes: this.bytes, lines: this.lines, preview: done ? '' : this.preview(), ...extra, done } };
|
||||
}
|
||||
}
|
||||
|
||||
const kb = n => (n >= 10_000 ? `${Math.round(n / 1000)} KB` : `${(n / 1000).toFixed(1)} KB`);
|
||||
|
||||
/** Calls the model was still writing when its reply was cut: [{ name, target, bytes }]. */
|
||||
export function cutCalls(drafts) {
|
||||
return [...drafts].filter(d => d.bytes > 0).map(d => ({ name: d.name, target: d.target, bytes: d.bytes }));
|
||||
}
|
||||
|
||||
const withTokens = tokens => (tokens > 0 ? ` (${Number(tokens).toLocaleString('en-US')} tokens)` : '');
|
||||
|
||||
/** The sentence for the person, with the catalog slots it needs: { text, key, values }. `again` is true when the agent was already asked to carry on. */
|
||||
export function cutNotice({ calls = [], tokens = 0, again = false, retrying = false } = {}) {
|
||||
const call = calls.find(c => c.target) ?? calls[0] ?? null;
|
||||
const file = call?.target ? String(call.target).split(/[\\/]/).filter(Boolean).at(-1) : '';
|
||||
const what = file || (/^(write|edit)$/.test(call?.name ?? '') ? 'a file' : call?.name);
|
||||
if (call && retrying) {
|
||||
const key = 'That reply reached its length limit{0} while writing {1} ({2}), so that write did not happen. I asked the assistant to look at what is on disk and write the rest in smaller parts.';
|
||||
const values = [withTokens(tokens), what, kb(call.bytes)];
|
||||
return { key, values, text: fill(key, values) };
|
||||
}
|
||||
if (call) {
|
||||
const key = 'The reply reached its length limit{0} while writing {1} ({2}), so that write did not happen. Say “continue” and it will look at what is on disk and go on in smaller parts.';
|
||||
const values = [withTokens(tokens), what, kb(call.bytes)];
|
||||
return { key, values, text: fill(key, values) };
|
||||
}
|
||||
if (retrying) {
|
||||
const key = 'That reply reached its length limit{0} and was cut off. I asked the assistant to carry on.';
|
||||
return { key, values: [withTokens(tokens)], text: fill(key, [withTokens(tokens)]) };
|
||||
}
|
||||
const key = 'The reply reached its length limit{0} and was cut off. Say “continue” to go on.';
|
||||
return { key, values: [withTokens(tokens)], text: fill(key, [withTokens(tokens)]) };
|
||||
}
|
||||
|
||||
export function fill(template, values) { return String(template).replace(/\{(\d+)\}/g, (_, i) => String(values[Number(i)] ?? '')); }
|
||||
|
||||
/** The message sent to the assistant so it carries on. It is not shown as the person's message. */
|
||||
export function cutNudge({ calls = [], tokens = 0 } = {}) {
|
||||
const call = calls.find(c => c.target) ?? calls[0] ?? null;
|
||||
if (!call) {
|
||||
return 'Your last reply was cut off by the output limit before it was finished. Carry on from where it stopped without repeating what you already wrote, and keep your thinking short.';
|
||||
}
|
||||
const file = call.target || 'the file';
|
||||
return `Your last reply was cut off by the output limit${tokens > 0 ? ` (${Number(tokens).toLocaleString('en-US')} tokens)` : ''} while you were writing ${file} in one tool call, so that call did not run and nothing was changed by it. First look at what is on disk now (list the folder, read the end of the file). Do not delete or start over a file that is already complete. If it is not complete, write what is missing in several smaller parts (about 150 lines or fewer each) with edits or appends, and read the end of the file back when you are done. Keep your thinking short.`;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export const TOOL_GROUPS = [
|
||||
{ id: 'search', area: 'web', name: 'Search the web', about: 'Lets the assistant search the web when it needs fresh information.', asks: true, web: 'search' },
|
||||
{ id: 'helpers', area: 'together', name: 'Helper assistants', about: 'Lets the assistant start helpers that work on parts of a task at the same time.', asks: false, plugins: ['delegation'] },
|
||||
{ id: 'goals', area: 'together', name: 'Goals', about: 'Lets you give a goal with /goal. The assistant keeps working until it is done.', asks: false, plugins: ['command-goal', 'tool-goal'] },
|
||||
{ id: 'questions', area: 'together', name: 'Questions for you', about: 'Lets the assistant stop and ask you a question, with answers to pick from, when it needs your decision.', asks: false, plugins: ['tool-ask-user'] },
|
||||
{ id: 'todo', area: 'together', name: 'To-do list', about: 'Lets the assistant keep a checklist while it works on a big task.', asks: false, plugins: ['tool-todo'] },
|
||||
{ id: 'skills', area: 'together', name: 'Skills', about: 'Lets the assistant use your saved skills when they fit the task.', asks: false, plugins: ['skill-filesystem', 'tool-skill'] },
|
||||
{ id: 'present', area: 'together', name: 'Show results', about: 'Lets the assistant show a page or file in the preview next to the chat.', asks: false, plugins: ['present'] },
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* The agent's own questions (the Harness's `ask_user_question` tool, #4244). The Harness raises the `user-questions/request` event with
|
||||
* `{ questions: [{ id, question, detail?, header?, options?: [{ label, description? }], multiSelect?, intent? }], wait?: { callId } }` and wants
|
||||
* `{ answers: [{ id, selected: string[], custom? }] }` back (AskUserQuestionRequestEvent / AskUserQuestionAnswer of the pinned Harness).
|
||||
* This file only reads the first into something the page can draw, and checks the second before it goes out.
|
||||
*/
|
||||
const text = (v, max) => (typeof v === 'string' ? v.trim().slice(0, max) : '');
|
||||
|
||||
/** The questions of a request, bounded and plain; an empty list when the request carries nothing that can be shown. */
|
||||
export function parseQuestions(raw) {
|
||||
const out = [];
|
||||
for (const q of Array.isArray(raw) ? raw.slice(0, 6) : []) {
|
||||
const id = text(q?.id, 120), question = text(q?.question, 800);
|
||||
if (!id || !question) continue;
|
||||
const options = (Array.isArray(q.options) ? q.options.slice(0, 12) : [])
|
||||
.map(o => ({ label: text(o?.label, 200), description: text(o?.description, 400) || undefined })).filter(o => o.label);
|
||||
out.push({ id, question, detail: text(q.detail, 800) || undefined, header: text(q.header, 80) || undefined, options, multiSelect: q.multiSelect === true });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The answer the person gave, checked against the questions that were asked: every question answered, only the offered labels (or the person's own
|
||||
* words), one choice for a single-select question. Throws a plain Error with a code on the first thing that does not fit.
|
||||
*/
|
||||
export function buildAnswer(questions, given) {
|
||||
const byId = new Map((Array.isArray(given) ? given : []).map(a => [String(a?.id ?? ''), a]));
|
||||
const answers = [];
|
||||
for (const q of questions) {
|
||||
const a = byId.get(q.id);
|
||||
const labels = new Set(q.options.map(o => o.label));
|
||||
const selected = (Array.isArray(a?.selected) ? a.selected : []).map(s => String(s)).filter(s => labels.has(s));
|
||||
const custom = text(a?.custom, 2000);
|
||||
if (!selected.length && !custom) throw Object.assign(new Error('Answer every question, or skip them.'), { code: 'question-incomplete' });
|
||||
if (!q.multiSelect && selected.length > 1) throw Object.assign(new Error('Choose one answer for each question.'), { code: 'question-invalid' });
|
||||
answers.push({ id: q.id, selected: [...new Set(selected)], ...(custom ? { custom } : {}) });
|
||||
}
|
||||
return { answers };
|
||||
}
|
||||
@@ -7,6 +7,16 @@ const run=(command,args)=>promisify(execFile)(command,args,{env:externalEnvironm
|
||||
const extensions=new Set(['.md','.txt','.pdf','.html','.htm','.png','.jpg','.jpeg','.webp','.gif','.svg','.csv','.ts','.tsx','.js','.jsx','.py','.json','.css','.rs','.go','.sh','.mp4','.webm']);
|
||||
export async function openArtifact(body,{examplesDir,previewsDir,platform=process.platform,launch=run}) {
|
||||
if(!['open','reveal'].includes(body.action))throw new Error('Choose Open or Show in file explorer.');
|
||||
if(typeof body.url==='string') {
|
||||
// #4721: a web address goes to the person's default browser (the app window drops window.open: WebView2 has no new-window handler). http and https only, no shell.
|
||||
if(body.action!=='open')throw new Error('A web address can only be opened.');
|
||||
let address;try{address=new URL(body.url);}catch{throw new Error('This is not a web address.');}
|
||||
if(!['http:','https:'].includes(address.protocol)||body.url.length>4096)throw new Error('Only http and https addresses can be opened.');
|
||||
if(platform==='darwin')await launch('open',[address.href]);
|
||||
else if(platform==='win32')await launch('rundll32.exe',['url.dll,FileProtocolHandler',address.href]);
|
||||
else await launch('xdg-open',[address.href]);
|
||||
return {ok:true};
|
||||
}
|
||||
let file;
|
||||
if(typeof body.path==='string') {
|
||||
// Only public example assets can be addressed by path. Never accept host paths.
|
||||
|
||||
@@ -121,10 +121,18 @@ export class ControlError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Run one CLI command of Control. Resolves { code, json, text, stderr }; rejects only if it cannot start or times out. */
|
||||
export function runCli(control, args, { env = process.env, timeoutMs = 60_000, spawnProcess = spawn, components, acquire = false, onLine = null, signal = null } = {}) {
|
||||
/**
|
||||
* Small Control programs that are a module of strata_control and not a `strata` command. They run on Control's interpreter with Control's folder first on the
|
||||
* path (the folder is the working directory), so they work whatever the interpreter does with the script folder.
|
||||
*/
|
||||
const BOOT = {
|
||||
pictures: 'import os,sys;sys.path.insert(0,os.getcwd());from strata_control import pictures;sys.exit(pictures.main(sys.argv[1:]))',
|
||||
};
|
||||
|
||||
/** Run one CLI command of Control (or, with `boot`, one of its modules). Resolves { code, json, text, stderr }; rejects only if it cannot start or times out. */
|
||||
export function runCli(control, args, { env = process.env, timeoutMs = 60_000, spawnProcess = spawn, components, acquire = false, onLine = null, signal = null, boot = null } = {}) {
|
||||
return new Promise((resolveRun, reject) => {
|
||||
const child = spawnProcess(control.python, [control.cli, ...args], {
|
||||
const child = spawnProcess(control.python, boot ? ['-c', BOOT[boot], ...args] : [control.cli, ...args], {
|
||||
cwd: control.dir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: controlEnvironment(control, { env, acquire, ...(components ? { components } : {}) }),
|
||||
});
|
||||
@@ -185,9 +193,9 @@ export function createControl({ control = findControl(), env = process.env, run
|
||||
if (result.json?.ok) return result.json;
|
||||
throw failure(result, 'The update check failed.');
|
||||
},
|
||||
async update({ feed = env.STRATA_UPDATE_FEED ?? null, engine = true, control: withControl = true, appVersion = env.STRATA_APP_VERSION ?? null, force = false, onEvent = null, signal = null } = {}) {
|
||||
async update({ feed = env.STRATA_UPDATE_FEED ?? null, engine = true, control: withControl = true, appVersion = env.STRATA_APP_VERSION ?? null, force = false, stopModel = false, onEvent = null, signal = null } = {}) {
|
||||
const args = ['update', '--output', 'jsonl', ...feedArgs(feed), ...(engine ? [] : ['--no-engine']), ...(withControl ? [] : ['--no-control']),
|
||||
...(appVersion ? ['--app-version', appVersion] : []), ...(force ? ['--force'] : [])];
|
||||
...(appVersion ? ['--app-version', appVersion] : []), ...(force ? ['--force'] : []), ...(stopModel ? ['--stop-model'] : [])];
|
||||
const result = await call(args, { timeoutMs: 3 * 3600_000, acquire: true, onLine: onEvent ?? (() => {}), signal });
|
||||
if (result.json?.ok) return result.json;
|
||||
const failed = result.json?.results?.find?.(r => r.status === 'failed');
|
||||
@@ -205,6 +213,24 @@ export function createControl({ control = findControl(), env = process.env, run
|
||||
if (result.json?.ok) return result.json;
|
||||
throw failure(result, 'The engine could not be rolled back.');
|
||||
},
|
||||
/** The engines installed on this computer, by family (`strata components engines`): the one list the Terminal prints too. Reads files only. */
|
||||
async engines() {
|
||||
const result = await call(['components', 'engines', '--output', 'json'], { timeoutMs: 60_000 });
|
||||
if (result.json?.ok) return result.json;
|
||||
throw failure(result, 'The installed engines could not be read.');
|
||||
},
|
||||
/** Switch to an installed engine (`strata components use`): nothing is downloaded. `root` is only for a DeepSeek engine that lives in more than one place. */
|
||||
async useEngine({ family, folder }) {
|
||||
const result = await call(['components', 'use', folder, '--family', family, '--output', 'json'], { timeoutMs: 600_000 });
|
||||
if (result.json?.ok) return result.json;
|
||||
throw failure(result, 'The engine could not be switched.');
|
||||
},
|
||||
/** Delete an installed engine (`strata components remove`); Control refuses the one in use. */
|
||||
async removeEngine({ family, folder }) {
|
||||
const result = await call(['components', 'remove', folder, '--family', family, '--output', 'json'], { timeoutMs: 600_000 });
|
||||
if (result.json?.ok) return result.json;
|
||||
throw failure(result, 'The engine could not be deleted.');
|
||||
},
|
||||
async engine({ backend = null } = {}) {
|
||||
const result = await call(['components', 'engine', '--output', 'json', ...(backend ? ['--backend', backend] : [])], { timeoutMs: 30_000 });
|
||||
if (result.json) return result.json; // exit 5 still carries the report and the notice
|
||||
@@ -266,17 +292,30 @@ export function createControl({ control = findControl(), env = process.env, run
|
||||
* (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 });
|
||||
const result = await call(['drafting', 'plan', ...(configPath ? ['--config', configPath] : []), ...(mode ? ['--mode', mode] : []), ...(family ? ['--family', family] : []), ...(dataDir ? ['--data-dir', dataDir] : []), '--no-split', '--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 args = ['drafting', 'fetch', '--config', configPath, ...(mode ? ['--mode', mode] : []), '--no-split', '--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' });
|
||||
},
|
||||
/**
|
||||
* The picture files of an installed model that has none (strata_control/pictures.py): found or downloaded once (about 0.9 GB, shared), and the run
|
||||
* config's `vision` section worked out for this engine. Nothing else of the model is touched and nothing is written to its run config: the caller does.
|
||||
* Resolves { mmproj, vision, fetched, note, data_dir }; a failure is a ControlError whose `code` is offline | disk | failed | checksum | unsupported | no_reader.
|
||||
* `onEvent` gets {step, done, total}. This is an explicit step that may reach the network (`acquire`), only for the Load the person pressed.
|
||||
*/
|
||||
async picturesFetch({ configPath, engineDir, vision = 'yes', dataDir = null, family = null, hardware = null, onEvent = null, signal = null } = {}) {
|
||||
const args = ['fetch', '--config', configPath, '--engine-dir', engineDir, '--vision', vision, ...(dataDir ? ['--data-dir', dataDir] : []), ...(family ? ['--family', family] : []),
|
||||
...(hardware?.vram_gb != null ? ['--vram-gb', String(hardware.vram_gb)] : []), ...(hardware?.make ? ['--make', String(hardware.make)] : []), ...(hardware?.arch != null ? ['--arch', String(hardware.arch)] : [])];
|
||||
const result = await call(args, { timeoutMs: 3 * 3600_000, acquire: true, boot: 'pictures', 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 picture files could not be downloaded.'), { exitCode: result.code, output: result.json }), { code: result.json?.error?.code ?? '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);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { plainEngineError } from './update-messages.mjs';
|
||||
|
||||
/**
|
||||
* Settings > Engine: the engines installed on this computer, switched and deleted through Control (`strata components engines | use | remove`),
|
||||
* the same code the Terminal runs. This module only asks, and turns Control's answer into plain words; it never touches an engine folder itself.
|
||||
* Nothing here downloads: a switch uses what is installed.
|
||||
*/
|
||||
|
||||
const FAMILIES = ['qwen', 'deepseek'];
|
||||
|
||||
/** { status, body } for GET /api/engines. Without Control (a source checkout) the list is not available and the page says so. */
|
||||
export async function listEngines(control) {
|
||||
if (typeof control?.engines !== 'function') return { status: 200, body: { available: false, families: [], total_bytes: 0 } };
|
||||
try {
|
||||
const listing = await control.engines();
|
||||
return { status: 200, body: { available: true, families: listing.families ?? [], total_bytes: listing.total_bytes ?? 0, serving: listing.serving === true } };
|
||||
} catch (error) {
|
||||
return { status: 500, body: { error: "The installed engines couldn't be read.", details: String(error?.message ?? error) } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* { status, body } for POST /api/engines/use and /api/engines/remove. `modelsRunning()` says whether the app has a model loaded: a switch then
|
||||
* answers at once with the one next step (stop it first); a delete of an engine nothing uses is allowed, Control refuses the one in use.
|
||||
*/
|
||||
export async function changeEngine(control, action, body, { modelsRunning = () => false } = {}) {
|
||||
if (typeof control?.useEngine !== 'function') return { status: 501, body: { error: 'Engines can be switched and deleted in the installed Strata app.' } };
|
||||
const family = body?.family, folder = body?.folder;
|
||||
if (!FAMILIES.includes(family) || typeof folder !== 'string' || !/^[0-9A-Za-z][\w.+-]*$/.test(folder)) {
|
||||
return { status: 400, body: { error: 'Choose an engine from the list.' } };
|
||||
}
|
||||
if (action === 'use' && modelsRunning()) {
|
||||
return { status: 409, body: { error: 'A model is running, so nothing was changed. Unload the model in Models, then try again.' } };
|
||||
}
|
||||
try {
|
||||
const result = action === 'use' ? await control.useEngine({ family, folder }) : await control.removeEngine({ family, folder });
|
||||
return { status: 200, body: { ok: true, ...result } };
|
||||
} catch (error) {
|
||||
const plain = plainEngineError(error, action === 'use' ? 'switch' : 'delete');
|
||||
return { status: plain.message.startsWith('A model is running') ? 409 : 400, body: { error: plain.message, details: plain.details } };
|
||||
}
|
||||
}
|
||||
@@ -43,12 +43,15 @@ export function createEngineStep({ control, signal = null, onChange = () => {} }
|
||||
return { ...part };
|
||||
}
|
||||
const version = engine.offered?.version ?? null;
|
||||
set({ state: 'downloading', version, name: `Engine ${version ?? ''}`.trim(), total_gb: round((engine.download_bytes ?? 0) / GB), message: 'Downloading the engine for this PC…' });
|
||||
// An engine already works here: say what is about to change and that it can be undone (#4823), not only "downloading".
|
||||
const before = engine.installed?.version ?? null;
|
||||
const change = before && version && before !== version ? `Updating the engine from ${before} to ${version}. The engine you have now is kept, and Settings can go back to it.` : null;
|
||||
set({ state: 'downloading', version, name: `Engine ${version ?? ''}`.trim(), total_gb: round((engine.download_bytes ?? 0) / GB), message: change ?? 'Downloading the engine for this PC…' });
|
||||
const result = await control.update({
|
||||
engine: true, control: false, signal,
|
||||
onEvent: event => {
|
||||
if (event.event === 'progress') {
|
||||
set({ state: 'downloading', percent: event.percent ?? 0, done_gb: round((event.done ?? 0) / GB), total_gb: round((event.total ?? 0) / GB), message: `Downloading the engine for this PC: ${event.percent ?? 0}%` });
|
||||
set({ state: 'downloading', percent: event.percent ?? 0, done_gb: round((event.done ?? 0) / GB), total_gb: round((event.total ?? 0) / GB), message: `${change ? `Updating the engine from ${before} to ${version}` : 'Downloading the engine for this PC'}: ${event.percent ?? 0}%` });
|
||||
} else if (event.event === 'phase') {
|
||||
const state = { verify: 'verifying', test: 'testing', switch: 'switching' }[event.phase];
|
||||
if (state) set({ state, message: PHASE_TEXT[event.phase] ?? part.message });
|
||||
@@ -58,7 +61,7 @@ export function createEngineStep({ control, signal = null, onChange = () => {} }
|
||||
},
|
||||
});
|
||||
const done = (result.results ?? []).find(r => r.component === 'engine');
|
||||
set({ state: 'done', percent: 100, done_gb: part.total_gb, version: done?.installed_after?.version ?? version, message: 'The engine for this PC is ready.' });
|
||||
set({ state: 'done', percent: 100, done_gb: part.total_gb, version: done?.installed_after?.version ?? version, message: change ? `The engine was updated from ${before} to ${version}. Settings can go back to the one you had.` : 'The engine for this PC is ready.' });
|
||||
} catch (error) {
|
||||
const plain = plainUpdateError(error, 'engine');
|
||||
note = `Your model is ready. The engine for this PC couldn't be downloaded. ${plain.next}`;
|
||||
|
||||
@@ -25,7 +25,12 @@ export function windowsFolderScript(prompt) {
|
||||
'$dialog.ShowNewFolderButton = $true',
|
||||
// READY tells the host the window is about to open; a host that never sees it gives up and asks for the path in words (QA F06).
|
||||
'[Console]::Out.WriteLine("READY"); [Console]::Out.Flush()',
|
||||
'if ($dialog.ShowDialog($owner) -eq "OK") { [Console]::Out.Write($dialog.SelectedPath) }',
|
||||
// The dialog starts at the Desktop folder. Where that folder does not exist (it was deleted, a OneDrive move left a dead path, the profile was
|
||||
// redirected) ShowDialog throws "Unable to retrieve the root folder" and no folder could be chosen at all: start at This PC instead, and when even
|
||||
// that fails say NODIALOG so the app asks for the path.
|
||||
'$answer = $null',
|
||||
'try { $answer = $dialog.ShowDialog($owner) } catch { try { $dialog.RootFolder = [System.Environment+SpecialFolder]::MyComputer; $answer = $dialog.ShowDialog($owner) } catch { [Console]::Out.WriteLine("NODIALOG"); [Console]::Out.Flush() } }',
|
||||
'if ($answer -eq "OK") { [Console]::Out.Write($dialog.SelectedPath) }',
|
||||
'$owner.Close()',
|
||||
].join('; ');
|
||||
}
|
||||
@@ -88,11 +93,13 @@ export async function chooseFolder(prompt, options = {}) {
|
||||
try {
|
||||
const done = windowsDialog ? await windowsDialog(shell, windowsFolderArgs(prompt)) : await run(shell, windowsFolderArgs(prompt));
|
||||
if (done.noDialog) return { unavailable: true, failed: true, reason: 'The folder window did not open. Type the folder path instead.' };
|
||||
if (/^NODIALOG\s*$/m.test(String(done.stdout ?? ''))) return { unavailable: true, failed: true, reason: 'The folder window could not open. Type the folder path instead.' };
|
||||
const answer = answerOf(done.stdout).trim();
|
||||
return answer ? { path: cleanChosenPath(answer) } : { cancelled: true };
|
||||
} catch (error) {
|
||||
if (missing(error)) return { unavailable: true, reason: 'Windows PowerShell was not found.' };
|
||||
throw new Error('The folder chooser could not open.');
|
||||
// The window opened or tried to and ended with an error: the person is asked for the path instead of being left with an error and no way on.
|
||||
return { unavailable: true, failed: true, reason: 'The folder window could not open. Type the folder path instead.', detail: `exit ${error?.code ?? '?'}` };
|
||||
}
|
||||
}
|
||||
return chooseLinuxFolder(prompt, { run, portal });
|
||||
|
||||
@@ -74,6 +74,7 @@ import { installBundledEngine, cleanupAfterInstall } from './bundled-engine.mjs'
|
||||
import { systemLocale } from './region.mjs';
|
||||
import { createShellNotifier, DEFAULT_PORT, parsePortNotice, readShellPrefs, writeShellPrefs } from './shell-prefs.mjs';
|
||||
import { findControl, createControl, componentsDirectory } from './control-runtime.mjs';
|
||||
import { listEngines, changeEngine } from './engine-library.mjs';
|
||||
import { forbidden as acquisitionForbidden } from './acquisition.mjs';
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { nativeGateway } from './native-gateway.mjs';
|
||||
@@ -1010,6 +1011,7 @@ async function api(req, res, path, method, url) {
|
||||
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/engine-restart/dismiss'&&method==='POST')return json(res,200,native.dismissRestart());
|
||||
if(path==='/api/native/logs'&&method==='GET')return json(res,200,{logs:native.logs()});
|
||||
if(path==='/api/native/logs/save'&&method==='POST'){
|
||||
/* Diagnostics to a file: the engine messages the app kept, failed operations, and the tail of each model's server log under Control.
|
||||
@@ -1354,6 +1356,15 @@ async function api(req, res, path, method, url) {
|
||||
try { let at = componentsDirectory(); while (at && !existsSync(at) && dirname(at) !== at) at = dirname(at); const f = statfsSync(at); info.freeBytes = Number(f.bavail) * Number(f.bsize); } catch { /* unknown: no warning */ }
|
||||
return json(res, 200, info);
|
||||
}
|
||||
if (path === '/api/engines' && method === 'GET') {
|
||||
const { status, body } = await listEngines(control);
|
||||
return json(res, status, body);
|
||||
}
|
||||
if ((path === '/api/engines/use' || path === '/api/engines/remove') && method === 'POST') {
|
||||
const { status, body } = await changeEngine(control, path.endsWith('/use') ? 'use' : 'remove', await readBody(req), { modelsRunning: () => native.state().instances.length > 0 });
|
||||
if (status === 200) Promise.resolve(native.refresh?.(true)).catch(() => { /* the next poll reads it */ });
|
||||
return json(res, status, body);
|
||||
}
|
||||
if (path === '/api/engine/job' && method === 'GET') {
|
||||
// The newest engine download / file install, read without waiting for the native snapshot to connect (the first-run card needs it before that).
|
||||
const kinds = ['update-engine', 'install-engine-file'];
|
||||
@@ -1914,6 +1925,18 @@ async function api(req, res, path, method, url) {
|
||||
try { return json(res, 200, agent.queueMessage({ chatId: String(body.sessionId ?? ''), text: body.message })); }
|
||||
catch (e) { return error(res, e.code === 'queue-idle' ? 409 : 400, e.message); }
|
||||
}
|
||||
// The person's answer to a question the agent asked, or a skip (#4244).
|
||||
if (path === '/api/chat/answer' && method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
try { return json(res, 200, await agent.answerQuestion({ chatId: String(body.sessionId ?? ''), callId: String(body.callId ?? ''), answers: body.answers, skip: body.skip === true })); }
|
||||
catch (e) { return error(res, e.code === 'question-gone' ? 409 : 400, e.message); }
|
||||
}
|
||||
// A message for one helper (#4749).
|
||||
if (path === '/api/chat/helper' && method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
try { return json(res, 200, await agent.messageHelper({ chatId: String(body.sessionId ?? ''), helperId: body.helperId, text: body.message, delivery: body.delivery })); }
|
||||
catch (e) { return error(res, e.code === 'helper-idle' || e.code === 'helper-refused' ? 409 : 400, e.message); }
|
||||
}
|
||||
if (path === '/api/chat/queue/cancel' && method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
try { return json(res, 200, agent.cancelQueued({ chatId: String(body.sessionId ?? ''), id: String(body.id ?? '') })); }
|
||||
@@ -2211,7 +2234,7 @@ async function staticFile(req, res, path) {
|
||||
if (!existsSync(DIST)) {
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
res.end(
|
||||
`<!doctype html><meta charset="utf-8"><title>Strata host</title>
|
||||
`<!doctype html><meta charset="utf-8"><title>Strata</title>
|
||||
<body style="font:16px/1.6 -apple-system,system-ui;background:#0e1113;color:#eef1f3;padding:48px">
|
||||
<h1 style="margin:0 0 8px">Strata host is running</h1>
|
||||
<p style="color:#8e979f">No built web app found at <code>${DIST}</code>.</p>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createHash } from 'node:crypto';
|
||||
import { basename, dirname, extname, isAbsolute, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { readGGUF } from './gguf.mjs';
|
||||
const ARCHITECTURES=new Set(['qwen2','qwen2moe','qwen3','qwen3moe','qwen3next','qwen35','qwen35moe','llama','gemma','gemma2','gemma3','phi3']);
|
||||
const ARCHITECTURES=new Set(['qwen2','qwen2moe','qwen3','qwen3moe','qwen3next','qwen35','qwen35moe','qwen4exp','llama','gemma','gemma2','gemma3','phi3']);
|
||||
const QUANTS={0:'F32',1:'F16',2:'Q4_0',3:'Q4_1',7:'Q8_0',8:'Q5_0',9:'Q5_1',10:'Q2_K',11:'Q3_K_S',12:'Q3_K_M',13:'Q3_K_L',14:'Q4_K_S',15:'Q4_K_M',16:'Q5_K_S',17:'Q5_K_M',18:'Q6_K',19:'IQ2_XXS',20:'IQ2_XS',21:'Q2_K_S',22:'IQ3_XS',23:'IQ3_XXS',24:'IQ1_S',25:'IQ4_NL',26:'IQ3_S',27:'IQ3_M',28:'IQ2_S',29:'IQ2_M',30:'IQ4_XS',31:'IQ1_M',32:'BF16'};
|
||||
const clean=value=>String(value??'').replace(/[\x00-\x1f\x7f]/g,' ').trim().slice(0,160);
|
||||
function fullPath(input){if(typeof input!=='string'||!input.trim()||input.includes('\0'))throw new Error('Choose a model file or enter its full path.');const path=input.trim().replace(/^~(?=$|[\\/])/,homedir());if(!isAbsolute(path))throw new Error('Enter a full path.');try{return realpathSync(path);}catch{throw new Error('This file or folder is unavailable. Check its location and permissions.');}}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { detect, cachedHardware } from './hardware.mjs';
|
||||
import { readGpuTelemetry, gpuMemoryTotals } from './device-probes.mjs';
|
||||
import { createSystemSampler } from './system-telemetry.mjs';
|
||||
import { sensorPace, WATCH_MS } from './sensor-pace.mjs';
|
||||
import { readTemperatures } from './temperature-telemetry.mjs';
|
||||
import { readTemperatures, stopTemperatures } from './temperature-telemetry.mjs';
|
||||
import { addEnergy, energyWh } from './energy-meter.mjs';
|
||||
import { state as servingState } from './serving.mjs';
|
||||
import { originalDashboard } from './monitor-dashboard.mjs';
|
||||
@@ -127,11 +127,14 @@ function tick() {
|
||||
if(!sensorsPending&&Date.now()-sensorsAt>=pace.intervalMs){
|
||||
sensorsPending=true;sensorsAt=Date.now();
|
||||
const hardware=cachedHardware()??hwCache;
|
||||
void Promise.all([readGpuTelemetry(hardware?.gpus??[],{transfer:pace.transfer}),sampleSystem(hardware?.processors??[]),(process.env.STRATA_REAL_ENGINE==='1'?readTemperatures():Promise.resolve(temperatures)).catch(()=>({cpuTempC:null,ssdTempC:null}))])
|
||||
void Promise.all([readGpuTelemetry(hardware?.gpus??[],{transfer:pace.transfer}),sampleSystem(hardware?.processors??[]),(process.env.STRATA_REAL_ENGINE==='1'&&Date.now()<watchedUntil?readTemperatures():Promise.resolve(temperatures)).catch(()=>({cpuTempC:null,ssdTempC:null}))])
|
||||
.then(([readings,system,temps])=>{state.hardware.gpus=readings;systemReading=system;temperatures=temps;})
|
||||
.catch(()=>{state.hardware.gpus=[];systemReading=null;})
|
||||
.finally(()=>{sensorsPending=false;});
|
||||
}
|
||||
/* #4661: the drive-temperature reader asks every physical drive for its temperature. Only while the Monitor is looked at, never from the first second of the app
|
||||
(on some PCs the storage queries stall the whole system); it ends a couple of minutes after the Monitor was last seen. */
|
||||
if(Date.now()>watchedUntil+120000)stopTemperatures();
|
||||
addEnergy(hardwareSnapshot().powerW,Date.now());
|
||||
const hw = state.hardware;
|
||||
const busy = state.phase === 'generating' || state.phase === 'reading' || state.phase === 'thinking';
|
||||
|
||||
@@ -7,12 +7,12 @@ import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, realpa
|
||||
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, speedSwitchRows } from './native-settings.mjs';
|
||||
import { NATIVE_FIELDS, APP_OWNED_FIELDS, withoutDraftLayer, resolveDrafter, validateNativeSettings, validateMcpServers, publicMcpServers, publicNativeEnv, restoreNativeEnv, speedSwitchRows } from './native-settings.mjs';
|
||||
import { resourceSettings, saferStart, reserveChoice } from './native-resources.mjs';
|
||||
import { USER_KEY, capture as captureUserArgs, merge as mergeUserArgs, setFlag as setUserFlag, stamped as stampedUserArgs } from './native-user-args.mjs';
|
||||
import { resolvePython, venvPython } from './python-resolver.mjs';
|
||||
import os from 'node:os';
|
||||
import { runtimeVision } from './native-vision.mjs';
|
||||
import { markPictures, runtimeVision } from './native-vision.mjs';
|
||||
import { dsEngineArgs, dsUserArgs, dsArgsNote, withoutVisionFlag } from './native-ds-args.mjs';
|
||||
import { componentsDirectory, installerEnvironment, controlEnvironment, sourceServerEnvironment } from './control-runtime.mjs';
|
||||
import { createEngineStep } from './engine-step.mjs';
|
||||
@@ -32,7 +32,7 @@ const busy=requests=>requests.some(r=>['queued','generating'].includes(r.state))
|
||||
function readJson(path,fallback={}){try{return JSON.parse(readFileSync(path,'utf8').replace(/^\uFEFF/,''));}catch{return fallback;}}
|
||||
|
||||
/** The plain words for "no usable engine": the model still downloads, but loading needs the engine (owner decision 2026-10-18). */
|
||||
export const ENGINE_NOT_RECOGNISED='Strata didn’t recognise an engine for this computer. The model is downloaded, but it can’t run until the right engine is installed. Open Settings → Engine.';
|
||||
export const ENGINE_NOT_RECOGNISED='Strata didn’t recognise an engine for this computer. The model is downloaded, but it can’t run until the right engine is installed. Open Settings → About.';
|
||||
export function createNativeControl({directory,requests=()=>[],changed=()=>{},spawnProcess=spawn,fetcher=fetch,modelsDirectory,nativeDataDirectory=value=>value,beforeLoad=async()=>{},hardwareProbe=()=>[],control=null,ready=null,appUpdater=null,defaultRepo=process.env.STRATA_NATIVE_REPO??resolve(ROOT,'../../Strata')}={}){
|
||||
mkdirSync(directory,{recursive:true});
|
||||
const settingsPath=join(directory,'native-host.json');
|
||||
@@ -63,17 +63,19 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
|
||||
const updateLog=line=>{try{console.log(`update · ${line}`);}catch{/* no console */}};
|
||||
const NO_INSTALL='Installing, updating, tuning with calibration or rolling back model files and engines happens from the Terminal (strata install), never while the app runs.';
|
||||
let defaultId=null,timer=null,closing=false,polling=false;
|
||||
/* Models the engine update stopped (with the person's yes): kept so the app can offer to start them again with the same settings. */
|
||||
let stoppedForUpdate=null;
|
||||
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 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 dflash2Served=null,dflash2Support=null,dflash2Selq4=false,dflash2Split=false;
|
||||
function probeDrafting(){if(!packaged||dflash2Served!==null||typeof control.draftingPlan!=='function')return;dflash2Served=false;control.draftingPlan({mode:'auto'}).then(p=>{dflash2Served=p.serves===true;dflash2Support=p.serves===true&&p.support&&p.support_line?{level:p.support,line:p.support_line}:null;dflash2Selq4=p.serves===true&&p.selq4===true;dflash2Split=p.split===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,stoppedForUpdate:stoppedForUpdate?{updated:stoppedForUpdate.updated,at:stoppedForUpdate.at,models:stoppedForUpdate.instances.map(i=>({id:i.id,label:i.label}))}:null,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,dflash2Support,dflash2Selq4,dflash2Split}};}
|
||||
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)}));}
|
||||
const modelArena=id=>({arenaGb:(snapshot.catalog?.families??[]).flatMap(f=>f.sizes??[]).find(s=>s.id===id)?.experts_gb});
|
||||
const modelArena=id=>({arenaGb:(snapshot.catalog?.families??[]).flatMap(f=>f.sizes??[]).find(s=>s.id===id)?.experts_gb,engineVersion:snapshot.engine?.version??snapshot.installed?.engine?.version??null});
|
||||
/* A start that ends with `server_exited` carries the engine's last log line with it; the app turns the code into words and
|
||||
keeps this text under "Details" (src/lib/plain-errors.ts). */
|
||||
function exitCodeText(code){const n=Number(code);if(!Number.isFinite(n)||n===0)return '';const u=n<0?n+2**32:n;return u>=0x80000000?`exit code 0x${u.toString(16).toUpperCase().padStart(8,'0')}`:`exit code ${u}`;}
|
||||
@@ -88,7 +90,11 @@ 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,contextMax:configContextLimit(cfg)??undefined,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'};})};}
|
||||
/* DeepSeek reads pictures with the engine's own tower, which is part of the pack (pack_gguf writes the tower's geometry into ds4.txt when the picture file was there): a pack with it is "prepared", a text-only pack (made with --vision off, as the app did before 0.2.9) is not, and the Models screen then offers the one missing shared file. null for every other model. */
|
||||
const dsPictures=cfg=>{if(cfg?.arch!=='deepseek41')return null;const a=Array.isArray(cfg.args)?cfg.args:[];const at=a.indexOf('--pack');if(at<0||typeof a[at+1]!=='string')return false;try{const t=readFileSync(join(a[at+1],'ds4.txt'),'utf8');return /image_token_id/.test(t)&&/vision[.]num_hidden_layers/.test(t);}catch{return false;}};
|
||||
/* DeepSeek's picture switch is a pair, --vision on|off, in the saved arguments: set it to the person's choice at every load (on only where the pack carries the tower). */
|
||||
const dsVisionPair=(a,value)=>{if(!value)return a;const at=a.indexOf('--vision');return at<0?[...a,'--vision',value]:a.map((x,i)=>i===at+1?value:x);};
|
||||
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;/* The folder this model's own run config keeps its prepared data in: the one that holds its packs/<tag> (or mtp/rt). It is the real data folder of the model, wherever the app's models folder is; a model in the person's own GGUF folder has its pack and draft layer here, not in the app's folder. A layout that only looks like <data>/models/<tag> says nothing: no pack, no folder. */const flagValue=flag=>{const i=cfg.args?.indexOf(flag)??-1;return i>=0&&typeof cfg.args[i+1]==='string'?cfg.args[i+1]:undefined;};const holder=(path,child,name)=>{if(!path||basename(dirname(path)).toLowerCase()!==child||(name&&basename(path).toLowerCase()!==name))return undefined;return dirname(dirname(path));};const runDataFolder=holder(flagValue('--pack'),'packs')??holder(flagValue('--mtp'),'mtp','rt');/* 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,runDataFolder,contextMax:configContextLimit(cfg)??undefined,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,contextChosen:cfg.ui_context_chosen===true,visionPrepared:dsPictures(cfg)??(!!cfg.vision&&entry.picturesMissing!==true),/* 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:(dsPictures(cfg)??(!!cfg.vision&&entry.picturesMissing!==true))?'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');}
|
||||
@@ -122,10 +128,11 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
|
||||
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,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 onlyPictures=m=>m.component_status==='missing_or_invalid'&&Array.isArray(m.missing)&&m.missing.length>0&&m.missing.every(p=>p?.part==='vision/mmproj')&&Array.isArray(m.issue_codes)&&m.issue_codes.length===m.missing.length&&m.issue_codes.every(c=>/^COMPONENT_(MISSING|INVALID)$/.test(c));
|
||||
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'||onlyPictures(m))&&!(disk[m.id]?.partsMissing),/* Only the picture file is gone (run config has the picture section, the file does not exist): the model is not "missing files". It loads, and the Load fetches the picture file again. */picturesMissing:m.component_status!=='present'&&onlyPictures(m),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},
|
||||
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,updateAvailable:engine.update_available??null},
|
||||
installed:{strata_folder:list.install_dir,data_folder:null,engine:engine.engine?{version:engine.engine.version,backend:engine.engine.backend,folder:engine.engine.root}:{folder:null,version:null},models,usage:installer.installed?.usage??null},
|
||||
hardware:installer.hardware?{...installer.hardware,cpu:{...(installer.hardware.cpu??{}),cores:installer.hardware.cpu?.cores??cores},gpus:installer.hardware.gpus??[]}:{cpu:{cores},ram_gb:Math.round(os.totalmem()/1024**3),gpus:[]},
|
||||
recommendation:installer.recommendation??null,installerError:installer.error??null,install:installer.install??null,running:null};
|
||||
@@ -141,7 +148,7 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
|
||||
is refreshed in the background. Only the first read, and a forced one after an operation, waits. */
|
||||
if(refreshing)return !force&&snapshot.available?state():force?refreshing.then(()=>refresh(true)):refreshing; /* a forced read that meets one already running (started before an engine update finished) waits for it and reads again, so it never settles on the old answer */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;
|
||||
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=snapshot.visionReader===true;}}const markImages=c=>markPictures(c,visionReader);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,{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();}
|
||||
@@ -157,11 +164,11 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
|
||||
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);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 */}}
|
||||
const choice=args.drafting??(dflash2Served===true?'auto':undefined);if(packaged&&typeof choice==='string'&&typeof control.draftingPlan==='function'){try{const size=snapshot.catalog?.families?.find(f=>f.family===args.family)?.sizes.find(s=>s.model===args.model),here=snapshot.installed?.models?.find(m=>m.model===size?.id);const d=await control.draftingPlan({mode:choice,family:args.family??null,dataDir:here?.runDataFolder??snapshot.installed?.data_folder??null});if(d.needed||d.mtp_needed)return {...result,drafting:{needed:true,drafter:d.needed?'dflash2':'mtp',gb:d.needed?d.gb:d.mtp_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 installArgs(args,confirm){const allowed=['family','model','context','vision','kv','gpu','gpus','backend','low_ram','port','gguf_dir','drafter'];/* One drafter per run: when the choice means DFlash2 the installer does not fetch the MTP layer's ~5 GB (the drafter is fetched by the load); choosing the MTP later fetches the layer once. */if(args.drafter===undefined&&args.drafting!==undefined||args.drafter===undefined&&dflash2Served===true){const d=resolveDrafter(args.drafting,args.family,dflash2Served,dflash2Split);if(d==='dflash2')args={...args,drafter:'dflash2'};}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};/* A model that is installed keeps working in the data folder its own run config names (where its pack and draft layer are), whether its files are under it or in the person's own folder: that is never changed or written to the settings from here. Only a folder taken from the app's models folder is the person's choice, and the one the installer is told about. */const own=args.data_dir===undefined?existing?.runDataFolder:undefined;const data=own??nativeDataDirectory(ownFilesDataFolder(args.data_dir??(args.gguf_dir?undefined:existing?.dataFolder)??preferences.downloadDirectory??modelsDirectory?.(),args));if(data&&packaged&&!args.data_dir&&!own)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&&args.family!=='deepseek41'?{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.'};}}}
|
||||
@@ -195,7 +202,9 @@ 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,...(typeof args.drafting==='string'?{values:{drafting:args.drafting}}:{})});}
|
||||
/* A context the person picked is kept with a marker (ui_context_chosen), so only an old saved 32K, which has no marker, is read as the old default. */
|
||||
if(args.contextChosen===true){try{const id=(result.install.family??args.family??'qwen')==='qwen'?String(args.model).toLowerCase():`${args.family}-${String(args.model).toLowerCase()}`;rememberSetting(record(id).path,'ui_context_chosen',true);}catch{/* the install itself is done */}}
|
||||
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,...(args.visionUnavailable===true?{visionUnavailable:true}:{}),...(typeof args.drafting==='string'?{values:{drafting:args.drafting}}:{})});}
|
||||
return finalResult;
|
||||
}
|
||||
}
|
||||
@@ -267,18 +276,20 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
const sized=gb>40?Math.max(300,600+(gb-40)*8):300;
|
||||
return Math.trunc(Math.max(300,Math.min(ceiling,Math.max(ready,sized))));
|
||||
}
|
||||
function buildConfig(args,config){
|
||||
const vision=runtimeVision(config,args.vision);
|
||||
const visionPreferences=args.vision!==undefined?{ui_vision_enabled:args.vision!=='no',ui_vision_device:args.vision==='no'?config.ui_vision_device??'yes':args.vision}:{};
|
||||
function buildConfig(args,config,drafter=null){
|
||||
/* `visionUnavailable`: this engine has no picture reader, so the run goes without pictures but nothing is saved: the model's own setting stays what the person had (it used to save "off", and the model stayed off after the engine got the reader). */
|
||||
const noReader=args.visionUnavailable===true;
|
||||
const vision=ownEngine(config)?undefined:runtimeVision(config,noReader?'no':args.vision); /* DeepSeek has its own picture tower: its picture setting is the --vision flag setup wrote */
|
||||
const visionPreferences=args.vision!==undefined&&!noReader?{ui_vision_enabled:args.vision!=='no',ui_vision_device:args.vision==='no'?config.ui_vision_device??'yes':args.vision}:{};
|
||||
/* The engine options the person set by hand (user_args, and what they wrote into "args" since the last write) go over everything the app generates, and an
|
||||
explicit GPU-memory reserve (the Expert setting, user_args, a value they wrote) is never replaced by the safety default (#3215 #3270 #3216). */
|
||||
const values0=args.values??{},cleared='vram_reserve_mib' in values0&&(values0.vram_reserve_mib===null||values0.vram_reserve_mib===''),base0=cleared?{...config,vram_reserve_mib:undefined,[USER_KEY]:setUserFlag(config[USER_KEY],'--vram-reserve-mib',null)}:config;
|
||||
let mine=captureUserArgs(base0);const asked=cleared?null:reserveChoice({...base0,[USER_KEY]:mine},snapshot.hardware,values0).asked;
|
||||
if(asked!==null)mine=setUserFlag(mine,'--vram-reserve-mib',asked);
|
||||
const resources=args.resourceProfile&&!ownEngine(config)?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};
|
||||
const cfg={...config,...visionPreferences,...(args.contextChosen===true?{ui_context_chosen:true}:{}),...(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!=='')||!!config.gpu_roles;
|
||||
const namedInFile=((Array.isArray(config.gpu)?config.gpu.length>0:config.gpu!==undefined&&config.gpu!==null&&config.gpu!=='')||!!config.gpu_roles)&&!setupPickedOneCard(config,snapshot.hardware);
|
||||
/* 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.');}
|
||||
@@ -289,9 +300,10 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
if((cfg.mtp===false||cfg.drafting==='off')&&Array.isArray(cfg.args))cfg.args=withoutDraftLayer(cfg.args);
|
||||
/* The Qwen engine's --vision stands alone, so it goes (with a picture word after it, if an older file has one). The DeepSeek engine's is a pair, `--vision on|off`, that setup wrote and the engine reads: it stays whole (taking out only the name left `off` behind: `--eos 1 off`, "unknown option off", #4358 #4359 #4505 #4507). */
|
||||
if(!cfg.vision&&!ownEngine(config)&&Array.isArray(cfg.args))cfg.args=withoutVisionFlag(cfg.args);
|
||||
if(ownEngine(config)&&Array.isArray(cfg.args)&&args.vision!==undefined)cfg.args=dsVisionPair(cfg.args,args.vision==='no'?'off':dsPictures(cfg)?'on':null);
|
||||
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. */
|
||||
const parallelChoice=packaged&&!ownEngine(config)?chooseParallel({config:cfg,values:args.values??{},hardware:snapshot.hardware?.gpus?.length?snapshot.hardware:{gpus:hardwareProbe()??[]}}):null;
|
||||
const parallelChoice=packaged&&!ownEngine(config)?chooseParallel({config:cfg,values:args.values??{},drafter,hardware:snapshot.hardware?.gpus?.length?snapshot.hardware:{gpus:hardwareProbe()??[]}}):null;
|
||||
let parallelNote=null,dsNote='';
|
||||
/* The agent asks for long answers (a thinking model can use most of the window): let the engine clamp the output to what is left of the context instead of refusing the request. */
|
||||
if(packaged&&!ownEngine(config)&&cfg.fit_max_tokens===undefined)cfg.fit_max_tokens=true;
|
||||
@@ -304,32 +316,115 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
else delete cfg.ds_experts;
|
||||
return {cfg,resources,visionPreferences,parallelChoice,parallelNote,dsNote};
|
||||
}
|
||||
/* One drafter per run. Control (strata_control/drafting.py) says which one this load runs and whether its file is on this computer: the plan of the saved model file with the choice the Load carries. */
|
||||
async function draftPlan(args,configFile){
|
||||
if(!packaged||typeof control.draftingPlan!=='function')return null;
|
||||
const asked=args.values?.drafting;
|
||||
try{return await control.draftingPlan({configPath:configFile,...(asked===undefined?{}:{mode:asked===null?'auto':asked})});}catch{return null;}
|
||||
}
|
||||
/* The choice changed to the MTP and its layer is not here yet: it is fetched once, by the installer (finished steps are skipped), in this Load. Only the Models screen's own Load carries the choice; a restart or a tuning run never fetches. */
|
||||
async function installMtpLayer(job,args,config){
|
||||
const fam=snapshot.catalog?.families?.find(f=>f.sizes.some(s=>s.id===args.model)),size=fam?.sizes.find(s=>s.id===args.model);
|
||||
if(!fam||!size)return;
|
||||
job.summary='Downloading the MTP drafter…';emit();
|
||||
/* the model's own files folder (the run config's --native): without it the installer, asked for this model by name, would fetch the whole model again next to the layer (an app install adopted from the person's own GGUF folder has its files elsewhere) */const native=(()=>{const a=(config.args??[]).map(String),i=a.indexOf('--native');return i>=0?(a[i+1]??''):'';})();try{await install(job,{family:fam.family,model:size.model,drafter:'mtp',vision:args.vision??(config.vision?'yes':'no'),...(native?{gguf_dir:dirname(native)}:{})});}
|
||||
catch{if(job.state==='running'){job.drafterNote='The MTP drafter could not be downloaded.';emit();}}/* one short line, not the installer's own text; Drafting below says what runs instead */
|
||||
}
|
||||
/* 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){
|
||||
Never stops the load: a drafter that cannot be had leaves the MTP running if its layer is here, else no drafter, and the job says so. The choice itself is remembered in the model's settings file. */
|
||||
async function prepareDrafter(job,args,cfg,configFile,drafts){
|
||||
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;
|
||||
/* Only the Models screen's own Load passes the choice (its dialog has listed the download): a restart, a tuning run or any other load leaves `asked` undefined and never fetches; the model then starts with the drafter that is on this computer and says why. */
|
||||
if(!packaged||typeof control.draftingFetch!=='function'||asked===undefined||drafts?.needed!==true)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();}}});
|
||||
await control.draftingFetch({configPath:probe,mode:cfg.drafting??'auto',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();}}
|
||||
}catch(error){if(job.state==='running'){job.drafterNote=error.message;job.summary=drafts.mtp_here===false?'The DFlash2 drafter could not be downloaded, so the model starts without a drafter. Loading the model…':'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');}
|
||||
/* ---- The picture files of an installed model that has none (strata_control/pictures.py, run by Control) ----
|
||||
Only the Load or the "Download picture-reading files" button the person pressed reaches the network, and only for the picture file (about 0.9 GB, one copy
|
||||
shared by the models of a data folder). The model's own files, its pack, its draft layer, the data folder and the settings' data_dir are not touched; the one change
|
||||
to the model's run config is the picture section. A restart, a tuning run or any other load that does not ask for pictures never downloads. */
|
||||
const PICTURES_WORDS={download:'Downloading the picture files (0.9 GB, once)…',check:'Checking the picture files…',loading:'Loading the model…',
|
||||
offline:'No internet connection: the model is loaded without picture reading. Load it again when you are online.',
|
||||
disk:'There is not enough free disk space for the picture files, so the model is loaded without picture reading.',
|
||||
failed:'The picture files could not be downloaded, so the model is loaded without picture reading. Try again later.',
|
||||
gone:'The picture files are missing, so the model is loaded without picture reading.'};
|
||||
const PLAIN_PICTURE_CODES=['offline','disk','failed','checksum','unsupported','no_reader'];
|
||||
const askedPictures=value=>['yes','gpu','cpu'].includes(value);
|
||||
const pictureFileGone=config=>!config.vision?.mmproj||!existsSync(resolve(repo(),String(config.vision.mmproj)));
|
||||
/* The card the picture reader will be chosen for: the cards the model runs on, the one with the most memory (setup's rule is the card's memory: the CPU reader under 24 GB). */
|
||||
function pictureCard(config){const make=snapshot.hardware?.engine_vendor,named=Array.isArray(config.gpu)?config.gpu:config.gpu===undefined||config.gpu===null?null:[config.gpu];const cards=(snapshot.hardware?.gpus??[]).filter(g=>g.usable!==false&&(!make||!g.vendor||g.vendor===make)&&(!named||named.includes(g.index)));const best=[...cards].sort((a,b)=>(Number(b.vram_gb)||0)-(Number(a.vram_gb)||0))[0];return best?{vram_gb:Number(best.vram_gb)||0,make:best.vendor??make??null,arch:best.arch??null}:null;}
|
||||
async function ensurePictures(job,args,configFile,config,{strict=false,missing=false}={}){
|
||||
const keep={config,args,note:null};
|
||||
if(!packaged||ownEngine(config)||args.visionUnavailable===true)return keep;
|
||||
const prepared=!!config.vision,asked=askedPictures(args.vision);
|
||||
/* Control says the picture file is gone (missing) or, when pictures were asked for, this look at the disk says so; a load that asked for nothing never second-guesses Control. */
|
||||
if(prepared&&!missing&&!(asked&&pictureFileGone(config)))return keep;
|
||||
if(!asked){const none={...args,vision:'no',visionUnavailable:true};return prepared?{config,args:none,note:PICTURES_WORDS.gone}:keep;}
|
||||
if(typeof control.picturesFetch!=='function')return keep;
|
||||
job.abort=new AbortController();job.summary=PICTURES_WORDS.download;job.details=undefined;emit();
|
||||
try{
|
||||
const engine=await control.engine();const engineDir=engine?.engine?.root;
|
||||
if(!engineDir)throw Object.assign(new Error('This engine has no picture reader.'),{code:'no_reader'});
|
||||
const family=snapshot.catalog?.families?.find(f=>f.sizes?.some(s=>s.id===args.model))?.family??null;
|
||||
const here=snapshot.installed?.models?.find(m=>m.model===args.model);
|
||||
let fallbackData=null;try{fallbackData=here?.runDataFolder??here?.dataFolder??nativeDataDirectory(preferences.downloadDirectory??modelsDirectory?.())??null;}catch{fallbackData=here?.runDataFolder??here?.dataFolder??null;}
|
||||
const got=await control.picturesFetch({configPath:configFile,engineDir,vision:args.vision,dataDir:fallbackData,family,hardware:pictureCard(config),signal:job.abort.signal,
|
||||
onEvent:event=>{if(event?.event!=='progress'||job.state!=='running')return;const total=Number(event.total)||0,done=Number(event.done)||0;
|
||||
if(/^Checking/i.test(String(event.step))){job.summary=PICTURES_WORDS.check;}
|
||||
else{job.summary=PICTURES_WORDS.download;if(total>0)job.result={install:{download:{file:'picture reader',done_gb:done/1e9,total_gb:total/1e9,remaining_gb:Math.max(0,total-done)/1e9,percent:Math.min(99,Math.floor(100*done/total))}}};}
|
||||
emit();}});
|
||||
if(job.state!=='running')return keep;
|
||||
const next={...config,vision:got.vision};
|
||||
writeModelConfig(configFile,next);
|
||||
if(got.note)append(args.model,`Picture files: ${got.note}`);
|
||||
job.result=null;job.summary=PICTURES_WORDS.loading;job.details=undefined;emit();
|
||||
return {config:next,args,note:null};
|
||||
}catch(error){
|
||||
job.result=null;
|
||||
if(job.state!=='running')return keep;
|
||||
const code=error?.code,details=error?.output?.error?.details;
|
||||
append(args.model,`Picture files: ${error?.message??error}${details?` (${details})`:''}`);
|
||||
if(strict)throw Object.assign(new Error(PLAIN_PICTURE_CODES.includes(code)?String(error.message):'The picture files could not be downloaded. Try again.'),{details:details?[details]:undefined});
|
||||
job.summary=PICTURES_WORDS.loading;job.details=undefined;emit();
|
||||
return {config,args:{...args,vision:'no',visionUnavailable:true},note:code==='offline'?PICTURES_WORDS.offline:code==='disk'?PICTURES_WORDS.disk:PICTURES_WORDS.failed};
|
||||
}
|
||||
}
|
||||
/* "Download picture-reading files": the same step without a load. */
|
||||
async function picturesOnly(job,args){
|
||||
await refresh(true);if(job.state!=='running')return;
|
||||
const {path:configFile}=recordFile(args.model);const config=checkedSettingsConfig(readFileSync(configFile,'utf8'));
|
||||
if(!packaged)throw new Error('The picture files are downloaded by the installed app.');
|
||||
if(ownEngine(config))throw new Error('This model reads pictures with its own files.');
|
||||
const {entry}=recordFile(args.model);
|
||||
if(config.vision&&!entry.picturesMissing&&!pictureFileGone(config))return {summary:'The picture files are ready.'};
|
||||
await ensurePictures(job,{...args,vision:askedPictures(args.vision)?args.vision:'yes'},configFile,config,{strict:true,missing:entry.picturesMissing===true});
|
||||
if(job.state!=='running')return;
|
||||
await refresh(true);
|
||||
return {summary:'The picture files are ready.'};
|
||||
}
|
||||
async function loadEngine(job,args){
|
||||
await refresh(true);if(job.state!=='running')return;const {entry,path:configFile}=recordFile(args.model);const config=checkedSettingsConfig(readFileSync(configFile,'utf8'));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;}
|
||||
await refresh(true);if(job.state!=='running')return;const {entry,path:configFile}=recordFile(args.model);let config=checkedSettingsConfig(readFileSync(configFile,'utf8'));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,dsNote}=buildConfig(args,config);
|
||||
/* Read pictures is on and the picture files are not here: the Load fetches those files (about 0.9 GB, once) and nothing else of the model, then goes on. No internet: the model loads without pictures and the page says so. */
|
||||
let picturesNote=null;{const got=await ensurePictures(job,args,configFile,config,{missing:entry.picturesMissing===true});if(job.state!=='running')return;config=got.config;args=got.args;picturesNote=got.note;}
|
||||
/* One drafter per run (strata_control/drafting.py): switching to the MTP on a model set up for DFlash2 fetches the layer once, here; DFlash2 is fetched below. */
|
||||
let drafts=await draftPlan(args,configFile);
|
||||
if(drafts?.mtp_needed===true&&(args.values?.drafting!==undefined||drafts.split===true&&(config.drafting==null||String(config.drafting).toLowerCase()==='auto'))){/* the Models screen's own Load carries the choice; a plain Auto load fetches the layer too when Control says the all-cards split engine will take the start (it runs the MTP and nothing else): the same as the Terminal */await installMtpLayer(job,args,config);if(job.state!=='running')return;await refresh(true);config=checkedSettingsConfig(readFileSync(recordFile(args.model).path,'utf8'));drafts=await draftPlan(args,configFile);}
|
||||
const {cfg,resources,visionPreferences,parallelChoice,parallelNote:chosenNote,dsNote}=buildConfig(args,config,drafts?.choice==='dflash2'&&drafts.needed===true&&args.values?.drafting===undefined?'mtp':drafts?.choice??null);/* (a drafter that is not here and will not be fetched by this load: the MTP runs, and the request slots stay) */
|
||||
let parallelNote=chosenNote;
|
||||
await prepareDrafter(job,args,cfg,configFile);if(job.state!=='running')return;
|
||||
await prepareDrafter(job,args,cfg,configFile,drafts);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};
|
||||
@@ -344,7 +439,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
if(!args.multiple){if(busy(requests().filter(r=>instances.has(r.instanceId))))throw new Error('Finish or cancel native requests before switching models.');for(const id of [...instances.keys()])await unload(id);}else if(matching)await unload(matching.id);
|
||||
if(job.state!=='running')return;
|
||||
writeFileSync(configPath,JSON.stringify(cfg,null,2),{mode:0o600});
|
||||
const instance={id,apiModelId,fingerprint,context:entry.context??0,aliases:cfg.aliases??[],vision:!!cfg.vision,resourceProfile:cfg.ui_resource_profile??null,model:args.model,label:friendlyModelName(snapshot.catalog,args.model)??entry.model_name??args.model,port:cfg.port,gpus:Array.isArray(cfg.gpu)?cfg.gpu:cfg.gpu===undefined?[]:[cfg.gpu],status:'loading',phase:'loading',parallel:cfg.parallel??1,mcpServers:cfg.mcp_servers??cfg.mcpServers??{},key,configPath,loadedAt:new Date().toISOString(),metrics:null,error:null};
|
||||
const instance={id,apiModelId,fingerprint,context:entry.context??0,aliases:cfg.aliases??[],vision:!!cfg.vision,resourceProfile:cfg.ui_resource_profile??null,model:args.model,label:friendlyModelName(snapshot.catalog,args.model)??entry.model_name??args.model,port:cfg.port,gpus:Array.isArray(cfg.gpu)?cfg.gpu:cfg.gpu===undefined?[]:[cfg.gpu],status:'loading',phase:'loading',parallel:cfg.parallel??1,mcpServers:cfg.mcp_servers??cfg.mcpServers??{},key,configPath,loadedAt:new Date().toISOString(),metrics:null,error:null,...(picturesNote?{picturesNote}:{})};
|
||||
/* Another model on the same graphics card: the engine takes the card's free memory, so a second one usually finds none. Loading goes on (recommend, never force); if it fails, the reason is said. */
|
||||
const sharedWith=instance.gpus.length?[...instances.values()].find(i=>i.id!==id&&i.status!=='error'&&i.model!==args.model&&i.gpus.some(g=>instance.gpus.includes(g))):null;
|
||||
const sharedSentence=sharedWith?`GPU ${instance.gpus.find(g=>sharedWith.gpus.includes(g))+1} is already used by ${sharedWith.label}, so this model did not fit there. Load it on another GPU, or unload ${sharedWith.label} first.`:null;
|
||||
@@ -391,7 +486,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';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{}
|
||||
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||args.contextChosen===true||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,...(args.contextChosen===true?{ui_context_chosen:true}:{}),...(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:''}${picturesNote?' '+picturesNote:''}`,instanceId:id};}}catch{}
|
||||
await new Promise(resolve=>setTimeout(resolve,1200));
|
||||
}
|
||||
if(job.state!=='running'){await unload(id);return;}
|
||||
@@ -418,10 +513,10 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
shell's signed Tauri updater. Models are not part of any of it. */
|
||||
async function componentProgram(job,args={}){
|
||||
if(!COMPONENT_KINDS.includes(job.kind)||typeof control.update!=='function')throw new Error(NO_INSTALL);
|
||||
if(instances.size)throw new Error('Unload native models before changing engine files.');
|
||||
if(instances.size&&!(job.kind==='update-engine'&&args.stopModel===true))throw new Error('Unload native models before changing engine files.');
|
||||
job.abort=new AbortController();
|
||||
const subject=job.kind==='update-strata'?'Strata':'engine';
|
||||
try{return await componentSteps(job,subject,args);}catch(error){updateLog(`${job.kind} failed: ${String(error?.message??error).slice(0,400)}`);throw error?.details!==undefined&&error.message?error:plainError(error,subject);}
|
||||
try{return await componentSteps(job,subject,args);}catch(error){updateLog(`${job.kind} failed: ${String(error?.message??error).slice(0,400)}`);throw error?.details!==undefined&&error.message?error:plainError(error,subject,args?.firstRun===true?'Continue':undefined);}
|
||||
}
|
||||
async function componentSteps(job,subject,args={}){
|
||||
const onEvent=event=>{
|
||||
@@ -433,7 +528,13 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
updateLog(`${job.kind} started (app ${thisAppVersion()??'unknown'})`);
|
||||
if(job.kind==='install-engine-file'){if(typeof args.path!=='string'||!args.path)throw new Error('Choose the engine file first.');let done;try{done=await control.installEngineFile(args.path,{onEvent,signal:job.abort.signal});}finally{try{const own=resolve(directory,'incoming-engine')+sep;if(resolve(args.path).startsWith(own))rmSync(args.path,{force:true});}catch{/* the copy in the host's own folder; a leftover is harmless */}}return {summary:updateSummary(done)==='Everything is already up to date.'?(done.results?.[0]?.note??'Nothing was changed.'):updateSummary(done),result:done};}
|
||||
if(job.kind==='rollback'){const done=await control.rollbackEngine();return {summary:'The previous engine is back. Your models and settings are unchanged.',result:done};}
|
||||
if(job.kind==='update-engine'){const done=await control.update({engine:true,control:false,onEvent,signal:job.abort.signal});return {summary:updateSummary(done),result:done};}
|
||||
if(job.kind==='update-engine'){
|
||||
/* A model is loaded and the person said yes to stopping it (the dialog names it): Control downloads while it serves, stops it cleanly, updates, and leaves it stopped.
|
||||
Whatever the result, the models it stopped are kept so the app offers to start them again, on the new engine or, after a failed update, on the old one. */
|
||||
const gate=args.stopModel===true&&instances.size?updateGate(job):null;
|
||||
try{const done=await control.update({engine:true,control:false,stopModel:!!gate,onEvent:gate?e=>{onEvent(e);gate.event(e);}:onEvent,signal:job.abort.signal});gate?.finish(true);return {summary:updateSummary(done),result:done};}
|
||||
catch(error){gate?.finish(false);if(gate?.refusal)throw Object.assign(new Error(gate.refusal),{details:`Running outside this app: ${gate.outside.join(', ')}`});throw error;}
|
||||
}
|
||||
const done=await control.update({engine:false,control:true,onEvent,signal:job.abort.signal});
|
||||
updateLog(`Control step finished: ${updateSummary(done)}`);
|
||||
const notes=[updateSummary(done)];
|
||||
@@ -446,7 +547,47 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
}
|
||||
return {summary:notes.filter(Boolean).join(' '),result:done};
|
||||
}
|
||||
function updateSummary(done){const r=(done.results??[]).filter(x=>x.status==='updated').map(x=>x.component==='engine'?'The engine is updated and ready.':'Strata is updated. It takes effect the next time you open the app.');return r.length?r.join(' '):'Everything is already up to date.';}
|
||||
/* The engine update with a model loaded (strata update --stop-model --output jsonl). The `models` events say what Control is doing to the models; this turns them into the app's own
|
||||
state: the model leaves the list when it is stopped, and a model the app did not start is never stopped (the update is ended before anything is downloaded). */
|
||||
function updateGate(job){
|
||||
const mine=new Map([...instances.values()].filter(i=>i.profile).map(i=>[i.profile,i])),stopped=[],gate={refusal:null,event(e){
|
||||
if(e.event!=='models')return;
|
||||
const models=Array.isArray(e.models)?e.models:[],label=m=>mine.get(m.profile)?.label??m.name??'The model';
|
||||
if(e.phase==='consent'){
|
||||
const other=models.filter(m=>!mine.has(m.profile));
|
||||
if(other.length){gate.refusal='A model that was not started from this app is running, so nothing was changed. Stop it, then update the engine again.';gate.outside=other.map(label);job.abort.abort();return;}
|
||||
job.step='getting-ready';job.models=models.map(label);job.summary=`Getting the new engine ready. ${job.models.join(' and ')} keeps running until it is ready.`;
|
||||
}
|
||||
else if(e.phase==='stopping'){for(const m of models){const i=mine.get(m.profile);if(i)i.status='unloading';}job.step='stopping';job.models=models.map(label);job.summary=`Stopping ${job.models.join(' and ')}. Answers in progress finish first.`;}
|
||||
else if(e.phase==='stopped'){for(const m of models){const i=mine.get(m.profile);if(i&&instances.get(i.id)===i){instances.delete(i.id);stopped.push(i);if(defaultId===i.id)defaultId=instances.keys().next().value??null;}}job.step='stopped';job.models=models.map(label);job.summary=`${job.models.join(' and ')} stopped. Updating the engine…`;}
|
||||
emit();
|
||||
},finish(updated){stoppedForUpdate=stopped.length?{updated,at:new Date().toISOString(),instances:stopped}:null;emit();}};
|
||||
return gate;
|
||||
}
|
||||
/* "Start again with the same settings": the stopped model's saved profile (the run file it was started with) is started again; the engine in use is whichever Control has primary now. */
|
||||
async function restartStopped(job){
|
||||
const offer=stoppedForUpdate;if(!offer?.instances?.length)throw new Error('There is no stopped model to start again.');
|
||||
stoppedForUpdate=null;emit();
|
||||
const labels=[];
|
||||
for(const old of offer.instances){
|
||||
const instance={...old,status:'loading',phase:'loading',error:null,metrics:null,loadedAt:new Date().toISOString()};
|
||||
instances.set(instance.id,instance);if(!defaultId||!instances.has(defaultId))defaultId=instance.id;job.summary=`Starting ${instance.label}. Loading can take a few minutes.`;emit();
|
||||
let wait=960;try{const {config,entry}=record(instance.model);wait=startWaitFor(config,entry,instance.model);}catch{/* the usual wait */}
|
||||
try{
|
||||
await control.start(instance.profile,{waitSeconds:wait});
|
||||
const deadline=Date.now()+300000;let ready=false;
|
||||
while(!ready&&Date.now()<deadline&&job.state==='running'){try{const response=await fetcher(`http://127.0.0.1:${instance.port}/health`,{headers:{authorization:`Bearer ${instance.key}`},signal:AbortSignal.timeout(2000)});ready=response.ok;}catch{/* not yet */}if(!ready)await new Promise(resolve=>setTimeout(resolve,1200));}
|
||||
if(!ready)throw new Error('Loading took more than five minutes. Check the engine log and free memory.');
|
||||
instance.status='ready';instance.phase='idle';ensurePoll();emit();labels.push(instance.label);
|
||||
}catch(error){
|
||||
await control.stop(instance.profile).catch(()=>{});
|
||||
instance.status='error';instance.error=humanStartError(error);emit();throw new Error(`${instance.label} did not start again. ${instance.error}`);
|
||||
}
|
||||
}
|
||||
return {summary:`${labels.join(' and ')} ${labels.length>1?'are':'is'} running again with the same settings.`};
|
||||
}
|
||||
function dismissRestart(){stoppedForUpdate=null;emit();return state();}
|
||||
function updateSummary(done){const r=(done.results??[]).filter(x=>x.status==='updated').map(x=>x.component==='engine'||x.component==='dsv41'?'The engine is updated and ready.':'Strata is updated. It takes effect the next time you open the app.');return r.length?r.join(' '):'Everything is already up to date.';}
|
||||
function execute(cmd,args,cwd){return new Promise((resolve,reject)=>{let output='';const process=spawnProcess(cmd,args,{cwd,stdio:['ignore','pipe','pipe']});process.stdout.on('data',c=>{output+=c;if(output.length>100000)process.kill();});process.stderr.on('data',c=>append('update',c.toString()));process.on('error',reject);process.on('exit',code=>code===0?resolve(output):reject(new Error('Git could not update this installation. Open diagnostics.')));});}
|
||||
/* Every write of a model's run config keeps the person's options: those they wrote into "args" or "user_args" since the file was last written are read from the file itself, go over what the caller built, and "args_written" is refreshed. */
|
||||
function writeModelConfig(path,next){const mine=mergeUserArgs(captureUserArgs(readJson(path)),next[USER_KEY]??[]);next=stampedUserArgs(next,mine);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);}
|
||||
@@ -468,7 +609,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
const next={...current.config,...values,ui_resource_profile:args.profile};
|
||||
writeModelConfig(current.path,next);
|
||||
job.summary='Applying your resource preference and loading the model…';emit();
|
||||
const loaded=await loadEngine(job,{model:args.model,multiple:false,vision:current.config.vision?args.vision:'no'});
|
||||
const loaded=await loadEngine(job,{model:args.model,multiple:false,vision:current.config.vision?args.vision:'no',...(args.visionUnavailable===true?{visionUnavailable:true}:{})});
|
||||
if(job.state!=='running'||!loaded?.instanceId)return;
|
||||
job.summary='Checking your model’s speed…';emit();
|
||||
const measured=await benchmark(job,{instanceId:loaded.instanceId});
|
||||
@@ -491,7 +632,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
const room=args.resourceProfile==='work';
|
||||
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','install-engine-file','remove'].includes(kind))throw new Error('Choose a supported native operation.');if(!snapshot.available&&!(packaged&&COMPONENT_KINDS.includes(kind)))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}:{})});}
|
||||
function operation(kind,args={}){if(!['install','load','pictures','benchmark','tune','autotune','recheck','calibrate','update-engine','update-strata','rollback','install-engine-file','remove','restart-models'].includes(kind))throw new Error('Choose a supported native operation.');if(kind==='restart-models'&&!packaged)throw new Error('Choose a supported native operation.');if(!snapshot.available&&!(packaged&&(COMPONENT_KINDS.includes(kind)||kind==='restart-models')))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==='pictures'?picturesOnly(job,args):kind==='benchmark'?benchmark(job,args):kind==='tune'?tune(job,args):kind==='autotune'||kind==='recheck'?autotune(job,args):kind==='remove'?removeModel(job,args):kind==='restart-models'?restartStopped(job):program(job,args),{guided:args.guided===true,model:args.model,...(args.family?{family:args.family}:{})});}
|
||||
/* ---- 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');
|
||||
@@ -591,5 +732,15 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
// 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,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};
|
||||
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,dismissRestart,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};
|
||||
}
|
||||
/* A DeepSeek config on AMD written by setup 0.2.8 or earlier names ONE card and marks it "asked", though setup picked it itself (strata_control/gpu_pick.py): it must not stop the app's recommended cards from applying. A card setup named for itself is the first of the usable cards, largest memory first; another card number was the person's. Roles and a "gpus_by" marker are always honoured. */
|
||||
export function setupPickedOneCard(config,hardware){
|
||||
if(!config||config.arch!=='deepseek41'||config.backend!=='hip'||config.gpus_by||config.gpu_roles||!config.gpus_asked)return false;
|
||||
const one=Array.isArray(config.gpu)?(config.gpu.length===1?config.gpu[0]:null):config.gpu;if(!Number.isInteger(Number(one))||one===null||one==='')return false;
|
||||
const cards=(hardware?.gpus??[]).filter(g=>(g.vendor==='amd')&&g.usable!==false),shares=g=>!!(g.uma||g.integrated||g.unified_gb),vram=g=>Number(g.vram_gb)||0;
|
||||
const real=cards.some(g=>!shares(g)&&vram(g)>=4),keep=cards.filter(g=>!(shares(g)&&real)&&vram(g)>=8);
|
||||
if(process.platform==='win32'||keep.length<2)return false;
|
||||
const first=[...keep].sort((a,b)=>Math.round(vram(b))-Math.round(vram(a))||a.index-b.index)[0];
|
||||
return Number(one)===first.index;
|
||||
}
|
||||
@@ -2,16 +2,16 @@
|
||||
import { once } from 'node:events';
|
||||
export async function nativeGateway({req,res,path,body,control,serving,fetcher=fetch}){
|
||||
let instance;try{instance=control.resolveInstance(body.model);}catch(e){res.writeHead(400,{'content-type':'application/json'});res.end(JSON.stringify({error:{message:e.message,type:'invalid_request_error',param:'model',code:null}}));return;}
|
||||
const abort=new AbortController(),started=Date.now();res.on('close',()=>{if(!res.writableEnded)abort.abort();});
|
||||
const abort=new AbortController(),started=Date.now();let sawDone=false;/* #4690: a client that hangs up after `data: [DONE]` (the OpenAI SDK does) has the whole answer: that is not a cancel. */res.on('close',()=>{if(!res.writableEnded&&!sawDone)abort.abort();});
|
||||
const entry=serving.open({client:req.headers['user-agent']?.slice(0,100)??'API client',endpoint:path.slice(3),model:instance.apiModelId,instanceId:instance.id,gpuIds:instance.gpus.map(String),cancel:()=>abort.abort()});
|
||||
let tokensIn=null,tokensOut=null,tps=null,promptTps=null;
|
||||
const observe=value=>{const usage=value.usage??value.response?.usage;if(usage){tokensIn=usage.prompt_tokens??usage.input_tokens??tokensIn;tokensOut=usage.completion_tokens??usage.output_tokens??tokensOut;}if(value.timings){tps=value.timings.predicted_per_second??tps;promptTps=value.timings.prompt_per_second??promptTps;}};
|
||||
const observe=value=>{/* Anthropic's stream carries the prompt count in message_start (message.usage) and only the output count in message_delta (usage): both are read (#4678). */for(const usage of [value.message?.usage,value.usage??value.response?.usage])if(usage){tokensIn=usage.prompt_tokens??usage.input_tokens??tokensIn;tokensOut=usage.completion_tokens??usage.output_tokens??tokensOut;}if(value.timings){tps=value.timings.predicted_per_second??tps;promptTps=value.timings.prompt_per_second??promptTps;}};
|
||||
try{
|
||||
const response=await fetcher(`http://127.0.0.1:${instance.port}${path}`,{method:'POST',headers:{'content-type':'application/json',authorization:`Bearer ${instance.key}`},signal:abort.signal,body:JSON.stringify({...body,model:instance.apiModelId,strata_mcp:false})});
|
||||
if(!response.ok){const content=await response.text();serving.finish(entry.id,{durationMs:Date.now()-started,error:`Engine returned ${response.status}`});res.writeHead(response.status,{'content-type':response.headers.get('content-type')??'application/json'});res.end(content);return;}
|
||||
serving.mark(entry.id,{state:'generating'});
|
||||
res.writeHead(response.status,{'content-type':response.headers.get('content-type')??'application/json','cache-control':'no-cache','x-strata-request-id':entry.id,'x-accel-buffering':'no'});
|
||||
if(body.stream===true){let buffer='';const decoder=new TextDecoder();for await(const chunk of response.body){buffer+=decoder.decode(chunk,{stream:true});let end;while((end=buffer.indexOf('\n'))>=0){const line=buffer.slice(0,end);buffer=buffer.slice(end+1);if(line.startsWith('data:'))try{observe(JSON.parse(line.slice(5)));}catch{}}if(!res.write(chunk))await once(res,'drain',{signal:abort.signal});}}
|
||||
if(body.stream===true){let buffer='';const decoder=new TextDecoder();for await(const chunk of response.body){buffer+=decoder.decode(chunk,{stream:true});let end;while((end=buffer.indexOf('\n'))>=0){const line=buffer.slice(0,end);buffer=buffer.slice(end+1);if(line.startsWith('data:')){if(line.slice(5).trim()==='[DONE]')sawDone=true;else try{observe(JSON.parse(line.slice(5)));}catch{}}}if(!res.destroyed&&!res.write(chunk))await once(res,'drain',{signal:abort.signal});}}
|
||||
else{const text=await response.text();try{observe(JSON.parse(text));}catch{}res.write(text);}
|
||||
serving.mark(entry.id,{tokensIn,tokensOut,tps,promptTps,usageSource:tokensIn===null||tokensOut===null?'unavailable':'reported'});serving.finish(entry.id,{tokensOut,durationMs:Date.now()-started});res.end();
|
||||
}catch(e){serving.finish(entry.id,{tokensOut,durationMs:Date.now()-started,error:abort.signal.aborted?'Request cancelled.':e.message});if(!res.headersSent){res.writeHead(502,{'content-type':'application/json'});res.end(JSON.stringify({error:{message:e.message,type:'server_error'}}));}else res.end();}
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
*/
|
||||
|
||||
export const DEFAULT_SLOTS = 4;
|
||||
/** What the person is told when DFlash2 is chosen and nobody picked a number of requests (strata_control/drafting.py ONE_REQUEST_NOTE). */
|
||||
export const DFLASH2_NOTE = 'DFlash2 answers one request at a time, so Strata set this model to one request at a time.';
|
||||
/** The most the engine's batch window holds without grouped MTP. */
|
||||
export const MAX_SLOTS = 8;
|
||||
/**
|
||||
@@ -46,7 +48,11 @@ export const FIRST_CARD_OTHER_GB = 6.9;
|
||||
* phaseA-tests/notes-021-settings.md). With "Draft layer (MTP)" off in Expert settings (`"mtp": false`) the engine does not load it, so
|
||||
* the card has that much more cache and the slots may use it.
|
||||
*/
|
||||
export const MTP_VRAM_GB = 1.0;
|
||||
export const MTP_VRAM_GB = 1.08;
|
||||
/** Graphics memory of DFlash2 alone, with the MTP layer not loaded beside it (RESULTS-DF2MEM.md: 890 MiB; the MTP layer 1082 MiB). Fit, plan and slot estimates use the drafter that is chosen. */
|
||||
export const DFLASH2_VRAM_GB = 0.89;
|
||||
/** The same with the converted pack (the selector's codebooks as Q4_0 rows, 715 MiB; strata_control/drafting.py DRAFTER_MIB "dflash2_selq4"): `capabilities.dflash2Selq4` says it is the one in use. */
|
||||
export const DFLASH2_SELQ4_VRAM_GB = 0.72;
|
||||
export const NEXT_CARD_OTHER_GB = 2.0;
|
||||
/**
|
||||
* `--batch-mtp` lets each slot verify draft tokens. Where it pays and where it does not (all measured):
|
||||
@@ -95,9 +101,10 @@ export function engineFacts(config = {}) {
|
||||
* @param {object} [input.values] what the person set for this load (Expert mode's "Concurrent requests")
|
||||
* @param {object} [input.hardware] the host's hardware read (`gpus[{index, vram_gb}]`)
|
||||
* @param {number} [input.requested] the default to aim for
|
||||
* @param {string} [input.drafter] the drafter this load runs (resolveDrafter): dflash2 answers one request at a time
|
||||
* @returns {{slots:number, batchMtp:boolean, explicit:boolean, requested:number, note:string|null, slotGb:number}}
|
||||
*/
|
||||
export function chooseParallel({ config = {}, values = {}, hardware = null, requested = DEFAULT_SLOTS }) {
|
||||
export function chooseParallel({ config = {}, values = {}, hardware = null, requested = DEFAULT_SLOTS, drafter = null }) {
|
||||
const facts = engineFacts(config);
|
||||
const slot = slotGb({ context: facts.context, kv: facts.kv, family: facts.family });
|
||||
const chosen = values.parallel !== undefined && values.parallel !== null ? Number(values.parallel)
|
||||
@@ -108,12 +115,14 @@ export function chooseParallel({ config = {}, values = {}, hardware = null, requ
|
||||
const wantMtp = (values.batchMtp ?? config.batchMtp) === true && !facts.batchMtp && facts.mtp && facts.gpus.length === 1 && (slots ?? 0) > 1;
|
||||
return { slots: slots ?? 0, batchMtp: wantMtp, explicit: true, requested, note: null, slotGb: slot };
|
||||
}
|
||||
// DFlash2 chosen on purpose: it answers one request at a time, and request slots would keep the MTP (the engine says "not --batch"). Nobody chose a number here, so one.
|
||||
if ((drafter ?? ((values.drafting ?? config.drafting) === 'dflash2' ? 'dflash2' : null)) === 'dflash2') return { slots: 1, batchMtp: false, explicit: false, requested, note: DFLASH2_NOTE, slotGb: slot };
|
||||
const cards = (hardware?.gpus ?? []).filter(g => facts.gpus.includes(g.index)).map(g => Number(g.vram_gb)).filter(n => Number.isFinite(n) && n > 0);
|
||||
// Without a hardware read nothing can be sized: one at a time is the safe default (a number can still be chosen).
|
||||
if (!cards.length) return { slots: PARALLEL_POLICY === 'fixed' ? requested : 1, batchMtp: false, explicit: false, requested, note: null, slotGb: slot };
|
||||
const total = cards.reduce((a, b) => a + b, 0);
|
||||
const oneCard = facts.gpus.length === 1 && cards.length === 1;
|
||||
const soloCache = Math.max(0, total - (FIRST_CARD_OTHER_GB - (facts.mtp ? 0 : MTP_VRAM_GB)) - NEXT_CARD_OTHER_GB * (cards.length - 1));
|
||||
const soloCache = Math.max(0, total - (FIRST_CARD_OTHER_GB - MTP_VRAM_GB + (facts.mtp ? MTP_VRAM_GB : 0)) - NEXT_CARD_OTHER_GB * (cards.length - 1));
|
||||
let slots = requested;
|
||||
if (PARALLEL_POLICY === 'fit') while (slots > 1 && slots * slot > CACHE_LOSS_SHARE * soloCache) slots -= 1;
|
||||
const note = slots >= requested ? null
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {USER_KEY,flagValue} from './native-user-args.mjs';
|
||||
import {parseEngineVersion} from './engine-version.mjs';
|
||||
|
||||
/** The engine's own default reserve, and the one setup writes for pictures read on the GPU: neither is a choice of the person. */
|
||||
const ENGINE_DEFAULT_RESERVE_MIB=700;
|
||||
@@ -75,23 +76,50 @@ export function resourceSettings(config,hardware,profile,model={},values={}){
|
||||
/* A reserve the person set is theirs, 0 included: no floor and no share of the card (#3215 #3270). Without one the card gets the safety default (defaultReserves). */
|
||||
const reserve=asked!==null?asked:defaults[profile];
|
||||
remove('--vram-reserve-mib');args.push('--vram-reserve-mib',String(reserve));
|
||||
// RAM: "leave 20% for my work" keeps the model's RAM within 80% of the PC. A model whose experts do not fit there (the
|
||||
// setup rule: experts plus 10 GB) keeps only that many GiB of them in RAM and reads the rest from the model files
|
||||
// (--resident-budget-gib, which implies --mmap-experts). Full performance takes the budget out again.
|
||||
// RAM: "leave 20% for my work". An engine that knows the setting (RAM_KEEP_MIN_ENGINE and newer) is asked to keep W GiB of the RAM free, W = ramKeepGib(RAM), as the env STRATA_RAM_KEEP_GIB: it decides from
|
||||
// the RAM that is free at its start, not from the PC's total, using the full RAM copy of the experts (the arena) only if W GiB stay free after it and otherwise keeping the hottest experts in a RAM copy
|
||||
// that leaves max(W, its own safety headroom) free. An env variable and not the flag --ram-keep-gib, so that an engine without the setting never meets an argument it does not know. The engine in use
|
||||
// is the one the Engine tab selected (its version, model.engineVersion, or the folder named in the run config's exe); an older engine, or one whose version cannot be read, gets the rule of 0.2.9
|
||||
// unchanged, which every engine understands: a RAM budget of 80% of the PC (--resident-budget-gib, which implies --mmap-experts) when the model's experts do not fit there (the setup rule: experts plus
|
||||
// 10 GB), and a STRATA_RESIDENT_HEADROOM_GIB of 20% of the RAM. Full performance (and `strata serve`) pass nothing: the whole system, the arena whenever it fits; either rule is taken out again.
|
||||
if(baseline.ramBudget)remove('--resident-budget-gib');
|
||||
let env=null; // the engine's own "leave RAM free" setting travels in the run config's env
|
||||
if(baseline.ramHeadroom&&config.env){env={...config.env};delete env.STRATA_RESIDENT_HEADROOM_GIB;}
|
||||
const ram=Number(hardware?.ram_gb)||0,arena=Number(model?.arenaGb)||0;let ramBudget=false;
|
||||
if(profile==='work'&&ram>0&&arena>0&&!['--resident-experts','--mmap-experts','--resident-budget-gib'].some(f=>args.includes(f))&&ram*.8<arena+10){
|
||||
args.push('--resident-budget-gib',String(Math.max(8,Math.min(Math.floor(ram*.8)-10,Math.floor(arena/1.073741824)))));ramBudget=true;
|
||||
let env=null;
|
||||
const dropEnv=name=>{if(config.env&&name in config.env){env={...(env??config.env)};delete env[name];}};
|
||||
if(baseline.ramHeadroom)dropEnv('STRATA_RESIDENT_HEADROOM_GIB');
|
||||
if(baseline.ramKeep)dropEnv('STRATA_RAM_KEEP_GIB');
|
||||
const ram=Number(hardware?.ram_gb)||0,arena=Number(model?.arenaGb)||0;let ramKeep=false,ramBudget=false,ramHeadroom=false;
|
||||
// The person's own RAM choice wins: a RAM mode or --ram-keep-gib in their args, and STRATA_RAM_KEEP_GIB or STRATA_RESIDENT_HEADROOM_GIB of theirs in the env (the engine reads those first).
|
||||
const mineEnv=Boolean((env??config.env)?.STRATA_RAM_KEEP_GIB);
|
||||
const modes=['--resident-experts','--mmap-experts','--resident-budget-gib','--ram-keep-gib'];
|
||||
if(profile==='work'&&ram>0&&arena>0&&!modes.some(f=>args.includes(f))){
|
||||
if(engineKnowsRamKeep(engineVersionOfExe(config?.exe)??model?.engineVersion)){
|
||||
if(!mineEnv){env={...(env??config.env??{}),STRATA_RAM_KEEP_GIB:String(ramKeepGib(ram))};ramKeep=true;}
|
||||
}else if(ram*.8<arena+10){
|
||||
args.push('--resident-budget-gib',String(Math.max(8,Math.min(Math.floor(ram*.8)-10,Math.floor(arena/1.073741824)))));ramBudget=true;
|
||||
if(!(env??config.env)?.STRATA_RESIDENT_HEADROOM_GIB){env={...(env??config.env??{}),STRATA_RESIDENT_HEADROOM_GIB:String(Math.ceil(ram*.2))};ramHeadroom=true;}
|
||||
}
|
||||
}
|
||||
// The budget caps what the engine keeps in RAM. "Leave 20%" is also asked of the engine itself: at its start it leaves 20% of the RAM
|
||||
// available for other programs (it keeps fewer experts if the PC is busy), and it said nothing about this before.
|
||||
let ramHeadroom=false;
|
||||
if(ramBudget&&!(config.env?.STRATA_RESIDENT_HEADROOM_GIB)){env={...(env??config.env??{}),STRATA_RESIDENT_HEADROOM_GIB:String(Math.ceil(ram*.2))};ramHeadroom=true;}
|
||||
return {args,vram_reserve_mib:reserve,...(asked!==null?{}:{ui_reserve_applied:reserve}),...(env?{env}:{}),ui_resource_baseline:{...baseline,ramBudget,ramHeadroom}};
|
||||
return {args,vram_reserve_mib:reserve,...(asked!==null?{}:{ui_reserve_applied:reserve}),...(env?{env}:{}),ui_resource_baseline:{...baseline,ramBudget,ramHeadroom,ramKeep}};
|
||||
}
|
||||
|
||||
/** The RAM, in GiB, "Leave 20% for my work" asks the engine to keep free: a fifth of the PC's RAM rounded up, never under 5 or over 8
|
||||
* (5 at 24 GB, 7 at 32 GB, 8 at 64 GB and above). The engine adds it to the arena's need and, when the arena does not fit, leaves it free beside the RAM copy. It is sent as the env variable STRATA_RAM_KEEP_GIB. */
|
||||
export const ramKeepGib=ram=>Math.min(8,Math.max(5,Math.ceil((Number(ram)||0)*.2)));
|
||||
|
||||
/** The first engine version that reads STRATA_RAM_KEEP_GIB / --ram-keep-gib (the setting was added in 0.1.56, the engine of the 0.2.9 update; the released 0.2.9 engine is 0.1.55). An engine older than this, or one whose version cannot be read, gets the
|
||||
* rule of 0.2.9 instead. Lower it (or raise the engine's version) only together with the release that carries the setting. */
|
||||
export const RAM_KEEP_MIN_ENGINE='0.1.56';
|
||||
/** True when the engine with this version string knows the setting. Unknown or unreadable: false (the 0.2.9 rule works on every engine, an ignored env variable would keep no room). */
|
||||
export function engineKnowsRamKeep(version){
|
||||
const have=parseEngineVersion(version),need=parseEngineVersion(RAM_KEEP_MIN_ENGINE);
|
||||
if(!have||!need)return false;
|
||||
for(let i=0;i<4;i++){const a=have[i]??0,b=need[i]??0;if(a!==b)return a>b;}
|
||||
return true;
|
||||
}
|
||||
/** The version in the folder a run config's exe names (.../engine/0.1.55-windows-x86_64-cuda-cuda13/strata.exe), or null (the exe "component:strata-engine" means the active engine, whose version the caller gives). */
|
||||
export const engineVersionOfExe=exe=>{const m=/engine[\\/](\d+(?:\.\d+)+)-/.exec(String(exe??''));return m?m[1]:null;};
|
||||
|
||||
|
||||
/* One automatic second try after a start that died. Returns null when there is nothing safer to try (the caller then reports the first
|
||||
failure as it was) or {config, words, detail}: the same run config with ONE safer memory setting, a sentence for the user and the
|
||||
technical line for Details. The caller tries once and never again, and does not save this config. */
|
||||
|
||||
@@ -8,9 +8,9 @@ export const NATIVE_FIELDS=[
|
||||
{id:'aliases',label:'API model aliases',group:'Serving',type:'array',hint:'Comma-separated names clients may use for this model.'},
|
||||
{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:'mtp',notForModel:'deepseek',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',notForModel:'deepseek',label:'Drafting',group:'Serving',type:'enum',choices:['auto','mtp','dflash2','off'],hint:'Which drafter guesses words ahead to make answers faster, one at a time. Auto (recommended) is DFlash2 for Qwen3.8-Flash-Next where the engine has it, the MTP otherwise. DFlash2 downloads a 1.2 GB drafter once, the MTP its draft layer once. If DFlash2 cannot run (several requests at once), the MTP is used alone and Details says why. Off turns the drafter off.'},
|
||||
{id:'batchMtp',notForModel:'deepseek',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:'ds_experts',label:'Speed (DeepSeek)',group:'Serving',type:'enum',choices:['5','6'],forModel:'deepseek',hint:'Faster uses 5 of 6 experts per word: a small quality change, usually not noticeable. Full quality uses all 6. Left alone, the engine picks Faster on a PC whose graphics cards total 16 GB or less.'},
|
||||
{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},
|
||||
@@ -87,6 +87,16 @@ export function setSpeedSwitch(env,id,on){
|
||||
export function speedSwitchRows(env,context){return SPEED_SWITCHES.filter(s=>speedSwitchApplies(s.id,context)).map(s=>({...s,on:speedSwitchIsOn(env,s.id)}));}
|
||||
/** 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','drafting','ds_experts'];
|
||||
/** The families that have a Drafting choice and the model names the DFlash2 drafter was made for (strata_control/drafting.py SUPPORTED_FAMILIES, _SUPPORTED_NAME): Qwen3.8-Flash-Next, Gyro and Unsloth's quantizations of the same weights; not Swift, Coder or NVFP4. */
|
||||
export const DRAFTING_FAMILIES=['qwen','gyro','unsloth'];
|
||||
const DFLASH2_MODEL=/^qwen3\.8-flash-next(-gyro-[a-z0-9._-]+|-unsloth-[a-z0-9._-]+|-(q|iq|tq)\d[a-z0-9_.]*)?$/i;
|
||||
/** The drafter a choice means (one per run; strata_control/drafting.py choice_of): Auto is DFlash2 where the engine has it (`served`, Control's word) and the model is one it was made for, else the MTP; off and mtp are what they say. `model` is a run config's model name or a family. */
|
||||
/** `split` (Control's plan: the all-cards split engine will take this start) makes Auto the MTP, the draft layer that engine runs; a DFlash2 the person chose by name stays. */
|
||||
export function resolveDrafter(choice,model,served,split){
|
||||
const c=String(choice??'auto').toLowerCase();if(c==='off'||c==='mtp')return c;
|
||||
const name=String(model??'');if(!(served===true&&(DRAFTING_FAMILIES.includes(name.toLowerCase())||DFLASH2_MODEL.test(name))))return 'mtp';
|
||||
return split===true&&c!=='dflash2'?'mtp':'dflash2';
|
||||
}
|
||||
/** 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);
|
||||
|
||||
@@ -8,3 +8,17 @@ export function runtimeVision(config,requested){
|
||||
if(mode==='cpu'&&config.vision.cpu_exe){const {cpu_exe,...rest}=config.vision;return {...rest,exe:cpu_exe,gpu:false};} // AMD: the CPU reader program, not the HIP one with the GPU hidden
|
||||
return {...config.vision,...(mode==='cpu'?{gpu:false}:mode==='gpu'?{gpu:true}:{})};
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog with each picture-capable size marked as ready (`images`) or not (`imagesCapable` stays true either way). The Qwen engines
|
||||
* read pictures with a separate program (strata-vision) that has to sit in the engine folder; DeepSeek-V4.1-Flash (`multi_file`, its own
|
||||
* engine) carries its own picture tower, so it is ready whatever that folder holds (#4772: it was told to update an engine that was fine).
|
||||
*/
|
||||
export function markPictures(catalog,visionReader){
|
||||
if(!catalog||!Array.isArray(catalog.families))return catalog;
|
||||
const ready=(s)=>!!s.images&&(s.multi_file===true||visionReader===true);
|
||||
return {...catalog,families:catalog.families.map(f=>{
|
||||
const sizes=(f.sizes??[]).map(s=>({...s,imagesCapable:!!s.images,images:ready(s)}));
|
||||
return {...f,imagesCapable:!!f.images,images:sizes.length?sizes.some(s=>s.images):!!f.images&&visionReader===true,sizes};
|
||||
})};
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export const KNOWN_KEYS = new Set([
|
||||
'elastic', 'elastic_idle_s', 'elastic_mib', 'min_free_vram_mib', 'vram_segment_mib', 'slot_save_path', 'telemetry_read_key', 'batch_telemetry',
|
||||
'reasoning_effort', 'reasoning_close_retry', 'reasoning_loop_recovery', 'repeat_stop_tokens', 'tool_call_recovery', 'codex_compaction_cache',
|
||||
'codex_thread_titles', 'expert_profile_save', 'expert_profile_save_every', 'images', 'vision', 'mmproj', 'draft_vocab', 'ui_resource_profile',
|
||||
'ui_vision_enabled', 'component', 'native', 'uma', 'remote_expert_opt', 'diffusion_model', 'llm', 'vae', 'quant', 'type', 'url', 'timeout_s',
|
||||
'ui_vision_enabled', 'ui_context_chosen', 'component', 'native', 'uma', 'remote_expert_opt', 'diffusion_model', 'llm', 'vae', 'quant', 'type', 'url', 'timeout_s',
|
||||
]);
|
||||
|
||||
const BOOLS = new Set(['open_browser', 'prevent_sleep', 'elastic', 'env_to_user', 'gpus_asked', 'batch_telemetry', 'tool_call_recovery', 'reasoning_loop_recovery']);
|
||||
|
||||
@@ -39,6 +39,8 @@ export function state() {
|
||||
revision,
|
||||
running,
|
||||
baseUrl: sharing.active ? sharing.url : baseUrl(),
|
||||
// The address on this computer, the one the app's own port serves, kept apart from the sharing address (#4798).
|
||||
localUrl: baseUrl(),
|
||||
sharing,
|
||||
tunnel,
|
||||
port,
|
||||
|
||||
@@ -218,7 +218,7 @@ export async function start({ modelId, modelPath, port = 8080, config, hardware,
|
||||
const binary = await resolveBinary();
|
||||
if (!binary) {
|
||||
state.status = 'error';
|
||||
state.error = 'No llama-server binary found. Download the engine from Settings → Engine.';
|
||||
state.error = 'No llama-server binary found. Download the engine from Settings → About.';
|
||||
throw new Error(state.error);
|
||||
}
|
||||
if (!existsSync(modelPath)) {
|
||||
|
||||
@@ -122,3 +122,8 @@ export async function readTemperatures({ platform = process.platform, now = Date
|
||||
}
|
||||
return { cpuTempC: null, ssdTempC: null };
|
||||
}
|
||||
|
||||
/** Ends the Windows drive-temperature loop (the Monitor has not been open for a while); the next read starts it again. */
|
||||
export function stopTemperatures() {
|
||||
if (windowsReader) { windowsReader.stop(); windowsReader = null; }
|
||||
}
|
||||
@@ -20,8 +20,9 @@ export function looksOffline(error) {
|
||||
}
|
||||
export const OFFLINE_MESSAGE = "No internet connection. Try again when you're online.";
|
||||
|
||||
/** { message, details, next } for an error thrown by Control's update calls (or the shell bridge). `subject` is "engine" or "Strata". */
|
||||
export function plainUpdateError(error, subject = 'engine') {
|
||||
/** { message, details, next } for an error thrown by Control's update calls (or the shell bridge). `subject` is "engine" or "Strata".
|
||||
* `retry` names the button of a screen that has no Settings page yet (the first-run card's Continue): the next step then says that button. */
|
||||
export function plainUpdateError(error, subject = 'engine', retry = undefined) {
|
||||
const raw = String(error?.message ?? error ?? '');
|
||||
const code = error?.code ?? '';
|
||||
const exit = error?.exitCode ?? null;
|
||||
@@ -43,9 +44,18 @@ export function plainUpdateError(error, subject = 'engine') {
|
||||
} else if (/could not be read|interrupted|timed out|timeout|ECONN|ENOTFOUND|fetch failed|could not reach|server answered|not answer within/i.test(raw)) {
|
||||
message = "Couldn't reach the update server.";
|
||||
next = `Check your internet connection, then press ${button} in Settings.`;
|
||||
} else if (/free space|not enough/i.test(raw)) {
|
||||
} else if (/free space|not enough|no space left|disk is full/i.test(raw)) {
|
||||
message = 'There is not enough free disk space for the update.';
|
||||
next = `Free some disk space, then press ${button} in Settings.`;
|
||||
} else if (/could not be moved into place|could not be unpacked|not accepted after the switch/i.test(raw)) {
|
||||
// The files were downloaded and checked; Windows or a virus scanner would not let Strata put them in place (#4853). Not a download problem.
|
||||
const long = /longer than Windows allows|path.*too long/i.test(raw);
|
||||
message = long
|
||||
? "The engine's folder is too deep for Windows, so it could not be put in place."
|
||||
: 'The engine was downloaded and checked, but Windows or a security program would not let Strata put it in place.';
|
||||
next = long
|
||||
? `Open Details, then follow the folder advice there, and press ${button} in Settings.`
|
||||
: `Wait a minute, then press ${button} in Settings. If it happens again, pause your virus scanner's live scanning for a few minutes while it runs.`;
|
||||
} else if (/no engine for this PC|has no engine|no package/i.test(raw)) {
|
||||
message = "There isn't an engine for this PC's graphics card yet.";
|
||||
next = 'Check for updates again later.';
|
||||
@@ -56,11 +66,26 @@ export function plainUpdateError(error, subject = 'engine') {
|
||||
message = `The update didn't pass its safety check, so it was not installed. ${keep}`;
|
||||
next = 'Try again later.';
|
||||
}
|
||||
if (retry) next = next.split(`${button} in Settings`).join(retry).replace(/in Settings/g, '').replace(/ {2,}/g, ' ').replace(/ ([.,])/g, '$1');
|
||||
return { message: `${message} ${next}`.trim(), headline: message, next, details: raw || undefined };
|
||||
}
|
||||
|
||||
/** Plain words for a switch or a delete in Settings > Engine: { message, details }. Control's own sentence for "the engine in use" is already plain and is kept. */
|
||||
export function plainEngineError(error, action = 'switch') {
|
||||
const raw = String(error?.message ?? error ?? '');
|
||||
const exit = error?.exitCode ?? null;
|
||||
let message = action === 'delete' ? "The engine wasn't deleted. Nothing else was changed." : "The engine wasn't switched. Nothing was changed.";
|
||||
if (exit === 7 || /is serving|model is running/i.test(raw)) message = 'A model is running, so nothing was changed. Unload the model in Models, then try again.';
|
||||
else if (/engine in use/i.test(raw)) message = 'This is the engine in use. Switch to another one first, then delete it.';
|
||||
else if (/running program is using|something is using it/i.test(raw)) message = 'A program is still using this engine, so it was not deleted. Unload your models and try again.';
|
||||
else if (/not installed|already been deleted/i.test(raw)) message = 'That engine is already gone. The list has been refreshed.';
|
||||
else if (/another update is already running/i.test(raw)) message = 'An update is running. Wait for it to finish, then try again.';
|
||||
else if (exit === 5 || /can no longer be used|does not fit|cannot run/i.test(raw)) message = "This engine can't run on this computer, so nothing was changed.";
|
||||
return { message, details: raw || undefined };
|
||||
}
|
||||
|
||||
/** An Error carrying the plain message and, behind it, the technical reason. */
|
||||
export function plainError(error, subject) {
|
||||
const { message, details } = plainUpdateError(error, subject);
|
||||
export function plainError(error, subject, retry = undefined) {
|
||||
const { message, details } = plainUpdateError(error, subject, retry);
|
||||
return Object.assign(new Error(message), { details, code: error?.code });
|
||||
}
|
||||
@@ -64,7 +64,8 @@ export async function checkComponentUpdates({ control, appUpdater = null, appVer
|
||||
const installedEngine = engine.installed?.version ?? null;
|
||||
const offered = engine.offered?.version ?? null;
|
||||
const engineView = { installedVersion: installedEngine, latestVersion: offered ?? undefined, canInstall: engine.status === 'available' || engine.status === 'install' };
|
||||
if (engine.status === 'available') Object.assign(engineView, { status: 'available', message: `A new engine is available (${mib(engine.download_bytes)}).` });
|
||||
if (engine.status === 'available' && engine.installed_locally) Object.assign(engineView, { status: 'available', message: 'A newer engine is already on this computer. Update switches to it; nothing is downloaded.' });
|
||||
else 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 });
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Sidebar } from './Sidebar';
|
||||
import { CommandPalette } from './CommandPalette';
|
||||
import { ShortcutsDialog } from './Shortcuts';
|
||||
import { FolderPathDialog } from '../components/FolderPathDialog';
|
||||
import { handleAppShortcut, runCommand } from '../lib/commands';
|
||||
import { MOD, handleAppShortcut, runCommand } from '../lib/commands';
|
||||
import { useMedia } from '../lib/responsive';
|
||||
import { Btn, Dialog, IconBtn, Toasts } from '../components/ui';
|
||||
import { Brand } from '../components/Brand';
|
||||
@@ -111,6 +111,9 @@ function ConnectionBar() {
|
||||
* way out. Everything else on this screen is the conversation.
|
||||
*/
|
||||
function FocusBar() {
|
||||
// The hints are chords, not decorations: on Windows the modifier is Ctrl and
|
||||
// Alt is spelled out, because ⌘ and ⌥ are not on that keyboard.
|
||||
const mac = MOD === '⌘';
|
||||
const native = useNative(), nativeCurrent = native?.instances.find(i => i.id === native.defaultId);
|
||||
const library = useStore((s) => s.library);
|
||||
const monitor = useStore((s) => s.monitor);
|
||||
@@ -139,11 +142,11 @@ function FocusBar() {
|
||||
{/* A chord reads as one key: the two caps sit tight together, with
|
||||
the space before the label doing the separating. */}
|
||||
<span className="focusbar__chord">
|
||||
<kbd>⌥↑</kbd>
|
||||
<kbd>⌥↓</kbd>
|
||||
<kbd>{mac ? '⌥↑' : 'Alt+↑'}</kbd>
|
||||
<kbd>{mac ? '⌥↓' : 'Alt+↓'}</kbd>
|
||||
</span>{tr("turns")}</span>
|
||||
<span>
|
||||
<kbd>⌘↓</kbd>{tr(" latest")}</span>
|
||||
<kbd>{mac ? '⌘↓' : 'Ctrl+↓'}</kbd>{tr(" latest")}</span>
|
||||
<span>
|
||||
<kbd>{tr("Esc")}</kbd>{tr(" leave")}</span>
|
||||
</span>
|
||||
|
||||
@@ -136,7 +136,7 @@ export function CommandPalette({ open }: {
|
||||
<input ref={inputRef} className="st-input st-input--bare" style={{ flex: 1 }} placeholder={tr("Search chats, screens or actions…")} aria-label={tr("Search commands")} aria-activedescendant={flat[cursor] ? `command-${flat[cursor].id}` : undefined} value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={onKeyDown}/>
|
||||
<kbd className="kbd">{tr("esc")}</kbd>
|
||||
</div>
|
||||
<div style={{ overflowY: 'auto', padding: 8 }}>
|
||||
<div className="palette__list">
|
||||
{tr(flat.length === 0 ? (<div className="small muted" style={{ padding: 18 }}>{tr("Nothing matches “{0}”. Try > for commands, # for conversations, or @ for workspaces.", [query])}</div>) : (grouped.map(([group, items]) => (<div key={group} style={{ marginBottom: 6 }}>
|
||||
<div className="navgroup__label" style={{ padding: '8px 12px 4px' }}>
|
||||
{tr(group)}
|
||||
@@ -167,6 +167,11 @@ export function CommandPalette({ open }: {
|
||||
}))}
|
||||
</div>))))}
|
||||
</div>
|
||||
<div className="palette__foot">
|
||||
<span className="palette__hint"><kbd className="kbd">Enter</kbd>{tr("Open")}</span>
|
||||
<span className="spacer"/>
|
||||
<span className="palette__hint"><kbd className="kbd">esc</kbd>{tr("Close")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { FloatingMenu } from '../components/FloatingMenu';
|
||||
import { QuitStrataDialog } from '../components/QuitStrata';
|
||||
import { useAppMode } from '../lib/app-mode';
|
||||
import { href, useLocation, type Route } from '../lib/router';
|
||||
import { cx, relTime } from '../lib/format';
|
||||
import { cx, relTime, relTimeShort } from '../lib/format';
|
||||
import { useMedia } from '../lib/responsive';
|
||||
import { recentChats, recentChatDescription } from '../lib/recent-chats';
|
||||
import type { Session } from '../lib/types';
|
||||
@@ -18,7 +18,7 @@ import { MOD, runCommand } from '../lib/commands';
|
||||
import { bindingFor, bindingKeys, useBindings } from '../lib/shortcut-bindings';
|
||||
import { Btn, Dialog, Icon, IconBtn, type IconName } from '../components/ui';
|
||||
import { Brand } from '../components/Brand';
|
||||
import { addWorkspace, deleteSession, go, isUnread, pickWorkspace, renameSession, removeWorkspace, renameWorkspace, selectSession, setActiveWorkspace, setNavOpen, togglePin, toggleRail, setTheme, useStore } from '../lib/store';
|
||||
import { addWorkspace, deleteSession, go, isUnread, liveChatIds, pickWorkspace, renameSession, removeWorkspace, renameWorkspace, selectSession, setActiveWorkspace, setNavOpen, togglePin, toggleRail, setTheme, useStore } from '../lib/store';
|
||||
export function Sidebar({ containerRef }: {
|
||||
containerRef: RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
@@ -37,6 +37,9 @@ export function Sidebar({ containerRef }: {
|
||||
const workspaces = useStore(s => s.workspaces);
|
||||
const activeWorkspace = useStore(s => s.activeWorkspace);
|
||||
const activeSession = useStore(s => s.activeSession);
|
||||
// The chats that have a turn running now (the board reads the same list). The count is what makes this redraw when one starts or ends.
|
||||
const liveCount = useStore(s => s.liveChats);
|
||||
const working = new Set(liveCount ? liveChatIds() : []);
|
||||
const [query, setQuery] = useState('');
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const searchInput = useRef<HTMLInputElement>(null);
|
||||
@@ -65,6 +68,10 @@ export function Sidebar({ containerRef }: {
|
||||
const modelQuant = nativeSecond ?? current?.quant ?? tr('View model versions');
|
||||
const phase = nativeCurrent ? (nativeCurrent.status === 'ready' ? nativeCurrent.phase : nativeCurrent.status) : monitor?.phase ?? 'idle';
|
||||
const status = phase === 'unavailable' ? 'Status unavailable' : phase === 'error' ? 'Error' : phase === 'loading' ? 'Loading' : !current && !nativeCurrent ? 'No model loaded' : phase === 'reading' ? 'Reading prompt' : phase === 'thinking' ? 'Thinking' : phase === 'generating' ? 'Generating' : 'Ready';
|
||||
// The model is busy with a chat that is not the one in front of the person: the chip says which one (a title, or how many).
|
||||
const awayChats = [...working].filter(id => !(location.route === 'chat' && id === activeSession));
|
||||
const awayTitle = awayChats.length === 1 ? sessions.find(s => s.id === awayChats[0]) : undefined;
|
||||
const workingLine = awayChats.length === 1 && awayTitle ? tr("Working · {0}", [sessionTitle(awayTitle)]) : awayChats.length > 1 ? tr("{0} chats working", [awayChats.length]) : null;
|
||||
const menuSession = sessions.find(s => s.id === menuId);
|
||||
const q = query.trim().toLowerCase();
|
||||
const matching = sessions.filter(s => (!s.archived || q || includeArchived) && (!q || `${sessionTitle(s)} ${s.messages.map(m => m.content).join(' ')}`.toLowerCase().includes(q))).sort((a, b) => Number(!!b.pinned) - Number(!!a.pinned) || b.updatedAt.localeCompare(a.updatedAt));
|
||||
@@ -92,7 +99,7 @@ export function Sidebar({ containerRef }: {
|
||||
<div className={cx('ws__row', activeWorkspace === w.id && 'is-active')}><button className="ws__name" aria-expanded={expanded} onClick={() => { setActiveWorkspace(w.id); setFolders(f => ({ ...f, [w.id]: !expanded })); }}><Icon name="chevron" size={14} className={cx('ws__chev', expanded && 'is-open')}/><Icon name="folder" size={17}/><span>{w.name}</span></button><IconBtn name="pencil" title={tr("Rename {0}", [w.name])} onClick={() => { setWsName(w.name); setNavOpen(false); setRenameWsId(w.id); }}/>{tr(w.id !== 'root' ? <IconBtn name="x" title={tr("Remove {0} from sidebar", [w.name])} onClick={() => { setNavOpen(false); setRemoveWorkspaceId(w.id); }}/> : null)}</div>
|
||||
{tr(expanded ? <div className="navsessions">
|
||||
{tr(shown.map(s => <div key={s.id}>
|
||||
<div className="ws__session row"><a className="navsession" href={href('chat', s.id)} aria-current={activeSession === s.id && location.route === 'chat' ? 'page' : undefined} onClick={() => selectSession(s.id)} onContextMenu={e => { e.preventDefault(); openMenu(s.id, e.currentTarget); }} title={sessionTitle(s)}><span className="navsession__title">{sessionTitle(s)}</span>{tr(s.pinned ? <span aria-label={tr("Pinned")}><Icon name="pin" size={12}/></span> : <span className="navsession__time">{tr(relTime(s.updatedAt))}</span>)}{tr(isUnread(s, activeSession) ? <span className="navsession__unread" aria-label={tr("Unread")}/> : null)}{tr(s.archived ? <span className="xs muted">{tr("Archived")}</span> : null)}</a><button className="st-btn st-btn--icon session-overflow" aria-label={tr("Actions for {0}", [sessionTitle(s)])} onClick={e => openMenu(s.id, e.currentTarget)}><Icon name="ellipsis" size={16}/></button></div>
|
||||
<div className="ws__session row"><a className="navsession" href={href('chat', s.id)} data-working={working.has(s.id) || undefined} aria-current={activeSession === s.id && location.route === 'chat' ? 'page' : undefined} onClick={() => selectSession(s.id)} onContextMenu={e => { e.preventDefault(); openMenu(s.id, e.currentTarget); }} title={sessionTitle(s)}>{tr(working.has(s.id) ? <span className="navsession__working" role="img" aria-label={tr("Working")} title={tr("Working")}><svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><circle className="navsession__track" cx="8" cy="8" r="6"/><circle className="navsession__arc" cx="8" cy="8" r="6" pathLength="100"/></svg></span> : null)}<span className="navsession__title">{sessionTitle(s)}</span>{tr(s.pinned ? <span aria-label={tr("Pinned")}><Icon name="pin" size={12}/></span> : <span className="navsession__time">{relTimeShort(s.updatedAt)}</span>)}{tr(!working.has(s.id) && isUnread(s, activeSession) ? <span className="navsession__unread" aria-label={tr("Unread")}/> : null)}{tr(s.archived ? <span className="xs muted">{tr("Archived")}</span> : null)}</a><button className="st-btn st-btn--icon session-overflow" aria-label={tr("Actions for {0}", [sessionTitle(s)])} onClick={e => openMenu(s.id, e.currentTarget)}><Icon name="ellipsis" size={16}/></button></div>
|
||||
</div>))}
|
||||
{tr(!shown.length ? <p className="small muted ws__empty">{tr(q ? 'No matching chats' : 'No chats yet')}</p> : null)}
|
||||
{tr(!q && rows.length > shown.length ? <button className="ws-view small" onClick={() => { setNavOpen(false); setRecentOpen(true); }}>{tr("View all chats")}</button> : null)}
|
||||
@@ -102,7 +109,7 @@ export function Sidebar({ containerRef }: {
|
||||
</div> : null}
|
||||
</div>
|
||||
<div className="nav__foot">
|
||||
<button className="enginechip" data-state={status === 'Ready' ? 'ready' : ['Reading prompt', 'Thinking', 'Generating', 'Loading'].includes(status) ? 'busy' : 'off'} onClick={() => go('models')} title={tr("{0} \u00B7 {1} \u00B7 {2}", [modelName, modelQuant, tr(status)])} aria-label={tr("{0} \u00B7 {1}", [modelName, modelQuant])}><span className="enginechip__family"><Icon name={modelFamilyIcon(current?.family)} size={20}/><span className="enginechip__dot"/></span><span className="enginechip__text nav__rail-hide"><span className="enginechip__title">{modelName}</span><span className="enginechip__sub">{modelQuant}</span></span></button>
|
||||
<button className="enginechip" data-state={status === 'Ready' ? 'ready' : ['Reading prompt', 'Thinking', 'Generating', 'Loading'].includes(status) ? 'busy' : 'off'} onClick={() => go('models')} title={tr("{0} \u00B7 {1} \u00B7 {2}", [modelName, workingLine ?? modelQuant, tr(status)])} aria-label={tr("{0} \u00B7 {1}", [modelName, modelQuant])}><span className="enginechip__family"><Icon name={modelFamilyIcon(current?.family)} size={20}/><span className="enginechip__dot"/></span><span className="enginechip__text nav__rail-hide"><span className="enginechip__title">{modelName}</span><span className="enginechip__sub">{workingLine ?? modelQuant}</span></span></button>
|
||||
<NavLink route="skills" label={tr("Skills")} icon="skills" current={location.route}/>
|
||||
<NavLink route="monitor" label={tr("Monitor")} icon="activity" current={location.route}/>
|
||||
<NavLink route="models" label={tr("Models")} icon="models" current={location.route === 'tuning' ? 'models' : location.route}/>
|
||||
@@ -190,5 +197,5 @@ function MoreMenu({ current }: {
|
||||
const [quitOpen, setQuitOpen] = useState(false);
|
||||
const appMode = useAppMode();
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
return <><button ref={ref} className="navitem nav__more" aria-label={tr("More — Serving and Settings")} aria-haspopup="menu" aria-expanded={open} data-active={['serving', 'settings'].includes(current) || undefined} onClick={() => setOpen(v => !v)}><Icon name="ellipsis"/><span className="navitem__text">{tr("More")}</span></button>{tr(open ? <FloatingMenu anchor={ref.current} title={tr("More")} onClose={() => { setOpen(false); ref.current?.focus(); }}>{tr(([{ route: 'serving', label: 'Serving', icon: 'link' }, { route: 'settings', label: 'Settings', icon: 'settings' }] as const).map(d => <a key={d.route} role="menuitem" className="menu__item" href={href(d.route)} onClick={() => setOpen(false)}><Icon name={d.icon}/>{tr(d.label)}</a>))}{appMode.canQuit ? <button type="button" role="menuitem" className="menu__item" onClick={() => { setOpen(false); setNavOpen(false); setQuitOpen(true); }}><Icon name="x"/>{tr("Quit Strata")}</button> : null}</FloatingMenu> : null)}<QuitStrataDialog open={quitOpen} onClose={() => setQuitOpen(false)}/></>;
|
||||
return <><button ref={ref} className="navitem nav__more" title={tr("More — Serving and Settings")} aria-label={tr("More — Serving and Settings")} aria-haspopup="menu" aria-expanded={open} data-active={['serving', 'settings'].includes(current) || undefined} aria-current={['serving', 'settings'].includes(current) ? 'page' : undefined} onClick={() => setOpen(v => !v)}><Icon name="ellipsis"/><span className="navitem__text">{tr("More")}</span></button>{tr(open ? <FloatingMenu anchor={ref.current} title={tr("More")} onClose={() => { setOpen(false); ref.current?.focus(); }}>{tr(([{ route: 'serving', label: 'Serving', icon: 'link' }, { route: 'settings', label: 'Settings', icon: 'settings' }] as const).map(d => <a key={d.route} role="menuitem" className="menu__item" href={href(d.route)} onClick={() => setOpen(false)}><Icon name={d.icon}/>{tr(d.label)}</a>))}{appMode.canQuit ? <button type="button" role="menuitem" className="menu__item" onClick={() => { setOpen(false); setNavOpen(false); setQuitOpen(true); }}><Icon name="x"/>{tr("Quit Strata")}</button> : null}</FloatingMenu> : null)}<QuitStrataDialog open={quitOpen} onClose={() => setQuitOpen(false)}/></>;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/** The card that asks the person to approve or deny one action of the assistant, and the dock that keeps it in view. */
|
||||
import { useMemo } from 'react';
|
||||
import { tr } from '../lib/i18n';
|
||||
import { Btn, Icon } from './ui';
|
||||
import { askQuestion, askSub, askIcon } from '../lib/ask';
|
||||
import { resolveTool, useStore } from '../lib/store';
|
||||
import { pendingApprovals } from '../lib/approvals';
|
||||
import { QuestionCard } from './QuestionCard';
|
||||
import type { AgentAsk } from '../lib/agent-ask';
|
||||
import type { ToolCall } from '../lib/types';
|
||||
|
||||
const NO_ASKS: AgentAsk[] = [];
|
||||
export const SCOPE: Record<ToolCall['scope'], {
|
||||
label: string;
|
||||
tone: 'plain' | 'info' | 'warn';
|
||||
}> = {
|
||||
read: { label: 'Reads', tone: 'plain' },
|
||||
write: { label: 'Writes', tone: 'warn' },
|
||||
shell: { label: 'Runs a command', tone: 'warn' },
|
||||
connected: { label: 'Connected tool', tone: 'info' },
|
||||
};
|
||||
|
||||
/** The one place the Approve and Deny buttons live. Chat shows it in the dock above the box, not in the middle of a long reply (#4725). */
|
||||
export function ApprovalCard({ messageId, tool: t }: {
|
||||
messageId: string;
|
||||
tool: ToolCall;
|
||||
}) {
|
||||
const scope = SCOPE[t.scope];
|
||||
if (t.ask)
|
||||
return <section className="askcard" data-state={t.state} aria-label={tr(askQuestion(t.ask))}>
|
||||
<div className="askcard__top"><span className="askcard__icon"><Icon name={askIcon(t.ask)} size={18}/></span>
|
||||
<div className="askcard__text"><p className="askcard__q">{askQuestion(t.ask)}</p>{askSub(t.ask) ? <p className="askcard__why" title={askSub(t.ask)}>{askSub(t.ask)}</p> : null}</div>
|
||||
<div className="askcard__acts"><Btn size="sm" onClick={() => resolveTool(messageId, t.id, false)}>{tr("Deny")}</Btn><Btn variant="primary" size="sm" onClick={() => resolveTool(messageId, t.id, true)}>{tr("Approve")}</Btn></div></div>
|
||||
<details className="askcard__details"><summary><Icon name="chevron" size={11}/>{tr("Details")}</summary><pre>{t.preview}</pre></details>
|
||||
</section>;
|
||||
return <section className="toolcard toolcard--compact" data-state={t.state} aria-label={t.name}>
|
||||
<details className="toolcard__disclosure">
|
||||
<summary><Icon name={t.scope === 'shell' ? 'bolt' : 'tool'} size={17}/><span className="toolcard__title">{tr(t.verb ?? t.name)}</span><span className="toolcard__scope xs muted">{tr(scope.label)}</span><Icon name="chevron" className="toolcard__chevron" size={15}/></summary>
|
||||
<div className="toolcard__body"><code className="toolcard__preview">{t.preview}</code><p className="small muted">{tr(t.effect)}</p>{t.result ? <p className="small">{t.result}</p> : null}</div>
|
||||
</details>
|
||||
{t.target ? <code className="toolcard__what" title={t.target}>{t.target}</code> : null}
|
||||
<div className="toolcard__quick-actions"><span className="xs muted">{tr("Needs your approval")}</span><Btn size="sm" onClick={() => resolveTool(messageId, t.id, false)}>{tr("Deny")}</Btn><Btn variant="primary" size="sm" icon="check" onClick={() => resolveTool(messageId, t.id, true)}>{tr("Approve")}</Btn></div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps an approval where the person is looking. A reply can run to many screens; the card used to sit above its text, so a
|
||||
* person reading the end of it never saw that the assistant had stopped to ask. The dock sits directly above the message box and
|
||||
* does not scroll with the conversation. Where the card was, the conversation says that it is waiting.
|
||||
*/
|
||||
export function ApprovalDock({ sessionId }: {
|
||||
sessionId: string | undefined;
|
||||
}) {
|
||||
const session = useStore(s => s.sessions.find(x => x.id === sessionId));
|
||||
const items = useMemo(() => pendingApprovals(session), [session]);
|
||||
const asks = useStore(s => (sessionId ? s.agentAsks[sessionId] : undefined)) ?? NO_ASKS;
|
||||
if (!items.length && !asks.length)
|
||||
return null;
|
||||
return <section className="approvaldock" role="group" aria-label={tr("Waiting for your answer")} data-count={items.length}>
|
||||
{asks.map(a => <QuestionCard key={a.callId} sessionId={sessionId!} ask={a}/>)}
|
||||
{items.map(({ messageId, tool }) => <ApprovalCard key={tool.id} messageId={messageId} tool={tool}/>)}
|
||||
</section>;
|
||||
}
|
||||
@@ -17,7 +17,8 @@ export function ArtifactCard({ artifact: a, onPreview }: {
|
||||
setMenu(false);
|
||||
setError('');
|
||||
if (!canReveal && url && action === 'open') {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
// The app window has no handler for a new window, so window.open does nothing there: the host opens the address in the default browser (#4721).
|
||||
api.post('/api/artifacts/open', { action: 'open', url }).catch(() => { window.open(url, '_blank', 'noopener,noreferrer'); });
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PreviewControls } from './PreviewControls';
|
||||
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Dialog, Icon, IconBtn, Seg, type IconName } from './ui';
|
||||
import { CopyBtn, CopyMenuItem } from './CopyBtn';
|
||||
import { Markdown } from './Markdown';
|
||||
import { SyntaxCode } from './SyntaxCode';
|
||||
import { FloatingMenu } from './FloatingMenu';
|
||||
@@ -61,15 +62,28 @@ export function ArtifactPanel({ items, selected, onSelect, onClose, onCloseTab,
|
||||
});
|
||||
return () => abort.abort();
|
||||
}, [current?.id, revision]);
|
||||
// A closed panel leaves with a short slide instead of vanishing (a bare shell; the page itself is already gone).
|
||||
const hadCurrent = useRef(false);
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
useEffect(() => {
|
||||
if (current) { hadCurrent.current = true; setLeaving(false); return; }
|
||||
if (!hadCurrent.current || !inline) return;
|
||||
hadCurrent.current = false;
|
||||
if (matchMedia('(prefers-reduced-motion: reduce)').matches || document.documentElement.dataset.motion === 'reduced') return;
|
||||
setLeaving(true);
|
||||
// The leaving shell takes the 460 ms step of the motion scale (liquid.css); it is removed just after.
|
||||
const timer = setTimeout(() => setLeaving(false), 520);
|
||||
return () => clearTimeout(timer);
|
||||
}, [!!current, inline]);
|
||||
if (!current)
|
||||
return null;
|
||||
return leaving ? <aside className="artifact-panel" data-leaving="true" aria-hidden="true"/> : null;
|
||||
const url = current.url ? safePreviewUrl(current.url) : null;
|
||||
const viewControls = current.kind === 'markdown' ? <Seg ariaLabel={tr("Markdown view")} value={mode} options={[{ value: 'preview', label: 'Read' }, { value: 'source', label: 'Source' }]} onChange={setMode}/> : current.kind === 'image' ? <PreviewControls label={tr("Image")} zoom={imageSize} onZoom={setImageSize}/> : null;
|
||||
const actions = <><div className="preview-file-actions__inline">{tr(current.kind === 'code' ? <IconBtn name="copy" title={tr("Copy code")} disabled={loading || !!error} onClick={() => void navigator.clipboard.writeText(text)}/> : null)}{tr(url ? <a className="st-btn st-btn--icon" href={url} target="_blank" rel="noreferrer" aria-label={tr("Open outside preview")} title={tr("Open outside preview")}><Icon name="link" size={17}/></a> : null)}<IconBtn name="refresh" title={tr("Reload preview")} onClick={() => setRevision(v => v + 1)}/></div><button ref={fileMenuRef} className="st-btn st-btn--icon preview-file-actions__more" aria-label={tr("Preview file actions")} title={tr("Preview file actions")} aria-haspopup="menu" aria-expanded={fileMenu} onClick={() => setFileMenu(v => !v)}><Icon name="ellipsis"/></button></>;
|
||||
const actionMenu = fileMenu ? <FloatingMenu anchor={fileMenuRef.current} title={tr("Preview file actions")} onClose={() => setFileMenu(false)}>{tr(current.kind === 'code' ? <button role="menuitem" className="menu__item" disabled={loading || !!error} onClick={() => { void navigator.clipboard.writeText(text); setFileMenu(false); }}><Icon name="copy"/>{tr("Copy code")}</button> : null)}{tr(url ? <a role="menuitem" className="menu__item" href={url} target="_blank" rel="noreferrer" onClick={() => setFileMenu(false)}><Icon name="link"/>{tr("Open outside preview")}</a> : null)}<button role="menuitem" className="menu__item" onClick={() => { setRevision(v => v + 1); setFileMenu(false); }}><Icon name="refresh"/>{tr("Reload preview")}</button></FloatingMenu> : null;
|
||||
const actions = <><div className="preview-file-actions__inline">{tr(current.kind === 'code' ? <CopyBtn text={text} label="Code" title="Copy code" disabled={loading || !!error}/> : null)}{tr(url ? <a className="st-btn st-btn--icon" href={url} target="_blank" rel="noreferrer" aria-label={tr("Open outside preview")} title={tr("Open outside preview")}><Icon name="link" size={17}/></a> : null)}<IconBtn name="refresh" title={tr("Reload preview")} onClick={() => setRevision(v => v + 1)}/></div><button ref={fileMenuRef} className="st-btn st-btn--icon preview-file-actions__more" aria-label={tr("Preview file actions")} title={tr("Preview file actions")} aria-haspopup="menu" aria-expanded={fileMenu} onClick={() => setFileMenu(v => !v)}><Icon name="ellipsis"/></button></>;
|
||||
const actionMenu = fileMenu ? <FloatingMenu anchor={fileMenuRef.current} title={tr("Preview file actions")} onClose={() => setFileMenu(false)}>{tr(current.kind === 'code' ? <CopyMenuItem text={text} label="Code" disabled={loading || !!error} onClose={() => setFileMenu(false)}>Copy code</CopyMenuItem> : null)}{tr(url ? <a role="menuitem" className="menu__item" href={url} target="_blank" rel="noreferrer" onClick={() => setFileMenu(false)}><Icon name="link"/>{tr("Open outside preview")}</a> : null)}<button role="menuitem" className="menu__item" onClick={() => { setRevision(v => v + 1); setFileMenu(false); }}><Icon name="refresh"/>{tr("Reload preview")}</button></FloatingMenu> : null;
|
||||
const content = <aside className="artifact-panel" aria-label={tr("{0} preview", [current.kind])}>
|
||||
{tr(!inline ? <ArtifactTabs onCloseTab={onCloseTab} items={items} selected={selected} onSelect={onSelect}/> : null)}
|
||||
<div id="artifact-content" role="tabpanel" aria-labelledby={`artifact-tab-${items.findIndex(a => a.id === selected)}`} className="artifact-content">
|
||||
<div key={current.id} id="artifact-content" role="tabpanel" aria-labelledby={`artifact-tab-${items.findIndex(a => a.id === selected)}`} className="artifact-content">
|
||||
{tr(current.kind === 'website' ? <><iframe key={`${current.id}-${revision}`} title={current.title} src={current.content ? undefined : url ?? undefined} srcDoc={current.content} sandbox="allow-scripts" referrerPolicy="no-referrer"/></> : current.kind === 'pdf' ? url ? <Suspense fallback={<p className="muted preview-placeholder">{tr("Opening PDF viewer\u2026")}</p>}><PdfPreview key={`${current.id}-${revision}`} url={url}/></Suspense> : <p>{tr("No PDF to display.")}</p> : current.kind === 'image' ? <div className="image-preview" data-size={imageSize === '1' ? 'actual' : 'fit'}>{tr(url ? <img key={`${current.id}-${revision}`} src={url} alt={current.title} onError={() => setError('Couldn’t load this image.')}/> : null)}{tr(error ? <p role="alert">{tr(error)}</p> : null)}</div> : loading ? <p className="muted preview-placeholder">{tr("Loading document\u2026")}</p> : error ? <p role="alert" className="inline-error preview-placeholder">{tr(error)}</p> : current.kind === 'code' ? <SyntaxCode text={text} filename={artifactFilename(current)} lineNumbers/> : current.kind === 'table' ? <TablePreview key={current.id} text={text}/> : mode === 'source' ? <SyntaxCode text={text} language="markdown" lineNumbers/> : <article className="document-markdown"><Markdown text={text}/></article>)}
|
||||
</div>
|
||||
{tr(viewControls ? <div className="artifact-view-controls">{tr(viewControls)}</div> : null)}
|
||||
|
||||
@@ -20,7 +20,7 @@ function NativeChatModelPicker() {
|
||||
const [open, setOpen] = useState(false), [error, setError] = useState('');
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const current = native?.instances.find(i => i.id === native.defaultId);
|
||||
return <><button className="chat-model-badge chat-model-picker" ref={ref} aria-label={tr("Choose model: {0}", [current?.label ?? 'Native model'])} aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(v => !v)}><Icon name="models" size={16}/><span>{tr(current?.label ?? 'Native model')}</span><Icon name="chevron" size={12}/></button>{tr(open ? <FloatingMenu anchor={ref.current} title={tr("Chat model")} width={340} onClose={() => setOpen(false)}>{tr(native?.instances.filter(i => i.status !== 'error').map(i => <button className="menu__item" role="menuitemradio" aria-checked={current?.id === i.id} disabled={streaming || i.status !== 'ready'} key={i.id} onClick={() => void nativeAction(`instances/${i.id}/default`).then(() => setOpen(false)).catch(e => setError(e.message))}><Icon name="models"/><span><strong>{tr(i.label)}</strong><small>{tr(i.gpus.map(g => `GPU ${g + 1}`).join(" + ") || "Automatic GPU")}</small></span></button>))}{tr(providers?.connections.flatMap(c => c.models.map(m => <button className="menu__item" role="menuitem" disabled={streaming} key={`${c.id}-${m.id}`} onClick={() => { selectRemoteModel({ connectionId: c.id, modelId: m.id }); setOpen(false); }}><Icon name="link"/>{m.name} · {c.name}</button>)))}<button className="menu__item" role="menuitem" onClick={() => { setOpen(false); go('models'); }}>{tr("Manage models")}</button>{tr(error ? <p className="small inline-error">{tr(error)}</p> : null)}</FloatingMenu> : null)}</>;
|
||||
return <><button className="chat-model-badge chat-model-picker" ref={ref} aria-label={tr("Choose model: {0}", [current?.label ?? 'Native model'])} aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(v => !v)}><Icon name="models" size={16}/><span>{tr(current?.label ?? 'Native model')}</span><Icon name="chevron" size={12}/></button>{tr(open ? <FloatingMenu anchor={ref.current} title={tr("Chat model")} width={340} onClose={() => setOpen(false)}>{tr(native?.instances.filter(i => i.status !== 'error').map(i => <button className="menu__item" role="menuitemradio" aria-checked={current?.id === i.id} disabled={streaming || i.status !== 'ready'} key={i.id} onClick={() => void nativeAction(`instances/${i.id}/default`).then(() => setOpen(false)).catch(e => setError(e.message))}><Icon name="models"/><span><strong>{tr(i.label)}</strong><small>{tr(i.gpus.map(g => `GPU ${g + 1}`).join(" + ") || "Automatic GPU")}</small></span>{tr(current?.id === i.id ? <Icon name="check" size={16} className="menu__check"/> : null)}</button>))}{tr(providers?.connections.flatMap(c => c.models.map(m => <button className="menu__item" role="menuitem" disabled={streaming} key={`${c.id}-${m.id}`} onClick={() => { selectRemoteModel({ connectionId: c.id, modelId: m.id }); setOpen(false); }}><Icon name="link"/>{m.name} · {c.name}</button>)))}<button className="menu__item" role="menuitem" onClick={() => { setOpen(false); go('models'); }}>{tr("Manage models")}</button>{tr(error ? <p className="small inline-error">{tr(error)}</p> : null)}</FloatingMenu> : null)}</>;
|
||||
}
|
||||
function LegacyChatModelPicker() {
|
||||
const native = useNative();
|
||||
|
||||
@@ -39,7 +39,7 @@ export function ChatToolbar({ session, items, selected, onSelect, onClose, onClo
|
||||
return <header className="chat-toolbar" style={previews && !expanded && previewWidth ? { gridTemplateColumns: `minmax(0,1fr) ${previewWidth}px` } : undefined} data-timeskip={selected?.startsWith('time-skip:') || undefined} data-preview={previews || undefined} data-expanded={expanded && previews || undefined}>
|
||||
<div className="chat-toolbar__conversation"><h1 title={title}>{title}</h1><ChatModelPicker /><button ref={agentsRef} className="chat-agents" title={tr(count ? `${count} subagents running · View overview` : 'View subagent overview')} aria-label={tr(count ? `${count} subagents running — show overview` : 'Subagents — show overview')} aria-haspopup="dialog" aria-expanded={agents} data-working={count > 0 || undefined} onClick={() => setAgents(v => !v)}><Icon name="experts" size={17}/>{tr(count > 0 ? <span className="agent-live-dot"/> : null)}<span className="chat-agents__label">{tr("Subagents")}</span>{tr(session?.agents?.length ? <span className="chat-agents__count">{tr(count || session.agents.length)}</span> : null)}<Icon name="chevron" className={agents ? 'is-open' : ''} size={13}/></button><span className="spacer"/><button ref={menuRef} className="st-btn st-btn--icon" title={tr("Chat options")} aria-label={tr("Chat options")} aria-haspopup="menu" aria-expanded={menu} onClick={() => setMenu(v => !v)}><Icon name="ellipsis"/></button>{tr(!selected && (items.length || extraTabs.length) ? <IconBtn name="panel" title={tr("Open previews")} onClick={() => onSelect(extraTabs.at(-1)?.id ?? items[0].id)}/> : null)}</div>
|
||||
{tr(previews ? <div className="chat-toolbar__previews"><ArtifactTabs extraTabs={extraTabs} onCloseTab={onCloseTab} items={items} selected={selected} onSelect={onSelect}/><div className="preview-window-actions"><div id="preview-file-actions" className="preview-file-actions"/><IconBtn name={expanded ? 'contract' : 'expand'} title={tr(expanded ? 'Exit full screen preview' : 'Full screen preview')} onClick={onExpand}/><IconBtn name="panel" title={tr("Collapse preview sidebar")} onClick={onClose}/></div></div> : null)}
|
||||
{tr(agents ? <Popover anchor={agentsRef.current} title={tr("Subagents")} detail={session?.agents?.length ? <>{tr(count)}{tr(" running \u00B7 ")}{tr(session.agents.filter(a => a.status === 'completed').length)}{tr(" completed \u00B7 ")}{tr(session.agents.length)}{tr(" total")}</> : undefined} width={460} onClose={() => setAgents(false)}>{tr(session?.agents?.length ? <>{tr(session.agents.map(a => <SubagentRow key={a.id} agent={a} now={now}/>))}</> : <div className="subagents-empty"><Icon name="experts" size={24}/><strong>{tr("No subagents recorded")}</strong><p className="small muted">{tr("Subagents that run in this conversation appear here, with their progress, token usage and elapsed time. Completed runs stay visible.")}</p></div>)}</Popover> : null)}
|
||||
{tr(agents ? <Popover anchor={agentsRef.current} title={tr(session?.agents?.length ? "Subagents" : "No subagents recorded")} detail={session?.agents?.length ? <>{tr(count)}{tr(" running \u00B7 ")}{tr(session.agents.filter(a => a.status === 'completed').length)}{tr(" completed \u00B7 ")}{tr(session.agents.length)}{tr(" total")}</> : undefined} width={460} onClose={() => setAgents(false)}>{tr(session?.agents?.length ? <>{tr(session.agents.map(a => <SubagentRow key={a.id} agent={a} now={now} sessionId={session && !session.showcase ? session.id : undefined}/>))}</> : null)}</Popover> : null)}
|
||||
{tr(menu ? <FloatingMenu anchor={menuRef.current} title={tr("Chat options")} onClose={() => setMenu(false)}><button role="menuitem" className="menu__item" disabled={!!remote} title={tr(remote ? "API models use their provider’s response settings." : undefined)} onClick={() => { setMenu(false); setSettings(true); }}><Icon name="sliders"/>{tr("Response settings")}</button><button role="menuitem" className="menu__item" disabled={!session} onClick={() => { if (session)
|
||||
downloadSessionLog(session); setMenu(false); }}><Icon name="download"/>{tr("Download session log")}</button></FloatingMenu> : null)}
|
||||
<SamplingDrawer open={settings} onClose={() => setSettings(false)}/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { tr } from '../lib/i18n';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Btn, ErrorNotice, Dialog, Icon, IconBtn } from './ui';
|
||||
import { Btn, ErrorNotice, Dialog, Icon } from './ui';
|
||||
import { CopyBtn } from './CopyBtn';
|
||||
import { api } from '../lib/client';
|
||||
import { setCloudflare, useStore } from '../lib/store';
|
||||
import type { TunnelState } from '../lib/types';
|
||||
@@ -84,7 +85,7 @@ export function CloudflareDialog({ open, onClose, onCreateKey }: {
|
||||
</>}>
|
||||
<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)}
|
||||
{tr(active ? <section className="tunnel-stage tunnel-stage--status" key={tunnel?.status} aria-live="polite"><span className="tunnel-stage__symbol"><Icon name={tunnel?.status === 'connected' ? 'check' : 'clock'} size={26}/></span><h3 className="h3">{tr(tunnel?.status === 'connected' ? 'You’re connected' : tunnel?.status === 'stopping' ? 'Stopping the tunnel…' : 'Connecting to Cloudflare…')}</h3>{tr(tunnel?.url ? <><p className="small muted">{tr("Use this address and your Strata API key in your other app.")}</p><div className="install-command"><code>{tunnel.url}</code><IconBtn name="copy" title={tr("Copy tunnel URL")} onClick={() => void navigator.clipboard.writeText(tunnel.url!)}/></div><p className="xs muted">{tr(tunnel.mode === 'quick' ? 'Temporary address · turn off streaming in your client.' : `Your Cloudflare route must point to ${tunnel.originUrl}.`)}</p></> : <p className="small muted">{tr("This usually takes a few seconds. You can close this window while it connects.")}</p>)}</section> : <section className="tunnel-stage" key={step} aria-labelledby="tunnel-step-title">
|
||||
{tr(active ? <section className="tunnel-stage tunnel-stage--status" key={tunnel?.status} aria-live="polite"><span className="tunnel-stage__symbol"><Icon name={tunnel?.status === 'connected' ? 'check' : 'clock'} size={26}/></span><h3 className="h3">{tr(tunnel?.status === 'connected' ? 'You’re connected' : tunnel?.status === 'stopping' ? 'Stopping the tunnel…' : 'Connecting to Cloudflare…')}</h3>{tr(tunnel?.url ? <><p className="small muted">{tr("Use this address and your Strata API key in your other app.")}</p><div className="install-command"><code>{tunnel.url}</code><CopyBtn text={tunnel.url!} label="Tunnel URL" title="Copy tunnel URL"/></div><p className="xs muted">{tr(tunnel.mode === 'quick' ? 'Temporary address · turn off streaming in your client.' : `Your Cloudflare route must point to ${tunnel.originUrl}.`)}</p></> : <p className="small muted">{tr("This usually takes a few seconds. You can close this window while it connects.")}</p>)}</section> : <section className="tunnel-stage" key={step} aria-labelledby="tunnel-step-title">
|
||||
<h3 ref={heading} id="tunnel-step-title" className="h3" tabIndex={-1}>{tr(titles[step])}</h3>
|
||||
{tr(step === 0 ? <><p className="small muted">{tr("Give your local API an HTTPS address that another app can reach.")}</p><div className="tunnel-choices" role="radiogroup" aria-label={tr("Cloudflare connection type")}>{tr([{ value: 'quick' as const, title: 'Free temporary URL', detail: 'No account or domain needed. Good for a quick connection.', icon: 'bolt' as const }, { value: 'domain' as const, title: 'My own domain', detail: 'A stable address on a domain you manage with Cloudflare.', icon: 'link' as const }].map(choice => <button key={choice.value} role="radio" aria-checked={mode === choice.value} tabIndex={mode === choice.value ? 0 : -1} onKeyDown={e => { if (['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
@@ -93,7 +94,7 @@ export function CloudflareDialog({ open, onClose, onCreateKey }: {
|
||||
} }} 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(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(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><CopyBtn text={tunnel?.originUrl ?? ''} label="Tunnel service URL" title="Copy tunnel service URL" disabled={!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(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)}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { tr } from '../lib/i18n';
|
||||
import { cx } from '../lib/format';
|
||||
import { Icon } from './ui';
|
||||
import { useSelectedPreviewId } from '../lib/code-preview';
|
||||
import type { PreviewArtifact } from '../lib/artifacts';
|
||||
|
||||
/** The preview control of a code block: an icon button like Copy, on its left. It shows both states: closed, and open (filled, "Close preview"). */
|
||||
export function CodePreviewBtn({ artifact, onPreview, onClose }: { artifact: PreviewArtifact; onPreview: (a: PreviewArtifact) => void; onClose?: (id: string) => void }) {
|
||||
const open = useSelectedPreviewId() === artifact.id;
|
||||
const label = tr(open ? 'Close preview' : 'Open preview');
|
||||
return <button type="button" className={cx('st-btn st-btn--icon', open && 'is-open')} data-open={open || undefined} aria-pressed={open} aria-label={label} title={label}
|
||||
onClick={() => (open && onClose ? onClose(artifact.id) : onPreview(artifact))}>
|
||||
<Icon name="panel" size={19}/>
|
||||
</button>;
|
||||
}
|
||||
@@ -45,6 +45,25 @@ export function ComposerControls({ session, onPreview, onAttach, onError, onSlas
|
||||
const remote = useStore(s => s.remoteModel), providers = useStore(s => s.providers);
|
||||
const apiModel = providers?.connections.find(c => c.id === remote?.connectionId)?.models.find(m => m.id === remote?.modelId);
|
||||
const [menu, setMenu] = useState(false), [preferences, setPreferences] = useState<'mode' | 'thinking' | null>(null), [reading, setReading] = useState(false);
|
||||
|
||||
// The mode menu prints 1–3 beside its rows. They were decoration: make those
|
||||
// keys pick the mode while the menu is open, and leave modified keys to the app.
|
||||
useEffect(() => {
|
||||
if (preferences !== 'mode')
|
||||
return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey)
|
||||
return;
|
||||
const option = modes[Number(event.key) - 1];
|
||||
if (!option)
|
||||
return;
|
||||
event.preventDefault();
|
||||
setChatPreferences(session?.id, { mode: option.value });
|
||||
setPreferences(null);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [preferences, session?.id]);
|
||||
const [urlOpen, setUrlOpen] = useState(false), [url, setUrl] = useState(''), [error, setError] = useState('');
|
||||
const files = useRef<HTMLInputElement>(null), folder = useRef<HTMLInputElement>(null), previewFile = useRef<HTMLInputElement>(null);
|
||||
const trigger = useRef<HTMLButtonElement>(null), modeTrigger = useRef<HTMLButtonElement>(null), thinkingTrigger = useRef<HTMLButtonElement>(null);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/** The Copy controls of the app, with one shared behaviour: the control itself shows that the copy worked (#4748). */
|
||||
import { createContext, useContext } from 'react';
|
||||
import { tr } from '../lib/i18n';
|
||||
import { cx } from '../lib/format';
|
||||
import { COPIED_MENU_MS, useCopy } from '../lib/use-copy';
|
||||
import { Icon } from './ui';
|
||||
|
||||
type Source = string | (() => string | Promise<string>);
|
||||
|
||||
/** A round icon button that copies `text`: the icon turns into a check mark for a moment, and a screen reader hears "Copied". */
|
||||
export function CopyBtn({ text, label, title, disabled, size = 19 }: { text: Source; label: string; title: string; disabled?: boolean; size?: number }) {
|
||||
const { copied, copy } = useCopy();
|
||||
return <button type="button" className={cx('st-btn st-btn--icon', copied && 'is-copied')} data-copied={copied || undefined} title={tr(copied ? 'Copied' : title)} aria-label={tr(title)} disabled={disabled} onClick={() => void copy(text, label)}>
|
||||
<Icon name={copied ? 'check' : 'copy'} size={size}/>
|
||||
<span className="sr-only" role="status">{copied ? tr('Copied') : ''}</span>
|
||||
</button>;
|
||||
}
|
||||
|
||||
/** What closes the menu a menu item is in; Chat's message menu provides it. */
|
||||
export const MenuCloseContext = createContext<() => void>(() => {});
|
||||
|
||||
/** A menu item that copies `text`: it says "Copied" for a moment, and then the menu closes. */
|
||||
export function CopyMenuItem({ text, label, children, disabled, onClose }: { text: Source; label: string; children: string; disabled?: boolean; onClose?: () => void }) {
|
||||
const { copied, copy } = useCopy(COPIED_MENU_MS);
|
||||
const close = useContext(MenuCloseContext);
|
||||
return <button role="menuitem" className="menu__item" data-copied={copied || undefined} disabled={disabled} onClick={e => {
|
||||
// The menu would close on this click before the person sees that it worked.
|
||||
e.stopPropagation();
|
||||
void copy(text, label).then(ok => { setTimeout(onClose ?? close, ok ? COPIED_MENU_MS : 0); });
|
||||
}}><Icon name={copied ? 'check' : 'copy'}/>{tr(copied ? 'Copied' : children)}</button>;
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { tr } from '../lib/i18n';
|
||||
import { fileAction, fileSize, fileTypeLabel, previewOf, rawUrlOf, type ActivityGroup, type Deliverable } from '../lib/deliverables';
|
||||
import { deliveryFolder, dirOf, fileAction, fileSize, fileTypeLabel, previewOf, rawUrlOf, rootOf, type ActivityGroup, type Deliverable } from '../lib/deliverables';
|
||||
import { useChatFolder } from '../lib/chat-folder';
|
||||
import type { PreviewArtifact } from '../lib/artifacts';
|
||||
import { draftSize, groupFromDraft } from '../lib/draft-view';
|
||||
import type { ToolDraft } from '../lib/types';
|
||||
import { pushToast } from '../lib/store';
|
||||
import { plainFailureText } from '../lib/plain-errors';
|
||||
import { FloatingMenu } from './FloatingMenu';
|
||||
import { CopyMenuItem } from './CopyBtn';
|
||||
import { Icon } from './ui';
|
||||
|
||||
const problem = (title: string, e: unknown) => pushToast('error', tr(title), plainFailureText(e instanceof Error ? e.message : String(e)).message);
|
||||
@@ -36,7 +40,7 @@ export function FileCard({ file, sessionId, onPreview }: { file: Deliverable; se
|
||||
{tr(menu ? <FloatingMenu anchor={more.current} title={tr("File actions")} width={220} onClose={() => setMenu(false)}>
|
||||
<button role="menuitem" className="menu__item" onClick={() => { setMenu(false); void showOf(file, sessionId, onPreview, file.kind); }}><Icon name="panel"/>{tr("Open in sidebar")}</button>
|
||||
<button role="menuitem" className="menu__item" onClick={() => void run(() => fileAction.open(sessionId, file.path, 'folder'), 'The folder was not opened')}><Icon name="folder"/>{tr("Show in folder")}</button>
|
||||
<button role="menuitem" className="menu__item" onClick={() => void run(async () => { await navigator.clipboard.writeText(await fileAction.fullPath(sessionId, file.path)); pushToast('info', tr('Path copied')); }, 'The path was not copied')}><Icon name="copy"/>{tr("Copy path")}</button>
|
||||
<CopyMenuItem text={() => fileAction.fullPath(sessionId, file.path)} label="Path" onClose={() => setMenu(false)}>Copy path</CopyMenuItem>
|
||||
<button role="menuitem" className="menu__item" onClick={() => void run(async () => { await fileAction.attach(sessionId, file.path); pushToast('info', tr('Attached to your next message'), file.name); }, 'The file was not attached')}><Icon name="attach"/>{tr("Attach as context")}</button>
|
||||
</FloatingMenu> : null)}
|
||||
</div>;
|
||||
@@ -56,6 +60,35 @@ export function EditedFiles({ files, sessionId, onPreview }: { files: Deliverabl
|
||||
</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A run that made many files (379 after one stopped agent run) as one compact block instead of a card per file: where the result was delivered, the folder the
|
||||
* chat works in, and the count ("379 files") that opens the full list. The list is closed by default, scrolls inside the block, and every row is a button.
|
||||
*/
|
||||
export function FilesSummary({ files, answer, sessionId, onPreview }: { files: Deliverable[]; answer: string; sessionId: string; onPreview: (a: PreviewArtifact) => void }) {
|
||||
const [open, setOpen] = useState(false), id = useId(), folder = useChatFolder(sessionId);
|
||||
const delivery = deliveryFolder(files, answer);
|
||||
const rootPath = folder?.path ?? rootOf(files[0]), rootName = folder?.name ?? (rootPath.split(/[\\/]/).filter(Boolean).at(-1) ?? '');
|
||||
const added = files.reduce((n, f) => n + f.added, 0), removed = files.reduce((n, f) => n + f.removed, 0);
|
||||
const openFolder = (path: string) => void fileAction.open(sessionId, path, 'app').catch(err => problem('The folder was not opened', err));
|
||||
const places: { key: string; label: string; name: string; full: string; open: string }[] = [];
|
||||
if (delivery) places.push({ key: 'delivered', label: 'Delivered to', name: delivery, full: rootPath ? `${rootPath}/${delivery}` : delivery, open: delivery });
|
||||
if (rootPath) places.push({ key: 'working', label: delivery ? 'Working folder' : 'Delivered to', name: rootName, full: rootPath, open: rootPath });
|
||||
return <section className="fsum" aria-label={tr("Files")}>
|
||||
{places.length ? <ul className="fsum__places">{places.map(p => <li key={p.key}>
|
||||
<Icon name="folder" size={16}/><span className="fsum__label">{tr(p.label)}</span><span className="fsum__name" title={p.full}>{p.name}</span>
|
||||
<button type="button" className="fsum__open" aria-label={tr("Open {0}", [p.name])} onClick={() => openFolder(p.open)}>{tr("Open")}</button>
|
||||
</li>)}</ul> : null}
|
||||
<button type="button" className="fsum__toggle" aria-expanded={open} aria-controls={id} onClick={() => setOpen(v => !v)}>
|
||||
<Icon name="chevron" size={14} className="fsum__chev"/><span>{tr('{0} files', [files.length.toLocaleString()])}</span>{added || removed ? <Plusminus added={added} removed={removed}/> : null}
|
||||
</button>
|
||||
{open ? <ul className="fsum__list" id={id}>{files.map(f => { const dir = dirOf(f.path); return <li key={f.path}>
|
||||
<button type="button" className="fsum__file" title={f.path} onClick={() => void showOf(f, sessionId, onPreview, f.kind)}>
|
||||
<span className="fsum__type">{fileTypeLabel(f)}</span><span className="fsum__fname">{f.name}</span>{dir ? <span className="fsum__dir">{dir}</span> : null}<span className="fsum__size">{fileSize(f.size)}</span>
|
||||
</button>
|
||||
</li>; })}</ul> : null}
|
||||
</section>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The saved skills an answer used, as one quiet line under the answer in the same style as the activity line ("Ran 2 commands, read a file"):
|
||||
* "Used 2 skills: Weekly report, Brand voice"; opened, each skill with its one-line description.
|
||||
@@ -79,13 +112,45 @@ export function ActivityLine({ group, sessionId, onPreview }: { group: ActivityG
|
||||
const picture = group.files.find(f => f.kind === 'image');
|
||||
// The host's sentence may end with the numbers ("… +225 −0"): they are drawn as figures instead.
|
||||
const sentence = group.line.replace(/\s*\+\d+\s*[−-]\s*\d+\s*$/, '');
|
||||
return <div className="act" data-open={open || undefined}>
|
||||
// While the run is open this is the live step: the host's own words for it when it sends them, else the line it already had.
|
||||
const live = !group.closed;
|
||||
return <><div className="act" data-open={open || undefined} data-live={live || undefined}>
|
||||
<button type="button" className="act__line" aria-expanded={open} disabled={!group.files.length} onClick={() => setOpen(v => !v)}>
|
||||
<span>{sentence}</span>{group.added || group.removed ? <Plusminus added={group.added} removed={group.removed}/> : null}
|
||||
<span>{live && group.live?.label ? group.live.label : sentence}</span>{live && group.live?.bytes ? <span className="act__meta">{draftSize(group.live.bytes)}{group.live.lines && group.live.lines > 1 ? <> {"\u00B7"} {tr("{0} lines", [group.live.lines])}</> : null}</span> : null}{group.added || group.removed ? <Plusminus added={group.added} removed={group.removed}/> : null}
|
||||
{group.files.length ? <Icon name="chevron" size={14} className="act__chev"/> : null}
|
||||
{!group.closed ? <span className="act__live" aria-hidden="true"/> : null}
|
||||
</button>
|
||||
{picture ? <img className="act__thumb" src={rawUrlOf(sessionId, picture.path)} alt="" loading="lazy" onClick={() => void showOf(picture, sessionId, onPreview)}/> : null}
|
||||
{open ? <ul className="act__files">{group.files.map(f => <li key={f.path}><button type="button" onClick={() => void showOf(f, sessionId, onPreview, f.kind)}><Icon name="file" size={14}/>{f.name}{f.added || f.removed ? <Plusminus added={f.added} removed={f.removed}/> : null}</button></li>)}</ul> : null}
|
||||
</div>;
|
||||
</div>
|
||||
{live && group.live?.output ? <p className="act__output" title={group.live.output}>{group.live.output}</p> : null}
|
||||
{live && group.live?.tail !== undefined ? <div className="act__tail" aria-hidden="true">{group.live.tail.split('\n').filter(Boolean).map((line, i) => <span key={i}>{line}</span>)}</div> : null}
|
||||
</>;
|
||||
}
|
||||
|
||||
/** The value, at most every `ms` (a file arrives about five times a second; the eye needs two). A cleared value is shown at once. */
|
||||
function useThrottled<T>(value: T, ms: number, cleared: (v: T) => boolean): T {
|
||||
const [shown, setShown] = useState(value), last = useRef(0), timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
clearTimeout(timer.current);
|
||||
if (cleared(value)) { setShown(value); return; }
|
||||
const wait = Math.max(0, last.current + ms - Date.now());
|
||||
timer.current = setTimeout(() => { last.current = Date.now(); setShown(value); }, wait);
|
||||
return () => clearTimeout(timer.current);
|
||||
}, [value, ms]);
|
||||
return shown;
|
||||
}
|
||||
|
||||
/**
|
||||
* The files (or commands) the model is writing right now, from `message.drafts`: each is one open activity line with the file's name, its growing size
|
||||
* and the last lines of its text. It draws nothing while there is nothing being written, and takes the place of the ordinary card when the call is whole.
|
||||
*/
|
||||
export function DraftLines({ drafts, sessionId, onPreview }: { drafts: ToolDraft[] | undefined; sessionId: string; onPreview: (a: PreviewArtifact) => void }) {
|
||||
const shown = useThrottled(drafts, 350, v => !v?.length);
|
||||
if (!drafts?.length || !shown?.length) return null;
|
||||
return <>{shown.map(d => {
|
||||
const file = d.target ? d.target.split(/[\\/]/).filter(Boolean).at(-1) : '';
|
||||
const verb = tr(d.verb ?? (d.scope === 'shell' ? 'Running a command' : 'Writing a file'));
|
||||
return <ActivityLine key={d.id} group={groupFromDraft(d, file ? `${verb} · ${file}` : verb)} sessionId={sessionId} onPreview={onPreview}/>;
|
||||
})}</>;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { api } from '../lib/client';
|
||||
import { useDownloadEta } from '../lib/download-eta';
|
||||
import type { NativeJob } from '../lib/native';
|
||||
import { megabytes } from '../lib/format';
|
||||
import { FirstRunLanguage } from './FirstRunLanguage';
|
||||
|
||||
/** What the host says about this PC's engine (GET /api/engine/first-run). */
|
||||
export interface FirstRunInfo {
|
||||
@@ -87,7 +88,7 @@ export function EngineFirstRun({ onDone }: { onDone: () => void }) {
|
||||
setProblem('');
|
||||
try {
|
||||
started.current = true;
|
||||
setJob(await api.post<NativeJob>('/api/native/operations', { kind: 'update-engine', args: {} }));
|
||||
setJob(await api.post<NativeJob>('/api/native/operations', { kind: 'update-engine', args: { firstRun: true } }));
|
||||
}
|
||||
catch (e) {
|
||||
started.current = false;
|
||||
@@ -165,9 +166,11 @@ export function EngineFirstRun({ onDone }: { onDone: () => void }) {
|
||||
actions = <><Btn variant="primary" disabled={busy} onClick={look}>{tr("Try again")}</Btn>{pick("Choose a downloaded file…")}<button type="button" className="onboard-continue-anyway" onClick={onDone}>{tr("Continue without an engine")}</button></>;
|
||||
}
|
||||
body = <><div className="onboard-welcome-copy"><h1 tabIndex={-1} data-initial-focus>{tr("Welcome to Strata")}</h1>{main}</div>
|
||||
{failed ? <div className="onboard-error" style={{ maxWidth: 560, margin: '0 auto' }}><p role="alert" className="small inline-error">{job?.error ?? job?.summary}</p><p className="small muted">{tr("The download stopped. Continue picks it up where it stopped.")}</p></div> : null}
|
||||
{failed ? <div className="onboard-error" style={{ maxWidth: 560, margin: '0 auto' }}><p role="alert" className="small inline-error">{job?.error ?? job?.summary}</p>
|
||||
{job?.details ? <details className="error-detail"><summary className="small">{tr('Details')}</summary><pre className="small muted">{job.details}</pre></details> : null}
|
||||
<p className="small muted">{percent >= 100 ? tr("The file was downloaded. Continue tries the install again.") : tr("The download stopped. Continue picks it up where it stopped.")}</p></div> : null}
|
||||
{problem ? <div className="onboard-error" style={{ maxWidth: 560, margin: '0 auto' }}><p role="alert" className="small inline-error">{problem}</p></div> : null}
|
||||
<footer className="onboard-footer onboard-footer--welcome">{actions}</footer></>;
|
||||
}
|
||||
return <div className="setup guided-onboarding" data-step="engine">{hidden}<header className="onboard-header"><Brand variant="lockup" height={22}/></header>{body}</div>;
|
||||
return <div className="setup guided-onboarding" data-step="engine">{hidden}<header className="onboard-header"><Brand variant="lockup" height={22}/><FirstRunLanguage/></header>{body}</div>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState } from 'react';
|
||||
import { tr } from '../lib/i18n';
|
||||
import { Btn, Icon } from './ui';
|
||||
import { nativeAction, type NativeState } from '../lib/native';
|
||||
|
||||
/** Strata Control's last check found a newer engine than the one installed (the Qwen engine, the DeepSeek engine, or both): its numbers come from the
|
||||
* check kept on this PC (`update_available` of `strata components engine`), so showing the card reads no network. After an app or Control update this is how a
|
||||
* person with an older engine is told (#4848). */
|
||||
export const engineUpdateOffered = (native: NativeState | null | undefined) => {
|
||||
const offer = native?.engine?.updateAvailable;
|
||||
return !!(offer && (offer.engine || offer.dsv41));
|
||||
};
|
||||
|
||||
/** One card, plain words, one button. The update itself is the existing "Update engine" job (its progress shows where every job's does); models are not touched. */
|
||||
export function EngineUpdateCard({ native, busy }: { native: NativeState | null | undefined; busy: boolean }) {
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
if (!engineUpdateOffered(native)) return null;
|
||||
const running = (native?.instances.length ?? 0) > 0;
|
||||
async function update() {
|
||||
setPending(true);
|
||||
setError('');
|
||||
try {
|
||||
await nativeAction('operations', { kind: 'update-engine' });
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
return <div className="engine-warning engine-update" role="status">
|
||||
<Icon name="info" size={18}/>
|
||||
<div>
|
||||
<p>{tr("A newer engine is available for this computer. Your models and settings stay as they are.")}</p>
|
||||
{running ? <small>{tr("Unload your running models first.")}</small> : null}
|
||||
{error ? <small className="inline-error" role="alert">{error}</small> : null}
|
||||
</div>
|
||||
<Btn size="sm" variant="primary" disabled={busy || pending || running} onClick={() => void update()}>{tr("Update engine")}</Btn>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { localeTag, tr } from '../lib/i18n';
|
||||
import { Bar, Btn, Dialog } from './ui';
|
||||
import { nativeAction, useNative, type NativeJob } from '../lib/native';
|
||||
|
||||
/** "A", "A and B", "A, B and C" in the language of the app. */
|
||||
const listOf = (names: string[]) => {
|
||||
try { return new Intl.ListFormat(localeTag(), { style: 'long', type: 'conjunction' }).format(names); } catch { return names.join(', '); }
|
||||
};
|
||||
|
||||
/**
|
||||
* Update engine with a model loaded (Settings > About). Strata Control does the work (`strata update --stop-model --output jsonl`, through the host); this is the
|
||||
* conversation around it, in four plain steps:
|
||||
* 1. Warn, naming the model, and ask. Nothing happens before the yes. With no model loaded the older dialog of Settings is used instead.
|
||||
* 2. Progress in words: the new engine downloads while the model keeps working; then the model stops; then the engine is updated.
|
||||
* 3. Offer to start the model again with the same settings. Never automatic.
|
||||
* 4. A failed update leaves the old engine as it was and offers to start the model again on it.
|
||||
* The dialog follows the host's state, so it also shows the offer when the update finished while Settings was closed.
|
||||
*/
|
||||
export function EngineUpdateDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const native = useNative();
|
||||
const [jobId, setJobId] = useState('');
|
||||
const [restartId, setRestartId] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const loaded = native?.instances ?? [];
|
||||
const offer = native?.stoppedForUpdate ?? null;
|
||||
const job: NativeJob | undefined = native?.jobs.find(j => j.id === jobId);
|
||||
const restart: NativeJob | undefined = native?.jobs.find(j => j.id === restartId);
|
||||
const offerKey = offer?.at ?? '';
|
||||
useEffect(() => { setHidden(false); }, [offerKey]);
|
||||
|
||||
const restarting = restart?.state === 'running';
|
||||
const updating = job?.state === 'running';
|
||||
const names = listOf(offer?.models.map(m => m.label) ?? loaded.map(i => i.label));
|
||||
const showOffer = !!offer && !hidden && !updating && !restarting;
|
||||
const failedRestart = restart?.state === 'error' && !offer ? restart : undefined;
|
||||
const ended = job?.state === 'done' || job?.state === 'error';
|
||||
const visible = open || updating || restarting || showOffer || !!failedRestart || ended;
|
||||
if (!visible) return null;
|
||||
|
||||
async function start() {
|
||||
setError('');
|
||||
try {
|
||||
const started = await nativeAction<NativeJob>('operations', { kind: 'update-engine', args: { stopModel: true } });
|
||||
setJobId(started.id);
|
||||
onClose(); // the dialog now follows the job; the caller's "open" is spent
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
async function startAgain() {
|
||||
setError('');
|
||||
try {
|
||||
const started = await nativeAction<NativeJob>('operations', { kind: 'restart-models' });
|
||||
setRestartId(started.id);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
async function notNow() {
|
||||
setHidden(true);
|
||||
try { await nativeAction('engine-restart/dismiss'); } catch { /* the offer stays hidden here either way */ }
|
||||
setJobId('');
|
||||
onClose();
|
||||
}
|
||||
function close() {
|
||||
if (showOffer) { void notNow(); return; }
|
||||
if (failedRestart) setRestartId('');
|
||||
onClose();
|
||||
}
|
||||
|
||||
const percent = Number(job?.result?.install?.download?.percent);
|
||||
const details = (j: NativeJob) => j.details ? <details className="native-settings-diagnostic"><summary className="small">{tr("Details")}</summary><p className="small muted">{j.details}</p></details> : null;
|
||||
|
||||
// 3 and 4: the model was stopped for the update.
|
||||
if (showOffer && offer) {
|
||||
const done = offer.updated;
|
||||
return <Dialog open compact title={tr(done ? 'The engine is updated' : "The engine was not updated")} onClose={close}
|
||||
actions={<><Btn onClick={() => void notNow()}>{tr("Not now")}</Btn><Btn variant="primary" onClick={() => void startAgain()}>{tr("Start again")}</Btn></>}>
|
||||
<p>{done ? tr("The engine is updated. Start {0} again with the same settings?", [names]) : tr("The update didn't finish. Your old engine is unchanged and still works. Start {0} again on it?", [names])}</p>
|
||||
{!done && job ? details(job) : null}
|
||||
{error ? <p className="small inline-error" role="alert">{tr(error)}</p> : null}
|
||||
</Dialog>;
|
||||
}
|
||||
// The model is starting again.
|
||||
if (restarting) {
|
||||
return <Dialog open compact title={tr("Starting {0} again", [names])} onClose={onClose}>
|
||||
<p role="status">{tr("Starting {0}. Loading can take a few minutes.", [names])}</p>
|
||||
<div className="engine-update-progress"><Bar value={0} indeterminate label={tr("Starting {0} again", [names])}/></div>
|
||||
</Dialog>;
|
||||
}
|
||||
if (failedRestart) {
|
||||
return <Dialog open compact title={tr("The model did not start again")} onClose={close} actions={<Btn variant="primary" onClick={close}>{tr("Close")}</Btn>}>
|
||||
<p>{tr("You can load it again from Models.")}</p>
|
||||
{failedRestart.error ? <p className="small muted">{failedRestart.error}</p> : null}
|
||||
</Dialog>;
|
||||
}
|
||||
// 2: the update is running.
|
||||
if (updating && job) {
|
||||
const step = job.step, label = listOf(job.models ?? loaded.map(i => i.label));
|
||||
return <Dialog open compact title={tr("Updating the engine")} onClose={onClose}>
|
||||
<p role="status" aria-live="polite">{step === 'stopping' ? tr("Stopping {0}. Answers in progress finish first.", [label])
|
||||
: step === 'stopped' ? tr("{0} is stopped. Updating the engine…", [label])
|
||||
: step === 'getting-ready' ? tr("Getting the new engine ready. {0} keeps working until it is ready.", [label])
|
||||
: tr(job.summary || 'Starting…')}</p>
|
||||
<div className="engine-update-progress">{step === 'getting-ready' && Number.isFinite(percent) ? <Bar value={percent} label={tr("Downloading…")}/> : <Bar value={0} indeterminate label={tr("Updating the engine")}/>}</div>
|
||||
</Dialog>;
|
||||
}
|
||||
// The update ended without stopping anything (declined, refused, or failed before the model was touched).
|
||||
if (job?.state === 'error') {
|
||||
return <Dialog open compact title={tr("The engine was not updated")} onClose={() => { setJobId(''); onClose(); }} actions={<Btn variant="primary" onClick={() => { setJobId(''); onClose(); }}>{tr("Close")}</Btn>}>
|
||||
<p>{tr(job.error ?? job.summary)}</p>
|
||||
{details(job)}
|
||||
</Dialog>;
|
||||
}
|
||||
if (job?.state === 'done') {
|
||||
return <Dialog open compact title={tr("The engine is updated")} onClose={() => { setJobId(''); onClose(); }} actions={<Btn variant="primary" onClick={() => { setJobId(''); onClose(); }}>{tr("Close")}</Btn>}>
|
||||
<p>{tr(job.summary)}</p>
|
||||
</Dialog>;
|
||||
}
|
||||
// 1: warn and ask.
|
||||
return <Dialog open compact title={tr("Update engine?")} onClose={close}
|
||||
actions={<><Btn onClick={close}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={!loaded.length} onClick={() => void start()}>{tr("Stop and update")}</Btn></>}>
|
||||
<p>{tr("To update the engine, Strata has to stop the model that is loaded: {0}.", [names])}</p>
|
||||
<p className="small muted">{tr("The new engine downloads first while the model keeps answering. Then the model stops (answers in progress finish first), the engine is updated, and you can start the model again with the same settings. Your models and settings are kept.")}</p>
|
||||
{error ? <p className="small inline-error" role="alert">{tr(error)}</p> : null}
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -9,5 +9,5 @@ export const engineMissing = (native: NativeState | null | undefined) => !!nativ
|
||||
export function EngineWarning({ native }: { native: NativeState | null | undefined }) {
|
||||
if (!engineMissing(native)) return null;
|
||||
if (native?.engine?.wrongMake && native.engine.notice) return <div className="engine-warning" role="status"><Icon name="info" size={18}/><div><p>{native.engine.notice}</p></div></div>;
|
||||
return <div className="engine-warning" role="status"><Icon name="info" size={18}/><div><p>{tr("Strata didn’t recognise an engine for this computer. The model will download, but it may not run until the right engine is installed (Settings → Engine).")}</p>{native?.engine?.notice ? <small className="muted">{native.engine.notice}</small> : null}</div></div>;
|
||||
return <div className="engine-warning" role="status"><Icon name="info" size={18}/><div><p>{tr("Strata didn’t recognise an engine for this computer. The model will download, but it may not run until the right engine is installed (Settings → About).")}</p>{native?.engine?.notice ? <small className="muted">{native.engine.notice}</small> : null}</div></div>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** The language choice on the first screen, so a person who cannot read English can change it before anything is downloaded (#3723). */
|
||||
import { tr } from '../lib/i18n';
|
||||
import { LANGUAGES, type Language } from '../lib/i18n';
|
||||
import { setLanguage, useStore } from '../lib/store';
|
||||
import { Select } from './ui';
|
||||
|
||||
export function FirstRunLanguage() {
|
||||
const language = useStore(s => s.language);
|
||||
return <div className="onboard-language"><Select translateLabels={false} ariaLabel={tr("Language")} value={language} options={[...LANGUAGES]} onChange={value => setLanguage(value as Language)}/></div>;
|
||||
}
|
||||
@@ -14,10 +14,14 @@ import type { LocalModelFolder, SetupState } from '../lib/types';
|
||||
import { downloadCounter, modelFolderLabel, onboardingError } from '../lib/onboarding-download';
|
||||
import { installProgress, installStage, stageText, useDownloadEta } from '../lib/download-eta';
|
||||
import { VersionPicker } from './VersionPicker';
|
||||
import { contextIsTight, defaultContext } from '../lib/default-context';
|
||||
import { deepseekContexts } from '../lib/deepseek-context';
|
||||
import { cacheGb } from '../lib/native-fit';
|
||||
import { FirstRunLanguage } from './FirstRunLanguage';
|
||||
import { DownloadParts } from './DownloadParts';
|
||||
import { EngineWarning } from './EngineWarning';
|
||||
import { OTHER_NOTE, PICK_NOTE, recommendedVersions } from '../lib/native-fit';
|
||||
import { nativeCatalogModels, catalogVersionLabel } from '../lib/native-catalog';
|
||||
import { nativeCatalogModels, catalogVersionLabel, startingChoice } from '../lib/native-catalog';
|
||||
type Choices = NonNullable<NonNullable<SetupState['choices']>['onboarding']>;
|
||||
function radioKeys(event: KeyboardEvent<HTMLDivElement>) { if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key))
|
||||
return; event.preventDefault(); const items = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[role=radio]')); const current = items.indexOf(event.target as HTMLButtonElement); const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : (current + (['ArrowLeft', 'ArrowUp'].includes(event.key) ? -1 : 1) + items.length) % items.length; items[next]?.focus(); items[next]?.click(); }
|
||||
@@ -36,10 +40,17 @@ export function GuidedSetup() {
|
||||
const families = nativeCatalogModels(native?.catalog?.families ?? []);
|
||||
const saved = !setup?.complete && !setup?.choices?.onboarding?.manual ? setup?.choices?.onboarding : undefined;
|
||||
const [step, setStep] = useState(saved?.manual || setup?.complete ? 1 : saved?.step ?? 1);
|
||||
const [family, setFamily] = useState(saved?.family ?? native?.catalog?.recommendation?.family ?? native?.catalog?.families?.[0]?.family ?? '');
|
||||
const initialFamily = families.find(f => f.family === family || f.sizes.some(size => size.sourceFamily === family));
|
||||
const [size, setSize] = useState(saved?.size ?? initialFamily?.sizes.find(s => s.model === native?.catalog?.recommendation?.model)?.id ?? initialFamily?.sizes[0]?.id ?? '');
|
||||
const [context, setContext] = useState(saved?.context ?? native?.catalog?.recommendation?.context ?? 32768);
|
||||
/* Only what the person (or an earlier visit) chose is state. Until then the start is worked out from the catalog on every render: a model already
|
||||
on this computer first, else the catalog's recommendation. The state used to be filled once at mount, before the catalog had arrived, and
|
||||
so fell back to the first size in the list (Q2_0, a 66 GB download) while an installed model sat beside it. */
|
||||
const [chosenFamily, setFamily] = useState(saved?.family ?? ''), [chosenSize, setSize] = useState(saved?.size ?? '');
|
||||
const start = startingChoice(families, (native?.installed?.models ?? []).filter(m => m.ready).map(m => m.model), native?.catalog?.recommendation);
|
||||
const family = chosenFamily || start?.family || native?.catalog?.recommendation?.family || families[0]?.family || '';
|
||||
const size = chosenSize || start?.size || '';
|
||||
/* 128K for every configuration unless the person picks another, on every card and for DeepSeek too (its pack's own limit allows it). The warning for a card
|
||||
it leaves little room on is under the chooser; nothing is lowered. */
|
||||
const topCardGb = Math.max(0, ...(native?.hardware?.gpus ?? []).filter(g => g.usable !== false).map(g => g.vram_gb));
|
||||
const [pickedContext, setContext] = useState<number | undefined>(saved?.context), [contextTouched, setContextTouched] = useState(false);
|
||||
const [vision, setVision] = useState(saved?.vision ?? true), [directory, setDirectory] = useState(saved?.directory ?? '');
|
||||
const [profile, setProfile] = useState<'work' | 'performance'>(saved?.profile ?? 'work');
|
||||
const [installJobId, setInstallJobId] = useState(saved?.installJobId ?? ''), [tuneJobId, setTuneJobId] = useState(saved?.tuneJobId ?? '');
|
||||
@@ -49,6 +60,8 @@ export function GuidedSetup() {
|
||||
const selectedFamily = families.find(f => f.family === family || f.sizes.some(size => size.sourceFamily === family)) ?? families[0];
|
||||
const version = selectedFamily?.sizes.find(s => s.id === size) ?? selectedFamily?.sizes[0];
|
||||
const installed = native?.installed?.models.find(m => m.model === version?.id);
|
||||
const contextList = version?.multi_file ? deepseekContexts(installed?.contextMax) : native?.catalog?.contexts ?? [131072];
|
||||
const context = pickedContext ?? defaultContext(contextList);
|
||||
const installJob = native?.jobs.find(j => j.id === installJobId) ?? (step === 3 ? native?.jobs.find(j => j.kind === 'install' && j.state === 'running' && j.guided && j.model === version?.model) : undefined), tuneJob = native?.jobs.find(j => j.id === tuneJobId) ?? (step === 4 ? native?.jobs.find(j => j.kind === 'tune' && j.state === 'running' && j.guided && j.model === version?.id) : undefined);
|
||||
const active = native?.jobs.find(j => j.state === 'running');
|
||||
const ready = native?.instances.some(i => i.model === version?.id && i.status === 'ready');
|
||||
@@ -141,8 +154,7 @@ export function GuidedSetup() {
|
||||
setTuneJobId(tuneJob.id);
|
||||
void persist(4, { tuneJobId: tuneJob.id }).catch(e => setError(e.message));
|
||||
} }, [step, installJob?.id, tuneJob?.id]);
|
||||
const fixedContext = version?.multi_file ? 8192 : null; // the one context DeepSeek's engine was checked at
|
||||
const values = { guided: true, family: version?.sourceFamily, model: version?.model, context: fixedContext ?? context, vision: version?.images ? 'yes' : 'no', kv: 'int8', low_ram: 'auto', loadAfter: false, multiple: false, ...(directory ? { data_dir: directory } : {}) };
|
||||
const values = { guided: true, family: version?.sourceFamily, model: version?.model, context, ...(contextTouched ? { contextChosen: true } : {}), vision: version?.images ? 'yes' : 'no', kv: 'int8', low_ram: 'auto', loadAfter: false, multiple: false, ...(directory ? { data_dir: directory } : {}) };
|
||||
async function review() {
|
||||
await perform(async () => {
|
||||
setInstallJobId('');
|
||||
@@ -201,7 +213,7 @@ export function GuidedSetup() {
|
||||
const title = step === 1 ? 'State of the art. At home.' : step === 2 ? 'Select the model you want to run.' : step === 3 ? 'Downloading the model...' : tuneJob?.state === 'running' ? 'A little tuning. A better fit.' : 'How you would like to work with Strata?';
|
||||
function changeDownloadFolder() { void perform(async () => { await advance(2); setFolderOpen(true); }); }
|
||||
return <div className="setup guided-onboarding" data-step={step}>
|
||||
<header className="onboard-header"><Brand variant="lockup" height={22}/>{tr(step !== 1 ? <button className="onboard-later" disabled={pending} onClick={() => void skip(false)}>{tr("Finish later")}<Icon name="chevron" size={13}/></button> : null)}</header>
|
||||
<header className="onboard-header"><Brand variant="lockup" height={22}/>{tr(step === 1 ? <FirstRunLanguage/> : null)}{tr(step !== 1 ? <button className="onboard-later" disabled={pending} onClick={() => void skip(false)}>{tr("Finish later")}<Icon name="chevron" size={13}/></button> : null)}</header>
|
||||
<div className="onboard-stepper" aria-label={tr("Setup progress")}>{tr(steps.map((label, i) => <div key={label} data-current={step === i + 1} data-done={step > i + 1} aria-current={step === i + 1 ? 'step' : undefined}><span>{tr(step > i + 1 ? <Icon name="check" size={12}/> : i + 1)}</span><small>{tr(label)}</small></div>))}</div>
|
||||
{tr(step === 1 ? <>
|
||||
<OnboardingArt />
|
||||
@@ -211,7 +223,8 @@ export function GuidedSetup() {
|
||||
<div className="onboard-stage-heading"><h1 ref={titleRef} tabIndex={-1} data-initial-focus>{tr(title)}</h1>{tr(step === 2 ? <p>{tr("You can always change your models later.")}</p> : step === 4 && tuneJob?.state === 'running' ? <p>{tr("We\u2019re measuring, applying your preference, and getting ready to chat.")}</p> : null)}</div>
|
||||
{tr(step === 2 ? <div className="onboard-model-stage">
|
||||
{tr(selectedFamily ? <>
|
||||
<div className="onboard-model-top"><div className="onboard-model-picker"><span className="onboard-control-label">{tr("Model")}</span><div><span className="onboard-model-symbol"><Icon name="models" size={23}/></span><Select ariaLabel={tr("Setup model")} value={selectedFamily.family} options={families.map(f => ({ value: f.family, label: f.title }))} onChange={value => chooseNative(value, '')}/></div></div><div className="onboard-context"><span className="onboard-control-label">{tr("Conversation context")}</span><Select ariaLabel={tr("Setup conversation context")} value={String(version?.multi_file ? 8192 : context)} options={(version?.multi_file ? [8192] : native?.catalog?.contexts ?? [32768]).map(n => ({ value: String(n), label: `${n / 1024}K` }))} onChange={value => setContext(Number(value))}/></div></div>
|
||||
<div className="onboard-model-top"><div className="onboard-model-picker"><span className="onboard-control-label">{tr("Model")}</span><div><span className="onboard-model-symbol"><Icon name="models" size={23}/></span><Select ariaLabel={tr("Setup model")} value={selectedFamily.family} options={families.map(f => ({ value: f.family, label: f.title }))} onChange={value => chooseNative(value, '')}/></div></div><div className="onboard-context"><span className="onboard-control-label">{tr("Conversation context")}</span><Select ariaLabel={tr("Setup conversation context")} value={String(context)} options={contextList.map(n => ({ value: String(n), label: `${n / 1024}K` }))} onChange={value => { setContextTouched(true); setContext(Number(value)); }}/></div></div>
|
||||
{tr(contextIsTight(topCardGb, context) ? <p className="small muted native-spill-note"><Icon name="warning" size={16}/>{tr("This much context takes about {0} GB of graphics memory and leaves little room for the model on this card. Strata will try it; if it does not start, choose a shorter context.", [cacheGb(context).toFixed(1)])}</p> : null)}
|
||||
<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>
|
||||
{tr(version?.multi_file ? <DeepSeekNote version={version} plan={plan}/> : null)}
|
||||
@@ -228,7 +241,7 @@ export function GuidedSetup() {
|
||||
</div> : <div className="onboard-tune-stage">
|
||||
{tr(tuneJob?.state === 'running' ? <><OnboardingArt kind="tune"/><div className="onboard-tuning-status" aria-live="polite"><span className="onboard-pulse-dot"/><strong>{tr(tuneJob.summary)}</strong><small>{tr("Your choice: ")}{tr(profile === 'work' ? '20% headroom for other work' : 'Full performance')}</small><div className="onboard-progress" data-indeterminate><span /></div><button disabled={pending} onClick={() => void perform(async () => { await nativeAction(`operations/${tuneJob.id}/cancel`); })}>{tr("Stop tuning")}</button></div><OnboardingTour page={tour} onChange={setTour}/></> : <>
|
||||
<OnboardingResources profile={profile}/>
|
||||
<div className="onboard-resource-options" role="radiogroup" aria-label={tr("Computer resource preference")} onKeyDown={radioKeys}>{tr(([{ id: 'work', icon: 'computer', title: 'Leave 20% for my work', hint: 'Use Strata while browsing, writing or working.', badge: 'Everyday choice' }, { id: 'performance', icon: 'bolt', title: 'Use full performance', hint: 'Focus this computer on your AI.', badge: '100% priority' }] as const).map(option => <button key={option.id} role="radio" tabIndex={profile === option.id ? 0 : -1} aria-checked={profile === option.id} data-selected={profile === option.id} onClick={() => setProfile(option.id)}><span className="onboard-resource-option-top"><span className="onboard-resource-option-icon"><Icon name={option.icon} size={22}/></span><span className="onboard-version-radio">{tr(profile === option.id ? <Icon name="check" size={12}/> : null)}</span></span><strong>{tr(option.title)}</strong><p>{tr(option.hint)}</p><small>{tr(option.badge)}</small></button>))}</div>
|
||||
<div className="onboard-resource-options" role="radiogroup" aria-label={tr("Computer resource preference")} onKeyDown={radioKeys}>{tr(([{ id: 'work', icon: 'computer', title: 'Leave room for my work', hint: 'Keeps 5-8 GB of memory free for other programs.', badge: 'Everyday choice' }, { id: 'performance', icon: 'bolt', title: 'Use full performance', hint: 'Focus this computer on your AI.', badge: '100% priority' }] as const).map(option => <button key={option.id} role="radio" tabIndex={profile === option.id ? 0 : -1} aria-checked={profile === option.id} data-selected={profile === option.id} onClick={() => setProfile(option.id)}><span className="onboard-resource-option-top"><span className="onboard-resource-option-icon"><Icon name={option.icon} size={22}/></span><span className="onboard-version-radio">{tr(profile === option.id ? <Icon name="check" size={12}/> : null)}</span></span><strong>{tr(option.title)}</strong><p>{tr(option.hint)}</p><small>{tr(option.badge)}</small></button>))}</div>
|
||||
<details className="onboard-resource-honesty"><summary>{tr("How resource sharing works")}</summary><p>{tr("We reserve GPU memory and limit CPU expert workers to leave headroom for other apps. Actual usage varies with your model. Model RAM requirements and system safety reserves still apply.")}</p></details>
|
||||
{tr(version?.images ? <label className="onboard-picture onboard-load-vision"><OnboardingCheckbox checked={vision} onChange={setVision}/><Icon name="image" size={20}/><span><strong>{tr("Read pictures when this model loads")}</strong><small>{tr("Understand photos and screenshots. Vision files are already downloaded.")}</small></span></label> : null)}
|
||||
{tr(native?.capabilities.calibration ? <label className="onboard-calibration"><OnboardingCheckbox checked={calibrate} onChange={setCalibrate}/><Icon name="gauge" size={20}/><span><strong>{tr("Find the fastest settings for this PC")}</strong><small>{tr("One-time measurement \u00B7 about 5\u201310 minutes. Your computer will be busy.")}</small></span></label> : <p className="onboard-calibration-note">{tr("We\u2019ll apply your preference and run a short speed check.")}</p>)}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { tr } from '../lib/i18n';
|
||||
import { copyText } from '../lib/clipboard';
|
||||
import { CopyBtn } from './CopyBtn';
|
||||
/** Safe, dependency-free rendering of the Markdown structures used in answers. */
|
||||
import { Fragment, type ReactNode } from 'react';
|
||||
import { Btn, IconBtn } from './ui';
|
||||
import { CodePreviewBtn } from './CodePreviewBtn';
|
||||
import { codePreview } from '../lib/code-preview';
|
||||
import type { PreviewArtifact } from '../lib/artifacts';
|
||||
import { SyntaxCode } from './SyntaxCode';
|
||||
import { MathView } from './MathView';
|
||||
import { displayBlock, hasMath, splitMath } from '../lib/math';
|
||||
@@ -57,15 +59,13 @@ export function continuesParagraph(lines: string[], at: number): boolean {
|
||||
return false;
|
||||
return !displayBlock(lines, at);
|
||||
}
|
||||
/** Copy a code block. The toast store needs a browser, so it is loaded here, when someone copies: the renderer itself must render without one. */
|
||||
async function copyCode(text: string) {
|
||||
const { pushToast } = await import('../lib/store');
|
||||
await copyText(text, 'Code', { toast: pushToast });
|
||||
}
|
||||
export function Markdown({ text, onOpenLink, onPreviewHtml, imageSession }: {
|
||||
export function Markdown({ text, onOpenLink, previewId, onPreview, onClosePreview, imageSession }: {
|
||||
text: string;
|
||||
onOpenLink?: (url: string) => void;
|
||||
onPreviewHtml?: (html: string) => void;
|
||||
/** Where an HTML or Markdown code block can be shown in the sidebar: the message it belongs to, and the handlers that open and close a preview. */
|
||||
previewId?: string;
|
||||
onPreview?: (a: PreviewArtifact) => void;
|
||||
onClosePreview?: (id: 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;
|
||||
}) {
|
||||
@@ -84,7 +84,9 @@ export function Markdown({ text, onOpenLink, onPreviewHtml, imageSession }: {
|
||||
while (i < lines.length && !/^\s*```/.test(lines[i]))
|
||||
body.push(lines[i++]);
|
||||
i++;
|
||||
nodes.push(<div className="st-code" key={key}><div className="st-code__head"><span>{language || tr('code')}</span>{tr(/^html$/i.test(language) && onPreviewHtml ? <Btn size="sm" onClick={() => onPreviewHtml(body.join('\n'))}>{tr("Open preview")}</Btn> : null)}<IconBtn name="copy" title={tr("Copy code")} onClick={() => void copyCode(body.join('\n'))}/></div><SyntaxCode text={body.join('\n')} language={language}/></div>);
|
||||
const code = body.join('\n');
|
||||
const preview = previewId && onPreview ? codePreview(previewId, language, code) : null;
|
||||
nodes.push(<div className="st-code" key={key}><div className="st-code__head"><span>{language || tr('code')}</span><span className="st-code__actions">{tr(preview && onPreview ? <CodePreviewBtn artifact={preview} onPreview={onPreview} onClose={onClosePreview}/> : null)}<CopyBtn text={code} label="Code" title="Copy code"/></span></div><SyntaxCode text={code} language={language}/></div>);
|
||||
continue;
|
||||
}
|
||||
const block = displayBlock(lines, i);
|
||||
|
||||
@@ -3,8 +3,9 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Screen } from './layout';
|
||||
import { VersionPicker } from './VersionPicker';
|
||||
import { FamilySwitch } from './FamilySwitch';
|
||||
import { PcFitCard } from './PcFitCard';
|
||||
import { fitOn, memoryNeeded } from '../lib/native-fit';
|
||||
import { PcComputer } from './PcComputer';
|
||||
import { cacheGb, fitOn, memoryNeeded, versionFit } from '../lib/native-fit';
|
||||
import { contextIsTight, defaultContext, DEFAULT_CONTEXT, OLD_DEEPSEEK_CONTEXT, openingContext } from '../lib/default-context';
|
||||
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';
|
||||
@@ -12,6 +13,8 @@ import { installProgress, installStage, stageText, useDownloadEta } from '../lib
|
||||
import { useKept } from '../lib/kept-state';
|
||||
import { DownloadParts } from './DownloadParts';
|
||||
import { EngineWarning } from './EngineWarning';
|
||||
import { EngineUpdateCard } from './EngineUpdateCard';
|
||||
import { EngineUpdateDialog } from './EngineUpdateDialog';
|
||||
import { sizeGb } from '../lib/format';
|
||||
import { NativeDeleteDialog } from './NativeDelete';
|
||||
import { plainFailureText } from '../lib/plain-errors';
|
||||
@@ -20,7 +23,8 @@ 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 { shownVision, steadyNoReader } from '../lib/pictures-choice';
|
||||
import { draftingChoices as draftingOptions, resolveDrafter } from '../lib/drafting';
|
||||
import { deepseekContexts, deepseekContextNote } from '../lib/deepseek-context';
|
||||
import { NativeSettings } from './NativeSettings';
|
||||
import { NativeSettingsFile } from './NativeSettingsFile';
|
||||
@@ -30,7 +34,7 @@ import { useStore } from '../lib/store';
|
||||
import { NativePresets } from './NativePresets';
|
||||
import { NativeTune } from './NativeTune';
|
||||
import { NativeTuneBadge } from './NativeTuneBadge';
|
||||
import { nativeCatalogModels, catalogVersionLabel } from '../lib/native-catalog';
|
||||
import { nativeCatalogModels, catalogVersionLabel, fetchesPictures } from '../lib/native-catalog';
|
||||
export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine }: {
|
||||
onOtherModels: () => void;
|
||||
onImport?: () => void;
|
||||
@@ -39,21 +43,35 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
}) {
|
||||
const native = useNative(), imageLibrary = useStore(s => s.imageModels), hardware = useStore(s => s.hardware);
|
||||
// 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 [family, setFamily] = useKept('models.family', ''), [size, setSize] = useKept('models.size', ''), [context, setContext] = useKept('models.context', DEFAULT_CONTEXT), [contextPicked, setContextPicked] = useKept('models.contextPicked', false), [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), [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 [pending, setPending] = useState(false), [error, setError] = useState(''), [plan, setPlan] = useState<Record<string, any> | null>(null), [drafterGb, setDrafterGb] = useState<number | 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);
|
||||
const presetPending = useRef(false), visionPicks = useRef(new Map<string, string>()); // Read pictures per version: what the person set, or what the version opened with; later reports of the host never overwrite it
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const families = nativeCatalogModels(native?.catalog?.families ?? []);
|
||||
const expertSection = useRef<HTMLElement>(null);
|
||||
useEffect(() => { if (expert)
|
||||
expertSection.current?.scrollIntoView({ block: 'start', behavior: 'smooth' }); }, [expert]);
|
||||
// The card is inserted above the fold and the browser's scroll anchoring
|
||||
// immediately compensates by scrolling down, which cancelled the smooth
|
||||
// scroll started in the same frame: the page stopped 121px in and clipped
|
||||
// the cards above. Scroll after the layout has settled, and never animate
|
||||
// when motion is reduced.
|
||||
useEffect(() => {
|
||||
if (!expert)
|
||||
return;
|
||||
const frame = requestAnimationFrame(() => expertSection.current?.scrollIntoView({
|
||||
block: 'start',
|
||||
behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
|
||||
}));
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [expert]);
|
||||
const selectedFamily = families.find(f => f.family === family || f.sizes.some(size => size.sourceFamily === family)) ?? families[0], selectedSize = selectedFamily?.sizes.find(s => s.id === size) ?? selectedFamily?.sizes[0];
|
||||
const installed = native?.installed?.models.find(m => m.model === selectedSize?.id);
|
||||
// The sizes this model offers for the conversation context; it opens at 128K (or its largest size when that is smaller).
|
||||
const contextList = selectedSize?.multi_file ? deepseekContexts(installed?.contextMax) : native?.catalog?.contexts ?? [8192, 32768, 65536, 131072, 262144];
|
||||
// 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.
|
||||
@@ -66,6 +84,8 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
const keptUse = keptModels.map(i => { const s = sizeOf(i.model); const card = pcGpus.find(g => g.index === i.gpus[0]); const f = s && pcRam ? fitOn(s, { ram_gb: pcRam, vram_gb: card?.vram_gb ?? pcVram }) : null; return { gpus: i.gpus, ram: f?.ramUseGb ?? 0, vram: f?.vramUseGb ?? 0 }; });
|
||||
const keptRam = keptUse.reduce((n, u) => n + u.ram, 0);
|
||||
const keptVramOn = (cards: number[]) => keptUse.filter(u => u.gpus.some(g => cards.includes(g))).reduce((n, u) => n + u.vram, 0);
|
||||
// The models that are loaded now, with what each takes of graphics memory, RAM and SSD (the app's estimate from the same rule the fit uses: the engine does not report this split).
|
||||
const loadedModels = (native?.instances ?? []).filter(i => i.status === 'ready' || i.status === 'loading').flatMap(i => { const s = sizeOf(i.model); if (!s || !pcRam) return []; const card = pcGpus.find(g => g.index === i.gpus[0]); return [{ id: i.model, label: `${i.label} · ${s.model}`, fit: fitOn(s, { ram_gb: pcRam, vram_gb: card?.vram_gb ?? pcVram }, true, { context: i.context ?? context, kv }) }]; });
|
||||
// The versions that are really on this computer: a config whose files are all there (every part of a model in parts).
|
||||
const readyIds = useMemo(() => new Set(downloaded.filter(m => m.ready).map(m => m.model)), [downloaded]);
|
||||
// What a running job works on, by its name in the list: "Qwen3.8-Flash-Next · Gyro · Smallest".
|
||||
@@ -90,7 +110,11 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
(j.model === selectedSize.model && j.family === selectedSize.sourceFamily));
|
||||
const job = native?.jobs.find(j => j.id === jobId && selectedJob(j)) ??
|
||||
native?.jobs.filter(j => ['load', 'install'].includes(j.kind) && selectedJob(j)).at(-1);
|
||||
const noFit = selectedSize?.on_this_pc === 'does not fit';
|
||||
// This model can read pictures but the installed engine has no picture reader: pictures run off for now, nothing is saved, and the row says nothing wordy.
|
||||
// While a job runs the host is busy and its picture-reader probe can drop out for a moment: the checkbox then keeps what it showed, so the person's choice never flickers.
|
||||
const noReaderNow = !!selectedSize?.imagesCapable && !selectedSize.images, noReaderSeen = useRef({ id: '', value: false });
|
||||
noReaderSeen.current = steadyNoReader(noReaderNow, !!active, noReaderSeen.current, selectedSize?.id ?? '');
|
||||
const noReader = noReaderSeen.current.value;
|
||||
// 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);
|
||||
@@ -105,18 +129,26 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
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)) };
|
||||
// How the chosen version sits on this computer (the same rule as the version list): the page says a word only for "SSD streaming" and "Doesn't fit".
|
||||
const fitState = selectedSize ? versionFit(selectedSize, fitHardware, { context, kv }) : 'unknown';
|
||||
// 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.
|
||||
// Drafting (which drafter guesses ahead): a choice exists only for Qwen3.8-Flash-Next (the qwen and gyro families) on a computer whose engine can run DFlash2; every other model has no dropdown.
|
||||
const draftingChoices = selectedSize ? draftingOptions(selectedSize.sourceFamily ?? selectedFamily?.family, native?.capabilities?.dflash2) : null;
|
||||
// The drafter this run loads (one per run): the fit and the plan use its graphics memory (DFlash2 0.89 GB, MTP 1.08 GB).
|
||||
const nowDrafter = draftingChoices ? resolveDrafter(drafting, selectedSize?.sourceFamily ?? selectedFamily?.family, native?.capabilities?.dflash2, native?.capabilities?.dflash2Split) : 'mtp';
|
||||
// DFlash2 with the converted pack (the selector as Q4_0 rows) takes 175 MiB less graphics memory: the fit says the true number (Control's `selq4`, strata_control/drafting.py vram_gb).
|
||||
const fitDrafter = nowDrafter === 'dflash2' && native?.capabilities?.dflash2Selq4 === true ? 'dflash2_selq4' : nowDrafter;
|
||||
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' };
|
||||
const downloadValues = { ...values, ...(draftingChoices ? { drafting } : {}), vision: selectedSize?.images && vision !== 'no' ? 'yes' : 'no', loadVision: selectedSize?.images ? vision : 'no', ...(noReader ? { visionUnavailable: true } : {}), ...(contextPicked ? { contextChosen: true } : {}) };
|
||||
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);
|
||||
setSize(f.sizes.find(s => s.id === current?.model)?.id ?? f.sizes.find(s => have.has(s.id))?.id ?? f.sizes.find(s => s.model === rec.model)?.id ?? f.sizes[0]?.id ?? '');
|
||||
setContext(('context' in rec ? rec.context : undefined) ?? 32768);
|
||||
const first = f.sizes.find(s => s.id === current?.model) ?? f.sizes.find(s => have.has(s.id)) ?? f.sizes.find(s => s.model === rec.model) ?? f.sizes[0];
|
||||
setSize(first?.id ?? '');
|
||||
// 128K for every configuration, DeepSeek included where its pack allows (the engine's own recommendation is not what the app opens with).
|
||||
setContext(defaultContext(first?.multi_file ? deepseekContexts() : native.catalog.contexts));
|
||||
} }, [native?.catalog]);
|
||||
// The version that is selected: a model already on this PC shows the settings it was prepared with (so loading it never asks to download);
|
||||
// a model that is not yet downloaded starts with Read pictures ON (owner rule: pictures on by default). This runs when the SELECTION or the
|
||||
@@ -128,33 +160,45 @@ 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.drafting, installed.resourceProfile].join('|') : `new|${selectedSize.id}`;
|
||||
const sig = installed ? ['have', installed.model, installed.ready, installed.context, installed.contextChosen, installed.kv, installed.lowRamMode, installed.drafting, installed.resourceProfile].join('|') : `new|${selectedSize.id}`;
|
||||
if (sig === appliedSig)
|
||||
return;
|
||||
setAppliedSig(sig);
|
||||
if (installed) {
|
||||
// The context it was prepared with when someone chose it; a model that holds the old default (32K, DeepSeek's 8K) without the marker opens at the new default.
|
||||
if (installed.context)
|
||||
setContext(installed.context);
|
||||
// Off only when the person turned it off (a saved choice); a model prepared without the picture files (images 'off') still starts with it ON.
|
||||
setVision(installed.visionEnabled === false ? 'no' : installed.visionDevice ?? (installed.images && installed.images !== 'off' ? installed.images : 'yes'));
|
||||
setContext(openingContext(installed.context, contextList, installed.contextChosen === true, selectedSize.multi_file ? [OLD_DEEPSEEK_CONTEXT] : undefined));
|
||||
// Off only when the person turned it off (a saved choice on a model that has the picture files); a model prepared without them (images 'off', or an old "off" saved while the engine had no reader) starts with it ON.
|
||||
// The first time this version is seen as installed it opens from its saved settings; from then on that value is the person's (a host report that changes while a job runs never changes it).
|
||||
const known = visionPicks.current.get(selectedSize.id), value = shownVision(known, installed);
|
||||
if (known === undefined)
|
||||
visionPicks.current.set(selectedSize.id, value);
|
||||
setVision(value);
|
||||
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.
|
||||
// A model never tuned has no saved choice: keep the page's own (Leave room for my work) rather than flipping to Full performance.
|
||||
if (installed.resourceProfile && !loadedSelected?.resourceProfile)
|
||||
setResourceProfile(installed.resourceProfile);
|
||||
}
|
||||
else {
|
||||
setVision('yes');
|
||||
if (selectedSize.multi_file)
|
||||
setContext(8192); // DeepSeek runs at the one context its engine was checked at (as in the guided setup)
|
||||
setDrafting('auto'); // a model that is not installed has no saved choice: Auto (the page never carries the last model's Drafting over to another one)
|
||||
setVision(shownVision(visionPicks.current.get(selectedSize.id), undefined));
|
||||
if (!contextPicked) // a context the person picked stays; otherwise 128K (DeepSeek too, where its pack allows)
|
||||
setContext(defaultContext(contextList));
|
||||
}
|
||||
}, [selectedSize?.id, installed?.model, installed?.ready, installed?.context, installed?.kv, installed?.images, installed?.visionEnabled, installed?.visionDevice, installed?.lowRamMode, installed?.resourceProfile]);
|
||||
}, [selectedSize?.id, installed?.model, installed?.ready, installed?.context, installed?.contextChosen, installed?.kv, installed?.lowRamMode, installed?.resourceProfile]);
|
||||
const pickVision = (next: string) => { if (selectedSize) visionPicks.current.set(selectedSize.id, next); setVision(next); };
|
||||
useEffect(() => { if (jobId && job?.state === 'done' && job.id !== toastedJob && (job.kind === 'load' || job.kind === 'install')) {
|
||||
setToastedJob(job.id);
|
||||
selectRemoteModel(null);
|
||||
pushToast('success', 'Model ready', 'Start a new chat when you’re ready.');
|
||||
} }, [job?.state]);
|
||||
// The picture files came without a load: a model that is loaded right now still runs without pictures until it is loaded again.
|
||||
useEffect(() => { if (jobId && job?.state === 'done' && job.id !== toastedJob && job.kind === 'pictures') {
|
||||
setToastedJob(job.id);
|
||||
pushToast('success', 'Picture files ready', loadedSelected && !loadedSelected.vision ? 'Load the model again to read pictures.' : 'Picture reading is ready.');
|
||||
} }, [job?.state]);
|
||||
async function perform(action: () => Promise<unknown>) { setPending(true); setError(''); try {
|
||||
await action();
|
||||
}
|
||||
@@ -164,13 +208,25 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
finally {
|
||||
setPending(false);
|
||||
} }
|
||||
/* Load model: no pop-up. The press itself is the yes. The plan is read first (Control checks the free disk space); a disk that is too small is said
|
||||
here, in the page, and nothing starts. Otherwise the download or the apply-and-load starts at once, with its size and notes in the page. */
|
||||
// The model is on this computer and only its picture files are missing: the Load fetches those files itself (about 0.9 GB, once) and goes on; it does not run the installer again.
|
||||
const picturesByLoad = fetchesPictures(selectedSize, native?.packaged);
|
||||
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, ...(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' } : {}) : {}) } });
|
||||
return; setPlan(null); setDrafterGb(null); await perform(async () => { if (installed?.ready && context === installed.context && (!selectedSize?.images || vision === 'no' || picturesByLoad || 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', ...(noReader ? { visionUnavailable: true } : {}), ...(contextPicked && !installed.contextChosen ? { contextChosen: true } : {}), 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
|
||||
setPlan(await api.post('/api/native/plan', downloadValues)); }); }
|
||||
else {
|
||||
const planned = await api.post<Record<string, any>>('/api/native/plan', downloadValues);
|
||||
if (planned?.plan?.disk_short) {
|
||||
setPlan(planned);
|
||||
return;
|
||||
}
|
||||
setDrafterGb(planned?.drafting?.needed ? Number(planned.drafting.gb) || null : null);
|
||||
const result = await nativeAction<NativeJob>('operations', { kind: 'install', args: downloadValues });
|
||||
setJobId(result.id);
|
||||
} }); }
|
||||
/* Unload / Use in chat: a refusal (a chat is still using the model) is said here, beside the button, not far below the page. */
|
||||
async function loadedAction(path: string) { setLoadedError(''); setPending(true); try {
|
||||
await nativeAction(path);
|
||||
@@ -181,7 +237,9 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
finally {
|
||||
setPending(false);
|
||||
} }
|
||||
async function download() { await perform(async () => { const result = await nativeAction<NativeJob>('operations', { kind: 'install', args: downloadValues }); setJobId(result.id); setPlan(null); }); }
|
||||
// The drafter note belongs to the download that is running; the not-enough-disk message belongs to the version it was said for.
|
||||
useEffect(() => { if (!active) setDrafterGb(null); }, [!!active]);
|
||||
useEffect(() => { setPlan(null); }, [selectedSize?.id]);
|
||||
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">
|
||||
@@ -189,37 +247,40 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
<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)
|
||||
return; presetPending.current = true; setFamily(f.family); setSize(f.sizes.find(s => s.model === v.model && (s.sourceFamily === v.family || f.family === v.family))?.id ?? ''); setContext(v.context ?? 32768); setVision(v.vision ?? 'no'); setKv(v.kv ?? 'int8'); setLowRam(v.low_ram ?? 'auto'); }}/><Btn icon="models" onClick={() => setManage(true)}>{tr("Manage models")}</Btn></span></div>
|
||||
<div className="native-version"><VersionPicker pick={native?.catalog?.recommendation} versions={allSizes} selectedId={selectedSize.id} hardware={fitHardware} downloaded={readyIds} onSelect={id => { const f = families.find(f => f.sizes.some(s => s.id === id)); if (f) { setFamily(f.family); setSize(id); setError(''); } }} onOwnGguf={() => (onImport ?? onOtherModels)()}/></div>
|
||||
{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)}
|
||||
return; presetPending.current = true; setFamily(f.family); setSize(f.sizes.find(s => s.model === v.model && (s.sourceFamily === v.family || f.family === v.family))?.id ?? ''); setContext(v.context ?? 32768); setVision(v.vision ?? 'no'); setKv(v.kv ?? 'int8'); setLowRam(v.low_ram ?? 'auto'); }}/><Btn icon="models" size="sm" variant="ghost" onClick={() => setManage(true)}>{tr("Manage models")}</Btn></span></div>
|
||||
<div className="native-version"><VersionPicker pick={native?.catalog?.recommendation} versions={selectedFamily?.sizes ?? []} details={expert} selectedId={selectedSize.id} hardware={fitHardware} cache={{ context, kv, drafter: fitDrafter }} downloaded={readyIds} onSelect={id => { const f = families.find(f => f.sizes.some(s => s.id === id)); if (f) { setFamily(f.family); setSize(id); setError(''); } }} onOwnGguf={() => (onImport ?? onOtherModels)()}/></div>
|
||||
{tr(fitState === 'ssd' ? <p className="small muted native-spill-note"><Icon name="disk" size={16}/>{tr("SSD streaming · will run slower")}</p> : fitState === 'no' ? <p className="small muted native-spill-note"><Icon name="warning" size={16}/>{tr("Doesn't fit")}</p> : null)}
|
||||
{tr(selectedSize?.multi_file && deepseekContextNote(context) ? <p className="small muted native-spill-note"><Icon name="info" size={16}/>{tr(deepseekContextNote(context))}</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(selectedSize?.multi_file ? deepseekContexts(installed?.contextMax) : 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>
|
||||
<div className="native-choice__col"><span className="native-choice__label">{tr("Conversation context")}</span><Select ariaLabel={tr("Conversation context")} value={String(context)} options={contextChoices(contextList, context).map(n => ({ value: String(n), label: n % 1024 === 0 ? `${n / 1024}K` : `${n.toLocaleString()} tokens` }))} onChange={n => { setContextPicked(true); 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 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 room for my work")}</strong><small>{tr("Keeps 5-8 GB of memory free for other programs.")}</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(contextIsTight(fitHardware.vram_gb, context, kv) ? <p className="small muted native-spill-note"><Icon name="warning" size={16}/>{tr("This much context takes about {0} GB of graphics memory and leaves little room for the model on this card. Strata will try it; if it does not start, choose a shorter context.", [cacheGb(context, kv).toFixed(1)])}</p> : 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)}
|
||||
{tr(selectedSize?.images || noReader ? <><div className="native-image-reading"><label title={noReader ? tr("Needs an engine update") : undefined}><input type="checkbox" checked={!noReader && vision !== 'no'} disabled={noReader || !!active} onChange={e => pickVision(e.target.checked ? 'yes' : 'no')}/><span><strong>{tr("Read pictures")}</strong></span></label>{tr(expert && vision !== 'no' && selectedSize?.sourceFamily !== 'deepseek41' ? <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={pickVision}/> : null)}</div>{tr(vision !== 'no' && !noReader && !active && loadedSelected?.picturesNote ? <p className="small muted native-vision-note" role="status">{tr(loadedSelected.picturesNote)}</p> : null)}</> : null)}
|
||||
</> : null)}
|
||||
<EngineWarning native={native}/>
|
||||
<EngineUpdateCard native={native} busy={!!active}/>
|
||||
<EngineUpdateDialog open={false} onClose={() => {}}/>
|
||||
{tr(active ? <NativeProgress job={active} label={jobLabel(active)}/> : null)}
|
||||
{tr(active && drafterGb ? <p className="small muted native-spill-note"><Icon name="info" size={16}/>{tr(nowDrafter === 'mtp' ? "Choosing MTP downloads its drafter: about {0} GB, once." : "Choosing DFlash2 downloads its drafter: about {0} GB, once.", [drafterGb])}</p> : null)}
|
||||
{tr(job?.state === 'error' ? <ErrorNotice message={job.error ?? job.summary} action={tr("Retry")} onRetry={() => void load()}/> : null)}
|
||||
{tr(plan?.plan?.disk_short ? <ErrorNotice message={plan.plan.disk_short}/> : 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} onClick={() => void load()}>{tr(pending ? 'Checking…' : installed?.ready ? 'Load model' : 'Download & load')}</Btn></>)}</div>
|
||||
<div className="native-model-actions">{tr(!loadedSelected && !installed?.ready && selectedSize?.download_gb && !active ? <span className="native-download-size small muted">{tr("{0} download", [sizeGb(selectedSize.download_gb)])}</span> : null)}{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>{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)}
|
||||
{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={KV_CHOICES.map(c => ({ value: c.value, label: c.label, title: c.hint }))} onChange={setKv}/>{tr(KV_CHOICES.find(c => c.value === kv)?.hint ? <small className="muted native-kv-hint">{tr(KV_CHOICES.find(c => c.value === kv)!.hint)}</small> : null)}</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} diskGb={selectedSize?.multi_file ? selectedSize.disk_gb ?? selectedSize.download_gb : null} inUse={keptModels.length ? { ramGb: Math.round(keptRam), vramGb: Math.round(keptVramOn(chosenCards)) } : null} fit={selectedSize ? fitOn(selectedSize, fitHardware, true, { context, kv }) : 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>
|
||||
<aside className="stack stack--lg"><PcComputer gpuName={pcGpu?.name} ramGb={pcRam} vramGb={pcVram} vramAllGb={pcGpus.reduce((n, g) => n + g.vram_gb, 0) || undefined} loaded={loadedModels} selectedLoaded={!!selectedSize && loadedModels.some(m => m.id === selectedSize.id)} diskGb={selectedSize?.multi_file ? selectedSize.disk_gb ?? selectedSize.download_gb : null} inUse={keptModels.length ? { ramGb: Math.round(keptRam), vramGb: Math.round(keptVramOn(chosenCards)) } : null} fit={selectedSize ? fitOn(selectedSize, fitHardware, true, { context, kv }) : null}/><section className="st-card"><h2 className="h4">{tr("Model files")}</h2><code className="native-path">{installed?.dataFolder ?? native?.downloadDirectory ?? native?.installed?.data_folder ?? 'Choose an installation'}</code><div className="model-files__actions"><Btn size="sm" variant="ghost" onClick={() => { setSettingsSection('Storage'); go('settings'); }}>{tr("Storage settings")}</Btn><Btn size="sm" variant="ghost" onClick={onOtherModels} className="other-models">{tr("Other local models\u2026")}</Btn></div></section></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(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">
|
||||
<Dialog open={manage} compact title={tr("Manage models")} onClose={() => setManage(false)} actions={<Btn onClick={() => setManage(false)}>{tr("Done")}</Btn>}><div className="native-manage">
|
||||
{tr(downloaded.some(m => m.ready || m.diskGb) ? <div className="native-manage-list" role="list">{tr(downloaded.filter(m => m.ready || m.diskGb).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}>
|
||||
<span className="native-manage-name"><strong>{tr(v ? `${catalogVersionLabel(v)}` : m.model_name ?? m.model)}</strong><small>{tr(v ? v.model : m.model)}{tr(m.diskGb ? ` · ${sizeGb(m.diskGb)}` : '')}</small></span>
|
||||
<span className={loaded ? 'st-pill st-pill--accent' : 'st-pill'}>{tr(loaded ? 'Loaded' : 'Downloaded')}</span>
|
||||
@@ -227,7 +288,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
</div>; }))}</div> : <p className="small muted native-manage-empty">{tr("No downloaded models")}</p>)}
|
||||
<div className="native-manage-foot"><Btn variant="ghost" icon="folder" onClick={() => { setManage(false); (onImport ?? onOtherModels)(); }}>{tr("Use a local GGUF…")}</Btn></div>
|
||||
</div></Dialog>
|
||||
{selectedSize ? <NativeTune open={tuneOpen} onClose={() => setTuneOpen(false)} model={selectedSize.id} resourceProfile={resourceProfile} vision={selectedSize.images ? vision : 'no'}/> : null}
|
||||
{selectedSize ? <NativeTune open={tuneOpen} onClose={() => setTuneOpen(false)} model={selectedSize.id} resourceProfile={resourceProfile} vision={selectedSize.images ? vision : 'no'} visionUnavailable={noReader}/> : null}
|
||||
</Screen>;
|
||||
}
|
||||
/** setup's "5/7: downloading …" as a sentence a person can read. */
|
||||
@@ -241,15 +302,48 @@ function installStepLabel(step?: string): string {
|
||||
return step ? 'Getting things ready…' : 'Downloading and preparing model files…';
|
||||
return n <= 2 ? 'Checking your computer…' : n <= 4 ? 'Getting things ready…' : n === 5 ? 'Downloading your model…' : n === 6 ? 'Preparing your model for Strata…' : 'Finishing up…';
|
||||
}
|
||||
/** The formats of the conversation cache and what each costs, in one line (the memory figures are from docs/DETAILS.md: Q4 half of 8-bit, K8/V4 23% less). */
|
||||
const KV_CHOICES = [
|
||||
{ value: 'int8', label: 'INT8', hint: 'INT8 is the standard: good quality and a medium amount of memory.' },
|
||||
{ value: 'q4_0', label: 'Q4', hint: 'Q4 uses about half the memory of INT8, but is less precise on very long documents.' },
|
||||
{ value: 'k8v4', label: 'K8 / V4', hint: 'K8 / V4 uses about a quarter less memory than INT8 and is more precise than Q4. Prompts are 2 to 5% slower.' },
|
||||
];
|
||||
/**
|
||||
* What a running job is doing, and nothing else: one line with the step, one small line with the numbers when there are any, a thin bar, and Stop as a
|
||||
* plain word. A soft ring breathes beside the step (transform and opacity only; still under reduced motion). The bar fills with the real percent, or
|
||||
* glides while no percent is known.
|
||||
*/
|
||||
export function NativeProgress({ job, label }: {
|
||||
job: NativeJob;
|
||||
label?: string;
|
||||
}) { const download = installProgress(job.result?.install); const detailsText = [job.details, job.result?.install?.engine?.details].filter(Boolean).join('\n'); const eta = useDownloadEta(job.kind === 'install' ? download : undefined, job.id); const [stopping, setStopping] = useState(false), [error, setError] = useState(''); async function stop() { setStopping(true); setError(''); try {
|
||||
await nativeAction(`operations/${job.id}/cancel`);
|
||||
}) {
|
||||
const download = installProgress(job.result?.install), detailsText = [job.details, job.result?.install?.engine?.details].filter(Boolean).join('\n');
|
||||
const eta = useDownloadEta(job.kind === 'install' ? download : undefined, job.id);
|
||||
const [stopping, setStopping] = useState(false), [error, setError] = useState('');
|
||||
async function stop() { setStopping(true); setError(''); try {
|
||||
await nativeAction(`operations/${job.id}/cancel`);
|
||||
}
|
||||
catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
finally {
|
||||
setStopping(false);
|
||||
} }
|
||||
const running = job.state === 'running', stoppable = ['install', 'load', 'pictures', 'autotune', 'recheck'].includes(job.kind);
|
||||
const kindTitle = job.kind === 'install' ? (label ? tr('Preparing {0}', [label]) : 'Preparing your model') : job.kind === 'load' ? 'Loading model' : job.kind === 'pictures' ? 'Downloading the picture files' : job.kind === 'autotune' || job.kind === 'recheck' ? 'Tuning your model' : 'Engine operation';
|
||||
const step = 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;
|
||||
const percent = download && Number.isFinite(download.percent) ? Math.max(0, Math.min(100, Number(download.percent))) : null;
|
||||
const numbers = [percent !== null ? `${Math.round(percent)}%` : '', download && Number.isFinite(download.done_gb) && Number.isFinite(download.total_gb) ? `${download.done_gb!.toFixed(1)} / ${download.total_gb!.toFixed(1)} GB` : '', eta].filter(Boolean).join(' · ');
|
||||
const words = tr(step || kindTitle);
|
||||
return <section className="st-card native-progress" aria-live="polite" data-state={job.state}>
|
||||
<div className="native-progress__row">
|
||||
<span className="native-progress__ring" aria-hidden="true"/>
|
||||
<div className="native-progress__text"><p className="native-progress__step">{words}</p>{numbers ? <p className="native-progress__numbers">{numbers}</p> : null}</div>
|
||||
{stoppable && running ? <button type="button" className="native-progress__stop" disabled={stopping} onClick={() => void stop()}>{tr(stopping ? 'Stopping…' : 'Stop')}</button> : null}
|
||||
</div>
|
||||
{running ? <div className="native-progress__bar" role="progressbar" aria-label={words} aria-valuemin={0} aria-valuemax={100} aria-valuenow={percent ?? undefined} data-indeterminate={percent === null || undefined}><i style={percent !== null ? { transform: `scaleX(${percent / 100})` } : undefined}/></div> : null}
|
||||
<DownloadParts parts={job.result?.install?.parts}/>
|
||||
{detailsText ? <details><summary className="small">{tr("Details")}</summary><pre className="small muted error-detail-text">{detailsText}</pre></details> : null}
|
||||
{error ? <ErrorNotice message={error}/> : null}
|
||||
</section>;
|
||||
}
|
||||
catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(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' ? (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>; }
|
||||
@@ -2,11 +2,11 @@ import {localeTag} from '../lib/i18n';
|
||||
import { tr } from '../lib/i18n';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../lib/client';
|
||||
import { fetchUpdateCheck, publishUpdateCheck, useInstalledVersions, useLastUpdateCheck } from '../lib/update-check';
|
||||
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';
|
||||
import { SettingsEngine } from './SettingsEngine';
|
||||
export function NativeBenchmark({ open, onClose }: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -54,29 +54,8 @@ export function NativeOperations() {
|
||||
at: string;
|
||||
instanceId: string;
|
||||
line: string;
|
||||
}[]>([]), [showLogs, setShowLogs] = useState(false), [confirm, setConfirm] = useState(false);
|
||||
// Opening this page shows the last check kept on this computer and makes no network call. The check runs when "Check for updates" is pressed.
|
||||
const versions = useInstalledVersions();
|
||||
const last = useLastUpdateCheck(versions);
|
||||
const [checking, setChecking] = useState(false), [checkError, setCheckError] = useState('');
|
||||
const update: 'checking' | 'available' | 'current' | 'unknown' = checking ? 'checking' : last?.engine.status === 'available' ? 'available' : last?.engine.status === 'current' ? 'current' : 'unknown';
|
||||
}[]>([]), [showLogs, setShowLogs] = useState(false);
|
||||
const active = native?.jobs.find(j => j.state === 'running');
|
||||
async function checkForUpdates() {
|
||||
setChecking(true);
|
||||
setCheckError('');
|
||||
try {
|
||||
const data = await fetchUpdateCheck();
|
||||
publishUpdateCheck(data, versions);
|
||||
if (data.engine.status === 'error')
|
||||
setCheckError(data.engine.message);
|
||||
}
|
||||
catch (e) {
|
||||
setCheckError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!showLogs)
|
||||
return;
|
||||
@@ -87,16 +66,6 @@ export function NativeOperations() {
|
||||
const timer = setInterval(read, 2000);
|
||||
return () => clearInterval(timer);
|
||||
}, [showLogs]);
|
||||
async function updateEngine() {
|
||||
setError('');
|
||||
try {
|
||||
await nativeAction('operations', { kind: 'update-engine' });
|
||||
setConfirm(false);
|
||||
}
|
||||
catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
// The window cannot save a download, so the host writes the file to Downloads and says where.
|
||||
async function saveLogs() {
|
||||
setSaving(true);
|
||||
@@ -114,13 +83,12 @@ 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>
|
||||
<SettingsEngine />
|
||||
<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></>}>
|
||||
<Dialog open={showLogs} wide compact 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)}
|
||||
</Dialog>
|
||||
{tr(active ? <NativeProgress job={active}/> : null)}{tr(error ? <ErrorNotice message={error}/> : null)}
|
||||
<Dialog open={confirm} compact title={tr("Update engine?")} onClose={() => setConfirm(false)} actions={<><Btn onClick={() => setConfirm(false)}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={!!active || (native?.instances.length ?? 0) > 0} onClick={() => void updateEngine()}>{tr("Update")}</Btn></>}><p>{tr("The native installer updates the installation. Model files and settings are kept. Unload native models before updating.")}</p></Dialog>
|
||||
</div>;
|
||||
}
|
||||
@@ -29,6 +29,6 @@ export function NativePresets({ values, onApply }: {
|
||||
setError('Browser storage is full. Free space before saving settings.');
|
||||
return false;
|
||||
} }
|
||||
return <><button className="st-btn st-btn--secondary" ref={trigger} aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(v => !v)}><Icon name="layers"/>{tr("Saved settings ")}<Icon name="chevron" size={12}/></button>{tr(open ? <FloatingMenu anchor={trigger.current} title={tr("Saved settings")} width={290} onClose={() => setOpen(false)}><p className="model-picker-heading">{tr("Saved settings")}</p>{tr(items.map(item => <button className="menu__item" role="menuitem" key={item.id} onClick={() => { onApply(item.values); setOpen(false); }}><Icon name="sliders"/><span>{item.name}<small>{tr(item.values.model)} · {tr((item.values.context ?? 32768) / 1024)}{tr("K")}</small></span></button>))}{tr(!items.length ? <p className="model-picker-empty">{tr("Save a setup to use it again.")}</p> : null)}<button className="menu__item" role="menuitem" onClick={() => { setOpen(false); setSave(true); setName(''); }}><Icon name="plus"/>{tr("Save current settings\u2026")}</button></FloatingMenu> : null)}<Dialog open={save} compact title={tr("Saved settings")} onClose={() => setSave(false)} actions={<><Btn onClick={() => setSave(false)}>{tr("Done")}</Btn><Btn variant="primary" disabled={!name.trim()} onClick={() => { if (persist([...items, { id: crypto.randomUUID(), name: name.trim(), values }]))
|
||||
return <><button className="st-btn st-btn--ghost st-btn--sm" ref={trigger} aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(v => !v)}><Icon name="layers"/>{tr("Saved settings ")}<Icon name="chevron" size={12}/></button>{tr(open ? <FloatingMenu anchor={trigger.current} title={tr("Saved settings")} width={290} onClose={() => setOpen(false)}><p className="model-picker-heading">{tr("Saved settings")}</p>{tr(items.map(item => <button className="menu__item" role="menuitem" key={item.id} onClick={() => { onApply(item.values); setOpen(false); }}><Icon name="sliders"/><span>{item.name}<small>{tr(item.values.model)} · {tr((item.values.context ?? 32768) / 1024)}{tr("K")}</small></span></button>))}{tr(!items.length ? <p className="model-picker-empty">{tr("Save a setup to use it again.")}</p> : null)}<button className="menu__item" role="menuitem" onClick={() => { setOpen(false); setSave(true); setName(''); }}><Icon name="plus"/>{tr("Save current settings\u2026")}</button></FloatingMenu> : null)}<Dialog open={save} compact title={tr("Saved settings")} onClose={() => setSave(false)} actions={<><Btn onClick={() => setSave(false)}>{tr("Done")}</Btn><Btn variant="primary" disabled={!name.trim()} onClick={() => { if (persist([...items, { id: crypto.randomUUID(), name: name.trim(), values }]))
|
||||
setSave(false); }}>{tr("Save")}</Btn></>}><label className="input-field"><span>{tr("Name")}</span><input className="st-input" value={name} maxLength={80} onChange={e => setName(e.target.value)} placeholder={tr("Everyday")}/></label>{tr(items.map(item => <div className="row row--between" key={item.id}><span>{item.name}</span><Btn size="sm" variant="ghost" onClick={() => persist(items.filter(i => i.id !== item.id))}>{tr("Remove")}</Btn></div>))}{tr(error ? <p role="alert" className="small inline-error">{tr(error)}</p> : null)}</Dialog></>;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { api } from '../lib/client';
|
||||
import { refreshNative, useNative, type NativeField } from '../lib/native';
|
||||
import { Btn, ErrorNotice, Select } from './ui';
|
||||
import { speedOptions } from '../lib/deepseek-speed';
|
||||
import { draftingChoices } from '../lib/drafting';
|
||||
import type { RunCard } from '../lib/native-fit';
|
||||
import { NativeMcpEditor } from './NativeMcpEditor';
|
||||
/** One of the speed features: what it is called, what it does, whether it is on by default, and the engine environment it writes outside its default (server/native-settings.mjs). */
|
||||
@@ -114,15 +115,18 @@ export function NativeSettings({ model }: {
|
||||
<summary>{tr('Native engine settings')}</summary>
|
||||
{error ? <><ErrorNotice message={tr('Couldn’t load model settings.')} next={tr('Check that the engine is connected and this model is installed, then try again.')} onRetry={() => setRevision(value => value + 1)}/><details className="native-settings-diagnostic"><summary className="small">{tr('Details')}</summary><p className="small muted">{error}</p></details></> : <p className="small muted" role="status">{tr('Reading saved settings…')}</p>}
|
||||
</details>;
|
||||
const rows = (native?.fields ?? []).filter(f => (!f.forModel || (model.toLowerCase().includes(f.forModel) && (f.id !== 'ds_experts' || dsExperts))) && `${tr(f.label)} ${f.id} ${tr(f.group)}`.toLowerCase().includes(query.toLowerCase()));
|
||||
const family = (native?.catalog?.families ?? []).find(f => f.sizes.some(s => s.id === model))?.family ?? null;
|
||||
const draftingOffered = !!draftingChoices(family, native?.capabilities?.dflash2);
|
||||
const rows = (native?.fields ?? []).filter(f => (!f.forModel || (model.toLowerCase().includes(f.forModel) && (f.id !== 'ds_experts' || dsExperts))) && (!f.notForModel || !model.toLowerCase().includes(f.notForModel)) && (f.id !== 'drafting' || draftingOffered) && `${tr(f.label)} ${f.id} ${tr(f.group)}`.toLowerCase().includes(query.toLowerCase()));
|
||||
const switchRows = switches.filter(sw => `${tr(sw.label)} ${sw.id} ${tr('Speed')}`.toLowerCase().includes(query.toLowerCase()));
|
||||
return <details className="native-settings"><summary>{tr("Native engine settings")}</summary>{tr(tuned?.changed?.length ? <div className="native-tuned"><p className="small"><strong>{tr("Your tuning '{0}' sets", [tuned.preset?.name ?? ''])}</strong></p><ul>{tuned.changed.map(c => { const d = describeChange(c); return <li key={c.flag}><span>{d.name}</span><b>{d.now || tr('off')}</b>{d.was !== null ? <small className="muted">{tr("was {0}", [d.was])}</small> : null}</li>; })}</ul></div> : null)}<p className="small muted">{tr(!original ? 'Reading saved settings… ' : '')}{tr("Settings for this model. Blank values use Strata\u2019s defaults.")}</p><div className="editor-tabs" role="tablist" aria-label={tr("Native settings editor")}>{tr(['fields', 'json', 'tools'].map(t => <button key={t} role="tab" aria-selected={tab === t} onClick={() => chooseTab(t)}>{tr(t === 'fields' ? 'Parameters' : t === 'json' ? 'JSON' : 'MCP tools')}</button>))}</div>
|
||||
{tr(tab === 'fields' ? <><input className="st-input" type="search" aria-label={tr("Search native parameters")} placeholder={tr("Search parameters…")} value={query} onChange={e => setQuery(e.target.value)}/>{tr(['Responses', 'GPU & memory', 'Serving', 'Image reading', 'Tools', 'Recovery'].map(group => rows.some(f => f.group === group) ? <section className="native-settings-group" key={group}><h3 className="h4">{tr(group)}</h3>{tr(rows.filter(f => f.group === group).map(f => <label className="native-setting-row" key={f.id}><span><strong>{tr(f.label)}</strong>{tr(f.supported === false ? <small>{tr("Requires a newer Strata installation.")}</small> : f.hint ? <small>{tr(f.hint)}</small> : null)}</span><NativeFieldInput field={f} value={values[f.id]} onChange={v => change(f.id, v)}/></label>))}</section> : null))}{tr(switchRows.length ? <section className="native-settings-group"><h3 className="h4">{tr("Speed")}</h3>{tr(switchRows.map(sw => <label className="native-setting-row" key={sw.id}><span><strong>{tr(sw.label)}</strong><small>{tr(sw.hint)}</small></span><Select ariaLabel={tr(sw.label)} value={switchIsOn(sw, values.env) ? 'on' : 'off'} options={sw.default === 'on' ? [{ value: 'on', label: 'On (recommended)' }, { value: 'off', label: 'Off' }] : [{ value: 'off', label: 'Off (recommended)' }, { value: 'on', label: 'On' }]} onChange={v => changeSwitch(sw, v === 'on')}/></label>))}</section> : null)}<section className="native-settings-group"><h3 className="h4">{tr("Sampling")}</h3>{tr([['temperature', 'Temperature'], ['top_p', 'Top-p'], ['top_k', 'Top-k'], ['min_p', 'Min-p'], ['repetition_penalty', 'Repetition penalty'], ['presence_penalty', 'Presence penalty'], ['frequency_penalty', 'Frequency penalty'], ['penalty_last_n', 'Penalty window'], ['seed', 'Seed']].map(([id, label]) => <label className="native-setting-row" key={id}><span><strong>{tr(label)}</strong></span><DecimalInput ariaLabel={tr("Native {0}", [label])} value={values.sampling?.[id] ?? null} onChange={n => { const sampling = { ...values.sampling }; if (n === null) delete sampling[id]; else sampling[id] = n; change('sampling', sampling); }}/></label>))}</section></> : tab === 'json' ? <><p className="small muted">{tr("Engine arguments and STRATA environment flags are editable here. Program paths and network access remain managed by the host.")}</p><textarea className="st-input native-json" aria-label={tr("Native configuration JSON")} spellCheck={false} value={text} onChange={e => { setText(e.target.value); setNotice(''); }}/></> : <><p className="small muted">{tr("Add local programs or HTTP MCP servers. Connected tools can read or change the resources you give them access to. Enable tools in chat when you want the model to use them.")}</p><NativeMcpEditor text={mcp} onChange={value => { setMcp(value); setNotice(''); }}/><details><summary className="small">{tr("Configuration example")}</summary><pre className="native-example">{'{\n "files": {\n "command": "npx",\n "args": ["-y", "@modelcontextprotocol/server-filesystem", "/your/folder"],\n "disabled": false\n }\n}'}</pre><p className="xs muted">{tr("Saved credentials are masked. Keep the mask to preserve an existing value; type a new value to replace it.")}</p></details></>)}
|
||||
{tr(tab === 'fields' ? <><input className="st-input" type="search" aria-label={tr("Search native parameters")} placeholder={tr("Search parameters…")} value={query} onChange={e => setQuery(e.target.value)}/>{tr(['Responses', 'GPU & memory', 'Serving', 'Image reading', 'Tools', 'Recovery'].map(group => rows.some(f => f.group === group) ? <section className="native-settings-group" key={group}><h3 className="h4">{tr(group)}</h3>{tr(rows.filter(f => f.group === group).map(f => <label className="native-setting-row" key={f.id}><span><strong>{tr(f.label)}</strong>{tr(f.supported === false ? <small>{tr("Requires a newer Strata installation.")}</small> : f.hint ? <small>{tr(f.hint)}</small> : null)}</span><NativeFieldInput field={f} model={model} value={values[f.id]} onChange={v => change(f.id, v)}/></label>))}</section> : null))}{tr(switchRows.length ? <section className="native-settings-group"><h3 className="h4">{tr("Speed")}</h3>{tr(switchRows.map(sw => <label className="native-setting-row" key={sw.id}><span><strong>{tr(sw.label)}</strong><small>{tr(sw.hint)}</small></span><Select ariaLabel={tr(sw.label)} value={switchIsOn(sw, values.env) ? 'on' : 'off'} options={sw.default === 'on' ? [{ value: 'on', label: 'On (recommended)' }, { value: 'off', label: 'Off' }] : [{ value: 'off', label: 'Off (recommended)' }, { value: 'on', label: 'On' }]} onChange={v => changeSwitch(sw, v === 'on')}/></label>))}</section> : null)}<section className="native-settings-group"><h3 className="h4">{tr("Sampling")}</h3>{tr([['temperature', 'Temperature'], ['top_p', 'Top-p'], ['top_k', 'Top-k'], ['min_p', 'Min-p'], ['repetition_penalty', 'Repetition penalty'], ['presence_penalty', 'Presence penalty'], ['frequency_penalty', 'Frequency penalty'], ['penalty_last_n', 'Penalty window'], ['seed', 'Seed']].map(([id, label]) => <label className="native-setting-row" key={id}><span><strong>{tr(label)}</strong></span><DecimalInput ariaLabel={tr("Native {0}", [label])} value={values.sampling?.[id] ?? null} onChange={n => { const sampling = { ...values.sampling }; if (n === null) delete sampling[id]; else sampling[id] = n; change('sampling', sampling); }}/></label>))}</section></> : tab === 'json' ? <><p className="small muted">{tr("Engine arguments and STRATA environment flags are editable here. Program paths and network access remain managed by the host.")}</p><textarea className="st-input native-json" aria-label={tr("Native configuration JSON")} spellCheck={false} value={text} onChange={e => { setText(e.target.value); setNotice(''); }}/></> : <><p className="small muted">{tr("Add local programs or HTTP MCP servers. Connected tools can read or change the resources you give them access to. Enable tools in chat when you want the model to use them.")}</p><NativeMcpEditor text={mcp} onChange={value => { setMcp(value); setNotice(''); }}/><details><summary className="small">{tr("Configuration example")}</summary><pre className="native-example">{'{\n "files": {\n "command": "npx",\n "args": ["-y", "@modelcontextprotocol/server-filesystem", "/your/folder"],\n "disabled": false\n }\n}'}</pre><p className="xs muted">{tr("Saved credentials are masked. Keep the mask to preserve an existing value; type a new value to replace it.")}</p></details></>)}
|
||||
<div className="native-settings-actions"><Btn disabled={pending || !original || !dirty} variant="primary" onClick={() => void save()}>{tr(pending ? 'Saving…' : 'Save settings')}</Btn><Btn disabled={pending} onClick={() => { const saved = JSON.parse(original || '{"values":{},"mcp":{}}'); setValues(saved.values); setText(JSON.stringify(saved.values, null, 2)); setMcp(JSON.stringify(saved.mcp, null, 2)); setError(''); }}>{tr("Restore saved")}</Btn></div>{tr(notice ? <p role="status" className="small">{tr(notice)}</p> : null)}{tr(error ? <ErrorNotice message={error} onRetry={() => setRevision(value => value + 1)}/> : null)}
|
||||
</details>;
|
||||
}
|
||||
function NativeFieldInput({ field, value, onChange }: {
|
||||
function NativeFieldInput({ field, value, onChange, model }: {
|
||||
field: NativeField;
|
||||
model?: string;
|
||||
value: any;
|
||||
onChange: (v: any) => void;
|
||||
}) {
|
||||
@@ -131,7 +135,7 @@ function NativeFieldInput({ field, value, onChange }: {
|
||||
if (field.type === 'boolean')
|
||||
return <Select disabled={field.supported === false} ariaLabel={tr(field.label)} value={value === null || value === undefined ? '' : String(value)} options={[{ value: '', label: 'Default' }, { value: 'true', label: 'On' }, { value: 'false', label: 'Off' }]} onChange={v => onChange(v === '' ? null : v === 'true')}/>;
|
||||
if (field.id === 'ds_experts')
|
||||
return <DeepSeekSpeed field={field} value={value} onChange={onChange}/>;
|
||||
return <DeepSeekSpeed field={field} model={model} value={value} onChange={onChange}/>;
|
||||
if (field.type === 'enum')
|
||||
return <Select disabled={field.supported === false} ariaLabel={tr(field.label)} value={value ?? ''} options={[{ value: '', label: 'Default' }, ...(field.choices ?? []).map(v => ({ value: v, label: v }))]} onChange={v => onChange(v || null)}/>;
|
||||
if (field.type === 'number')
|
||||
@@ -139,9 +143,11 @@ function NativeFieldInput({ field, value, onChange }: {
|
||||
return <input disabled={field.supported === false} className="st-input" aria-label={tr(field.label)} placeholder={tr("Default")} value={Array.isArray(value) ? value.join(', ') : value ?? ''} onChange={e => onChange(e.target.value === '' ? null : field.type === 'array' ? e.target.value.split(',').map(s => s.trim()).filter(Boolean) : field.type === 'number' ? Number(e.target.value) : e.target.value)}/>;
|
||||
}
|
||||
/** DeepSeek's Speed choice. The engine decides the default itself (--experts auto); the labels name the same default (lib/deepseek-speed.ts). */
|
||||
function DeepSeekSpeed({ field, value, onChange }: { field: NativeField; value: any; onChange: (v: any) => void }) {
|
||||
function DeepSeekSpeed({ field, value, onChange, model }: { field: NativeField; model?: string; value: any; onChange: (v: any) => void }) {
|
||||
const native = useNative();
|
||||
const options = speedOptions(native?.hardware?.gpus as RunCard[] | undefined).map(o => ({ ...o, label: tr(o.label) }));
|
||||
// the catalog row of this model says whether the engine gives 5 on RDNA4 cards here (one card, or several holding half of the experts)
|
||||
const size = (native?.catalog?.families ?? []).flatMap(f => f.sizes ?? []).find(s => s.model.toLowerCase() === (model ?? '').toLowerCase());
|
||||
const options = speedOptions(native?.hardware?.gpus as RunCard[] | undefined, size?.rdna4_faster).map(o => ({ ...o, label: tr(o.label) }));
|
||||
return <Select disabled={field.supported === false} ariaLabel={tr(field.label)} value={value === null || value === undefined ? '' : String(value)}
|
||||
options={options} onChange={v => onChange(v === '' ? null : v)}/>;
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@ import { tr } from '../lib/i18n';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Btn, Dialog, Select } from './ui';
|
||||
import { TuneRunView, TuneOutcomeCard } from './TuneRunView';
|
||||
import { readOutcome } from '../lib/tune-outcome';
|
||||
import { failureDetails, readOutcome } from '../lib/tune-outcome';
|
||||
import { go } from '../lib/store';
|
||||
import { nativeAction, useNative, type NativeJob } from '../lib/native';
|
||||
import { answerNotice, tunePresetAction, tuneStatus, tuneUndo, type TuneProgress, type TunePreset, type TuneResult, type TuneStatus } from '../lib/native-tune';
|
||||
import { TUNE_BLOCK_TEXT, answerNotice, tunePresetAction, tuneStartBlock, tuneStatus, tuneUndo, type TuneProgress, type TunePreset, type TuneResult, type TuneStatus } from '../lib/native-tune';
|
||||
|
||||
type Phase = 'idle' | 'running' | 'result';
|
||||
|
||||
@@ -14,12 +14,14 @@ type Phase = 'idle' | 'running' | 'result';
|
||||
* with the same saved tunings); this dialog only starts it, shows its progress and result in plain words, and lists, switches,
|
||||
* renames, deletes and undoes the saved tunings. It reuses the Auto-tune dialog's look.
|
||||
*/
|
||||
export function NativeTune({ open, onClose, model, resourceProfile, vision }: {
|
||||
export function NativeTune({ open, onClose, model, resourceProfile, vision, visionUnavailable = false }: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
model: string;
|
||||
resourceProfile: 'work' | 'performance';
|
||||
vision: string;
|
||||
/** The engine has no picture reader: the run goes without pictures and the model's own setting is left as it is. */
|
||||
visionUnavailable?: boolean;
|
||||
}) {
|
||||
const native = useNative();
|
||||
const [status, setStatus] = useState<TuneStatus | null>(null), [error, setError] = useState(''), [busy, setBusy] = useState(false);
|
||||
@@ -40,7 +42,7 @@ export function NativeTune({ open, onClose, model, resourceProfile, vision }: {
|
||||
async function start(kind: 'autotune' | 'recheck') {
|
||||
setBusy(true); setError('');
|
||||
try {
|
||||
const started = await nativeAction<NativeJob>('operations', { kind, args: { model, resourceProfile, vision, mode, use, allowSmallDifferences: allowSmall, presetId: status?.active?.id } });
|
||||
const started = await nativeAction<NativeJob>('operations', { kind, args: { model, resourceProfile, vision, ...(visionUnavailable ? { visionUnavailable: true } : {}), mode, use, allowSmallDifferences: allowSmall, presetId: status?.active?.id } });
|
||||
setJobId(started.id);
|
||||
} catch (e) { setError(e instanceof Error ? e.message : String(e)); }
|
||||
finally { setBusy(false); }
|
||||
@@ -57,6 +59,9 @@ export function NativeTune({ open, onClose, model, resourceProfile, vision }: {
|
||||
const result = job?.state === 'done' ? (job.result as TuneResult | null) : null;
|
||||
const ratio = progress && progress.runsPlanned ? Math.min(0.96, progress.runsDone / progress.runsPlanned) : 0;
|
||||
const active = status?.active ?? null;
|
||||
const block = tuneStartBlock({ busy, otherRunning: !!other, status, error });
|
||||
// The one sentence beside a greyed-out Start tuning, in the footer so it is never below the fold of a scrolling body (the same line serves Tune again and Check again).
|
||||
const why = block === 'other' || block === 'loading' || block === 'unreadable' ? <p className="small muted tune-why" role="status">{tr(TUNE_BLOCK_TEXT[block])}</p> : null;
|
||||
|
||||
const notice = (cls?: string, steady?: boolean) => cls ? <p className="small muted">{tr(answerNotice(cls, steady))}</p> : null;
|
||||
let body;
|
||||
@@ -67,10 +72,11 @@ export function NativeTune({ open, onClose, model, resourceProfile, vision }: {
|
||||
} else if (phase === 'result') {
|
||||
const outcome = readOutcome(result, job?.state);
|
||||
const ok = outcome.tone === 'ok';
|
||||
body = <div className="stack"><TuneOutcomeCard outcome={outcome} details={job?.state === 'error' ? (job.details || job.error) : null}/>{outcome.code === 'faster' ? notice(result?.answers?.class, result?.answers?.default_repeatable) : null}</div>;
|
||||
body = <div className="stack"><TuneOutcomeCard outcome={outcome} details={job?.state === 'error' ? (job.details || job.error) : outcome.code === 'failed' || outcome.code === 'busy_pc' ? failureDetails(job?.result as TuneResult | null, native?.hardware) : null}/>{outcome.code === 'faster' ? notice(result?.answers?.class, result?.answers?.default_repeatable) : null}</div>;
|
||||
const again = <Btn variant={ok ? undefined : 'primary'} disabled={busy || !!other} onClick={() => void start('autotune')}>{tr(outcome.code === 'faster' || outcome.code === 'already_fastest' || outcome.code === 'still_fast' ? 'Tune again' : 'Try again')}</Btn>;
|
||||
actions = <>
|
||||
{status?.can_undo && outcome.code === 'faster' ? <Btn disabled={busy} onClick={() => void act(async () => { await tuneUndo(model, resourceProfile, vision); setJobId(''); })}>{tr("Undo")}</Btn> : null}
|
||||
{why}
|
||||
<span className="tune-actions-gap"/>
|
||||
{ok ? <>{again}<Btn variant="primary" onClick={() => { close(); go('chat'); }}>{tr(outcome.code === 'faster' ? 'Start chatting' : 'Close')}</Btn></> : <><Btn onClick={close}>{tr("Close")}</Btn>{again}</>}
|
||||
</>;
|
||||
@@ -78,13 +84,12 @@ export function NativeTune({ open, onClose, model, resourceProfile, vision }: {
|
||||
const stale = active && status?.state && status.state !== 'fresh';
|
||||
body = <div className="stack">
|
||||
{error ? <p className="small inline-error" role="alert">{error}</p> : null}
|
||||
{active ? <div><p className="strong">{tr("Saved tuning in use: {0}", [active.name])}</p>
|
||||
{block === 'loading' ? <p className="small muted">{tr("Checking for saved tunings\u2026")}</p> : active ? <div><p className="strong">{tr("Saved tuning in use: {0}", [active.name])}</p>
|
||||
{active.gain_pct ? <p className="small muted">{tr("+{0}% faster answers", [Math.round(active.gain_pct)])}</p> : null}
|
||||
{notice(active.answers?.class, active.answers?.default_repeatable)}
|
||||
{stale ? <p className="small">{status?.state === 'hard' ? tr("This PC changed since it was tuned, so these settings are not used until you tune again.") : tr("This PC or the engine changed since it was tuned. A short check takes about 2 minutes.")}</p> : null}</div>
|
||||
: <p className="small muted">{tr("No saved tuning for these choices yet.")}</p>}
|
||||
: status ? <p className="small muted">{tr("No saved tuning for these choices yet.")}</p> : null}
|
||||
<p className="small muted">{tr("Strata tries different settings on this PC and keeps the ones that make answers faster. Your model is unloaded while this runs, and the computer will be busy.")}</p>
|
||||
{other ? <p className="small inline-error" role="alert">{tr("Finish the current operation first.")}</p> : null}
|
||||
<label className="input-field"><span>{tr("How long to spend")}</span><Select value={mode} ariaLabel={tr("How long to spend")} options={[{ value: 'quick', label: 'Quick (about 20 minutes)' }, { value: 'thorough', label: 'Thorough (up to 2 hours)' }]} onChange={v => setMode(v as 'quick' | 'thorough')}/></label>
|
||||
<label className="input-field"><span>{tr("What you mostly do")}</span><Select value={use} ariaLabel={tr("What you mostly do")} options={[{ value: 'chat', label: 'Chat' }, { value: 'coding', label: 'Coding with long prompts' }]} onChange={v => setUse(v as 'chat' | 'coding')}/></label>
|
||||
<label className="check-label"><input type="checkbox" checked={allowSmall} onChange={e => setAllowSmall(e.target.checked)}/>{tr("Allow settings that change answers very slightly (same quality)")}</label>
|
||||
@@ -98,9 +103,10 @@ export function NativeTune({ open, onClose, model, resourceProfile, vision }: {
|
||||
{renaming ? <div className="row"><input className="st-input" aria-label={tr("Name")} value={name} maxLength={80} onChange={e => setName(e.target.value)}/><Btn size="sm" variant="primary" disabled={!name.trim() || busy} onClick={() => void act(async () => { await tunePresetAction(renaming.id, 'rename', { name: name.trim() }); setRenaming(null); })}>{tr("Save")}</Btn><Btn size="sm" onClick={() => setRenaming(null)}>{tr("Cancel")}</Btn></div> : null}
|
||||
</div>;
|
||||
actions = <>
|
||||
{why}
|
||||
{status?.can_undo ? <Btn disabled={busy || !!other} onClick={() => void act(async () => { await tuneUndo(model, resourceProfile, vision); })}>{tr("Undo")}</Btn> : null}
|
||||
{active && status?.state === 'soft' ? <Btn disabled={busy || !!other} onClick={() => void start('recheck')}>{tr("Check again")}</Btn> : null}
|
||||
<Btn variant="primary" icon="gauge" disabled={busy || !!other || !status} onClick={() => void start('autotune')}>{tr(active ? "Tune again" : "Start tuning")}</Btn></>;
|
||||
<Btn variant="primary" icon="gauge" disabled={block !== null} onClick={() => void start('autotune')}>{tr(active ? "Tune again" : "Start tuning")}</Btn></>;
|
||||
}
|
||||
return <Dialog open={open} wide={phase !== 'idle'} title={phase === 'result' && readOutcome(result, job?.state).code === 'faster' ? tr("Tuned and saved") : tr("Tune this model")} onClose={close} actions={actions}>{body}</Dialog>;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect } from 'react';
|
||||
import { api } from '../lib/client';
|
||||
import { useStore } from '../lib/store';
|
||||
import { PcFitCard, type PcLive } from './PcFitCard';
|
||||
|
||||
const tenth = (n: number | null | undefined) => typeof n === 'number' && Number.isFinite(n) ? Math.round(n * 10) / 10 : null;
|
||||
|
||||
/**
|
||||
* "This computer" with the host's own readings: while the Models page is open the host reads the card every few seconds (the same ask the
|
||||
* Monitor makes), and the free graphics memory and RAM come from those readings, not from a guess. It subscribes to the three numbers only,
|
||||
* so the Models page itself does not redraw on every reading.
|
||||
*/
|
||||
export function PcComputer(props: Omit<React.ComponentProps<typeof PcFitCard>, 'live'> & { vramAllGb?: number }) {
|
||||
const vramUsed = useStore(s => tenth(s.monitor?.hardware?.vramUsedGb));
|
||||
const ramUsed = useStore(s => tenth(s.monitor?.hardware?.ramUsedGb));
|
||||
const diskRead = useStore(s => tenth(s.monitor?.hardware?.diskReadGbs));
|
||||
useEffect(() => {
|
||||
const ask = () => { if (typeof document === 'undefined' || document.visibilityState !== 'hidden') void api.get('/api/monitor').catch(() => undefined); };
|
||||
ask();
|
||||
const timer = setInterval(ask, 10000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
const vramTotal = props.vramAllGb ?? props.vramGb;
|
||||
const live: PcLive = {
|
||||
vramTotalGb: vramTotal,
|
||||
freeVramGb: vramUsed !== null && vramTotal > 0 ? Math.max(0, Math.round((vramTotal - vramUsed) * 10) / 10) : null,
|
||||
freeRamGb: ramUsed !== null && props.ramGb ? Math.max(0, Math.round((props.ramGb - ramUsed) * 10) / 10) : null,
|
||||
diskReadGbs: diskRead,
|
||||
};
|
||||
return <PcFitCard {...props} live={live}/>;
|
||||
}
|
||||
@@ -2,28 +2,43 @@ import { tr } from '../lib/i18n';
|
||||
import type { Fit } from '../lib/native-fit';
|
||||
|
||||
const gb = (n: number) => `${Number.isInteger(n) ? n : n.toFixed(1)} GB`;
|
||||
const about = (n: number) => `${Math.round(n)} GB`;
|
||||
|
||||
function Meter({ label, total, use }: { label: string; total: number; use: number }) {
|
||||
const pct = total > 0 ? Math.max(2, Math.min(100, (use / total) * 100)) : 0;
|
||||
return <div className="pc-meter">
|
||||
<div className="pc-meter__top"><span>{tr(label)}</span><b>{gb(use)}</b><span className="muted">{tr("of {0}", [gb(total)])}</span></div>
|
||||
<div className="pc-meter__track" role="img" aria-label={tr("{0}: about {1} of {2} used by this model", [tr(label), gb(use), gb(total)])}><i style={{ width: `${pct}%` }}/></div>
|
||||
/** What this computer has free right now (real readings of the host; null where one is not available). */
|
||||
export interface PcLive { vramTotalGb?: number; freeVramGb: number | null; freeRamGb: number | null; diskReadGbs: number | null }
|
||||
/** A model that is loaded: what it takes of graphics memory and RAM, and how much of it is read from the SSD (the app's estimate: the engine does not report this split). */
|
||||
export interface PcLoaded { id: string; label: string; fit: Fit }
|
||||
|
||||
function FreeMeter({ label, total, free }: { label: string; total: number; free: number }) {
|
||||
const used = total > 0 ? Math.max(0, Math.min(100, ((total - free) / total) * 100)) : 0;
|
||||
return <div className="pc-meter" data-low={free / (total || 1) < 0.1 || undefined}>
|
||||
<div className="pc-meter__top"><span>{tr(label)}</span><b>{tr("{0} free", [gb(free)])}</b><span className="muted">{tr("of {0}", [gb(total)])}</span></div>
|
||||
<div className="pc-meter__track" role="img" aria-label={tr("{0}: {1} free of {2}", [tr(label), gb(free), gb(total)])}><i style={{ transform: `scaleX(${used / 100})` }}/></div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* "This computer": the graphics memory and RAM, and what the chosen version would take of each. One plain line says whether it fits
|
||||
* or how much would be read from the SSD (the n-gram table, which always sits on the SSD, is not counted).
|
||||
* "This computer": how much graphics memory and RAM is free right now. While a model is loaded that is all the card shows: the hardware line, the model's
|
||||
* name, "Loaded", what it takes and the SSD line are gone (owner, 11-01: the page already says what is loaded); only while the loaded model really reads
|
||||
* from the SSD, how fast it reads right now. Before anything is loaded the card says what the chosen version would take. That a version does not fit in
|
||||
* memory is said once, under the version picker ("SSD streaming · will run slower"), not again here. (The n-gram table, which always sits on the SSD, is not counted.)
|
||||
*/
|
||||
export function PcFitCard({ gpuName, ramGb, vramGb, fit, inUse = null, diskGb = null }: { gpuName?: string | null; ramGb: number | null; vramGb: number; fit: Fit | null; inUse?: { ramGb: number; vramGb: number } | null; diskGb?: number | null }) {
|
||||
const freeVram = Math.max(0, vramGb - (inUse?.vramGb ?? 0)), freeRam = ramGb ? Math.max(0, ramGb - (inUse?.ramGb ?? 0)) : 0;
|
||||
return <section className="st-card pc-card">
|
||||
<div><h2 className="h4">{tr("This computer")}</h2>
|
||||
<p className="small muted">{tr(gpuName ? tr("{0} · {1} graphics memory", [gpuName, gb(vramGb)]) : tr("No supported graphics card reported by this installation."))}{ramGb ? tr(" · {0} RAM", [gb(ramGb)]) : ''}</p></div>
|
||||
{tr(fit?.known && vramGb > 0 ? <Meter label="Graphics memory" total={freeVram} use={fit.vramUseGb}/> : null)}
|
||||
{tr(fit?.known && ramGb ? <Meter label="RAM" total={freeRam} use={fit.ramUseGb}/> : null)}
|
||||
export function PcFitCard({ gpuName, ramGb, vramGb, fit, inUse = null, diskGb = null, live = null, loaded = [], selectedLoaded = false }: { gpuName?: string | null; ramGb: number | null; vramGb: number; fit: Fit | null; inUse?: { ramGb: number; vramGb: number } | null; diskGb?: number | null; live?: PcLive | null; loaded?: PcLoaded[]; selectedLoaded?: boolean }) {
|
||||
const showFit = !!fit?.known && !selectedLoaded;
|
||||
const isLoaded = loaded.length > 0;
|
||||
const streamed = Math.max(0, ...loaded.map(m => (m.fit.known ? m.fit.spillGb : 0)));
|
||||
return <section className="st-card pc-card" data-loaded={isLoaded ? true : undefined}>
|
||||
{isLoaded ? <h2 className="h4">{tr("This computer")}</h2> : <div><h2 className="h4">{tr("This computer")}</h2>
|
||||
<p className="small muted">{tr(gpuName ? tr("{0} · {1} graphics memory", [gpuName, gb(vramGb)]) : tr("No supported graphics card reported by this installation."))}{ramGb ? tr(" · {0} RAM", [gb(ramGb)]) : ''}</p></div>}
|
||||
{tr(live && (live.freeVramGb !== null || live.freeRamGb !== null) ? <div className="pc-free">
|
||||
<h3 className="pc-label">{tr("Free right now")}</h3>
|
||||
{tr(live.freeVramGb !== null && vramGb > 0 ? <FreeMeter label="Graphics memory" total={live.vramTotalGb ?? vramGb} free={live.freeVramGb}/> : null)}
|
||||
{tr(live.freeRamGb !== null && ramGb ? <FreeMeter label="RAM" total={ramGb} free={live.freeRamGb}/> : null)}
|
||||
</div> : null)}
|
||||
{tr(isLoaded && streamed >= 1 && live?.diskReadGbs && live.diskReadGbs >= 0.05 ? <p className="pc-note">{tr("Reading from your SSD now: {0}", [`${live.diskReadGbs.toFixed(1)} GB/s`])}</p> : null)}
|
||||
{tr(inUse ? <p className="xs muted">{tr("Models that stay loaded use about {0} GB of RAM and {1} GB of this card’s memory.", [inUse.ramGb, inUse.vramGb])}</p> : null)}
|
||||
{tr(showFit && !isLoaded && fit!.vramUseGb + fit!.ramUseGb > 0 ? <p className="pc-note">{tr("This version would take about {0} of graphics memory and {1} of RAM.", [about(fit!.vramUseGb), about(fit!.ramUseGb)])}</p> : null)}
|
||||
{tr(showFit && diskGb && fit!.spillGb >= 1 ? <p className="pc-note">{tr("About {0} GB will be read from the SSD, so answers can be a little slower.", [Math.round(fit!.spillGb)])}</p> : null)}
|
||||
{tr(diskGb ? <p className="pc-note">{tr("Needs about {0} GB of free disk space. What does not fit in memory is read from your SSD.", [Math.round(diskGb)])}</p> : null)}
|
||||
{tr(fit?.known && (diskGb ? fit.spillGb >= 1 : true) ? <p className="pc-note">{tr(fit.spillGb >= 1 ? 'About {0} GB will be read from the SSD, so answers can be a little slower.' : 'Fits fully in memory.', [Math.round(fit.spillGb)])}</p> : null)}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,17 +1,10 @@
|
||||
import { tr } from '../lib/i18n';
|
||||
|
||||
const NOTES = [
|
||||
'Strata sends no usage telemetry.',
|
||||
'Conversations stay in browser storage.',
|
||||
'When you choose an API model, that conversation and enabled skill instructions are sent to its provider.',
|
||||
'The UI request log keeps client, timing and token metadata in memory.',
|
||||
'Native API monitoring can retain request content in memory when enabled.',
|
||||
'Connected tools access the resources you configure.',
|
||||
];
|
||||
/** What Strata collects and where things go: one compact paragraph (the six sentences read together, translated as one in every language). */
|
||||
const NOTES = 'Strata sends no usage telemetry. Conversations stay in browser storage. When you choose an API model, that conversation and enabled skill instructions are sent to its provider. The UI request log keeps client, timing and token metadata in memory. Native API monitoring can retain request content in memory when enabled. Connected tools access the resources you configure.';
|
||||
|
||||
/** What Strata collects and where things go: plain lines of text, nothing else. */
|
||||
export function PrivacyNotes() {
|
||||
return <div className="privacy-notes" aria-label={tr("Collection and logs")}>
|
||||
{NOTES.map(text => <p key={text}>{tr(text)}</p>)}
|
||||
<p>{tr(NOTES)}</p>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/** A question the agent asked the person, in the style of the approval card: options to pick, the person's own words, one answer or a skip (#4244). */
|
||||
import { useState } from 'react';
|
||||
import { tr } from '../lib/i18n';
|
||||
import { Btn, Icon } from './ui';
|
||||
import { answerAgentAsk } from '../lib/store';
|
||||
import { plainFailureText } from '../lib/plain-errors';
|
||||
import { answerComplete, toAnswers, type AgentAsk, type AgentQuestion } from '../lib/agent-ask';
|
||||
|
||||
type Draft = Record<string, { selected: string[]; custom: string }>;
|
||||
|
||||
export function QuestionCard({ sessionId, ask }: { sessionId: string; ask: AgentAsk }) {
|
||||
const [draft, setDraft] = useState<Draft>({});
|
||||
const [busy, setBusy] = useState(false), [problem, setProblem] = useState('');
|
||||
const current = (q: AgentQuestion) => draft[q.id] ?? { selected: [], custom: '' };
|
||||
const pick = (q: AgentQuestion, label: string) => setDraft(d => {
|
||||
const cur = d[q.id] ?? { selected: [], custom: '' };
|
||||
const selected = q.multiSelect ? (cur.selected.includes(label) ? cur.selected.filter(x => x !== label) : [...cur.selected, label]) : [label];
|
||||
// One choice for a single-select question: picking an option replaces words typed instead.
|
||||
return { ...d, [q.id]: { selected, custom: q.multiSelect ? cur.custom : '' } };
|
||||
});
|
||||
const type = (q: AgentQuestion, custom: string) => setDraft(d => {
|
||||
const cur = d[q.id] ?? { selected: [], custom: '' };
|
||||
return { ...d, [q.id]: { selected: q.multiSelect || !custom.trim() ? cur.selected : [], custom } };
|
||||
});
|
||||
async function send(skip: boolean) {
|
||||
if (busy) return;
|
||||
setBusy(true); setProblem('');
|
||||
try { await answerAgentAsk(sessionId, ask.callId, skip ? null : toAnswers(ask.questions, draft)); }
|
||||
catch (e) { setProblem(plainFailureText(e instanceof Error ? e.message : String(e)).message); setBusy(false); }
|
||||
}
|
||||
return <section className="askcard askq" role="group" aria-label={tr("The assistant has a question for you")}>
|
||||
{ask.questions.map(q => {
|
||||
const cur = current(q);
|
||||
return <div className="askq__item" key={q.id}>
|
||||
<div className="askcard__top"><span className="askcard__icon"><Icon name="chat" size={18}/></span>
|
||||
<div className="askcard__text">{q.header ? <p className="xs muted">{q.header}</p> : null}<p className="askcard__q">{q.question}</p>{q.detail ? <p className="small muted askq__detail">{q.detail}</p> : null}</div></div>
|
||||
{q.options.length ? <div className="askq__opts" role={q.multiSelect ? 'group' : 'radiogroup'} aria-label={q.question}>{q.options.map(o => {
|
||||
const on = cur.selected.includes(o.label);
|
||||
return <button type="button" key={o.label} role={q.multiSelect ? 'checkbox' : 'radio'} aria-checked={on} className="askq__opt" data-on={on || undefined} disabled={busy} onClick={() => pick(q, o.label)}><span>{o.label}</span>{o.description ? <small className="muted">{o.description}</small> : null}</button>;
|
||||
})}</div> : null}
|
||||
<input className="st-input askq__own" aria-label={tr("Type your own answer")} placeholder={tr("Type your own answer")} value={cur.custom} maxLength={2000} disabled={busy} onChange={e => type(q, e.target.value)}/>
|
||||
</div>;
|
||||
})}
|
||||
{problem ? <p className="small inline-error askq__problem" role="alert">{problem}</p> : null}
|
||||
<div className="askq__acts"><Btn size="sm" disabled={busy} onClick={() => void send(true)}>{tr("Skip")}</Btn><Btn variant="primary" size="sm" disabled={busy || !answerComplete(ask.questions, draft)} onClick={() => void send(false)}>{tr("Send answer")}</Btn></div>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useState } from 'react';
|
||||
import { localeTag, tr } from '../lib/i18n';
|
||||
import { useNative } from '../lib/native';
|
||||
import { changeInstalledEngine, engineMake, engineSize, modelsNeedingNewer, useEngineList, type InstalledEngine } from '../lib/engines';
|
||||
import { ApiError } from '../lib/client';
|
||||
import { Btn, Dialog, ErrorNotice, Icon } from './ui';
|
||||
|
||||
/** Why this computer cannot run an engine: Control gives a code and its values, the page words it (so every language has it). */
|
||||
function unfitWords(engine: InstalledEngine): string {
|
||||
const a = engine.why_args ?? [];
|
||||
switch (engine.why_code) {
|
||||
case 'os': return tr("This engine was made for a different operating system.");
|
||||
case 'gpu_make': return tr("This engine was made for a different kind of graphics card than this computer has.");
|
||||
case 'strata': return tr("This engine needs Strata {0} or newer.", [a[0] ?? '']);
|
||||
case 'gpu_arch': return tr("This engine doesn't support this computer's graphics card.");
|
||||
case 'driver': return tr("This engine needs NVIDIA driver {0} or newer. This computer has {1}.", [a[0] ?? '', a[1] ?? '']);
|
||||
case 'files': return tr("Some of this engine's files are missing or changed, so it can't be started.");
|
||||
default: return tr("This engine's description is missing or damaged, so it can't be started.");
|
||||
}
|
||||
}
|
||||
|
||||
/** Settings > Engine: the engines installed on this computer. Switch to any of them (the newest too) or delete one. Updating is in About. */
|
||||
export function SettingsEngine() {
|
||||
const native = useNative();
|
||||
const { list, failed, reload } = useEngineList();
|
||||
const [use, setUse] = useState<InstalledEngine | null>(null);
|
||||
const [del, setDel] = useState<InstalledEngine | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<{ message: string; detail?: string } | null>(null);
|
||||
const [said, setSaid] = useState('');
|
||||
const running = (native?.instances.length ?? 0) > 0;
|
||||
const busy = !!native?.jobs.some(j => j.state === 'running') || pending;
|
||||
const groups = list?.families ?? [];
|
||||
const count = groups.reduce((sum, g) => sum + g.engines.length, 0);
|
||||
|
||||
async function run(action: 'use' | 'remove', engine: InstalledEngine) {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
setSaid('');
|
||||
try {
|
||||
await changeInstalledEngine(action, engine);
|
||||
setSaid(action === 'use' ? tr("Engine {0} is now in use.", [engine.version]) : tr("Engine {0} was deleted.", [engine.version]));
|
||||
} catch (e) {
|
||||
const body = e instanceof ApiError ? e.body as { details?: string } | undefined : undefined;
|
||||
setError({ message: e instanceof Error ? e.message : String(e), detail: body?.details });
|
||||
} finally {
|
||||
setUse(null);
|
||||
setDel(null);
|
||||
setPending(false);
|
||||
void reload();
|
||||
}
|
||||
}
|
||||
|
||||
function row(engine: InstalledEngine) {
|
||||
const facts = [engineMake(engine.backend), engineSize(engine.bytes, localeTag()), engine.newest ? tr("Newest") : ''].filter(Boolean).join(' · ');
|
||||
return <div className="engine-row" key={`${engine.root ?? ''}${engine.folder}`} data-active={engine.active ? 'true' : undefined} data-unfit={engine.fits ? undefined : 'true'}>
|
||||
<Icon name="engine" size={20}/>
|
||||
<span className="engine-row__text">
|
||||
<strong>{engine.version}</strong>
|
||||
<small>{facts}</small>
|
||||
{engine.fits ? null : <small className="engine-row__why">{unfitWords(engine)}</small>}
|
||||
</span>
|
||||
{engine.active ? <span className="engine-row__state">{tr("In use")}</span> : <>
|
||||
{engine.can_use ? <Btn size="sm" disabled={busy} onClick={() => { setError(null); setSaid(''); setUse(engine); }}>{tr("Use this")}</Btn> : null}
|
||||
<Btn size="sm" variant="ghost" disabled={busy || !engine.can_delete} title={engine.blocked_code === 'running' ? tr("A running program is using this engine. Unload your models first.") : undefined} onClick={() => { setError(null); setSaid(''); setDel(engine); }}>{tr("Delete")}</Btn>
|
||||
</>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
const needNewer = use ? modelsNeedingNewer(native, use) : [];
|
||||
return <div className="stack stack--lg native-engine-settings">
|
||||
<section className="st-card engine-list">
|
||||
<div className="section-toolbar"><h2 className="h3">{tr("Installed engines")}</h2></div>
|
||||
<p className="small muted">{tr("Switch to an older engine to test something, then switch back to the newest. Updating to the latest is in About.")}</p>
|
||||
{list && !list.available ? <p className="small muted">{tr("Engines can be switched and deleted in the installed Strata app.")}</p> : null}
|
||||
{failed ? <p className="small inline-error" role="alert">{tr("The installed engines couldn't be read. Try again in a moment.")}</p> : null}
|
||||
{list?.available && count === 0 ? <p className="small muted">{tr("No engine is installed on this computer yet. Update in About downloads the right one.")}</p> : null}
|
||||
{groups.map(group => <div key={group.family} className="engine-group">
|
||||
{groups.length > 1 ? <h3 className="engine-group__title">{tr(group.title)}</h3> : null}
|
||||
{group.engines.map(row)}
|
||||
</div>)}
|
||||
{count > 0 ? <p className="small muted engine-total">{tr("Engines take {0} on this computer.", [engineSize(list?.total_bytes ?? 0, localeTag())])}</p> : null}
|
||||
{count > 0 ? <details className="native-settings-diagnostic engine-details"><summary className="small">{tr("Details")}</summary>{groups.flatMap(g => g.engines).map(e => <p key={`${e.root ?? ''}${e.folder}`} className="small muted">{e.version} · {e.path}{e.build_id ? ` · ${e.build_id}` : ''}</p>)}</details> : null}
|
||||
<div role="status" aria-live="polite">{said ? <p className="small">{said}</p> : null}</div>
|
||||
{error ? <ErrorNotice message={error.message} detail={error.detail}/> : null}
|
||||
</section>
|
||||
|
||||
<Dialog open={!!use} compact title={tr("Use engine {0}?", [use?.version ?? ''])} onClose={() => { if (!pending) setUse(null); }}
|
||||
actions={<><Btn disabled={pending} onClick={() => setUse(null)}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={pending || running} onClick={() => use && void run('use', use)}>{pending ? tr("Working…") : tr("Use engine {0}", [use?.version ?? ''])}</Btn></>}>
|
||||
<p>{tr("Models you start from now on run on engine {0}. Your models and settings stay as they are. You can switch back at any time.", [use?.version ?? ''])}</p>
|
||||
{needNewer.length ? <p className="small muted">{tr("These models need a newer engine and may not start on it: {0}.", [needNewer.join(', ')])}</p> : null}
|
||||
{running ? <p className="small muted" role="status">{tr("A model is running. Unload it in Models first, then switch.")}</p> : null}
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!del} compact title={tr("Delete engine {0}?", [del?.version ?? ''])} onClose={() => { if (!pending) setDel(null); }}
|
||||
actions={<><Btn disabled={pending} onClick={() => setDel(null)}>{tr("Keep engine")}</Btn><Btn variant="danger" disabled={pending} onClick={() => del && void run('remove', del)}>{pending ? tr("Working…") : tr("Delete engine")}</Btn></>}>
|
||||
<p>{tr("This frees {0}. Your models and settings are kept.", [engineSize(del?.bytes ?? 0, localeTag())])}</p>
|
||||
<p className="small muted">{del?.newest ? tr("The newest engine can be downloaded again with Update in About.") : tr("Strata can only download the newest engine, so a deleted older engine can't be brought back.")}</p>
|
||||
</Dialog>
|
||||
</div>;
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import {localeTag} from '../lib/i18n';
|
||||
import { tr } from '../lib/i18n';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Btn, Dialog, Icon, Select } from './ui';
|
||||
import { Btn, Dialog, Icon } from './ui';
|
||||
import { useStore } from '../lib/store';
|
||||
import type { SoftwareUpdate, UpdateCheckResult } from '../lib/types';
|
||||
import { nativeAction, useNative } from '../lib/native';
|
||||
import { NativeProgress } from './NativeModels';
|
||||
import { EngineUpdateDialog } from './EngineUpdateDialog';
|
||||
import { jobErrorText } from '../lib/native-job-diagnostics';
|
||||
import { appVersionLabel } from '../lib/app-version';
|
||||
import { fetchUpdateCheck, publishUpdateCheck, useInstalledVersions, useLastUpdateCheck } from '../lib/update-check';
|
||||
@@ -21,8 +22,6 @@ export function SettingsUpdates() {
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const appVersion = appVersionLabel(useStore(s => s.setup?.app.version), native?.version);
|
||||
const engineVersion = useStore(s => s.engine?.engine.version);
|
||||
const [rollback, setRollback] = useState(false);
|
||||
const [component, setComponent] = useState<'strata' | 'engine'>('strata');
|
||||
const [checking, setChecking] = useState(false);
|
||||
// Opening Settings shows the last check kept on this computer. The network is touched only when "Check for updates" is pressed.
|
||||
const versions = useInstalledVersions();
|
||||
@@ -31,15 +30,15 @@ export function SettingsUpdates() {
|
||||
const [error, setError] = useState('');
|
||||
const request = useRef<AbortController | null>(null);
|
||||
useEffect(() => () => request.current?.abort(), []);
|
||||
const installed = component === 'strata' ? appVersion : native?.available ? (native.installed?.engine?.version ?? 'Not installed') : shownEngineVersion(native, engineVersion);
|
||||
const active = native?.jobs.find(j => j.state === 'running');
|
||||
const result = checking || error ? null : (fresh ?? last);
|
||||
// Update engine with a model loaded: its own dialog (warns naming the model, stops it with the person's yes, offers to start it again).
|
||||
const withModel = (native?.instances.length ?? 0) > 0 && !!native?.capabilities.componentUpdates;
|
||||
async function operate(kind: string) {
|
||||
setError('');
|
||||
try {
|
||||
await nativeAction('operations', { kind });
|
||||
setConfirm('');
|
||||
setRollback(false);
|
||||
}
|
||||
catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
@@ -80,23 +79,16 @@ export function SettingsUpdates() {
|
||||
<div className="settings-versions" aria-busy={checking}>
|
||||
{tr(versionRow('Strata', appVersion, result?.strata))}
|
||||
{tr(versionRow('Engine', native?.available ? (native.installed?.engine?.version ?? 'Not installed') : shownEngineVersion(native, engineVersion), result?.engine))}
|
||||
<div className="settings-update-actions"><Btn size="sm" icon="refresh" disabled={checking} className={checking ? 'settings-update-checking' : undefined} onClick={() => void check()}>{tr(checking ? 'Checking…' : error || result?.strata.status === 'error' ? 'Try again' : result ? 'Check again' : 'Check for updates')}</Btn><Btn size="sm" variant="ghost" onClick={() => setRollback(true)}>{tr("Roll back\u2026")}</Btn></div>
|
||||
<div className="settings-update-actions"><Btn size="sm" icon="refresh" disabled={checking} className={checking ? 'settings-update-checking' : undefined} onClick={() => void check()}>{tr(checking ? 'Checking…' : error || result?.strata.status === 'error' ? 'Try again' : result ? 'Check again' : 'Check for updates')}</Btn></div>
|
||||
<div className="settings-update-inline" role="status" aria-live="polite">{tr(checking ? 'Checking for new versions…' : error ? <span className="inline-error">{tr(error)}</span> : result ? <><span className="sr-only">{tr(result.strata.message)} {tr(result.engine.message)} </span>{tr("Checked at ")}{tr(checkedLabel(result.checkedAt))}</> : null)}</div>
|
||||
</div>
|
||||
<Dialog open={rollback} compact title={tr("Roll back a version")} onClose={() => setRollback(false)} actions={<Btn onClick={() => setRollback(false)}>{tr("Done")}</Btn>}>
|
||||
<div className="settings-update-dialog">
|
||||
<label className="input-field"><span>{tr("Software")}</span><Select ariaLabel={tr("Software to manage")} value={component} options={[{ value: 'strata', label: 'Strata' }, { value: 'engine', label: 'Engine' }]} onChange={setComponent}/></label>
|
||||
<div className="settings-update-installed"><span>{tr("Installed version")}</span><strong>{tr(installed ?? 'Unavailable')}</strong></div>
|
||||
<div className="settings-update-status" role="status"><Icon name="info" size={18}/><div><strong>{tr(component === 'engine' && native?.capabilities.rollback ? 'Previous engine available' : 'No saved previous version')}</strong><p>{tr(component === 'engine' && native?.capabilities.rollback ? 'The native installer can restore its previous engine. Model files stay in place.' : 'Rollback requires a previous engine saved by a version of Strata that supports rollback.')}</p></div></div>
|
||||
<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(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>
|
||||
{tr(active ? <NativeProgress job={active}/> : null)}{tr(native?.jobs.filter(j => j.state === 'error' && ['update-strata', 'update-engine'].includes(j.kind)).slice(-1).map(j => <p role="alert" key={j.id} className="small inline-error">{tr(jobErrorText(j, native.jobs))}</p>))}
|
||||
<EngineUpdateDialog open={confirm === 'update-engine' && withModel} onClose={() => setConfirm('')}/>
|
||||
<Dialog open={!!confirm && !(confirm === 'update-engine' && withModel)} 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.8 · Engine 0.1.54": the app's own version first, the engine's beside it (About). */
|
||||
/** "Strata 0.2.10 · Engine 0.1.56": 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);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { tr } from '../lib/i18n';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Btn, Dialog, type IconName } from './ui';
|
||||
import { api } from '../lib/client';
|
||||
import { plainFailureText } from '../lib/plain-errors';
|
||||
import { compactTokens, elapsed } from '../lib/chat-metrics';
|
||||
import type { Session } from '../lib/types';
|
||||
type Agent = NonNullable<Session['agents']>[number];
|
||||
@@ -11,17 +13,29 @@ const states: Record<Agent['status'], {
|
||||
working: { label: 'In progress', icon: 'clock' }, completed: { label: 'Completed', icon: 'check' },
|
||||
failed: { label: 'Failed', icon: 'error' }, queued: { label: 'Queued', icon: 'clock' }, cancelled: { label: 'Cancelled', icon: 'x' },
|
||||
};
|
||||
export function SubagentRow({ agent: a, now = Date.now() }: {
|
||||
export function SubagentRow({ agent: a, now = Date.now(), sessionId }: {
|
||||
agent: Agent;
|
||||
now?: number;
|
||||
/** The chat the helper belongs to: with it the row can take a message for the helper (#4749). */
|
||||
sessionId?: string;
|
||||
}) {
|
||||
const [log, setLog] = useState(false);
|
||||
const [writing, setWriting] = useState(false), [draft, setDraft] = useState(''), [busy, setBusy] = useState(false), [sent, setSent] = useState(false), [problem, setProblem] = useState('');
|
||||
// A working helper gets it at its next step (steer); one that has finished or waits gets a new turn with it (queue).
|
||||
async function send() {
|
||||
const message = draft.trim();
|
||||
if (!message || !sessionId || busy) return;
|
||||
setBusy(true); setProblem(''); setSent(false);
|
||||
try { await api.post('/api/chat/helper', { sessionId, helperId: a.id, message, delivery: a.status === 'working' ? 'steer' : 'queue' }); setDraft(''); setSent(true); }
|
||||
catch (e) { setProblem(plainFailureText(e instanceof Error ? e.message : String(e)).message); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
const observedAt = useRef(Date.now());
|
||||
const duration = a.durationMs + (a.status === 'working' ? Math.max(0, now - (a.updatedAt ?? observedAt.current)) : 0);
|
||||
const state = states[a.status] ?? states.queued;
|
||||
return <div className="subagent-row" data-status={a.status}>
|
||||
<span className="subagent-row__dot" aria-label={tr(state.label)}/>
|
||||
<div className="subagent-row__copy"><div className="subagent-row__title"><strong title={a.name}>{a.name}</strong><span className="subagent-row__status">{tr(state.label)}</span></div><p title={a.description}>{a.description}</p>{tr(a.status === 'failed' ? <button className="text-action" onClick={() => setLog(true)}>{tr("View log")}</button> : null)}</div>
|
||||
<div className="subagent-row__copy"><div className="subagent-row__title"><strong title={a.name}>{a.name}</strong><span className="subagent-row__status">{tr(state.label)}</span></div><p title={a.description}>{a.description}</p>{tr(a.status === 'failed' ? <button className="text-action" onClick={() => setLog(true)}>{tr("View log")}</button> : null)}{tr(sessionId && a.status !== 'queued' && a.status !== 'cancelled' ? <>{writing ? <form className="subagent-row__reply" onSubmit={e => { e.preventDefault(); void send(); }}><input className="st-input" value={draft} maxLength={8000} aria-label={tr("Message to {0}", [a.name])} placeholder={tr("Write to {0}\u2026", [a.name])} disabled={busy} onChange={e => { setDraft(e.target.value); setSent(false); }}/><Btn size="sm" variant="primary" type="submit" disabled={busy || !draft.trim()}>{tr("Send")}</Btn></form> : <button className="text-action" onClick={() => setWriting(true)}>{tr("Message this helper")}</button>}{tr(sent ? <p className="small subagent-row__note" role="status">{tr("Sent to {0}.", [a.name])}</p> : null)}{tr(problem ? <p className="small inline-error subagent-row__note" role="alert">{problem}</p> : null)}</> : null)}</div>
|
||||
<div className="subagent-row__metrics"><span>{tr(compactTokens(a.tokens))}{tr(" tok")}</span><span>{tr(elapsed(duration))}</span></div>
|
||||
<Dialog open={log} title={tr("{0} \u00B7 log", [a.name])} onClose={() => setLog(false)} actions={<Btn onClick={() => setLog(false)}>{tr("Done")}</Btn>}><p>{a.description}</p><p className="small muted">{tr("Status: ")}{tr(state.label)} · {tr(elapsed(a.durationMs))} · {tr(compactTokens(a.tokens))}{tr(" tokens")}</p></Dialog></div>;
|
||||
}
|
||||
Loaded 100 of 967 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user