mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-10 03:41:36 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2f0964758 | ||
|
|
9522b52499 | ||
|
|
712fec6b42 | ||
|
|
e8f2380f36 | ||
|
|
93e0abb845 | ||
|
|
d5d5b08379 | ||
|
|
b82a368102 | ||
|
|
b7bcbcde99 | ||
|
|
e3bd5cfdef | ||
|
|
61d8833155 | ||
|
|
9f1550c3cf | ||
|
|
46ea3947e6 | ||
|
|
e1da007f82 | ||
|
|
d362bc5f00 | ||
|
|
90ca808a20 | ||
|
|
63de72ce17 | ||
|
|
ab56b84831 | ||
|
|
8920bd493b | ||
|
|
febc0226f5 | ||
|
|
82d3a03458 | ||
|
|
f16e297e5e | ||
|
|
a16f3fe39e | ||
|
|
a1b9cc89ac | ||
|
|
b5f5e38a44 | ||
|
|
6dba262794 | ||
|
|
a65e14cc05 | ||
|
|
201ee46b46 | ||
|
|
8a9feaedde | ||
|
|
a04860fadf | ||
|
|
6ec4d68ab2 | ||
|
|
60d0f66de7 | ||
|
|
1ecb9de791 | ||
|
|
51b75ea25f | ||
|
|
c47e9b9929 | ||
|
|
35b776c34d | ||
|
|
132c5809b8 | ||
|
|
e6569ae4df | ||
|
|
3289d8280d | ||
|
|
9947e7d6f5 | ||
|
|
b12773820f | ||
|
|
458c312c9c | ||
|
|
4890fd9539 | ||
|
|
41e20da530 | ||
|
|
28bd0185f9 | ||
|
|
ee1b382e9c | ||
|
|
88ea8e2a02 | ||
|
|
508e6999ab | ||
|
|
451f3e8a60 | ||
|
|
03882d09db | ||
|
|
b40d65ea4c | ||
|
|
f223047daa | ||
|
|
eddbf228dc | ||
|
|
e66450705e | ||
|
|
1115121839 | ||
|
|
974c0e3467 | ||
|
|
8b16677015 | ||
|
|
19418261e9 | ||
|
|
bc83c088de | ||
|
|
067b4dda5f | ||
|
|
fdb376f398 | ||
|
|
5c2e80ff43 | ||
|
|
0fdfa40b47 | ||
|
|
97a50dc24c | ||
|
|
7f46672a48 | ||
|
|
db137d917a | ||
|
|
0bf2efe331 | ||
|
|
ed1f58d275 | ||
|
|
dcaf4755c1 | ||
|
|
a875d702cf | ||
|
|
7893d4972e | ||
|
|
97a1df1b30 | ||
|
|
c3bdd42dc2 | ||
|
|
496a5382e4 | ||
|
|
906aa63c75 | ||
|
|
021e07be72 | ||
|
|
a78b2d6278 | ||
|
|
a479b17a15 | ||
|
|
943e67ea98 | ||
|
|
ab5a09b6ff | ||
|
|
e172375b25 | ||
|
|
0ff18ef93d | ||
|
|
269f33859b | ||
|
|
2b36e8a027 | ||
|
|
321794636c | ||
|
|
41717f29c1 | ||
|
|
09eb827914 | ||
|
|
8893ba5eec | ||
|
|
b72b451529 | ||
|
|
e1e9d6ff95 | ||
|
|
30fabc8ac9 | ||
|
|
c598662834 | ||
|
|
b9de2cdb8c | ||
|
|
3b64e076c8 | ||
|
|
5306fee502 | ||
|
|
da7b1d022e | ||
|
|
2351f2cccb | ||
|
|
6e4ef31e47 | ||
|
|
78c27655c8 | ||
|
|
b273c8006e | ||
|
|
3d209733d2 | ||
|
|
4dad59ab37 | ||
|
|
fc1c4e4295 | ||
|
|
d10a8cd7e1 | ||
|
|
646826312a | ||
|
|
0f3ae296bf | ||
|
|
cb73b620a2 | ||
|
|
61632296f3 | ||
|
|
1631a9fde8 | ||
|
|
0458430719 | ||
|
|
204a503d05 | ||
|
|
c9e9f133ef | ||
|
|
38ab10e03e | ||
|
|
03dd20f82b | ||
|
|
d3f696fe4e | ||
|
|
3d26434f21 | ||
|
|
ff6cd56cb3 | ||
|
|
242e8f333e | ||
|
|
b3f696ecad | ||
|
|
8c743a697d | ||
|
|
de9c0448b2 | ||
|
|
bcee5f92c4 | ||
|
|
2baea39521 | ||
|
|
f19907ba77 | ||
|
|
e40597a066 | ||
|
|
fd5b3c4559 | ||
|
|
78f5417195 | ||
|
|
48f17ff35e | ||
|
|
e709a5ff44 | ||
|
|
4e50c30a59 | ||
|
|
fad95e2611 | ||
|
|
741310713c | ||
|
|
b4e9ac7c29 | ||
|
|
c5d3f55dfe | ||
|
|
f637d759b9 | ||
|
|
6f379c8e7c | ||
|
|
caebc48411 | ||
|
|
f3e99e58d5 | ||
|
|
99807e8da4 | ||
|
|
ddbfa1d5c8 | ||
|
|
75822f3e9e | ||
|
|
edee9ea256 | ||
|
|
20f7f1ad0b | ||
|
|
499d971666 | ||
|
|
d19ab73020 | ||
|
|
6a71879979 | ||
|
|
3c32937a2b | ||
|
|
d1121b8931 | ||
|
|
88e6634390 | ||
|
|
055274c882 | ||
|
|
2bc8c6692a | ||
|
|
3a8254d046 | ||
|
|
cd14b976af | ||
|
|
c94ef2375a | ||
|
|
5e1ea3ff6e | ||
|
|
4041af8e6c | ||
|
|
af7759b69e | ||
|
|
b0fc432ed9 | ||
|
|
1300170273 | ||
|
|
b8d604d287 | ||
|
|
e8c3acd2d1 | ||
|
|
391f7ce8dc | ||
|
|
e2acc3a950 | ||
|
|
e62f3c1ffd | ||
|
|
64fac3177c | ||
|
|
41b87a4bd2 | ||
|
|
dff2c91145 | ||
|
|
cf0161bfb3 | ||
|
|
5d60cf13f3 | ||
|
|
506f65781e | ||
|
|
7b52ab055c | ||
|
|
0ea3b73145 | ||
|
|
af9f44e522 | ||
|
|
ffb94c578c | ||
|
|
567c46873f | ||
|
|
0a50eda160 | ||
|
|
6858c919ca | ||
|
|
6bb7e7a2a3 | ||
|
|
0029379234 | ||
|
|
456b07cbe7 | ||
|
|
343b8eae6c | ||
|
|
c9abd38a7f | ||
|
|
1015f5515d | ||
|
|
cb012aa5df | ||
|
|
96b45a8415 | ||
|
|
7ce44a1fd3 | ||
|
|
118e20757c | ||
|
|
d8db7ced36 | ||
|
|
a37d0a57ef | ||
|
|
93a1e18bce | ||
|
|
b6769b7f9a | ||
|
|
b6f585164c | ||
|
|
8feca419ff | ||
|
|
e396ce821f | ||
|
|
6ea300dde5 | ||
|
|
daf035a8c1 | ||
|
|
be32c7f3fb | ||
|
|
01f9c62a0a | ||
|
|
affa7d96e7 | ||
|
|
4a5a6ece70 | ||
|
|
c10b746970 | ||
|
|
146df3c468 | ||
|
|
9b02c62ab5 | ||
|
|
9780a8c27d | ||
|
|
311926416a | ||
|
|
0fe4ae5090 | ||
|
|
ea765a5717 | ||
|
|
c49f198c77 | ||
|
|
b1541ab245 | ||
|
|
8a42a9457d | ||
|
|
05506f9532 | ||
|
|
95381770f8 | ||
|
|
d7e555bdbb | ||
|
|
75acfca321 | ||
|
|
63fd759227 | ||
|
|
54828c7efb | ||
|
|
68ab15ddc1 | ||
|
|
7be42963e6 | ||
|
|
d4b72bc901 | ||
|
|
37e91bfdbb | ||
|
|
8221fb8c1c | ||
|
|
5b1728358b | ||
|
|
e17394fe1c | ||
|
|
9bbbb66061 | ||
|
|
a123ba696c | ||
|
|
e7d4844637 | ||
|
|
7084a49b7b | ||
|
|
1e88e88c5f | ||
|
|
c152be538c | ||
|
|
aae7721b6d | ||
|
|
706efc5b0f | ||
|
|
0a0e4463ea | ||
|
|
e77bd43bc9 | ||
|
|
37b5cf0db2 | ||
|
|
c0e7a6f4f8 | ||
|
|
645b9177e3 | ||
|
|
071e4dc51e | ||
|
|
bca7931f3d | ||
|
|
2b9f05093d | ||
|
|
24c6c2f682 | ||
|
|
386f9959f1 | ||
|
|
f281e3fe26 | ||
|
|
f46e88d610 | ||
|
|
d486fbe7d6 | ||
|
|
65909b2d0f | ||
|
|
cae562fa01 | ||
|
|
63f063852f | ||
|
|
cc3f9142e3 | ||
|
|
f138653938 | ||
|
|
ce6bbd7d54 | ||
|
|
aba4ddaca7 | ||
|
|
31928fbe34 | ||
|
|
10831eceb2 | ||
|
|
7604725595 | ||
|
|
2a8217c0ce | ||
|
|
a7cc8faa5d | ||
|
|
20885f7af0 | ||
|
|
abf2953be0 | ||
|
|
78c5a834e3 | ||
|
|
51548e5539 | ||
|
|
7597e383cf | ||
|
|
f2eedfe9f2 | ||
|
|
bd0e878f42 | ||
|
|
4084979c95 | ||
|
|
530a29ea2d | ||
|
|
3e8d3b2940 | ||
|
|
039cecc5a5 | ||
|
|
d735d66a4d | ||
|
|
7bf5ec4ebf | ||
|
|
178d1c74b5 | ||
|
|
eecd7f61d7 | ||
|
|
86c3367a89 | ||
|
|
04b73d061d | ||
|
|
1daec2a5b8 | ||
|
|
b94ab5f4c6 | ||
|
|
445cdf4540 | ||
|
|
8463d15c84 | ||
|
|
1c768f9d12 | ||
|
|
b0b0348c2b | ||
|
|
7e3fae57ca | ||
|
|
32944ce1ee | ||
|
|
f000d4a455 | ||
|
|
6368dc68ab | ||
|
|
55dc17697c | ||
|
|
241b2f203c | ||
|
|
044105d8de | ||
|
|
994029b02f | ||
|
|
89f4fa18ee | ||
|
|
e2d3159996 | ||
|
|
1ed923f59e | ||
|
|
aeaca205eb | ||
|
|
f225844d19 | ||
|
|
40849df0a8 | ||
|
|
a5b820a415 | ||
|
|
b97433df05 | ||
|
|
64cc20a8e2 | ||
|
|
1d342f0836 | ||
|
|
0e1d440713 | ||
|
|
19136808f9 | ||
|
|
2c0ad33f71 | ||
|
|
3cc5714f37 | ||
|
|
687a7d79d7 | ||
|
|
6e31946cbe | ||
|
|
66b346c29f | ||
|
|
830732a33d | ||
|
|
e431a4a8e2 | ||
|
|
4dbaf703fd | ||
|
|
c4fa1d9e1e | ||
|
|
9f7d977d22 | ||
|
|
5d37280db1 | ||
|
|
dc9dfcfd29 | ||
|
|
c3b3f50355 | ||
|
|
d7f5c6dd11 | ||
|
|
0c0b7b8751 | ||
|
|
86ffdbeb17 | ||
|
|
aac067021c | ||
|
|
e2b5378403 | ||
|
|
ba5bef2d61 | ||
|
|
3e0210663b | ||
|
|
437668151d | ||
|
|
0bdcef4682 | ||
|
|
408a5ca37b | ||
|
|
10df7ace1d | ||
|
|
d6eaab500f | ||
|
|
aa678f10d0 | ||
|
|
af1ffd913a | ||
|
|
edb7c4819b | ||
|
|
74a4441354 | ||
|
|
bee9168114 | ||
|
|
f699fc0a7d | ||
|
|
96d48dff45 | ||
|
|
99cbdd5592 | ||
|
|
7faeb8c264 | ||
|
|
39ea3e5216 | ||
|
|
60ab1e52f3 | ||
|
|
bfbe7fab2b | ||
|
|
85e7c83d96 | ||
|
|
199fbc7a45 | ||
|
|
9ed3c77899 | ||
|
|
c1d5e4d5e5 | ||
|
|
14a4636b83 | ||
|
|
5d56ff8c06 | ||
|
|
cbef1a9ff6 | ||
|
|
2b8179f3e6 | ||
|
|
ce4197ce81 | ||
|
|
3fda37bd8c | ||
|
|
3c4afa8d78 | ||
|
|
975eaab2bb | ||
|
|
0fa4288d30 | ||
|
|
90c77357fb | ||
|
|
1814da7c44 | ||
|
|
64b48c0d00 | ||
|
|
5d363f11ea | ||
|
|
aba888972a | ||
|
|
a87c399c91 | ||
|
|
e10be676b1 | ||
|
|
0269e2c42a | ||
|
|
6f5cf9f6b7 | ||
|
|
be2d275361 | ||
|
|
c7272c70db | ||
|
|
8768e95f12 | ||
|
|
548fdf061e | ||
|
|
3e009d2b75 | ||
|
|
e82c706981 | ||
|
|
68bead1082 | ||
|
|
85269ca155 | ||
|
|
1d5096beba | ||
|
|
e647e132d5 | ||
|
|
8ad605789b | ||
|
|
792b626980 | ||
|
|
cd66956ca6 | ||
|
|
16904848bc | ||
|
|
0855796e68 | ||
|
|
71395ec9c7 | ||
|
|
9cb1dc6454 | ||
|
|
b247f4ecda | ||
|
|
d33d45ade2 | ||
|
|
cabb271145 | ||
|
|
094bb3f0a3 | ||
|
|
0481347ec0 | ||
|
|
b75e8a975d | ||
|
|
2204582932 | ||
|
|
22a3d70688 | ||
|
|
d8b6ce356c | ||
|
|
d73ce8608e | ||
|
|
127d829018 | ||
|
|
4c45967460 | ||
|
|
74f390538c | ||
|
|
50c2db6905 | ||
|
|
a8767488ce | ||
|
|
f4d8688e8c | ||
|
|
4d482e627c | ||
|
|
66331f9bc0 | ||
|
|
daf4ac087a | ||
|
|
576d06553c | ||
|
|
2bfd151578 | ||
|
|
049ae1c678 | ||
|
|
39851463bb | ||
|
|
3c95402c4f | ||
|
|
b776e1cd02 | ||
|
|
c08fd2ecc8 | ||
|
|
97da46cc1c | ||
|
|
f1fd753de8 | ||
|
|
b3c6338689 | ||
|
|
0d922698f2 | ||
|
|
3f6960f1fc | ||
|
|
65cff54c0d | ||
|
|
66385404ff | ||
|
|
632b611df9 | ||
|
|
f8f97d9f36 | ||
|
|
5af35d050e | ||
|
|
c6dda74bf2 | ||
|
|
64abc62393 | ||
|
|
25fe8bca8e | ||
|
|
d0f86f8359 | ||
|
|
c30aa64754 | ||
|
|
8431dde5d4 | ||
|
|
efaa100b37 | ||
|
|
0b5096edd4 | ||
|
|
b526f98104 | ||
|
|
fce58233fb | ||
|
|
acbaa4391b | ||
|
|
5899c5bbfd | ||
|
|
380212689a | ||
|
|
c3109534d0 | ||
|
|
3434ed0e3b | ||
|
|
474fe147de | ||
|
|
fe4a03e2f5 | ||
|
|
a09ab1c781 | ||
|
|
bae1e1d4d3 | ||
|
|
ac944fc1f8 | ||
|
|
faf3db296e | ||
|
|
16ff27af85 | ||
|
|
5bf9626fed | ||
|
|
f374b5029d | ||
|
|
3bbf0f399e | ||
|
|
06eb02b1da | ||
|
|
c7596ce873 | ||
|
|
ad142b468c | ||
|
|
23dda3099f | ||
|
|
2d5319cef1 | ||
|
|
ff36fa9282 | ||
|
|
4be6a003aa | ||
|
|
8181522f15 | ||
|
|
a837fb450b | ||
|
|
888c5eca84 | ||
|
|
eb4f41fb9a | ||
|
|
a3a10b90af | ||
|
|
0e76645a73 | ||
|
|
ac1dfef883 | ||
|
|
1fef27e836 | ||
|
|
aa12025515 | ||
|
|
dbdd81a675 | ||
|
|
8b98b6f1ca | ||
|
|
a1c7f05059 | ||
|
|
081edf2d9b | ||
|
|
181b668e5a | ||
|
|
ae5e73de20 | ||
|
|
938b76eb3e | ||
|
|
75bbf42912 | ||
|
|
c65b9f8be8 | ||
|
|
0748e92701 | ||
|
|
f6cea71a78 | ||
|
|
2d8ead05b8 | ||
|
|
212d15ba88 | ||
|
|
52ac4e7337 | ||
|
|
c850b979b5 | ||
|
|
95cb87d427 | ||
|
|
c2de8af1c4 | ||
|
|
dfc675848f | ||
|
|
31a7351534 | ||
|
|
ee0b367fb7 | ||
|
|
c2335eebba | ||
|
|
9149835c6a | ||
|
|
62853e78de | ||
|
|
f95411b843 | ||
|
|
e2f7c47447 | ||
|
|
e318910206 | ||
|
|
fc5dac23c5 | ||
|
|
3671e77acd | ||
|
|
22f4dbe0e1 | ||
|
|
55a2bcd474 | ||
|
|
07e1508893 | ||
|
|
2613b4de5a | ||
|
|
66cfc4bcdb | ||
|
|
d2dc05557e | ||
|
|
0ffbf096f7 | ||
|
|
fcd1493c1a | ||
|
|
c2d44e93ac | ||
|
|
d00d937b89 | ||
|
|
213f1a3905 | ||
|
|
6983b09bba | ||
|
|
38c08443e0 | ||
|
|
37e4fcf9ac | ||
|
|
cde3399b31 | ||
|
|
a1e6ef3246 | ||
|
|
e364620c56 | ||
|
|
400e26e3a8 | ||
|
|
0ed3a193ac | ||
|
|
9f891886d6 | ||
|
|
73dabf548d | ||
|
|
3f15274e8f | ||
|
|
c984ab42a0 | ||
|
|
e8570ac616 | ||
|
|
8088d056f0 | ||
|
|
deebb73a65 | ||
|
|
750cbf0dbd | ||
|
|
697fec9d06 | ||
|
|
8969119a42 | ||
|
|
a2aa1f3122 | ||
|
|
d716ccfca6 | ||
|
|
838b2e0de6 | ||
|
|
8fbf6b1902 | ||
|
|
1bf5e13e7f | ||
|
|
082af9c19a | ||
|
|
4a49d25975 |
No files matched your search
@@ -28,3 +28,4 @@ docker/compose/* text eol=lf
|
||||
|
||||
# vendored KaTeX (serve/web/vendor): the files are hashed byte for byte (PROVENANCE.json), never convert their line endings.
|
||||
serve/web/vendor/** -text
|
||||
third_party/strata-ggml/** text eol=lf
|
||||
@@ -1 +1,2 @@
|
||||
github: Niko1221
|
||||
buy_me_a_coffee: strataengine
|
||||
@@ -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.10 · engine 0.1.56"
|
||||
placeholder: "Strata 0.2.13 · engine 0.1.59"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
name: Backend compile checks
|
||||
|
||||
# Compile and link every engine backend with a pinned toolchain, without a GPU: nothing is run, so a call into a kernel that only
|
||||
# exists for another backend (or an API a toolkit version dropped) fails here instead of on a user's machine. Hardware runs are separate.
|
||||
# CUDA 12.9 and 13.0 nvidia/cuda devel container targets strata, strata_kq (CMake preset cuda-compile)
|
||||
# HIP (ROCm 7.2) rocm/dev container target strata (CMake preset hip-compile)
|
||||
# SYCL (oneAPI 2026.1) ubuntu + Intel's apt repo target strata of sycl/ (the commands of packaging/build_sycl_linux.sh)
|
||||
# Metal macOS runner targets strata and strata_ds4 (metal/; metal-build.yml keeps its own strata build)
|
||||
# llama.cpp (ggml) comes at the commit pinned in setup.py (LLAMA_CPP_COMMIT), the same one the release builds use.
|
||||
# Architectures are fewer than the release package's (compile time); the flags that decide which sources and defines are built are the release's.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- .github/workflows/backends.yml
|
||||
- CMakeLists.txt
|
||||
- CMakePresets.json
|
||||
- cmake/**
|
||||
- src/**
|
||||
- include/**
|
||||
- third_party/**
|
||||
- sycl/**
|
||||
- metal/**
|
||||
- setup.py
|
||||
- packaging/build_cuda_linux.sh
|
||||
- packaging/build_hip_linux.sh
|
||||
- packaging/build_sycl_linux.sh
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/backends.yml
|
||||
- CMakeLists.txt
|
||||
- CMakePresets.json
|
||||
- cmake/**
|
||||
- src/**
|
||||
- include/**
|
||||
- third_party/**
|
||||
- sycl/**
|
||||
- metal/**
|
||||
- setup.py
|
||||
- packaging/build_cuda_linux.sh
|
||||
- packaging/build_hip_linux.sh
|
||||
- packaging/build_sycl_linux.sh
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: backends-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CCACHE_DIR: ${{ github.workspace }}/.ccache
|
||||
CCACHE_MAXSIZE: 2G
|
||||
|
||||
jobs:
|
||||
cuda:
|
||||
name: CUDA ${{ matrix.cuda }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
container: nvidia/cuda:${{ matrix.image }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# the cuda12 package is the one for Pascal and Volta: sm_60 needs STRATA_EXPERIMENTAL_SM60 (packaging/build_cuda_linux.sh)
|
||||
- { cuda: '12.9', image: '12.9.1-devel-ubuntu24.04', archs: '60-real;61-real;70-real;86-real', sm60: 'ON' }
|
||||
- { cuda: '13.0', image: '13.0.2-devel-ubuntu24.04', archs: '75-real;86-real;120-real', sm60: 'OFF' }
|
||||
steps:
|
||||
- name: Tools
|
||||
run: |
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends git ca-certificates cmake ninja-build ccache
|
||||
cmake --version | head -1
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: llama.cpp (ggml) at the pinned commit
|
||||
run: |
|
||||
commit=$(sed -n 's/^LLAMA_CPP_COMMIT = "\(.*\)"/\1/p' setup.py)
|
||||
mkdir llama.cpp && cd llama.cpp && git init -q
|
||||
git remote add origin https://github.com/ggml-org/llama.cpp.git
|
||||
git fetch -q --depth 1 origin "$commit" && git checkout -q FETCH_HEAD
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: .ccache
|
||||
key: ccache-cuda${{ matrix.cuda }}-${{ hashFiles('CMakeLists.txt', 'src/**', 'include/**', 'cmake/**') }}
|
||||
restore-keys: ccache-cuda${{ matrix.cuda }}-
|
||||
- name: Configure (preset cuda-compile)
|
||||
run: |
|
||||
nvcc --version | tail -2
|
||||
cmake --preset cuda-compile -B build -DSTRATA_GGML_DIR="$PWD/llama.cpp" \
|
||||
"-DCMAKE_CUDA_ARCHITECTURES=${{ matrix.archs }}" -DSTRATA_EXPERIMENTAL_SM60=${{ matrix.sm60 }} \
|
||||
-DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DCMAKE_CUDA_COMPILER_LAUNCHER=ccache
|
||||
- name: Build strata and strata_kq
|
||||
run: cmake --build build --target strata strata_kq -j "$(nproc)"
|
||||
- name: Cache statistics
|
||||
run: ccache -s | head -8
|
||||
|
||||
hip:
|
||||
name: HIP ROCm 7.2
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
container: rocm/dev-ubuntu-24.04:7.2.4-complete
|
||||
steps:
|
||||
- name: Tools
|
||||
run: |
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends git ca-certificates cmake ninja-build ccache
|
||||
cmake --version | head -1
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: llama.cpp (ggml) at the pinned commit
|
||||
run: |
|
||||
commit=$(sed -n 's/^LLAMA_CPP_COMMIT = "\(.*\)"/\1/p' setup.py)
|
||||
mkdir llama.cpp && cd llama.cpp && git init -q
|
||||
git remote add origin https://github.com/ggml-org/llama.cpp.git
|
||||
git fetch -q --depth 1 origin "$commit" && git checkout -q FETCH_HEAD
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: .ccache
|
||||
key: ccache-hip72-${{ hashFiles('CMakeLists.txt', 'src/**', 'include/**', 'cmake/**') }}
|
||||
restore-keys: ccache-hip72-
|
||||
- name: Configure (preset hip-compile)
|
||||
run: |
|
||||
/opt/rocm/llvm/bin/clang++ --version | head -1
|
||||
cmake --preset hip-compile -B build -DSTRATA_GGML_DIR="$PWD/llama.cpp" \
|
||||
-DCMAKE_HIP_COMPILER=/opt/rocm/llvm/bin/clang++ -DCMAKE_HIP_COMPILER_ROCM_ROOT=/opt/rocm -DCMAKE_PREFIX_PATH=/opt/rocm \
|
||||
-DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DCMAKE_HIP_COMPILER_LAUNCHER=ccache
|
||||
- name: Build strata
|
||||
run: cmake --build build --target strata -j "$(nproc)"
|
||||
- name: Cache statistics
|
||||
run: ccache -s | head -8
|
||||
|
||||
sycl:
|
||||
# The engine needs oneAPI 2026.1 or newer (sycl/include/dpct/util.hpp uses the entangle and chunked_partition extensions, which the
|
||||
# 2025.3.2 compiler does not have: it stops at "no member named 'entangle'"; the intel/oneapi-basekit images end at 2025.3), so the
|
||||
# compiler comes from Intel's apt repository at a pinned version.
|
||||
name: SYCL oneAPI 2026.1
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
container: ubuntu:24.04
|
||||
steps:
|
||||
- name: Tools and oneAPI 2026.1
|
||||
run: |
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends git ca-certificates cmake ninja-build ccache wget gpg
|
||||
wget -qO- https://apt.repos.intel.com/intel-gpg-keys/GPG-PUB-KEY-INTEL-SW-PRODUCTS.PUB | gpg --dearmor -o /usr/share/keyrings/oneapi-archive-keyring.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/oneapi-archive-keyring.gpg] https://apt.repos.intel.com/oneapi all main" > /etc/apt/sources.list.d/oneAPI.list
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
intel-oneapi-compiler-dpcpp-cpp-2026.1=2026.1.1-325 intel-oneapi-mkl-devel-2026.1=2026.1.0-236
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: llama.cpp (ggml) at the pinned commit
|
||||
run: |
|
||||
commit=$(sed -n 's/^LLAMA_CPP_COMMIT = "\(.*\)"/\1/p' setup.py)
|
||||
mkdir llama.cpp && cd llama.cpp && git init -q
|
||||
git remote add origin https://github.com/ggml-org/llama.cpp.git
|
||||
git fetch -q --depth 1 origin "$commit" && git checkout -q FETCH_HEAD
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: .ccache
|
||||
key: ccache-sycl-${{ hashFiles('CMakeLists.txt', 'src/**', 'include/**', 'sycl/**') }}
|
||||
restore-keys: ccache-sycl-
|
||||
- name: Configure and build (the commands of packaging/build_sycl_linux.sh)
|
||||
shell: bash
|
||||
run: |
|
||||
. /opt/intel/oneapi/setvars.sh --force >/dev/null
|
||||
icpx --version | head -1
|
||||
cmake -S sycl -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=icx -DCMAKE_CXX_COMPILER=icpx \
|
||||
-DSTRATA_SYCL_PARITY=OFF -DSTRATA_SYCL_IQ_DECODE_FAST=ON -DSTRATA_GGML_DIR="$PWD/llama.cpp" \
|
||||
-DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache
|
||||
cmake --build build --target strata -j "$(nproc)"
|
||||
- name: Cache statistics
|
||||
run: ccache -s | head -8
|
||||
|
||||
metal:
|
||||
name: Metal strata + strata_ds4
|
||||
# the macOS 26 SDK: metal/src/mtl_rt.mm names MTLGPUFamilyMetal4 behind @available (a run-time check); the names must be in the SDK
|
||||
runs-on: macos-26
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
# metal/setup_mac.py's metal-cpp pin (the same as metal-build.yml)
|
||||
METAL_CPP_URL: https://developer.apple.com/metal/cpp/files/metal-cpp_26.zip
|
||||
METAL_CPP_SHA256: 4df3c078b9aadcb516212e9cb03004cbc5ce9a3e9c068fa3144d021db585a3a4
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Tools
|
||||
run: brew install ninja ccache
|
||||
- name: metal-cpp
|
||||
run: |
|
||||
curl -fsSL "$METAL_CPP_URL" -o metal-cpp.zip
|
||||
echo "$METAL_CPP_SHA256 metal-cpp.zip" | shasum -a 256 -c -
|
||||
unzip -q metal-cpp.zip -d _mcpp
|
||||
- name: llama.cpp (ggml) at the pinned commit
|
||||
run: |
|
||||
commit=$(sed -n 's/^LLAMA_CPP_COMMIT = "\(.*\)"/\1/p' setup.py)
|
||||
mkdir llama.cpp && cd llama.cpp && git init -q
|
||||
git remote add origin https://github.com/ggml-org/llama.cpp.git
|
||||
git fetch -q --depth 1 origin "$commit" && git checkout -q FETCH_HEAD
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: .ccache
|
||||
key: ccache-metal-${{ hashFiles('CMakeLists.txt', 'src/**', 'include/**', 'metal/**') }}
|
||||
restore-keys: ccache-metal-
|
||||
- name: Configure
|
||||
run: |
|
||||
cmake -S metal -B build-metal -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
||||
-DMETAL_CPP_DIR="$PWD/_mcpp/metal-cpp" -DSTRATA_GGML_DIR="$PWD/llama.cpp" \
|
||||
-DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache
|
||||
- name: Build and link strata and strata_ds4
|
||||
run: cmake --build build-metal --target strata strata_ds4
|
||||
@@ -0,0 +1,185 @@
|
||||
name: Checks
|
||||
|
||||
# The fast checks (tools/check), one job per step so a failure names its step: the CPU CMake preset, the Python suites, the host's
|
||||
# typecheck / test / build. No GPU, no model and no download of weights. The config snapshot has its own workflow (snapshot.yml)
|
||||
# and the backend compile checks theirs (backends.yml). Run the same thing by hand: python tools/check --only <step>
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- .github/workflows/check.yml
|
||||
- tools/check
|
||||
- requirements*.txt
|
||||
- CMakeLists.txt
|
||||
- CMakePresets.json
|
||||
- cmake/**
|
||||
- src/**
|
||||
- include/**
|
||||
- bench/micro/**
|
||||
- third_party/**
|
||||
- tests/**
|
||||
- serve/**
|
||||
- strata.py
|
||||
- setup.py
|
||||
- strata_cli/**
|
||||
- strata_control/**
|
||||
- tools/**
|
||||
- packaging/**
|
||||
- docker/**
|
||||
- apps/host/**
|
||||
- apps/desktop/**
|
||||
- metal/**
|
||||
- sycl/**
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/check.yml
|
||||
- tools/check
|
||||
- requirements*.txt
|
||||
- CMakeLists.txt
|
||||
- CMakePresets.json
|
||||
- cmake/**
|
||||
- src/**
|
||||
- include/**
|
||||
- bench/micro/**
|
||||
- third_party/**
|
||||
- tests/**
|
||||
- serve/**
|
||||
- strata.py
|
||||
- setup.py
|
||||
- strata_cli/**
|
||||
- strata_control/**
|
||||
- tools/**
|
||||
- packaging/**
|
||||
- docker/**
|
||||
- apps/host/**
|
||||
- apps/desktop/**
|
||||
- metal/**
|
||||
- sycl/**
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: checks-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
name: Linux / ${{ matrix.name }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 90
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { name: cpu CMake preset, only: cmake-cpu }
|
||||
- { name: Python tests/, only: py-tests }
|
||||
- { name: Python serve/, only: py-serve }
|
||||
- { name: Python tools/, only: py-tools }
|
||||
- { name: host typecheck + test + build, only: "host-typecheck,host-test,host-build" }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: pip
|
||||
cache-dependency-path: requirements*.txt
|
||||
- uses: actions/setup-node@v4
|
||||
if: ${{ startsWith(matrix.only, 'host') }}
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: apps/host/package-lock.json
|
||||
- name: Python packages
|
||||
run: python -m pip install -r requirements.txt -r requirements-dev.txt -r requirements-build.txt
|
||||
- name: tools/check
|
||||
run: python tools/check --only "${{ matrix.only }}" --keep --scratch "$RUNNER_TEMP/check" --json "$RUNNER_TEMP/check/result.json"
|
||||
- name: Logs
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: check-linux-${{ strategy.job-index }}
|
||||
path: |
|
||||
${{ runner.temp }}/check/*.log
|
||||
${{ runner.temp }}/check/result.json
|
||||
retention-days: 7
|
||||
|
||||
windows:
|
||||
name: Windows / ${{ matrix.name }}
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 120
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { name: cpu CMake preset (MSVC), only: cmake-cpu }
|
||||
- { name: Python tests/, only: py-tests }
|
||||
- { name: Python serve/, only: py-serve }
|
||||
- { name: Python tools/, only: py-tools }
|
||||
- { name: host typecheck + test + build, only: "host-typecheck,host-test,host-build" }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: pip
|
||||
cache-dependency-path: requirements*.txt
|
||||
- uses: actions/setup-node@v4
|
||||
if: ${{ startsWith(matrix.only, 'host') }}
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: apps/host/package-lock.json
|
||||
- name: Python packages
|
||||
run: python -m pip install -r requirements.txt -r requirements-dev.txt -r requirements-build.txt
|
||||
# tools/check finds Visual Studio's compiler itself (vcvars64.bat); bash is Git for Windows' (the tester-README tests need it)
|
||||
- name: tools/check
|
||||
run: python tools/check --only "${{ matrix.only }}" --keep --scratch "$env:RUNNER_TEMP\check" --json "$env:RUNNER_TEMP\check\result.json"
|
||||
- name: Logs
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: check-windows-${{ strategy.job-index }}
|
||||
path: |
|
||||
${{ runner.temp }}\check\*.log
|
||||
${{ runner.temp }}\check\result.json
|
||||
retention-days: 7
|
||||
|
||||
macos:
|
||||
# Cheap and useful (the audit found the host and Python fixtures reading the Mac's own paths), but this job has not run on a
|
||||
# Mac runner yet: it reports and does not block until its first green run.
|
||||
name: macOS / ${{ matrix.name }}
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 60
|
||||
continue-on-error: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { name: Python tools/, only: py-tools }
|
||||
- { name: host typecheck + test + build, only: "host-typecheck,host-test,host-build" }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: pip
|
||||
cache-dependency-path: requirements*.txt
|
||||
- uses: actions/setup-node@v4
|
||||
if: ${{ startsWith(matrix.only, 'host') }}
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: apps/host/package-lock.json
|
||||
- name: Python packages
|
||||
run: python -m pip install -r requirements.txt -r requirements-dev.txt
|
||||
- name: tools/check
|
||||
run: python tools/check --only "${{ matrix.only }}"
|
||||
@@ -0,0 +1,72 @@
|
||||
name: Docs
|
||||
|
||||
# The documentation checks (python tools/check --only docs): every local link in the current pages resolves, the version lines and
|
||||
# installer names agree with docs/data/release.json, the capability table and its summaries are what docs/data/capabilities.json says, and the
|
||||
# settings page matches the register. Python standard library only; nothing is built, downloaded or run on a GPU.
|
||||
# Archived pages (history/, release notes, bench/results) are not checked; their baseline is history/LINK-BASELINE.md.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- .github/workflows/docs.yml
|
||||
- '**/*.md'
|
||||
- docs/data/release.json
|
||||
- docs/data/capabilities.json
|
||||
- data/settings-register.json
|
||||
- tools/check
|
||||
- tools/check_links.py
|
||||
- tools/doc_pages.py
|
||||
- tools/release_manifest.py
|
||||
- tools/capability_table.py
|
||||
- tools/settings_register.py
|
||||
- strata_cli/__init__.py
|
||||
- CMakeLists.txt
|
||||
- apps/host/package.json
|
||||
- apps/desktop/package.json
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/docs.yml
|
||||
- '**/*.md'
|
||||
- docs/data/release.json
|
||||
- docs/data/capabilities.json
|
||||
- data/settings-register.json
|
||||
- tools/check
|
||||
- tools/check_links.py
|
||||
- tools/doc_pages.py
|
||||
- tools/release_manifest.py
|
||||
- tools/capability_table.py
|
||||
- tools/settings_register.py
|
||||
- strata_cli/__init__.py
|
||||
- CMakeLists.txt
|
||||
- apps/host/package.json
|
||||
- apps/desktop/package.json
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: docs-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
docs:
|
||||
name: Links, release manifest, capability table
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: tools/check docs
|
||||
run: python tools/check --only docs --keep --scratch "$RUNNER_TEMP/check" --json "$RUNNER_TEMP/check/result.json"
|
||||
- name: Log
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: docs-check
|
||||
path: ${{ runner.temp }}/check/*.log
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,83 @@
|
||||
name: Config snapshot
|
||||
|
||||
# tests/snapshot: what `strata launch`, the app's plain Load and the app's Models-screen Load hand the engine (arguments, environment, drafter,
|
||||
# confidence), for every simulated machine class and model set, against the golden files in tests/snapshot/golden. Nothing is loaded,
|
||||
# downloaded or run on a GPU. Windows and Linux must give byte-identical output, so both run. A change that moves what users
|
||||
# get has to update the golden files in the same commit (STRATA_SNAPSHOT_UPDATE=1 python -m pytest tests/snapshot, see tests/snapshot/README.md).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- .github/workflows/snapshot.yml
|
||||
- tests/snapshot/**
|
||||
- serve/**
|
||||
- strata_cli/**
|
||||
- strata_control/**
|
||||
- setup.py
|
||||
- strata.py
|
||||
- requirements*.txt
|
||||
- apps/host/server/**
|
||||
- apps/host/package*.json
|
||||
- packaging/**
|
||||
- tools/check
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/snapshot.yml
|
||||
- tests/snapshot/**
|
||||
- serve/**
|
||||
- strata_cli/**
|
||||
- strata_control/**
|
||||
- setup.py
|
||||
- strata.py
|
||||
- requirements*.txt
|
||||
- apps/host/server/**
|
||||
- apps/host/package*.json
|
||||
- packaging/**
|
||||
- tools/check
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: snapshot-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
snapshot:
|
||||
name: ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-24.04, windows-2022]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: pip
|
||||
cache-dependency-path: requirements*.txt
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: apps/host/package-lock.json
|
||||
- name: Python packages
|
||||
run: python -m pip install -r requirements.txt -r requirements-dev.txt
|
||||
- name: Host packages (the app's Load runs in Node)
|
||||
working-directory: apps/host
|
||||
run: npm ci --no-audit --no-fund
|
||||
- name: tools/check snapshot
|
||||
run: python tools/check --only snapshot --keep --scratch "${{ runner.temp }}/check"
|
||||
- name: Logs
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: snapshot-${{ matrix.os }}
|
||||
path: ${{ runner.temp }}/check/*.log
|
||||
retention-days: 7
|
||||
@@ -1,6 +1,6 @@
|
||||
# AGENTS.md
|
||||
|
||||
Version 0.2.10, engine 0.1.56. The public model families are DeepSeekV4.1-Flash-1017,
|
||||
Version 0.2.13, engine 0.1.59. 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.9-windows-x64-setup.exe`, `Strata-0.2.9-linux-x86_64.AppImage`, `Strata-0.2.9-macos-arm64.dmg`.
|
||||
`Strata-0.2.13-windows-x64-setup.exe`, `Strata-0.2.13-linux-x86_64.AppImage`, `Strata-0.2.13-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
|
||||
|
||||
+145
-2478
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"version": 5,
|
||||
"cmakeMinimumRequired": { "major": 3, "minor": 24, "patch": 0 },
|
||||
"configurePresets": [
|
||||
{
|
||||
"name": "cpu-check",
|
||||
"displayName": "CPU check build (no GPU, no network)",
|
||||
"description": "The fast check: the CPU-only engine pieces and every test that needs no GPU and no model. STRATA_NATIVE_EXPERTS=OFF, so ggml is not needed. tools/check runs this preset.",
|
||||
"generator": "Ninja",
|
||||
"binaryDir": "${sourceDir}/build/${presetName}",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Release",
|
||||
"STRATA_BUILD_TESTS": "ON",
|
||||
"STRATA_NATIVE_EXPERTS": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "cpu-check-native",
|
||||
"displayName": "CPU check build with the native expert tests",
|
||||
"description": "As cpu-check plus the native (IQ) expert kernels and their tests. ggml comes from the vendored third_party/strata-ggml (no download); -DSTRATA_GGML_DIR=<llama.cpp checkout> overrides it.",
|
||||
"inherits": "cpu-check",
|
||||
"cacheVariables": { "STRATA_NATIVE_EXPERTS": "ON" }
|
||||
},
|
||||
{
|
||||
"name": "cuda-compile",
|
||||
"displayName": "CUDA compile-only (the release flags of packaging/build_cuda_linux.sh, no tests)",
|
||||
"description": "Builds the strata and strata_kq targets for the given CUDA architectures. No GPU is needed to compile; nothing is run. ggml is the vendored third_party/strata-ggml; -DSTRATA_GGML_DIR=<llama.cpp checkout> overrides it (CI still passes one).",
|
||||
"generator": "Ninja",
|
||||
"binaryDir": "${sourceDir}/build/${presetName}",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Release",
|
||||
"STRATA_ENABLE_CUDA": "ON",
|
||||
"STRATA_BUILD_TESTS": "OFF",
|
||||
"STRATA_NATIVE_EXPERTS": "ON",
|
||||
"STRATA_PORTABLE": "ON",
|
||||
"STRATA_Q6K_EXPERTS": "ON",
|
||||
"STRATA_MMQ_KQUANTS": "ON",
|
||||
"STRATA_EXPERIMENTAL_SM60": "OFF",
|
||||
"CMAKE_CUDA_ARCHITECTURES": "86-real;89-real;89-virtual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "hip-compile",
|
||||
"displayName": "HIP compile-only (the release flags of packaging/build_hip_linux.sh, no tests)",
|
||||
"description": "Builds the strata target for gfx1100 and gfx1201 with ROCm's hipcc. ggml is the vendored third_party/strata-ggml (-DSTRATA_GGML_DIR=<llama.cpp checkout> overrides it); if hipcc is not on PATH pass -DCMAKE_HIP_COMPILER=<rocm>/llvm/bin/clang++.",
|
||||
"generator": "Ninja",
|
||||
"binaryDir": "${sourceDir}/build/${presetName}",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Release",
|
||||
"STRATA_ENABLE_HIP": "ON",
|
||||
"STRATA_ENABLE_CUDA": "OFF",
|
||||
"STRATA_BUILD_TESTS": "OFF",
|
||||
"STRATA_PREFILL_MMQ": "ON",
|
||||
"STRATA_PORTABLE": "ON",
|
||||
"CMAKE_HIP_ARCHITECTURES": "gfx1100;gfx1201"
|
||||
}
|
||||
}
|
||||
],
|
||||
"buildPresets": [
|
||||
{ "name": "cpu-check", "configurePreset": "cpu-check", "jobs": 0 },
|
||||
{ "name": "cpu-check-native", "configurePreset": "cpu-check-native", "jobs": 0 },
|
||||
{ "name": "cuda-compile", "configurePreset": "cuda-compile", "targets": ["strata", "strata_kq"], "jobs": 0 },
|
||||
{ "name": "hip-compile", "configurePreset": "hip-compile", "targets": ["strata"], "jobs": 0 }
|
||||
],
|
||||
"testPresets": [
|
||||
{
|
||||
"name": "cpu-check",
|
||||
"configurePreset": "cpu-check",
|
||||
"output": { "outputOnFailure": true, "shortProgress": true },
|
||||
"execution": { "timeout": 120, "noTestsAction": "error", "jobs": 4 }
|
||||
},
|
||||
{
|
||||
"name": "cpu-check-native",
|
||||
"configurePreset": "cpu-check-native",
|
||||
"inherits": "cpu-check"
|
||||
}
|
||||
]
|
||||
}
|
||||
+17
-3
@@ -17,6 +17,7 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/Niko1221/Strata/releases/latest"><img src="https://img.shields.io/github/v/release/Niko1221/Strata?label=download&color=0ab887" alt="Strata herunterladen"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/Niko1221/Strata" alt="MIT-Lizenz"></a>
|
||||
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor-EA4AAA?logo=githubsponsors&logoColor=fff" alt="Strata über GitHub Sponsors unterstützen"></a>
|
||||
<a href="https://buymeacoffee.com/strataengine"><img src="https://img.shields.io/badge/Buy_me_a_coffee-FFDD00?logo=buymeacoffee&logoColor=000" alt="Strata auf Buy Me a Coffee unterstützen"></a>
|
||||
</p>
|
||||
|
||||
@@ -62,7 +63,7 @@ In der **Desktop-App** und im **Terminal**. Was nicht in den Arbeitsspeicher pas
|
||||
| 2.00 bpw | 257 GB | etwa 265 GB |
|
||||
| 2.25 bpw | 274 GB | etwa 280 GB |
|
||||
|
||||
Läuft auf NVIDIA RTX 30, 40 und 50 (Windows und Linux) und auf AMD Radeon AI PRO R9700 (Linux). Das Setup lädt die Dateien und die DeepSeek-Engine herunter und startet dann das Modell.
|
||||
Läuft auf NVIDIA RTX 30, 40 und 50 (Windows und Linux), AMD Radeon AI PRO R9700 (Linux), AMD Ryzen AI Max — Strix Halo (Windows) und Apple-Silicon-Macs mit 64 GB oder mehr ([Mac](docs/DEEPSEEK_MAC.md)). Das Setup lädt die Dateien und die DeepSeek-Engine herunter und startet dann das Modell.
|
||||
|
||||
```sh
|
||||
strata launch --family deepseek41 --model ist-das-2.00bpw --plan
|
||||
@@ -95,7 +96,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.9-windows-x64-setup.exe` · `Strata-0.2.9-linux-x86_64.AppImage` · `Strata-0.2.9-macos-arm64.dmg`</sub>
|
||||
<sub>Dateien: `Strata-0.2.13-windows-x64-setup.exe` · `Strata-0.2.13-linux-x86_64.AppImage` · `Strata-0.2.13-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">
|
||||
@@ -175,10 +176,23 @@ Führe **`strata connect`** aus, um die Einrichtung für dein Tool zu sehen. [Ve
|
||||
|
||||
## Strata unterstützen
|
||||
|
||||
**Hilf mit, lokale KI für alle offen zu halten.** Ein Kaffee hilft, Entwicklung und Tests auf mehr Hardware zu finanzieren.
|
||||
**Hilf mit, lokale KI für alle offen zu halten.** Ein GitHub-Sponsoring oder ein Kaffee hilft, Entwicklung und Tests auf mehr Hardware zu finanzieren.
|
||||
|
||||
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor_on_GitHub-EA4AAA?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="GitHub Sponsors: Strata unterstützen" height="48"></a>
|
||||
<a href="https://buymeacoffee.com/strataengine"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee: Strata unterstützen" height="48"></a>
|
||||
|
||||
### Treue Unterstützer
|
||||
|
||||
Danke an alle, die Strata unterstützen.
|
||||
|
||||
<!-- supporters:start (one line per public supporter, newest last; copy a line and change the GitHub name in all four places) -->
|
||||
<a href="https://github.com/ryan4yin"><img src="https://github.com/ryan4yin.png?size=96" width="48" height="48" alt="ryan4yin" title="ryan4yin"></a>
|
||||
<a href="https://github.com/themactep"><img src="https://github.com/themactep.png?size=96" width="48" height="48" alt="themactep" title="themactep"></a>
|
||||
<a href="https://github.com/macintog"><img src="https://github.com/macintog.png?size=96" width="48" height="48" alt="macintog" title="macintog"></a>
|
||||
<a href="https://github.com/bisonbet"><img src="https://github.com/bisonbet.png?size=96" width="48" height="48" alt="bisonbet" title="bisonbet"></a>
|
||||
<a href="https://github.com/vizv"><img src="https://github.com/vizv.png?size=96" width="48" height="48" alt="vizv" title="vizv"></a>
|
||||
<!-- supporters:end -->
|
||||
|
||||
[MIT-Lizenz](LICENSE) · [Danksagung und Modelllizenzen](docs/HOW_IT_WORKS.md#credits) · [Datenschutz](docs/SAFETY.md)
|
||||
|
||||
<a id="option-c-let-your-ai-set-it-up"></a>
|
||||
|
||||
+17
-3
@@ -17,6 +17,7 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/Niko1221/Strata/releases/latest"><img src="https://img.shields.io/github/v/release/Niko1221/Strata?label=download&color=0ab887" alt="Descargar Strata"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/Niko1221/Strata" alt="Licencia MIT"></a>
|
||||
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor-EA4AAA?logo=githubsponsors&logoColor=fff" alt="Patrocina Strata en GitHub Sponsors"></a>
|
||||
<a href="https://buymeacoffee.com/strataengine"><img src="https://img.shields.io/badge/Buy_me_a_coffee-FFDD00?logo=buymeacoffee&logoColor=000" alt="Apoya Strata en Buy Me a Coffee"></a>
|
||||
</p>
|
||||
|
||||
@@ -62,7 +63,7 @@ En la **aplicación de escritorio** y en el **Terminal**. Lo que no cabe en la m
|
||||
| 2.00 bpw | 257 GB | unos 265 GB |
|
||||
| 2.25 bpw | 274 GB | unos 280 GB |
|
||||
|
||||
Funciona en NVIDIA RTX 30, 40 y 50 (Windows y Linux) y en AMD Radeon AI PRO R9700 (Linux). La instalación descarga los archivos y el motor de DeepSeek, y después inicia el modelo.
|
||||
Funciona en NVIDIA RTX 30, 40 y 50 (Windows y Linux), AMD Radeon AI PRO R9700 (Linux), AMD Ryzen AI Max — Strix Halo (Windows) y Macs con Apple Silicon de 64 GB o más ([Mac](docs/DEEPSEEK_MAC.md)). La instalación descarga los archivos y el motor de DeepSeek, y después inicia el modelo.
|
||||
|
||||
```sh
|
||||
strata launch --family deepseek41 --model ist-das-2.00bpw --plan
|
||||
@@ -95,7 +96,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.9-windows-x64-setup.exe` · `Strata-0.2.9-linux-x86_64.AppImage` · `Strata-0.2.9-macos-arm64.dmg`</sub>
|
||||
<sub>Archivos: `Strata-0.2.13-windows-x64-setup.exe` · `Strata-0.2.13-linux-x86_64.AppImage` · `Strata-0.2.13-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">
|
||||
@@ -175,10 +176,23 @@ Ejecuta **`strata connect`** para ver las instrucciones de tu herramienta. [Guí
|
||||
|
||||
## Apoya Strata
|
||||
|
||||
**Ayuda a mantener la IA local abierta a todos.** Un café ayuda a financiar el desarrollo y las pruebas en más hardware.
|
||||
**Ayuda a mantener la IA local abierta a todos.** Un patrocinio en GitHub o un café ayuda a financiar el desarrollo y las pruebas en más hardware.
|
||||
|
||||
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor_on_GitHub-EA4AAA?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="GitHub Sponsors: patrocina Strata" height="48"></a>
|
||||
<a href="https://buymeacoffee.com/strataengine"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee: apoya Strata" height="48"></a>
|
||||
|
||||
### Apoyos fieles
|
||||
|
||||
Gracias a todas las personas que apoyan Strata.
|
||||
|
||||
<!-- supporters:start (one line per public supporter, newest last; copy a line and change the GitHub name in all four places) -->
|
||||
<a href="https://github.com/ryan4yin"><img src="https://github.com/ryan4yin.png?size=96" width="48" height="48" alt="ryan4yin" title="ryan4yin"></a>
|
||||
<a href="https://github.com/themactep"><img src="https://github.com/themactep.png?size=96" width="48" height="48" alt="themactep" title="themactep"></a>
|
||||
<a href="https://github.com/macintog"><img src="https://github.com/macintog.png?size=96" width="48" height="48" alt="macintog" title="macintog"></a>
|
||||
<a href="https://github.com/bisonbet"><img src="https://github.com/bisonbet.png?size=96" width="48" height="48" alt="bisonbet" title="bisonbet"></a>
|
||||
<a href="https://github.com/vizv"><img src="https://github.com/vizv.png?size=96" width="48" height="48" alt="vizv" title="vizv"></a>
|
||||
<!-- supporters:end -->
|
||||
|
||||
[Licencia MIT](LICENSE) · [Créditos y licencias de los modelos](docs/HOW_IT_WORKS.md#credits) · [Privacidad](docs/SAFETY.md)
|
||||
|
||||
<a id="option-c-let-your-ai-set-it-up"></a>
|
||||
|
||||
+17
-3
@@ -17,6 +17,7 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/Niko1221/Strata/releases/latest"><img src="https://img.shields.io/github/v/release/Niko1221/Strata?label=download&color=0ab887" alt="Télécharger Strata"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/Niko1221/Strata" alt="Licence MIT"></a>
|
||||
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor-EA4AAA?logo=githubsponsors&logoColor=fff" alt="Soutenir Strata sur GitHub Sponsors"></a>
|
||||
<a href="https://buymeacoffee.com/strataengine"><img src="https://img.shields.io/badge/Buy_me_a_coffee-FFDD00?logo=buymeacoffee&logoColor=000" alt="Soutenir Strata sur Buy Me a Coffee"></a>
|
||||
</p>
|
||||
|
||||
@@ -62,7 +63,7 @@ Dans l'**application de bureau** et dans le **Terminal**. Ce qui ne tient pas en
|
||||
| 2.00 bpw | 257 GB | environ 265 GB |
|
||||
| 2.25 bpw | 274 GB | environ 280 GB |
|
||||
|
||||
Fonctionne sur les NVIDIA RTX 30, 40 et 50 (Windows et Linux) et sur l'AMD Radeon AI PRO R9700 (Linux). L'installation télécharge les fichiers et le moteur DeepSeek, puis démarre le modèle.
|
||||
Fonctionne sur les NVIDIA RTX 30, 40 et 50 (Windows et Linux), l'AMD Radeon AI PRO R9700 (Linux), l'AMD Ryzen AI Max — Strix Halo (Windows) et les Mac Apple Silicon avec 64 Go ou plus ([Mac](docs/DEEPSEEK_MAC.md)). L'installation télécharge les fichiers et le moteur DeepSeek, puis démarre le modèle.
|
||||
|
||||
```sh
|
||||
strata launch --family deepseek41 --model ist-das-2.00bpw --plan
|
||||
@@ -95,7 +96,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.9-windows-x64-setup.exe` · `Strata-0.2.9-linux-x86_64.AppImage` · `Strata-0.2.9-macos-arm64.dmg`</sub>
|
||||
<sub>Fichiers: `Strata-0.2.13-windows-x64-setup.exe` · `Strata-0.2.13-linux-x86_64.AppImage` · `Strata-0.2.13-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">
|
||||
@@ -175,10 +176,23 @@ Lancez **`strata connect`** pour voir la configuration de votre outil. [Guide de
|
||||
|
||||
## Soutenir Strata
|
||||
|
||||
**Aidez à garder l'IA locale ouverte à tous.** Un café aide à financer le développement et les tests sur davantage de matériel.
|
||||
**Aidez à garder l'IA locale ouverte à tous.** Un parrainage sur GitHub ou un café aide à financer le développement et les tests sur davantage de matériel.
|
||||
|
||||
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor_on_GitHub-EA4AAA?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="GitHub Sponsors : soutenir Strata" height="48"></a>
|
||||
<a href="https://buymeacoffee.com/strataengine"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee : soutenir Strata" height="48"></a>
|
||||
|
||||
### Fidèles soutiens
|
||||
|
||||
Merci à toutes les personnes qui soutiennent Strata.
|
||||
|
||||
<!-- supporters:start (one line per public supporter, newest last; copy a line and change the GitHub name in all four places) -->
|
||||
<a href="https://github.com/ryan4yin"><img src="https://github.com/ryan4yin.png?size=96" width="48" height="48" alt="ryan4yin" title="ryan4yin"></a>
|
||||
<a href="https://github.com/themactep"><img src="https://github.com/themactep.png?size=96" width="48" height="48" alt="themactep" title="themactep"></a>
|
||||
<a href="https://github.com/macintog"><img src="https://github.com/macintog.png?size=96" width="48" height="48" alt="macintog" title="macintog"></a>
|
||||
<a href="https://github.com/bisonbet"><img src="https://github.com/bisonbet.png?size=96" width="48" height="48" alt="bisonbet" title="bisonbet"></a>
|
||||
<a href="https://github.com/vizv"><img src="https://github.com/vizv.png?size=96" width="48" height="48" alt="vizv" title="vizv"></a>
|
||||
<!-- supporters:end -->
|
||||
|
||||
[Licence MIT](LICENSE) · [Crédits et licences des modèles](docs/HOW_IT_WORKS.md#credits) · [Confidentialité](docs/SAFETY.md)
|
||||
|
||||
<a id="option-c-let-your-ai-set-it-up"></a>
|
||||
|
||||
+17
-3
@@ -17,6 +17,7 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/Niko1221/Strata/releases/latest"><img src="https://img.shields.io/github/v/release/Niko1221/Strata?label=download&color=0ab887" alt="Strata をダウンロード"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/Niko1221/Strata" alt="MIT ライセンス"></a>
|
||||
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor-EA4AAA?logo=githubsponsors&logoColor=fff" alt="GitHub Sponsors で Strata を支援する"></a>
|
||||
<a href="https://buymeacoffee.com/strataengine"><img src="https://img.shields.io/badge/Buy_me_a_coffee-FFDD00?logo=buymeacoffee&logoColor=000" alt="Buy Me a Coffee で Strata を支援する"></a>
|
||||
</p>
|
||||
|
||||
@@ -62,7 +63,7 @@
|
||||
| 2.00 bpw | 257 GB | 約265 GB |
|
||||
| 2.25 bpw | 274 GB | 約280 GB |
|
||||
|
||||
NVIDIA RTX 30・40・50 シリーズ(Windows と Linux)と、AMD Radeon AI PRO R9700(Linux)で動きます。セットアップがファイルと DeepSeek エンジンをダウンロードし、モデルを起動します。
|
||||
NVIDIA RTX 30・40・50 シリーズ(Windows と Linux)、AMD Radeon AI PRO R9700(Linux)、AMD Ryzen AI Max — Strix Halo(Windows)、メモリ 64 GB 以上の Apple シリコン搭載 Mac([Mac](docs/DEEPSEEK_MAC.md))で動きます。セットアップがファイルと DeepSeek エンジンをダウンロードし、モデルを起動します。
|
||||
|
||||
```sh
|
||||
strata launch --family deepseek41 --model ist-das-2.00bpw --plan
|
||||
@@ -95,7 +96,7 @@ Strata 0.2.5 で 2.00 bpw のセットを使って測定しました。「出力
|
||||
|
||||
アプリをインストールし、モデルを選んで、チャットを始めましょう。インストーラーはシステムごとに1つです。初回起動時に、アプリがお使いのグラフィックスカード用のエンジンをダウンロードします。[インストーラーのヘルプ](docs/INSTALL.md#the-app)。
|
||||
|
||||
<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>
|
||||
<sub>ファイル: `Strata-0.2.13-windows-x64-setup.exe` · `Strata-0.2.13-linux-x86_64.AppImage` · `Strata-0.2.13-macos-arm64.dmg`</sub>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/media/app-chat.png" width="900" alt="チャットとライブのコードプレビューを並べて表示した Strata デスクトップアプリ">
|
||||
@@ -175,10 +176,23 @@ strata launch
|
||||
|
||||
## Strata を支援する
|
||||
|
||||
**ローカル AI をみんなに開かれたものに。** コーヒー1杯が、開発と、より多くのハードウェアでのテストの支えになります。
|
||||
**ローカル AI をみんなに開かれたものに。** GitHub でのスポンサーやコーヒー1杯が、開発と、より多くのハードウェアでのテストの支えになります。
|
||||
|
||||
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor_on_GitHub-EA4AAA?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="GitHub Sponsors: Strata を支援する" height="48"></a>
|
||||
<a href="https://buymeacoffee.com/strataengine"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee: Strata を支援する" height="48"></a>
|
||||
|
||||
### いつも支えてくださる方々
|
||||
|
||||
Strata を支えてくださるすべての方に感謝します。
|
||||
|
||||
<!-- supporters:start (one line per public supporter, newest last; copy a line and change the GitHub name in all four places) -->
|
||||
<a href="https://github.com/ryan4yin"><img src="https://github.com/ryan4yin.png?size=96" width="48" height="48" alt="ryan4yin" title="ryan4yin"></a>
|
||||
<a href="https://github.com/themactep"><img src="https://github.com/themactep.png?size=96" width="48" height="48" alt="themactep" title="themactep"></a>
|
||||
<a href="https://github.com/macintog"><img src="https://github.com/macintog.png?size=96" width="48" height="48" alt="macintog" title="macintog"></a>
|
||||
<a href="https://github.com/bisonbet"><img src="https://github.com/bisonbet.png?size=96" width="48" height="48" alt="bisonbet" title="bisonbet"></a>
|
||||
<a href="https://github.com/vizv"><img src="https://github.com/vizv.png?size=96" width="48" height="48" alt="vizv" title="vizv"></a>
|
||||
<!-- supporters:end -->
|
||||
|
||||
[MIT ライセンス](LICENSE) · [クレジットとモデルのライセンス](docs/HOW_IT_WORKS.md#credits) · [プライバシー](docs/SAFETY.md)
|
||||
|
||||
<a id="option-c-let-your-ai-set-it-up"></a>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/Niko1221/Strata/releases/latest"><img src="https://img.shields.io/github/v/release/Niko1221/Strata?label=download&color=0ab887" alt="Download Strata"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/Niko1221/Strata" alt="MIT license"></a>
|
||||
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor-EA4AAA?logo=githubsponsors&logoColor=fff" alt="Sponsor Strata on GitHub Sponsors"></a>
|
||||
<a href="https://buymeacoffee.com/strataengine"><img src="https://img.shields.io/badge/Buy_me_a_coffee-FFDD00?logo=buymeacoffee&logoColor=000" alt="Support Strata on Buy Me a Coffee"></a>
|
||||
</p>
|
||||
|
||||
@@ -65,7 +66,7 @@ disk space.
|
||||
| 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.
|
||||
(Windows), and Apple Silicon Macs with 64 GB or more ([Mac](docs/DEEPSEEK_MAC.md)). Setup downloads the files and the DeepSeek engine, then starts the model.
|
||||
|
||||
```sh
|
||||
strata launch --family deepseek41 --model ist-das-2.00bpw --plan
|
||||
@@ -100,7 +101,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.9-windows-x64-setup.exe` · `Strata-0.2.9-linux-x86_64.AppImage` · `Strata-0.2.9-macos-arm64.dmg`</sub>
|
||||
<sub>Files: `Strata-0.2.13-windows-x64-setup.exe` · `Strata-0.2.13-linux-x86_64.AppImage` · `Strata-0.2.13-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">
|
||||
@@ -181,10 +182,23 @@ Run **`strata connect`** to see setup instructions for your tool. [Connection gu
|
||||
|
||||
## Support Strata
|
||||
|
||||
**Help keep local AI open to everyone.** A coffee helps fund development and testing on more hardware.
|
||||
**Help keep local AI open to everyone.** A GitHub sponsorship or a coffee helps fund development and testing on more hardware.
|
||||
|
||||
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor_on_GitHub-EA4AAA?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="GitHub Sponsors: sponsor Strata" height="48"></a>
|
||||
<a href="https://buymeacoffee.com/strataengine"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee: support Strata" height="48"></a>
|
||||
|
||||
### Loyal supporters
|
||||
|
||||
Thank you to everyone who supports Strata.
|
||||
|
||||
<!-- supporters:start (one line per public supporter, newest last; copy a line and change the GitHub name in all four places) -->
|
||||
<a href="https://github.com/ryan4yin"><img src="https://github.com/ryan4yin.png?size=96" width="48" height="48" alt="ryan4yin" title="ryan4yin"></a>
|
||||
<a href="https://github.com/themactep"><img src="https://github.com/themactep.png?size=96" width="48" height="48" alt="themactep" title="themactep"></a>
|
||||
<a href="https://github.com/macintog"><img src="https://github.com/macintog.png?size=96" width="48" height="48" alt="macintog" title="macintog"></a>
|
||||
<a href="https://github.com/bisonbet"><img src="https://github.com/bisonbet.png?size=96" width="48" height="48" alt="bisonbet" title="bisonbet"></a>
|
||||
<a href="https://github.com/vizv"><img src="https://github.com/vizv.png?size=96" width="48" height="48" alt="vizv" title="vizv"></a>
|
||||
<!-- supporters:end -->
|
||||
|
||||
[MIT license](LICENSE) · [Credits and model licenses](docs/HOW_IT_WORKS.md#credits) · [Privacy](docs/SAFETY.md)
|
||||
|
||||
<a id="option-c-let-your-ai-set-it-up"></a>
|
||||
|
||||
+17
-3
@@ -17,6 +17,7 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/Niko1221/Strata/releases/latest"><img src="https://img.shields.io/github/v/release/Niko1221/Strata?label=download&color=0ab887" alt="Pobierz Stratę"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/Niko1221/Strata" alt="Licencja MIT"></a>
|
||||
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor-EA4AAA?logo=githubsponsors&logoColor=fff" alt="Wesprzyj Stratę na GitHub Sponsors"></a>
|
||||
<a href="https://buymeacoffee.com/strataengine"><img src="https://img.shields.io/badge/Buy_me_a_coffee-FFDD00?logo=buymeacoffee&logoColor=000" alt="Wesprzyj Stratę na Buy Me a Coffee"></a>
|
||||
</p>
|
||||
|
||||
@@ -64,7 +65,7 @@ dużo wolnego miejsca na dysku.
|
||||
| 2.25 bpw | 274 GB | około 280 GB |
|
||||
|
||||
Działa na kartach NVIDIA RTX serii 30, 40 i 50 (Windows i Linux), AMD Radeon AI PRO R9700 (Linux) oraz AMD Ryzen AI Max — Strix Halo
|
||||
(Windows). Instalacja pobiera pliki i silnik DeepSeek, a potem uruchamia model.
|
||||
(Windows) oraz na komputerach Mac z Apple Silicon i co najmniej 64 GB pamięci ([Mac](docs/DEEPSEEK_MAC.md)). Instalacja pobiera pliki i silnik DeepSeek, a potem uruchamia model.
|
||||
|
||||
```sh
|
||||
strata launch --family deepseek41 --model ist-das-2.00bpw --plan
|
||||
@@ -99,7 +100,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.9-windows-x64-setup.exe` · `Strata-0.2.9-linux-x86_64.AppImage` · `Strata-0.2.9-macos-arm64.dmg`</sub>
|
||||
<sub>Pliki: `Strata-0.2.13-windows-x64-setup.exe` · `Strata-0.2.13-linux-x86_64.AppImage` · `Strata-0.2.13-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">
|
||||
@@ -179,10 +180,23 @@ Uruchom **`strata connect`**, aby zobaczyć instrukcję konfiguracji dla swojego
|
||||
|
||||
## Wesprzyj Stratę
|
||||
|
||||
**Pomóż zachować lokalną sztuczną inteligencję otwartą dla wszystkich.** Kawa pomaga finansować rozwój i testy na większej liczbie sprzętu.
|
||||
**Pomóż zachować lokalną sztuczną inteligencję otwartą dla wszystkich.** Sponsoring na GitHubie lub kawa pomaga finansować rozwój i testy na większej liczbie sprzętu.
|
||||
|
||||
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor_on_GitHub-EA4AAA?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="GitHub Sponsors: wesprzyj Stratę" height="48"></a>
|
||||
<a href="https://buymeacoffee.com/strataengine"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee: wesprzyj Stratę" height="48"></a>
|
||||
|
||||
### Wierni wspierający
|
||||
|
||||
Dziękujemy wszystkim, którzy wspierają Stratę.
|
||||
|
||||
<!-- supporters:start (one line per public supporter, newest last; copy a line and change the GitHub name in all four places) -->
|
||||
<a href="https://github.com/ryan4yin"><img src="https://github.com/ryan4yin.png?size=96" width="48" height="48" alt="ryan4yin" title="ryan4yin"></a>
|
||||
<a href="https://github.com/themactep"><img src="https://github.com/themactep.png?size=96" width="48" height="48" alt="themactep" title="themactep"></a>
|
||||
<a href="https://github.com/macintog"><img src="https://github.com/macintog.png?size=96" width="48" height="48" alt="macintog" title="macintog"></a>
|
||||
<a href="https://github.com/bisonbet"><img src="https://github.com/bisonbet.png?size=96" width="48" height="48" alt="bisonbet" title="bisonbet"></a>
|
||||
<a href="https://github.com/vizv"><img src="https://github.com/vizv.png?size=96" width="48" height="48" alt="vizv" title="vizv"></a>
|
||||
<!-- supporters:end -->
|
||||
|
||||
[Licencja MIT](LICENSE) · [Podziękowania i licencje modeli](docs/HOW_IT_WORKS.md#credits) · [Prywatność](docs/SAFETY.md)
|
||||
|
||||
<a id="option-c-let-your-ai-set-it-up"></a>
|
||||
|
||||
+17
-3
@@ -17,6 +17,7 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/Niko1221/Strata/releases/latest"><img src="https://img.shields.io/github/v/release/Niko1221/Strata?label=download&color=0ab887" alt="Baixar o Strata"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/Niko1221/Strata" alt="Licença MIT"></a>
|
||||
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor-EA4AAA?logo=githubsponsors&logoColor=fff" alt="Apoie o Strata no GitHub Sponsors"></a>
|
||||
<a href="https://buymeacoffee.com/strataengine"><img src="https://img.shields.io/badge/Buy_me_a_coffee-FFDD00?logo=buymeacoffee&logoColor=000" alt="Apoie o Strata no Buy Me a Coffee"></a>
|
||||
</p>
|
||||
|
||||
@@ -62,7 +63,7 @@ No **aplicativo de desktop** e no **Terminal**. O que não cabe na memória é l
|
||||
| 2.00 bpw | 257 GB | cerca de 265 GB |
|
||||
| 2.25 bpw | 274 GB | cerca de 280 GB |
|
||||
|
||||
Roda em NVIDIA RTX 30, 40 e 50 (Windows e Linux) e em AMD Radeon AI PRO R9700 (Linux). A instalação baixa os arquivos e o motor do DeepSeek e depois inicia o modelo.
|
||||
Roda em NVIDIA RTX 30, 40 e 50 (Windows e Linux), AMD Radeon AI PRO R9700 (Linux), AMD Ryzen AI Max — Strix Halo (Windows) e Macs com Apple Silicon e 64 GB ou mais ([Mac](docs/DEEPSEEK_MAC.md)). A instalação baixa os arquivos e o motor do DeepSeek e depois inicia o modelo.
|
||||
|
||||
```sh
|
||||
strata launch --family deepseek41 --model ist-das-2.00bpw --plan
|
||||
@@ -95,7 +96,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.9-windows-x64-setup.exe` · `Strata-0.2.9-linux-x86_64.AppImage` · `Strata-0.2.9-macos-arm64.dmg`</sub>
|
||||
<sub>Arquivos: `Strata-0.2.13-windows-x64-setup.exe` · `Strata-0.2.13-linux-x86_64.AppImage` · `Strata-0.2.13-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">
|
||||
@@ -175,10 +176,23 @@ Execute **`strata connect`** para ver as instruções da sua ferramenta. [Guia d
|
||||
|
||||
## Apoie o Strata
|
||||
|
||||
**Ajude a manter a IA local aberta para todos.** Um café ajuda a financiar o desenvolvimento e os testes em mais hardware.
|
||||
**Ajude a manter a IA local aberta para todos.** Um patrocínio no GitHub ou um café ajuda a financiar o desenvolvimento e os testes em mais hardware.
|
||||
|
||||
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor_on_GitHub-EA4AAA?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="GitHub Sponsors: apoie o Strata" height="48"></a>
|
||||
<a href="https://buymeacoffee.com/strataengine"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee: apoie o Strata" height="48"></a>
|
||||
|
||||
### Apoiadores fiéis
|
||||
|
||||
Obrigado a todas as pessoas que apoiam o Strata.
|
||||
|
||||
<!-- supporters:start (one line per public supporter, newest last; copy a line and change the GitHub name in all four places) -->
|
||||
<a href="https://github.com/ryan4yin"><img src="https://github.com/ryan4yin.png?size=96" width="48" height="48" alt="ryan4yin" title="ryan4yin"></a>
|
||||
<a href="https://github.com/themactep"><img src="https://github.com/themactep.png?size=96" width="48" height="48" alt="themactep" title="themactep"></a>
|
||||
<a href="https://github.com/macintog"><img src="https://github.com/macintog.png?size=96" width="48" height="48" alt="macintog" title="macintog"></a>
|
||||
<a href="https://github.com/bisonbet"><img src="https://github.com/bisonbet.png?size=96" width="48" height="48" alt="bisonbet" title="bisonbet"></a>
|
||||
<a href="https://github.com/vizv"><img src="https://github.com/vizv.png?size=96" width="48" height="48" alt="vizv" title="vizv"></a>
|
||||
<!-- supporters:end -->
|
||||
|
||||
[Licença MIT](LICENSE) · [Créditos e licenças dos modelos](docs/HOW_IT_WORKS.md#credits) · [Privacidade](docs/SAFETY.md)
|
||||
|
||||
<a id="option-c-let-your-ai-set-it-up"></a>
|
||||
|
||||
+17
-3
@@ -17,6 +17,7 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/Niko1221/Strata/releases/latest"><img src="https://img.shields.io/github/v/release/Niko1221/Strata?label=download&color=0ab887" alt="下载 Strata"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/Niko1221/Strata" alt="MIT 许可证"></a>
|
||||
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor-EA4AAA?logo=githubsponsors&logoColor=fff" alt="在 GitHub Sponsors 上支持 Strata"></a>
|
||||
<a href="https://buymeacoffee.com/strataengine"><img src="https://img.shields.io/badge/Buy_me_a_coffee-FFDD00?logo=buymeacoffee&logoColor=000" alt="在 Buy Me a Coffee 上支持 Strata"></a>
|
||||
</p>
|
||||
|
||||
@@ -62,7 +63,7 @@
|
||||
| 2.00 bpw | 257 GB | 约265 GB |
|
||||
| 2.25 bpw | 274 GB | 约280 GB |
|
||||
|
||||
支持 NVIDIA RTX 30、40 和 50 系列(Windows 和 Linux),以及 AMD Radeon AI PRO R9700(Linux)。安装程序会下载模型文件和 DeepSeek 引擎,然后启动模型。
|
||||
支持 NVIDIA RTX 30、40 和 50 系列(Windows 和 Linux)、AMD Radeon AI PRO R9700(Linux)、AMD Ryzen AI Max — Strix Halo(Windows),以及内存 64 GB 或以上的 Apple 芯片 Mac([Mac](docs/DEEPSEEK_MAC.md))。安装程序会下载模型文件和 DeepSeek 引擎,然后启动模型。
|
||||
|
||||
```sh
|
||||
strata launch --family deepseek41 --model ist-das-2.00bpw --plan
|
||||
@@ -95,7 +96,7 @@ strata launch --family deepseek41 --model ist-das-2.00bpw --plan
|
||||
|
||||
安装应用,选择模型,就可以开始聊天。每个系统只有一个安装程序。首次启动时,应用会下载适合你显卡的引擎。[安装程序帮助](docs/INSTALL.md#the-app)。
|
||||
|
||||
<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>
|
||||
<sub>文件: `Strata-0.2.13-windows-x64-setup.exe` · `Strata-0.2.13-linux-x86_64.AppImage` · `Strata-0.2.13-macos-arm64.dmg`</sub>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/media/app-chat.png" width="900" alt="Strata 桌面应用,聊天和实时代码预览并排显示">
|
||||
@@ -175,10 +176,23 @@ strata launch
|
||||
|
||||
## 支持 Strata
|
||||
|
||||
**帮助本地 AI 保持开放。** 一杯咖啡能帮助我们在更多硬件上开发和测试。
|
||||
**帮助本地 AI 保持开放。** GitHub 赞助或一杯咖啡,都能帮助我们在更多硬件上开发和测试。
|
||||
|
||||
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor_on_GitHub-EA4AAA?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="GitHub Sponsors:支持 Strata" height="48"></a>
|
||||
<a href="https://buymeacoffee.com/strataengine"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee:支持 Strata" height="48"></a>
|
||||
|
||||
### 忠实支持者
|
||||
|
||||
感谢每一位支持 Strata 的人。
|
||||
|
||||
<!-- supporters:start (one line per public supporter, newest last; copy a line and change the GitHub name in all four places) -->
|
||||
<a href="https://github.com/ryan4yin"><img src="https://github.com/ryan4yin.png?size=96" width="48" height="48" alt="ryan4yin" title="ryan4yin"></a>
|
||||
<a href="https://github.com/themactep"><img src="https://github.com/themactep.png?size=96" width="48" height="48" alt="themactep" title="themactep"></a>
|
||||
<a href="https://github.com/macintog"><img src="https://github.com/macintog.png?size=96" width="48" height="48" alt="macintog" title="macintog"></a>
|
||||
<a href="https://github.com/bisonbet"><img src="https://github.com/bisonbet.png?size=96" width="48" height="48" alt="bisonbet" title="bisonbet"></a>
|
||||
<a href="https://github.com/vizv"><img src="https://github.com/vizv.png?size=96" width="48" height="48" alt="vizv" title="vizv"></a>
|
||||
<!-- supporters:end -->
|
||||
|
||||
[MIT 许可证](LICENSE) · [致谢和模型许可证](docs/HOW_IT_WORKS.md#credits) · [隐私](docs/SAFETY.md)
|
||||
|
||||
<a id="option-c-let-your-ai-set-it-up"></a>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "strata-desktop",
|
||||
"productName": "Strata",
|
||||
"version": "0.2.10",
|
||||
"version": "0.2.13",
|
||||
"private": true,
|
||||
"description": "Strata Desktop - local AI, tuned to one model.",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Is every static-PIE program inside an AppImage still the program it was staged as? (#4337)
|
||||
|
||||
Why: the bundled ripgrep (`harness/node_modules/@vscode/ripgrep-linux-x64/bin/rg`) is a statically linked PIE. Such a program relocates itself at
|
||||
start-up by reading its own `.dynamic` section through a fixed offset in its code. linuxdeploy, which Tauri runs over the AppDir, treats every ELF file
|
||||
as a library to fix up and rewrites its RPATH; for a static PIE that moves `.dynamic` to a new segment and leaves the old place filled with a filler
|
||||
byte, so the start-up code reads garbage. Reporters on #4337 see the same crash offset in every run (`rg + 0x3ffcf1`) and filler bytes (0x5A) where
|
||||
`.rela.dyn` and `.dynamic` were. A static PIE whose `.dynamic` is filled does not start: tools/test_appimage_elf.py shows that on a real one.
|
||||
apps/desktop/scripts/repack-appimage.sh puts the staged bytes back; this file is the check that says whether it did.
|
||||
|
||||
appimage_elf.py scan <folder> every static PIE under <folder> must have a sound .dynamic (exit 1 if not)
|
||||
appimage_elf.py compare <staged-folder> <folder> the same files, byte for byte, plus what differs in their ELF headers
|
||||
|
||||
What is checked: only static PIE programs. A shared library (.so, Python's and Node's native modules, Pillow, numpy, sharp) is not one, however it is
|
||||
named: it has no entry point, or a DT_SONAME and no DF_1_PIE flag, and its RPATH/RUNPATH is normal. A static PIE is an ET_DYN with no interpreter, an entry
|
||||
point, and DF_1_PIE in DT_FLAGS_1; one whose .dynamic cannot be read at all (overwritten) is reported as damaged, since the overwrite takes the flags too.
|
||||
|
||||
Standard library only; reads ELF64 little endian (x86-64 and arm64), anything else is ignored.
|
||||
"""
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
|
||||
ET_DYN = 3
|
||||
PT_LOAD, PT_DYNAMIC, PT_INTERP = 1, 2, 3
|
||||
DT_NULL, DT_NEEDED, DT_RELA, DT_RELASZ, DT_RELAENT, DT_SONAME, DT_RPATH, DT_RUNPATH = 0, 1, 7, 8, 9, 14, 15, 29
|
||||
DT_FLAGS_1, DF_1_PIE = 0x6FFFFFFB, 0x08000000
|
||||
FILLERS = (0x5A, 0x58) # 'Z' (what the reports found) and 'X' (what patchelf documents)
|
||||
RUN = 128 # that many equal filler bytes in a row is not code or data
|
||||
|
||||
|
||||
def _known_tag(tag):
|
||||
"""The dynamic tags the ELF specification and the GNU and processor ranges define: anything else is garbage."""
|
||||
return 0 <= tag <= 0x22 or 0x6000000D <= tag <= 0x6FFFFFFF or 0x70000000 <= tag <= 0x7FFFFFFF
|
||||
|
||||
|
||||
def read_elf(data):
|
||||
"""The facts about an ELF64 little-endian file, or None when it is not one."""
|
||||
if len(data) < 64 or data[:4] != b"\x7fELF" or data[4] != 2 or data[5] != 1:
|
||||
return None
|
||||
e_type = struct.unpack_from("<H", data, 16)[0]
|
||||
e_entry = struct.unpack_from("<Q", data, 0x18)[0]
|
||||
phoff = struct.unpack_from("<Q", data, 0x20)[0]
|
||||
phentsize, phnum = struct.unpack_from("<HH", data, 0x36)
|
||||
if phentsize < 56 or phoff + phentsize * phnum > len(data):
|
||||
return {"type": e_type, "entry": e_entry, "phdrs": [], "broken": "the program header table is outside the file"}
|
||||
phdrs = []
|
||||
for i in range(phnum):
|
||||
p_type, flags, off, vaddr, _paddr, filesz, memsz, align = struct.unpack_from("<IIQQQQQQ", data, phoff + i * phentsize)
|
||||
phdrs.append({"type": p_type, "flags": flags, "off": off, "vaddr": vaddr, "filesz": filesz, "memsz": memsz, "align": align})
|
||||
return {"type": e_type, "entry": e_entry, "phdrs": phdrs, "broken": None}
|
||||
|
||||
|
||||
def is_pie_candidate(info):
|
||||
"""An ET_DYN with no interpreter, a dynamic segment and an entry point: what a static PIE looks like from outside the dynamic section.
|
||||
A shared library normally has no entry point (e_entry 0) and is not a candidate; the few that do (the C library itself, the dynamic loader)
|
||||
have a readable dynamic section and are told apart by it (static_pie_kind). The test uses nothing the damage we look for can erase."""
|
||||
return bool(info) and info["type"] == ET_DYN and not info["broken"] and info.get("entry", 0) != 0 and not any(p["type"] == PT_INTERP for p in info["phdrs"]) and any(p["type"] == PT_DYNAMIC for p in info["phdrs"])
|
||||
|
||||
|
||||
def static_pie_kind(data, info):
|
||||
"""'static-pie' for a sound static PIE (DT_FLAGS_1 has DF_1_PIE, no DT_SONAME), 'damaged' for a candidate whose dynamic section cannot be read
|
||||
(a static PIE with an overwritten .dynamic loses its flags with it, so unreadable is treated as damaged), None for everything else:
|
||||
shared libraries, which carry a DT_SONAME and no DF_1_PIE, and programs that are not static PIEs."""
|
||||
if not is_pie_candidate(info):
|
||||
return None
|
||||
entries, why = dynamic_entries(data, info)
|
||||
if why or any(not _known_tag(t) for t, _ in entries):
|
||||
return "damaged"
|
||||
tags = {t: v for t, v in entries}
|
||||
if DT_SONAME in tags:
|
||||
return None
|
||||
return "static-pie" if tags.get(DT_FLAGS_1, 0) & DF_1_PIE else None
|
||||
|
||||
|
||||
def is_static_pie(info):
|
||||
"""Kept for callers: a candidate. check_static_pie decides with static_pie_kind."""
|
||||
return is_pie_candidate(info)
|
||||
|
||||
|
||||
def _mapped(info, vaddr, size):
|
||||
"""The file offset of [vaddr, vaddr+size) when one PT_LOAD maps all of it from the file, else None."""
|
||||
for p in info["phdrs"]:
|
||||
if p["type"] == PT_LOAD and p["vaddr"] <= vaddr and vaddr + size <= p["vaddr"] + p["filesz"]:
|
||||
return p["off"] + (vaddr - p["vaddr"])
|
||||
return None
|
||||
|
||||
|
||||
def dynamic_entries(data, info):
|
||||
"""(tag, value) pairs of the PT_DYNAMIC segment up to DT_NULL, and the problem if it does not end properly."""
|
||||
seg = next(p for p in info["phdrs"] if p["type"] == PT_DYNAMIC)
|
||||
if seg["off"] + seg["filesz"] > len(data):
|
||||
return [], "the dynamic segment is outside the file"
|
||||
out = []
|
||||
for at in range(seg["off"], seg["off"] + seg["filesz"] - 15, 16):
|
||||
tag, val = struct.unpack_from("<qQ", data, at)
|
||||
if tag == DT_NULL:
|
||||
return out, None
|
||||
out.append((tag, val))
|
||||
return out, "the dynamic segment does not end with DT_NULL"
|
||||
|
||||
|
||||
def _longest_filler(data, start, end):
|
||||
best = run = 0
|
||||
prev = None
|
||||
for b in data[start:end]:
|
||||
run = run + 1 if b == prev and b in FILLERS else (1 if b in FILLERS else 0)
|
||||
prev = b
|
||||
best = max(best, run)
|
||||
return best
|
||||
|
||||
|
||||
def check_static_pie(data):
|
||||
"""The problems of one file (a list of sentences); empty for a sound static PIE and for anything that is not one."""
|
||||
info = read_elf(data)
|
||||
if static_pie_kind(data, info) is None:
|
||||
return []
|
||||
problems = []
|
||||
seg = next(p for p in info["phdrs"] if p["type"] == PT_DYNAMIC)
|
||||
if _mapped(info, seg["vaddr"], max(seg["filesz"], 1)) is None:
|
||||
problems.append("the dynamic segment is not inside any loaded segment")
|
||||
entries, why = dynamic_entries(data, info)
|
||||
if why:
|
||||
problems.append(why)
|
||||
bad = [t for t, _ in entries if not _known_tag(t)]
|
||||
if bad:
|
||||
problems.append(f"{len(bad)} dynamic entries have tags that do not exist (first {bad[0]:#x}): the section is overwritten")
|
||||
if any(t in (DT_RPATH, DT_RUNPATH) for t, _ in entries):
|
||||
problems.append("a static PIE has an RPATH or RUNPATH: something rewrote it after it was linked")
|
||||
values = dict(entries)
|
||||
if DT_RELA in values:
|
||||
size = values.get(DT_RELASZ, 0)
|
||||
at = _mapped(info, values[DT_RELA], max(size, 1))
|
||||
if size == 0 or at is None:
|
||||
problems.append("the relocation table named by DT_RELA is empty or outside the loaded segments")
|
||||
elif _longest_filler(data, at, at + size) >= min(RUN, size):
|
||||
problems.append("the relocation table is filled with one byte")
|
||||
for p in info["phdrs"]:
|
||||
if p["type"] == PT_LOAD and _longest_filler(data, p["off"], p["off"] + p["filesz"]) >= RUN:
|
||||
problems.append(f"{RUN} or more filler bytes (0x5A or 0x58) in a row inside a loaded segment")
|
||||
break
|
||||
return problems
|
||||
|
||||
|
||||
def elf_differences(staged, packaged):
|
||||
"""What differs between two builds of one program in the parts that matter for start-up: for the report."""
|
||||
a, b = read_elf(staged), read_elf(packaged)
|
||||
if not a or not b:
|
||||
return [] if staged == packaged else ["one of the two is not an ELF64 file"]
|
||||
out = []
|
||||
ta, tb = [p["type"] for p in a["phdrs"]], [p["type"] for p in b["phdrs"]]
|
||||
if ta != tb:
|
||||
out.append(f"program headers {len(ta)} -> {len(tb)} ({ta.count(PT_LOAD)} -> {tb.count(PT_LOAD)} loaded segments)")
|
||||
if is_pie_candidate(a) and is_pie_candidate(b):
|
||||
da, _ = dynamic_entries(staged, a)
|
||||
db, _ = dynamic_entries(packaged, b)
|
||||
if da != db:
|
||||
out.append(f"dynamic section: {len(da)} -> {len(db)} entries" + ("; an RPATH/RUNPATH was added" if any(t in (DT_RPATH, DT_RUNPATH) for t, _ in db) and not any(t in (DT_RPATH, DT_RUNPATH) for t, _ in da) else ""))
|
||||
return out
|
||||
|
||||
|
||||
def _files(folder):
|
||||
for base, dirs, names in os.walk(folder):
|
||||
dirs.sort()
|
||||
for name in sorted(names):
|
||||
path = os.path.join(base, name)
|
||||
if os.path.islink(path) or not os.path.isfile(path):
|
||||
continue
|
||||
yield path
|
||||
|
||||
|
||||
def scan(folder):
|
||||
"""{relative path: problems} for every static PIE under `folder` that has any."""
|
||||
found = {}
|
||||
for path in _files(folder):
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
head = f.read(4)
|
||||
if head != b"\x7fELF":
|
||||
continue
|
||||
data = head + f.read()
|
||||
except OSError:
|
||||
continue
|
||||
problems = check_static_pie(data)
|
||||
if problems:
|
||||
found[os.path.relpath(path, folder)] = problems
|
||||
return found
|
||||
|
||||
|
||||
def compare(staged_folder, folder):
|
||||
"""{relative path: sentences} for every staged ELF file that is missing or differs in `folder`."""
|
||||
found = {}
|
||||
for path in _files(staged_folder):
|
||||
rel = os.path.relpath(path, staged_folder)
|
||||
with open(path, "rb") as f:
|
||||
want = f.read()
|
||||
if want[:4] != b"\x7fELF":
|
||||
continue
|
||||
other = os.path.join(folder, rel)
|
||||
if not os.path.isfile(other):
|
||||
found[rel] = ["missing"]
|
||||
continue
|
||||
with open(other, "rb") as f:
|
||||
have = f.read()
|
||||
if have != want:
|
||||
found[rel] = ["bytes differ"] + elf_differences(want, have)
|
||||
return found
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) >= 3 and argv[1] == "scan":
|
||||
bad = scan(argv[2])
|
||||
for rel, problems in bad.items():
|
||||
print(f"BROKEN {rel}: " + "; ".join(problems))
|
||||
print(f"{len(bad)} static PIE file(s) with a damaged dynamic section")
|
||||
return 1 if bad else 0
|
||||
if len(argv) >= 4 and argv[1] == "compare":
|
||||
bad = compare(argv[2], argv[3])
|
||||
for rel, why in bad.items():
|
||||
print(f"DIFFERS {rel}: " + "; ".join(why))
|
||||
print(f"{len(bad)} ELF file(s) differ from what was staged")
|
||||
return 1 if bad else 0
|
||||
print(__doc__)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -9,7 +9,9 @@
|
||||
# installer's own integrity promise ("what we staged is what runs") is broken. This script unpacks the AppImage, copies the staged
|
||||
# folders (control, harness, node, engine-bundle) over the AppDir byte for byte, and packs it again with the same runtime.
|
||||
#
|
||||
# --check does not write anything: it lists every engine-bundle file whose bytes differ from the manifest in the AppImage.
|
||||
# --check does not write anything: it lists every file of control, harness, node and engine-bundle whose bytes differ from what was staged, then
|
||||
# looks at every static PIE in the AppImage (appimage_elf.py: the bundled ripgrep is one; linuxdeploy's RPATH rewrite leaves such a program with a
|
||||
# filled .dynamic and it crashes at start, #4337) and runs the bundled `rg` once. A single "0 file(s) differ" and "ok" is what a release needs.
|
||||
set -euo pipefail
|
||||
|
||||
IN="$(realpath "${1:?in.AppImage}")"; OUT="${2:-}"
|
||||
@@ -33,13 +35,36 @@ LIB="$(find "$WORK/AppDir/usr/lib" -maxdepth 2 -type d -name engine-bundle -prin
|
||||
[ -n "$LIB" ] || LIB="$(find "$WORK/AppDir/usr/lib" -maxdepth 2 -type d -name control -printf '%h\n' | head -1)"
|
||||
[ -n "$LIB" ] || { echo "no Strata resource folder in the AppImage" >&2; exit 1; }
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Every static PIE under the AppDir must still have a sound .dynamic, and the bundled ripgrep must start (x86-64 Linux only: it is run).
|
||||
elf_and_rg_check() {
|
||||
local bad=0
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 "$HERE/appimage_elf.py" scan "$LIB" || bad=1
|
||||
else
|
||||
echo "python3 is not here: the static PIE check was skipped" >&2
|
||||
fi
|
||||
local rg
|
||||
rg="$(find "$LIB/harness" -path '*ripgrep-linux-x64/bin/rg' -type f 2>/dev/null | head -1)"
|
||||
if [ -n "$rg" ] && [ "$(uname -s)" = Linux ] && [ "$(uname -m)" = x86_64 ]; then
|
||||
local t; t="$(mktemp -d -t strata-rg.XXXXXX)"
|
||||
echo 'needle in a haystack' > "$t/h.txt"
|
||||
if "$rg" --version >/dev/null 2>&1 && [ "$("$rg" --no-config -c needle "$t/h.txt" 2>/dev/null)" = 1 ]; then echo "bundled rg runs: ok"
|
||||
else echo "the bundled rg does not run: $rg" >&2; bad=1; fi
|
||||
rm -rf "$t"
|
||||
fi
|
||||
return $bad
|
||||
}
|
||||
|
||||
if [ "$CHECK" = 1 ]; then
|
||||
bad=0
|
||||
while IFS= read -r f; do
|
||||
rel="${f#$RES/}"
|
||||
if ! cmp -s "$f" "$LIB/$rel"; then echo "differs: $rel"; bad=$((bad + 1)); fi
|
||||
done < <(find "$RES/engine-bundle" "$RES/node" -type f ! -name .gitignore)
|
||||
done < <(for d in control harness node engine-bundle; do [ -d "$RES/$d" ] && find "$RES/$d" -type f ! -name .gitignore; done)
|
||||
echo "$bad file(s) differ from what was staged"
|
||||
elf_and_rg_check || bad=$((bad + 1))
|
||||
[ "$bad" = 0 ]
|
||||
exit
|
||||
fi
|
||||
@@ -49,6 +74,8 @@ for d in control harness node engine-bundle; do
|
||||
[ -d "$RES/$d" ] || continue
|
||||
rsync -aL --checksum --delete --exclude .gitignore "$RES/$d/" "$LIB/$d/"
|
||||
done
|
||||
# What was put back must be sound before it is packed: a damaged static PIE (the bundled ripgrep) stops the pack here, not on a person's computer.
|
||||
elf_and_rg_check || { echo "the restored AppDir failed the static PIE / ripgrep check; not packing" >&2; exit 1; }
|
||||
# The launcher records the environment the AppImage was started from, before its own hooks add the AppImage's libraries and GTK/GIO
|
||||
# settings (LD_LIBRARY_PATH, GTK_*, XDG_DATA_DIRS ...). The host gives it back to every program that is not Strata's own (zenity,
|
||||
# kdialog, xdg-open, the assistant's shell): apps/host/server/external-env.mjs. Without it a system program loads the AppImage's libssl
|
||||
|
||||
@@ -616,10 +616,10 @@ await scenario('stop', { installed: true, env: { FAKE_TOKEN_DELAY: '0.1', FAKE_R
|
||||
await scenario('latest', {}, async ({ base, browser, shot }) => {
|
||||
const page = await newPage(browser, base);
|
||||
const now = Date.now();
|
||||
await page.evaluate(now => {
|
||||
await page.evaluate(async now => {
|
||||
const messages = [];
|
||||
for (let i = 0; i < 40; i++) { messages.push({ id: 'u' + i, role: 'user', content: 'question ' + i, at: now }, { id: 'a' + i, role: 'assistant', content: 'answer number ' + i + ' with some text to fill the line and more of it.', at: now, tokens: 20, inputTokens: 5 }); }
|
||||
localStorage.setItem('strata.sessions.v1', JSON.stringify([{ id: 's_latest', title: 'long', updatedAt: now, createdAt: now, messages }]));
|
||||
await fetch('/api/chats/s_latest', { method: 'PUT', headers: { 'x-strata-client': 'ui' }, body: JSON.stringify({ id: 's_latest', title: 'long', updatedAt: now, createdAt: now, messages }) });
|
||||
localStorage.setItem('strata.active-session.v1', 's_latest');
|
||||
}, now);
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
@@ -690,12 +690,12 @@ if (only.has('restart')) {
|
||||
await run(async (context, { base }) => {
|
||||
const page = context.pages()[0] ?? await context.newPage();
|
||||
await page.goto(base, { waitUntil: 'domcontentloaded' });
|
||||
await page.evaluate(() => { localStorage.setItem('strata.prefs.v2', JSON.stringify({ theme: 'dark', contentFontSize: 19, language: 'de' })); localStorage.setItem('strata.sessions.v1', JSON.stringify([{ id: 'chat-1', title: 'My chat' }])); });
|
||||
await page.evaluate(() => { localStorage.setItem('strata.prefs.v2', JSON.stringify({ theme: 'dark', contentFontSize: 19, language: 'de' })); return fetch('/api/chats/chat-1', { method: 'PUT', headers: { 'x-strata-client': 'ui' }, body: JSON.stringify({ id: 'chat-1', title: 'My chat', messages: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }) }); });
|
||||
});
|
||||
await run(async (context, { base }) => {
|
||||
const page = context.pages()[0] ?? await context.newPage();
|
||||
await page.goto(base, { waitUntil: 'domcontentloaded' });
|
||||
const saved = await page.evaluate(() => ({ prefs: JSON.parse(localStorage.getItem('strata.prefs.v2') || '{}'), chats: localStorage.getItem('strata.sessions.v1'), theme: document.documentElement.dataset.theme }));
|
||||
const saved = await page.evaluate(async () => ({ prefs: JSON.parse(localStorage.getItem('strata.prefs.v2') || '{}'), chats: JSON.stringify(await (await fetch('/api/chats', { headers: { 'x-strata-client': 'ui' } })).json()), theme: document.documentElement.dataset.theme }));
|
||||
check(saved.prefs.contentFontSize === 19 && saved.prefs.language === 'de', 'text size and language survive a restart on the same port', JSON.stringify(saved.prefs));
|
||||
check(saved.theme === 'dark', 'the theme survives too', saved.theme);
|
||||
check(/My chat/.test(saved.chats ?? ''), 'and the chat list', saved.chats);
|
||||
|
||||
Generated
+3
-1
@@ -3625,7 +3625,7 @@ checksum = "ff7589a1859b09232522bb9edf076ca7fcf14ded96685d3fa629db73023ffbb7"
|
||||
|
||||
[[package]]
|
||||
name = "strata-desktop"
|
||||
version = "0.2.10"
|
||||
version = "0.2.13"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"serde",
|
||||
@@ -3637,6 +3637,8 @@ dependencies = [
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "strata-desktop"
|
||||
version = "0.2.10"
|
||||
version = "0.2.13"
|
||||
description = "Strata Desktop — local AI, tuned to one model."
|
||||
edition = "2021"
|
||||
rust-version = "1.77"
|
||||
@@ -11,6 +11,11 @@ license = "MIT"
|
||||
gtk = "0.18"
|
||||
webkit2gtk = "2.0"
|
||||
|
||||
# Windows: the shell listens for the page's renderer failing (WebView2 ProcessFailed, see hook_renderer_failures in main.rs); these are the versions wry itself uses.
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
webview2-com = "0.39"
|
||||
windows-core = "0.62"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@ fn graphics_environment() -> Vec<String> {
|
||||
const NAMES: &[&str] = &[
|
||||
"XDG_SESSION_TYPE", "XDG_CURRENT_DESKTOP", "DESKTOP_SESSION", "DISPLAY", "WAYLAND_DISPLAY", "GDK_BACKEND", "GTK_THEME",
|
||||
"WEBKIT_DISABLE_DMABUF_RENDERER", "WEBKIT_DISABLE_COMPOSITING_MODE", "WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS", "LIBGL_ALWAYS_SOFTWARE",
|
||||
"GALLIUM_DRIVER", "__GLX_VENDOR_LIBRARY_NAME", "__NV_PRIME_RENDER_OFFLOAD", "MESA_LOADER_DRIVER_OVERRIDE", "APPIMAGE", "APPDIR",
|
||||
"FONTCONFIG_FILE", "FONTCONFIG_PATH", "STRATA_FONT_CONFIG", "GALLIUM_DRIVER", "__GLX_VENDOR_LIBRARY_NAME", "__NV_PRIME_RENDER_OFFLOAD", "MESA_LOADER_DRIVER_OVERRIDE", "APPIMAGE", "APPDIR",
|
||||
];
|
||||
NAMES.iter().filter_map(|n| std::env::var(n).ok().map(|v| format!(" {n}={v}"))).collect()
|
||||
}
|
||||
@@ -584,7 +584,7 @@ mod tests {
|
||||
let path = write_startup_failure(&dir, "the app stopped unexpectedly: boom (at main.rs:1, thread main)", Some(Path::new("/x/desktop-1.log"))).unwrap();
|
||||
assert!(path.file_name().unwrap().to_string_lossy().starts_with("desktop-crash-"));
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
for needle in ["boom", "main.rs:1", "/x/desktop-1.log", "0.2.0", "System:", "Meaning:"] {
|
||||
for needle in ["boom", "main.rs:1", "/x/desktop-1.log", env!("CARGO_PKG_VERSION"), "System:", "Meaning:"] {
|
||||
assert!(text.contains(needle), "{needle}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
//! Linux: keep the colour emoji font that the AppImage's WebKitGTK cannot draw away from the app window (#4533).
|
||||
//!
|
||||
//! The WebKitGTK inside the AppImage paints a COLRv1 colour font through Skia, and its `colrv1_configure_skpaint` reads past the end of a
|
||||
//! gradient's colour-stop list. The library is built with libstdc++ range checks on, so the read becomes `abort()` and `WebKitWebProcess`
|
||||
//! dies: the window goes grey the first time a chat title or a message draws an emoji (Fedora 44 and newer Ubuntu ship
|
||||
//! `Noto-COLRv1.ttf` as the default colour emoji font; the reporters on #4533 name the same assertion and the same workaround).
|
||||
//!
|
||||
//! fontconfig reads one file when `FONTCONFIG_FILE` is set, and that file replaces the normal configuration, so the file written here
|
||||
//! includes the system configuration first and only then rejects the COLRv1 faces. Emoji are then drawn from whatever else the system
|
||||
//! has (the outline "Noto Emoji" is preferred); nothing else about the fonts changes.
|
||||
//!
|
||||
//! Only the window process is told (main.rs run_window_process, before WebKit starts its own processes). The shell owns the host, the
|
||||
//! engine and the agent, and they keep the fonts the person chose. A machine with no COLRv1 font is not touched at all.
|
||||
//!
|
||||
//! This file is plain std so its decisions are unit-tested on every platform; only the window process on Linux uses it.
|
||||
//! The idea is that of community PR #4485; the code is written for this tree.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// File name patterns of the COLRv1 faces (fontconfig globs, case sensitive, so both spellings). Bitmap (`CBDT`) and outline faces are not
|
||||
/// listed: they draw fine, and hiding them would leave an empty box where an emoji should be.
|
||||
pub const COLRV1_GLOBS: &[&str] = &["*COLRv1*", "*colrv1*", "*COLRV1*"];
|
||||
|
||||
/// The outline family preferred in place of the colour one.
|
||||
const OUTLINE_EMOJI: &str = "Noto Emoji";
|
||||
|
||||
/// fontconfig's own configuration. `FONTCONFIG_FILE` replaces it, so the written file pulls it back in.
|
||||
pub const SYSTEM_CONFIG: &str = "/etc/fonts/fonts.conf";
|
||||
|
||||
/// `STRATA_FONT_CONFIG=off` turns this off; `STRATA_FONT_CONFIG=/path/to/fonts.conf` uses the person's own file.
|
||||
const OFF: &str = "off";
|
||||
|
||||
/// The name of the file written under the data folder.
|
||||
const FILE_NAME: &str = "fonts-no-colrv1.conf";
|
||||
|
||||
/// Folders fontconfig scans by default, where a colour emoji font is found: the system ones, the person's own, and the XDG data folders.
|
||||
pub fn font_dirs(env: &dyn Fn(&str) -> Option<String>) -> Vec<PathBuf> {
|
||||
let mut dirs = vec![PathBuf::from("/usr/share/fonts"), PathBuf::from("/usr/local/share/fonts")];
|
||||
let home = env("HOME").filter(|h| !h.is_empty()).map(PathBuf::from);
|
||||
match env("XDG_DATA_HOME").filter(|d| !d.is_empty()) {
|
||||
Some(data) => dirs.push(PathBuf::from(data).join("fonts")),
|
||||
None => {
|
||||
if let Some(h) = &home {
|
||||
dirs.push(h.join(".local/share/fonts"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(h) = &home {
|
||||
dirs.push(h.join(".fonts"));
|
||||
}
|
||||
if let Some(list) = env("XDG_DATA_DIRS") {
|
||||
for d in list.split(':').filter(|d| !d.is_empty()) {
|
||||
dirs.push(PathBuf::from(d).join("fonts"));
|
||||
}
|
||||
}
|
||||
dirs.dedup();
|
||||
dirs
|
||||
}
|
||||
|
||||
/// A COLRv1 font file installed under `dirs`, if any. The scan is shallow and stops at the first one found, so a window that starts on a
|
||||
/// machine without such a font costs a few directory reads and nothing else.
|
||||
pub fn colrv1_font_present(dirs: &[PathBuf]) -> Option<PathBuf> {
|
||||
fn scan(dir: &Path, depth: u8, found: &mut Option<PathBuf>) {
|
||||
if found.is_some() || depth == 0 {
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
scan(&path, depth - 1, found);
|
||||
}
|
||||
else if path.file_name().map(|n| n.to_string_lossy().to_ascii_lowercase().contains("colrv1")).unwrap_or(false) {
|
||||
*found = Some(path);
|
||||
return;
|
||||
}
|
||||
if found.is_some() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut found = None;
|
||||
for dir in dirs {
|
||||
scan(dir, 5, &mut found);
|
||||
if found.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// The configuration file's text: the system's own rules, then the rejection of the COLRv1 faces, then the outline family preferred.
|
||||
pub fn config_text(system_config: &str) -> String {
|
||||
let mut lines: Vec<String> = vec![
|
||||
"<?xml version=\"1.0\"?>".into(),
|
||||
"<!DOCTYPE fontconfig SYSTEM \"urn:fontconfig:fonts.dtd\">".into(),
|
||||
"<!-- Written by Strata for its own window only. The WebKitGTK inside the AppImage aborts while it draws a COLRv1 colour font,".into(),
|
||||
" so those faces are hidden from the window; every other font is the system's own setup, included first.".into(),
|
||||
" Off: STRATA_FONT_CONFIG=off. Your own file: set FONTCONFIG_FILE yourself. -->".into(),
|
||||
"<fontconfig>".into(),
|
||||
format!(" <include ignore_missing=\"yes\">{system_config}</include>"),
|
||||
" <selectfont>".into(),
|
||||
];
|
||||
// fontconfig rejects a font only inside <selectfont>
|
||||
for glob in COLRV1_GLOBS {
|
||||
lines.push(format!(" <rejectfont><glob>{glob}</glob></rejectfont>"));
|
||||
}
|
||||
lines.push(" </selectfont>".into());
|
||||
lines.push(" <alias binding=\"strong\">".into());
|
||||
lines.push(" <family>Noto Color Emoji</family>".into());
|
||||
lines.push(format!(" <prefer><family>{OUTLINE_EMOJI}</family></prefer>"));
|
||||
lines.push(" </alias>".into());
|
||||
lines.push("</fontconfig>".into());
|
||||
lines.join("\n") + "\n"
|
||||
}
|
||||
|
||||
/// Write the file under `dir` and return its path, or None when it cannot be written or there is no system configuration to include
|
||||
/// (then the window would be left without any font). Rewritten only when the contents differ, so a start-up touches the disk once, and a
|
||||
/// file left by an older Strata is put right. Written through a temporary file and moved, so a second instance starting at the same moment
|
||||
/// never hands fontconfig half a file.
|
||||
pub fn ensure_config(dir: &Path, system_config: &Path) -> Option<PathBuf> {
|
||||
if !system_config.exists() {
|
||||
return None;
|
||||
}
|
||||
std::fs::create_dir_all(dir).ok()?;
|
||||
let path = dir.join(FILE_NAME);
|
||||
let want = config_text(&system_config.to_string_lossy());
|
||||
if matches!(std::fs::read_to_string(&path), Ok(have) if have == want) {
|
||||
return Some(path);
|
||||
}
|
||||
let tmp = dir.join(format!("{FILE_NAME}.{}.tmp", std::process::id()));
|
||||
std::fs::write(&tmp, want.as_bytes()).ok()?;
|
||||
if std::fs::rename(&tmp, &path).is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
// another writer got there first: take its file only if it is the one wanted
|
||||
return (std::fs::read_to_string(&path).ok()? == want).then_some(path);
|
||||
}
|
||||
Some(path)
|
||||
}
|
||||
|
||||
/// The file the window process should hand to fontconfig, or None to leave the environment exactly as it is. In this order, so a person's
|
||||
/// own choice always wins and a machine without the font is untouched:
|
||||
/// 1. `FONTCONFIG_FILE` already set: theirs.
|
||||
/// 2. `STRATA_FONT_CONFIG=off`: nothing.
|
||||
/// 3. `STRATA_FONT_CONFIG=<path>`: that file, when it exists.
|
||||
/// 4. no COLRv1 font installed: nothing.
|
||||
pub fn config_for(env: &dyn Fn(&str) -> Option<String>, dir: &Path, dirs: &[PathBuf], system_config: &Path) -> Option<PathBuf> {
|
||||
if env("FONTCONFIG_FILE").is_some() || env("STRATA_FONT_CONFIG").as_deref() == Some(OFF) {
|
||||
return None;
|
||||
}
|
||||
if let Some(custom) = env("STRATA_FONT_CONFIG") {
|
||||
let p = PathBuf::from(custom);
|
||||
return p.is_file().then_some(p);
|
||||
}
|
||||
colrv1_font_present(dirs)?;
|
||||
ensure_config(dir, system_config)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn env_from(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
|
||||
let owned: Vec<(String, String)> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
|
||||
move |k: &str| owned.iter().find(|(a, _)| a == k).map(|(_, v)| v.clone())
|
||||
}
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("strata-fontguard-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_includes_the_system_config_first_and_rejects_only_colrv1_faces_inside_selectfont() {
|
||||
let text = config_text("/etc/fonts/fonts.conf");
|
||||
assert!(text.starts_with("<?xml"));
|
||||
let include = text.find("/etc/fonts/fonts.conf").expect("the system's fonts are kept");
|
||||
let select = text.find("<selectfont>").expect("a rejection needs <selectfont>");
|
||||
let select_end = text.find("</selectfont>").expect("closed");
|
||||
assert!(include < select && select < select_end);
|
||||
assert_eq!(text.matches("<rejectfont>").count(), COLRV1_GLOBS.len());
|
||||
for glob in COLRV1_GLOBS {
|
||||
let at = text.find(&format!("<glob>{glob}</glob>")).unwrap_or_else(|| panic!("{glob} is rejected"));
|
||||
assert!(at > select && at < select_end, "{glob} is inside <selectfont>");
|
||||
}
|
||||
// the bitmap and the outline faces stay
|
||||
assert!(!text.contains("NotoColorEmoji.ttf") && !text.contains("NotoEmoji-Regular.ttf"));
|
||||
assert!(text.contains("<prefer><family>Noto Emoji</family></prefer>"));
|
||||
assert!(!text.contains('{') && !text.contains('}'), "no placeholder left: {text}");
|
||||
// well formed enough: every opened tag is closed
|
||||
for tag in ["fontconfig", "selectfont", "alias"] {
|
||||
assert_eq!(text.matches(&format!("<{tag}")).count(), text.matches(&format!("</{tag}>")).count(), "{tag}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_globs_match_the_real_file_names_and_not_the_faces_that_draw_fine() {
|
||||
// fontconfig's glob is `*` anywhere; this is the same rule on the file names that matter
|
||||
let hit = |name: &str| COLRV1_GLOBS.iter().any(|g| { let core = g.trim_matches('*'); name.contains(core) });
|
||||
for bad in ["/usr/share/fonts/google-noto-color-emoji-fonts/Noto-COLRv1.ttf", "/usr/share/fonts/truetype/noto/NotoColorEmoji-COLRv1.ttf",
|
||||
"/home/me/.fonts/Twemoji-colrv1.ttf", "/usr/share/fonts/NotoSansCOLRv1-Regular.ttf"] {
|
||||
assert!(hit(bad), "{bad}");
|
||||
}
|
||||
for good in ["/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf", "/usr/share/fonts/truetype/noto/NotoEmoji-Regular.ttf",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansArabic-Regular.ttf"] {
|
||||
assert!(!hit(good), "{good}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_persons_own_fontconfig_or_an_opt_out_leaves_the_environment_alone() {
|
||||
let dir = scratch("own");
|
||||
let system = dir.join("system.conf");
|
||||
std::fs::write(&system, b"<fontconfig/>").unwrap();
|
||||
let fonts = dir.join("fonts");
|
||||
std::fs::create_dir_all(&fonts).unwrap();
|
||||
std::fs::write(fonts.join("Noto-COLRv1.ttf"), b"x").unwrap();
|
||||
let dirs = vec![fonts];
|
||||
assert_eq!(config_for(&env_from(&[("FONTCONFIG_FILE", "/home/me/fonts.conf")]), &dir, &dirs, &system), None);
|
||||
assert_eq!(config_for(&env_from(&[("STRATA_FONT_CONFIG", "off")]), &dir, &dirs, &system), None);
|
||||
let theirs = dir.join("theirs.conf");
|
||||
std::fs::write(&theirs, b"<fontconfig/>").unwrap();
|
||||
assert_eq!(config_for(&env_from(&[("STRATA_FONT_CONFIG", theirs.to_str().unwrap())]), &dir, &dirs, &system), Some(theirs));
|
||||
assert_eq!(config_for(&env_from(&[("STRATA_FONT_CONFIG", "/no/such/fonts.conf")]), &dir, &dirs, &system), None);
|
||||
// no setting at all: the font is there, so the file is written
|
||||
assert!(config_for(&env_from(&[]), &dir.join("out"), &dirs, &system).is_some());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_machine_without_a_colrv1_font_is_not_touched() {
|
||||
let dir = scratch("none");
|
||||
let system = dir.join("system.conf");
|
||||
std::fs::write(&system, b"<fontconfig/>").unwrap();
|
||||
let fonts = dir.join("fonts");
|
||||
std::fs::create_dir_all(fonts.join("noto")).unwrap();
|
||||
std::fs::write(fonts.join("noto/NotoColorEmoji.ttf"), b"bitmap").unwrap();
|
||||
std::fs::write(fonts.join("noto/NotoEmoji-Regular.ttf"), b"outline").unwrap();
|
||||
std::fs::write(fonts.join("noto/NotoSansArabic-Regular.ttf"), b"arabic").unwrap();
|
||||
assert_eq!(colrv1_font_present(&[fonts.clone()]), None);
|
||||
assert_eq!(config_for(&env_from(&[]), &dir.join("out"), &[fonts.clone()], &system), None);
|
||||
assert!(!dir.join("out").exists(), "nothing is written");
|
||||
std::fs::write(fonts.join("noto/Noto-COLRv1.ttf"), b"colour").unwrap();
|
||||
assert!(colrv1_font_present(&[fonts]).is_some());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scan_is_shallow_and_a_missing_folder_is_fine() {
|
||||
let dir = scratch("deep");
|
||||
let mut deep = dir.clone();
|
||||
for i in 0..9 { deep = deep.join(format!("d{i}")); }
|
||||
std::fs::create_dir_all(&deep).unwrap();
|
||||
std::fs::write(deep.join("Noto-COLRv1.ttf"), b"x").unwrap();
|
||||
assert_eq!(colrv1_font_present(&[dir.clone(), dir.join("missing")]), None, "nine levels down is not a font folder");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_folders_follow_the_xdg_variables() {
|
||||
let none = font_dirs(&env_from(&[("HOME", "/home/me")]));
|
||||
assert!(none.contains(&PathBuf::from("/usr/share/fonts")));
|
||||
assert!(none.contains(&PathBuf::from("/home/me/.local/share/fonts")));
|
||||
assert!(none.contains(&PathBuf::from("/home/me/.fonts")));
|
||||
let set = font_dirs(&env_from(&[("HOME", "/home/me"), ("XDG_DATA_HOME", "/data/me"), ("XDG_DATA_DIRS", "/usr/local/share:/var/lib/flatpak/exports/share")]));
|
||||
assert!(set.contains(&PathBuf::from("/data/me/fonts")));
|
||||
assert!(!set.contains(&PathBuf::from("/home/me/.local/share/fonts")));
|
||||
assert!(set.contains(&PathBuf::from("/var/lib/flatpak/exports/share/fonts")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_is_written_once_an_old_one_is_put_right_and_nothing_is_left_behind() {
|
||||
let dir = scratch("write");
|
||||
let system = dir.join("system.conf");
|
||||
std::fs::write(&system, b"<fontconfig/>").unwrap();
|
||||
let out = dir.join("out");
|
||||
let first = ensure_config(&out, &system).expect("written");
|
||||
assert_eq!(ensure_config(&out, &system).unwrap(), first, "the same path every start");
|
||||
std::fs::write(&first, "<fontconfig/>").unwrap();
|
||||
assert_eq!(ensure_config(&out, &system), Some(first.clone()));
|
||||
assert_eq!(std::fs::read_to_string(&first).unwrap(), config_text(&system.to_string_lossy()));
|
||||
assert_eq!(std::fs::read_dir(&out).unwrap().count(), 1, "one file, no temporary");
|
||||
// no system configuration to include: nothing is written (the window would have no font at all)
|
||||
assert_eq!(ensure_config(&dir.join("out2"), &dir.join("absent.conf")), None);
|
||||
assert!(!dir.join("out2").exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,11 @@ use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
|
||||
|
||||
mod updater;
|
||||
mod desktoplog;
|
||||
mod recovery;
|
||||
mod shell;
|
||||
// Plain std, built on every platform so its decisions are tested everywhere; only the Linux window process uses it.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
mod fontguard;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod supervisor;
|
||||
|
||||
@@ -879,7 +883,19 @@ fn main() {
|
||||
/// Windows and macOS: one process owns the host and the window.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn run_embedded() {
|
||||
tauri::Builder::default()
|
||||
let builder = tauri::Builder::default();
|
||||
// macOS: WKWebView ends the page's web content process under memory pressure and leaves a blank window; the page is loaded again (#5079).
|
||||
#[cfg(target_os = "macos")]
|
||||
let builder = builder.on_web_content_process_terminate(|webview| {
|
||||
let port = webview.app_handle().state::<HostPort>().0.lock().ok().and_then(|p| *p);
|
||||
let Some(port) = port else { return };
|
||||
let failed = webview.url().ok().map(|u| u.to_string());
|
||||
let target = webview.clone();
|
||||
on_renderer_failure(format!("http://127.0.0.1:{port}"), recovery::Failure::RendererExited, "web content process terminated".to_string(), failed, move |url| {
|
||||
if let Ok(url) = url.parse::<tauri::Url>() { let _ = target.navigate(url); }
|
||||
});
|
||||
});
|
||||
builder
|
||||
// First, as the plugin requires: a second launch ends here after telling the running app to show itself.
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
@@ -987,9 +1003,80 @@ fn open_window(app: &tauri::App, port: u16, shell_token: &str) -> Result<(), Box
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
hook_linux_drops(&window, port, shell_token);
|
||||
#[cfg(windows)]
|
||||
hook_renderer_failures(&window, port);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Milliseconds since the first failure report or window (a clock that only goes forward, for the recovery limit).
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn uptime_ms() -> u64 {
|
||||
static START: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
|
||||
START.get_or_init(Instant::now).elapsed().as_millis() as u64
|
||||
}
|
||||
|
||||
/// The page's renderer is gone (#5079). Writes it to the run log, and, when the window needs the page again, loads it again through
|
||||
/// `navigate` after a short wait: the same screen with `?recovery=1` the first time (the page then starts with the sidebar previews
|
||||
/// closed once), the home screen the second time, and nothing after two in two minutes. The chats are the host's and are not touched.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn on_renderer_failure(origin: String, failure: recovery::Failure, detail: String, failed: Option<String>, navigate: impl FnOnce(String) + Send + 'static) {
|
||||
static GUARD: Mutex<recovery::Guard> = Mutex::new(recovery::Guard::new());
|
||||
let say = |text: &str| { if let Some(log) = desktoplog::run_log() { log.line("shell", text); } };
|
||||
say(&format!("the page's {} ({detail}); it was showing {}", failure.name(), failed.as_deref().unwrap_or("an unknown address")));
|
||||
let plan = match GUARD.lock() {
|
||||
Ok(mut guard) => recovery::plan(&mut guard, uptime_ms(), &origin, failure, failed.as_deref()),
|
||||
Err(_) => recovery::Plan::Stop,
|
||||
};
|
||||
match plan {
|
||||
recovery::Plan::Ignore => {}
|
||||
recovery::Plan::Stop => say("the page's renderer failed again within two minutes of two recoveries; the window is left as it is (Reload in the page loads it again)"),
|
||||
recovery::Plan::Reload { attempt, url } => {
|
||||
say(&format!("loading the page again (recovery {attempt}): {url}"));
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(recovery::RELOAD_DELAY_MS));
|
||||
navigate(url);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows: WebView2 tells the shell when a process of the page fails; the page's renderer dying is recovered by on_renderer_failure.
|
||||
#[cfg(windows)]
|
||||
fn hook_renderer_failures(window: &tauri::WebviewWindow, port: u16) {
|
||||
use webview2_com::Microsoft::Web::WebView2::Win32::{ICoreWebView2ProcessFailedEventArgs2, COREWEBVIEW2_PROCESS_FAILED_KIND};
|
||||
use webview2_com::{take_pwstr, ProcessFailedEventHandler};
|
||||
use windows_core::{Interface, PWSTR};
|
||||
let target = window.clone();
|
||||
let origin = format!("http://127.0.0.1:{port}");
|
||||
let _ = window.with_webview(move |platform| unsafe {
|
||||
let Ok(core) = platform.controller().CoreWebView2() else { return };
|
||||
let handler = ProcessFailedEventHandler::create(Box::new(move |sender, args| {
|
||||
let Some(args) = args else { return Ok(()) };
|
||||
let mut kind = COREWEBVIEW2_PROCESS_FAILED_KIND::default();
|
||||
args.ProcessFailedKind(&mut kind)?;
|
||||
let mut detail = format!("kind {}", kind.0);
|
||||
if let Ok(more) = args.cast::<ICoreWebView2ProcessFailedEventArgs2>() {
|
||||
let mut reason = Default::default();
|
||||
let mut exit_code = 0i32;
|
||||
if more.Reason(&mut reason).is_ok() { detail = format!("{detail}, reason {}", recovery::reason_name(reason.0)); }
|
||||
if more.ExitCode(&mut exit_code).is_ok() { detail = format!("{detail}, exit code {exit_code:#x}"); }
|
||||
}
|
||||
let failed = sender.and_then(|view| {
|
||||
let mut source = PWSTR::null();
|
||||
view.Source(&mut source).ok()?;
|
||||
Some(take_pwstr(source))
|
||||
});
|
||||
let window = target.clone();
|
||||
on_renderer_failure(origin.clone(), recovery::Failure::from_webview2(kind.0), detail, failed, move |url| {
|
||||
if let Ok(url) = url.parse::<tauri::Url>() { let _ = window.navigate(url); }
|
||||
});
|
||||
Ok(())
|
||||
}));
|
||||
let mut token = 0i64;
|
||||
let _ = core.add_ProcessFailed(&handler, &mut token);
|
||||
});
|
||||
}
|
||||
|
||||
/// A file:// address from a drag, as a path (percent-escapes decoded); anything else (http, mailto, text) is not a dropped file.
|
||||
#[cfg_attr(windows, allow(dead_code))]
|
||||
fn file_uri_to_path(uri: &str) -> Option<String> {
|
||||
@@ -1143,6 +1230,15 @@ fn run_window_process() -> ! {
|
||||
eprintln!("[strata] STRATA_WINDOW_TEST_CRASH is set: the window process aborts on purpose.");
|
||||
std::process::abort();
|
||||
}
|
||||
// WebKitGTK aborts while it draws a COLRv1 colour emoji font (fontguard.rs, #4533): this process is told to leave that font out. Only this
|
||||
// process, and before WebKit starts its own: the host, the engine and the agent keep the fonts the person chose.
|
||||
{
|
||||
let env = |k: &str| std::env::var(k).ok();
|
||||
let dirs = fontguard::font_dirs(&env);
|
||||
if let Some(conf) = fontguard::config_for(&env, &desktoplog::data_dir().join("fonts"), &dirs, Path::new(fontguard::SYSTEM_CONFIG)) {
|
||||
std::env::set_var("FONTCONFIG_FILE", conf);
|
||||
}
|
||||
}
|
||||
// The shell is this process's parent: when it is gone (killed, crashed, logged off) the window goes too.
|
||||
let parent = std::os::unix::process::parent_id();
|
||||
std::thread::spawn(move || loop {
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
//! A page whose renderer dies is brought back, not left on the browser's error page (#5079).
|
||||
//!
|
||||
//! WebView2 (Windows) reports a dead renderer through its ProcessFailed event, and WKWebView (macOS) through
|
||||
//! `webViewWebContentProcessDidTerminate`; until now the shell listened to neither, so the window kept the browser's own "This page has a
|
||||
//! problem" page, nothing reached the log, and the person's Reload brought back the same chat that had just crashed. Linux does not need it:
|
||||
//! there the window is its own process and the shell moves to browser mode when it dies (supervisor.rs).
|
||||
//!
|
||||
//! This file is only decisions, so it builds and tests everywhere; the platform hooks are in main.rs.
|
||||
//! - which failures are worth a reload (the page's own renderer; not a GPU process that restarts by itself, not a frame of the page);
|
||||
//! - how many automatic recoveries are allowed in a window of time (a renderer that dies again at once is not reloaded for ever);
|
||||
//! - where the reload goes: the same screen first, with the sidebar previews closed once; the home screen the second time.
|
||||
//! The page reads `?recovery=<n>` in apps/host/src/lib/recovery.ts. The chats are the host's: nothing here touches them.
|
||||
|
||||
/// Automatic recoveries allowed inside `WINDOW_MS`; the next failure leaves the window as it is for the person to reload.
|
||||
pub const MAX_AUTOMATIC: usize = 2;
|
||||
/// The window of time the limit counts over.
|
||||
pub const WINDOW_MS: u64 = 120_000;
|
||||
/// Let the browser start a new renderer before the page is asked for again.
|
||||
pub const RELOAD_DELAY_MS: u64 = 400;
|
||||
|
||||
/// WebView2's COREWEBVIEW2_PROCESS_FAILED_KIND values, by number.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Failure {
|
||||
BrowserExited,
|
||||
/// The renderer of the page itself.
|
||||
RendererExited,
|
||||
Unresponsive,
|
||||
/// The renderer of a frame inside the page (a website preview); the page itself is alive.
|
||||
FrameRendererExited,
|
||||
Gpu,
|
||||
Other(i32),
|
||||
}
|
||||
|
||||
impl Failure {
|
||||
pub fn from_webview2(kind: i32) -> Failure {
|
||||
match kind {
|
||||
0 => Failure::BrowserExited,
|
||||
1 => Failure::RendererExited,
|
||||
2 => Failure::Unresponsive,
|
||||
3 => Failure::FrameRendererExited,
|
||||
6 => Failure::Gpu,
|
||||
other => Failure::Other(other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Only the page's own renderer dying leaves a window that has to be loaded again. A GPU process is restarted by the browser, a frame
|
||||
/// shows its own sad face, an unresponsive page may only be busy, and a dead browser process cannot navigate at all.
|
||||
pub fn needs_reload(self) -> bool {
|
||||
self == Failure::RendererExited
|
||||
}
|
||||
|
||||
pub fn name(self) -> String {
|
||||
match self {
|
||||
Failure::BrowserExited => "browser process exited".into(),
|
||||
Failure::RendererExited => "renderer exited".into(),
|
||||
Failure::Unresponsive => "renderer unresponsive".into(),
|
||||
Failure::FrameRendererExited => "frame renderer exited".into(),
|
||||
Failure::Gpu => "gpu process exited".into(),
|
||||
Failure::Other(n) => format!("process failure kind {n}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// WebView2's COREWEBVIEW2_PROCESS_FAILED_REASON, for the log.
|
||||
pub fn reason_name(reason: i32) -> &'static str {
|
||||
match reason {
|
||||
0 => "unexpected",
|
||||
1 => "unresponsive",
|
||||
2 => "terminated",
|
||||
3 => "crashed",
|
||||
4 => "launch failed",
|
||||
5 => "out of memory",
|
||||
6 => "profile deleted",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// Load the page again; `attempt` is 1 for the first recovery of the window, 2 for the second.
|
||||
Reload { attempt: usize },
|
||||
/// Too many in a short time: leave the window alone.
|
||||
Stop,
|
||||
}
|
||||
|
||||
/// Counts recoveries over a sliding window of time. `now_ms` is any clock that only goes forward (the caller's uptime).
|
||||
#[derive(Default, Debug)]
|
||||
pub struct Guard {
|
||||
times: Vec<u64>,
|
||||
}
|
||||
|
||||
impl Guard {
|
||||
pub const fn new() -> Guard {
|
||||
Guard { times: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn on_failure(&mut self, now_ms: u64) -> Action {
|
||||
self.times.retain(|t| now_ms.saturating_sub(*t) < WINDOW_MS);
|
||||
if self.times.len() >= MAX_AUTOMATIC {
|
||||
return Action::Stop;
|
||||
}
|
||||
self.times.push(now_ms);
|
||||
Action::Reload { attempt: self.times.len() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The address a recovery loads. The first goes back to the screen that died (its `#/...` part) with `?recovery=1`, which makes the page
|
||||
/// start with the sidebar previews closed once; the second goes to the home screen, in case the screen itself is what crashes.
|
||||
/// `failed` is the address the window was showing; it is used only when it is on `origin`.
|
||||
pub fn recovery_url(origin: &str, attempt: usize, failed: Option<&str>) -> String {
|
||||
let origin = origin.trim_end_matches('/');
|
||||
let base = format!("{origin}/?recovery={}", attempt.clamp(1, 9));
|
||||
if attempt > 1 {
|
||||
return base;
|
||||
}
|
||||
let Some(failed) = failed else { return base };
|
||||
// on this origin: after it comes the end, a path, a query or a fragment (not another digit of a longer port)
|
||||
let rest = match failed.strip_prefix(origin) {
|
||||
Some(rest) if rest.is_empty() || rest.starts_with(['/', '?', '#']) => rest,
|
||||
_ => return base,
|
||||
};
|
||||
match rest.find('#') {
|
||||
Some(at) => {
|
||||
let hash = &rest[at..];
|
||||
// only what a page address holds: no spaces, quotes, angle brackets or control characters
|
||||
let safe = hash.len() < 2048 && hash.starts_with("#/") && hash.chars().all(|c| c.is_ascii_graphic() && !matches!(c, '"' | '<' | '>' | '\\' | '`' | '{' | '}' | '|' | '^'));
|
||||
if safe { format!("{base}{hash}") } else { base }
|
||||
}
|
||||
None => base,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the shell does about one failure report.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Plan {
|
||||
/// Not a failure that leaves the window needing a reload: only logged.
|
||||
Ignore,
|
||||
/// A reload is due, to `url`.
|
||||
Reload { attempt: usize, url: String },
|
||||
/// Too many recent recoveries: the window is left as it is.
|
||||
Stop,
|
||||
}
|
||||
|
||||
/// One failure report in, the decision out. The platform hooks (main.rs) only collect the facts and carry the decision out.
|
||||
pub fn plan(guard: &mut Guard, now_ms: u64, origin: &str, failure: Failure, failed: Option<&str>) -> Plan {
|
||||
if !failure.needs_reload() {
|
||||
return Plan::Ignore;
|
||||
}
|
||||
match guard.on_failure(now_ms) {
|
||||
Action::Stop => Plan::Stop,
|
||||
Action::Reload { attempt } => Plan::Reload { attempt, url: recovery_url(origin, attempt, failed) },
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_the_pages_own_renderer_is_reloaded() {
|
||||
assert!(Failure::from_webview2(1).needs_reload());
|
||||
for kind in [0, 2, 3, 4, 5, 6, 7, 8, 9, 42] {
|
||||
assert!(!Failure::from_webview2(kind).needs_reload(), "kind {kind}");
|
||||
}
|
||||
assert_eq!(Failure::from_webview2(6), Failure::Gpu);
|
||||
assert_eq!(Failure::from_webview2(1).name(), "renderer exited");
|
||||
assert_eq!(Failure::from_webview2(42).name(), "process failure kind 42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasons_are_named_for_the_log() {
|
||||
assert_eq!(reason_name(5), "out of memory");
|
||||
assert_eq!(reason_name(3), "crashed");
|
||||
assert_eq!(reason_name(99), "unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_recoveries_in_two_minutes_then_the_window_is_left_alone() {
|
||||
let mut g = Guard::new();
|
||||
assert_eq!(g.on_failure(1_000), Action::Reload { attempt: 1 });
|
||||
assert_eq!(g.on_failure(5_000), Action::Reload { attempt: 2 });
|
||||
assert_eq!(g.on_failure(6_000), Action::Stop);
|
||||
assert_eq!(g.on_failure(100_000), Action::Stop);
|
||||
// the first has left the window: one more is allowed, and it is the second of the window
|
||||
assert_eq!(g.on_failure(1_000 + WINDOW_MS), Action::Reload { attempt: 2 });
|
||||
assert_eq!(g.on_failure(1_000 + WINDOW_MS + 1), Action::Stop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_renderer_that_dies_later_starts_over_at_the_first_recovery() {
|
||||
let mut g = Guard::new();
|
||||
assert_eq!(g.on_failure(0), Action::Reload { attempt: 1 });
|
||||
assert_eq!(g.on_failure(10), Action::Reload { attempt: 2 });
|
||||
assert_eq!(g.on_failure(WINDOW_MS + 100), Action::Reload { attempt: 1 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stopped_loop_stays_stopped_until_the_window_has_passed() {
|
||||
let mut g = Guard::new();
|
||||
let mut reloads = 0;
|
||||
// a renderer that dies every second for ten minutes: never more than two reloads in any two-minute window
|
||||
let mut loaded = Vec::new();
|
||||
for second in 0..600u64 {
|
||||
if let Action::Reload { .. } = g.on_failure(second * 1_000) {
|
||||
reloads += 1;
|
||||
loaded.push(second);
|
||||
}
|
||||
}
|
||||
assert!(reloads <= 2 * (600 / (WINDOW_MS / 1_000) as usize + 1));
|
||||
for w in loaded.windows(3) {
|
||||
assert!(w[2] - w[0] >= WINDOW_MS / 1_000, "three reloads inside one window: {w:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_recovery_keeps_the_screen_and_the_second_goes_home() {
|
||||
let o = "http://127.0.0.1:47831";
|
||||
assert_eq!(recovery_url(o, 1, Some("http://127.0.0.1:47831/#/chat/s_abc")), "http://127.0.0.1:47831/?recovery=1#/chat/s_abc");
|
||||
assert_eq!(recovery_url(o, 1, Some("http://127.0.0.1:47831/?recovery=1#/chat/s_abc")), "http://127.0.0.1:47831/?recovery=1#/chat/s_abc");
|
||||
assert_eq!(recovery_url(o, 2, Some("http://127.0.0.1:47831/#/chat/s_abc")), "http://127.0.0.1:47831/?recovery=2");
|
||||
assert_eq!(recovery_url(o, 1, None), "http://127.0.0.1:47831/?recovery=1");
|
||||
assert_eq!(recovery_url("http://127.0.0.1:47831/", 1, None), "http://127.0.0.1:47831/?recovery=1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_address_that_is_not_ours_or_not_plain_is_not_carried_over() {
|
||||
let o = "http://127.0.0.1:47831";
|
||||
for failed in [
|
||||
"https://example.com/#/chat/x",
|
||||
"http://127.0.0.1:47832/#/chat/x",
|
||||
"http://127.0.0.1:47831/#/chat/x y",
|
||||
"http://127.0.0.1:47831/#/chat/\"onload=",
|
||||
"http://127.0.0.1:47831/#/chat/<script>",
|
||||
"http://127.0.0.1:47831/#nothing",
|
||||
"http://127.0.0.1:47831/#/chat/\u{e9}",
|
||||
"chrome-error://chromewebdata/",
|
||||
] {
|
||||
assert_eq!(recovery_url(o, 1, Some(failed)), "http://127.0.0.1:47831/?recovery=1", "{failed}");
|
||||
}
|
||||
let long = format!("http://127.0.0.1:47831/#/{}", "a".repeat(3000));
|
||||
assert_eq!(recovery_url(o, 1, Some(&long)), "http://127.0.0.1:47831/?recovery=1");
|
||||
// a look-alike host is not the origin
|
||||
assert_eq!(recovery_url(o, 1, Some("http://127.0.0.1:478310/#/chat/x")), "http://127.0.0.1:47831/?recovery=1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failure_report_becomes_one_decision() {
|
||||
let o = "http://127.0.0.1:47831";
|
||||
let mut g = Guard::new();
|
||||
let chat = Some("http://127.0.0.1:47831/#/chat/s_1");
|
||||
// a GPU process or a frame going down never costs the person their page, and does not use up the allowance
|
||||
assert_eq!(plan(&mut g, 0, o, Failure::Gpu, chat), Plan::Ignore);
|
||||
assert_eq!(plan(&mut g, 1, o, Failure::FrameRendererExited, chat), Plan::Ignore);
|
||||
assert_eq!(plan(&mut g, 2, o, Failure::Unresponsive, chat), Plan::Ignore);
|
||||
assert_eq!(plan(&mut g, 3, o, Failure::BrowserExited, chat), Plan::Ignore);
|
||||
assert_eq!(plan(&mut g, 1_000, o, Failure::RendererExited, chat), Plan::Reload { attempt: 1, url: "http://127.0.0.1:47831/?recovery=1#/chat/s_1".into() });
|
||||
assert_eq!(plan(&mut g, 4_000, o, Failure::RendererExited, chat), Plan::Reload { attempt: 2, url: "http://127.0.0.1:47831/?recovery=2".into() });
|
||||
assert_eq!(plan(&mut g, 7_000, o, Failure::RendererExited, chat), Plan::Stop);
|
||||
}
|
||||
}
|
||||
+44
-5
@@ -1,15 +1,54 @@
|
||||
# Strata app source
|
||||
# Strata app: developer guide
|
||||
|
||||
Start with [the complete developer handoff](../handoff.md) and [the runbook](../docs/RUNBOOK.md).
|
||||
This folder is the app's Node host (`server/`) and its web interface (`src/`, React and Vite). The desktop shell that wraps
|
||||
them is [`../desktop`](../desktop) (Tauri). The engine and Strata Control are Python and C++ and live elsewhere in the
|
||||
repository; see [docs/SOURCE_INSTALL.md](../../docs/SOURCE_INSTALL.md) and [packaging/README.md](../../packaging/README.md).
|
||||
|
||||
## Start from a fresh clone
|
||||
|
||||
You need **Node 24.12 or newer** (`engines` in `package.json`) and npm. Nothing else: no model, no engine, no GPU, no
|
||||
sibling design folder.
|
||||
|
||||
```sh
|
||||
cd apps/host
|
||||
npm ci
|
||||
npm run preview:ui
|
||||
npm run build # makes dist/, which the preview and `npm run serve` need (dist/ is not checked in)
|
||||
npm run preview:ui # simulated hardware and models; open http://localhost:5272/#/models
|
||||
```
|
||||
|
||||
The built UI is included. To rebuild after source edits, run `npm run build`. `preview:ui` uses isolated simulated data; `npm run dev` connects the normal host.
|
||||
`preview:ui` starts an isolated host and a fixture server on temporary data and prints "UI PREVIEW ONLY". Nothing it shows
|
||||
is real: no engine is started, no model is loaded, no files outside its temporary folder are touched. Ctrl+C stops it.
|
||||
|
||||
The previous project README is retained in [docs/README-historical.md](docs/README-historical.md). It contains older implementation status and is historical reference.
|
||||
| Command | What it does |
|
||||
| --- | --- |
|
||||
| `npm run build` | Builds the interface into `dist/`. |
|
||||
| `npm run typecheck` | `tsc --noEmit`. |
|
||||
| `npm test` | The host and interface tests (`tests/*.test.mjs`, `node --test`). They write to scratch copies of the per-user folders, never to your own profile. A test that hangs fails after 10 minutes (`STRATA_TEST_TIMEOUT_MS`). |
|
||||
| `npm run dev` | The source host (port 8791) and the Vite dev server (port 5199) together; Vite proxies `/api` to the host. |
|
||||
| `npm run serve` | The host with the built `dist/` (run `npm run build` first). |
|
||||
| `npm run check` | Typecheck plus the interface audits (`audit`, `contrast`, `measure`, `luminance`). |
|
||||
|
||||
## Preview, source host and packaged Control
|
||||
|
||||
- **Simulated preview** (`npm run preview:ui`): fixtures only. Use it to look at screens.
|
||||
- **Source host** (`npm run dev`, `npm run serve`): the real host code from this checkout. Without `STRATA_REAL_ENGINE=1` it
|
||||
runs a clearly labelled example engine; with it, the host looks for a Strata installation as a source checkout does.
|
||||
Loading a real model needs an engine and model files; none of the commands above do that.
|
||||
- **Packaged app**: the installer ships the host next to Strata Control (Python plus `strata_control`), which does installs,
|
||||
updates and engine starts. A source checkout has no Control, so those paths keep their older behaviour; how Control is
|
||||
packaged is in [packaging/README.md](../../packaging/README.md).
|
||||
|
||||
The version numbers in `package.json` are the app's. They are checked against `docs/data/release.json` by `python tools/release_manifest.py --check`.
|
||||
|
||||
## Styles, icons and fonts
|
||||
|
||||
The checked-in files are the source. There is no generator and no sibling design folder.
|
||||
|
||||
- Kit styles: `src/styles/kit/tokens.css` and `components.css`; app styles sit on top in `src/styles/*.css`.
|
||||
- Icons: `public/icons/sprite.svg`. Add an icon as a `<symbol id="i-NAME">`; `npm run audit` fails on an icon that is used but missing.
|
||||
- Fonts: `public/fonts`.
|
||||
- The standalone server page (`serve/web/`) serves its own copy of `components.css`. The two copies are byte-identical and
|
||||
`tests/kit-copy.test.mjs` fails when they differ: change both together.
|
||||
|
||||
## Languages
|
||||
|
||||
|
||||
@@ -1,71 +1,5 @@
|
||||
# Strata UI fixes — handoff for Claude Code
|
||||
# Moved
|
||||
|
||||
This package comes from the Strata UI audit of 7 October 2026: 177 findings across 210 screen states. Here they are split into 19 tasks. Each task file lists the findings it covers, the files to change, and how to check the result.
|
||||
|
||||
## What's inside
|
||||
- `tasks/` has one file per task, ready to give to a subagent.
|
||||
- `findings.json` has all 177 findings with severity, state, issue, suggested fix and assigned task.
|
||||
- `designs/` holds the reference designs for T09 (Model) and T10 (Engine v3). They are design-component source files: read them for structure, copy, colours and behaviour. They won't run on their own.
|
||||
- `.claude/agents/strata-fixer.md` is a subagent definition (Sonnet, focused on one task).
|
||||
- `PROMPTS.md` has the prompts to paste into Claude Code.
|
||||
- `REVIEW.md` is the checklist for the review after each phase.
|
||||
- `PROGRESS.md` is the log that each task appends to.
|
||||
|
||||
## Setup
|
||||
1. Copy this folder into the repo root as `audit-fixes/` (so paths read `audit-fixes/tasks/T01-…`).
|
||||
2. Copy `audit-fixes/.claude/agents/strata-fixer.md` to `.claude/agents/strata-fixer.md` in the repo.
|
||||
3. Make sure `docs/evidence/audit-2026-10-07/` (the screenshots) is in the repo, because the task files point to it.
|
||||
4. Run `npm install`, then `npm run check` and `node --test tests/` once. Note anything that already fails, so it isn't blamed on a task.
|
||||
|
||||
## Order
|
||||
Phase 1 fixes shared components, so later tasks get smaller. Tasks in the same phase with no dependency between them can run in parallel on separate branches.
|
||||
|
||||
### Phase 1 — root causes (shared components)
|
||||
| Task | Title | Findings | High / Med / Low | Depends on |
|
||||
|---|---|---|---|---|
|
||||
| T01 | One model, one name | 11 | 6 / 2 / 3 | — |
|
||||
| T02 | Overlay layering and dialogs | 5 | 2 / 3 / 0 | — |
|
||||
| T03 | Primary, disabled and on states | 11 | 1 / 7 / 3 | — |
|
||||
| T04 | One error component, no fixture text | 10 | 0 / 9 / 1 | — |
|
||||
| T05 | Show numbers when they matter | 6 | 2 / 2 / 2 | T01 |
|
||||
| T06 | Fix the response hover layout | 1 | 1 / 0 / 0 | — |
|
||||
| T07 | Hide what is not built | 2 | 0 / 1 / 1 | — |
|
||||
| T08 | Engine and Model in the main nav | 1 | 1 / 0 / 0 | T01 |
|
||||
|
||||
### Phase 2 — rebuild Model and Engine
|
||||
| Task | Title | Findings | High / Med / Low | Depends on |
|
||||
|---|---|---|---|---|
|
||||
| T09 | Build the single-model Model page | 17 | 0 / 9 / 8 | T01, T03, T04 |
|
||||
| T10 | Build Engine v3 | 17 | 2 / 9 / 6 | T01, T02, T03 |
|
||||
|
||||
### Phase 3 — remaining findings per screen
|
||||
| Task | Title | Findings | High / Med / Low | Depends on |
|
||||
|---|---|---|---|---|
|
||||
| T11 | Chat: remaining findings | 38 | 0 / 20 / 18 | T01, T05, T06, T07 |
|
||||
| T12 | Time-skip: remaining findings | 6 | 0 / 3 / 3 | — |
|
||||
| T13 | Serving: remaining findings | 19 | 0 / 6 / 13 | T04 |
|
||||
| T14 | Monitor: remaining findings | 2 | 0 / 1 / 1 | T05 |
|
||||
| T15 | Skills and Tasks: remaining findings | 9 | 0 / 2 / 7 | T02, T03, T04 |
|
||||
| T16 | Settings and Setup: remaining findings | 7 | 0 / 2 / 5 | T02, T09 |
|
||||
| T17 | Navigation and overlays: remaining findings | 4 | 0 / 2 / 2 | T02, T08 |
|
||||
| T18 | Preview panel: remaining findings | 7 | 0 / 3 / 4 | — |
|
||||
| T19 | Dark mode: remaining findings | 4 | 0 / 1 / 3 | T02, T03 |
|
||||
|
||||
## Running it cheaply
|
||||
- Do high and medium first. Every task file puts low findings under "Optional polish". Skip them in the first pass. That cuts about a third of the work.
|
||||
- Use a fresh session per task. Each subagent starts clean with only its task file, so context stays small.
|
||||
- Check with tests first, and use screenshots only for the states listed in the task.
|
||||
- After each phase, run one review (see REVIEW.md) instead of reviewing after every task.
|
||||
|
||||
Rough cost at API list prices (Sonnet 5.5, medium effort, for the subagents; one review per phase):
|
||||
- Phase 1 (8 tasks): $10–20
|
||||
- Phase 2 (2 large tasks): $8–16
|
||||
- Phase 3 (9 tasks): $10–20, or about half that if you skip the low findings
|
||||
- Reviews: $8–15
|
||||
|
||||
## Ground rules for every task
|
||||
- Stay within the task's scope. If a fix needs a file that another task owns, make the smallest change and note it in PROGRESS.md.
|
||||
- Don't change the design kit tokens in `src/styles/kit/` unless the task says so. Run `npm run sync:kit` if you do.
|
||||
- Keep both themes working. Check light and dark.
|
||||
- Existing tests must stay green. Add tests for new logic (formatters, clamping, fit rules).
|
||||
- A finding can be closed as "already fixed by Txx" or "not reproducible", with a one-line reason.
|
||||
The UI audit-fix campaign (7 October 2026: 19 tasks, the finding register, the review and the scripts that applied them) is finished.
|
||||
It is kept whole, unchanged, in [history/apps/host/audit-fixes](../../../history/apps/host/audit-fixes/README.md).
|
||||
Open items from it are in [../docs/BACKLOG.md](../docs/BACKLOG.md).
|
||||
@@ -0,0 +1,54 @@
|
||||
# UI backlog from the October 2026 campaign
|
||||
|
||||
When the dated UI documents moved to [history/apps/host](../../../history/apps/host/docs), the requirements in them that were not done, or not shown to be done, were copied here.
|
||||
Each row names its source in `history/`. "Open" means a search of `src/` and `server/` on 7 November 2026 found nothing for it. "Check" means related
|
||||
code exists but nothing shows the requirement was met or tested. This is a list to triage, not a promise.
|
||||
|
||||
## Open
|
||||
|
||||
| Item | Source |
|
||||
| --- | --- |
|
||||
| Measure dragging, scrolling and streaming together on representative hardware (`scripts/motionbudget.mjs` reports NOT MEASURED). | docs/engine-models-onboarding-2026-10-05.md, Remaining 6 |
|
||||
| A browser performance benchmark with a 3,000-row model catalog. | docs/engine-models-onboarding-2026-10-05.md, Verification |
|
||||
| Decide the model card metrics (B08: reading speed, answer speed and task quality) and the profile names (B13: keep Agentic / Balanced / Performance, or rename) with real participants. The shipped choices were provisional. | docs/persona-review-2026-10-05/decision-report.md, Unresolved choices |
|
||||
| Platform checks never run: a normal-browser export download, launch from the installed manifest, favicon fallbacks, OS appearance changes, forced-colors mode, a deliberate image failure, native icon packaging. | docs/design-improvements-2026-10-05.md, Remaining boundaries 2 |
|
||||
| A safety cue when the engine settings leave their defaults. | docs/plans/03-engine-tuning.md |
|
||||
| "Copy as cURL" in the Serving request drawer. | docs/plans/05-serving.md |
|
||||
| Settings: grouping and per-section resets. | docs/plans/08-settings.md |
|
||||
| First run: a confirmation about local data. | docs/plans/09-first-run.md |
|
||||
| Command palette: a Recents group. | docs/plans/11-command-palette.md |
|
||||
| Time expectations on long operations. | docs/plans/14-states.md |
|
||||
| An accessibility "fatigue budget". | docs/plans/16-accessibility.md |
|
||||
|
||||
## Check
|
||||
|
||||
| Item | Source |
|
||||
| --- | --- |
|
||||
| A screen-reader pass, forced colors, contrast in every interactive state, the reading-size and focus matrix. 200% browser zoom was checked for Engine, Models and setup only. | engine-models-onboarding (Remaining 5); design-improvements (UI-18); persona implementation-verification |
|
||||
| Model fit from measured free headroom and the native engine's real footprint (the recommendation uses memory class). | engine-models-onboarding (Remaining 4); design-improvements (MODEL-03) |
|
||||
| Benchmark evidence: engine build and driver identity, and rules for when a measurement stops being valid. | engine-models-onboarding, Remaining 3 |
|
||||
| Curated agent and tool candidates, the Balanced ordering and comparable throughput data. | engine-models-onboarding, Remaining 2 |
|
||||
| Download resume and browser fault injection on the native path. | engine-models-onboarding, Verification and Recovery |
|
||||
| Real subagent orchestration (the demo engine had none). | annotations-followup-2026-10-09.md |
|
||||
| PDF input to the model (pictures work). | annotations-followup-2026-10-09.md |
|
||||
| Scheduled tasks that use file, shell, web, email and calendar tools. | user-feedback-2026-10-06.md |
|
||||
| LAN sharing as an explicit opt-in, with a test. | user-feedback-2026-10-06.md; dsh-recon.md |
|
||||
| A Windows and Linux save picker for the Monitor image export. | ui-followup-2026-10-06.md |
|
||||
| A live Cloudflare tunnel run (only a connector fixture was used). | ui-followup-2026-10-06.md |
|
||||
| Live microphone dictation (tested with simulated events only). | annotations-19-2026-10-09.md |
|
||||
| One coordinated arrow-key navigation system for menus (optional). | design-improvements, UI-11-12 |
|
||||
| Session-log export delivery in a normal browser. | design-improvements, CHAT-08 |
|
||||
| Windows and Linux runtime lifecycle, native tool and image grants, privacy and logging, native sensors. | design-improvements, Remaining boundaries 1 |
|
||||
| A human credential workflow test for Serving; native cancellation; assistive-technology announcements for invalid fields. | design-improvements, SERVE-05 to 07, UI-01-04 |
|
||||
| Plans: first-run scan substance, a labelled theme toggle, gauge animation, skeletons, operation-failed empties, a motion duration budget. | docs/plans/09, 10, 12, 14, 15 |
|
||||
| Shared items with no recorded closure: the loaded model shown in four places, context described in different words, destructive actions styled like navigation, value fills that jump. | docs/plans/INDEX-CHECKLIST.md, "Present on multiple screens" |
|
||||
|
||||
## Not captured individually
|
||||
|
||||
- The 16 screen plans list about 190 numbered items, of which the index says about a quarter were executed. The rest stay in
|
||||
[history/apps/host/docs/plans](../../../history/apps/host/docs/plans/README.md); read the plan for a screen before changing that screen.
|
||||
- The audit-fix campaign left 80 of its 177 findings as "optional, not scheduled" (all low priority); 91 were implemented and 6 closed by removal.
|
||||
The finding ids and the task each belongs to are in [history/apps/host/audit-fixes/FINDING-STATUS.md](../../../history/apps/host/audit-fixes/FINDING-STATUS.md).
|
||||
By task: T11 chat 18, T13 serving 13, T09 model page 8, T15 skills and tasks 7, T10 engine page 6, T16 settings and setup 5, T18 preview panel 4,
|
||||
T01, T03, T12 and T19 three each, T05 and T17 two each, T04, T07 and T14 one each.
|
||||
- Declined by the owner, so not backlog: Tuning's nine flat fields in two groups, the Settings section order, renaming "More" to Settings, and repeating About's config paths.
|
||||
@@ -0,0 +1,47 @@
|
||||
# App developer docs
|
||||
|
||||
Current:
|
||||
|
||||
- [interaction-guidelines.md](interaction-guidelines.md): motion, materials, navigation and contrast rules. `scripts/audit-ui.mjs` and the style sheets cite its sections.
|
||||
- [design-system-notes.md](design-system-notes.md): how the app uses the kit styles in `src/styles/kit/`.
|
||||
- [BACKLOG.md](BACKLOG.md): UI requirements from the October 2026 campaign that are still open or unchecked.
|
||||
- [../README.md](../README.md): start here (build, preview, tests).
|
||||
|
||||
## How the host is organised
|
||||
|
||||
This replaces the 14 October 2026 native-engine handover (kept in [history](../../../history/apps/host/docs/native-engine-handover.md); it was written against engine 0.1.38).
|
||||
|
||||
| Module | Responsibility |
|
||||
| --- | --- |
|
||||
| `server/native-control.mjs` | The bridge to Strata Control and the installed engine: catalog, status, install jobs, saved job state, per-instance config copies, process start and stop, benchmark, calibration and updates. |
|
||||
| `server/native-settings.mjs` | Allowed per-model settings, their bounds, MCP configuration validation and credential masking. |
|
||||
| `server/native-gateway.mjs` | Forwards Chat Completions, Anthropic and Responses requests to the selected instance. |
|
||||
| `server/chat-images.mjs` | Validation of attached pictures. |
|
||||
| `src/lib/native.ts` | Shared native state and polling. |
|
||||
| `src/components/NativeModels.tsx` | Model setup, device assignment, version management and load. |
|
||||
| `src/components/NativeSettings.tsx`, `NativeMcpEditor.tsx` | Per-model parameters, JSON and tool-server configuration. |
|
||||
| `src/components/NativeMonitor.tsx` | Rates per instance, per-instance detail and host measurements. |
|
||||
|
||||
Contracts that still hold:
|
||||
|
||||
- Each loaded instance runs `serve/server.py` on its own private loopback port with a random API key, from a config copy in the app's data folder.
|
||||
The one public URL shown in Serving forwards to it; the public model id is an alias in that config, and unknown or ambiguous ids fail before streaming.
|
||||
- A source checkout finds a Strata installation through `STRATA_NATIVE_REPO` (it must contain `setup.py`, `tools/strata_mcp.py` and `serve/server.py`) and a Python through
|
||||
`STRATA_PYTHON` or `STRATA_NATIVE_PYTHON` (`server/python-resolver.mjs`). The installed app uses the Control that ships beside it instead.
|
||||
- Saving settings validates first, keeps unrelated keys and non-Strata environment values, writes a `.bak` and replaces the config atomically. New settings apply on the next load.
|
||||
|
||||
## Moved to history
|
||||
|
||||
Pointer: [history/README.md](../../../history/README.md). The files, unchanged:
|
||||
|
||||
| Was | Now |
|
||||
| --- | --- |
|
||||
| `audit-fixes/` (next to this folder) | [history/apps/host/audit-fixes](../../../history/apps/host/audit-fixes/README.md) |
|
||||
| `README-historical.md` | [history/apps/host/docs/README-historical.md](../../../history/apps/host/docs/README-historical.md) |
|
||||
| `native-engine-handover.md` | [history/apps/host/docs/native-engine-handover.md](../../../history/apps/host/docs/native-engine-handover.md) |
|
||||
| `annotations-19-2026-10-09.md`, `annotations-32-2026-10-09.md`, `annotations-followup-2026-10-09.md` | [history/apps/host/docs/](../../../history/apps/host/docs) |
|
||||
| `chat-shell-2026-10-06.md`, `ui-followup-2026-10-06.md` | [history/apps/host/docs/](../../../history/apps/host/docs) |
|
||||
| `design-improvements-2026-10-05.md`, `dsh-recon.md`, `engine-models-onboarding-2026-10-05.md` | [history/apps/host/docs/](../../../history/apps/host/docs) |
|
||||
| `user-feedback-2026-10-05.md`, `user-feedback-2026-10-06.md` | [history/apps/host/docs/](../../../history/apps/host/docs) |
|
||||
| `persona-review-2026-10-05/` | [history/apps/host/docs/persona-review-2026-10-05](../../../history/apps/host/docs/persona-review-2026-10-05/decision-report.md) |
|
||||
| `plans/` | [history/apps/host/docs/plans](../../../history/apps/host/docs/plans/README.md) |
|
||||
@@ -7,17 +7,13 @@ tints composited over `--st-surface` first.
|
||||
|
||||
## 1. How the app consumes the kit
|
||||
|
||||
`src/styles/kit/{tokens,components}.css` are synced verbatim from `../ui` by
|
||||
`npm run sync:kit` (the only rewrite is making the `@font-face` URLs absolute).
|
||||
`src/styles/kit/{tokens,components}.css` are the checked-in kit styles and the source of truth
|
||||
(there is no generator; the kit was first imported from a separate design workspace that is not in this repository).
|
||||
`src/styles/app.css` sits on top and contains, in order: extensions to the kit's
|
||||
classes, app shell layout, utilities, app-only components, and the first-run
|
||||
takeover. There are no gradients in it and no literal colours — every value is
|
||||
either a kit token or a layout constant.
|
||||
|
||||
```
|
||||
npm run sync:kit # re-import tokens/components/fonts/icons from ../ui
|
||||
```
|
||||
|
||||
Every component the kit defines is in use:
|
||||
|
||||
| Kit class | Where the app uses it |
|
||||
|
||||
@@ -6,7 +6,7 @@ measure` / `npm run motion` (runtime), so a violation fails a command rather tha
|
||||
a code review.
|
||||
|
||||
Companion documents: [`design-system-notes.md`](design-system-notes.md) for how
|
||||
the app maps onto the kit, [`dsh-recon.md`](dsh-recon.md) for the product this
|
||||
the app maps onto the kit, [`dsh-recon.md`](../../../history/apps/host/docs/dsh-recon.md) (archived) for the product this
|
||||
one is modelled on.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Moved
|
||||
|
||||
The 5 October 2026 persona review (eight personas, debates, ballot, decision report) is kept in
|
||||
[history/apps/host/docs/persona-review-2026-10-05](../../../../history/apps/host/docs/persona-review-2026-10-05/decision-report.md).
|
||||
Open requirements from it are in [../BACKLOG.md](../BACKLOG.md).
|
||||
@@ -1,67 +1,4 @@
|
||||
# Strata · per-screen improvement plans
|
||||
# Moved
|
||||
|
||||
One plan per page and per scenario, written against the two rulebooks the app is
|
||||
built on — [interaction-guidelines.md](../interaction-guidelines.md) (Strata's own
|
||||
rules) and Apple's Liquid Glass HIG — and against what the code actually does
|
||||
today, not what it is meant to do.
|
||||
|
||||
Every plan follows the same shape: **what it is today** (grounded in the file and
|
||||
in measured numbers), **where it falls short**, **the plan**, **motion**, and
|
||||
**how the result is verified**. Claims about the present are checkable with the
|
||||
scripts in `scripts/`; claims about the future are written so they can be checked
|
||||
the same way once built.
|
||||
|
||||
## Screens
|
||||
|
||||
| # | Page | Route | Plan |
|
||||
|---|---|---|---|
|
||||
| 01 | Chat | `#/chat` | [01-chat.md](01-chat.md) |
|
||||
| 02 | Models | `#/models` | [02-models.md](02-models.md) |
|
||||
| 03 | Engine tuning | `#/tuning` | [03-engine-tuning.md](03-engine-tuning.md) |
|
||||
| 04 | Monitor | `#/monitor` | [04-monitor.md](04-monitor.md) |
|
||||
| 05 | Serving | `#/serving` | [05-serving.md](05-serving.md) |
|
||||
| 06 | Workspace | `#/workspace` | [06-workspace.md](06-workspace.md) |
|
||||
| 07 | Skills | `#/skills` | [07-skills.md](07-skills.md) |
|
||||
| 08 | Settings | `#/settings` | [08-settings.md](08-settings.md) |
|
||||
| 09 | First run | modal | [09-first-run.md](09-first-run.md) |
|
||||
|
||||
## Scenarios
|
||||
|
||||
| # | Scenario | Plan |
|
||||
|---|---|---|
|
||||
| 10 | Sidebar and navigation | [10-sidebar.md](10-sidebar.md) |
|
||||
| 11 | Command palette | [11-command-palette.md](11-command-palette.md) |
|
||||
| 12 | Context rail | [12-context-rail.md](12-context-rail.md) |
|
||||
| 13 | Overlays — drawers, menus, selects, popovers | [13-overlays.md](13-overlays.md) |
|
||||
| 14 | Empty, loading and error states | [14-states.md](14-states.md) |
|
||||
| 15 | Motion and fluidity | [15-motion.md](15-motion.md) |
|
||||
| 16 | Accessibility and long sessions | [16-accessibility.md](16-accessibility.md) |
|
||||
|
||||
## The shared vocabulary these plans assume
|
||||
|
||||
Fixed by the design system, so plans do not re-litigate them:
|
||||
|
||||
- **Light is the reference theme.** Every change is designed light-first and
|
||||
verified in dark, never the reverse.
|
||||
- **Capsules are for controls; the 8/14/20/24 scale is for surfaces.** Audit-enforced.
|
||||
- **Glass is only for the floating control layer.** Glass never samples glass;
|
||||
`npm run luminance` fails the build if a material ends up nested in another.
|
||||
- **One motion scale** — 100/160/240/320/460 ms, one standard curve, springs only
|
||||
for direct manipulation. No layout property is transitioned directly; grid
|
||||
tracks and opacity carry the movement.
|
||||
- **Contrast is measured on rendered pairings**, compositing alpha, in every
|
||||
interactive state.
|
||||
- **Reading measure is capped** at 68 characters in the conversation, verified at
|
||||
10, 16 and 22 px.
|
||||
|
||||
## Verification harness these plans refer to
|
||||
|
||||
```
|
||||
npm run check typecheck + audit + contrast + measure + luminance
|
||||
npm run audit off-scale values, materials, elevation, capsules, icons, native selects
|
||||
npm run contrast rendered pairings, composited, per state, both themes
|
||||
npm run measure characters per line at every content size
|
||||
npm run luminance dark-mode brightness budget + material layering
|
||||
npm run motion interruptibility, frame-sampled contact sheets
|
||||
npm run flow <name> scripted screen flows; `tour` and `tour-dark` cover every route
|
||||
```
|
||||
The 18 screen plans and their checklist (October 2026) are kept in [history/apps/host/docs/plans](../../../../history/apps/host/docs/plans/README.md).
|
||||
Open requirements from them are in [../BACKLOG.md](../BACKLOG.md).
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "strata",
|
||||
"version": "0.2.10",
|
||||
"version": "0.2.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "strata",
|
||||
"version": "0.2.10",
|
||||
"version": "0.2.13",
|
||||
"dependencies": {
|
||||
"html-to-image": "^1.11.13",
|
||||
"katex": "^0.19.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "strata",
|
||||
"private": true,
|
||||
"version": "0.2.10",
|
||||
"version": "0.2.13",
|
||||
"type": "module",
|
||||
"description": "Strata \u2014 local AI, tuned to one model. Web shell + engine host.",
|
||||
"scripts": {
|
||||
@@ -12,8 +12,7 @@
|
||||
"serve": "node server/index.mjs --dist dist",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"sync:kit": "node scripts/sync-design-kit.mjs",
|
||||
"shot": "node scripts/shoot.mjs",
|
||||
"shot": "node scripts/shoot.mjs",
|
||||
"flow": "node scripts/flow.mjs",
|
||||
"audit": "node scripts/audit-ui.mjs",
|
||||
"contrast": "node scripts/contrast.mjs",
|
||||
|
||||
@@ -17,7 +17,6 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
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. */
|
||||
@@ -264,24 +263,8 @@ 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);
|
||||
// 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.
|
||||
const norm = (t) => t.replace(/url\((["']?)\/fonts\//g, 'url($1fonts/').trim();
|
||||
if (norm(readFileSync(a, 'utf8')) !== norm(readFileSync(b, 'utf8'))) {
|
||||
fail('kit', 0, `${file} has drifted from ../ui — run npm run sync:kit`);
|
||||
}
|
||||
// The checked-in files in src/styles/kit are the source of the kit styles; there is no generator.
|
||||
if (!existsSync(join(KIT_COPY, file))) fail('kit', 0, `${file} is missing from src/styles/kit (restore it from git)`);
|
||||
}
|
||||
|
||||
// Which selectors have been given the capsule radius. Checked across the whole
|
||||
@@ -328,7 +311,7 @@ for (const file of [...readdirSync(join(ROOT, 'src'), { recursive: true })].filt
|
||||
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`);
|
||||
if (!symbols.has(name)) fail('src', 0, `icon "${name}" is referenced but not in the sprite: add a <symbol id="i-NAME"> to public/icons/sprite.svg`);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- undefined classes -- */
|
||||
|
||||
@@ -24,7 +24,7 @@ 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; } });
|
||||
const sess = await page.evaluate(async () => { try { const h = { 'x-strata-client': 'ui' }; const l = await (await fetch('/api/chats', { headers: h })).json(); return l.chats.reduce((n, c) => n + (c.bytes ?? 0), 0); } 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;
|
||||
}
|
||||
|
||||
@@ -1235,14 +1235,17 @@ if (flow === 'unread') {
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
// make the older conversation look updated since it was last read
|
||||
await page.evaluate(() => {
|
||||
const raw = JSON.parse(localStorage.getItem('strata.sessions.v1') ?? '[]');
|
||||
await page.evaluate(async () => {
|
||||
// The chats are files on the host (owner decision 11-04): read them and save the older one again, as the page would.
|
||||
const h = { 'x-strata-client': 'ui' };
|
||||
const list = await (await fetch('/api/chats', { headers: h })).json();
|
||||
const raw = await Promise.all(list.chats.map(async (c) => (await (await fetch('/api/chats/' + encodeURIComponent(c.id), { headers: h })).json()).session));
|
||||
const older = raw.filter((s) => s.messages.length > 0).at(-1);
|
||||
if (older) {
|
||||
older.updatedAt = new Date(Date.now() + 120000).toISOString();
|
||||
older.lastReadAt = new Date(Date.now() - 120000).toISOString();
|
||||
await fetch('/api/chats/' + encodeURIComponent(older.id), { method: 'PUT', headers: h, body: JSON.stringify(older) });
|
||||
}
|
||||
localStorage.setItem('strata.sessions.v1', JSON.stringify(raw));
|
||||
});
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(1800);
|
||||
@@ -1352,7 +1355,7 @@ if (flow === 'branch') {
|
||||
// remember the original so "untouched" means unchanged by the branch, not a
|
||||
// hardcoded length
|
||||
const before = await page.evaluate(() =>
|
||||
JSON.parse(localStorage.getItem('strata.sessions.v1') ?? '[]')
|
||||
(async () => { const h = { 'x-strata-client': 'ui' }; const l = await (await fetch('/api/chats', { headers: h })).json(); return Promise.all(l.chats.map(async (c) => (await (await fetch('/api/chats/' + encodeURIComponent(c.id), { headers: h })).json()).session)); })()
|
||||
.map((s) => ({ title: s.title, roles: s.messages.map((m) => m.role) })));
|
||||
const originalBefore = before.find((s) => !s.title.includes('branch'));
|
||||
|
||||
@@ -1370,7 +1373,7 @@ if (flow === 'branch') {
|
||||
console.log('from user :', JSON.stringify(await view()), '<- branch must show only the user turn, and the url must follow');
|
||||
|
||||
const sessions = await page.evaluate(() =>
|
||||
JSON.parse(localStorage.getItem('strata.sessions.v1') ?? '[]')
|
||||
(async () => { const h = { 'x-strata-client': 'ui' }; const l = await (await fetch('/api/chats', { headers: h })).json(); return Promise.all(l.chats.map(async (c) => (await (await fetch('/api/chats/' + encodeURIComponent(c.id), { headers: h })).json()).session)); })()
|
||||
.map((s) => ({ title: s.title, roles: s.messages.map((m) => m.role) })));
|
||||
console.log('stored :', JSON.stringify(sessions));
|
||||
const originalAfter = sessions.find((s) => !s.title.includes('branch'));
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Writes src/locales/sav-<lang>.json: the notice for chats the host could not write (owner wording 11-04, #5192).
|
||||
Run from apps/host: python scripts/make-chatsave-locales.py
|
||||
Every entry names all nine languages; the keys are sav01.. in the order of S. sav-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 upd-, df2-, pick- 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))
|
||||
|
||||
|
||||
add("Some chats could not be saved",
|
||||
"部分聊天无法保存",
|
||||
"一部のチャットを保存できませんでした",
|
||||
"Einige Chats konnten nicht gespeichert werden",
|
||||
"No se pudieron guardar algunos chats",
|
||||
"Не удалось сохранить некоторые чаты",
|
||||
"Alguns chats não puderam ser guardados",
|
||||
"일부 채팅을 저장하지 못했습니다",
|
||||
"Certaines conversations n'ont pas pu être enregistrées")
|
||||
add("Strata's chat storage is full, so the newest changes to {0} chat(s) are only in this window. Delete old chats to make room; they then save by themselves.",
|
||||
"Strata 的聊天存储空间已满,因此 {0} 个聊天的最新更改目前只保存在此窗口中。请删除旧聊天以腾出空间,之后它们会自动保存。",
|
||||
"Strata のチャット保存領域がいっぱいのため、{0} 件のチャットの最新の変更はこのウィンドウにしかありません。古いチャットを削除して空きを作ると、自動的に保存されます。",
|
||||
"Der Chat-Speicher von Strata ist voll, daher gibt es die neuesten Änderungen an {0} Chat(s) nur in diesem Fenster. Löschen Sie alte Chats, um Platz zu schaffen; dann werden sie automatisch gespeichert.",
|
||||
"El almacenamiento de chats de Strata está lleno, así que los cambios más recientes de {0} chat(s) solo están en esta ventana. Elimina chats antiguos para liberar espacio; después se guardarán solos.",
|
||||
"Хранилище чатов Strata заполнено, поэтому последние изменения в чатах ({0}) есть только в этом окне. Удалите старые чаты, чтобы освободить место; после этого они сохранятся сами.",
|
||||
"O armazenamento de chats do Strata está cheio, por isso as alterações mais recentes de {0} chat(s) só existem nesta janela. Apague chats antigos para libertar espaço; depois eles guardam-se sozinhos.",
|
||||
"Strata의 채팅 저장 공간이 가득 차서 {0}개 채팅의 최신 변경 내용이 이 창에만 있습니다. 오래된 채팅을 삭제해 공간을 확보하세요. 그러면 자동으로 저장됩니다.",
|
||||
"L'espace de stockage des conversations de Strata est plein : les dernières modifications de {0} conversation(s) ne sont que dans cette fenêtre. Supprimez d'anciennes conversations pour faire de la place ; elles s'enregistreront alors d'elles-mêmes.")
|
||||
add("Copy unsaved chats",
|
||||
"复制未保存的聊天",
|
||||
"未保存のチャットをコピー",
|
||||
"Nicht gespeicherte Chats kopieren",
|
||||
"Copiar chats sin guardar",
|
||||
"Скопировать несохранённые чаты",
|
||||
"Copiar chats não guardados",
|
||||
"저장되지 않은 채팅 복사",
|
||||
"Copier les conversations non enregistrées")
|
||||
|
||||
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"sav{i + 1:02d}"] = french(text) if lang == "fr" else text
|
||||
with (here / f"sav-{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")
|
||||
@@ -175,6 +175,51 @@ add("Drafting: MTP (DFlash2 didn't start)",
|
||||
"Previsão: MTP (o DFlash2 não iniciou)",
|
||||
"초안 예측: MTP(DFlash2가 시작되지 않았습니다)",
|
||||
"Anticipation : MTP (DFlash2 n'a pas démarré)")
|
||||
add("Auto uses the all-cards split engine with the MTP draft layer: about {0} GB to download, once.",
|
||||
"自动模式使用全部显卡的拆分引擎和 MTP 草稿层:约 {0} GB,只下载一次。",
|
||||
"自動では、全カードのスプリットエンジンと MTP ドラフト層を使います。約 {0} GB を一度だけダウンロードします。",
|
||||
"Automatisch nutzt die Alle-Karten-Split-Engine mit der MTP-Entwurfsschicht: etwa {0} GB, einmalig herunterzuladen.",
|
||||
"Automático usa el motor de reparto entre todas las tarjetas con la capa de borrador MTP: unos {0} GB, una sola vez.",
|
||||
"Авто использует движок с разделением по всем картам и черновой слой MTP: около {0} ГБ, скачивается один раз.",
|
||||
"O automático usa o motor de divisão entre todas as placas com a camada de rascunho MTP: cerca de {0} GB, uma vez.",
|
||||
"자동은 모든 카드를 쓰는 분할 엔진과 MTP 초안 층을 사용합니다(약 {0}GB, 한 번만 내려받음).",
|
||||
"Auto utilise le moteur réparti sur toutes les cartes avec la couche de brouillon MTP : environ {0} Go à télécharger, une seule fois.")
|
||||
add("The all-cards split engine could not start, so the normal layer split runs.",
|
||||
"全显卡拆分引擎未能启动,因此改用常规的分层拆分。",
|
||||
"全カードのスプリットエンジンを起動できなかったため、通常のレイヤー分割で動作しています。",
|
||||
"Die Alle-Karten-Split-Engine konnte nicht starten, deshalb läuft die normale Schichtaufteilung.",
|
||||
"El motor de reparto entre todas las tarjetas no pudo arrancar, así que se usa el reparto normal por capas.",
|
||||
"Движок с разделением по всем картам не запустился, поэтому работает обычное послойное разделение.",
|
||||
"O motor de divisão entre todas as placas não conseguiu iniciar, então a divisão normal por camadas está em uso.",
|
||||
"모든 카드를 쓰는 분할 엔진을 시작하지 못해 일반 레이어 분할로 실행합니다.",
|
||||
"Le moteur réparti sur toutes les cartes n'a pas pu démarrer, la répartition normale par couches est donc utilisée.")
|
||||
add("Drafting: off (the cards were short of memory)",
|
||||
"预测草稿:关闭(显卡内存不足)",
|
||||
"先読み:オフ(グラフィックスカードのメモリが不足していました)",
|
||||
"Vorausraten: aus (den Karten fehlte Speicher)",
|
||||
"Predicción: desactivada (a las tarjetas les faltaba memoria)",
|
||||
"Предсказание: выключено (на картах не хватило памяти)",
|
||||
"Previsão: desligada (faltou memória nas placas)",
|
||||
"초안 예측: 꺼짐(카드 메모리가 부족했습니다)",
|
||||
"Anticipation : désactivée (les cartes manquaient de mémoire)")
|
||||
add("On your Mac, DFlash2 was faster than MTP on this model in our tests.",
|
||||
"在您的 Mac 上,我们的测试中 DFlash2 在此模型上比 MTP 更快。",
|
||||
"お使いの Mac では、私たちのテストでこのモデルにおいて DFlash2 は MTP より速くなりました。",
|
||||
"Auf Ihrem Mac war DFlash2 bei diesem Modell in unseren Tests schneller als MTP.",
|
||||
"En su Mac, en nuestras pruebas DFlash2 fue más rápido que MTP con este modelo.",
|
||||
"На вашем Mac в наших тестах DFlash2 с этой моделью был быстрее MTP.",
|
||||
"No seu Mac, nos nossos testes o DFlash2 foi mais rápido que o MTP com este modelo.",
|
||||
"고객님의 Mac에서 저희 테스트 결과 이 모델에서는 DFlash2가 MTP보다 빨랐습니다.",
|
||||
"Sur votre Mac, DFlash2 a été plus rapide que MTP avec ce modèle lors de nos tests.")
|
||||
add("DFlash2 is off by default on this graphics card for now; choose it in Drafting to use it.",
|
||||
"目前在这张显卡上 DFlash2 默认关闭;如需使用,请在“预测草稿”中选择它。",
|
||||
"現在、このグラフィックスカードでは DFlash2 は既定でオフです。使うには「先読み」で選んでください。",
|
||||
"DFlash2 ist auf dieser Grafikkarte vorerst standardmäßig aus; wählen Sie es unter „Vorausraten“ aus, um es zu nutzen.",
|
||||
"DFlash2 está desactivado de forma predeterminada en esta tarjeta gráfica por ahora; elíjalo en Predicción para usarlo.",
|
||||
"На этой видеокарте DFlash2 пока выключен по умолчанию; чтобы использовать его, выберите его в разделе «Предсказание».",
|
||||
"O DFlash2 está desligado por predefinição nesta placa de vídeo por agora; escolha-o em Previsão para o usar.",
|
||||
"현재 이 그래픽 카드에서는 DFlash2가 기본적으로 꺼져 있습니다. 사용하려면 ‘초안 예측’에서 선택하세요.",
|
||||
"DFlash2 est désactivé par défaut sur cette carte graphique pour le moment ; choisissez-le dans Anticipation pour l'utiliser.")
|
||||
here = Path(__file__).resolve().parent.parent / "src" / "locales"
|
||||
NBSP = " "
|
||||
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* Sync the Strata UI kit (../ui) into the app's public/ folder.
|
||||
*
|
||||
* node scripts/sync-design-kit.mjs
|
||||
*
|
||||
* Copies fonts, and rebuilds a single clean icon sprite at public/icons/sprite.svg.
|
||||
* The kit's own SVG files carry C2PA manifest metadata (~12 KB each, base64);
|
||||
* we strip <metadata> so the shipped sprite stays small.
|
||||
*
|
||||
* Also mirrors tokens.css / components.css into src/styles/kit/ so the app has a
|
||||
* versioned copy that can be diffed against the kit.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, mkdirSync, readdirSync, copyFileSync, existsSync } from 'node:fs';
|
||||
import { join, dirname, basename } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(here, '..');
|
||||
const kit = join(root, '..', 'ui');
|
||||
const pub = join(root, 'public');
|
||||
const styles = join(root, 'src', 'styles', 'kit');
|
||||
|
||||
if (!existsSync(kit)) {
|
||||
console.error(`Design kit not found at ${kit}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
mkdirSync(join(pub, 'fonts'), { recursive: true });
|
||||
mkdirSync(join(pub, 'icons'), { recursive: true });
|
||||
mkdirSync(styles, { recursive: true });
|
||||
|
||||
// ---- fonts -----------------------------------------------------------------
|
||||
let fonts = 0;
|
||||
for (const f of readdirSync(join(kit, 'fonts'))) {
|
||||
if (!f.endsWith('.woff2')) continue;
|
||||
copyFileSync(join(kit, 'fonts', f), join(pub, 'fonts', f));
|
||||
fonts++;
|
||||
}
|
||||
|
||||
// ---- icon sprite -----------------------------------------------------------
|
||||
const strip = (svg) =>
|
||||
svg
|
||||
.replace(/<metadata>[\s\S]*?<\/metadata>/g, '')
|
||||
.replace(/<svg[^>]*>/, '')
|
||||
.replace(/<\/svg>\s*$/, '')
|
||||
.replace(/\s+xmlns(:\w+)?="[^"]*"/g, '')
|
||||
.trim();
|
||||
|
||||
const iconDir = join(kit, 'icons');
|
||||
const sources = new Map(readdirSync(iconDir)
|
||||
.filter((f) => f.endsWith('.svg') && f !== 'sprite.svg')
|
||||
.map((f) => [basename(f, '.svg'), join(iconDir, f)]));
|
||||
const appIconDir = join(root, 'src', 'assets', 'icons');
|
||||
if (existsSync(appIconDir)) {
|
||||
for (const file of readdirSync(appIconDir).filter(f => f.endsWith('.svg'))) {
|
||||
sources.set(basename(file, '.svg'), join(appIconDir, file));
|
||||
}
|
||||
}
|
||||
const names = [...sources.keys()].sort();
|
||||
|
||||
const symbols = names.map((name) => {
|
||||
const raw = readFileSync(sources.get(name), 'utf8');
|
||||
const body = strip(raw);
|
||||
const viewBox = /viewBox="([^"]+)"/.exec(raw)?.[1] ?? '0 0 24 24';
|
||||
// The root <svg> carries fill="none"; without it every stroked path inherits
|
||||
// the default black fill and the icons render as solid blobs.
|
||||
const rootFill = /<svg[^>]*\sfill="([^"]+)"/.exec(raw)?.[1] ?? 'none';
|
||||
return ` <symbol id="i-${name}" viewBox="${viewBox}" fill="${rootFill}">\n ${body}\n </symbol>`;
|
||||
});
|
||||
|
||||
const sprite = `<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">\n${symbols.join('\n')}\n</svg>\n`;
|
||||
writeFileSync(join(pub, 'icons', 'sprite.svg'), sprite);
|
||||
|
||||
// ---- styles ----------------------------------------------------------------
|
||||
// The kit references fonts relatively (fonts/x.woff2). In the app the fonts are
|
||||
// served from public/fonts, so rewrite the URLs to be absolute — otherwise the
|
||||
// browser resolves them against the CSS file and 404s in both dev and build.
|
||||
for (const f of ['tokens.css', 'components.css']) {
|
||||
const css = readFileSync(join(kit, f), 'utf8').replace(/url\((["']?)fonts\//g, 'url($1/fonts/');
|
||||
writeFileSync(join(styles, f), css);
|
||||
}
|
||||
|
||||
const kb = (n) => `${(n / 1024).toFixed(1)} KB`;
|
||||
console.log(`synced ${fonts} fonts -> public/fonts/`);
|
||||
console.log(`sprite: ${names.length} icons -> public/icons/sprite.svg (${kb(sprite.length)})`);
|
||||
console.log(`styles: tokens.css, components.css -> src/styles/kit/`);
|
||||
console.log(`icons: ${names.join(', ')}`);
|
||||
@@ -11,6 +11,8 @@ import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
const scratch=mkdtempSync(join(tmpdir(),'strata-host-suite-profile-'));
|
||||
const env={...process.env,STRATA_SETTINGS_FILE:join(scratch,'app-settings.json'),STRATA_COMPONENTS_DIR:join(scratch,'components'),LOCALAPPDATA:join(scratch,'Local'),APPDATA:join(scratch,'Roaming'),XDG_DATA_HOME:join(scratch,'xdg-data'),XDG_STATE_HOME:join(scratch,'xdg-state'),XDG_CONFIG_HOME:join(scratch,'xdg-config')};
|
||||
const child=spawn(process.execPath,['--test',...files],{cwd:root,stdio:'inherit',env});
|
||||
/* A test that never finishes fails after STRATA_TEST_TIMEOUT_MS (default 10 minutes) instead of holding the whole run for ever; extra arguments (a reporter, --test-name-pattern) go to node --test. */
|
||||
const timeout=Number(process.env.STRATA_TEST_TIMEOUT_MS)||600000;
|
||||
const child=spawn(process.execPath,['--test',`--test-timeout=${timeout}`,...process.argv.slice(2),...files],{cwd:root,stdio:'inherit',env});
|
||||
child.on('error',error=>{console.error(error.message);process.exitCode=1;});
|
||||
child.on('exit',(code,signal)=>{process.exitCode=code??(signal?1:0);});
|
||||
@@ -62,7 +62,8 @@ export async function open(scheme, width, height, sessions, { hash = '#/chat', p
|
||||
try {
|
||||
if (sessionStorage.getItem('ui-do-seeded')) return;
|
||||
sessionStorage.setItem('ui-do-seeded', '1');
|
||||
if (sessions) localStorage.setItem('strata.sessions.v1', JSON.stringify(sessions));
|
||||
// The chats are files on the host: put the seed there (a synchronous request, so it is in place before the page starts).
|
||||
for (const chat of sessions ?? []) { const request = new XMLHttpRequest(); request.open('PUT', '/api/chats/' + encodeURIComponent(chat.id), false); request.setRequestHeader('x-strata-client', 'ui'); request.send(JSON.stringify({ messages: [], ...chat })); }
|
||||
if (Object.keys(prefs).length) localStorage.setItem('strata.prefs.v1', JSON.stringify(prefs));
|
||||
} catch { /* storage refused */ }
|
||||
}, { sessions, prefs });
|
||||
|
||||
@@ -26,7 +26,7 @@ import { buildProfile, routeFor, MODE_PRESETS, modeOf, effortOf, localOutputBudg
|
||||
import { TurnProjector, parseArguments as parseArgs } from './events.mjs';
|
||||
import { ChildProjector } from './children.mjs';
|
||||
import { nearestBudget } from './llm-shape.mjs';
|
||||
import { DeliverableTracker, ActivityGroups } from './deliverables.mjs';
|
||||
import { DeliverableTracker, ActivityGroups, FolderClaims } 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';
|
||||
@@ -127,6 +127,8 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
const home = join(root, 'home');
|
||||
const sessionsFile = join(root, 'sessions.json');
|
||||
mkdirSync(root, { recursive: true });
|
||||
/** Which chat's file tools wrote which file, for chats that share a folder (#5008). */
|
||||
const folderClaims = new FolderClaims();
|
||||
|
||||
/**
|
||||
* Where the turn's file tracker must never look (deliverables.mjs): the app, the engine and its components, Strata's own data, AppData
|
||||
@@ -367,24 +369,27 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
let hideDeltas = !!researchTopic;
|
||||
signal?.addEventListener('abort', () => { send = () => {}; attached = false; }, { once: true });
|
||||
// What the assistant itself wrote in this run (not our own step lines): a plan turn reads its plan from it, a step reads its summary.
|
||||
const written = { all: '', step: '' };
|
||||
const written = { all: '', step: '', reasoning: '' };
|
||||
// The frame that closed the turn, kept with the turn's text for a page that comes back after it ended (attach).
|
||||
let terminal = null;
|
||||
// The turn guard (turn-guard.mjs): counts failing searches, searches, plan rewrites and a nearly full context. Set up once the route is known.
|
||||
let guard = null;
|
||||
const raw = frame => { try { send(frame); } catch { send = () => {}; attached = false; } };
|
||||
const raw = frame => { if (frame.type === 'done' || frame.type === 'error') terminal = frame; try { send(frame); } catch { send = () => {}; attached = false; } };
|
||||
// What the turn made (files) and compact activity lines (notes-split-agent8.md): derived from the cards the turn already sends.
|
||||
const tracker = new DeliverableTracker(workspacePath, trackerFolders());
|
||||
const tracker = new DeliverableTracker(workspacePath, { ...trackerFolders(), claims: folderClaims, owner: chatId });
|
||||
let toolsRan = false; // a turn that ran no tool wrote nothing, so there is nothing to look for at its end
|
||||
try { tracker.snapshot(); } catch { /* a folder that cannot be read has no deliverables */ }
|
||||
const groups = new ActivityGroups({ emit: raw });
|
||||
const skills = new SkillsUsed({ emit: raw, info: skillInfoFrom(skillsDir()) });
|
||||
const emit = frame => {
|
||||
if (frame.type === 'reasoning' && frame.text && written.reasoning.length < 2_000_000) written.reasoning += frame.text;
|
||||
if (frame.type === 'delta' && !frame.own) { written.all += frame.text; written.step += frame.text; if (guard) trip(guard.text(frame.text)); if (hideDeltas) return; }
|
||||
if (frame.own) { const { own, ...rest } = frame; frame = rest; }
|
||||
if (frame.type === 'delta' && frame.text) groups.text();
|
||||
if (frame.type === 'agent-tool' && frame.tool) {
|
||||
if (['done', 'error'].includes(frame.tool.state) && !['read', 'read_image', 'glob', 'grep', 'web_fetch', 'web_search'].includes(frame.tool.name)) toolsRan = true;
|
||||
let made = null;
|
||||
if (frame.tool.state === 'done' && (frame.tool.name === 'write' || frame.tool.name === 'edit')) { try { made = tracker.check(frame.tool.args?.file_path ?? frame.tool.args?.path); } catch { made = null; } }
|
||||
if (frame.tool.state === 'done' && (frame.tool.name === 'write' || frame.tool.name === 'edit')) { try { made = tracker.check(frame.tool.args?.file_path ?? frame.tool.args?.path, { explicit: true }); } catch { made = null; } }
|
||||
raw(frame);
|
||||
if (made) raw(made);
|
||||
if (guard) trip(guard.card(frame.tool));
|
||||
@@ -463,6 +468,20 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
research: researchTopic ? { phase: 'gather', nudged: false, waitingStart: false } : null,
|
||||
};
|
||||
c.turn = turn;
|
||||
if (workspacePath) folderClaims.begin(workspacePath, chatId);
|
||||
/**
|
||||
* A page that comes back (a reload, a new window) takes the running turn over again. Everything that was said while no page was there is
|
||||
* sent as one catch-up: the start, the text and thinking so far (a `resync`), every tool card as it is now (the ones waiting for a yes
|
||||
* included) and the questions waiting for an answer. After that the turn's frames flow to the new page as they did to the old one.
|
||||
*/
|
||||
turn.reattach = ({ onFrame, signal: again }) => {
|
||||
send = onFrame; attached = true;
|
||||
again?.addEventListener('abort', () => { send = () => {}; attached = false; }, { once: true });
|
||||
raw({ type: 'start', model: route.name, contextMax: route.contextWindow, demo: false, remoteModel: route.kind === 'api', agent: true, mode: appliedMode, sessionId, requestId, skills: [], resumed: true, ...(planTask ? { planning: true } : {}) });
|
||||
raw({ type: 'resync', content: hideDeltas ? '' : written.all, reasoning: written.reasoning });
|
||||
for (const t of projector.tools.values()) if (t.card) raw({ type: 'agent-tool', tool: t.card });
|
||||
for (const q of questions.values()) if (q.sessionId === sessionId) raw({ type: 'agent-question', callId: q.callId, questions: q.items });
|
||||
};
|
||||
/**
|
||||
* A turn must always end. If the Harness sends nothing at all for a while (a tool that never returns, a model server that went
|
||||
* quiet, a lost event) the turn is stopped and ended with a plain message instead of showing "thinking" forever. A running tool gets
|
||||
@@ -500,6 +519,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
reporter.clear();
|
||||
const extra = [];
|
||||
try { if (toolsRan) for (const d of tracker.sweep()) { emit(d); groups.addFile(d); } } catch { /* never blocks the end of a turn */ }
|
||||
if (workspacePath) folderClaims.end(workspacePath, chatId);
|
||||
groups.end();
|
||||
if (researchTopic && !failure && !turn.stopping) { const sources = parseSources(written.all); if (sources.length) extra.push({ type: 'research', topic: researchTopic, sources }); }
|
||||
try { extra.push(...settlePlan(failure)); } catch { /* a plan frame must never break the end of a turn */ }
|
||||
@@ -529,6 +549,8 @@ ${note.text}`, key: note.key, values: note.values, noteReason: 'cut' });
|
||||
}
|
||||
for (const f of frames.slice(-1)) emit(f);
|
||||
c.turn = null;
|
||||
// What a page that was away needs to show the end of this turn (attach, below).
|
||||
c.last = { requestId, sessionId, mode: appliedMode, model: route.name, contextMax: route.contextWindow, remoteModel: route.kind === 'api', content: hideDeltas ? '' : written.all.slice(0, 4_000_000), reasoning: written.reasoning, cards: [...projector.tools.values()].filter(t => t.card).map(t => t.card), terminal };
|
||||
turn.ended.resolve({ reason: projector.ended, failure: failure ?? null });
|
||||
notifyIdle();
|
||||
// A turn that filled the context, or that the guard stopped, leaves a long history behind: summarise the older part now so the next message has room.
|
||||
@@ -1402,6 +1424,34 @@ ${note.text}
|
||||
return { stopped: true, ended: result.reason?.kind ?? 'unknown', ms: Date.now() - t0 };
|
||||
}
|
||||
|
||||
/** The turn running for a chat now, for a page that has just loaded; null when none. */
|
||||
function turnOf(chatId) {
|
||||
const t = chats.get(chatId)?.turn;
|
||||
if (!t || t.finished) return null;
|
||||
return { chatId, sessionId: t.sessionId, requestId: t.requestId, mode: t.mode, stopping: !!t.stopping, pending: pendingFor(chatId) };
|
||||
}
|
||||
/** Every chat with a turn running. */
|
||||
function runningTurns() { return [...chats.keys()].map(turnOf).filter(Boolean); }
|
||||
/**
|
||||
* Take a turn over from a new page. A running turn: the catch-up, then the live frames until it ends or the page leaves again. A turn that
|
||||
* ended while no page was there (`requestId` names it): its text, tool cards and closing frame once. null when there is nothing to take over.
|
||||
*/
|
||||
function attach({ chatId, requestId = null, onFrame, signal }) {
|
||||
const c = chats.get(chatId);
|
||||
const turn = c?.turn;
|
||||
if (turn && !turn.finished && typeof turn.reattach === 'function') {
|
||||
if (requestId && turn.requestId !== requestId) return null;
|
||||
turn.reattach({ onFrame, signal });
|
||||
return { ended: turn.ended.promise.then(() => undefined), live: true };
|
||||
}
|
||||
const last = c?.last;
|
||||
if (!last || !requestId || last.requestId !== requestId) return null;
|
||||
onFrame({ type: 'start', model: last.model, contextMax: last.contextMax, demo: false, remoteModel: last.remoteModel, agent: true, mode: last.mode, sessionId: last.sessionId, requestId: last.requestId, skills: [], resumed: true });
|
||||
onFrame({ type: 'resync', content: last.content, reasoning: last.reasoning });
|
||||
for (const card of last.cards) onFrame({ type: 'agent-tool', tool: card });
|
||||
if (last.terminal) onFrame(last.terminal);
|
||||
return { ended: Promise.resolve(), live: false };
|
||||
}
|
||||
function pendingFor(chatId) {
|
||||
const sessionId = sessionMap[chatId]?.sessionId;
|
||||
const helpers = chats.get(chatId)?.turn?.children;
|
||||
@@ -1423,7 +1473,7 @@ ${note.text}
|
||||
/** 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, 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 }) };
|
||||
return { compactCommand, messageHelper, answerQuestion, goalState, goalControl, harnessDir, status, turnOf, runningTurns, attach, 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. */
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* timeoutSec the time allowed, 1 to 3600 (default 120). A command that runs over is ended together with the programs it started.
|
||||
*/
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { endTree as endTreeNow, ownGroup } from '../process-tree.mjs';
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
@@ -67,10 +68,7 @@ export const clampTimeoutSec = value => {
|
||||
};
|
||||
|
||||
function endTree(child) {
|
||||
try {
|
||||
if (process.platform === 'win32' && child.pid) spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, timeout: 10000 });
|
||||
else child.kill('SIGKILL');
|
||||
} catch { /* gone */ }
|
||||
endTreeNow(child, 'SIGKILL'); // the command's whole process group on Linux and macOS (it is started as its own group below), taskkill /T on Windows
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,7 +83,7 @@ export function runCommand({ command, input = '', cwd, callCwd, shell = 'auto',
|
||||
const limit = clampTimeoutSec(timeoutSec);
|
||||
let child;
|
||||
try {
|
||||
child = spawn(plan.file, plan.args, { shell: plan.shell, windowsHide: true, cwd: where.cwd, env: { ...process.env, STRATA_INPUT: String(input ?? '') }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
child = spawn(plan.file, plan.args, { shell: plan.shell, windowsHide: true, detached: ownGroup(), cwd: where.cwd, env: { ...process.env, STRATA_INPUT: String(input ?? '') }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
} catch (e) { return resolve({ ok: false, text: `The command could not start in ${plan.name}: ${e.message}` }); }
|
||||
let text = '', cut = false, timedOut = false;
|
||||
const take = chunk => { if (text.length < OUT_MAX + 1) text += chunk.toString(); else cut = true; };
|
||||
|
||||
@@ -49,6 +49,44 @@ export function isWithin(parent, child) {
|
||||
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
||||
}
|
||||
|
||||
/**
|
||||
* Who made which file when several chats work in one folder (#5008). A folder chosen for two chats is shared on purpose, but a
|
||||
* chat's list of "files this turn made" is private to that turn: a file another chat wrote must not be shown as this chat's work.
|
||||
* One registry is shared by the turns of a host. A turn `begin`s on its folder, `claim`s the files its own file tools wrote, and
|
||||
* `end`s. Turns that were running at the same time in the same folder are `overlapped`: for them the end-of-turn sweep reports only
|
||||
* files nobody else claimed AND that no overlapping turn could have written; since a script's output cannot be told apart, it is
|
||||
* left out for an overlapped turn rather than given to the wrong chat.
|
||||
*/
|
||||
export class FolderClaims {
|
||||
#active = new Map(); // folder key -> Set(owner)
|
||||
#overlap = new Map(); // folder key + owner -> true
|
||||
#claims = new Map(); // folder key -> Map(rel path -> owner)
|
||||
#key(root) { return same(resolve(String(root))); }
|
||||
begin(root, owner) {
|
||||
const k = this.#key(root);
|
||||
const set = this.#active.get(k) ?? new Set();
|
||||
const others = [...set].filter(o => o !== owner);
|
||||
if (others.length) { for (const o of [...others, owner]) this.#overlap.set(`${k}|${o}`, true); }
|
||||
set.add(owner); this.#active.set(k, set);
|
||||
}
|
||||
end(root, owner) {
|
||||
const k = this.#key(root);
|
||||
const set = this.#active.get(k);
|
||||
if (!set) return;
|
||||
set.delete(owner); this.#overlap.delete(`${k}|${owner}`);
|
||||
if (!set.size) { this.#active.delete(k); this.#claims.delete(k); }
|
||||
}
|
||||
claim(root, rel, owner) {
|
||||
const k = this.#key(root);
|
||||
if (!this.#active.has(k)) return;
|
||||
const m = this.#claims.get(k) ?? new Map();
|
||||
m.set(rel, owner); this.#claims.set(k, m);
|
||||
}
|
||||
ownerOf(root, rel) { return this.#claims.get(this.#key(root))?.get(rel) ?? null; }
|
||||
/** True when another turn was running in this folder at any time during this owner's turn. */
|
||||
overlapped(root, owner) { return this.#overlap.get(`${this.#key(root)}|${owner}`) === true; }
|
||||
}
|
||||
|
||||
export class DeliverableTracker {
|
||||
/**
|
||||
* `exclude`: folders that are never searched when they lie inside the chat's folder (the app, the engine and its components, AppData,
|
||||
@@ -56,8 +94,9 @@ export class DeliverableTracker {
|
||||
* tracked at all. The turn only reports files the turn itself wrote: an explicit file-tool call, or (for scripts) a file whose modified
|
||||
* or creation time is after the turn started.
|
||||
*/
|
||||
constructor(root, { now = Date.now, exclude = [], forbid = [] } = {}) {
|
||||
constructor(root, { now = Date.now, exclude = [], forbid = [], claims = null, owner = null } = {}) {
|
||||
this.root = root ? resolve(root) : null;
|
||||
this.claims = claims; this.owner = owner;
|
||||
this.now = now;
|
||||
if (this.root && forbid.filter(Boolean).some(p => isWithin(p, this.root))) this.root = null;
|
||||
// only the folders that lie inside the chat's folder matter for the search
|
||||
@@ -104,13 +143,15 @@ export class DeliverableTracker {
|
||||
}
|
||||
|
||||
/** The deliverable for one path now, compared with the snapshot (and the last report), or null when nothing changed. */
|
||||
check(rawPath) {
|
||||
check(rawPath, { explicit = false } = {}) {
|
||||
if (!this.root) return null;
|
||||
const full = resolve(this.root, String(rawPath ?? ''));
|
||||
const rel = relative(this.root, full).split(sep).join('/');
|
||||
if (!rel || rel.startsWith('..') || isAbsolute(rel) || rel.split('/').some(seg => SKIP_DIR.test(seg)) || this.#excluded(dirname(full))) return null;
|
||||
let st; try { st = statSync(full); } catch { return null; }
|
||||
if (!st.isFile()) return null;
|
||||
// A file this chat's own file tool wrote is this chat's, whoever else works in the folder (#5008).
|
||||
if (explicit && this.claims && this.owner) this.claims.claim(this.root, rel, this.owner);
|
||||
const old = this.before.get(rel);
|
||||
if (old && old.size === st.size && old.mtimeMs === st.mtimeMs) return null;
|
||||
const type = fileType(rel);
|
||||
@@ -139,7 +180,14 @@ export class DeliverableTracker {
|
||||
// modified during the turn; or created during it (a copy that kept its old modified time), which is only certain when the first look saw the whole folder
|
||||
const fresh = st.mtimeMs >= since || (!this.snapshotTruncated && (st.birthtimeMs || 0) >= since);
|
||||
if (!fresh) return;
|
||||
const d = this.check(relative(this.root, full)); if (d) out.push(d);
|
||||
const rel = relative(this.root, full).split(sep).join('/');
|
||||
if (this.claims && this.owner) {
|
||||
// Another chat's file tool wrote it: not ours. Written by a script while another chat was working in the same folder: nobody can say whose it is.
|
||||
const by = this.claims.ownerOf(this.root, rel);
|
||||
if (by && by !== this.owner) return;
|
||||
if (!by && this.claims.overlapped(this.root, this.owner)) return;
|
||||
}
|
||||
const d = this.check(rel); if (d) out.push(d);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* per saved API connection, so that each chat can pick its own model. This module writes the same two
|
||||
* Cordis entries (`agent-default-model`, `llm-pi-ai`) for the current set.
|
||||
*
|
||||
* Rules carried over from the integration notes (docs/work/IMPLEMENTATION_STATUS.md section 1.8):
|
||||
* Rules carried over from the integration notes (history/docs/work/IMPLEMENTATION_STATUS.md section 1.8):
|
||||
* - `llm-pi-ai` config is `{ providers: { "<route>": {...} } }`; a route key at the top level is
|
||||
* silently ignored.
|
||||
* - the output budget must fit the whole request, so `maxTokens` is derived from the real context.
|
||||
|
||||
@@ -22,7 +22,7 @@ export function refusedFolder(dir) {
|
||||
const win = (process.env.SystemRoot ?? 'C:\\Windows').toLowerCase();
|
||||
const q = p.toLowerCase();
|
||||
if (q === win || q.startsWith(win + sep) || /^[a-z]:\\(program files( \(x86\))?|programdata)(\\|$)/.test(q)) return 'That is a system folder. Choose one of your own folders.';
|
||||
} else if (/^\/(bin|boot|dev|etc|lib|lib64|proc|root|run|sbin|sys|usr|var)(\/|$)/.test(p)) return 'That is a system folder. Choose one of your own folders.';
|
||||
} else if (/^\/(bin|boot|dev|etc|lib|lib64|proc|root|run|sbin|sys|usr|var)(\/|$)/.test(p) && !/^\/run\/media\/[^/]+\/./.test(p)) return 'That is a system folder. Choose one of your own folders.';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ export function parseCsv(text, delimiter = ',', maxRows = 500) {
|
||||
export function previewFile(root, rawPath, sessionId) {
|
||||
const file = resolveChatFile(root, rawPath);
|
||||
const st = statSync(file); const kind = fileType(file);
|
||||
const base = { kind, name: basename(file), path: file, size: st.size, url: pathToFileURL(file).href };
|
||||
const base = { kind, name: basename(file), path: file, size: st.size, mtime: Math.round(st.mtimeMs), url: pathToFileURL(file).href };
|
||||
const raw = `/api/files/raw?sessionId=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(file)}`;
|
||||
if (kind === 'image' || kind === 'pdf') return { ...base, rawUrl: raw, mime: mimeOf(file) };
|
||||
if (kind === 'other') return { ...base, note: 'This kind of file has no preview. Open it in its own app.' };
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* The chats, saved by the host as files (owner decision 11-04: the browser's 5 MB storage limit is gone).
|
||||
*
|
||||
* <data folder>/chats/index.json { v: 1, chats: [{ id, rev, createdAt, updatedAt, bytes, title }] }
|
||||
* <data folder>/chats/<id>.json { "v": 1, "rev": N, "savedAt": ISO, "session": { ...the chat exactly as the page sent it... } }
|
||||
* <data folder>/chats/conflicts/ the version a later save displaced when two windows saved the same chat from an older copy
|
||||
*
|
||||
* - One file per chat. A save writes a temporary file next to it, flushes it and renames it over the chat: a crash or a full disk
|
||||
* in the middle leaves the older file whole. Temporary files left by a crash are removed at the next start.
|
||||
* - `rev` counts the saves of a chat. A save that names a `baseRev` older than the stored one (a second window saved first) is still
|
||||
* written, but the version it displaces is kept in conflicts/ first, so nothing is lost.
|
||||
* - The index is a list for fast start. If it is missing or does not match the files it is rebuilt from the files; a file that cannot
|
||||
* be read is reported and left exactly as it is.
|
||||
* - Nothing is cut: the chat is stored byte for byte as received (a text splice, no re-serialising).
|
||||
* - Owner-only: the folder is created private (POSIX 0700 and files 0600; on Windows the folder gets a protected ACL that its files
|
||||
* inherit), the same level as the rest of the profile.
|
||||
*/
|
||||
import { mkdirSync, readdirSync, readFileSync, statSync, unlinkSync } from 'node:fs';
|
||||
import { open, readFile, rename, unlink, mkdir, copyFile, readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export const CHAT_FORMAT = 1;
|
||||
const ID = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
|
||||
const RESERVED = /^(con|prn|aux|nul|com\d|lpt\d|index|import|conflicts)(\.|$)/i;
|
||||
const KEEP_CONFLICTS = 5;
|
||||
|
||||
export class ChatStoreError extends Error {
|
||||
constructor(message, status, code) { super(message); this.status = status; this.code = code; }
|
||||
}
|
||||
|
||||
/** The disk operations, replaceable in tests to make a disk full, a permission failure or a crash in the middle of a write. */
|
||||
export const realIo = {
|
||||
async writeFile(path, text) {
|
||||
const handle = await open(path, 'w', 0o600);
|
||||
try { await handle.writeFile(text, 'utf8'); await handle.sync(); } finally { await handle.close(); }
|
||||
},
|
||||
rename, unlink, copyFile, readFile, readdir, mkdir,
|
||||
};
|
||||
|
||||
export const validChatId = id => typeof id === 'string' && ID.test(id) && !RESERVED.test(id);
|
||||
|
||||
/** Map a failed disk operation to what the page is told: a full disk and a refused write are different words for the same "not saved". */
|
||||
export function storeFailure(error) {
|
||||
if (error instanceof ChatStoreError) return error;
|
||||
const code = error?.code;
|
||||
if (code === 'ENOSPC' || code === 'EDQUOT' || code === 'EFBIG') return new ChatStoreError('The disk is full.', 507, 'disk-full');
|
||||
if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') return new ChatStoreError('Strata may not write its chat folder.', 403, 'permission');
|
||||
return new ChatStoreError(error?.message ?? 'The chat could not be written.', 500, code ?? 'write-failed');
|
||||
}
|
||||
|
||||
export function createChatStore({ dir, io = realIo, privateDir = () => {}, now = () => new Date() }) {
|
||||
const root = join(dir, 'chats');
|
||||
const conflictsDir = join(root, 'conflicts');
|
||||
mkdirSync(root, { recursive: true, mode: 0o700 });
|
||||
try { privateDir(root); } catch { /* the folder is inside the profile; the tighter ACL is best effort */ }
|
||||
let tmpCount = 0;
|
||||
let index = new Map(); // id -> entry
|
||||
const damaged = new Set();
|
||||
|
||||
const fileOf = id => join(root, `${id}.json`);
|
||||
const bytesOf = text => Buffer.byteLength(text, 'utf8');
|
||||
const entryOf = (id, rev, session, bytes) => ({ id, rev, createdAt: session?.createdAt ?? null, updatedAt: session?.updatedAt ?? null, bytes, title: typeof session?.title === 'string' ? session.title.slice(0, 200) : '' });
|
||||
|
||||
// A chain, so saves of one host never overlap: the file, the index and the conflicts folder change one save at a time.
|
||||
let chain = Promise.resolve();
|
||||
const queue = work => { const run = chain.then(work, work); chain = run.catch(() => {}); return run; };
|
||||
|
||||
function loadSync() {
|
||||
// Temporary files from a write that never finished.
|
||||
let names = [];
|
||||
try { names = readdirSync(root); } catch { /* empty */ }
|
||||
for (const name of names) if (/\.tmp-\d+-\d+$/.test(name)) { try { unlinkSync(join(root, name)); } catch { /* left */ } }
|
||||
const files = names.filter(n => n.endsWith('.json') && n !== 'index.json' && !/\.tmp-/.test(n)).map(n => n.slice(0, -5)).filter(validChatId);
|
||||
let saved = null;
|
||||
try { const parsed = JSON.parse(readFileSync(join(root, 'index.json'), 'utf8')); if (parsed?.v === CHAT_FORMAT && Array.isArray(parsed.chats)) saved = parsed.chats; } catch { /* rebuilt below */ }
|
||||
const map = new Map();
|
||||
if (saved) for (const e of saved) if (e && validChatId(e.id) && files.includes(e.id)) map.set(e.id, e);
|
||||
// Files the index does not know (a crash before the index was written, an index lost): read from the files.
|
||||
for (const id of files) {
|
||||
if (map.has(id)) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(fileOf(id), 'utf8'));
|
||||
if (parsed?.v !== CHAT_FORMAT || !parsed.session || parsed.session.id !== id) throw new Error('not a chat file');
|
||||
map.set(id, entryOf(id, Number(parsed.rev) || 1, parsed.session, statSync(fileOf(id)).size));
|
||||
} catch { damaged.add(id); }
|
||||
}
|
||||
index = map;
|
||||
}
|
||||
loadSync();
|
||||
|
||||
async function writeAtomic(path, text) {
|
||||
const temp = `${path}.tmp-${process.pid}-${++tmpCount}`;
|
||||
try {
|
||||
await io.writeFile(temp, text);
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try { await io.rename(temp, path); break; }
|
||||
catch (e) { if (attempt >= 5 || !['EPERM', 'EBUSY', 'EACCES'].includes(e?.code)) throw e; await new Promise(r => setTimeout(r, 40 * (attempt + 1))); } // an antivirus scan holds the file for a moment on Windows
|
||||
}
|
||||
} catch (e) {
|
||||
try { await io.unlink(temp); } catch { /* nothing to remove */ }
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeIndex() {
|
||||
const list = [...index.values()];
|
||||
try { await writeAtomic(join(root, 'index.json'), JSON.stringify({ v: CHAT_FORMAT, savedAt: now().toISOString(), chats: list })); }
|
||||
catch { /* only a list: it is rebuilt from the files when it does not match */ }
|
||||
}
|
||||
|
||||
async function keepDisplaced(id, rev) {
|
||||
try {
|
||||
await io.mkdir(conflictsDir, { recursive: true });
|
||||
await io.copyFile(fileOf(id), join(conflictsDir, `${id}.rev${rev}.json`));
|
||||
const mine = (await io.readdir(conflictsDir)).filter(n => n.startsWith(`${id}.rev`)).sort((a, b) => Number(a.match(/rev(\d+)/)?.[1]) - Number(b.match(/rev(\d+)/)?.[1]));
|
||||
for (const old of mine.slice(0, Math.max(0, mine.length - KEEP_CONFLICTS))) { try { await io.unlink(join(conflictsDir, old)); } catch { /* left */ } }
|
||||
} catch (e) {
|
||||
throw storeFailure(e); // not saved rather than saved over the only copy of the other window's work
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
/** The chats, newest first. */
|
||||
list() {
|
||||
const chats = [...index.values()].sort((a, b) => String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? '')) || a.id.localeCompare(b.id));
|
||||
return { v: CHAT_FORMAT, chats, damaged: [...damaged] };
|
||||
},
|
||||
has: id => index.has(id),
|
||||
rev: id => index.get(id)?.rev ?? 0,
|
||||
/** The stored file text of a chat ({"v","rev","savedAt","session"}), or null. */
|
||||
async read(id) {
|
||||
if (!validChatId(id) || !index.has(id)) return null;
|
||||
try { return await io.readFile(fileOf(id), 'utf8'); }
|
||||
catch (e) { if (e?.code === 'ENOENT') { index.delete(id); return null; } throw storeFailure(e); }
|
||||
},
|
||||
/**
|
||||
* Save one chat. `sessionText` is the chat's JSON as the page wrote it. Resolves { id, rev, bytes, conflict }.
|
||||
* Rejects with a ChatStoreError (status 400 for a chat that is not a chat, 507 disk full, 403 refused, 500 other): nothing was changed.
|
||||
*/
|
||||
save(id, sessionText, { baseRev = null } = {}) {
|
||||
if (!validChatId(id)) return Promise.reject(new ChatStoreError('That is not a chat id.', 400, 'bad-id'));
|
||||
let session;
|
||||
try { session = JSON.parse(sessionText); } catch { return Promise.reject(new ChatStoreError('The chat must be JSON.', 400, 'bad-json')); }
|
||||
if (!session || typeof session !== 'object' || Array.isArray(session) || session.id !== id) return Promise.reject(new ChatStoreError('The chat does not match its id.', 400, 'bad-chat'));
|
||||
const body = sessionText.trim();
|
||||
return queue(async () => {
|
||||
const current = index.get(id);
|
||||
const rev = (current?.rev ?? 0) + 1;
|
||||
let conflict = false;
|
||||
if (current && baseRev !== null && Number(baseRev) !== current.rev) { conflict = true; await keepDisplaced(id, current.rev); }
|
||||
const text = `{"v":${CHAT_FORMAT},"rev":${rev},"savedAt":${JSON.stringify(now().toISOString())},"session":${body}}`;
|
||||
try { await writeAtomic(fileOf(id), text); } catch (e) { throw storeFailure(e); }
|
||||
const bytes = bytesOf(text);
|
||||
index.set(id, entryOf(id, rev, session, bytes));
|
||||
damaged.delete(id);
|
||||
await writeIndex();
|
||||
return { id, rev, bytes, conflict };
|
||||
});
|
||||
},
|
||||
/** Delete a chat (the person deleted it). A chat that is not there is already gone. */
|
||||
remove(id) {
|
||||
if (!validChatId(id)) return Promise.reject(new ChatStoreError('That is not a chat id.', 400, 'bad-id'));
|
||||
return queue(async () => {
|
||||
try { await io.unlink(fileOf(id)); } catch (e) { if (e?.code !== 'ENOENT') throw storeFailure(e); }
|
||||
const had = index.delete(id);
|
||||
await writeIndex();
|
||||
return { id, removed: had };
|
||||
});
|
||||
},
|
||||
/** Chats from the browser's old storage: every one the host does not have yet is saved; the ones it has are left alone. */
|
||||
async importMany(sessions) {
|
||||
const imported = [], skipped = [], failed = [];
|
||||
for (const session of Array.isArray(sessions) ? sessions : []) {
|
||||
const id = session?.id;
|
||||
if (!validChatId(id)) { failed.push({ id: String(id ?? ''), error: 'not a chat id' }); continue; }
|
||||
if (index.has(id)) { skipped.push(id); continue; }
|
||||
try { await this.save(id, JSON.stringify(session)); imported.push(id); }
|
||||
catch (e) { failed.push({ id, error: e.message, code: e.code }); if (e.code === 'disk-full') break; }
|
||||
}
|
||||
return { imported, skipped, failed };
|
||||
},
|
||||
/** Wait until every save queued so far is on disk (tests, shutdown). */
|
||||
idle: () => chain,
|
||||
};
|
||||
}
|
||||
@@ -288,21 +288,32 @@ export function createControl({ control = findControl(), env = process.env, run
|
||||
throw Object.assign(failure(result, 'The partial copies could not be removed.'), { code: result.json?.error?.code ?? 'leave_failed', details: result.json?.error?.details ?? '' });
|
||||
},
|
||||
/**
|
||||
* The DFlash2 drafter for a model's run config and the drafting choice (`strata drafting plan`): { needed, gb, line, note, drafter }. Control decides
|
||||
* (the table of strata_control/drafting.py); the app never reads it. Nothing is downloaded.
|
||||
* The drafter for a model's run config and the drafting choice (`strata drafting plan`): { needed, tuned_needed, gb, drafter_gb, tuned, file, line, note, drafter, split, mtp_needed, mtp_gb }. `model` (with `family`) names a model that has no run config yet. Control decides
|
||||
* (the table of strata_control/drafting.py and the all-cards split engine's rule, strata_control/split_engine.py: the same answer as the Terminal's); the app never reads
|
||||
* it. Nothing is downloaded. `noSplit` asks as if the split engine will not take the start (only after the draft layer for the split could not be 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] : []), '--no-split', '--output', 'json'], { timeoutMs: 30_000 });
|
||||
async draftingPlan({ configPath = null, mode = null, family = null, model = null, dataDir = null, noSplit = false, unloading = false } = {}) {
|
||||
const result = await call(['drafting', 'plan', ...(configPath ? ['--config', configPath] : []), ...(mode ? ['--mode', mode] : []), ...(family ? ['--family', family] : []), ...(model && !configPath ? ['--model', model] : []), ...(dataDir ? ['--data-dir', dataDir] : []), ...(noSplit ? ['--no-split'] : []), ...(unloading ? ['--engines-unloading'] : []), '--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] : []), '--no-split', '--if-needed', '--output', 'jsonl'];
|
||||
async draftingFetch({ configPath, mode = null, noSplit = false, unloading = false, throttle = false, onEvent = null, signal = null } = {}) {
|
||||
const args = ['drafting', 'fetch', '--config', configPath, ...(mode ? ['--mode', mode] : []), ...(noSplit ? ['--no-split'] : []), ...(unloading ? ['--engines-unloading'] : []), '--if-needed', ...(throttle ? ['--throttle'] : []), '--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 all-cards split engine for the run config the app is about to start (`strata split-engine apply`, the step `strata launch` does itself): Control checks the rule, the
|
||||
* engine package and the draft layer on this computer and writes the split engine's run config to `outPath` when the answer is yes. Resolves { split, line, warn, reasons, cards, written };
|
||||
* `configPath` is never changed, nothing is downloaded.
|
||||
*/
|
||||
async splitEngine({ configPath, outPath } = {}) {
|
||||
const result = await call(['split-engine', 'apply', '--config', configPath, '--out', outPath, '--output', 'json'], { timeoutMs: 60_000 });
|
||||
if (result.json?.ok) return result.json;
|
||||
throw failure(result, 'The all-cards split engine could not be checked.');
|
||||
},
|
||||
/**
|
||||
* 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.
|
||||
|
||||
+81
-10
@@ -46,12 +46,15 @@ import * as engine from './engine.mjs';
|
||||
import * as monitor from './monitor.mjs';
|
||||
import * as serving from './serving.mjs';
|
||||
import * as store from './store.mjs';
|
||||
import { createChatStore, storeFailure } from './chat-store.mjs';
|
||||
import { makePrivateDir } from './private-file.mjs';
|
||||
import * as supervisor from './supervisor.mjs';
|
||||
import * as engineInstall from './engine-install.mjs';
|
||||
import { detectBackend, currentBackend, streamChat, inferenceLine } from './inference.mjs';
|
||||
import { CONTEXT_TOKENS, VARIANTS, localModels, variant } from './catalog.mjs';
|
||||
import { browseLocalModels, inspectLocalModel } from './local-models.mjs';
|
||||
import { EventHub, error, json, readBody, readRaw, uid } from './util.mjs';
|
||||
import { keepStreamAlive } from './stream-beat.mjs';
|
||||
import { EventHub, badRequest, error, json, readBody, readRaw, requestUrl, sameSecret, uid } from './util.mjs';
|
||||
import { createUploads } from './chat-uploads.mjs';
|
||||
import { createNativeDrops } from './native-drop.mjs';
|
||||
import { createVisionProbe, effectiveVision, readsPicturesByDefault } from './vision-probe.mjs';
|
||||
@@ -118,6 +121,8 @@ const WORKSPACE_NAME = process.env.STRATA_WORKSPACE ? basename(WORKSPACE) : 'Str
|
||||
const insideApp = path => { const rel = relative(ROOT, resolve(path)); return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); };
|
||||
|
||||
const hub = new EventHub();
|
||||
/** The chats, as files in the profile's data folder (chat-store.mjs). */
|
||||
const chatStore = createChatStore({ dir: store.DATA_DIR, privateDir: makePrivateDir });
|
||||
const modelProviders = createModelProviders({ directory: store.DATA_DIR, knownThinks: (connection, modelId) => visionProbe.knownThinks(connection, modelId), changed: value => hub.send('providers', withPictureAnswers(value)) });
|
||||
const imageModels = createImageModels({ dataDir: store.DATA_DIR, modelsDir: () => store.MODELS_DIR, real: models.REAL, changed: value => hub.send('image-models', value) });
|
||||
/* The installed app ships Strata Control (python + strata_control) next to the host; a source checkout has none and keeps its older paths. */
|
||||
@@ -943,7 +948,9 @@ const taskTimer = setInterval(() => tasks.tick(), 15000);
|
||||
taskTimer.unref();
|
||||
|
||||
// The share and tunnel listeners take a free port (the next ones above the host's own) instead of a fixed PORT+1 / PORT+2 that Docker, WSL or Hyper-V may hold or reserve.
|
||||
const sharePort = await findFreePort(Number(process.env.STRATA_SHARE_PORT ?? PORT + 1), '0.0.0.0', { skip: [PORT] });
|
||||
// The probe listens on the loopback address only: a probe on 0.0.0.0 made Windows Defender Firewall ask "Allow Node.js to communicate on these networks?" on the
|
||||
// first start of every new install path (QA 0213). Reserved or taken ports are caught on any address; the share listener itself binds 0.0.0.0 only when the person turns sharing on.
|
||||
const sharePort = await findFreePort(Number(process.env.STRATA_SHARE_PORT ?? PORT + 1), '127.0.0.1', { skip: [PORT] });
|
||||
const localShare = createLocalShare({ handle: openai, hasKey: () => !!store.get().serving?.apiKey, port: sharePort, changed: () => serving.setSharing(localShare.state()) });
|
||||
const tunnelPort = await findFreePort(Number(process.env.STRATA_TUNNEL_PORT ?? PORT + 2), '127.0.0.1', { skip: [PORT, sharePort] });
|
||||
const tunnelOrigin = createLocalShare({ handle: openai, hasKey: () => !!store.get().serving?.apiKey, host: '127.0.0.1', port: tunnelPort });
|
||||
@@ -954,7 +961,8 @@ await cloudflare.inspect();
|
||||
/* --------------------------------------------------------------- routing --- */
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
||||
const url = requestUrl(req);
|
||||
if (!url) return badRequest(res); // #4397: a bad Host or request target is a 400, never an unhandled rejection
|
||||
const path = url.pathname;
|
||||
const method = req.method ?? 'GET';
|
||||
|
||||
@@ -985,12 +993,55 @@ const server = http.createServer(async (req, res) => {
|
||||
return await staticFile(req, res, path);
|
||||
} catch (err) {
|
||||
console.error('[strata]', err);
|
||||
if (res.headersSent) { res.end(); return; } // a stream already began: it ends here (a second writeHead would throw out of this handler and end the process)
|
||||
error(res, 500, err.message ?? 'Internal error');
|
||||
}
|
||||
});
|
||||
|
||||
async function api(req, res, path, method, url) {
|
||||
if(path==='/api/fonts'&&method==='GET')return json(res,200,await systemFonts());
|
||||
// --- chats: one file each in the data folder -----------------------------
|
||||
// Not held back by a model folder move: saving a chat has nothing to do with the models. A page must name itself (x-strata-client), which
|
||||
// a web page from another site cannot do without the browser asking the host first.
|
||||
const chatsRoute = /^\/api\/chats(?:\/([^/]+))?$/.exec(path);
|
||||
if (chatsRoute) {
|
||||
if (!req.headers['x-strata-client']) return error(res, 403, 'Not allowed.');
|
||||
let id = null;
|
||||
try { id = chatsRoute[1] ? decodeURIComponent(chatsRoute[1]) : null; } catch { return error(res, 400, 'That is not a chat id.', { code: 'bad-id' }); }
|
||||
const from = typeof req.headers['x-strata-window'] === 'string' ? req.headers['x-strata-window'].slice(0, 64) : null;
|
||||
try {
|
||||
if (!id && method === 'GET') return json(res, 200, chatStore.list());
|
||||
if (id === 'import' && method === 'POST') {
|
||||
const body = JSON.parse((await readRaw(req, 512 * 1024 * 1024)).toString('utf8') || '{}');
|
||||
const result = await chatStore.importMany(body.sessions);
|
||||
for (const imported of result.imported) hub.send('chats', { id: imported, rev: chatStore.rev(imported), from });
|
||||
return json(res, 200, result);
|
||||
}
|
||||
if (id && method === 'GET') {
|
||||
const text = await chatStore.read(id);
|
||||
if (text === null) return error(res, 404, 'No such chat.', { code: 'not-found' });
|
||||
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(text), 'cache-control': 'no-store' });
|
||||
res.end(text);
|
||||
return undefined;
|
||||
}
|
||||
if (id && method === 'PUT') {
|
||||
const text = (await readRaw(req, 256 * 1024 * 1024)).toString('utf8');
|
||||
const base = req.headers['x-strata-base-rev'];
|
||||
const saved = await chatStore.save(id, text, { baseRev: typeof base === 'string' && /^\d+$/.test(base) ? Number(base) : null });
|
||||
hub.send('chats', { id, rev: saved.rev, from });
|
||||
return json(res, 200, saved);
|
||||
}
|
||||
if (id && method === 'DELETE') {
|
||||
const removed = await chatStore.remove(id);
|
||||
hub.send('chats', { id, deleted: true, from });
|
||||
return json(res, 200, removed);
|
||||
}
|
||||
} catch (e) {
|
||||
const failure = e instanceof SyntaxError ? { status: 400, message: 'The request must be JSON.', code: 'bad-json' } : storeFailure(e);
|
||||
return error(res, failure.status, failure.message, { code: failure.code });
|
||||
}
|
||||
return error(res, 405, 'Method not allowed.');
|
||||
}
|
||||
if (storageChanging && !['GET', 'OPTIONS'].includes(method)) return error(res, 409, 'Wait for the model folder check to finish.');
|
||||
// --- events -------------------------------------------------------------
|
||||
if (path === '/api/events') return hub.add(res);
|
||||
@@ -1047,7 +1098,7 @@ async function api(req, res, path, method, url) {
|
||||
if(nativeInstance&&method==='POST')return json(res,200,nativeInstance[2]==='unload'?await native.unload(nativeInstance[1]):native.setDefault(nativeInstance[1]));
|
||||
const nativeSettings=/^\/api\/native\/models\/([a-z0-9_.-]+)\/settings$/.exec(path);
|
||||
/* Expert mode shows what will actually run: the saved tuning's values come with the settings (null when none applies or unreadable). */
|
||||
if(nativeSettings&&method==='GET'){const settings=native.settings(nativeSettings[1]);let tuned=null;try{const t=await native.tuneEffective({model:nativeSettings[1],resourceProfile:url.searchParams.get('resourceProfile')||undefined,vision:url.searchParams.get('vision')||undefined});tuned=t.applied?t:null;}catch{tuned=null;}return json(res,200,{...settings,tuned});}
|
||||
if(nativeSettings&&method==='GET'){const settings=native.settings(nativeSettings[1]);let tuned=null;try{const t=await native.tuneEffective({model:nativeSettings[1],resourceProfile:url.searchParams.get('resourceProfile')||undefined,vision:url.searchParams.get('vision')||undefined});tuned=t.applied?t:null;}catch{tuned=null;}return json(res,200,{...settings,tuned,hints:await native.settingsHints?.(nativeSettings[1])});}
|
||||
if(nativeSettings&&method==='PATCH')return json(res,200,native.save(nativeSettings[1],await readBody(req)));
|
||||
const nativeSettingsFile=/^\/api\/native\/models\/([a-z0-9_.-]+)\/settings-file$/.exec(path);
|
||||
if(nativeSettingsFile&&method==='GET')return json(res,200,native.settingsFile(nativeSettingsFile[1]));
|
||||
@@ -1195,9 +1246,7 @@ async function api(req, res, path, method, url) {
|
||||
}
|
||||
// One revision attempt for a job this host started: the app's assistant works inside the candidate folder.
|
||||
if (path === '/api/internal/timeskip-revise' && method === 'POST') {
|
||||
const given = Buffer.from(String(req.headers['x-strata-internal'] ?? ''));
|
||||
const want = Buffer.from(INTERNAL_SECRET);
|
||||
if (given.length !== want.length || !timingSafeEqual(given, want)) return error(res, 403, 'Not allowed.');
|
||||
if (!sameSecret(req.headers['x-strata-internal'], INTERNAL_SECRET)) return error(res, 403, 'Not allowed.');
|
||||
const body = await readBody(req);
|
||||
const cwd = String(body.cwd ?? '');
|
||||
// Only the job's own candidate folders may be worked in.
|
||||
@@ -1307,8 +1356,7 @@ async function api(req, res, path, method, url) {
|
||||
// The shell (and nothing else) holds the secret that lets it end the host or tell it which window is in use.
|
||||
const fromShell = () => {
|
||||
const token = process.env.STRATA_SHUTDOWN_TOKEN ?? '';
|
||||
const given = String(req.headers['x-strata-shutdown'] ?? '');
|
||||
return token.length >= 32 && given.length === token.length && timingSafeEqual(Buffer.from(given), Buffer.from(token));
|
||||
return token.length >= 32 && sameSecret(req.headers['x-strata-shutdown'], token);
|
||||
};
|
||||
// Which window the person is using: the page shows "Quit Strata" in browser mode only.
|
||||
if (path === '/api/app/mode' && method === 'GET') return json(res, 200, appUi.state());
|
||||
@@ -1614,7 +1662,7 @@ async function api(req, res, path, method, url) {
|
||||
if (cancelMatch && method === 'POST') return json(res, 200, serving.cancel(cancelMatch[1]));
|
||||
|
||||
// --- monitor ------------------------------------------------------------
|
||||
if (path === '/api/monitor') { monitor.watch(); return json(res, 200, monitor.snapshot()); }
|
||||
if (path === '/api/monitor') { monitor.watch(); if (url.searchParams.get('drives') === '1') monitor.watchDrives(); return json(res, 200, monitor.snapshot()); }
|
||||
|
||||
// --- skills / workspace -------------------------------------------------
|
||||
if (path === '/api/skills' && method === 'GET') {
|
||||
@@ -1989,6 +2037,28 @@ async function api(req, res, path, method, url) {
|
||||
if (typeof b.sessionId !== 'string' || !['manual', 'plan', 'bypass'].includes(b.mode)) return error(res, 400, 'sessionId and mode (manual, plan or bypass) are required');
|
||||
try { return json(res, 200, await agent.setMode(b.sessionId, b.mode)); } catch (e) { return error(res, e.code === 'mode-unavailable' ? 409 : 502, e.message); }
|
||||
}
|
||||
/* A page that has just loaded asks which chats have a turn running (a reload does not stop a turn), and takes one over again: the catch-up
|
||||
of what was said while no page was there, then the live frames, as on /api/chat/stream. A turn that ended while the page was away
|
||||
(the page names it by requestId) is sent once, whole. 404 when there is nothing to take over. */
|
||||
if (path === '/api/chat/active' && method === 'GET') return json(res, 200, { turns: agent.runningTurns() });
|
||||
if (path === '/api/chat/attach' && method === 'POST') {
|
||||
const b = await readBody(req);
|
||||
if (typeof b.sessionId !== 'string' || !b.sessionId) return error(res, 400, 'sessionId is required');
|
||||
const controller = new AbortController();
|
||||
res.on('close', () => { if (!res.writableEnded) controller.abort(); });
|
||||
let started = false;
|
||||
const write = frame => {
|
||||
if (res.writableEnded || res.destroyed) return;
|
||||
if (!started) { started = true; res.writeHead(200, { 'content-type': 'application/x-ndjson; charset=utf-8', 'cache-control': 'no-cache, no-transform', connection: 'keep-alive' }); keepStreamAlive(res); }
|
||||
res.write(`${JSON.stringify(frame)}
|
||||
`);
|
||||
};
|
||||
const attached = agent.attach({ chatId: b.sessionId, requestId: typeof b.requestId === 'string' ? b.requestId : null, onFrame: write, signal: controller.signal });
|
||||
if (!attached) return error(res, 404, 'There is no turn to take over.', { code: 'no-turn' });
|
||||
await Promise.race([attached.ended, new Promise(r => controller.signal.addEventListener('abort', r, { once: true }))]);
|
||||
if (!res.writableEnded) res.end();
|
||||
return undefined;
|
||||
}
|
||||
/* Stop one chat's turn. Resolves once the turn has really ended, not when the cancel was admitted. */
|
||||
if (path === '/api/chat/stop' && method === 'POST') {
|
||||
const b = await readBody(req);
|
||||
@@ -2018,6 +2088,7 @@ async function api(req, res, path, method, url) {
|
||||
'cache-control': 'no-cache, no-transform',
|
||||
connection: 'keep-alive',
|
||||
});
|
||||
keepStreamAlive(res); // #4878: a quiet turn stays open; the timer ends with the response
|
||||
const controller = new AbortController();
|
||||
res.on('close', () => { if (!res.writableEnded) controller.abort(); });
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import http from 'node:http';
|
||||
import os from 'node:os';
|
||||
import { apiPath } from './api-paths.mjs';
|
||||
import { listenProblem } from './ports.mjs';
|
||||
import { badRequest, requestUrl } from './util.mjs';
|
||||
/** Sharing without a key is allowed (owner decision, 0.2.2): the person is told once, in plain words, and the app keeps offering to make a key. */
|
||||
export const NO_KEY_WARNING = 'No API key is set, so any device on this network can use your model. Create an API key to protect it.';
|
||||
export function createLocalShare({ handle, hasKey, changed = () => {}, host = '0.0.0.0', port = 8792 }) {
|
||||
@@ -13,7 +14,9 @@ export function createLocalShare({ handle, hasKey, changed = () => {}, host = '0
|
||||
if (active) return state();
|
||||
if (listener) throw new Error('Sharing is already starting.');
|
||||
listener = http.createServer(async (req, res) => {
|
||||
const path = apiPath(new URL(req.url ?? '/', 'http://localhost').pathname);
|
||||
const url = requestUrl({ url: req.url, headers: {} }); // the Host header is not read here; a request target that is not a path is a 400 (#4397)
|
||||
if (!url) return badRequest(res);
|
||||
const path = apiPath(url.pathname);
|
||||
const method = req.method ?? 'GET';
|
||||
// #3208: with an API key set, a browser page of any site can call the shared API - the key decides, not the
|
||||
// origin. The browser's preflight (OPTIONS) never carries the key (the spec says so), so it is answered here,
|
||||
|
||||
@@ -62,7 +62,12 @@ let ticker = null;
|
||||
let memoryWarningShown = false;
|
||||
let sensorsPending = false, sensorsAt = 0;
|
||||
/* The Monitor screen asks (GET /api/monitor) every few seconds while it is open; a model that is loaded counts as live (#4066). */
|
||||
let watchedUntil = 0, liveProbe = () => false;
|
||||
let watchedUntil = 0, drivesUntil = 0, liveProbe = () => false;
|
||||
const hostStartedAt = Date.now();
|
||||
/** The drive-temperature reader is not started in the first minute of the host: the app's own start-up traffic is not a person looking at the Monitor (#4661). */
|
||||
export const DRIVES_AFTER_MS = 60000;
|
||||
/** Only the Monitor screen's own ask (GET /api/monitor?drives=1) wants drive temperatures; the start-up fetch and the Models page do not show them. */
|
||||
export function watchDrives() { drivesUntil = Date.now() + WATCH_MS; }
|
||||
export function watch() {
|
||||
const wasIdle = Date.now() >= watchedUntil;
|
||||
watchedUntil = Date.now() + WATCH_MS;
|
||||
@@ -127,14 +132,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'&&Date.now()<watchedUntil?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()<drivesUntil&&Date.now()-hostStartedAt>DRIVES_AFTER_MS?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();
|
||||
if(Date.now()>drivesUntil+120000)stopTemperatures();
|
||||
addEnergy(hardwareSnapshot().powerW,Date.now());
|
||||
const hw = state.hardware;
|
||||
const busy = state.phase === 'generating' || state.phase === 'reading' || state.phase === 'thinking';
|
||||
|
||||
@@ -7,7 +7,7 @@ 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, resolveDrafter, validateNativeSettings, validateMcpServers, publicMcpServers, publicNativeEnv, restoreNativeEnv, speedSwitchRows } from './native-settings.mjs';
|
||||
import { draftingHint, 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';
|
||||
@@ -27,6 +27,8 @@ import { parseSaved as parseRoles, mirror as mirrorRoles } from './gpu-roles.mjs
|
||||
import { configContextLimit } from './dsv41-context.mjs';
|
||||
|
||||
const ROOT=resolve(dirname(fileURLToPath(import.meta.url)),'..');
|
||||
/* What the load says when the server had to start the normal layer split because the all-cards split engine would not come up (the engine's own reason stays in its log). */
|
||||
const SPLIT_FELL_BACK='The all-cards split engine could not start, so the normal layer split runs.';
|
||||
const safeId=value=>typeof value==='string'&&/^[a-z0-9](?:[a-z0-9_-]|\.(?!\.)){0,79}$/i.test(value); /* a dot inside the name: deepseek-v4.1-flash-ist-das-2.00bpw */
|
||||
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;}}
|
||||
@@ -38,6 +40,8 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
|
||||
const settingsPath=join(directory,'native-host.json');
|
||||
let preferences=readJson(settingsPath,{repo:defaultRepo});
|
||||
let child=null,seq=0,buffer='',pending=new Map(),definitions=[],refreshing=null,lastRead=0,connecting=null;
|
||||
/* QAGUI 0213: until Control has answered once, the state says it is still being asked (`checking`), not that nothing is installed. A failed read during Control's start is read again after a few seconds instead of standing for 45 s; after READ_TRIES failures the answer is final and the error shows. */
|
||||
let everRead=false,failedReads=0;const READ_TRIES=4,RETRY_MS=3000;
|
||||
let snapshot={available:false,repo:preferences.repo,summary:'Connect a local Strata installation.',catalog:null,installed:null,hardware:null,error:null};
|
||||
const instances=new Map(),processes=new Map(),jobs=new Map(),logs=[];
|
||||
const savedJobs=readJson(join(directory,'native-jobs.json'),[]);/* An app update ends the app on purpose: the installer replaces it and starts it again. The job that asked for it was saved as running together with the app version it started from (appUpdateFrom); a start that finds a different version knows the update finished, instead of calling it an interrupted operation. */
|
||||
@@ -71,7 +75,7 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
|
||||
/* 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,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}};}
|
||||
function state(){const checking=!everRead&&failedReads<READ_TRIES;const folder=preferences.downloadDirectory??(modelsDirectory?modelsDirectory():snapshot.installed?.data_folder),space=folder?diskSpace(folder):null;return {...snapshot,checking,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)}));}
|
||||
@@ -148,7 +152,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=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;
|
||||
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();everRead=true;failedReads=0;if(status.install?.running&&![...jobs.values()].some(j=>j.state==='running'))begin('install',j=>watchInstall(j,status.install));}catch(error){failedReads++;snapshot={...snapshot,available:false,error:error.message};lastRead=!everRead&&failedReads<READ_TRIES?Date.now()-45000+RETRY_MS: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();}
|
||||
@@ -160,11 +164,13 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
|
||||
/* A mistake in the file holds the start back with the line, instead of an engine that dies on it. Warnings (an unknown key) never do. */
|
||||
function checkedSettingsConfig(text){const check=checkAndParseRunConfig(text);if(!check.ok)throw new Error(`The settings file for this model has a mistake, so the model was not started. ${describeIssues(check.issues,'error').join(' ')}`);return check.config;}
|
||||
function settings(model){const {entry,config}=record(model);const values=Object.fromEntries(NATIVE_FIELDS.map(f=>[f.id,config[f.id]??null]));if(typeof values.aliases==='string')values.aliases=values.aliases.split(',').map(v=>v.trim()).filter(Boolean);values.sampling=config.sampling??{};values.args=config.args??[];values.env=publicNativeEnv(config.env??{});return {model,values,mcp:publicMcpServers(config.mcp_servers??config.mcpServers??{}),restartNeeded:true,capabilities:{dsExperts:ownEngine(config)&&dsExpertsSupported(config)},speedSwitches:speedSwitchRows(config.env??{},{deepseek:ownEngine(config),arch:config.arch??null,backend:config.backend??entry?.backend??snapshot.installed?.engine?.backend??null,gpus:snapshot.hardware?.gpus??[]})};}
|
||||
/* The Drafting hint of this model's settings: the size of the drafter DFlash2 would fetch for it (Control's plan with the choice DFlash2); the generic hint when Control does not say. */
|
||||
async function settingsHints(model){try{if(!packaged||typeof control.draftingPlan!=='function')return {};const {path}=recordFile(model),d=await control.draftingPlan({configPath:path,mode:'dflash2'});return Number(d?.drafter_gb)>0?{drafting:draftingHint(d.drafter_gb)}:{};}catch{return {};}}
|
||||
function save(model,{values,mcp}){if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish the current operation before saving native settings.');const {path,config}=record(model);if(busy(requests().filter(r=>instances.get(r.instanceId)?.model===model)))throw new Error('Finish this model’s requests before saving its settings.');const patch=validateNativeSettings(values??{}),next={...config};if(patch.ds_experts!=null&&!dsExpertsSupported(config))throw new Error('The Speed choice needs the newer DeepSeek engine. Update the engine in Settings, then choose it.');for(const field of nativeFields())if(field.supported===false&&patch[field.id]!=null&&JSON.stringify(patch[field.id])!==JSON.stringify(config[field.id]))throw new Error(`${field.label} needs a newer native Strata installation. Update it in Settings → About.`);for(const [key,value] of Object.entries(patch)){if(key==='env')next.env=restoreNativeEnv(value,config.env??{});else if(value===null)delete next[key];else next[key]=value;}if('vram_reserve_mib' in patch){const base=captureUserArgs(next);const mine=setUserFlag(base,'--vram-reserve-mib',patch.vram_reserve_mib);if(mine.length)next[USER_KEY]=mine;else delete next[USER_KEY];next.args=mine.length&&patch.vram_reserve_mib!==null?mergeUserArgs(next.args??[],mine):setUserFlag(next.args??[],'--vram-reserve-mib',null);delete next.ui_reserve_applied;if(Array.isArray(next.args_written))next.args_written=[...next.args];}if(mcp!==undefined){next.mcp_servers=validateMcpServers(mcp,config.mcp_servers??config.mcpServers??{});delete next.mcpServers;}copyFileSync(path,path+'.bak');chmodSync(path+'.bak',0o600);const temp=path+'.ui-tmp';writeFileSync(temp,JSON.stringify(next,null,2)+'\n',{mode:0o600});renameSync(temp,path);return settings(model);}
|
||||
function previousEngine(){if(packaged)return !!snapshot.engine?.previous;try{const data=snapshot.installed?.data_dir??snapshot.installed?.data_folder;return [join(preferences.repo,'engine/.previous'),data&&join(data,'engine/.previous')].filter(Boolean).some(existsSync)&&readFileSync(join(preferences.repo,'setup.py'),'utf8').includes('--rollback-engine');}catch{return false;}}
|
||||
async function plan(args){await refresh();if(!snapshot.available)throw new Error(snapshot.error);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??(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 */}}
|
||||
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,model:args.model??null,dataDir:here?.runDataFolder??snapshot.installed?.data_folder??null,...(unloadsRunning(args)?{unloading:true}:{})});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,...(d.needed&&d.tuned===true?{tuned:true}:{})}};}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;}
|
||||
@@ -317,22 +323,47 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
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){
|
||||
/* The engines this Load is about to unload (the load below unloads every instance unless it adds one beside them): their graphics memory is given back before the new engine starts, so Control must not count it as busy memory when it asks whether the all-cards split engine fits (`--engines-unloading`; the Terminal does the same, Runner.card_probe). A model added beside the others (`multiple`) keeps their memory busy. */
|
||||
function unloadsRunning(args){return instances.size>0&&args?.multiple!==true;}
|
||||
async function draftPlan(args,configFile,{noSplit=false}={}){
|
||||
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;}
|
||||
try{return await withPlanFile(args,configFile,planFile=>control.draftingPlan({configPath:planFile,...(asked===undefined?{}:{mode:asked===null?'auto':asked}),...(noSplit?{noSplit:true}:{}),...(unloadsRunning(args)?{unloading:true}:{})}));}catch{return null;}
|
||||
}
|
||||
/* The plan is for the cards this Load uses: the screen's own card choice goes over the ones the saved settings name (the all-cards split engine needs two or more). `use` gets the file to hand to Control; a temporary one is removed after. */
|
||||
async function withPlanFile(args,configFile,use){
|
||||
let planFile=configFile,temporary=null;
|
||||
if(Array.isArray(args.gpus)&&args.gpus.length&&args.gpus.every(Number.isInteger)){try{const work=join(directory,'native-instances');mkdirSync(work,{recursive:true});temporary=join(work,'drafting-plan-'+randomUUID()+'.json');writeFileSync(temporary,JSON.stringify({...readJson(configFile),gpu:args.gpus.length===1?args.gpus[0]:args.gpus}),{mode:0o600});planFile=temporary;}catch{temporary=null;planFile=configFile;}}
|
||||
try{return await use(planFile);}
|
||||
finally{if(temporary){try{rmSync(temporary,{force:true});}catch{/* best effort */}}}
|
||||
}
|
||||
/* Auto and its drafter (the tuned one where the set has one, else the standard DFlash2 drafter: whatever Control's Auto plan says is `needed`), on every path that starts a model: a plain load, a restart of the stopped models and a tuning run fetch it as the Models screen's own Load does (Control: pinned, checked, marked, licence and notice), but they were not asked for it, so
|
||||
the 12 hour rule of the Terminal's launch applies (--throttle: a feed that had nothing a short while ago is not asked again, a feed that does not answer is not an error). A choice the person named or saved (MTP, DFlash2, off) wins: this never runs for it. */
|
||||
function autoChoice(value){return value==null||String(value).toLowerCase()==='auto';}
|
||||
async function fetchAutoDrafter(job,args,configFile,drafts,{noSplit=false,after}={}){
|
||||
if(!packaged||typeof control.draftingFetch!=='function'||drafts?.needed!==true||!autoChoice(readJson(configFile).drafting))return false;
|
||||
job.engineAbort??=new AbortController();
|
||||
try{
|
||||
const got=await withPlanFile(args,configFile,planFile=>control.draftingFetch({configPath:planFile,mode:'auto',throttle:true,...(noSplit?{noSplit:true}:{}),...(unloadsRunning(args)?{unloading:true}:{}),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'&&after){job.summary=after;job.details=undefined;emit();}
|
||||
return got?.pack!=null&&got?.skipped!==true;
|
||||
}catch(error){if(job.state==='running'){job.drafterNote=error.message;if(after){job.summary=after;job.details=undefined;}emit();}return false;}
|
||||
}
|
||||
/* The same for a path that has no Load of its own around it (a restart of stopped models, a tuning run): plan the saved settings file, then fetch. */
|
||||
async function autoDrafterFor(job,model,after){
|
||||
try{const {path}=recordFile(model),args={model,multiple:true},drafts=await draftPlan(args,path);return await fetchAutoDrafter(job,args,path,drafts,{after});}catch{return false;}
|
||||
}
|
||||
/* 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 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)}:{})});return true;}
|
||||
catch{if(job.state==='running'){job.drafterNote='The MTP drafter could not be downloaded.';emit();}return false;}/* 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 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){
|
||||
async function prepareDrafter(job,args,cfg,configFile,drafts,{noSplit=false}={}){
|
||||
const asked=args.values?.drafting;
|
||||
if(asked!==undefined)rememberSetting(configFile,'drafting',asked===null||asked==='auto'?null:asked);
|
||||
/* 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. */
|
||||
@@ -341,7 +372,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
try{
|
||||
writeFileSync(probe,JSON.stringify(cfg),{mode:0o600});
|
||||
job.engineAbort??=new AbortController();
|
||||
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();}}});
|
||||
await control.draftingFetch({configPath:probe,mode:cfg.drafting??'auto',...(noSplit?{noSplit:true}:{}),...(unloadsRunning(args)?{unloading:true}:{}),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=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 */}}
|
||||
@@ -349,7 +380,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
/* One key of a model's settings file, set or removed (the Drafting choice): written like the Settings screen writes it, to be there for the next load. */
|
||||
function rememberSetting(configFile,key,value){try{const current=readJson(configFile);if(value===null?!(key in current):current[key]===value)return;const next={...current};if(value===null)delete next[key];else next[key]=value;const temp=configFile+'.ui-tmp';writeFileSync(temp,JSON.stringify(next,null,2)+'\n',{mode:0o600});renameSync(temp,configFile);}catch{/* the load goes on with the choice for this start */}}
|
||||
/* Which drafter runs, when it is not the one that was asked for (the engine's own words, "Drafting: MTP (DFlash2 didn't fit)"): under Details of the load. */
|
||||
async function noteDrafting(job,instance,key){let note='';try{const r=await fetcher(`http://127.0.0.1:${instance.port}/metrics`,{headers:{authorization:`Bearer ${key}`},signal:AbortSignal.timeout(2000)});if(r.ok)note=String((await r.json())?.engine?.drafting_note??'');}catch{/* a convenience */}const lines=[job.drafterNote,note].filter(Boolean);if(lines.length)job.details=[job.details,...lines].filter(Boolean).join('\n');}
|
||||
async function noteDrafting(job,instance,key){let note='',fellBack=false;try{const r=await fetcher(`http://127.0.0.1:${instance.port}/metrics`,{headers:{authorization:`Bearer ${key}`},signal:AbortSignal.timeout(2000)});if(r.ok){const engine=(await r.json())?.engine;note=String(engine?.drafting_note??'');fellBack=engine?.split_fallback===true;}}catch{/* a convenience */}/* the all-cards split engine: the line of what runs (the server starts the normal layer split by itself when the split engine would not come up) */const lines=[job.drafterNote,fellBack?SPLIT_FELL_BACK:instance.splitNote,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
|
||||
@@ -420,11 +451,15 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
/* 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);}
|
||||
let drafts=await draftPlan(args,configFile),noSplit=false;
|
||||
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);
|
||||
/* The draft layer for the split could not be had (no internet, disk): the load goes on as the 0.2.10 app did, the normal layer split with the plan DFlash2's again (the Models screen's own Load fetches that drafter below). One plain line says so. */
|
||||
if(drafts?.split===true&&drafts.mtp_needed===true){noSplit=true;job.drafterNote=job.drafterNote??'The MTP drafter could not be downloaded.';job.summary='Loading the model…';emit();drafts=await draftPlan(args,configFile,{noSplit:true});}}
|
||||
/* A plain load (the Models screen's own Load carries its choice and fetches below): Auto on a set that has the tuned drafter fetches it now, so the start uses it. Not on this computer and not reachable: the MTP or the stock drafter runs as before. */
|
||||
if(args.values?.drafting===undefined&&drafts?.needed===true){const got=await fetchAutoDrafter(job,args,configFile,drafts,{noSplit,after:'Loading the model…'});if(job.state!=='running')return;if(got)drafts=await draftPlan(args,configFile,{noSplit});}
|
||||
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,drafts);if(job.state!=='running')return;
|
||||
await prepareDrafter(job,args,cfg,configFile,drafts,{noSplit});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};
|
||||
@@ -443,25 +478,43 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
/* 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;
|
||||
let proc=null,started=null,retried=null,startWait=null;
|
||||
let proc=null,started=null,retried=null,startWait=null,adopted=false;
|
||||
if(packaged){
|
||||
/* How long this start waits, and the server's own engine wait kept above it. The server ends the engine when its engine wait runs
|
||||
out (900 s, or the config's engine_ready_s), so a start that waits longer than that can never win: as in `strata launch`, the
|
||||
server gets a wait above the start's. It is only ever raised, never lowered, so a config that already names a longer one
|
||||
(setup's 3600 for DeepSeek V4.1-Flash) is left alone. */
|
||||
startWait=startWaitFor(config,entry,args.model);
|
||||
/* The split engine reads every expert into the cards before it answers, and a start that falls back to the layer split begins again: the 10 minutes the Terminal gives a launch (never above the ceiling). */
|
||||
if(!noSplit&&drafts?.split===true)startWait=Math.min(startWaitCeiling(),Math.max(startWait,600));
|
||||
if(startWait+60>Math.max(900,Number(cfg.engine_ready_s)||0))cfg.engine_ready_s=startWait+60;
|
||||
/* The engine starts under Control's owner runtime, not as our child: a saved profile per slot, then `runtime start`. */
|
||||
const used=new Set([...instances.values()].map(i=>i.profile));let slot=1;while(used.has('app-'+slot))slot++;instance.profile='app-'+slot;
|
||||
instances.set(id,instance);if(!defaultId||!args.multiple||wasDefault)defaultId=id;emit();
|
||||
if(dsNote)append(id,dsNote);
|
||||
const startWith=value=>{writeFileSync(configPath,JSON.stringify(value,null,2),{mode:0o600});return control.stop(instance.profile).catch(()=>{}).then(()=>control.saveProfile(instance.profile,configPath)).then(()=>control.start(instance.profile,{waitSeconds:startWait}));};
|
||||
/* The all-cards split engine: where the plan says it takes this start, Control turns the run config into the split engine's (the step `strata launch` does itself: the rule, the engine package and the draft layer are checked on this computer) and says so in one line. Any "no" leaves the layer split config as it is. The second try after a failed start is never split. */
|
||||
const splitStart=async value=>{
|
||||
if(noSplit||drafts?.split!==true||typeof control.splitEngine!=='function')return value;
|
||||
const input=join(work,id+'.layer.json'),output=join(work,id+'.split.json');
|
||||
try{
|
||||
writeFileSync(input,JSON.stringify(value,null,2),{mode:0o600});rmSync(output,{force:true});
|
||||
const answer=await control.splitEngine({configPath:input,outPath:output});
|
||||
if(answer.line){instance.splitNote=answer.line;append(id,answer.line);}
|
||||
if(answer.split===true&&answer.written)return JSON.parse(readFileSync(output,'utf8'));
|
||||
}catch{/* the layer split config stands */}
|
||||
finally{for(const f of [input,output]){try{rmSync(f,{force:true});}catch{/* best effort */}}}
|
||||
return value;
|
||||
};
|
||||
const startWith=async(value,split=true)=>{const run=split?await splitStart(value):value;writeFileSync(configPath,JSON.stringify(run,null,2),{mode:0o600});return control.stop(instance.profile).catch(()=>{}).then(()=>control.saveProfile(instance.profile,configPath)).then(()=>control.start(instance.profile,{waitSeconds:startWait}));};
|
||||
/* A Control whose wait ran out has NOT stopped the engine: `runtime start` answers "startup_wait_expired" (exit 8), or "runtime operation wait expired" when the owner was too busy to answer in time, while the engine is still loading (#5177 #4293). That is not a failure: Control is asked, and a start it still reports as starting (or ready) is kept and watched below instead of being stopped and marked failed. */
|
||||
const stillLoading=async e=>{if(!/startup_wait_expired|runtime operation wait expired/i.test(String(e?.message??'')+JSON.stringify(e?.output??'')))return false;if(!instance.profile||typeof control.status!=='function'||job.state!=='running')return false;try{const st=await control.status(instance.profile);return st?.state==='starting'||st?.state==='ready';}catch{return false;}};
|
||||
started=startWith(cfg);
|
||||
started.then(()=>append(id,'Strata Control reports the engine is ready.'),error=>{append(id,error.message);});
|
||||
try{await started;}catch(firstError){let error=firstError;
|
||||
/* A settings, profile or run file an elevated run made can be closed to this user: repair the access in Strata's own folders and start again (this is not the memory try below). */
|
||||
const isAccess=e=>/PermissionError|access is denied|EACCES/i.test(String(e?.message??'')+JSON.stringify(e?.output??''));
|
||||
if(isAccess(error)&&typeof control.repairAccess==='function'&&job.state==='running'){await control.repairAccess();append(id,'Strata repaired access to its own files; starting again.');try{await startWith(cfg);started=Promise.resolve();error=null;}catch(again){error=again;}}
|
||||
if(error&&await stillLoading(error)){adopted=true;error=null;}
|
||||
if(error&&sharedSentence&&!isAccess(error)){instance.status='error';instance.error=sharedSentence;emit();throw new Error(sharedSentence);}
|
||||
if(error){const said=humanStartError(error);
|
||||
if(isAccess(error)){instance.status='error';instance.error=said;emit();throw new Error(said);} // access errors are not memory stops: no safer retry
|
||||
@@ -476,16 +529,19 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
const words=safer?safer.words:FALLBACK_NOTE,detail=[safer?.detail,dropParallel?'one request at a time':null].filter(Boolean).join('; ');
|
||||
retried={words,detail};if(dropParallel){instance.parallel=1;if(!safer)parallelNote=FALLBACK_NOTE;}
|
||||
append(id,`The first start stopped (${said}). Starting once more: ${detail}`);job.summary=words;job.details=[said,`Second try: ${detail}`];instance.notice=words;emit();
|
||||
try{await startWith(retryConfig);append(id,'Strata Control reports the engine is ready (second try).');}
|
||||
catch(second){const said2=humanStartError(second);instance.status='error';instance.error=said2;/* the first stop and the second try stay in job.details; the pop-up shows the last stop once */emit();throw new Error(instance.error);}}}
|
||||
delete instance.splitNote;
|
||||
try{await startWith(retryConfig,false);append(id,'Strata Control reports the engine is ready (second try).');}
|
||||
catch(second){if(await stillLoading(second))adopted=true;else{const said2=humanStartError(second);instance.status='error';instance.error=said2;/* the first stop and the second try stay in job.details; the pop-up shows the last stop once */emit();throw new Error(instance.error);}}}}
|
||||
} else {
|
||||
proc=spawnProcess(py,[join(repo(),'serve/server.py'),'--engine','strata','--config',configPath,'--port',String(cfg.port),'--host','127.0.0.1'],{cwd:repo(),detached:process.platform!=='win32',stdio:['ignore','pipe','pipe'],env:sourceServerEnvironment()});
|
||||
instances.set(id,instance);processes.set(id,proc);if(!defaultId||!args.multiple||wasDefault)defaultId=id;
|
||||
for(const stream of [proc.stdout,proc.stderr])stream.on('data',chunk=>{const lines=chunk.toString().split('\n').filter(Boolean);lines.forEach(line=>append(id,line.replaceAll(key,'[redacted]')));job.summary=lines.at(-1)??job.summary;emit();});
|
||||
proc.on('error',error=>{instance.status='error';instance.error=error.message;emit();});proc.on('exit',code=>{if(instances.has(id)&&instance.status!=='unloading'){instance.status='error';instance.error=`Engine stopped${code===null?'':` (exit ${code})`}. Open diagnostics or reload.`;emit();}});emit();
|
||||
}
|
||||
const deadline=Date.now()+300000;while(Date.now()<deadline&&job.state==='running'){
|
||||
/* A start Control stopped waiting for is watched for as long as the start asked for (the server ends the engine itself when its own wait runs out, a minute after this one), and Control is asked now and then whether the engine is still there. */
|
||||
const deadline=Date.now()+(adopted?Math.max(300,startWait??300)*1000:300000);let lastAsked=0;while(Date.now()<deadline&&job.state==='running'){
|
||||
if(instance.status==='error')throw new Error(instance.error);
|
||||
if(adopted&&instance.profile&&Date.now()-lastAsked>10000){lastAsked=Date.now();const st=await control.status(instance.profile).catch(()=>null);if(st&&['failed','stopped','stale'].includes(st.state)){instance.status='error';instance.error=humanStartError({message:st.error||'server_exited',output:st});emit();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||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));
|
||||
}
|
||||
@@ -496,7 +552,9 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
let pollCount=0;
|
||||
/* The engine's metrics carry every finished request with `?requests=all`: a long session makes that a large JSON that the host parses and
|
||||
pushes to the window every 2 s. The last dozen is what the app shows live; the full list is refreshed every tenth poll. */
|
||||
async function poll(){if(polling||closing)return;polling=true;const full=pollCount++%10===0;try{await Promise.all([...instances.values()].map(async instance=>{if(instance.status!=='ready')return;try{const response=await fetcher(`http://127.0.0.1:${instance.port}/metrics${full?'?requests=all':''}`,{headers:{authorization:`Bearer ${instance.key}`},signal:AbortSignal.timeout(1500)});if(!response.ok)throw new Error('Metrics unavailable');const fresh=await response.json();const older=instance.metrics?.requests,recent=fresh.requests;instance.metrics=full||!Array.isArray(older)||!Array.isArray(recent)||!recent.length?fresh:{...fresh,requests:[...recent,...older.filter(r=>Number(r.time)<Math.min(...recent.map(x=>Number(x.time))))]};const tools=await fetcher(`http://127.0.0.1:${instance.port}/mcp`,{headers:{authorization:`Bearer ${instance.key}`},signal:AbortSignal.timeout(1500)});if(tools.ok)instance.metrics.mcp=await tools.json();instance.phase=instance.metrics.live?.state??'idle';}catch{instance.metrics=null;instance.phase='unavailable';}}));emit();}finally{polling=false;}}
|
||||
async function poll(){if(polling||closing)return;polling=true;const full=pollCount++%10===0;try{await Promise.all([...instances.values()].map(async instance=>{if(instance.status!=='ready')return;try{const response=await fetcher(`http://127.0.0.1:${instance.port}/metrics${full?'?requests=all':''}`,{headers:{authorization:`Bearer ${instance.key}`},signal:AbortSignal.timeout(1500)});if(!response.ok)throw new Error('Metrics unavailable');const fresh=await response.json();const older=instance.metrics?.requests,recent=fresh.requests;instance.metrics=full||!Array.isArray(older)||!Array.isArray(recent)||!recent.length?fresh:{...fresh,requests:[...recent,...older.filter(r=>Number(r.time)<Math.min(...recent.map(x=>Number(x.time))))]};const tools=await fetcher(`http://127.0.0.1:${instance.port}/mcp`,{headers:{authorization:`Bearer ${instance.key}`},signal:AbortSignal.timeout(1500)});if(tools.ok)instance.metrics.mcp=await tools.json();instance.phase=instance.metrics.live?.state??'idle';instance.misses=0;}catch{instance.metrics=null;instance.phase='unavailable';instance.misses=(instance.misses??0)+1;await reconcileLost(instance);}}));emit();}finally{polling=false;}}
|
||||
/* The engine of a ready instance stopped answering. After a few looks in a row Control is asked: an owner that says the engine is stopped, failed or lost ends the "ready" the app was still showing (the model looked loaded for good while every request failed, #4973); an engine that is only busy stays as it is. */
|
||||
async function reconcileLost(instance){if(instance.misses<3||instance.status!=='ready'||!instance.profile||typeof control.status!=='function')return;let st=null;try{st=await control.status(instance.profile);}catch{return;}if(instance.status==='ready'&&['stopped','failed','stale'].includes(st?.state)){instance.status='error';instance.error='Engine stopped. Open diagnostics or reload.';}}
|
||||
function ensurePoll(){if(!timer){timer=setInterval(()=>void poll(),2000);timer.unref?.();}void poll();}
|
||||
async function benchmark(job,args){const instance=resolveInstance(args.instanceId);job.instanceId=instance.id;if(busy(requests().filter(r=>r.instanceId===instance.id)))throw new Error('Wait for this model to finish before benchmarking.');const started=Date.now();job.abort=new AbortController();const response=await fetcher(`http://127.0.0.1:${instance.port}/v1/chat/completions`,{method:'POST',headers:{'content-type':'application/json',authorization:`Bearer ${instance.key}`},signal:AbortSignal.any([job.abort.signal,AbortSignal.timeout(180000)]),body:JSON.stringify({model:instance.apiModelId,messages:[{role:'user',content:'Explain how a rainbow forms in clear, simple language.'}],temperature:0,reasoning_effort:'none',max_tokens:200,stream:false})});const body=await response.json();if(!response.ok)throw new Error(body.error?.message??'Benchmark failed.');if(job.state!=='running')return;const result={summary:'Measured benchmark complete.',at:new Date().toISOString(),model:instance.apiModelId,durationMs:Date.now()-started,tokensIn:body.usage?.prompt_tokens??null,tokensOut:body.usage?.completion_tokens??null,outputTps:body.timings?.predicted_per_second??null,promptTps:body.timings?.prompt_per_second??null,source:'engine',settings:'Greedy · thinking off · up to 200 output tokens'};writeFileSync(join(directory,'native-benchmark.json'),JSON.stringify(result,null,2),{mode:0o600});return result;}
|
||||
function resolveInstance(id){const aliases=[...instances.values()].filter(i=>i.aliases?.includes(id));if(aliases.length>1&&!instances.has(id)&&![...instances.values()].some(i=>i.apiModelId===id))throw new Error('This alias matches more than one instance. Choose its exact model ID.');const i=instances.get(id)??[...instances.values()].find(i=>i.apiModelId===id)??aliases[0]??(!id&&instances.size===1?[...instances.values()][0]:undefined);if(!i)throw new Error('Choose a loaded model ID.');if(i.status!=='ready')throw new Error('This model is not ready.');return i;}
|
||||
@@ -572,6 +630,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
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();
|
||||
await autoDrafterFor(job,instance.model,`Starting ${instance.label}. Loading can take a few minutes.`);
|
||||
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});
|
||||
@@ -626,6 +685,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
const {path}=tuneConfig(args);
|
||||
if(externalRunning().length)throw new Error('Stop the native server running outside this UI before tuning.');
|
||||
if(busy(requests().filter(r=>instances.has(r.instanceId))))throw new Error('Finish requests before tuning.');
|
||||
await autoDrafterFor(job,args.model,'Finding the fastest settings for your PC…');if(job.state!=='running')return;
|
||||
for(const id of [...instances.keys()])await unload(id);
|
||||
if(job.state!=='running')return;
|
||||
job.summary='Finding the fastest settings for your PC…';emit();
|
||||
@@ -732,7 +792,7 @@ 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,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};
|
||||
return {state:publicState,settingsHints,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){
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Preserve native protocol events and tool payloads; the gateway owns identity and cancellation. */
|
||||
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;}
|
||||
let instance;try{instance=control.resolveInstance(body.model);}catch(e){/* #5312: an Anthropic client sends a model name of its own (Claude Desktop: claude-5-5-sonnet) and cannot change it: with exactly one model loaded the route answers with it */if(path==='/v1/messages'&&body.model){try{instance=control.resolveInstance(undefined);}catch{}}if(!instance){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();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;
|
||||
|
||||
@@ -101,7 +101,7 @@ 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
|
||||
* @param {string} [input.drafter] the drafter this load runs (resolveDrafter): with dflash2 the app fills in one request at a time unless the person chose a number
|
||||
* @returns {{slots:number, batchMtp:boolean, explicit:boolean, requested:number, note:string|null, slotGb:number}}
|
||||
*/
|
||||
export function chooseParallel({ config = {}, values = {}, hardware = null, requested = DEFAULT_SLOTS, drafter = null }) {
|
||||
@@ -112,10 +112,14 @@ export function chooseParallel({ config = {}, values = {}, hardware = null, requ
|
||||
// A number someone chose (including 1) is the person's decision, and so is an explicit --batch in the engine arguments.
|
||||
if (facts.explicitBatch || (chosen !== null && Number.isFinite(chosen))) {
|
||||
const slots = facts.explicitBatch ? null : Math.max(1, Math.min(1024, Math.trunc(chosen)));
|
||||
// DFlash2 chosen by name with a number of requests (owner, 2026-11-03: the person's own choice wins) runs DFlash2 with that many requests, a drafter per request
|
||||
// (strata_control/drafting.py blocked_by `named`). Expert mode's batch-mtp wish is still added here as in 0.2.10: the server knows the backend and the engine, takes
|
||||
// --batch-mtp out when DFlash2 runs, and keeps it when the MTP runs instead (Metal, which has no per-request drafters).
|
||||
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.
|
||||
// DFlash2 (named, or Auto where the engine has it) and nobody chose a number of requests: one at a time, the app does not fill in slots beside it. (A number the person chose is kept
|
||||
// above: DFlash2 named runs it with that many requests; Auto keeps the MTP for several.)
|
||||
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).
|
||||
|
||||
@@ -125,6 +125,8 @@ export const engineVersionOfExe=exe=>{const m=/engine[\\/](\d+(?:\.\d+)+)-/.exec
|
||||
technical line for Details. The caller tries once and never again, and does not save this config. */
|
||||
export function saferStart(config,hardware,model,failure){
|
||||
const text=String(failure??'');
|
||||
// The engine refused an oversized prompt chunk at the start (an APU whose pool the expert cache fills; the person or the environment named the chunk): the same chunk would fail again, and a memory setting is not the cause.
|
||||
if(/prompt chunk does not fit/i.test(text))return null;
|
||||
// A start that died for a reason that is not memory (a missing or bad DLL, a crash, a blocked file) is not helped by a memory setting, and must not be called a memory failure.
|
||||
if(/0xC0000135|0xC000007B|0xC0000142|0xC000001D|0xC0000005|0xC0000409|0xC0000022|error while loading shared libraries|cannot open shared object file|\bENOENT\b|exit code 127|exit code 139|illegal instruction/i.test(text))return null;
|
||||
const ranOut=/out of memory|memory guard|\boom\b|\bkilled\b|exit code 137|cannot allocate|not enough memory|bad_alloc|insufficient memory/i.test(text),first=ranOut?'The first start ran out of memory':'The first start did not finish';
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { validMcpName, MCP_NAME_HELP } from './mcp-names.mjs';
|
||||
import { SPEED_SWITCH_TABLE, DRAFTING_FAMILIES as GENERATED_DRAFTING_FAMILIES, DFLASH2_MODEL_PATTERN } from './policy-generated.mjs';
|
||||
/** Editable native keys. Unknown config keys and program paths stay intact. */
|
||||
/* The Drafting hint, word for word, with the size of the drafter this model would really fetch (the tuned one is 0.34 GB, 0.52 on an Arc card; the stock one 1.2). */
|
||||
export function draftingHint(gb=1.2){const size=Number.isFinite(Number(gb))&&Number(gb)>0?Number(gb):1.2;return `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; on two or more identical graphics cards that the all-cards split engine takes, it is that engine with the MTP draft layer. DFlash2 downloads a ${size} GB drafter once, the MTP its draft layer once. With Auto, several requests at once use the MTP. If you choose DFlash2 yourself, it runs with the number of requests you set. If DFlash2 cannot run, the MTP is used alone and Details says why. Off turns the drafter off.`;}
|
||||
export const NATIVE_FIELDS=[
|
||||
{id:'reasoning_budget_tokens',label:'Thinking token budget',group:'Responses',type:'number',min:0,max:1048576,hint:'Leave blank for the model default.'},
|
||||
{id:'fit_max_tokens',label:'Fit output to remaining context',group:'Responses',type:'boolean',hint:'Shortens the requested output cap when the prompt leaves less room.'},
|
||||
@@ -9,7 +12,7 @@ export const NATIVE_FIELDS=[
|
||||
{id:'idle_unload_s',label:'Unload after idle seconds',group:'Serving',type:'number',min:0,max:86400,hint:'Zero keeps the model loaded.'},
|
||||
{id:'lazy_load',label:'Load on first request',group:'Serving',type:'boolean'},
|
||||
{id:'mtp',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:'drafting',notForModel:'deepseek',label:'Drafting',group:'Serving',type:'enum',choices:['auto','mtp','dflash2','off'],hint:draftingHint(1.2)},
|
||||
{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.'},
|
||||
@@ -36,22 +39,8 @@ export const ENV_FLAG=/^(?:STRATA|DS4)_[A-Z0-9_]+$/;
|
||||
* turns it on, and the STRATA_ROUTE_PRIOR=0 that 0.2.7's Off wrote is the default state now. The other two are on by default. The names are the engine switch contract (phaseA-tests/notes-027-plan.md); strata_control/speed_switches.py says the
|
||||
* same for the Terminal (tests/terminal/test_speed_switches.py compares the two).
|
||||
*/
|
||||
export const SPEED_SWITCHES=[
|
||||
{id:'faster_long_prompts',label:'Faster long prompts',group:'Speed',
|
||||
hint:'Reads very long prompts faster by approximating part of the work. Off by default: answers about long documents can miss or mix up details.',
|
||||
default:'off',env:{STRATA_KVA:'1'}},
|
||||
{id:'faster_12gb_nvidia',label:'Faster answers on 12 GB NVIDIA cards',group:'Speed',
|
||||
hint:'Prefers the parts of the model already on the graphics card, which makes answers faster on 12 GB cards. Off by default: in long agent sessions it can repeat itself and give weaker results. Turn on if you mostly chat and want the speed.',
|
||||
default:'off',env:{STRATA_ROUTE_PRIOR:'1.5'}},
|
||||
{id:'faster_radeon_9000',label:'Faster answers on Radeon RX 9000 / AI PRO R9700',group:'Speed',
|
||||
hint:'Shares the work between the graphics card and the processor in a way that makes answers faster on these cards. Turn off to go back to the previous setting, which is slower.',
|
||||
default:'on',env:{STRATA_PCIE_FRAC_DEFAULT:'old'}},
|
||||
{id:'deepseek_speedups',label:'DeepSeek speed-ups',group:'Speed',
|
||||
hint:'Reads long prompts faster and loads the parts of the model it needs faster. Turn off if DeepSeek stops working or its answers look wrong; it then runs as in version 0.2.6.',
|
||||
default:'on',env:{DS4_DEFAULTS:'026',DS4_FAST_ATTN:'0',DS4_ATTN_SLAB:'0',DS4_COPY_PIN:'0',DS4_COPY_HUGETLB:'0',DS4_STORE_SPARE:'0',DS4_STORE_PUT:'0'},
|
||||
/* an earlier test build wrote only these six for the same state: they read as the switch being set, and go with it */
|
||||
legacy:{DS4_FAST_ATTN:'0',DS4_ATTN_SLAB:'0',DS4_COPY_PIN:'0',DS4_COPY_HUGETLB:'0',DS4_STORE_SPARE:'0',DS4_STORE_PUT:'0'}},
|
||||
];
|
||||
/* The table (ids, words, defaults, environment entries) is generated from strata_control/speed_switches.py into policy-generated.mjs; this file only adds the group the screen lists them under and the rules for where a switch applies (below), which tests/speed-switches.test.mjs and tests/terminal/test_speed_switches.py run against the same cases. */
|
||||
export const SPEED_SWITCHES=SPEED_SWITCH_TABLE.map(s=>({id:s.id,label:s.label,group:'Speed',hint:s.hint,default:s.default,env:s.env,...(s.legacy?{legacy:s.legacy}:{})}));
|
||||
const GFX12=/^gfx120[01]$/i, RADEON_9000=/\b(?:RX\s*9\d{3}|AI\s*PRO\s*R97\d{2}|R97\d{2})\b/i;
|
||||
/**
|
||||
* Which switches make sense for this model on this PC: only those are shown. `backend` is the engine's (cuda, hip, sycl, metal, cpu). `arch` is the "arch"
|
||||
@@ -88,8 +77,8 @@ export function speedSwitchRows(env,context){return SPEED_SWITCHES.filter(s=>spe
|
||||
/** 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;
|
||||
export const DRAFTING_FAMILIES=GENERATED_DRAFTING_FAMILIES;
|
||||
const DFLASH2_MODEL=new RegExp(DFLASH2_MODEL_PATTERN,'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){
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// GENERATED by `python -m strata_control.app_policy` from strata_control/speed_switches.py and strata_control/drafting.py. Do not edit it by hand:
|
||||
// change the Python and regenerate; tests/terminal/test_app_policy.py and apps/host/tests/policy-generated.test.mjs fail when this file is out of date.
|
||||
/** The speed switches of the app's Expert settings (strata_control/speed_switches.py app_switches()): id, words, default, the environment entries outside the default. */
|
||||
export const SPEED_SWITCH_TABLE = [
|
||||
{"id": "faster_long_prompts", "label": "Faster long prompts", "hint": "Reads very long prompts faster by approximating part of the work. Off by default: answers about long documents can miss or mix up details.", "default": "off", "env": {"STRATA_KVA": "1"}},
|
||||
{"id": "faster_12gb_nvidia", "label": "Faster answers on 12 GB NVIDIA cards", "hint": "Prefers the parts of the model already on the graphics card, which makes answers faster on 12 GB cards. Off by default: in long agent sessions it can repeat itself and give weaker results. Turn on if you mostly chat and want the speed.", "default": "off", "env": {"STRATA_ROUTE_PRIOR": "1.5"}},
|
||||
{"id": "faster_radeon_9000", "label": "Faster answers on Radeon RX 9000 / AI PRO R9700", "hint": "Shares the work between the graphics card and the processor in a way that makes answers faster on these cards. Turn off to go back to the previous setting, which is slower.", "default": "on", "env": {"STRATA_PCIE_FRAC_DEFAULT": "old"}},
|
||||
{"id": "deepseek_speedups", "label": "DeepSeek speed-ups", "hint": "Reads long prompts faster and loads the parts of the model it needs faster. Turn off if DeepSeek stops working or its answers look wrong; it then runs as in version 0.2.6.", "default": "on", "env": {"DS4_DEFAULTS": "026", "DS4_FAST_ATTN": "0", "DS4_ATTN_SLAB": "0", "DS4_COPY_PIN": "0", "DS4_COPY_HUGETLB": "0", "DS4_STORE_SPARE": "0", "DS4_STORE_PUT": "0"}, "legacy": {"DS4_FAST_ATTN": "0", "DS4_ATTN_SLAB": "0", "DS4_COPY_PIN": "0", "DS4_COPY_HUGETLB": "0", "DS4_STORE_SPARE": "0", "DS4_STORE_PUT": "0"}},
|
||||
];
|
||||
/** The families that have a Drafting choice (strata_control/drafting.py SUPPORTED_FAMILIES). */
|
||||
export const DRAFTING_FAMILIES = ["qwen", "gyro", "unsloth"];
|
||||
/** The model names the DFlash2 drafter was made for (strata_control/drafting.py _SUPPORTED_NAME), a case-insensitive pattern. */
|
||||
export const DFLASH2_MODEL_PATTERN = "^qwen3\\.8-flash-next(-gyro-[a-z0-9._-]+|-unsloth-[a-z0-9._-]+|-(q|iq|tq)\\d[a-z0-9_.]*)?$";
|
||||
@@ -2,9 +2,10 @@
|
||||
import net from 'node:net';
|
||||
|
||||
/** Why a listen failed, in words a person can act on (Windows keeps whole ranges for Hyper-V, WSL and Docker). */
|
||||
export function listenProblem(error, port, host = '127.0.0.1') {
|
||||
export function listenProblem(error, port, host = '127.0.0.1', platform = process.platform) {
|
||||
const code = error?.code;
|
||||
if (code === 'EADDRINUSE') return `Port ${port} is already in use. Strata may already be running; close it or the other program, or start Strata with another port (STRATA_PORT).`;
|
||||
if (code === 'EACCES' && platform !== 'win32') return `This system does not let Strata use port ${port}. On Linux and macOS, ports below 1024 need administrator rights. Start Strata with another port (STRATA_PORT), for example 8801.`;
|
||||
if (code === 'EACCES') return `Windows or a firewall does not let Strata use port ${port}. Hyper-V, WSL and Docker keep ranges of ports for themselves (see "netsh int ipv4 show excludedportrange protocol=tcp"). Start Strata with another port (STRATA_PORT), for example 8801.`;
|
||||
if (code === 'EADDRNOTAVAIL') return `${host} is not an address of this computer, so Strata cannot listen on it. Check STRATA_BIND.`;
|
||||
return `Strata cannot listen on ${host}:${port} (${error?.message ?? code}).`;
|
||||
|
||||
@@ -24,3 +24,13 @@ export function makePrivate(path, { platform = process.platform } = {}) {
|
||||
// The owner too, so a file an elevated run made is not left owned by Administrators (needs the right to; best effort).
|
||||
try { execFileSync(system32('icacls.exe'), [path, '/setowner', `*${currentUserSid()}`], { stdio: 'ignore', timeout: 15000, windowsHide: true }); } catch { /* the explicit entry above already opens it to the user */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner-only folder. POSIX: mode 0700. Windows: a protected ACL naming only the current user that the files made inside inherit,
|
||||
* so a file written later (a chat saved every second) is private without a tool run per write.
|
||||
*/
|
||||
export function makePrivateDir(path, { platform = process.platform } = {}) {
|
||||
if (platform !== 'win32') { chmodSync(path, 0o700); return; }
|
||||
execFileSync(system32('icacls.exe'), [path, '/grant:r', `*${currentUserSid()}:(OI)(CI)F`, '/inheritance:r'],
|
||||
{ stdio: 'ignore', timeout: 15000, windowsHide: true });
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// End a program this host started together with everything that program started (#4529 #4570).
|
||||
//
|
||||
// Windows: `taskkill /T` walks the tree. Linux and macOS have no such call, and signalling the child alone leaves its own children running (they are
|
||||
// re-parented to init). A child started with `detached: ownGroup()` leads a process group of its own, so a signal to the negative pid reaches the
|
||||
// whole group and nothing else: the group is the ownership record, no other process (a same-named one included) is ever signalled by name.
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
/** The spawn option that makes a child lead its own process group on Linux and macOS (Windows needs none: taskkill /T). */
|
||||
export const ownGroup = () => process.platform !== 'win32';
|
||||
|
||||
/** Whether the group the child leads still has a member. */
|
||||
export function groupAlive(child) {
|
||||
if (process.platform === 'win32' || !child?.pid) return false;
|
||||
try { process.kill(-child.pid, 0); return true; } catch (error) { return error?.code === 'EPERM'; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal the child's whole tree once. A child that does not lead its own group (it was not started with ownGroup()) is signalled alone, which is what
|
||||
* every caller did before.
|
||||
*/
|
||||
export function endTree(child, signal = 'SIGTERM') {
|
||||
if (!child) return;
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
if (child.pid) spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore', timeout: 10000 });
|
||||
return;
|
||||
}
|
||||
if (child.pid) {
|
||||
try { process.kill(-child.pid, signal); return; } catch { /* no such group: it did not lead one, or it is gone */ }
|
||||
}
|
||||
// A child that never started has no pid. On macOS, kill() on it right after a failed spawn signals the caller's own process group (pid 0), so
|
||||
// it is left alone: there is nothing to end.
|
||||
if (child.pid) child.kill(signal);
|
||||
} catch { /* already gone */ }
|
||||
}
|
||||
|
||||
/** SIGTERM to the tree, then SIGKILL to whatever of its group is still there after `graceMs`. Returns the timer so a caller may clear it. */
|
||||
export function endTreeThenKill(child, graceMs = 2000) {
|
||||
endTree(child, 'SIGTERM');
|
||||
if (process.platform === 'win32' || !child?.pid) return null;
|
||||
const timer = setTimeout(() => { if (groupAlive(child)) endTree(child, 'SIGKILL'); }, graceMs);
|
||||
timer.unref?.();
|
||||
return timer;
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import { NATIVE_FIELDS } from './native-settings.mjs';
|
||||
/** Every top-level key a run config may carry: what setup writes, what serve/server.py reads, the app's own fields. */
|
||||
export const KNOWN_KEYS = new Set([
|
||||
...NATIVE_FIELDS.map(f => f.id),
|
||||
'exe', 'args', 'cwd', 'tokenizer', 'model_name', 'model', 'arch', 'log', 'lib_dirs', 'port', 'host', 'api_key', 'cuda', 'backend', 'gpu', 'gpus_asked',
|
||||
'exe', 'args', 'args_written', 'user_args', 'gpus_by', 'cwd', 'tokenizer', 'model_name', 'model', 'arch', 'log', 'lib_dirs', 'port', 'host', 'api_key', 'cuda', 'backend', 'gpu', 'gpus_asked',
|
||||
'gpu_policy', 'hip_ordinal', 'env', 'env_to_user', 'sampling', 'mcp_servers', 'mcpServers', 'before_load', 'before_load_ignore_failure',
|
||||
'before_load_strict', 'before_load_timeout_s', 'cors_origins', 'trusted_origins', 'allowed_hosts', 'open_browser', 'prevent_sleep',
|
||||
'max_queue', 'queue_timeout', 'max_tokens', 'min_tokens', 'answer_room', 'threads', 'replicas', 'replica_cpus', 'replica_load_tokens',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import { get as getStore, update } from './store.mjs';
|
||||
import { nowIso, round, uid } from './util.mjs';
|
||||
import { nowIso, round, sameSecret, uid } from './util.mjs';
|
||||
|
||||
const requests = [];
|
||||
const MAX_REQUESTS = 60;
|
||||
@@ -85,10 +85,13 @@ export function patch(body) {
|
||||
export function authorized(req) {
|
||||
const key = getStore().serving?.apiKey;
|
||||
if (!key) return true;
|
||||
const header = req.headers.authorization ?? '';
|
||||
const bearer = header.startsWith('Bearer ') ? header.slice(7) : '';
|
||||
const alt = req.headers['x-api-key'];
|
||||
return bearer === key || alt === key;
|
||||
const header = req.headers?.authorization;
|
||||
const bearer = typeof header === 'string' && header.startsWith('Bearer ') ? header.slice(7) : '';
|
||||
const alt = req.headers?.['x-api-key'];
|
||||
// #4535: constant time over fixed-length digests, and both headers are always compared so the time does not show which one was sent.
|
||||
const viaBearer = sameSecret(bearer, key);
|
||||
const viaHeader = sameSecret(alt, key);
|
||||
return viaBearer || viaHeader;
|
||||
}
|
||||
|
||||
export function open({ client, endpoint, tokensIn = null, model, instanceId, gpuIds, cancel }) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* A quiet NDJSON answer stays open (#4878). A chat turn can say nothing for minutes (the model reads a long prompt, a tool
|
||||
* runs, an approval waits), and the desktop's web view gives up on a request that has been silent for 60 seconds ("Load failed")
|
||||
* while the turn goes on in the background. A beat frame, the same `{"type":"beat"}` the Time-skip stream uses and the chat
|
||||
* consumer ignores, goes out every 15 seconds. It changes nothing else: a client that goes away still cancels (or detaches
|
||||
* from) its turn through the response's own close, exactly as before, and the timer ends on finish, on close and on stop().
|
||||
*/
|
||||
export const BEAT_MS = 15_000;
|
||||
const BEAT_LINE = `${JSON.stringify({ type: 'beat' })}\n`;
|
||||
|
||||
/** The interval (a tester may shorten it: STRATA_STREAM_BEAT_MS); anything that is not a sensible number is the default. */
|
||||
export function beatInterval(env = process.env) {
|
||||
const n = Number(env.STRATA_STREAM_BEAT_MS);
|
||||
return Number.isFinite(n) && n >= 20 && n <= 60_000 ? n : BEAT_MS;
|
||||
}
|
||||
|
||||
/** Start beating on `res`. Returns stop(), which is safe to call more than once. */
|
||||
export function keepStreamAlive(res, { ms = beatInterval() } = {}) {
|
||||
let timer = setInterval(() => {
|
||||
if (res.writableEnded || res.destroyed) { stop(); return; }
|
||||
try { res.write(BEAT_LINE); } catch { stop(); }
|
||||
}, ms);
|
||||
timer.unref?.();
|
||||
function stop() {
|
||||
if (timer === null) return;
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
res.off?.('finish', stop);
|
||||
res.off?.('close', stop);
|
||||
res.off?.('error', stop);
|
||||
}
|
||||
res.once('finish', stop);
|
||||
res.once('close', stop);
|
||||
res.once('error', stop);
|
||||
return stop;
|
||||
}
|
||||
@@ -10,8 +10,11 @@
|
||||
* macOS: none.
|
||||
*/
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { createPowerShellLoop, endWithHost } from './windows-poll.mjs';
|
||||
import { stateRoot } from './platform-paths.mjs';
|
||||
|
||||
const CPU_SENSORS = new Set(['coretemp', 'k10temp', 'zenpower', 'cpu_thermal', 'cpu-thermal']);
|
||||
const DISK_SENSORS = new Set(['nvme', 'drivetemp']);
|
||||
@@ -82,28 +85,66 @@ while($true){
|
||||
if($c.Count -gt 0){ $max=($c | Measure-Object Temperature -Maximum).Maximum }
|
||||
else {
|
||||
if(-not $compiled){ Add-Type -TypeDefinition $cs; $compiled=$true }
|
||||
foreach($n in 0..15){ $v=[StrataDiskTemp]::Read($n); if($v -gt $max){ $max=$v } }
|
||||
# only drives Windows names as NVMe, one at a time: a RAID, SATA, USB or unknown drive is never asked (a driver stack that stalls on this query
|
||||
# froze whole PCs, #4661)
|
||||
foreach($d in @(Get-PhysicalDisk | Where-Object { $_.BusType -eq 'NVMe' })){ $v=[StrataDiskTemp]::Read([int]$d.DeviceId); if($v -gt $max){ $max=$v } }
|
||||
}
|
||||
if($max -gt 0){ [Console]::Out.WriteLine('ssd '+$max) } else { [Console]::Out.WriteLine('ssd -') }
|
||||
[Console]::Out.Flush()
|
||||
Start-Sleep -Seconds 30
|
||||
}`;
|
||||
|
||||
export function createWindowsSsdReader({ spawnFn = spawn, maxStarts = 2, now = Date.now, intervalSec = 30 } = {}) {
|
||||
let latest = null;
|
||||
/** How long the first pass of the reader may take before it is given up for good (a healthy pass takes a second or two). */
|
||||
export const FIRST_PASS_MS = 45_000;
|
||||
|
||||
/** The marker file the reader leaves before it asks the drives and removes after its first answer. */
|
||||
export const sentinelPath = (env = process.env, platform = process.platform) => join(stateRoot(env, platform), 'drive-temperature.probe');
|
||||
|
||||
/**
|
||||
* `sentinel`: { exists(), write(), remove() }. The reader leaves the marker before it first asks a drive and removes it when the first answer has come. A host
|
||||
* that is still there with a marker at its next start died, or the PC did, in the middle of the question: the reader stays off from then on (the line is left
|
||||
* out of the Monitor; deleting the file brings it back). It never asks again after a first pass that took longer than FIRST_PASS_MS either.
|
||||
*/
|
||||
export function createWindowsSsdReader({ spawnFn = spawn, maxStarts = 2, now = Date.now, intervalSec = 30, sentinel = null, firstPassMs = FIRST_PASS_MS } = {}) {
|
||||
let latest = null, off = false, startedAt = null;
|
||||
const loop = createPowerShellLoop({
|
||||
script: WINDOWS_SCRIPT, spawnFn, maxStarts,
|
||||
onLine: line => {
|
||||
const parsed = parseSsdLine(line);
|
||||
if (parsed === undefined) return false;
|
||||
latest = { value: parsed, at: now() };
|
||||
if (sentinel) { try { sentinel.remove(); } catch { /* best effort */ } }
|
||||
return true;
|
||||
},
|
||||
});
|
||||
return {
|
||||
read() { loop.start(); return latest && now() - latest.at < 4 * intervalSec * 1000 ? latest.value : null; },
|
||||
read() {
|
||||
if (off) return null;
|
||||
if (startedAt === null) {
|
||||
if (sentinel) {
|
||||
try { if (sentinel.exists()) { off = true; return null; } sentinel.write(); } catch { off = true; return null; } // cannot leave the marker: do not ask
|
||||
}
|
||||
startedAt = now();
|
||||
} else if (latest === null && now() - startedAt > firstPassMs) {
|
||||
off = true; // the first pass is stuck: ended, and the marker stays
|
||||
loop.stop();
|
||||
return null;
|
||||
}
|
||||
loop.start();
|
||||
return latest && now() - latest.at < 4 * intervalSec * 1000 ? latest.value : null;
|
||||
},
|
||||
stop() { loop.stop(); },
|
||||
get starts() { return loop.starts; },
|
||||
get off() { return off; },
|
||||
};
|
||||
}
|
||||
|
||||
/** The real marker file under Strata's state folder. */
|
||||
export function fileSentinel(path = sentinelPath()) {
|
||||
return {
|
||||
exists: () => existsSync(path),
|
||||
write: () => { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, String(Date.now())); },
|
||||
remove: () => rmSync(path, { force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -117,7 +158,7 @@ export async function readTemperatures({ platform = process.platform, now = Date
|
||||
return linuxCache.value;
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
windowsReader ??= endWithHost(createWindowsSsdReader());
|
||||
windowsReader ??= endWithHost(createWindowsSsdReader({ sentinel: fileSentinel() }));
|
||||
return { cpuTempC: null, ssdTempC: windowsReader.read() };
|
||||
}
|
||||
return { cpuTempC: null, ssdTempC: null };
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* A run is a job, not a request: it outlives the HTTP call that started it. The endpoint returns the
|
||||
* run id immediately and the client follows it with `/api/timeskip/status`.
|
||||
*/
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { endTreeThenKill, ownGroup } from './process-tree.mjs';
|
||||
import { existsSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import { timeSkipDir } from './platform-paths.mjs';
|
||||
@@ -146,10 +147,7 @@ export function timeskipRevisionMessage(request) {
|
||||
|
||||
/** End a process and everything it started (the job, the Python it runs, the revision command and its Node). */
|
||||
function killTree(child) {
|
||||
try {
|
||||
if (process.platform === 'win32' && child.pid) spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
|
||||
else child.kill('SIGTERM');
|
||||
} catch { /* already gone */ }
|
||||
endTreeThenKill(child); // Windows: taskkill /T. Linux and macOS: the job leads its own process group (startDetached), so the group is ended (#4529 #4570)
|
||||
}
|
||||
|
||||
/** How big a folder is, stopping early once it is clearly too big to copy for a trial. */
|
||||
@@ -452,7 +450,7 @@ export function createTimeSkip({ repoRoot, python, pythonArgs = [], timeoutMs =
|
||||
'--attempts', String(Math.max(0, Math.min(10, Number(attempts) || 0))), '--output', 'json'];
|
||||
if (reviseCommand) args.push('--revise-command', reviseCommand);
|
||||
const child = spawn(python, [...pythonArgs, cli, ...args], {
|
||||
cwd: repoRoot, windowsHide: true, env: { ...env, PYTHONUTF8: '1', PYTHONIOENCODING: 'utf-8', ...extraEnv, STRATA_TIMESKIP_DIR: root }, stdio: ['ignore', 'pipe', 'pipe'],
|
||||
cwd: repoRoot, windowsHide: true, detached: ownGroup(), env: { ...env, PYTHONUTF8: '1', PYTHONIOENCODING: 'utf-8', ...extraEnv, STRATA_TIMESKIP_DIR: root }, stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let out = '', err = '', closed = null;
|
||||
child.stdout.on('data', b => { out += b.toString(); });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Shared helpers for the Strata host. */
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { whichCommand } from './platform-paths.mjs';
|
||||
@@ -24,6 +24,46 @@ export function error(res, status, message, extra = {}) {
|
||||
json(res, status, { error: message, ...extra });
|
||||
}
|
||||
|
||||
/**
|
||||
* Two secrets compared in constant time (#4535). Both sides are hashed first, so the comparison always runs over two
|
||||
* 32-byte digests: neither the content nor the length of the given value can end it early. Anything that is not a
|
||||
* non-empty string never matches (an empty secret is "not set", not "equal to an empty header").
|
||||
*/
|
||||
export function sameSecret(given, wanted) {
|
||||
const ok = typeof given === 'string' && typeof wanted === 'string' && given.length > 0 && wanted.length > 0;
|
||||
const digest = value => createHash('sha256').update(typeof value === 'string' ? value : '', 'utf8').digest();
|
||||
return timingSafeEqual(digest(given), digest(wanted)) && ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* The request's URL, or null when the request cannot be read as one (#4397). The Host header is the caller's: an empty
|
||||
* value, one with a space, a path, credentials or an unclosed bracket used to make `new URL` throw before the routing
|
||||
* try/catch and end the whole process. A missing Host (HTTP/1.0) is read as localhost; a good one keeps its port and IPv6 form.
|
||||
*/
|
||||
export function requestUrl(req) {
|
||||
const host = req.headers?.host;
|
||||
try {
|
||||
let authority = 'localhost';
|
||||
if (host !== undefined) {
|
||||
if (typeof host !== 'string' || !host.trim() || /[\s/?#@\\]/.test(host)) return null;
|
||||
const base = new URL(`http://${host}`);
|
||||
if (!base.host || base.username || base.password || base.search || base.hash || base.pathname !== '/') return null;
|
||||
authority = host;
|
||||
}
|
||||
return new URL(req.url ?? '/', `http://${authority}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The answer to a request that could not be read as a URL: bounded, and the connection closes. */
|
||||
export function badRequest(res) {
|
||||
if (res.headersSent) return res.end();
|
||||
const payload = '{"error":"bad_host"}';
|
||||
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(payload), 'cache-control': 'no-store', connection: 'close' });
|
||||
res.end(payload);
|
||||
}
|
||||
|
||||
export async function readBody(req, limit = 4 * 1024 * 1024) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
|
||||
+21
-13
@@ -1,7 +1,7 @@
|
||||
import { tr } from '../lib/i18n';
|
||||
/** App shell: routes, setup takeover, palette, toasts. */
|
||||
import { useNative } from '../lib/native';
|
||||
import { Component, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { Component, Suspense, useDeferredValue, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { useSurfaceFocus } from '../lib/focus';
|
||||
import { closeSettings, useLocation } from '../lib/router';
|
||||
import { bootOnce, bootstrap, setNavOpen, setFocus, useStore, } from '../lib/store';
|
||||
@@ -13,14 +13,16 @@ 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';
|
||||
import { SetupScreen } from '../screens/Setup';
|
||||
import { ChatScreen } from '../screens/Chat';
|
||||
import { ModelRunScreen } from '../screens/ModelRun';
|
||||
import { ServingScreen } from '../screens/Serving';
|
||||
import { MonitorScreen } from '../screens/Monitor';
|
||||
import { TasksScreen } from '../screens/Tasks';
|
||||
import { SkillsScreen } from '../screens/Skills';
|
||||
import { SettingsScreen } from '../screens/Settings';
|
||||
import { lazyScreen, useLoadedModule } from '../lib/lazy-screen';
|
||||
// The start screen is Chat. Every other screen loads the first time it is opened (7.1), so the first start reads and runs less code.
|
||||
const loadSetup = () => import('../screens/Setup');
|
||||
const loadSettings = () => import('../screens/Settings');
|
||||
const ModelRunScreen = lazyScreen(() => import('../screens/ModelRun'), (m) => m.ModelRunScreen);
|
||||
const ServingScreen = lazyScreen(() => import('../screens/Serving'), (m) => m.ServingScreen);
|
||||
const MonitorScreen = lazyScreen(() => import('../screens/Monitor'), (m) => m.MonitorScreen);
|
||||
const TasksScreen = lazyScreen(() => import('../screens/Tasks'), (m) => m.TasksScreen);
|
||||
const SkillsScreen = lazyScreen(() => import('../screens/Skills'), (m) => m.SkillsScreen);
|
||||
function AppShell() {
|
||||
useStore(s => s.language);
|
||||
const location = useLocation();
|
||||
@@ -49,7 +51,13 @@ function AppShell() {
|
||||
// demanding five steps before anything appears.
|
||||
const setupOpen = location.route === 'setup' || (!!setup && !setup.complete && !setup.dismissed) || firstRunOpen;
|
||||
// Hook runs here, after setupOpen exists — see src/lib/focus.ts.
|
||||
useSurfaceFocus(setupOpen, setupRef);
|
||||
// The dialogs open once their code has loaded, so they appear and take focus exactly as before.
|
||||
const setupCode = useLoadedModule(loadSetup, setupOpen);
|
||||
const setupShown = setupOpen && !!setupCode;
|
||||
useSurfaceFocus(setupShown, setupRef);
|
||||
const settingsWanted = location.query.get('settings') === '1';
|
||||
const settingsCode = useLoadedModule(loadSettings, settingsWanted);
|
||||
const routeShown = useDeferredValue(location.route);
|
||||
return (<div className="app" data-rail={compact ? !navOpen : rail} data-layout={mobile ? 'mobile' : compact ? 'compact' : 'desktop'} data-nav-open={navOpen || undefined} data-focus={focus && location.route === 'chat' || undefined}>
|
||||
<Sidebar containerRef={navRef}/>
|
||||
{tr(navOpen && compact ? <button className="nav-scrim" tabIndex={-1} aria-hidden="true" aria-label={tr("Close navigation")} onClick={() => setNavOpen(false)}/> : null)}
|
||||
@@ -62,16 +70,16 @@ function AppShell() {
|
||||
</div>
|
||||
<ConnectionBar />
|
||||
{tr(focus && location.route === 'chat' ? <FocusBar /> : null)}
|
||||
{tr(!ready ? <BootSkeleton /> : <RouteView route={location.route}/>)}
|
||||
{tr(!ready ? <BootSkeleton /> : <Suspense fallback={<BootSkeleton />}><RouteView route={routeShown}/></Suspense>)}
|
||||
</main>
|
||||
<CommandPalette open={paletteOpen}/>
|
||||
<Dialog open={location.query.get('settings') === '1'} title={tr("Settings")} className="settings-dialog" onClose={closeSettings}><SettingsScreen /></Dialog>
|
||||
<Dialog open={settingsWanted && !!settingsCode} title={tr("Settings")} className="settings-dialog" onClose={closeSettings}>{settingsCode ? <settingsCode.SettingsScreen /> : null}</Dialog>
|
||||
<ShortcutsDialog />
|
||||
<FolderPathDialog />
|
||||
{tr(setupOpen ? (<div className="setupmodal" role="dialog" aria-modal="true" aria-label={tr("Set up Strata")}>
|
||||
{tr(setupShown ? (<div className="setupmodal" role="dialog" aria-modal="true" aria-label={tr("Set up Strata")}>
|
||||
<div className="scrim" data-open="true" aria-hidden="true"/>
|
||||
<div className="setupmodal__surface" ref={setupRef}>
|
||||
<SetupScreen />
|
||||
{setupCode ? <setupCode.SetupScreen /> : null}
|
||||
</div>
|
||||
</div>) : null)}
|
||||
<Toasts />
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TaskTree } from './TaskTree';
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState, type RefObject } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { FloatingMenu } from '../components/FloatingMenu';
|
||||
import { measureScale } from '../lib/overlay-place';
|
||||
import { QuitStrataDialog } from '../components/QuitStrata';
|
||||
import { useAppMode } from '../lib/app-mode';
|
||||
import { href, useLocation, type Route } from '../lib/router';
|
||||
@@ -162,8 +163,9 @@ function RecentChatLink({ session, active, slot }: {
|
||||
const rect = anchor.current?.getBoundingClientRect();
|
||||
if (!rect)
|
||||
return;
|
||||
const height = tooltip.current?.getBoundingClientRect().height ?? 80;
|
||||
setPosition({ left: rect.right + 12, top: Math.max(12, Math.min(rect.top, window.innerHeight - height - 12)) });
|
||||
const scale = measureScale();
|
||||
const height = tooltip.current?.getBoundingClientRect().height ?? 80 * scale;
|
||||
setPosition({ left: (rect.right + 12) / scale, top: Math.max(12, Math.min(rect.top, window.innerHeight - height - 12)) / scale });
|
||||
};
|
||||
place();
|
||||
window.addEventListener('resize', place);
|
||||
|
||||
@@ -7,7 +7,7 @@ Original SVGs supplied by Niko in `modelandweights.zip`, 10 October 2026.
|
||||
|
||||
The originals retain their metadata and vector geometry. The app sprite strips
|
||||
metadata and uses their 24 x 24 viewBox, currentColor, 1.5px strokes, and 12% fills.
|
||||
`scripts/sync-design-kit.mjs` includes these files when rebuilding the kit sprite.
|
||||
They are already in the checked-in sprite, `public/icons/sprite.svg`, which is the source: a new icon is added there as a `<symbol id="i-NAME">`.
|
||||
|
||||
Also supplied by Niko on 10 October 2026:
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { tr } from '../lib/i18n';
|
||||
import { PreviewControls } from './PreviewControls';
|
||||
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, 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 { PreviewMarkdown } from './PreviewMarkdown';
|
||||
import { SyntaxCode } from './SyntaxCode';
|
||||
import { FloatingMenu } from './FloatingMenu';
|
||||
import { TablePreview } from './TablePreview';
|
||||
import { artifactFilename, artifactIcon, safePreviewUrl, type PreviewArtifact } from '../lib/artifacts';
|
||||
import { scrollBehavior } from '../lib/responsive';
|
||||
import { previewVersion } from '../lib/preview-merge';
|
||||
const PdfPreview = lazy(() => import('./PdfPreview'));
|
||||
export function ArtifactPanel({ items, selected, onSelect, onClose, onCloseTab, inline }: {
|
||||
items: PreviewArtifact[];
|
||||
@@ -20,17 +21,22 @@ export function ArtifactPanel({ items, selected, onSelect, onClose, onCloseTab,
|
||||
inline: boolean;
|
||||
}) {
|
||||
const current = items.find(a => a.id === selected);
|
||||
// What the text loading depends on: the file's identity and its content version, not the object (the chat hands over new objects often).
|
||||
const version = useMemo(() => current ? previewVersion(current) : '', [current]);
|
||||
const [text, setText] = useState(''), [error, setError] = useState(''), [mode, setMode] = useState<'preview' | 'source'>('preview'), [revision, setRevision] = useState(0), [loading, setLoading] = useState(false), [imageSize, setImageSize] = useState('fit');
|
||||
const [toolbarHost, setToolbarHost] = useState<HTMLElement | null>(null);
|
||||
const [fileMenu, setFileMenu] = useState(false);
|
||||
const fileMenuRef = useRef<HTMLButtonElement>(null);
|
||||
useLayoutEffect(() => { setToolbarHost(inline && current ? document.getElementById('preview-file-actions') : null); }, [inline, current?.id]);
|
||||
// A different file starts in the reading view; the same file with new content keeps the person's view.
|
||||
useEffect(() => {
|
||||
setError('');
|
||||
setMode('preview');
|
||||
setLoading(false);
|
||||
setImageSize('fit');
|
||||
setFileMenu(false);
|
||||
}, [current?.id]);
|
||||
useEffect(() => {
|
||||
setError('');
|
||||
setLoading(false);
|
||||
if (!current || !['markdown', 'code', 'table'].includes(current.kind))
|
||||
return;
|
||||
if (current.content !== undefined) {
|
||||
@@ -61,7 +67,7 @@ export function ArtifactPanel({ items, selected, onSelect, onClose, onCloseTab,
|
||||
setLoading(false);
|
||||
});
|
||||
return () => abort.abort();
|
||||
}, [current?.id, revision]);
|
||||
}, [current?.id, version, 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);
|
||||
@@ -84,7 +90,7 @@ export function ArtifactPanel({ items, selected, onSelect, onClose, onCloseTab,
|
||||
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 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>)}
|
||||
{tr(current.kind === 'website' ? <><iframe key={`${current.id}-${revision}-${version}`} 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}-${version}`} 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}-${version}`} 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"><PreviewMarkdown text={text}/></article>)}
|
||||
</div>
|
||||
{tr(viewControls ? <div className="artifact-view-controls">{tr(viewControls)}</div> : null)}
|
||||
</aside>;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Icon } from './ui';
|
||||
*/
|
||||
export function FamilySwitch({ families, selected, onPick }: { families: CatalogModel[]; selected?: string; onPick: (family: CatalogModel) => void }) {
|
||||
const shown = families.filter(f => f.sizes.length);
|
||||
const missing = families.filter(f => !f.sizes.length && f.available === false); // a model of the update feed this engine does not run: said, never offered
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!shown.length) return null;
|
||||
@@ -19,12 +20,20 @@ export function FamilySwitch({ families, selected, onPick }: { families: Catalog
|
||||
<Icon name="models" size={20}/><strong>{tr(current.title)}</strong><Icon name="chevron" size={16} className="family-trigger__chev"/>
|
||||
</button>
|
||||
{tr(open ? <FloatingMenu anchor={trigger.current} title={tr("Model family")} width={320} onClose={() => setOpen(false)}>
|
||||
<div className="family-menu">
|
||||
{shown.map(f => <button key={f.family} type="button" role="menuitemradio" aria-checked={f.family === current.family} className="family-menu__item" onClick={() => { setOpen(false); if (f.family !== current.family) onPick(f); }}>
|
||||
<Icon name="models" size={18}/><span><strong>{tr(f.title)}</strong>{f.experimental ? <small>{tr("Experimental")}</small> : null}</span>
|
||||
{f.family === current.family ? <Icon name="check" size={16} className="family-menu__check"/> : null}
|
||||
</button>)}
|
||||
</div>
|
||||
<FamilyMenuList shown={shown} missing={missing} current={current} onPick={f => { setOpen(false); if (f.family !== current.family) onPick(f); }}/>
|
||||
</FloatingMenu> : null)}
|
||||
</>;
|
||||
}
|
||||
|
||||
/** The rows of the family menu: the families to pick, then (as notes, not choices) the models of the update feed this engine cannot use, each with the plain reason. */
|
||||
export function FamilyMenuList({ shown, missing, current, onPick }: { shown: CatalogModel[]; missing: CatalogModel[]; current: CatalogModel; onPick: (family: CatalogModel) => void }) {
|
||||
return (
|
||||
<div className="family-menu">
|
||||
{shown.map(f => <button key={f.family} type="button" role="menuitemradio" aria-checked={f.family === current.family} className="family-menu__item" onClick={() => onPick(f)}>
|
||||
<Icon name="models" size={18}/><span><strong>{tr(f.title)}</strong>{f.experimental ? <small>{tr("Experimental")}</small> : null}{f.available === false && f.unavailable_reason ? <small>{f.unavailable_reason}</small> : null}</span>
|
||||
{f.family === current.family ? <Icon name="check" size={16} className="family-menu__check"/> : null}
|
||||
</button>)}
|
||||
{missing.map(f => <div key={f.family} className="family-menu__item" role="note" aria-disabled="true"><Icon name="models" size={18}/><span><strong>{f.title}</strong><small>{f.unavailable_reason}</small></span></div>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { tr } from '../lib/i18n';
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { measureScale, placeAnchored } from '../lib/overlay-place';
|
||||
export function FloatingMenu({ anchor, title, onClose, children, width = 232 }: {
|
||||
anchor: HTMLElement | null;
|
||||
title: string;
|
||||
@@ -9,22 +10,28 @@ export function FloatingMenu({ anchor, title, onClose, children, width = 232 }:
|
||||
width?: number;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [placed, setPlaced] = useState({ left: 12, top: 12 });
|
||||
const [placed, setPlaced] = useState<{ left: number; top: number; maxWidth?: number; maxHeight?: number }>({ left: 12, top: 12 });
|
||||
useLayoutEffect(() => {
|
||||
const rect = anchor?.getBoundingClientRect();
|
||||
const height = ref.current?.getBoundingClientRect().height ?? 200;
|
||||
const left = Math.max(12, Math.min(rect?.left ?? 12, window.innerWidth - Math.min(width, window.innerWidth - 24) - 12));
|
||||
const below = (rect?.bottom ?? 12) + 8; // a little air between the button and its panel
|
||||
const top = below + height + 12 <= window.innerHeight ? below : Math.max(12, (rect?.top ?? window.innerHeight) - height - 6);
|
||||
setPlaced({ left, top });
|
||||
// Placed in visual pixels and written in the page's own pixels (lib/overlay-place.ts): the page may be scaled by "Interface size" (#4653).
|
||||
const place = () => {
|
||||
const el = ref.current;
|
||||
const scale = measureScale();
|
||||
const rect = anchor?.getBoundingClientRect();
|
||||
const natural = el ? Math.max(el.getBoundingClientRect().height, el.scrollHeight * scale) : 200 * scale;
|
||||
const home = { left: 12, top: 12, right: 12, bottom: 12 };
|
||||
const p = placeAnchored({ anchor: rect ?? home, size: { width: width * scale, height: natural }, viewport: { width: window.innerWidth, height: window.innerHeight }, scale });
|
||||
setPlaced({ left: p.left, top: p.top, maxWidth: p.maxWidth, maxHeight: p.maxHeight });
|
||||
};
|
||||
place();
|
||||
ref.current?.querySelector<HTMLElement>('[role="menuitem"]:not(:disabled), [role="menuitemradio"]:not(:disabled)')?.focus();
|
||||
const outside = (e: PointerEvent) => { if (!ref.current?.contains(e.target as Node) && !anchor?.contains(e.target as Node))
|
||||
onClose(); };
|
||||
document.addEventListener('pointerdown', outside);
|
||||
return () => { document.removeEventListener('pointerdown', outside); if (anchor?.isConnected)
|
||||
window.addEventListener('resize', place);
|
||||
return () => { document.removeEventListener('pointerdown', outside); window.removeEventListener('resize', place); if (anchor?.isConnected)
|
||||
anchor.focus({ preventScroll: true }); };
|
||||
}, [anchor, width]);
|
||||
return createPortal(<div className="menu" role="menu" aria-label={tr(title)} ref={ref} style={{ ...placed, zIndex: anchor?.closest('[role="dialog"]') ? 'var(--layer-dialog-popover)' : 'var(--layer-popover)', width, maxWidth: 'calc(100dvw - 24px)', maxHeight: `calc(100dvh - ${placed.top + 12}px)`, overflowY: 'auto' }} onKeyDown={e => {
|
||||
return createPortal(<div className="menu" role="menu" aria-label={tr(title)} ref={ref} style={{ ...placed, zIndex: anchor?.closest('[role="dialog"]') ? 'var(--layer-dialog-popover)' : 'var(--layer-popover)', width, maxWidth: placed.maxWidth ?? 'calc(100dvw - 24px)', maxHeight: placed.maxHeight ?? 'calc(100dvh - 24px)', overflowY: 'auto' }} onKeyDown={e => {
|
||||
const nodes = Array.from(ref.current?.querySelectorAll<HTMLElement>('[role="menuitem"]:not(:disabled), [role="menuitemradio"]:not(:disabled)') ?? []);
|
||||
const at = nodes.indexOf(document.activeElement as HTMLElement);
|
||||
if (['Escape', 'Tab'].includes(e.key)) {
|
||||
|
||||
@@ -70,7 +70,9 @@ export function Markdown({ text, onOpenLink, previewId, onPreview, onClosePrevie
|
||||
imageSession?: string | null;
|
||||
}) {
|
||||
const nodes: ReactNode[] = [];
|
||||
const lines = text.split('\n');
|
||||
// A file written on Windows has \r\n: the \r stays at the end of every line, where `.` does not match it, so a list line was never taken and the
|
||||
// list loop below never moved on (an endless loop that filled the page's memory, found in the sidebar preview). Line ends are read as one.
|
||||
const lines = text.split(/\r\n|\r|\n/);
|
||||
for (let i = 0; i < lines.length;) {
|
||||
const line = lines[i];
|
||||
if (!line.trim()) {
|
||||
@@ -111,6 +113,11 @@ export function Markdown({ text, onOpenLink, previewId, onPreview, onClosePrevie
|
||||
items.push(<li key={i}>{tr(inline(lines[i].replace(re, '$1'), onOpenLink))}</li>);
|
||||
i++;
|
||||
}
|
||||
if (i === key) { // never stand still: a line the list start accepted but the item pattern did not is shown as text and passed
|
||||
nodes.push(<p key={key}>{line}</p>);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
nodes.push(ordered ? <ol key={key}>{tr(items)}</ol> : <ul key={key}>{tr(items)}</ul>);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { NativeDeleteDialog } from './NativeDelete';
|
||||
import { plainFailureText } from '../lib/plain-errors';
|
||||
import { api } from '../lib/client';
|
||||
import { go, setSettingsSection, selectRemoteModel, pushToast } from '../lib/store';
|
||||
import { nativeAction, useNative, type NativeJob } from '../lib/native';
|
||||
import { installedListPending, nativeAction, useNative, type NativeJob } from '../lib/native';
|
||||
import { tuneEffective } from '../lib/native-tune';
|
||||
import { tunedFields } from '../lib/tune-values';
|
||||
import { shownVision, steadyNoReader } from '../lib/pictures-choice';
|
||||
@@ -69,7 +69,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
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);
|
||||
const installed = native?.installed?.models.find(m => m.model === selectedSize?.id), listPending = installedListPending(native);
|
||||
// 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.
|
||||
@@ -129,14 +129,16 @@ 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".
|
||||
// How the chosen version sits on this computer (the same rule as the version list): the page says a word only for "may run slower".
|
||||
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): 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;
|
||||
// Control's plan says the all-cards split engine takes an Auto start on this computer (it runs the MTP draft layer); it needs two or more cards, so one card chosen in the roles is not it.
|
||||
const splitCards = native?.capabilities?.dflash2Split === true && cardsGpus.length !== 1;
|
||||
// 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';
|
||||
const nowDrafter = draftingChoices ? resolveDrafter(drafting, selectedSize?.sourceFamily ?? selectedFamily?.family, native?.capabilities?.dflash2, splitCards) : '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' };
|
||||
@@ -244,12 +246,12 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
<div className="native-model-layout native-model-layout--fit">
|
||||
<div className="stack">
|
||||
{tr(native?.instances.length && expert ? <section className="st-card native-running"><h2 className="h4">{tr(native.instances.length === 1 ? 'Loaded model' : 'Loaded models')}</h2>{tr(native.instances.map(i => <div key={i.id}><span><strong>{tr(i.label)}</strong><small>{tr(i.gpus.length ? i.gpus.map((g, n) => `GPU ${g + 1}${i.gpus.length > 1 && n === i.gpus.length - 1 ? ' (Main)' : ''}`).join(' + ') : 'Automatic GPU')} · {tr(i.status === 'ready' ? i.phase : i.status)}{tr(native.defaultId === i.id ? ' · Chat default' : '')}</small>{tr(i.error ? <small className="inline-error">{tr(plainFailureText(i.error).message)}</small> : null)}</span>{tr(expert && multiple && native.defaultId !== i.id ? <Btn size="sm" variant="ghost" onClick={() => void loadedAction(`instances/${i.id}/default`)}>{tr("Use in chat")}</Btn> : null)}<Btn size="sm" disabled={pending || !!active} onClick={() => void loadedAction(`instances/${i.id}/unload`)}>{tr("Unload")}</Btn></div>))}{tr(loadedError ? <ErrorNotice message={loadedError} next={tr("Stop or answer the chat that is still using this model, then try again.")}/> : null)}</section> : null)}
|
||||
<section className="st-card native-choice">{tr(selectedFamily && (!selectedSize || selectedSize.needsNewerEngine) ? <><h2 className="h3">{tr("This model needs a newer engine.")}</h2><p className="small muted">{tr("Update your engine or import a local GGUF for this model.")}</p><Btn onClick={() => { setSettingsSection('Engine'); go('settings'); }}>{tr("Update engine")}</Btn><Btn variant="ghost" onClick={onImport ?? onOtherModels}>{tr("Use a local GGUF\u2026")}</Btn></> : null)}{tr(!selectedFamily ? <><h2 className="h3">{tr("Set up Strata")}</h2><p className="small muted">{tr("Connect your local installation to choose an optimized model.")}</p><Btn variant="primary" onClick={onConnectEngine ?? (() => { setSettingsSection('Engine'); go('settings'); })}>{tr("Connect engine")}</Btn>{tr(native?.error ? <p className="small inline-error">{tr(native.error)}</p> : null)}<Btn variant="ghost" onClick={onOtherModels}>{tr("Other local models")}</Btn></> : null)}
|
||||
<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 && !listPending ? <><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" 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(fitState === 'ssd' ? <p className="small muted native-spill-note"><Icon name="disk" size={16}/>{tr("may run slower")}</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(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>
|
||||
@@ -266,12 +268,12 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
<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(active && drafterGb ? <p className="small muted native-spill-note"><Icon name="info" size={16}/>{tr(nowDrafter === 'mtp' ? (splitCards && drafting === 'auto' ? "Auto uses the all-cards split engine with the MTP draft layer: about {0} GB to download, once." : "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 && !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>
|
||||
<div className="native-model-actions">{tr(!loadedSelected && !listPending && !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={listPending || !installed?.ready || !native?.available || !!active || !selectedSize || selectedSize.needsNewerEngine} onClick={() => setTuneOpen(true)}>{tr("Tune model")}</Btn><Btn variant="primary" icon="models" disabled={pending || listPending || !native?.available || !!active || !selectedSize || !!selectedSize.needsNewerEngine} onClick={() => void load()}>{tr(pending || listPending ? '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={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)}
|
||||
|
||||
@@ -28,6 +28,7 @@ export function NativeSettings({ model }: {
|
||||
}) {
|
||||
const native = useNative();
|
||||
const [revision,setRevision]=useState(0);
|
||||
const [hints, setHints] = useState<Record<string, string>>({}); // a field's hint worded for this model (the Drafting size)
|
||||
const [switches, setSwitches] = useState<SpeedSwitch[]>([]); // the speed switches that apply to this model on this PC
|
||||
const [dsExperts, setDsExperts] = useState(false); // the installed DeepSeek engine knows --experts (0.1.50 or newer)
|
||||
const [tuned, setTuned] = useState<{ preset: { name: string } | null; changed: TunedChange[] } | null>(null);
|
||||
@@ -44,6 +45,7 @@ export function NativeSettings({ model }: {
|
||||
values: Record<string, any>;
|
||||
mcp: Record<string, any>;
|
||||
tuned?: { preset: { name: string } | null; changed: TunedChange[] } | null;
|
||||
hints?: Record<string, string>;
|
||||
capabilities?: { dsExperts?: boolean };
|
||||
speedSwitches?: SpeedSwitch[];
|
||||
}>(`/api/native/models/${model}/settings`).then(input => {
|
||||
@@ -52,6 +54,7 @@ export function NativeSettings({ model }: {
|
||||
setDsExperts(!!input.capabilities?.dsExperts);
|
||||
setSwitches(input.speedSwitches ?? []);
|
||||
setTuned(input.tuned ?? null);
|
||||
setHints(input.hints ?? {});
|
||||
const value = nativeSettingsResponse(input);
|
||||
setValues(value.values);
|
||||
setText(JSON.stringify(value.values, null, 2));
|
||||
@@ -120,7 +123,7 @@ export function NativeSettings({ model }: {
|
||||
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} 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></>)}
|
||||
{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(hints[f.id] ?? 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>;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ function FreeMeter({ label, total, free }: { label: string; total: number; free:
|
||||
* "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.)
|
||||
* memory is said once, under the version picker ("may 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, 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;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { tr } from '../lib/i18n';
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { IconBtn } from './ui';
|
||||
import { measureScale } from '../lib/overlay-place';
|
||||
/** Anchored, non-modal surface. Focus and dismissal stay with its own trigger. */
|
||||
export function Popover({ anchor, title, detail, onClose, children, above = false, width = 360 }: {
|
||||
anchor: HTMLElement | null;
|
||||
@@ -15,8 +16,10 @@ export function Popover({ anchor, title, detail, onClose, children, above = fals
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState({ left: 12, top: 12 });
|
||||
const [maxWidth, setMaxWidth] = useState<number | string>('calc(100vw - 24px)');
|
||||
useLayoutEffect(() => {
|
||||
const place = () => { const r = anchor?.getBoundingClientRect(); const h = ref.current?.offsetHeight ?? 200; const w = Math.min(width, window.innerWidth - 24); const y = above ? (r?.top ?? h) - h - 10 : (r?.bottom ?? 12) + 8; setPosition({ left: Math.max(12, Math.min(r?.left ?? 12, window.innerWidth - w - 12)), top: Math.max(12, Math.min(y, window.innerHeight - h - 12)) }); };
|
||||
// The numbers from the anchor are visual pixels and the numbers written on the panel are the page's own: they differ when the page is scaled (#4653).
|
||||
const place = () => { const scale = measureScale(); const r = anchor?.getBoundingClientRect(); const h = (ref.current?.offsetHeight ?? 200) * scale; const w = Math.min(width * scale, window.innerWidth - 24); const y = above ? (r?.top ?? h) - h - 10 : (r?.bottom ?? 12) + 8; setPosition({ left: Math.max(12, Math.min(r?.left ?? 12, window.innerWidth - w - 12)) / scale, top: Math.max(12, Math.min(y, window.innerHeight - h - 12)) / scale }); setMaxWidth((window.innerWidth - 24) / scale); };
|
||||
place();
|
||||
ref.current?.focus({ preventScroll: true });
|
||||
const outside = (e: PointerEvent) => {
|
||||
@@ -31,7 +34,7 @@ export function Popover({ anchor, title, detail, onClose, children, above = fals
|
||||
return () => { resize.disconnect(); document.removeEventListener('pointerdown', outside); window.removeEventListener('resize', place); };
|
||||
}, [anchor, width, above]);
|
||||
function close() { onClose(); anchor?.focus({ preventScroll: true }); }
|
||||
return createPortal(<div ref={ref} role="dialog" aria-label={tr(title)} tabIndex={-1} className="telepop anchored-popover" style={{ ...position, zIndex: anchor?.closest('[role="dialog"]') ? 'var(--layer-dialog-popover)' : 'var(--layer-popover)', width, maxWidth: 'calc(100vw - 24px)' }} onKeyDown={e => {
|
||||
return createPortal(<div ref={ref} role="dialog" aria-label={tr(title)} tabIndex={-1} className="telepop anchored-popover" style={{ ...position, zIndex: anchor?.closest('[role="dialog"]') ? 'var(--layer-dialog-popover)' : 'var(--layer-popover)', width, maxWidth }} onKeyDown={e => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* The Markdown preview of the sidebar, bounded (#5180). Same elements as <Markdown>, but:
|
||||
* - memoised by the text, so a chat that renders for every group of streamed tokens does not rebuild an unchanged file;
|
||||
* - drawn in slices (lib/preview-render.ts) that are memoised one by one, so a file changed in one place re-renders that slice;
|
||||
* - only a few slices at a time, the rest added by one waiting step that a newer text or leaving the view cancels;
|
||||
* - a block too large to parse, and a file above the limit, shown as plain text.
|
||||
*/
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { Markdown } from './Markdown';
|
||||
import { SyntaxCode } from './SyntaxCode';
|
||||
import { previewSlices, previewTier, slicesAfter, slicesAtStart, textRevision } from '../lib/preview-render';
|
||||
|
||||
const Slice = memo(function Slice({ text, plain }: { text: string; plain: boolean }) {
|
||||
return plain ? <SyntaxCode text={text}/> : <Markdown text={text}/>;
|
||||
});
|
||||
|
||||
export const PreviewMarkdown = memo(function PreviewMarkdown({ text }: { text: string }) {
|
||||
if (previewTier(text) === 'plain')
|
||||
return <SyntaxCode text={text}/>;
|
||||
return <Sliced text={text}/>;
|
||||
});
|
||||
|
||||
function Sliced({ text }: { text: string }) {
|
||||
const slices = useMemo(() => previewSlices(text), [text]);
|
||||
const revision = useMemo(() => textRevision(text), [text]);
|
||||
const [shown, setShown] = useState(() => slicesAtStart(0, slices.length));
|
||||
// one step waiting at most; a new revision or leaving the view clears it, so work for an old text never runs
|
||||
useEffect(() => {
|
||||
if (shown >= slices.length)
|
||||
return;
|
||||
const timer = setTimeout(() => setShown(n => slicesAfter(n, slices.length)), 24);
|
||||
return () => clearTimeout(timer);
|
||||
}, [revision, shown, slices.length]);
|
||||
const count = Math.min(Math.max(shown, slicesAtStart(0, slices.length)), slices.length);
|
||||
return <>{slices.slice(0, count).map(s => <Slice key={s.key} text={s.text} plain={s.plain}/>)}</>;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { tr } from '../lib/i18n';
|
||||
|
||||
/** 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.';
|
||||
const NOTES = "Strata sends no usage telemetry. Conversations stay on this computer, in Strata's data folder. 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.";
|
||||
|
||||
export function PrivacyNotes() {
|
||||
return <div className="privacy-notes" aria-label={tr("Collection and logs")}>
|
||||
|
||||
@@ -88,7 +88,7 @@ export function SettingsUpdates() {
|
||||
</>;
|
||||
}
|
||||
|
||||
/** "Strata 0.2.10 · Engine 0.1.56": the app's own version first, the engine's beside it (About). */
|
||||
/** "Strata 0.2.13 · Engine 0.1.59": 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,7 +1,19 @@
|
||||
import { useMemo } from 'react';
|
||||
import { codeLanguage, highlightedLines } from '../lib/syntax';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { codeLanguage } from '../lib/code-language';
|
||||
import type { CodeToken } from '../lib/syntax';
|
||||
/** The Prism grammars are a separate file (src/lib/syntax.ts), fetched after the first screen is up (App.tsx asks for it a few seconds in) or by the first code block, whichever comes first. Until it is here a block shows its text uncoloured. */
|
||||
type Highlighter = typeof import('../lib/syntax');
|
||||
let loaded: Highlighter | null = null;
|
||||
let loading: Promise<Highlighter | null> | null = null;
|
||||
export function loadHighlighter(): Promise<Highlighter | null> {
|
||||
loading ??= import('../lib/syntax').then(module => (loaded = module), () => { loading = null; return null; });
|
||||
return loading;
|
||||
}
|
||||
const plainLines = (text: string): CodeToken[][] => text.split('\n').map(part => (part ? [{ text: part, classes: [] }] : []));
|
||||
export function SyntaxCode({text,language='',filename,lineNumbers=false,className=''}:{text:string;language?:string;filename?:string;lineNumbers?:boolean;className?:string}){
|
||||
const resolved=filename?codeLanguage(filename,true):codeLanguage(language);
|
||||
const lines=useMemo(()=>highlightedLines(text,resolved),[text,resolved]);
|
||||
const [highlighter,setHighlighter]=useState<Highlighter|null>(loaded);
|
||||
useEffect(()=>{if(highlighter)return;let live=true;void loadHighlighter().then(module=>{if(live&&module)setHighlighter(module);});return()=>{live=false;};},[highlighter]);
|
||||
const lines=useMemo(()=>highlighter?highlighter.highlightedLines(text,resolved):plainLines(text),[highlighter,text,resolved]);
|
||||
return <pre className={`syntax-code ${lineNumbers?'code-preview':''} ${className}`} data-language={resolved}><code>{lines.map((line,i)=><span className="syntax-line" key={i}>{lineNumbers?<span className="syntax-gutter" aria-hidden="true">{i+1}</span>:null}<span className="syntax-text">{line.map((part,j)=>part.classes.length?<span className={part.classes.map(c=>`syntax-${c}`).join(' ')} key={j}>{part.text}</span>:part.text)}{!lineNumbers&&i<lines.length-1?'\n':''}</span></span>)}</code></pre>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { tr } from '../lib/i18n';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { measureScale } from '../lib/overlay-place';
|
||||
export function VersionMemoryTooltip({ id, anchor, text, onClose, onEnter, onLeave }: {
|
||||
id: string;
|
||||
anchor: HTMLElement;
|
||||
@@ -12,11 +13,12 @@ export function VersionMemoryTooltip({ id, anchor, text, onClose, onEnter, onLea
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState({ left: 12, top: 12 });
|
||||
useLayoutEffect(() => {
|
||||
const rect = anchor.getBoundingClientRect(), width = Math.min(260, window.innerWidth - 24);
|
||||
const height = ref.current?.getBoundingClientRect().height ?? 80;
|
||||
const scale = measureScale();
|
||||
const rect = anchor.getBoundingClientRect(), width = Math.min(260 * scale, window.innerWidth - 24);
|
||||
const height = ref.current?.getBoundingClientRect().height ?? 80 * scale;
|
||||
const left = Math.max(12, Math.min(rect.right - width, window.innerWidth - width - 12));
|
||||
const top = rect.bottom + height + 20 <= window.innerHeight ? rect.bottom + 8 : Math.max(12, rect.top - height - 8);
|
||||
setPosition({ left, top });
|
||||
setPosition({ left: left / scale, top: top / scale });
|
||||
}, [anchor, text]);
|
||||
useEffect(() => {
|
||||
const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') {
|
||||
|
||||
@@ -26,9 +26,9 @@ const whole = (n: number) => `${Math.round(n)} GB`;
|
||||
|
||||
/**
|
||||
* Where the versions of the chosen base model are listed, each exactly once. At the top: the versions we recommend (the pick for this PC first; no "Recommended" tag since 11-02, the order says it) and the versions already downloaded (tagged "Downloaded"). Below, folded under "More
|
||||
* versions": all the others. A row is the name, small tags, a word only where one is needed ("SSD streaming · will run slower" for a version that does
|
||||
* not fully fit in memory, "Doesn't fit" where it cannot run at all; a version that fits says nothing: see `versionFit`) and the size. The maker, the
|
||||
* memory it needs and the SSD figure are behind "Show details" (open from the start in Expert mode). The other base models are not in this list: they
|
||||
* versions": all the others. A row is the name, small tags, a word only where one is needed ("may run slower" for a version that does
|
||||
* not fit in memory; a version that fits says nothing: see `versionFit`) and the size. The maker and the
|
||||
* memory it needs are behind "Show details" (open from the start in Expert mode). The other base models are not in this list: they
|
||||
* appear when picked at the top of the page.
|
||||
*/
|
||||
export function VersionPicker({ versions, selectedId, hardware, onSelect, onOwnGguf, downloaded, showRecommended = true, moreLabel, pick, cache, details: detailsOpen = false }: {
|
||||
@@ -67,21 +67,20 @@ export function VersionPicker({ versions, selectedId, hardware, onSelect, onOwnG
|
||||
const foldedOpen = moreOpen || !top.length || more.some(v => v.id === selectedId);
|
||||
|
||||
const need = (v: CatalogVersion, technical: boolean) => {
|
||||
const fit = fitOn(v, hardware, true, cache);
|
||||
if (technical) return v.multi_file && v.disk_gb ? tr("{0} RAM + VRAM, plus about {1} GB of disk", [gb(fit.memoryGb), Math.round(v.disk_gb)]) : tr("{0} RAM + VRAM", [gb(fit.memoryGb)]);
|
||||
return v.multi_file && v.disk_gb ? tr("Needs {0} of memory and about {1} GB of disk", [whole(fit.memoryGb), Math.round(v.disk_gb)]) : tr("Needs {0} of memory", [whole(fit.memoryGb)]);
|
||||
const size = typeof v.fit_gb === 'number' && Number.isFinite(v.fit_gb) ? v.fit_gb : fitOn(v, hardware, true, cache).memoryGb; // the files without the lookup table: the figure the label uses
|
||||
if (technical) return v.multi_file && v.disk_gb ? tr("{0} RAM + VRAM, plus about {1} GB of disk", [gb(size), Math.round(v.disk_gb)]) : tr("{0} RAM + VRAM", [gb(size)]);
|
||||
return v.multi_file && v.disk_gb ? tr("Needs {0} of memory and about {1} GB of disk", [whole(size), Math.round(v.disk_gb)]) : tr("Needs {0} of memory", [whole(size)]);
|
||||
};
|
||||
const row = (v: CatalogVersion) => {
|
||||
const fit = fitOn(v, hardware, true, cache);
|
||||
const on = v.id === selectedId, have = !!downloaded?.has(v.id);
|
||||
const state = versionFit(v, hardware, cache);
|
||||
const technical = [MAKERS[v.sourceFamily], need(v, true), state === 'ssd' ? tr("Uses SSD {0} GB", [Math.round(fit.spillGb)]) : ''].filter(Boolean).join(' · ');
|
||||
const technical = [MAKERS[v.sourceFamily], need(v, true)].filter(Boolean).join(' · ');
|
||||
const note = versionNote(v.model);
|
||||
return <button key={v.id} role="menuitemradio" aria-checked={on} className={cx('version-row', on && 'is-on')} onClick={() => { onSelect(v.id); setOpen(false); }}>
|
||||
<span className="version-row__check">{tr(on ? <Icon name="check" size={14}/> : null)}</span>
|
||||
<span className="version-row__body">
|
||||
<span className="version-row__title"><strong>{versionName(v)}</strong>{tr(have ? <span className="version-row__tags"><span className="version-tag version-tag--have">{tr("Downloaded")}</span></span> : null)}</span>
|
||||
{tr(state === 'ssd' || state === 'no' ? <span className="version-row__fit" data-fit={state}>{tr(state === 'ssd' ? 'SSD streaming · will run slower' : "Doesn't fit")}</span> : null)}
|
||||
{tr(state === 'ssd' ? <span className="version-row__fit" data-fit={state}>{tr('may run slower')}</span> : null)}
|
||||
{tr(details ? <span className="version-row__tech">{technical}{tr(note ? <><br/>{note}</> : null)}</span> : null)}
|
||||
</span>
|
||||
<span className="version-row__size">{have ? gb(v.download_gb) : tr("{0} download", [gb(v.download_gb)])}</span>
|
||||
|
||||
@@ -7,12 +7,13 @@ import { tr } from '../lib/i18n';
|
||||
* the same system. Anything added here is a documented extension that only uses
|
||||
* kit tokens — see src/styles/app.css.
|
||||
*/
|
||||
import { createContext, useContext, useCallback, useEffect, useId, useRef, useState, type AriaAttributes, type CSSProperties, type ReactNode } from 'react';
|
||||
import { createContext, useContext, useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type AriaAttributes, type CSSProperties, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { cx, userError } from '../lib/format';
|
||||
import { plainFailure } from '../lib/plain-errors';
|
||||
import { dismissActionError, dismissToast, holdToast, releaseToast, useStore } from '../lib/store';
|
||||
import { isTopSurface, useSurfaceFocus } from '../lib/focus';
|
||||
import { measureScale, placeAnchored } from '../lib/overlay-place';
|
||||
/* -------------------------------------------------------------------- icon */
|
||||
export type IconName = 'pause' | 'play' | 'activity' | 'plus' | 'shield' | 'computer'
|
||||
// Authored for the shell: see docs/design-system-notes.md.
|
||||
@@ -248,7 +249,7 @@ export function Select<T extends string>({ value, valueSuffix, options, onChange
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
up: boolean;
|
||||
maxHeight: number;
|
||||
} | null>(null);
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const [typed, setTyped] = useState('');
|
||||
@@ -260,34 +261,47 @@ export function Select<T extends string>({ value, valueSuffix, options, onChange
|
||||
if (refocus)
|
||||
triggerRef.current?.focus();
|
||||
}, []);
|
||||
const openMenu = useCallback(() => {
|
||||
/* Where the list goes, in screen pixels, written in the page's own pixels (lib/overlay-place.ts; the page may be scaled by "Interface size", #4653):
|
||||
below its button when the whole list fits there, else above, else the side with more room; as tall as the list needs, and no taller than
|
||||
the room (it then scrolls, using all of it). It used to be capped at 320 px whatever the window, and opened upward by a transform that
|
||||
sent it off the top of a short window. `measured` is the rendered popup, when there is one. */
|
||||
const placeList = useCallback((measured: HTMLElement | null) => {
|
||||
const el = triggerRef.current;
|
||||
if (!el)
|
||||
return;
|
||||
return null;
|
||||
const scale = measureScale();
|
||||
const r = el.getBoundingClientRect();
|
||||
const height = Math.min(320, options.length * (menuSize === 'comfortable' ? 52 : 38) + 12);
|
||||
const below = window.innerHeight - r.bottom;
|
||||
// Flip above the trigger when there is more room there than below.
|
||||
const up = below < height + 12 && r.top > below;
|
||||
const width = Math.min(menuWidth ?? Math.max(r.width, menuMinWidth ?? (menuSize === 'comfortable' ? 340 : 180)), window.innerWidth - 24);
|
||||
setPlaced({ left: Math.max(12, Math.min(r.left, window.innerWidth - width - 12)), top: up ? r.top - 6 : r.bottom + 6, width, up });
|
||||
const width = Math.min((menuWidth ?? Math.max(r.width / scale, menuMinWidth ?? (menuSize === 'comfortable' ? 340 : 180))) * scale, window.innerWidth - 24);
|
||||
const natural = measured
|
||||
? measured.scrollHeight * scale + (measured.getBoundingClientRect().height - measured.clientHeight * scale)
|
||||
: (options.length * (menuSize === 'comfortable' ? 52 : 38) + 12) * scale;
|
||||
const p = placeAnchored({ anchor: r, size: { width, height: natural }, viewport: { width: window.innerWidth, height: window.innerHeight }, scale, gapBelow: 6, gapAbove: 6 });
|
||||
return { left: p.left, top: p.top, width: width / scale, maxHeight: p.maxHeight };
|
||||
}, [options.length, menuSize, menuMinWidth, menuWidth]);
|
||||
const openMenu = useCallback(() => {
|
||||
const next = placeList(null);
|
||||
if (!next)
|
||||
return;
|
||||
setPlaced(next);
|
||||
setCursor(Math.max(0, options.findIndex((o) => o.value === value)));
|
||||
setOpen(true);
|
||||
}, [options, value, menuSize, menuMinWidth, menuWidth]);
|
||||
}, [options, value, placeList]);
|
||||
// Once the list is on the screen its real height is known: place it again with that.
|
||||
useLayoutEffect(() => {
|
||||
if (!open)
|
||||
return;
|
||||
const next = placeList(popupRef.current);
|
||||
if (next)
|
||||
setPlaced(next);
|
||||
}, [open, placeList]);
|
||||
// The popup is fixed-positioned, so it has to follow its trigger.
|
||||
useEffect(() => {
|
||||
if (!open)
|
||||
return undefined;
|
||||
const reposition = () => {
|
||||
const el = triggerRef.current;
|
||||
if (!el)
|
||||
return;
|
||||
const r = el.getBoundingClientRect();
|
||||
const height = popupRef.current?.offsetHeight ?? Math.min(320, options.length * (menuSize === 'comfortable' ? 52 : 38) + 12);
|
||||
const below = window.innerHeight - r.bottom;
|
||||
const up = below < height + 12 && r.top > below;
|
||||
const width = Math.min(menuWidth ?? Math.max(r.width, menuMinWidth ?? (menuSize === 'comfortable' ? 340 : 180)), window.innerWidth - 24);
|
||||
setPlaced({ left: Math.max(12, Math.min(r.left, window.innerWidth - width - 12)), top: up ? r.top - 6 : r.bottom + 6, width, up });
|
||||
const next = placeList(popupRef.current);
|
||||
if (next)
|
||||
setPlaced(next);
|
||||
};
|
||||
const onDocDown = (e: PointerEvent) => {
|
||||
if (!triggerRef.current?.contains(e.target as Node) && !popupRef.current?.contains(e.target as Node))
|
||||
@@ -301,7 +315,7 @@ export function Select<T extends string>({ value, valueSuffix, options, onChange
|
||||
window.removeEventListener('resize', reposition);
|
||||
document.removeEventListener('pointerdown', onDocDown, true);
|
||||
};
|
||||
}, [open, options.length, close, menuSize, menuMinWidth, menuWidth]);
|
||||
}, [open, close, placeList]);
|
||||
useEffect(() => {
|
||||
if (open)
|
||||
document.getElementById(`${listId}-${cursor}`)?.scrollIntoView({ block: 'nearest' });
|
||||
@@ -368,7 +382,7 @@ export function Select<T extends string>({ value, valueSuffix, options, onChange
|
||||
<Icon name="chevron" size={16}/>
|
||||
</button>
|
||||
{tr(open && placed
|
||||
? createPortal(<div ref={popupRef} id={listId} className={cx('select__pop', menuSize === 'comfortable' && 'select__pop--comfortable', singleLine && 'select__pop--single-line', menuClassName)} role="listbox" aria-label={tr(ariaLabel)} data-up={placed.up || undefined} style={{ left: placed.left, top: placed.top, width: placed.width, zIndex: triggerRef.current?.closest('[role="dialog"]') ? 'var(--layer-dialog-popover)' : 'var(--layer-popover)' }}>
|
||||
? createPortal(<div ref={popupRef} id={listId} className={cx('select__pop', menuSize === 'comfortable' && 'select__pop--comfortable', singleLine && 'select__pop--single-line', menuClassName)} role="listbox" aria-label={tr(ariaLabel)} style={{ left: placed.left, top: placed.top, width: placed.width, maxHeight: placed.maxHeight, zIndex: triggerRef.current?.closest('[role="dialog"]') ? 'var(--layer-dialog-popover)' : 'var(--layer-popover)' }}>
|
||||
{tr(options.map((o, i) => (<div key={o.value} id={`${listId}-${i}`} role="option" aria-selected={o.value === value} aria-disabled={o.disabled || undefined} tabIndex={-1} className="select__opt" data-active={i === cursor || undefined} onMouseEnter={() => setCursor(i)} onMouseDown={(e) => e.preventDefault()} onClick={() => {
|
||||
if (o.disabled)
|
||||
return;
|
||||
@@ -469,6 +483,7 @@ export function Toasts() {
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="st-toast__title">{tr(t.title)}</div>
|
||||
{tr(t.body ? <div className="st-toast__body">{tr(t.body)}</div> : null)}
|
||||
{tr(t.action ? <button type="button" className="st-btn st-btn--sm" style={{ marginTop: 8 }} onClick={t.action.run}>{tr(t.action.label)}</button> : null)}
|
||||
</div>
|
||||
<button type="button" className="st-btn st-btn--icon st-btn--sm" onClick={() => dismissToast(t.id)} aria-label={tr("Dismiss")}>
|
||||
<Icon name="chevron" size={15} style={{ transform: 'rotate(90deg)' }}/>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export interface PreviewArtifact { id: string; kind: 'website' | 'markdown' | 'pdf' | 'image' | 'code' | 'table'; title: string; url?: string; content?: string; demo?: boolean; }
|
||||
export interface PreviewArtifact { id: string; kind: 'website' | 'markdown' | 'pdf' | 'image' | 'code' | 'table'; title: string; url?: string; content?: string; demo?: boolean;
|
||||
/** The host's version of the file this was read from (modified time and size), for a preview with an address instead of text. */
|
||||
version?: string;
|
||||
/** When this copy was read or made (ms): a message's copy never replaces a later one (lib/preview-merge.ts). */
|
||||
at?: number; }
|
||||
export const PREVIEW_EXAMPLES: PreviewArtifact[] = [
|
||||
{ id:'sample-website', kind:'website', title:'Fieldnotes — website', url:'/examples/fieldnotes.html', demo:true },
|
||||
{ id:'sample-markdown', kind:'markdown', title:'project-brief.md', url:'/examples/project-brief.md', demo:true },
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/* All the interface strings of one language (de): the base file and the 13 add-on files, merged in the order the app has always used (a later file wins).
|
||||
Loaded the first time the language is used (see loadCatalog in i18n.ts); English stays in the main file because the English text is the key. */
|
||||
import base from '../locales/de.json';
|
||||
import tune from '../locales/tune-de.json';
|
||||
import tools from '../locales/tools-de.json';
|
||||
import fix from '../locales/fix-de.json';
|
||||
import gh from '../locales/gh-de.json';
|
||||
import ui from '../locales/ui-de.json';
|
||||
import engine from '../locales/engine-de.json';
|
||||
import pick from '../locales/pick-de.json';
|
||||
import df2 from '../locales/df2-de.json';
|
||||
import upd from '../locales/upd-de.json';
|
||||
import r3 from '../locales/r3-de.json';
|
||||
import pic from '../locales/pic-de.json';
|
||||
import gui from '../locales/gui-de.json';
|
||||
import sav from '../locales/sav-de.json';
|
||||
export default {...base,...tune,...tools,...fix,...gh,...ui,...engine,...pick,...df2,...upd,...r3,...pic,...gui,...sav} as Record<string,string>;
|
||||
@@ -0,0 +1,17 @@
|
||||
/* All the interface strings of one language (es): the base file and the 13 add-on files, merged in the order the app has always used (a later file wins).
|
||||
Loaded the first time the language is used (see loadCatalog in i18n.ts); English stays in the main file because the English text is the key. */
|
||||
import base from '../locales/es.json';
|
||||
import tune from '../locales/tune-es.json';
|
||||
import tools from '../locales/tools-es.json';
|
||||
import fix from '../locales/fix-es.json';
|
||||
import gh from '../locales/gh-es.json';
|
||||
import ui from '../locales/ui-es.json';
|
||||
import engine from '../locales/engine-es.json';
|
||||
import pick from '../locales/pick-es.json';
|
||||
import df2 from '../locales/df2-es.json';
|
||||
import upd from '../locales/upd-es.json';
|
||||
import r3 from '../locales/r3-es.json';
|
||||
import pic from '../locales/pic-es.json';
|
||||
import gui from '../locales/gui-es.json';
|
||||
import sav from '../locales/sav-es.json';
|
||||
export default {...base,...tune,...tools,...fix,...gh,...ui,...engine,...pick,...df2,...upd,...r3,...pic,...gui,...sav} as Record<string,string>;
|
||||
@@ -0,0 +1,17 @@
|
||||
/* All the interface strings of one language (fr): the base file and the 13 add-on files, merged in the order the app has always used (a later file wins).
|
||||
Loaded the first time the language is used (see loadCatalog in i18n.ts); English stays in the main file because the English text is the key. */
|
||||
import base from '../locales/fr.json';
|
||||
import tune from '../locales/tune-fr.json';
|
||||
import tools from '../locales/tools-fr.json';
|
||||
import fix from '../locales/fix-fr.json';
|
||||
import gh from '../locales/gh-fr.json';
|
||||
import ui from '../locales/ui-fr.json';
|
||||
import engine from '../locales/engine-fr.json';
|
||||
import pick from '../locales/pick-fr.json';
|
||||
import df2 from '../locales/df2-fr.json';
|
||||
import upd from '../locales/upd-fr.json';
|
||||
import r3 from '../locales/r3-fr.json';
|
||||
import pic from '../locales/pic-fr.json';
|
||||
import gui from '../locales/gui-fr.json';
|
||||
import sav from '../locales/sav-fr.json';
|
||||
export default {...base,...tune,...tools,...fix,...gh,...ui,...engine,...pick,...df2,...upd,...r3,...pic,...gui,...sav} as Record<string,string>;
|
||||
@@ -0,0 +1,17 @@
|
||||
/* All the interface strings of one language (ja): the base file and the 13 add-on files, merged in the order the app has always used (a later file wins).
|
||||
Loaded the first time the language is used (see loadCatalog in i18n.ts); English stays in the main file because the English text is the key. */
|
||||
import base from '../locales/ja.json';
|
||||
import tune from '../locales/tune-ja.json';
|
||||
import tools from '../locales/tools-ja.json';
|
||||
import fix from '../locales/fix-ja.json';
|
||||
import gh from '../locales/gh-ja.json';
|
||||
import ui from '../locales/ui-ja.json';
|
||||
import engine from '../locales/engine-ja.json';
|
||||
import pick from '../locales/pick-ja.json';
|
||||
import df2 from '../locales/df2-ja.json';
|
||||
import upd from '../locales/upd-ja.json';
|
||||
import r3 from '../locales/r3-ja.json';
|
||||
import pic from '../locales/pic-ja.json';
|
||||
import gui from '../locales/gui-ja.json';
|
||||
import sav from '../locales/sav-ja.json';
|
||||
export default {...base,...tune,...tools,...fix,...gh,...ui,...engine,...pick,...df2,...upd,...r3,...pic,...gui,...sav} as Record<string,string>;
|
||||
@@ -0,0 +1,17 @@
|
||||
/* All the interface strings of one language (ko): the base file and the 13 add-on files, merged in the order the app has always used (a later file wins).
|
||||
Loaded the first time the language is used (see loadCatalog in i18n.ts); English stays in the main file because the English text is the key. */
|
||||
import base from '../locales/ko.json';
|
||||
import tune from '../locales/tune-ko.json';
|
||||
import tools from '../locales/tools-ko.json';
|
||||
import fix from '../locales/fix-ko.json';
|
||||
import gh from '../locales/gh-ko.json';
|
||||
import ui from '../locales/ui-ko.json';
|
||||
import engine from '../locales/engine-ko.json';
|
||||
import pick from '../locales/pick-ko.json';
|
||||
import df2 from '../locales/df2-ko.json';
|
||||
import upd from '../locales/upd-ko.json';
|
||||
import r3 from '../locales/r3-ko.json';
|
||||
import pic from '../locales/pic-ko.json';
|
||||
import gui from '../locales/gui-ko.json';
|
||||
import sav from '../locales/sav-ko.json';
|
||||
export default {...base,...tune,...tools,...fix,...gh,...ui,...engine,...pick,...df2,...upd,...r3,...pic,...gui,...sav} as Record<string,string>;
|
||||
@@ -0,0 +1,17 @@
|
||||
/* All the interface strings of one language (pt): the base file and the 13 add-on files, merged in the order the app has always used (a later file wins).
|
||||
Loaded the first time the language is used (see loadCatalog in i18n.ts); English stays in the main file because the English text is the key. */
|
||||
import base from '../locales/pt.json';
|
||||
import tune from '../locales/tune-pt.json';
|
||||
import tools from '../locales/tools-pt.json';
|
||||
import fix from '../locales/fix-pt.json';
|
||||
import gh from '../locales/gh-pt.json';
|
||||
import ui from '../locales/ui-pt.json';
|
||||
import engine from '../locales/engine-pt.json';
|
||||
import pick from '../locales/pick-pt.json';
|
||||
import df2 from '../locales/df2-pt.json';
|
||||
import upd from '../locales/upd-pt.json';
|
||||
import r3 from '../locales/r3-pt.json';
|
||||
import pic from '../locales/pic-pt.json';
|
||||
import gui from '../locales/gui-pt.json';
|
||||
import sav from '../locales/sav-pt.json';
|
||||
export default {...base,...tune,...tools,...fix,...gh,...ui,...engine,...pick,...df2,...upd,...r3,...pic,...gui,...sav} as Record<string,string>;
|
||||
@@ -0,0 +1,17 @@
|
||||
/* All the interface strings of one language (ru): the base file and the 13 add-on files, merged in the order the app has always used (a later file wins).
|
||||
Loaded the first time the language is used (see loadCatalog in i18n.ts); English stays in the main file because the English text is the key. */
|
||||
import base from '../locales/ru.json';
|
||||
import tune from '../locales/tune-ru.json';
|
||||
import tools from '../locales/tools-ru.json';
|
||||
import fix from '../locales/fix-ru.json';
|
||||
import gh from '../locales/gh-ru.json';
|
||||
import ui from '../locales/ui-ru.json';
|
||||
import engine from '../locales/engine-ru.json';
|
||||
import pick from '../locales/pick-ru.json';
|
||||
import df2 from '../locales/df2-ru.json';
|
||||
import upd from '../locales/upd-ru.json';
|
||||
import r3 from '../locales/r3-ru.json';
|
||||
import pic from '../locales/pic-ru.json';
|
||||
import gui from '../locales/gui-ru.json';
|
||||
import sav from '../locales/sav-ru.json';
|
||||
export default {...base,...tune,...tools,...fix,...gh,...ui,...engine,...pick,...df2,...upd,...r3,...pic,...gui,...sav} as Record<string,string>;
|
||||
@@ -0,0 +1,17 @@
|
||||
/* All the interface strings of one language (zh): the base file and the 13 add-on files, merged in the order the app has always used (a later file wins).
|
||||
Loaded the first time the language is used (see loadCatalog in i18n.ts); English stays in the main file because the English text is the key. */
|
||||
import base from '../locales/zh.json';
|
||||
import tune from '../locales/tune-zh.json';
|
||||
import tools from '../locales/tools-zh.json';
|
||||
import fix from '../locales/fix-zh.json';
|
||||
import gh from '../locales/gh-zh.json';
|
||||
import ui from '../locales/ui-zh.json';
|
||||
import engine from '../locales/engine-zh.json';
|
||||
import pick from '../locales/pick-zh.json';
|
||||
import df2 from '../locales/df2-zh.json';
|
||||
import upd from '../locales/upd-zh.json';
|
||||
import r3 from '../locales/r3-zh.json';
|
||||
import pic from '../locales/pic-zh.json';
|
||||
import gui from '../locales/gui-zh.json';
|
||||
import sav from '../locales/sav-zh.json';
|
||||
export default {...base,...tune,...tools,...fix,...gh,...ui,...engine,...pick,...df2,...upd,...r3,...pic,...gui,...sav} as Record<string,string>;
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { Session } from './types';
|
||||
import {
|
||||
HOST_FLAG_KEY, PENDING_KEY, TransportError, readBrowserSessions,
|
||||
type ChatTransport, type HostChatEntry, type StorageLike,
|
||||
} from './session-persist';
|
||||
|
||||
/**
|
||||
* The page's side of the chat files on the host (server/chat-store.mjs): the HTTP transport, the first read at start (with the move of
|
||||
* the browser's old chats to the host), the last-moment copy for a page that goes away, and the merge of what another window saved.
|
||||
*/
|
||||
|
||||
const BASE = () => (globalThis as { __STRATA_HOST__?: string }).__STRATA_HOST__ ?? '';
|
||||
/** Names this window to the host, so the event of its own save is not mistaken for another window's. */
|
||||
export const WINDOW_ID = `w_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36).slice(-4)}`;
|
||||
|
||||
export function createHttpTransport(fetchImpl: typeof fetch = (...args) => fetch(...args)): ChatTransport {
|
||||
const headers = (extra: Record<string, string> = {}) => ({ 'x-strata-client': 'ui', 'x-strata-window': WINDOW_ID, ...extra });
|
||||
async function call(method: string, path: string, init: { body?: string; headers?: Record<string, string> } = {}): Promise<Response> {
|
||||
let res: Response;
|
||||
try { res = await fetchImpl(`${BASE()}${path}`, { method, headers: headers(init.headers), body: init.body }); }
|
||||
catch (e) { throw new TransportError(e instanceof Error ? e.message : 'The host did not answer.', 0, 'offline', true); }
|
||||
if (res.ok) return res;
|
||||
let code: string | undefined, message = `Request failed (${res.status})`;
|
||||
try { const data = await res.json() as { error?: string; code?: string }; code = data.code; if (data.error) message = data.error; } catch { /* no body */ }
|
||||
throw new TransportError(message, res.status, code);
|
||||
}
|
||||
return {
|
||||
async list() { return await (await call('GET', '/api/chats')).json() as { chats: HostChatEntry[]; damaged?: string[] }; },
|
||||
async get(id) {
|
||||
try {
|
||||
const data = await (await call('GET', `/api/chats/${encodeURIComponent(id)}`)).json() as { rev: number; session: Session };
|
||||
return { rev: Number(data.rev) || 0, session: data.session };
|
||||
} catch (e) { if (isTransportError(e) && (e as TransportError).status === 404) return null; throw e; }
|
||||
},
|
||||
async put(id, text, baseRev) {
|
||||
const res = await call('PUT', `/api/chats/${encodeURIComponent(id)}`, { body: text, headers: { 'content-type': 'application/json', ...(baseRev !== null ? { 'x-strata-base-rev': String(baseRev) } : {}) } });
|
||||
const data = await res.json() as { rev: number; conflict?: boolean };
|
||||
return { rev: Number(data.rev) || 0, conflict: data.conflict === true };
|
||||
},
|
||||
async remove(id) { await call('DELETE', `/api/chats/${encodeURIComponent(id)}`); },
|
||||
async importMany(sessions) {
|
||||
const res = await call('POST', '/api/chats/import', { body: JSON.stringify({ sessions }), headers: { 'content-type': 'application/json' } });
|
||||
return await res.json() as { imported: string[]; skipped: string[]; failed: { id: string; error: string; code?: string }[] };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface HydrateResult {
|
||||
/** The chats to show: the host's, newest first, with this page's own newer ones kept. */
|
||||
sessions: Session[];
|
||||
/** [id, rev] of every chat read from the host. */
|
||||
revs: Map<string, number>;
|
||||
/** The host could not be asked: `sessions` is what the page had, nothing was moved. */
|
||||
offline: boolean;
|
||||
/** Chats moved from the browser's storage to the host this time. */
|
||||
moved: number;
|
||||
/** Chats the host refused during the move (disk full / permission) or could not be read; they stay in the browser. */
|
||||
failed: { id: string; error: string; code?: string }[];
|
||||
/** Chats that came back from the last-moment copy because the host's version was older. */
|
||||
recovered: string[];
|
||||
}
|
||||
|
||||
/** By name, so it holds across two copies of this module (a bundle per test file). */
|
||||
const isTransportError = (e: unknown): boolean => e instanceof TransportError || (e as { name?: string } | null)?.name === 'TransportError';
|
||||
const newer = (a?: string | null, b?: string | null) => String(a ?? '') > String(b ?? '');
|
||||
|
||||
/** Read a batch of chats from the host, a few at a time. */
|
||||
async function readAll(transport: ChatTransport, entries: HostChatEntry[], parallel = 4): Promise<Map<string, { rev: number; session: Session }>> {
|
||||
const out = new Map<string, { rev: number; session: Session }>();
|
||||
let next = 0;
|
||||
const worker = async () => {
|
||||
for (;;) {
|
||||
const entry = entries[next++];
|
||||
if (!entry) return;
|
||||
const got = await transport.get(entry.id);
|
||||
if (got) out.set(entry.id, got);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(parallel, entries.length) }, worker));
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first read of the host's chats at start. Before the host has ever been given the browser's chats (no flag), they are handed over
|
||||
* (the host takes the ones it does not have, in one request) and the flag is set; the browser's copy is left untouched. When the host
|
||||
* cannot be asked the page keeps what it has and the writer stays off.
|
||||
*/
|
||||
export async function hydrateChats(opts: {
|
||||
transport: ChatTransport;
|
||||
browser: StorageLike;
|
||||
/** The chats in memory now (the browser's copy at start, or what this window already holds). */
|
||||
current: Session[];
|
||||
/** A chat that must not be replaced from the host (a turn is running in it). */
|
||||
isLive?: (id: string) => boolean;
|
||||
/** Whether a chat in memory has changes the host does not have yet. */
|
||||
isDirty?: (session: Session) => boolean;
|
||||
}): Promise<HydrateResult> {
|
||||
const { transport, browser } = opts;
|
||||
const isLive = opts.isLive ?? (() => false);
|
||||
const isDirty = opts.isDirty ?? (() => false);
|
||||
let moved = 0;
|
||||
const failed: HydrateResult['failed'] = [];
|
||||
try {
|
||||
let flagged = false;
|
||||
try { flagged = browser.getItem(HOST_FLAG_KEY) === '1'; } catch { flagged = false; }
|
||||
let list = await transport.list();
|
||||
if (!flagged) {
|
||||
const old = readBrowserSessions(browser).sessions;
|
||||
if (old.length) {
|
||||
const result = await transport.importMany(old);
|
||||
moved = result.imported.length;
|
||||
failed.push(...result.failed);
|
||||
list = await transport.list();
|
||||
}
|
||||
// Set only when nothing was refused: a disk-full move is tried again at the next start.
|
||||
if (!failed.length) { try { browser.setItem(HOST_FLAG_KEY, '1'); } catch { /* the flag is a courtesy; the host skips what it has */ } }
|
||||
}
|
||||
const fromHost = await readAll(transport, list.chats);
|
||||
const revs = new Map([...fromHost].map(([id, v]) => [id, v.rev]));
|
||||
const mine = new Map(opts.current.map(s => [s.id, s]));
|
||||
const merged: Session[] = [];
|
||||
for (const entry of list.chats) {
|
||||
const remote = fromHost.get(entry.id);
|
||||
const local = mine.get(entry.id);
|
||||
if (!remote) { if (local) merged.push(local); continue; }
|
||||
merged.push(local && (isLive(entry.id) || isDirty(local) || newer(local.updatedAt, remote.session.updatedAt)) ? local : remote.session);
|
||||
mine.delete(entry.id);
|
||||
}
|
||||
// Chats only this page has (made while the host was being read): kept, and saved when the writer starts.
|
||||
const hostIds = new Set(list.chats.map(c => c.id));
|
||||
const onlyHere = opts.current.filter(s => !hostIds.has(s.id));
|
||||
const sessions = [...onlyHere, ...merged];
|
||||
const recovered = takeLastMomentCopy(browser, sessions, revs);
|
||||
return { sessions: recovered.sessions, revs, offline: false, moved, failed, recovered: recovered.ids };
|
||||
} catch (e) {
|
||||
if (isTransportError(e)) return { sessions: opts.current, revs: new Map(), offline: true, moved, failed, recovered: [] };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- the last-moment copy */
|
||||
|
||||
/** The page is going away with chats the host has not confirmed: keep them (best effort, may not fit) for the next start. */
|
||||
export function keepLastMomentCopy(storage: StorageLike, sessions: Session[]): boolean {
|
||||
try {
|
||||
if (!sessions.length) { storage.removeItem(PENDING_KEY); return true; }
|
||||
storage.setItem(PENDING_KEY, JSON.stringify(sessions));
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/** At start: chats from the last-moment copy that are newer than the host's (or the host lacks) replace the host's version in memory, to be saved. */
|
||||
export function takeLastMomentCopy(storage: StorageLike, sessions: Session[], revs: Map<string, number>): { sessions: Session[]; ids: string[] } {
|
||||
let kept: Session[] = [];
|
||||
try { const raw = storage.getItem(PENDING_KEY); if (raw) { const parsed = JSON.parse(raw) as unknown; if (Array.isArray(parsed)) kept = parsed.filter((s): s is Session => !!s && typeof (s as Session).id === 'string'); } } catch { kept = []; }
|
||||
if (!kept.length) return { sessions, ids: [] };
|
||||
const byId = new Map(sessions.map(s => [s.id, s]));
|
||||
const ids: string[] = [];
|
||||
for (const copy of kept) {
|
||||
const have = byId.get(copy.id);
|
||||
if (!have || newer(copy.updatedAt, have.updatedAt)) { byId.set(copy.id, copy); ids.push(copy.id); }
|
||||
}
|
||||
try { storage.removeItem(PENDING_KEY); } catch { /* removed at the next start */ }
|
||||
if (!ids.length) return { sessions, ids };
|
||||
const order = sessions.map(s => s.id);
|
||||
const result = order.map(id => byId.get(id)!).concat([...byId.keys()].filter(id => !order.includes(id)).map(id => byId.get(id)!));
|
||||
void revs;
|
||||
return { sessions: result, ids };
|
||||
}
|
||||
@@ -14,9 +14,10 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
async function request<T>(method: string, path: string, body?: unknown, signal?: AbortSignal): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method,
|
||||
signal,
|
||||
headers: body === undefined ? undefined : { 'content-type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
@@ -31,7 +32,7 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>('GET', path),
|
||||
get: <T>(path: string, signal?: AbortSignal) => request<T>('GET', path, undefined, signal),
|
||||
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
||||
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
|
||||
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
|
||||
@@ -47,7 +48,7 @@ export interface StreamEvent {
|
||||
export function onEvent(handler: (e: StreamEvent) => void, onStatus?: (s: 'live' | 'offline') => void) {
|
||||
let source: EventSource;
|
||||
let seenAt = Date.now();
|
||||
const names = ['engine', 'monitor', 'models', 'native', 'image-models', 'runtime', 'providers', 'serving', 'download', 'log', 'setup', 'skills', 'task', 'goal', 'timeskip', 'plan', 'queue'];
|
||||
const names = ['engine', 'monitor', 'models', 'native', 'image-models', 'runtime', 'providers', 'serving', 'download', 'log', 'setup', 'skills', 'task', 'goal', 'timeskip', 'plan', 'queue', 'chats'];
|
||||
const connect = () => {
|
||||
source?.close();
|
||||
seenAt = Date.now();
|
||||
|
||||
Loaded 100 of 1110 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user