Compare commits

...
404 Commits
Author SHA1 Message Date
Niko1221 011c9e600a README: RX 6000 cards get their own engine package in 0.2.2 (no installer of their own; built, not tested on a real card) 2026-10-21 11:07:39 +02:00
Niko1221 76f90e5843 Fix #3425: the memory tiers count RAM + VRAM less 5 GB that Windows and the system keep (setup.py and tools/strata_mcp.py, one constant each; the app and strata launch read the engine's pick; the Mac keeps its own macOS share) 2026-10-21 07:03:33 +02:00
Niko1221 328a3f5bd2 Gyro-M text: 16-21% slower than Q2_0 everywhere (setup's model list, strata_mcp, GYRO.md intro), as README and MODELS.md say 2026-10-21 05:04:54 +02:00
Niko1221 1df3a3108b Merge f-022-int (68864c564) into f-022-gyrocuda: release notes keep both sides 2026-10-21 05:03:36 +02:00
Niko1221 daa654bd35 Gyro docs: the 0.2.2 decode numbers on the pinned files (RTX 3060, 5 pairs, merged tree against f-022-int)
Gyro-S 54.3 -> 62.5 tok/s (+15.2%), Gyro-M 50.2 -> 54.8 (+9.1%), Q2_0 66.4 -> 67.1 (untouched); against Q2_0: Gyro-S 16% -> 7% slower, Gyro-M 23% -> 16%; prompts unchanged.
2026-10-21 05:03:12 +02:00
Niko1221 68864c564e Merge f-022-specfix: first-window HIP verify hang (PLE flag before the first launch of each window graph) + 1-token prompt starts the speculative loop 2026-10-21 03:48:34 +02:00
Niko1221 2c27afec08 docs/GYRO.md: Gyro-M was 24% slower than Q2_0 before 0.2.2 (the measured table says 24) 2026-10-21 02:57:31 +02:00
Niko1221 1c9bfd2a6e Version bump for 0.2.2 (proposed): app 0.2.2, engine and Control 0.1.47
Cargo.toml and Cargo.lock, desktop and host package.json, PRODUCT_VERSION, CMake project and Metal STRATA_VERSION, build_hip_linux PKGVER default, About comment, tests that pin the number, Docker image tags and docs, download names in the READMEs, AMD_HIP.md and UPDATES.md.
2026-10-21 02:57:22 +02:00
Niko1221 be4e24d421 Fix the gfx906 query-swizzle host guard test: the dispatch expression now spans two lines and reads kernel 2026-10-21 02:57:22 +02:00
Niko1221 3a7a6955cf docs: release notes 0.2.2 cover every merged stream (GPU roles, Gyro on Arc/Mac/NVIDIA/P100, models folder, NUMA, RX 6000, API key, own files, speed definition, fixes), what 0.2.1.1-0.2.1.4 already shipped, known limits 2026-10-21 02:26:58 +02:00
Niko1221 4f864bcd2d docs: translated READMEs give Gyro-M's decode gap to Q2_0 as 16-21% (RTX 3060 12 GB: 16%, R9700: 21%), like the English README 2026-10-21 02:22:24 +02:00
Niko1221 2c8eb88196 Merge f-022-gyrocuda: Gyro trellis kernels faster on NVIDIA (8-lane rows on every card above Pascal): Gyro-S +17%, Gyro-M +11% on an RTX 3060, identical answers 2026-10-21 02:20:43 +02:00
Niko1221 e0cfb80f53 Merge f-022-engfix: #3465 NaN retry reads the prompt batched, #3441/#3075 tool-call envelopes, #3503 cuda12 package keeps Pascal/Volta, #3480 queue priming respects GPU_MAX_HW_QUEUES 2026-10-21 01:48:02 +02:00
Niko1221 86d10ed8f9 verify (HIP): the first launch of each window graph reads the PLE rows and raises the PLE flag before cudaGraphLaunch, so the launch cannot wait for a GPU that waits for the flag (first-window hang on gfx1151; STRATA_VERIFY_EARLY_PLE_FLAG=0/1/2) 2026-10-21 01:38:57 +02:00
Niko1221 01f8c32559 Merge f-022-int into f-022-gyrocuda: Pascal path kept for cc 6.0, 8-lane trellis rows for every other CUDA card
iq_kernels.cu is f-022-int's (staged codebook, row chunks and staged activations on cc 6.0; STRATA_TQ_STAGE). On top of it, tq_sub8_on()
(CUDA, not cc 6.0, STRATA_TQ_STAGE / STRATA_TQ_SUB8 not 0) turns on the staged codebook and the staged 8-lane down rows for the other cards
and adds the 8-lane 160-dot gate/up rows (row_dot_160_sub8). RTX 3060, Gyro-M, 24 groups, 1 token: gate/up 142 us (the cc 6.0 path forced:
158, off: 225), down 77 (off: 184); the hashes of the outputs are equal in all three modes.
2026-10-21 01:16:12 +02:00
Niko1221 894f133b7d Fix #3465: after non-finite logits in a prompt window the retry reads the whole prompt batched
The reporter's prompts ended in non-finite logits in the window that reads the prompt's last few tokens (after the last turn boundary), every time and on every retry, and passed with --short-read 0. The cause on their machine (a Windows VM, sm_89, a Broadwell AVX2 CPU, IQ3_S) was not reproduced on a P100 with a Broadwell Xeon, an RTX 3060 or a Core Ultra.
The engine now reads the request that follows such a window with --short-read 0 for that one request, so the server's transparent retry succeeds instead of failing the same way. Nothing changes until a prompt window reports non-finite logits. STRATA_NAN_INJECT=1 exercises it.
2026-10-21 00:52:29 +02:00
Niko1221 8a4106a47e suffix drafter: randomized soundness test (small vocabularies, tiny tables, propose_after), clean under ASan/UBSan 2026-10-21 00:41:25 +02:00
Niko1221 4c3edf4765 generate: spec_loop_runs() names the speculative loop's start rule (+ test); STRATA_SPEC_WATCH=<s>, a stall watchdog with the stage names for the CLI's verify loop (stall report and thread dump, off by default) 2026-10-21 00:41:25 +02:00
Niko1221 f05725c332 Fix #3480: the HIP queue priming stands aside when GPU_MAX_HW_QUEUES is 1 or 2
With the runtime capped to two hardware queues the two primed queues are all of them, and the high-priority draft stream and the draft side stream share them: a 4K prompt on an RX 9060 XT read 681-753 tok/s primed and 1188-1238 unprimed. Primed only when the cap is 3 or more or unset; STRATA_HIP_QUEUE_PRIME=nn still forces it. HIP code only (gfx12, Linux).
2026-10-21 00:37:50 +02:00
Niko1221 8de1ec4165 Test: torch has one pin per Python range since #3474; compare the package set 2026-10-21 00:23:52 +02:00
Niko1221 3fbcea2f79 Merge f-022-issues2: #3488 #3502 #3490 #3507 #3424 #3501 #3504 #3425 #3469 #3437 #3510 2026-10-21 00:23:39 +02:00
Niko1221 dc16e3a7cb Fix #3510: on a card that shares the PC memory (Strix Halo) the Work profile no longer reserves a fifth of the shared pool as graphics memory 2026-10-21 00:23:09 +02:00
Niko1221 5cdb59cfa9 Fix #3469, #3437: UPDATE.bat and setup fetch the engine from the signed update feed when the release has no flat zip, and the minimum engine is 0.1.46 2026-10-21 00:21:24 +02:00
Niko1221 0cf83f610a Merge main (0.2.1.4 Terminal update: RX 9000 engine choice, download checks); engine choice keeps the RX 6000 tie-break 2026-10-21 00:20:01 +02:00
Niko1221 18b4affb87 Fix #3425: the recommendation says how much of the picked size would be read from the SSD when RAM + VRAM is only just the tier's floor 2026-10-21 00:16:23 +02:00
Niko1221 aae92ceb26 Gyro: decode speed on NVIDIA (docs, 0.2.2 notes), Gyro-M's slowdown against Q2_0 16-21%
GYRO.md gets the measured RTX 3060 numbers of the shared-memory codebook / 8-lane row kernels (Gyro-S 53.6 -> 62.8 tok/s, Gyro-M 50.6 -> 56.1, 5 interleaved pairs, identity gate identical), the release notes one line, and the places that said Gyro-M decodes about 20% slower than Q2_0 now say 16-21% (RTX 3060 12 GB after the kernels: 16%, R9700: 17-21%). The translated READMEs keep the old figure.
2026-10-21 00:14:08 +02:00
Niko1221 926e7a38b8 Merge f-022-uma-linux: Strix Halo + discrete card on Linux (no exit with PF_PAD, iGPU never last stage, UMA-aware expert cache, picture reader on a card with its own memory, nicer engine on the display chip); ports PR #3484 by John-Pi 2026-10-21 00:13:34 +02:00
Niko1221 366c8dbaae Fix #3503: the Linux cuda12 engine package carries sm_60/61/70; the source Dockerfile builds it
packaging/build_cuda_linux.sh is the recipe for the Linux cuda13 and cuda12 packages (cuda12: CUDA 12.9, 60;61;70;75;80;86;89, STRATA_EXPERIMENTAL_SM60=ON, driver 575.51). package_engine.py refuses a cuda12 package without sm_60, sm_61 or sm_70, so the 0.2.1 mistake (sm_75/86/89 only, a P100 container in a restart loop) cannot ship again.
The root Dockerfile takes the CUDA image from CUDA_IMAGE: the CUDA 12 case turns on the Pascal/Volta build when an architecture below 75 is listed, names the package cuda12 with the matching driver, and the terminal stage has git (package_terminal.py calls it).
2026-10-21 00:13:14 +02:00
Niko1221 70f4240728 Fix #3441, #3075: a drifted tool-call opener is read as the call; a call for a tool the request did not declare stays text
A <tool_call> whose opener drops the word function (<=NAME>, <function:NAME>, <NAME>) is read as <function=NAME> when NAME is a declared tool.
The answer follows the rule the reasoning already had (#804): a call counts only for a declared tool. Chat Completions and /v1/messages requests that declared no tools get the text back; the Responses route keeps its Codex compaction behaviour.
Blocks that open like a call but do not read as one are counted in the server window.
2026-10-21 00:08:55 +02:00
Niko1221 30a67051d0 setup: say when the BIOS gives a big carve-out to a Strix Halo this run does not use (the OS loses that RAM; the experts and the desktop need it) 2026-10-21 00:02:32 +02:00
Niko1221 b8b381a3b9 Fix #3504: a two-value option (control-vector-layer-range) is written and removed as two argv entries, and an empty Advanced value is no longer a bare flag 2026-10-21 00:02:01 +02:00
Niko1221 25d025fef8 docs: Strix Halo with a card of its own on Linux (RAM counted once, split order, picture reader, nice) 2026-10-21 00:00:22 +02:00
Niko1221 8895949ef7 gpu_fit: a chip that shares the system memory adds only its BIOS carve-out beside the RAM (a Strix Halo model was counted as VRAM and as RAM) 2026-10-21 00:00:22 +02:00
Niko1221 0da82e740c prefill gemm: skip the padded dequant on a device that has no padded product (a gfx12 stage of a gfx1151 + gfx1201 split)
After #3484 the process-wide STRATA_PF_PAD can be on for a gfx12 stage, which then dequantized every weight padded, found no product and dequantized it
again unpadded before the copy path. Ask strata_pf_padded_gemm_available() for the current device first; same products, same bits.
2026-10-21 00:00:22 +02:00
TheHubUser4860938 95cfbd4cc4 Fix #3501: in_trash splits backslashes, so a Windows Recycle Bin path is found on Linux too 2026-10-21 00:00:02 +02:00
Niko1221 c8b1436022 Fix #3507, #3424: first-run 'Use your own GGUF' goes to the local GGUF page instead of making the folder the data folder (which downloaded the files again); preparing own files never uses their folder as data_dir 2026-10-20 23:59:31 +02:00
Niko1221 9de04cd44e Control 0.1.46.3: version bump for the Terminal/Control-only update (engine packages unchanged) 2026-10-20 23:56:06 +02:00
Niko1221 31ca248fbe Progress words: a file being repaired after a wrong SHA-256 says so in plain words
Python only (strata_control/progress_words.py, test).
2026-10-20 23:55:59 +02:00
Niko1221 63214a5655 Downloads: every ranged answer is checked, a file with a wrong SHA-256 is repaired piece by piece instead of deleted
A 39.8 GB shard came out with the wrong SHA-256 on 0.2.1 and was deleted, 40 GB of download lost.

- every ranged answer must be a 206 with exactly the Content-Range asked for (start, end and total), a fitting
  Content-Length and the same ETag as the first answer; a changed file part-way stops the download and drops the .part
- the raw unbuffered write is looped until every byte is down (its return value was ignored)
- a .part records the address and ETag it came from (.part.src); one from another mirror or revision is dropped, not mixed
- a parallel download always has a piece list before the first byte is written; if none can be kept it uses one stream,
  so holes in the sparse file are never read as a finished prefix
- the one-stream resume accepts a 206 only at the byte asked for; another range restarts from zero
- a finished file with a pinned hash but no finish mark is checked, not marked; the rename waits for a virus scanner
- a SHA-256 mismatch re-reads the file from the server piece by piece, rewrites only the blocks that differ and checks
  again ("Some parts of the file were damaged; downloading only those parts again"); deleted only if still wrong
- update archives (strata_control/transfer.py): a resumed answer is appended only when its Content-Range starts at the byte asked for
- tests: tests/terminal/test_download_integrity.py (a local server that answers 200 for ranges, wrong ranges and totals,
  cut bodies, another ETag, 503s, flipped bytes)

Python only (setup.py, strata_control/transfer.py, tests).
2026-10-20 23:55:59 +02:00
Niko1221 86620837b2 Updates: an AMD card with an unknown gfx never gets the newest HIP build by version; read the gfx from the Windows display adapters, choose by GPU name, keep the installed variant 2026-10-20 23:55:36 +02:00
Niko1221 75d0d0b966 Fix #3490: tuning stops waiting for memory when none is being freed instead of ten minutes per candidate, and says how much is free and needed 2026-10-20 23:55:11 +02:00
Niko1221 6515aebac2 Fix #3502: a CUDA-init failure in a tuning run is not recorded as OOM (the memory guard info line no longer decides), and a command that passed before is not blacklisted by a later failure 2026-10-20 23:55:11 +02:00
Niko1221 f801953992 Linux integrated GPU: the auto expert cache counts the RAM once (cache <= RAM available - desktop reserve); the APU warning uses the same reserve
A Strix Halo's free VRAM is the GPU pool (carve-out + GTT, nearly all RAM with ttm.pages_limit raised) while the cache copies experts into that
same RAM on top of the arena, so a cache sized from the pool alone could ask for more than the OS had left (OOM killer). The auto size is now
min(GPU free, MemAvailable - max(8 GiB, 8% of RAM)); --expert-cache N is kept as asked, STRATA_UMA_RESERVE_GIB=N sets the reserve (0 = old sizing).
2026-10-20 23:53:10 +02:00
Niko1221 53752401f2 Fix #3488: a reused PID in a child marker without a start time no longer blocks every load; the engine hash check reads twice before refusing 2026-10-20 23:53:07 +02:00
johnpi ab61f892d8 prefill: a padded activation is gfx11 only, and Gemm::native copies it unpadded instead of ending the engine
STRATA_PF_PAD=1 STRATA_PF_GEMM=1 lets the two K 6144 producers write row stride 6,208 into the prompt GEMM, but the only
product that reads a padded stride is strata_pf_gemm_f16_ld, which is built into wmma_gemm.cu's gfx11 matrix-core block
and returns false everywhere else.  On an RDNA4 card Gemm::native then reached its "a padded X (ldx 6208, K 6144) needs
STRATA_PF_PAD's path" branch and called std::exit(1): under serve/ that is one engine restart per request.  Reproduced
on an RX 9070 (gfx1201, ROCm 7.2.4, kernel 7.2.11) at --vram-reserve-mib 700 and 1,536, so it was not the VRAM guard.

- wmma_gemm.cu/.h: strata_pf_padded_gemm_available() answers whether the padded product exists on the current device
  (the same gfx11 list the kernels are compiled for; false in a build with no gfx11 target).
- prefill.cpp: pf_pad() asks it before padding, and prints one line when the env asks for a product this card has not.
- gemm.cu/.hpp: a padded X that no kernel took is copied to a contiguous FP16 buffer (one cudaMemcpy2DAsync, one
  grow-only buffer per Gemm) and the ordinary paths run - the same products, the same bits.  exit(1) remains only for a
  failed allocation, with a message that says so.

Checked on the RX 9070, same 1,199-token prompt and 8 greedy tokens in every arm:
- before (0.1.45, pre-fix binary): `prefill gemm: a padded X (ldx 6208, K 6144) needs STRATA_PF_PAD's path`, exit 1.
- after, STRATA_PF_PAD=1 STRATA_PF_GEMM=1: `prefill gemm: STRATA_PF_PAD needs the padded gfx11 product, which this card
  does not run; not padding`, prompt and decode complete, exit 0, output identical to the unpadded run.
- after, with the gate forced open so that the producers really pad and Gemm::native takes the copy path: no exit, no
  "needs STRATA_PF_PAD's path", output identical to the unpadded run (13 271 248046 198 248045 74455 198 248068).
  With the gate in place STRATA_PF_GEMM=1 alone is inert on this card (4K 1,247 against 1,243 tok/s, 8K 1,330 against
  1,321, medians of 3 starts).

docs/AMD_HIP.md carries the RDNA4 bullet for it.  Built for gfx1201 (RX 9070, ROCm 7.2.4, Release): 338 of 338; ctest
77 of 79, the two failures (ple_parity without the Q2_0 GGUF here, hip_prefill_mmq_parity's APR block reading
ggml_type_name(145) with GGML_TYPE_COUNT 43) fail identically on origin/main.  The changed files are shared with the
CUDA build and were not compiled for CUDA here (no nvcc on this machine); SYCL is untouched.

(cherry picked from commit 341625013d)
2026-10-20 23:51:15 +02:00
Niko1221 034422c4d3 AMD: built-in Radeon graphics (Strix Halo) is never the last stage of a layer split, the picture reader leaves it, --gpus all leaves it out beside two cards, engine nice +5 on Linux with such a chip, split note
The #1576 memory guard compared the iGPU's BIOS carve-out with the dGPU's VRAM and could keep the iGPU as the last stage (head, draft layer, verify).
The picture reader inherited the engine's first visible card, the iGPU. setup --gpus all took every usable card including the APU.
All three are recommendations: a number list, gpu_order as_given, a manual layer split and vision.cuda_device are kept as asked.
2026-10-20 23:50:19 +02:00
Niko1221 aece46db89 generate: --spec on a native pack starts the speculative loop for a 1-token prompt (spec_pos -1 = unused, 0 is a real position) 2026-10-20 23:41:24 +02:00
Niko1221 1e7bcb8b6e Merge f-022-cores: start-up line lists the CPU worker processors, experimental --pool-cores, thread names, Windows no-topology fallback 2026-10-20 22:15:28 +02:00
Niko1221 c5eb2dd923 docs: reorder the placement section (threads that are not pinned, then the numbers) 2026-10-20 21:46:39 +02:00
Niko1221 88942998c5 Merge f-022-pascaltq: Gyro trellis kernels about 30% faster on Pascal (sm_60), other cards unchanged 2026-10-20 21:44:40 +02:00
Niko1221 0255581677 Release notes 0.2.2: Gyro on a Tesla P100 2026-10-20 21:43:27 +02:00
Niko1221 9b5b758358 tq_pascal_bench: other expert geometries (a longer hidden row, shorter experts) against the per-column kernels 2026-10-20 21:43:03 +02:00
Niko1221 29c811217f Merge f-022-dlintegrity: every ranged answer is checked (206, Content-Range, length, ETag), full writes, safe resume, and a SHA mismatch repairs only the differing parts instead of deleting the file 2026-10-20 21:29:41 +02:00
Niko1221 5f81c5dc47 Gyro decode (CUDA): trellis codebook in shared memory, 8-lane rows for the 160-dot gate/up and the 40-dot down
The int8 codebook lookups of tq_load went through the global table: a warp's 32 random 2-byte reads touched ~20 L1 sectors each
(native_expert_bench, ncu: 51M sectors for 2.8M requests, L1 64% busy). The grouped gate/up and down kernels now stage the 4 KB
codebook per block (stage_iq_grid, STRATA_IQ_STAGE_GRID=0 reads it in place) and take 4 rows per warp: gate/up K=160 (new
row_dot_160_sub8) and the K=40 down (row_dot_40_sub8, as IQ4_NL), 32 rows per block so the staging is amortised and no lane idles.
The sums keep row_dot_multi's tree (lane sums of t, t+8, t+16, t+24, then the same xor steps), so the outputs are bit for bit the
old ones: native_expert_bench prints an FNV hash of the outputs now, equal for base and new on Gyro-S, Gyro-M and Q2_0 blobs.
RTX 3060, 24 groups, 1 token: Gyro-M gate/up 221 -> 140 us, down 182 -> 86 us; Gyro-S 228 -> 157 and 173 -> 89.
2026-10-20 21:24:27 +02:00
Niko1221 cbf7f1d9f9 Test: Gyro-S and Gyro-M are in the Mac model list now (Mac Gyro support) 2026-10-20 21:24:07 +02:00
Niko1221 4e83ef04d5 Merge f-022-fitauto: a model just short of fitting gets a smaller context / reserve / prompt chunk automatically (Gyro-M on 8 GB Arc) 2026-10-20 21:23:40 +02:00
Niko1221 34d7b2a3ba Fit rule for cards that are just short: lower context, reserve and prompt chunk until the start fits (Gyro-M on the 8 GB Arc A750)
strata_control/vram_fit.py estimates the VRAM left for the expert cache (calibrated on 12 A750 starts) and walks the ladder 32K -> 16K -> 8K, prompt chunk 1024, reserve down to 100 MiB. Used by sycl/setup_intel.py (setup), strata launch's recommended context and the adopt path. A config that already fits is returned unchanged; values the user chose are kept; it never refuses.
2026-10-20 21:23:25 +02:00
Niko1221 31fa54fb68 Merge f-022-macgyro: Gyro-S and Gyro-M on Apple Silicon (Metal trellis kernels, Gyro dense tiles) 2026-10-20 20:57:36 +02:00
Niko1221 bc3f293861 Docs: Gyro-S on the Mac against the CUDA engine (KL at the CUDA noise level) 2026-10-20 20:57:15 +02:00
Niko1221 21b0fdb0d4 Merge f-022-int into f-022-macgyro: Gyro is listed as supported on Intel Arc and on the Mac (docs, seven READMEs, release notes keep both bullets) 2026-10-20 20:55:53 +02:00
Niko1221 564d0abebe Merge f-022-arcgyro: Gyro-S and Gyro-M on Intel Arc (SYCL trellis kernels, Hadamard, XMX prompt path) 2026-10-20 20:53:18 +02:00
Niko1221 413544b6d9 Merge f-022-int (e28889d12) into f-022-arcgyro 2026-10-20 20:51:47 +02:00
Niko1221 634e5826a5 docs: Gyro on Arc, pinned file and CUDA 1.4K-prompt check 2026-10-20 20:51:41 +02:00
Niko1221 c4f1e05dbd --pool-cores: a list of more than 4096 processors is refused by the parser (with a message; the pool then keeps its default placement) 2026-10-20 20:42:25 +02:00
Niko1221 e2b4464917 CPU pool: read the logical-processor layout when the pool is built
The start-up line and the --pool-cores check used the layout as seen by whichever thread asked, and on Linux a thread that was
already pinned (the host, after the session pins it) sees only its own CPU. The layout is now read in the constructor.
2026-10-20 20:24:00 +02:00
Niko1221 10edc50f05 start-up: no 'workers by default' line when --pool-cores gave the list (its own line says where they run) 2026-10-20 20:20:12 +02:00
Niko1221 f353087104 docs: Gyro on Intel Arc (GYRO.md, INTEL.md, release notes 0.2.2) 2026-10-20 20:14:39 +02:00
Niko1221 79946b9c4f Gyro on Intel Arc: README and MODELS say so, ctest cases for the trellis kernels, setup's Gyro memory warning reads the real RAM on the Intel path 2026-10-20 20:13:49 +02:00
Niko1221 4cb96acd81 docs: the CPU picture encoder stays where the OS puts it (pinning it one per core was 7 to 19 times slower on a 16-core Windows PC) 2026-10-20 20:01:56 +02:00
Niko1221 ca40c88ef4 Docs: Gyro on the Mac (MAC.md, GYRO.md, MODELS.md, README, 0.2.2 release notes) 2026-10-20 19:51:16 +02:00
Niko1221 e28889d120 Merge f-022-gpuroles: Main / Helper / Off per graphics card in the app, TUI, launch, setup, server and Docker 2026-10-20 19:48:30 +02:00
Niko1221 890fee81b0 GPU roles: two identical cards recommend the highest number as Main, so the engine order stays [0, 1]; a model that keeps one card keeps setup's old pick; golden back to [0, 1] with roles only 2026-10-20 19:48:20 +02:00
Niko1221 5e633b4ff7 Metal: Gyro dense K-quant GEMV tiles (+5% decode, 5/5 pairs, same output), GYRO.md supports the Mac 2026-10-20 19:39:16 +02:00
Niko1221 7c11a196d5 Server: the card roles (and a stop when every card is Off) come before anything starts 2026-10-20 19:31:34 +02:00
Niko1221 8bf77c0755 SYCL Gyro: trellis packs take the link share from a 12 GB/s reference (A750: 0.29 -> 0.48, decode 13.7 -> 15.8 tok/s, 5/5 pairs); Gyro memory warnings on an Arc read the real RAM 2026-10-20 19:21:44 +02:00
Niko1221 8c41d3611c Thread names: the Windows half lives in pool.cpp, not in a header every engine file includes
thread_name.hpp pulled <windows.h> (and WIN32_LEAN_AND_MEAN) into every file that includes aux_cpus.hpp; on Windows the function is now
defined in pool.cpp, which already includes it. The PLE reader and the direct file readers are not named (they do not link the pool).
2026-10-20 19:20:00 +02:00
Niko1221 2aa1ec2d56 serve, launch: "pool_cores" in the run config and strata launch --pool-cores
Both pass --pool-cores to the engine (experimental, off by default). A list that cannot be read is said and left out.
strata launch also shows the notices that the flags themselves produce (they were dropped before).
2026-10-20 19:16:17 +02:00
Niko1221 7a55995818 CPU pool: say where the workers run, --pool-cores (experimental), names for the engine's threads
The start-up now has one plain line: CPU workers on logical processors 2,4,...,30 (15 workers, one per physical core; host on 0).
It says so when two workers share a core, when the host's core is taken, and when the OS did not say which processors are
siblings. --pool-cores LIST / STRATA_POOL_CORES pins the workers to exactly the listed logical processors (off by default;
siblings, the host's processor and processors the process may not use are warned about, never refused). The default placement
is unchanged. The pool workers, the host thread and the long-lived helper threads get names (strata-pool-N, strata-host,
strata-stage, strata-adapt, ...), so ps -L, /proc and Windows thread tools show which is which. Windows without core
information no longer counts every logical processor as a core when it picks the default worker count.
2026-10-20 19:16:15 +02:00
Niko1221 82a97251ea Merge main (0.2.1.3 Terminal update); docs keep the 0.2.2 text 2026-10-20 19:13:58 +02:00
Niko1221 cbfd5b926b Merge main (0.2.1.2 engine hotfix) into the 0.2.1.3 Terminal update 2026-10-20 19:12:29 +02:00
Niko1221 2e58e320b5 MCP config description names gpu_roles 2026-10-20 19:05:28 +02:00
Niko1221 301ab7c7b4 strata launch: --gpu-roles summary line in the plan and before the start; tests for the flag, the mirror and installed roles 2026-10-20 19:04:17 +02:00
Niko1221 0fce78c1c3 Docs: Main, Helper, Off in MULTI_GPU.md (the main card is the last stage), Docker GPU_ROLES, wording fixes 2026-10-20 19:03:27 +02:00
Niko1221 095e524fca App: GPU roles in the generic Run screen (two or more cards), preview fixture can name its cards 2026-10-20 19:02:06 +02:00
Niko1221 88278af79a Gyro trellis gate/up on compute capability 6.0: the group's activations in shared memory for the block's row chunks 2026-10-20 19:00:14 +02:00
Niko1221 c864d804b6 Gyro trellis experts on compute capability 6.0: row chunks per block so the staged codebook is paid once per several rows; bench: rows of different lengths 2026-10-20 19:00:14 +02:00
Niko1221 51f418f15a Gyro trellis experts on compute capability 6.0: read-only loads for the block words, TQK6 needs three words of the stream 2026-10-20 19:00:14 +02:00
Niko1221 9f38ee338c Gyro trellis experts on compute capability 6.0: the int8 codebook staged in shared memory, the TQ down rows as 8-lane rows (bitwise the same sums) 2026-10-20 19:00:14 +02:00
Niko1221 b2f2149431 tq_pascal_bench: the Gyro trellis decode kernels on synthetic blocks (bitwise vs the per-column kernels, output checksums, --bench) 2026-10-20 19:00:14 +02:00
Niko1221 ed61ad2a9c App: GPU roles section (Main / Helper / Off per card) replaces the card option list and the expert Run on picker; host writes roles plus the legacy mirror; 46 strings in 8 locales 2026-10-20 18:58:30 +02:00
Niko1221 06e614017d Merge f-022-int (b1594fbbe) into f-022-arcgyro 2026-10-20 18:57:46 +02:00
Niko1221 2411e7cd3b SYCL Gyro: decode-once multi-entry trellis kernels, the grouped XMX prompt path takes the trellis types (prompt 40 -> 82 tok/s on the A750), tq_sycl_parity --bench; setup keeps Gyro's n-gram table on the SSD on i915 2026-10-20 18:57:15 +02:00
Niko1221 8cfe44b71d GPU roles in strata launch (--gpu-roles, M/H/O/R/D Cards screen), setup (--gpu-roles, one roles question) and the host's TypeScript/JS rules; golden and setup tests follow the new defaults 2026-10-20 18:52:30 +02:00
Niko1221 1b47c1d076 Metal: Gyro read-ahead skips the n-gram table, Q6_K/IQ4_NL hyper-connection matrices go through BF16 before their Q8_0 copy (as the pack does for CUDA) 2026-10-20 18:51:27 +02:00
Niko1221 b1594fbbe5 Merge main (0.2.1.2 Strix Halo engine hotfix) 2026-10-20 18:46:37 +02:00
Niko1221 4dd62738df Progress words: a file being repaired after a wrong SHA-256 says so in plain words
Python only (strata_control/progress_words.py, test).
2026-10-20 18:41:45 +02:00
Niko1221 f6b4101127 metal/tests/gyro_parity.sh: the Gyro kernel checks in one script 2026-10-20 18:40:57 +02:00
Niko1221 d581286885 Hardware report: speed per NVIDIA card (SMs x clock) for the Main-card pick 2026-10-20 18:39:38 +02:00
Niko1221 0b736f7702 Gyro on the Mac: model list tests, resident wrap span without the n-gram table, trellis gate/up tile 2026-10-20 18:37:04 +02:00
Niko1221 b6825a493c Server: gpu_roles (Main last, Off excluded, hand-edited gpu wins), roles log line, every-card-Off handling 2026-10-20 18:36:30 +02:00
Niko1221 c0b0869b03 Downloads: every ranged answer is checked, a file with a wrong SHA-256 is repaired piece by piece instead of deleted
A 39.8 GB shard came out with the wrong SHA-256 on 0.2.1 and was deleted, 40 GB of download lost.

- every ranged answer must be a 206 with exactly the Content-Range asked for (start, end and total), a fitting
  Content-Length and the same ETag as the first answer; a changed file part-way stops the download and drops the .part
- the raw unbuffered write is looped until every byte is down (its return value was ignored)
- a .part records the address and ETag it came from (.part.src); one from another mirror or revision is dropped, not mixed
- a parallel download always has a piece list before the first byte is written; if none can be kept it uses one stream,
  so holes in the sparse file are never read as a finished prefix
- the one-stream resume accepts a 206 only at the byte asked for; another range restarts from zero
- a finished file with a pinned hash but no finish mark is checked, not marked; the rename waits for a virus scanner
- a SHA-256 mismatch re-reads the file from the server piece by piece, rewrites only the blocks that differ and checks
  again ("Some parts of the file were damaged; downloading only those parts again"); deleted only if still wrong
- update archives (strata_control/transfer.py): a resumed answer is appended only when its Content-Range starts at the byte asked for
- tests: tests/terminal/test_download_integrity.py (a local server that answers 200 for ranges, wrong ranges and totals,
  cut bodies, another ETag, 503s, flipped bytes)

Python only (setup.py, strata_control/transfer.py, tests).
2026-10-20 18:33:45 +02:00
Niko1221 2219cc99bf GPU roles: one shared rule for Main / Helper / Off (recommend, set, order, mirror, warnings) with shared test vectors 2026-10-20 18:32:50 +02:00
beplee 4681762e3c setup: FP4-Local conversion installs torch 2.14.1 on Python 3.14+ (2.9.1 has no cp315 CPU wheel), older Pythons keep 2.9.1 (#3474) 2026-10-20 18:29:35 +02:00
Niko1221 6b93b42877 tests: Gyro-M per-tensor parity (--experts-first on a sparse copy), moe_mm_parity takes the trellis types 2026-10-20 18:28:01 +02:00
Niko1221 d3cb2f58f1 Merge f-022-gpu0: strata launch keeps the installed card choice; engine/nvidia-smi number map; hip_ordinal and image gpu list fixes 2026-10-20 18:27:52 +02:00
Niko1221 9bb1dd2044 Keep the card a person chose: strata launch no longer re-picks GPU 0, the engine's GPU 0 is explained, old hip_ordinal and list GPUs for images
- strata launch with no --gpu replaced the installed config's own card choice (setup --gpu 1, the app's pick, "gpus_asked") by the
  fit rule's pick, which is card 0 on equal cards. The installed choice and its order are kept; the Cards step preselects it.
- The server prints one line tying the engine's own numbering ("GPU 0 / CUDA0" in its log) to nvidia-smi's, when they differ.
- A card named by the app or by strata launch drops an old AMD config's hip_ordinal, which would have sent the engine to the old card.
- A list "gpu" in an image config is a device list ("1,0"), not "[1, 0]".
2026-10-20 18:27:36 +02:00
Niko1221 c3472caf8e Metal: Gyro in the Mac setup and app model lists, the n-gram table stays out of the dense copy, decode-check test
stream.cpp: load_dense skipped nothing of the n-gram table because it was always another shard; in a Gyro
file it is the first 28.8 GB of the one file (the 32 GB allocation that failed on the Mac mini).
mac_fit / setup_mac / setup.py (app path) / strata_mcp: Gyro-S and Gyro-M as the 'gyro' family of the Mac
(one file, --compat-bf16 pack, --native and --ple-gguf the same file).
expert_parity --decode-check compares ref_quant's trellis decoder with the format's definition written out bit by bit.
2026-10-20 18:20:41 +02:00
Niko1221 f1bfcc2253 SYCL Gyro: APR pack log line in generate, second-GPU helper declines Hadamard-folded experts 2026-10-20 18:01:04 +02:00
Niko1221 a64f1dd99b Metal: Gyro engine message fix, expert_parity prints the pipelines' limits (PIPE_INFO) 2026-10-20 17:57:19 +02:00
Niko1221 02791a6d18 SYCL: trellis experts (TQ2_T, TQK6, TQK7) for decode and the prompt path, Hadamard-folded inputs; tq_sycl_parity (host replay of the kernels bit for bit, dequantizers, Hadamard) 2026-10-20 17:41:12 +02:00
Niko1221 672fd99ccc Metal: trellis expert kernels (TQ2_T, TQK6, TQK7) and the block Hadamard rotation for Gyro
The decode (moe_gu / moe_down) and prompt (mm_gu2 / mm_down2) expert kernels read the three trellis types
through a unit decoder in quant.metal (the codebook from threadgroup or constant memory); had128 kernels
rotate the gate/up and down inputs of Hadamard-folded layers in the decode window and both prompt paths.
ref_quant decodes the types with the CPU engine's own state extraction; expert_parity takes real experts of a
GGUF (--gguf --layer), the pack's hadamard.txt (--had-pack) and has a bitwise rotation test (--had-test).
2026-10-20 17:31:52 +02:00
Niko1221 5b47f889b0 Test: the calibrate setup test's data_folder stand-in takes the extra arguments 2026-10-20 17:26:14 +02:00
Niko1221 5db632e72a Test: engine minimums are checked against the tree's CMake version, not a fixed 0.1.45 2026-10-20 17:26:14 +02:00
Niko1221 f5c4047e78 Test: the unsloth setup tests answer every disk-space question themselves 2026-10-20 17:26:14 +02:00
Niko1221 1f727f4b52 Merge f-022-left: interrupted-move prompt (app + Terminal), deterministic disk test, engine-minimum test reads the tree version 2026-10-20 17:18:00 +02:00
Niko1221 ebe4db78d4 Test: the calibrate setup test's data_folder stand-in takes the extra arguments 2026-10-20 17:17:45 +02:00
Niko1221 dea6920b81 hip-rdna3 on Windows: Strix Halo (gfx1151) keeps the expert arena unregistered by default; strata.exe finds rocm\bin for --version (delay-loaded ROCm math DLLs, bundled by package_windows.py) 2026-10-20 17:09:46 +02:00
Niko1221 9e24f4ab32 Control 0.1.46.2: version bump for the Terminal/Control-only update (engine packages unchanged) 2026-10-20 17:01:22 +02:00
Niko1221 dcbaa992d2 launch --plan: a part that is already on disk is not listed as a download, and a draft layer or picture reader found in the data folder is not counted 2026-10-20 17:00:31 +02:00
Niko1221 79cb59c574 Updater tests reset a signature verifier left behind by an earlier test file 2026-10-20 17:00:30 +02:00
Niko1221 ae456dfe5c iq_pack: an interrupted experts.bin write continues at the next layer (finished layers are flushed and recorded, the last kept one is checked against the GGUF) instead of starting from zero; says so 2026-10-20 17:00:30 +02:00
Niko1221 176e11a597 launch: a run by flags reuses the tuning result stored for the same model, choices and computer and says so; --retune measures again 2026-10-20 17:00:29 +02:00
Niko1221 5dc151d4d3 launch: setup step 6 (packing, converting) is no longer silent - the plain sentence for what it does is shown and a 'still working, N min so far' line comes every 30 s of silence; a cancel is seen at once 2026-10-20 17:00:29 +02:00
Niko1221 0dbea601a0 Download label: the total counts every shard and the picture reader, in the data folder setup is given (--data-dir), not the first shard only 2026-10-20 17:00:29 +02:00
Niko1221 3d5594d656 launch --plan: names the model your files are (title, size, picture setting, a copyable line with --model) instead of placeholders, and says what would be downloaded and how big (0 when everything is here, the draft layer and picture reader counted, engine mentioned) 2026-10-20 17:00:28 +02:00
Niko1221 284552fe26 Own model files: links to shards are accepted with or without --model, and a problem that applies to several files is said once (problem_words), in the Terminal, the screens and 'models import'; tests for items 2 and 3 2026-10-20 17:00:28 +02:00
Niko1221 c24f2a139f Own model files: a --model-path that is not there or holds no model is said in plain words before the engine or setup start (no 'missing /Qwen...gguf', no promise of a download), also in --plan and the screens 2026-10-20 17:00:28 +02:00
Niko1221 bd4c6bf1af Setup tests follow the merges: verify_sha256 takes own=, a tight disk margin warns and a real shortfall refuses 2026-10-20 17:00:27 +02:00
Niko1221 fa56c2b387 Start: a picture reader path from an older engine folder follows the installed package (engine update or other image no longer ends in server_exited) 2026-10-20 17:00:27 +02:00
Niko1221 cf34e915c4 Update rules: Control --version waits longer and tries twice, a busy card is not held against an engine (other failures expire after a day), pre-release Control handover, a wrong PC clock says so 2026-10-20 17:00:26 +02:00
Niko1221 14be49605f Models folder: a whole drive becomes D:\Strata (and D:\Strata\Models), the person's folder is written into the settings file Control reads (so the installer accepts it whatever it is called), no link where the disk cannot hold one, plain words for a folder that is gone or not writable; the Terminal remembers a chosen --data-dir; docs: moving models to another disk and what stays on C: 2026-10-20 17:00:26 +02:00
Niko1221 6b29adad1a strata launch without a real tty (Git Bash, mintty): say so and go on with plain questions instead of refusing 2026-10-20 17:00:26 +02:00
Niko1221 e728a132d7 POSIX owner-only checks: repair our own folders whose mode drifted, note drives without modes, and name the chown command for folders another account owns 2026-10-20 17:00:25 +02:00
Niko1221 32b9f75376 Tests: loopback.py is the one allowed opener for this computer's own servers 2026-10-20 17:00:25 +02:00
Niko1221 1972af6da1 Download and prepare steps say what they are: one set of plain sentences (draft layer download, packing experts, checking files, picture reader) for the app and the Terminal; the SHA-256 check shows its own figure instead of a download stuck at 99%; a step's heading is kept in view; the .part file no longer jumps the bar 2026-10-20 17:00:23 +02:00
Niko1221 7db513297d Launch: a tuning that found the standard settings already fastest counts as tuned (no 20-minute measurement at every start) 2026-10-20 17:00:08 +02:00
Niko1221 bf2926e05a Terminal and app checker: the folder holding the model is found above the picked one, links keep their model name, one plain wording for every refusal (file name + why) 2026-10-20 17:00:07 +02:00
Niko1221 4dee320dd5 setup --gguf-dir never deletes the person's own file: a size or SHA-256 mismatch stops with the file's name and why, and the file stays 2026-10-20 17:00:07 +02:00
Niko1221 c316a7c215 Fix: Windows runtime folder refused as not owner-only 2026-10-20 17:00:06 +02:00
Niko1221 73b68dfd88 Fix #3410: a Strata folder in the Trash is not remembered or offered as an install 2026-10-20 17:00:06 +02:00
ctr-137 ce938fb8c9 Fix #2971: a tool call no longer widens the web page on phones (from PR #3156) 2026-10-20 17:00:06 +02:00
Yash Raj Pandey fb2f387407 Fix #2496: an engine program that cannot be started is a 503 with the reason, not a dropped connection (from PR #2497) 2026-10-20 17:00:05 +02:00
xrip 9d2870d187 Fix #2289: Anthropic usage reports cache_creation_input_tokens, the prompt tokens read now and kept for the next request (from PR #2290) 2026-10-20 17:00:05 +02:00
xrip b611ec5f1e Fix #2141: honour llama-server's thinking_budget_tokens (from PR #2140) 2026-10-20 17:00:04 +02:00
MEsfits ddbbda4824 Fix #3275: calibrate a dual config whose gpu is the string 0,1 (from PR #3276; a single card given as a number keeps asking for that card) 2026-10-20 17:00:04 +02:00
Niko1221 386155fa25 Fix #3370: the vision work folder is made again when a temp cleaner removed it 2026-10-20 17:00:03 +02:00
Niko1221 ff64e08bba Fix #3403: the Mac setup installs cmake and ninja before it compiles 2026-10-20 17:00:03 +02:00
Niko1221 a569d80680 Fix #3187: calibrate checks the report folder before the sweep, not after it 2026-10-20 17:00:03 +02:00
Niko1221 461d0baee7 Fix #3188: strata_bench names the port it starts its own server on 2026-10-20 17:00:02 +02:00
Niko1221 ef5d8ab946 Fix #3302: owner-only file checks accept the built-in Administrator (SDDL alias LA) 2026-10-20 17:00:02 +02:00
Niko1221 bcda2e4b0d Fix #3298: --mtp with a depth number is not a folder in the model file checks 2026-10-20 17:00:02 +02:00
Niko1221 34d6c37df3 Disk space: stop only when the bytes do not fit, warn inside the working-room margin, a resumed download's partial file is not counted twice 2026-10-20 17:00:01 +02:00
Niko1221 f207907b14 Own model files: the checker in front of setup accepts what setup --gguf-dir accepted (Unsloth split sets with a metadata-only first shard, links, several sizes in a folder, a folder above the model folder, more than 128 files); Unsloth Q5/Q6/Q8 and Swift IQ3_S are in the table 2026-10-20 17:00:01 +02:00
Niko1221 7ebdd34356 Windows file in use: every state-file swap goes through atomic.replace (retry); a sharing violation no longer says run as yourself 2026-10-20 17:00:00 +02:00
Niko1221 aa44b9ce53 No engine for this PC: say it plainly (no graphics card Strata can use, or no engine for the card), no feed address as the next step 2026-10-20 17:00:00 +02:00
Niko1221 af189129a4 Calls to this computer's own servers skip the system proxy (tuner, tune session, launch self-check) 2026-10-20 17:00:00 +02:00
Niko1221 c6a0e739be Fix #3417: the hardware report counts physical cores, so the Work profile no longer asks for more CPU workers than the CPU has cores 2026-10-20 16:59:59 +02:00
Niko1221 e52668857c API key is optional beyond this computer: warn, never refuse
The server and strata serve start on 0.0.0.0 without a key and print one plain
warning. strata launch gets --no-api-key; the Access screen makes a key by
default and t turns it off. api_key "none" in a config means no key on
purpose. Cloudflare without a key gets a stronger warning. Docs updated.
2026-10-20 16:59:59 +02:00
Niko1221 c0c3a06ce4 Hand-over: a source checkout never hands over to an installed Control 2026-10-20 16:59:58 +02:00
Niko1221 f39d951904 Terminal hands over to the Control that strata update installed
The Terminal on PATH ignored components/control/active.json, so after
strata update the old Control kept running. strata.py now checks the
pointer first (stdlib only) and runs the newer, whole Control with its
own interpreter; never an older one, never outside the control folder,
no loop (STRATA_HANDOVER), one warning and a fallback if it is broken.
strata update says the next command already runs the new Control.
2026-10-20 16:59:58 +02:00
Niko1221 968fcb70b0 Drop the cached update notice when Control, engine or app version changed 2026-10-20 16:59:57 +02:00
Niko1221 b486d6f2b4 Test: engine minimums are checked against the tree's CMake version, not a fixed 0.1.45 2026-10-20 16:58:31 +02:00
Niko1221 43127e3982 Test: the unsloth setup tests answer every disk-space question themselves 2026-10-20 16:58:14 +02:00
Niko1221 6c688faf22 Models folder: Finish or leave an interrupted move from the Storage card (host routes, card notice, 6 strings in 8 languages) 2026-10-20 16:57:27 +02:00
Niko1221 0a35538b74 Models move: remember an unfinished move in the old folder; strata models move --resume/--leave/--status; one-line notice at strata launch 2026-10-20 16:53:14 +02:00
Niko1221 72f66767ca Test: the models-folder strings moved to s8339-s8416 when the locale merge renumbered them 2026-10-20 16:48:21 +02:00
Niko1221 031adf99bf Merge f-022-modelsui: change the models folder in Settings > Storage (move / keep / start empty), as in the approved mockup 2026-10-20 16:46:46 +02:00
Niko1221 e3040e9e25 Models folder: spacing and the path as code in the dialogs, a plain line instead of the mockup's 'as in step 2', a way to force the copy path for tests, INSTALL.md sentence, string test 2026-10-20 16:41:58 +02:00
Niko1221 6f1eae68ff Merge f-022-flow2: --plan shows real files and download size, plain missing-path text, total download label, heartbeat, stored tuning reused, resumable packing 2026-10-20 16:41:26 +02:00
Niko1221 085474b8dc launch --plan: a part that is already on disk is not listed as a download, and a draft layer or picture reader found in the data folder is not counted 2026-10-20 16:35:12 +02:00
Niko1221 1d6a040129 Settings > Storage: Models folder card with the three choices (move, keep, start empty), progress, stop, and plain errors; host routes and Control calls; 78 strings in 8 languages; tests 2026-10-20 16:29:44 +02:00
Niko1221 a0e09803ac Models folder: move the model files to another folder (strata models move): copied and checked file by file, stoppable, run configs follow 2026-10-20 16:20:27 +02:00
Niko1221 2fcc25aab6 Release notes 0.2.2 draft: shared models folder and Docker flow fixes 2026-10-20 16:08:31 +02:00
Niko1221 67bb56bd1d Merge branch 'f-022-int' into f-022-intfix 2026-10-20 16:08:15 +02:00
Niko1221 d511c5102e Updater tests reset a signature verifier left behind by an earlier test file 2026-10-20 16:08:13 +02:00
Niko1221 d408fa2ff1 tools tests: the verify_sha256 stand-in takes the new own= argument (34 setup tests errored on it) 2026-10-20 16:04:05 +02:00
Niko1221 970c75c8f6 iq_pack: an interrupted experts.bin write continues at the next layer (finished layers are flushed and recorded, the last kept one is checked against the GGUF) instead of starting from zero; says so 2026-10-20 16:03:23 +02:00
Niko1221 ee8e6404b0 launch: a run by flags reuses the tuning result stored for the same model, choices and computer and says so; --retune measures again 2026-10-20 16:02:35 +02:00
Niko1221 17cb817594 launch: setup step 6 (packing, converting) is no longer silent - the plain sentence for what it does is shown and a 'still working, N min so far' line comes every 30 s of silence; a cancel is seen at once 2026-10-20 15:58:11 +02:00
Niko1221 594e379291 Download label: the total counts every shard and the picture reader, in the data folder setup is given (--data-dir), not the first shard only 2026-10-20 15:56:33 +02:00
Niko1221 fd4f7d445c launch --plan: names the model your files are (title, size, picture setting, a copyable line with --model) instead of placeholders, and says what would be downloaded and how big (0 when everything is here, the draft layer and picture reader counted, engine mentioned) 2026-10-20 15:55:39 +02:00
Niko1221 226b5f5492 Own model files: links to shards are accepted with or without --model, and a problem that applies to several files is said once (problem_words), in the Terminal, the screens and 'models import'; tests for items 2 and 3 2026-10-20 15:50:48 +02:00
Niko1221 9dc1c96377 Own model files: a --model-path that is not there or holds no model is said in plain words before the engine or setup start (no 'missing /Qwen...gguf', no promise of a download), also in --plan and the screens 2026-10-20 15:50:42 +02:00
Niko1221 4e1e6bad4e Merge f-022-flow: Docker and re-run fixes from the end-to-end flow walk 2026-10-20 15:41:52 +02:00
Niko1221 3de97a8892 Setup tests follow the merges: verify_sha256 takes own=, a tight disk margin warns and a real shortfall refuses 2026-10-20 15:41:30 +02:00
Niko1221 c23a4a5827 Merge f-022-modelsdir: the app and the Terminal share one models folder 2026-10-20 15:33:01 +02:00
Niko1221 4aabf3b8f5 Release notes 0.2.2: DRAFT paragraphs for the merged streams 2026-10-20 15:31:01 +02:00
Niko1221 6ce6413606 One models folder for the app and the Terminal: each settings file keeps when the folder was chosen, the one changed last wins and the other file follows (nothing is moved), tests 2026-10-20 15:30:37 +02:00
Niko1221 4fd5b0646d setup.py: the three remaining os.replace calls use atomic.replace 2026-10-20 15:29:52 +02:00
Niko1221 703daada09 Locales: keyless-sharing, own-GGUF, ports and models-folder strings in en, de, es, ja, ko, pt, ru, zh (s8301-s8313) 2026-10-20 15:29:52 +02:00
Niko1221 d69c9c4b4f Settings > Storage shows the Documents-fallback notice (app.modelsDirNotice) with the existing storage notice line 2026-10-20 15:29:51 +02:00
Niko1221 b22206c7bc Serving: Remove key stays available while sharing; plain warning in the remove dialog 2026-10-20 15:29:51 +02:00
Niko1221 a4f7975324 Removing the API key while sharing or Cloudflare runs is allowed (the key is optional); the remove dialog warns plainly 2026-10-20 15:29:51 +02:00
Niko1221 bcf88b7383 Merge f-022-refusals: environmental refusals repair or warn instead of blocking (audit items 1-4, 6, 8-10, 12-15) 2026-10-20 15:14:01 +02:00
Niko1221 6a55bcb5e9 Host tests: sharing and the tunnel start without a key (owner decision) 2026-10-20 15:13:11 +02:00
Niko1221 c7938c2d3e Merge f-022-ownfiles: own GGUF files load again, plain download/prepare steps, chosen models folder 2026-10-20 15:05:13 +02:00
Niko1221 69a6db1904 Docs: Docker, files you already have (what is and is not downloaded, mounts, links, old volume) 2026-10-20 14:49:46 +02:00
Niko1221 1b6f782faf Start: a picture reader path from an older engine folder follows the installed package (engine update or other image no longer ends in server_exited) 2026-10-20 14:40:43 +02:00
Niko1221 215aa14f25 Update rules: Control --version waits longer and tries twice, a busy card is not held against an engine (other failures expire after a day), pre-release Control handover, a wrong PC clock says so 2026-10-20 14:40:35 +02:00
Niko1221 4fc2e140bd Models folder: a whole drive becomes D:\Strata (and D:\Strata\Models), the person's folder is written into the settings file Control reads (so the installer accepts it whatever it is called), no link where the disk cannot hold one, plain words for a folder that is gone or not writable; the Terminal remembers a chosen --data-dir; docs: moving models to another disk and what stays on C: 2026-10-20 14:38:00 +02:00
Niko1221 740acc8934 strata launch without a real tty (Git Bash, mintty): say so and go on with plain questions instead of refusing 2026-10-20 14:34:21 +02:00
Niko1221 72fc52251b POSIX owner-only checks: repair our own folders whose mode drifted, note drives without modes, and name the chown command for folders another account owns 2026-10-20 14:34:20 +02:00
Niko1221 0c79d7db94 Tests: loopback.py is the one allowed opener for this computer's own servers 2026-10-20 14:34:20 +02:00
Niko1221 d298999c3c Download and prepare steps say what they are: one set of plain sentences (draft layer download, packing experts, checking files, picture reader) for the app and the Terminal; the SHA-256 check shows its own figure instead of a download stuck at 99%; a step's heading is kept in view; the .part file no longer jumps the bar 2026-10-20 14:29:58 +02:00
Niko1221 632b4ed283 Launch: a tuning that found the standard settings already fastest counts as tuned (no 20-minute measurement at every start) 2026-10-20 14:19:55 +02:00
Niko1221 b111d1e830 Merge f-022-issues: 14 small issue fixes + Windows runtime folder repair 2026-10-20 14:18:38 +02:00
Niko1221 56dee6b938 Terminal and app checker: the folder holding the model is found above the picked one, links keep their model name, one plain wording for every refusal (file name + why) 2026-10-20 14:17:32 +02:00
Niko1221 d101771758 App 'Use a local GGUF': take one .gguf file as well as a folder (quotes around the path allowed), hand setup the folder that really holds the files and the data folder their prepared data sits in, name the file and why when a set cannot be used 2026-10-20 14:17:31 +02:00
Niko1221 a5b1c2cefa setup --gguf-dir never deletes the person's own file: a size or SHA-256 mismatch stops with the file's name and why, and the file stays 2026-10-20 14:17:29 +02:00
Niko1221 ca1d469e9a Fix: Windows runtime folder refused as not owner-only 2026-10-20 14:13:35 +02:00
Niko1221 47d9d6c75a Fix #3410: a Strata folder in the Trash is not remembered or offered as an install 2026-10-20 14:13:35 +02:00
ctr-137 f2709679db Fix #2971: a tool call no longer widens the web page on phones (from PR #3156) 2026-10-20 14:13:34 +02:00
Yash Raj Pandey c330caf4da Fix #2496: an engine program that cannot be started is a 503 with the reason, not a dropped connection (from PR #2497) 2026-10-20 14:13:34 +02:00
xrip 7adced3db5 Fix #2289: Anthropic usage reports cache_creation_input_tokens, the prompt tokens read now and kept for the next request (from PR #2290) 2026-10-20 14:13:34 +02:00
xrip a0b6d1cd11 Fix #2141: honour llama-server's thinking_budget_tokens (from PR #2140) 2026-10-20 14:13:34 +02:00
MEsfits 774096d16b Fix #3275: calibrate a dual config whose gpu is the string 0,1 (from PR #3276; a single card given as a number keeps asking for that card) 2026-10-20 14:13:34 +02:00
Niko1221 76186bb595 Fix #3370: the vision work folder is made again when a temp cleaner removed it 2026-10-20 14:13:34 +02:00
Niko1221 e346198ff3 Fix #3403: the Mac setup installs cmake and ninja before it compiles 2026-10-20 14:13:34 +02:00
Niko1221 98faf8139e Fix #3359: the HIP picture reader is built position independent so it links on Linux 2026-10-20 14:13:34 +02:00
Yudhanjaya Wijeratne 17b93dcb7c Fix #3368: verify_q8 no longer calls the device-only __half_as_ushort from host code (CUDA 12.0/12.1) 2026-10-20 14:13:34 +02:00
Niko1221 52ecf67ef7 Fix #3187: calibrate checks the report folder before the sweep, not after it 2026-10-20 14:13:34 +02:00
Niko1221 f74ba2ea53 Fix #3188: strata_bench names the port it starts its own server on 2026-10-20 14:13:34 +02:00
Niko1221 4fb0dde729 Fix #3302: owner-only file checks accept the built-in Administrator (SDDL alias LA) 2026-10-20 14:13:34 +02:00
Niko1221 f94a34c330 Fix #3298: --mtp with a depth number is not a folder in the model file checks 2026-10-20 14:13:34 +02:00
Niko1221 c7c0e37bc4 Host ports: plain message when the main port is taken or reserved; share and tunnel take a free port instead of PORT+1 and PORT+2 2026-10-20 14:12:52 +02:00
Niko1221 2f4e42b9cf App Share and Cloudflare work without an API key and warn plainly; a key is still offered and made by default 2026-10-20 14:12:04 +02:00
Niko1221 d61a443eb8 Docker: start line names the model files when only MODEL_PATH is set; the stop text no longer promises a download 2026-10-20 14:11:50 +02:00
Niko1221 52cc6b4af0 Host: a model folder in Documents that cannot be made falls back to STRATA_HOME/models with a plain notice instead of crashing at start 2026-10-20 14:10:46 +02:00
Niko1221 31361a98bf install.ps1: ARM64 reason, TLS 1.2 and proxy sign-in, real error text, long-path unpack with tar.exe, mklink check with a launcher fallback, running version message 2026-10-20 14:09:47 +02:00
Niko1221 87b7bb2ccb install.sh on ARM Linux and Intel Mac: keep the refusal, say why and what to do 2026-10-20 14:09:47 +02:00
Niko1221 48c34f735a Docker: MODEL_PATH that is not mounted, or holds no GGUF files, ends the start in plain words before any setup work 2026-10-20 14:09:30 +02:00
Niko1221 537f8fef8e Disk space: stop only when the bytes do not fit, warn inside the working-room margin, a resumed download's partial file is not counted twice 2026-10-20 14:08:31 +02:00
Niko1221 83ae413176 Docker: an NVIDIA card the container cannot use ends the start before the model is prepared, with the --gpus all hint 2026-10-20 14:07:42 +02:00
Niko1221 80f7679b61 Docker: a volume of the older image is moved into place (no new download), run settings of another image's engine folder are made again 2026-10-20 14:07:42 +02:00
Niko1221 b8488e0d2b Own model files: the checker in front of setup accepts what setup --gguf-dir accepted (Unsloth split sets with a metadata-only first shard, links, several sizes in a folder, a folder above the model folder, more than 128 files); Unsloth Q5/Q6/Q8 and Swift IQ3_S are in the table 2026-10-20 14:07:31 +02:00
Niko1221 d0afabb6ab Windows file in use: every state-file swap goes through atomic.replace (retry); a sharing violation no longer says run as yourself 2026-10-20 14:04:57 +02:00
Niko1221 656478c68d Merge f-022-toks: one answer-speed definition everywhere (engine decode figures) 2026-10-20 14:03:44 +02:00
Niko1221 fccfd343d7 One definition of tok/s: produced tokens over decode time, from the engine's own figures
The agent footer reads the local engine's /metrics for the turn (no empty number on short replies, prompt reading speed kept apart), model APIs are timed from the first token, the monitor average and the request list use answer speeds, the footer shows the last reply's own speed, and the server's done line no longer prints a million tok/s for a one-token reply.
2026-10-20 14:03:26 +02:00
Niko1221 5a9b4f4fc4 Merge f-022-needsfmt: needs-format issue fixes (#3418, #3417, #3358 tests/build) 2026-10-20 14:01:25 +02:00
Niko1221 4509f2f997 No engine for this PC: say it plainly (no graphics card Strata can use, or no engine for the card), no feed address as the next step 2026-10-20 14:00:31 +02:00
Niko1221 f9e1fff983 Fix #3358: the ISA guard knows the trellis AVX-512 rows (tq_avx512.cpp), so isa_guard_check passes on a normal build 2026-10-20 13:59:25 +02:00
Niko1221 a6ffc04d6d Fix #3358: the HIP MMQ parity screen no longer builds a string from a null type name 2026-10-20 13:59:25 +02:00
Niko1221 a5c7abe074 Fix #3358: the trellis CPU tests skip, not fail, on a ggml without trellis types 2026-10-20 13:59:25 +02:00
Niko1221 2793e3bd33 Calls to this computer's own servers skip the system proxy (tuner, tune session, launch self-check) 2026-10-20 13:58:47 +02:00
Niko1221 1b9b768939 Fix #3417: the hardware report counts physical cores, so the Work profile no longer asks for more CPU workers than the CPU has cores 2026-10-20 13:56:13 +02:00
Niko1221 642425bf2e Fix #3418: the app finds the engine's strata-device in the component folder, so a single Radeon is numbered the way HIP numbers it (an iGPU on ordinal 0 no longer makes GPU 0 the wrong card) 2026-10-20 13:55:24 +02:00
Niko1221 cc500be1c9 Merge f-022-apikey: the API key is optional; serving without one warns, never refuses 2026-10-20 13:29:58 +02:00
Niko1221 13806c323e Tests: an RX 6000 card now has a package of its own (0.2.2), drop the old no-package text 2026-10-20 13:29:58 +02:00
Niko1221 2f361a3757 API key is optional beyond this computer: warn, never refuse
The server and strata serve start on 0.0.0.0 without a key and print one plain
warning. strata launch gets --no-api-key; the Access screen makes a key by
default and t turns it off. api_key "none" in a config means no key on
purpose. Cloudflare without a key gets a stronger warning. Docs updated.
2026-10-20 13:29:14 +02:00
Niko1221 2dfb254f7b Merge f-022-ctlswitch: the Terminal starts the Control that strata update installed 2026-10-20 13:21:46 +02:00
Niko1221 28b150f854 Hand-over: a source checkout never hands over to an installed Control 2026-10-20 13:21:46 +02:00
Niko1221 676b57bff2 Terminal hands over to the Control that strata update installed
The Terminal on PATH ignored components/control/active.json, so after
strata update the old Control kept running. strata.py now checks the
pointer first (stdlib only) and runs the newer, whole Control with its
own interpreter; never an older one, never outside the control folder,
no loop (STRATA_HANDOVER), one warning and a fallback if it is broken.
strata update says the next command already runs the new Control.
2026-10-20 13:18:07 +02:00
Niko1221 9424830516 Merge f-022-bench: strata bench run fixes from the first real run 2026-10-20 12:39:58 +02:00
Niko1221 00e8d10994 Merge f-022-idle: idle-GPU measurement (2507-0), no default change 2026-10-20 12:38:42 +02:00
Niko1221 bf69ec2da4 bench: results of the first real runs on the 0.2.1 release (RTX 3060, 2x RTX A4000), protocol text from the measured counts 2026-10-20 12:36:35 +02:00
Niko1221 7924bf65d2 MULTI_GPU: STRATA_SPLIT_OVERLAP is measured on NVIDIA now (no gain) 2026-10-20 12:36:06 +02:00
Niko1221 867d7477ff 2507-0: measure the idle GPUs with the existing switches (z440 2x A4000, P2003 RTX 3060)
tools/idle_gpu_ab.py is the 0.2.1 split A/B harness with a per-card nvidia-smi sampler. Result on the 0.2.1 engine: the two-card
split is 47-49 % idle in decode (cards take turns), the single card 5 %; STRATA_SPLIT_OVERLAP, --pipeline-windows 1/2 (with and
without the draft layer on card 0) and --batch-groups 2 give no gain worth a default. docs/MULTI_GPU.md: the overlap flag is now
measured on NVIDIA.
2026-10-20 12:35:59 +02:00
Niko1221 f5410a6feb bench: say what the short prompt and the output cap mean; measured duration replaces the 20-30 minute guess
- the plain summary and the README get a Capped column (requests that reached the 512-token cap) and leave out the
  short case's prompt rate (an 80-token prompt measures the fixed cost of a request); results.json keeps it
- BENCHMARK_PROTOCOL.md, COMMUNITY_BENCHMARKS.md, BENCH.bat and bench.sh: the standard suite took 2 to 4 minutes after
  loading on an RTX 3060 and on 2x RTX A4000, not 20 to 30
2026-10-20 12:13:07 +02:00
Niko1221 bb7e399c30 Merge main (0.2.1.1 long-path hotfix) 2026-10-20 11:39:11 +02:00
Niko1221 4bd5912695 Merge f-022-numa: NUMA-split CPU pool on multi-node machines (#3201) 2026-10-20 11:35:00 +02:00
Niko1221 8c07c0c36a Merge f-022-rx6000: RX 6000 (gfx1030/1031) engine package, built not tested on a card 2026-10-20 11:34:52 +02:00
Niko1221 9ae93f7b1f Control 0.1.46.1: version bump for the Windows long-path hotfix 2026-10-20 11:18:52 +02:00
Niko1221 9f13d1e78f Merge main (Docker commit fix) into the long-path hotfix 2026-10-20 11:15:54 +02:00
Niko1221 a0abdf7588 Engine install: survive Windows MAX_PATH (short staging folder, extended-length paths, plain file errors, deep-folder warning)
On a fresh Windows PC (long paths off) the AMD engine packages could not be unpacked: staging under .updates/staging/<12 hex> pushed the
longest hipBLASLt file past 259 characters, and the failure surfaced as 'local file operation failed (FileNotFoundError)'.

- staging is now <components>/.s/<4 hex>/engine/<folder>; Control stages the same way
- unpack, delete and verify go through extended-length paths (strata_control/longpath.py)
- OSError messages name the file and say so when the path is too long
- a plain warning (shown as a warn line by launch) when the install folder is too deep for the engine's own file loading
- tests: real 0.2.1 package path lengths for a 20 character user name
2026-10-20 11:15:20 +02:00
Niko1221 149abc896b Release notes 0.2.2 draft: NUMA pool 2026-10-20 10:54:40 +02:00
Niko1221 069384b4f6 Merge main: Docker commit fix 2026-10-20 10:51:45 +02:00
Niko1221 c644312d15 bench: fixes from the first real runs on release engines (LVM drive lookup, CUDA UMD banner, build runtime, engine log, fresh-load line, archive COMMIT)
The first runs of strata bench run on the 0.1.46 release engine (RTX 3060, 2x RTX A4000) showed:
- the release terminal archive carried no COMMIT file, so every run was nonconforming; package_terminal.py now takes
  --commit / STRATA_COMMIT and refuses to build a distributable archive when the commit is unknown
- the model drive on an LVM volume was not found (lsblk -s nests lvm -> part -> disk)
- nvidia-smi 615 prints CUDA UMD Version, so no runtime was recorded; the runtime is now the engine's build
  runtime (engine-component.json) plus the driver's supported CUDA version
- engine.log was empty (serve --quiet); it now holds the run config's log from the start of the run
- a fresh server (0 prior requests) was reported as 'could not tell whether freshly loaded'
2026-10-20 10:44:25 +02:00
Niko1221 ac71d2b32e RX 6000 package: docs, release-notes draft, Windows build comment, tuning-table test rows 2026-10-20 10:43:45 +02:00
Niko1221 c093ac263f docker/build.sh: STRATA_BUILD_COMMIT overrides the commit it passes 2026-10-20 10:39:48 +02:00
Niko1221 436fbd0a6e Docker: pass the source commit into the terminal stage (no git in the image)
package_terminal.py reads STRATA_BUILD_COMMIT first and only then asks git, and no longer crashes when git is missing. build.sh passes the commit as a build arg, the Dockerfile turns it into that env.
2026-10-20 10:39:15 +02:00
Niko1221 7274c10e9a NUMA pool: STRATA_TEST_SYSFS_ROOT lets a test give the pool a made-up two-node machine; docs 2026-10-20 10:31:13 +02:00
Niko1221 f0afedc50c Merge main: publish.sh executable 2026-10-20 10:29:56 +02:00
Niko1221 5bda973a43 Docker: make docker/publish.sh executable (the image workflow failed with Permission denied) 2026-10-20 10:29:54 +02:00
Niko1221 b5d14279aa pool_tasks_test: fix the string literals 2026-10-20 10:28:43 +02:00
Niko1221 2e12958759 pool_tasks_test: STRATA_NUMA_POOL=0 switches the split off; nothing set leaves no refusal behind 2026-10-20 10:26:57 +02:00
Niko1221 e4d22e7ef1 NUMA pool: tried by itself on a machine with 2 or more NUMA nodes, STRATA_NUMA_POOL=0 turns it off, one node unchanged 2026-10-20 10:26:01 +02:00
Niko1221 e59e64ec5d Merge f-022-notice: drop a stale update notice after a version change 2026-10-20 10:25:01 +02:00
Baptiste Rajaut 393eec80f9 fix: drop the pack and MTP files' page cache before the NUMA placement 2026-10-20 10:22:05 +02:00
Baptiste Rajaut 06425cf932 docs: update NUMA-split numbers measured with iommu=pt 2026-10-20 10:22:05 +02:00
Baptiste Rajaut afbd64bc4f docs: document the opt-in NUMA-split CPU expert pool 2026-10-20 10:22:05 +02:00
Baptiste Rajaut 2614b82c9a fix: list the pool's own worker cores for the start-up line and --aux-cpus under the NUMA split 2026-10-20 10:22:05 +02:00
Baptiste Rajaut 5b8cdaf2d9 fix: interleave an explicit pool worker count across sockets when the NUMA split is on 2026-10-20 10:22:05 +02:00
Baptiste Rajaut ea6b385df8 feat: span both sockets with the default pool workers when the NUMA split is on 2026-10-20 10:22:05 +02:00
Baptiste Rajaut 8ca9093d7d fix: print a NUMA slice as not verified when all its move_pages batches failed 2026-10-20 10:22:05 +02:00
Baptiste Rajaut ec841ca1e9 fix: leave the NUMA split plan clean when the split is refused 2026-10-20 10:22:05 +02:00
Baptiste Rajaut c4442fed95 fix: report an unverifiable NUMA placement instead of FAILED when move_pages fails 2026-10-20 10:22:04 +02:00
Baptiste Rajaut 2496795bd9 fix: refuse a lopsided NUMA split and accept only 1/test for STRATA_NUMA_SPLIT 2026-10-20 10:22:04 +02:00
Baptiste Rajaut 8d29804204 fix: word the NUMA per-node shortfall as at risk, FAILED stays for the verdicts 2026-10-20 10:22:04 +02:00
Baptiste Rajaut 1523c592a6 fix: judge NUMA placement after registration, log silent no-ops and per-node shortfall up front 2026-10-20 10:22:04 +02:00
Baptiste Rajaut f8c4410a38 fix: release the model files' cached pages before NUMA placement of the complement 2026-10-20 10:22:04 +02:00
Baptiste Rajaut df4706577e feat: place complement row slices on their NUMA node by pinned first touch (opt-in) 2026-10-20 10:22:04 +02:00
Baptiste Rajaut afd1f61cc5 test: assert slice-0/slice-1 ranges never intersect, across neighbouring slots too 2026-10-20 10:22:04 +02:00
Baptiste Rajaut 31d857f817 feat: per-NUMA-slice byte ranges of a complement slot 2026-10-20 10:22:04 +02:00
Baptiste Rajaut 4126250bf1 fix: NUMA split refuses STRATA_EXCHANGE_ROTATE=1, diag sums the slices in a split phase, mixed-mode split test 2026-10-20 10:22:04 +02:00
Baptiste Rajaut 709f8dc63d feat: per-NUMA-node task slices and counters in the CPU expert pool (opt-in) 2026-10-20 10:22:04 +02:00
Niko1221 3985c50777 Engine package for the RX 6000 series (gfx1030 / gfx1031): build variant, package choice, setup note, tests 2026-10-20 10:20:08 +02:00
Niko1221 f68ccf30c7 Drop the cached update notice when Control, engine or app version changed 2026-10-20 10:07:55 +02:00
Niko1221 90767252f4 Release notes 0.2.1: final two-card batch A/B result, drop TODOs 2026-10-20 09:40:48 +02:00
Niko1221 a7bdbfdabc Release notes 0.2.1: the #3237 drive probe is on by default (owner OK) 2026-10-20 06:27:42 +02:00
Niko1221 9944d2f7f1 Merge branch 'f-021-int' into f-021-rel 2026-10-20 05:56:29 +02:00
Niko1221 b42ec662c7 Release notes 0.2.1: the R9700 queue fix with the two-queue numbers 2026-10-20 05:56:26 +02:00
Niko1221 b35a9816c7 amd: open two GPU queues instead of five at start (keeps the fixed prompt speed, no decode loss on R9700) 2026-10-20 04:53:45 +02:00
Niko1221 d33bb832ca Release notes 0.2.1: engine 0.1.46 and feed sequence 102 (proposed), file-name and Docker TODOs resolved, tuning tables per package 2026-10-20 02:37:08 +02:00
Niko1221 3e7c5d8e1c build_hip_linux.sh: pick up lib/rocm_sysdeps when the ROCm tree has it (the gfx1151 halo package needed a patched copy in 0.2.0-beta) 2026-10-20 02:31:50 +02:00
Niko1221 894d1be1f4 Version bump for 0.2.1 (proposed): app 0.2.1, engine and Control 0.1.46
Cargo.toml and Cargo.lock, desktop and host package.json, PRODUCT_VERSION, CMake project and Metal STRATA_VERSION, build_hip_linux PKGVER default, About comment, tests that pin the number. tauri.conf.json has no version key (Tauri takes it from Cargo.toml). Download names in the READMEs, AMD_HIP.md and UPDATES.md follow.
2026-10-20 02:17:37 +02:00
Niko1221 93aa62ed64 Release notes 0.2.1: fill the iD, iE, zc and bimodal parts (measured defaults, fixes, known limits) 2026-10-20 02:14:44 +02:00
Niko1221 83027df587 Merge branch 'f-021-iD' into f-021-int 2026-10-20 02:13:21 +02:00
Niko1221 484e06b961 Linux file tier: a start-up drive probe overrides the room rule's file cache on a fast drive (#3237)
When the room rule picks the file cache but the cache holds under half of the read set, the engine now
times O_DIRECT preads (8 threads) against first touches of a mapping (3 threads) on random 1 MiB
pieces of the model file, once, and reads unbuffered when the direct path runs at 4 GB/s or more and
is 1.3x (under 30% held) / 1.5x (under 50%) faster than the cached path. The reporter's PCIe 5 NVMe
(8.18 vs 4.93 GB/s, 13% held) lands on unbuffered (2x prompts, 3-4x decode in their A/B). A 1.7-2.5
GB/s NVMe (P1004) stays on the cache, where it decodes 1.1-1.6x faster; a ratio alone could not
separate the two, hence the absolute floor. STRATA_FILE_TIER_PROBE=0 keeps the room rule alone.
Default change: proposed, not validated on a fast drive here. Decision in platform::file_tier_prefers_direct
(platform_memory_test).
2026-10-20 01:32:52 +02:00
Niko1221 ed4595ad69 Merge branch 'f-021-bimodal' into f-021-int 2026-10-20 01:20:25 +02:00
Niko1221 06d7604af7 Merge branch 'f-021-zc' into f-021-int 2026-10-20 01:07:32 +02:00
Niko1221 ecf2bdcb7f Linux: io prefetch + decode gate by default for experts.bin packs when the page cache has room for 55% of the experts
Zero-copy does not apply to experts.bin (one contiguous blob the kernels already read from the mapping), but the io_fill_ prefetch path does. RTX 3060, IQ3_XXS 43 GB pack, 32 GB cgroup: decode 1.38/1.21/1.33x (5/5 pairs), prompts 1.04/1.06x. At 16 GB the cold 4K prompt was 4-6% slower, so it stays off below the room test and the log says how to force it. Identity gates 10/10, exact_pp 4K/20K identical.
2026-10-20 01:07:19 +02:00
Niko1221 3f1fa67f14 amd: open the GPU queues in a fixed order at start (R9700 fast/slow prompt starts)
On gfx1200/gfx1201 under Linux the compute stream and the expert staging stream
landed on the same compute pipe in about half of the starts, which made 4K prompts
13% slower (p620, 1,980 vs 2,290 tok/s). Five queues (four normal, one highest) are
now opened one after the other on the main thread before any other thread starts.
STRATA_HIP_QUEUE_PRIME=0 turns it off. Answers are unchanged.
2026-10-20 01:04:48 +02:00
Niko1221 881a42adab Merge branch 'f-021-notes' into f-021-int 2026-10-20 00:48:22 +02:00
Niko1221 3616148bbf Merge branch 'f-021-iE' into f-021-int
# Conflicts:
#	apps/host/src/locales/de.json
#	apps/host/src/locales/en.json
#	apps/host/src/locales/es.json
#	apps/host/src/locales/ja.json
#	apps/host/src/locales/ko.json
#	apps/host/src/locales/pt.json
#	apps/host/src/locales/ru.json
#	apps/host/src/locales/zh.json
2026-10-20 00:48:09 +02:00
Niko1221 24e56539c5 Release notes 0.2.1 (draft): downloads table, what is new, measured defaults, fixes, privacy, known limits, TODO markers for the running streams 2026-10-20 00:47:45 +02:00
Niko1221 8e957cd53b SYCL: build.sh stops an AOT Battlemage build before the compile when ocloc is the one that crashes on the int8 kernels (Compute Runtime 26.22.38646.x, IGC 2.36.3); I8_ESIMD=OFF / STRATA_SYCL_I8_ESIMD builds without them; docs say docker build --pull (#3219) 2026-10-20 00:47:08 +02:00
Niko1221 4f6ec303b5 Merge branch 'f-021-tfix' into f-021-int 2026-10-20 00:44:55 +02:00
Niko1221 1f52a00373 apps/host staging-scripts-syntax test: use Git bash with forward-slash paths on Windows instead of the WSL launcher 2026-10-20 00:44:41 +02:00
Niko1221 e68a032a58 math test: the KaTeX render check skips when katex is not installed in a shared node_modules (#3268) 2026-10-20 00:43:04 +02:00
Niko1221 10715d856d gfx906 (HIP wave64) builds again: no Qwen3.6 engine (cuBLAS calls the compat layer does not map), GGML_USE_HIP on the W4A4 target, the trellis overlay sources and the moe_f16_kq stub in the HIP source list; all targets compile for gfx906 (#3172 #3271) 2026-10-20 00:41:38 +02:00
Niko1221 e45a9bfd3a Merge branch 'f-021-split' into f-021-int 2026-10-20 00:19:12 +02:00
Niko1221 34e7c5cb1a Merge branch 'f-021-iA' into f-021-int
# Conflicts:
#	apps/host/server/native-control.mjs
2026-10-20 00:12:03 +02:00
Niko1221 8aaf188aa9 Your own engine settings are kept: user_args, a reserve of 0 on a compute-only card, control vectors survive a load (#3215 #3270 #3216)
The app and setup rewrote the run config's "args" on every Load: the resource preference forced
--vram-reserve-mib to 1536 (2048 with pictures, a fifth of the card on "work") whatever the file said, and
a setup run (started by the Load when the context or pictures differ) dropped every option it did not
choose itself, the control vector options included.

- run config: "user_args" holds the options set by hand; "args_written" is the args as setup/the app last
  wrote them, so a hand edit of "args" is noticed and moved into user_args (strata_control/user_args.py,
  apps/host/server/native-user-args.mjs: the same rule)
- every start (serve/server.py engine_args), every setup run (setup.carry_user_args), every host write and load
  and a saved tuning put the person's options over the generated ones; the same option: theirs wins
- old files without args_written: setup keeps the options the old args had, the new ones lack and setup.py never names
  (plus the control vector group); a reserve that is not setup's own 700
- --vram-reserve-mib: any number the person set (Expert setting, user_args, the key, a value in args) is used as it
  is, 0 included; the safety default (1536/1024, 2048 with pictures, 20% on "work") stays for a card that drives a
  display or is not known to be headless; a card the probe says drives no display keeps 512 (700 with pictures);
  a 0.2.0-beta file where the app wrote the number itself is told from a changed one
- hardware: drives_display per card (nvidia-smi display_active; the Windows adapter with a desktop resolution for AMD)
- setup --vram-reserve-mib, the web settings page and `start --vram-reserve-mib` record the value as the person's
- docs/TROUBLESHOOTING.md: what is kept and what the app still chooses
2026-10-20 00:11:11 +02:00
Niko1221 22703894ec Merge branch 'f-021-iB' into f-021-int
# Conflicts:
#	apps/host/server/native-control.mjs
2026-10-20 00:06:49 +02:00
Niko1221 b1713b3ff2 docs: dual-socket CPU pool measurements (workers across sockets won on one box, lost on another), IOMMU check, no numactl pinning (#3201) 2026-10-20 00:06:38 +02:00
Niko1221 0875029d10 docs: two-card gfx906 tip, --pipeline-windows 2 (+36% decode) and --trim-stage-weights (+5% prompt) measured on 2x MI50, off by default (#3173) 2026-10-20 00:06:38 +02:00
Niko1221 952cce7752 GPU choice (0.2.1): Automatic skips cards the engine cannot run, one card sent as gpu, settings-file GPUs kept, readable dropdown, Monitor opens on assigned cards (#3203 #3195 #3256 #3221 #3211) 2026-10-20 00:06:22 +02:00
Niko1221 30c64f09d7 Test fixes after the 0.2.1 merges: the gfx1151 table parse stops before the iGPU set, the benchmark's model-card link is not a model-host fetch, docker/build.sh copies bench/prompts/standard-v1 for the Terminal package, the Docker script tests use Git bash with unix paths on Windows 2026-10-20 00:05:48 +02:00
Niko1221 cbc0e329c6 Trellis MMQ overlay follows the ggml-cuda it patches: the generated callbacks and the loader pass the precision argument when mmq.cuh has it (llama.cpp b11xxx), identical output on the pinned commit, off with a message for an unknown signature (#3217) 2026-10-20 00:03:13 +02:00
Niko1221 f680a80d88 Batch groups: PROPOSED NVIDIA two-card default is one serial group (measured +12..16% on 2x A4000); owner decides 2026-10-20 00:03:09 +02:00
Niko1221 d8a6a0e012 App settings: the Allow browser origins help says it does nothing in the app, that the app's API already allows pages of any site, and where cors_origins really goes; text in all 8 languages and in DETAILS (#3208) 2026-10-19 23:52:04 +02:00
Niko1221 3cc18d7bb7 Chat: LaTeX math is drawn with KaTeX (inline $..$ and \(..\), display $$..$$ and \[..\]); KaTeX and its woff2 fonts ship with the app and load only when an answer has a formula; money and code stay text (#3268) 2026-10-19 23:49:59 +02:00
Niko1221 1994f3acb2 FP4-Local: setup installs the pinned torch, transformers and safetensors the converter imports (requirements-nvfp4-local.txt) right before a prepare that runs; the tool names a missing package up front instead of after 20 minutes (#3227) 2026-10-19 23:44:24 +02:00
Niko1221 c911065a14 Windows installer: ask for the install folder (another drive when C: is short), keep the earlier folder in app updates and silent installs; docs name what stays on C: (#3186 #3190) 2026-10-19 23:43:47 +02:00
Niko1221 40c605f7bc Windows with a non-UTF-8 code page (Russian): PowerShell output is read as UTF-8 (Documents folder, save dialog, fonts, GPU names), Python runs in UTF-8 mode from the launchers and the host's children, and the engine and picture reader get a UTF-8 manifest so a Cyrillic model folder arrives intact (#3202) 2026-10-19 23:38:49 +02:00
Niko1221 96a9107a27 Downloads: the MTP fetch shows progress in the app and survives a flaky link (runs again, fewer connections, one writer per folder); a cancel and a killed setup end the whole tree on Windows (snapshot + the fetch ends with its parent); Gyro's low-RAM pack gets --compat-bf16 (#3257 #3214 #3193) 2026-10-19 23:38:38 +02:00
Niko1221 4e76d6c9a6 HIP prefill: no start-up hipBLAS warm-up when the cards are of more than one architecture (#3204)
f3be9743 warms the first BF16 and FP16 products of every device at start. On a layer split across a
gfx1151 and a gfx1201 card it slowed prompts about 25% (hc read of the gfx1151 stage 2.7 -> 9.0 s per
32K block; STRATA_PREFILL_WARM=0 restores it). Two cards of one architecture lose nothing (2x R9700,
interleaved pairs). The warm-up now runs only when every visible card has the same gcnArchName;
STRATA_PREFILL_WARM=1 asks for it anyway. Policy in warm_policy.hpp with warm_policy_test.
2026-10-19 23:37:11 +02:00
Niko1221 8441cfd0e2 App: a start that stopped for no VRAM left for the expert cache is tried once more with less kept free (#3192); a model on the person's own GGUF folder keeps that folder, so a reload does not download it (#3199) 2026-10-19 23:31:43 +02:00
Niko1221 9cc2a9b6aa docs: build from git on Linux (clone, setup, rebuild, trying a pull request, engine env, the monitor from a checkout, plain cmake) (#3191) 2026-10-19 23:27:09 +02:00
Niko1221 9b8436df4e tools: gguf-py is also looked up in the install folder (STRATA_INSTALL_DIR, the app's folder), so tools run by hand from the Control package find what setup.py downloaded; the error names STRATA_GGUF_PY (#3269) 2026-10-19 23:26:32 +02:00
Niko1221 c6a20b7e85 Linux: say it when a finite RLIMIT_RTTIME (PipeWire without rtkit) is the reason for an engine death by signal 9, warn at start, troubleshooting entry (#3238) 2026-10-19 23:25:48 +02:00
Niko1221 88b29d481a CPU pool: the LP-E cores (/sys/devices/cpu_lowpower/cpus) are efficiency cores, in the engine and in setup (#3262)
On Meteor Lake and Arrow Lake-H the LP-E cores are in neither the cpu_core nor the cpu_atom
list, so the pool and setup counted them as P-cores: a Core Ultra 7 255H read as 8P+8E, the
more-E-than-P default plan did not apply and 15 workers ran (-35%). A CPU in no list now falls
back to its capacity. Tests: pool_plan_test (helper), tools/test_setup_hybrid.py (fake /sys).
2026-10-19 23:25:46 +02:00
Niko1221 2efa4e067a Merge branch 'f-021-minipc' into f-021-int 2026-10-19 22:20:52 +02:00
Niko1221 eb7eac250b Radeon 780M: the matrix-core prompt set is the default on gfx1103 (integrated, Linux): prompts x1.6 at 1K and x2.0 at 4K, decode unchanged, same greedy ids; STRATA_IGPU_PROMPT_SET=0 turns it off; gfx1150/1151 and Windows stay off; docs note the MES reset issue; per-arch tests 2026-10-19 22:20:33 +02:00
Niko1221 9f5488e3b2 Merge branch 'f-021-arcvis' into f-021-int
# Conflicts:
#	docs/MODELS.md
#	strata_control/vision_reader.py
#	tools/vision/CMakeLists.txt
#	tools/vision/strata_vision.cpp
2026-10-19 21:42:12 +02:00
Niko1221 cac4816afd Intel Arc pictures: on by default for the Alchemist cards (CPU reader), Battlemage stays opt-in; SYCL GPU picture reader (strata-vision-sycl) for --vision gpu; reader rule in vision_reader.py; tests and docs 2026-10-19 21:40:50 +02:00
Niko1221 4ed8eb02fa hipBLASLt tuning table on by default on Strix Halo (gfx1151) only, the owner's choice; opt-in elsewhere 2026-10-19 21:37:42 +02:00
Niko1221 b203b5f71e arch_defaults: UMA arena is not a default (measured slower, resets not fixed): constant off, STRATA_ARENA_PIN_GIB=none stays opt-in; docs say the iGPU prompt set is opt-in with the 780M numbers 2026-10-19 21:37:25 +02:00
Niko1221 468e3a6252 Merge branch 'f-021-hip' into f-021-int 2026-10-19 21:34:18 +02:00
Niko1221 c6aa4223d9 AMD packages carry the hipBLASLt tuning tables; the engine start picks the one for the card
Each AMD engine package gets tuning/<arch>-hipblaslt-<version>.txt for the architectures it covers and the
hipBLASLt version it bundles, and records hipblaslt_version in BUILD.json (packaging/stage_tuning.py for the
Linux build scripts, tools/hip/package_windows.py for the Windows zip). package_engine.py lists the tables in
the manifest and refuses an AMD stage without a version or with a table for another version or architecture.

serve/server.py with_component_engine sets STRATA_HIPBLASLT_TUNING from the package (strata_control/
hipblaslt_tuning.py): a table only for the exact arch and version, a log line for a mismatch, an unreadable
card or a missing version, STRATA_HIPBLASLT_TABLE=on|off (or "hipblaslt_table" in the run config) as the one
switch, and a STRATA_HIPBLASLT_TUNING the person set is kept. setup.py looks in the engine folder's tuning/
first.

A table changes the rounding of the prompt's GEMMs (R9700: residual differs, first-token KL 0.016), so the
automatic choice is off (AUTO_USES_TABLE = False) until the owner decides; "on" uses the table. No gfx1201
table exists for hipBLASLt 1.2.0 and one calibrated for it was not worth shipping (+1% prompt, -2% decode).
2026-10-19 21:33:47 +02:00
Niko1221 c23a78694e Merge branch 'f-021-macvis' into f-021-int 2026-10-19 21:27:28 +02:00
Niko1221 509466a668 Mac pictures: signing smoke checks strata-vision, docs and the 7 READMEs say the Mac reads pictures
sign-and-notarize.sh starts strata-vision --version in the signed engine folders (it already signs every Mach-O).
MAC.md carries the M4 numbers (0.45 s on the GPU, 50.7 s on the CPU for a 260-token picture) and the rule.
2026-10-19 21:26:49 +02:00
Niko1221 a10777a671 Merge branch 'f-021-R' into f-021-int 2026-10-19 21:26:01 +02:00
Niko1221 bcf67d1774 Merge branch 'f-021-bench' into f-021-int 2026-10-19 21:23:46 +02:00
Niko1221 c8e5d2f3ce Docs: BENCHMARK_PROTOCOL (implemented, still draft: field mapping, Metal, versions, runner reading, SECURITY link), COMMUNITY_BENCHMARKS, TERMINAL; BENCH.bat, bench.sh and strata_bench.py point to strata bench run 2026-10-19 21:23:20 +02:00
Niko1221 6d41b37e76 strata bench run: the standard benchmark protocol (standard-v1 is the default suite); fixtures and COMMIT file in the Terminal archive; tests with a mock server 2026-10-19 21:23:20 +02:00
Niko1221 e1ef9df21c Standard benchmark: frozen fixtures (bench/prompts/standard-v1) and the tool that builds them 2026-10-19 21:23:19 +02:00
Niko1221 dab7ee72d0 arch_defaults: the integrated-GPU matrix-core prompt set is opt-in again (bit-changing, owner's call); the Radeon 780M keeps its expert arena unregistered by default on Linux (UMA mode); per-arch selection tests 2026-10-19 21:21:50 +02:00
Niko1221 6b28dbbd1e Merge branch 'f-021-dock' into f-021-int 2026-10-19 21:21:22 +02:00
Niko1221 e0e0c0545a Docker: DOCKER.md records the rocm-gfx120x card test of the 0.2.0-beta packages (Radeon AI PRO R9700) 2026-10-19 21:20:52 +02:00
Niko1221 8acee5a055 Docker: build.sh and publish.sh exit 0 after a good run; DOCKER.md records the 0.2.1 image tests
- The EXIT trap of build.sh (--ctx-only or --ctx given) and publish.sh (--work given) ended with a failing test, so dash reported exit 1 after a
  successful run. Both traps now use if/fi. Test: build.sh --ctx-only exits 0 and its context holds docs/TERMINAL_LAUNCH.md.
- docs/DOCKER.md: image sizes, the 2026-10-19 test of the images built from the 0.2.0-beta packages (cuda13 and cuda12 on an RTX A4000, ROCm images
  built and checked, wrong-card message).
2026-10-19 21:16:34 +02:00
Niko1221 dca9770ef4 Task board: Schedule | Board view on Tasks, derivation with tests, loop record, sidebar count, all 8 locales 2026-10-19 21:05:20 +02:00
Niko1221 ff18fbb868 Task board host side: dismiss mark, newest problem only, one plain line per run, pending approvals of every chat 2026-10-19 21:05:20 +02:00
Niko1221 cf4749276d Mac pictures: strata-vision with Metal built in, Mac setup and launch turn pictures on
tools/vision builds one program for the Mac: ggml's Metal backend inside, --gpu reads on the GPU, without it on the CPU. The reader
choice (strata_control/vision_reader.py) gives a Mac the GPU reader whatever its memory (the CPU reader needs ~50 s per picture:
ggml has no BF16 vector kernel on ARM). setup.py's Mac path downloads the mmproj and writes the vision section; strata launch,
the MCP server and metal/setup_mac.py default pictures on. packaging/build_vision_mac.sh builds the program for the engine package.
2026-10-19 20:59:29 +02:00
Niko1221 578bb6aa7a Docker: publish the images from 0.2.1 (workflow, signed-feed fetch, local script)
- .github/workflows/docker-images.yml: manual only (workflow_dispatch) with the release tag as input; a plan job lists the flavors the
  release has an engine package for; one job per flavor builds the image and, only with push on, pushes ghcr.io/niko1221/strata:<version>-<flavor>
  and latest-<flavor> with GITHUB_TOKEN (packages: write).
- docker/fetch-engines.py: downloads the engine package from the release and checks it against the signed update feed (feed signature, archive
  size and SHA-256, manifest hash, package signature, every file), the same code as `strata update`. Flavors cuda13, cuda12, rocm-gfx120x,
  rocm-gfx110x, rocm-gfx1151 (the halo package), sycl (only if the feed lists one).
- docker/publish.sh: the same steps by hand (fetch, build, start check, push only with --push).
- Fix: docker/build.sh did not copy docs/TERMINAL_LAUNCH.md, which package_terminal.py needs; no image could be built from a clean tree.
- Dockerfile: OCI source/url/documentation labels (ghcr links the package to the repository).
- Compose files and docs/DOCKER.md name the 0.2.1 tags on ghcr.io; DOCKER.md has a maintainers section. Tests for the scripts, the workflow and the fetch.
2026-10-19 20:57:22 +02:00
Niko1221 1073929c80 [Other] serve.test_parallel: split in two files, each about a minute
Not a hang: 42 tests against a fake engine that paces real time took 127 s in one module and passed. The yield/failure tests move to serve/test_parallel_yield.py; the shared fake-engine helpers are FakeEngineCase.
2026-10-19 20:54:34 +02:00
Niko1221 e5dc00c81e docs: integrated GPUs (mini PCs) - the matrix-core prompt set is on by default, numbers, off switches, Windows untested 2026-10-19 20:38:55 +02:00
Niko1221 1c3c6d3351 hip: STRATA_ARENA_PIN_GIB=none leaves the expert arena unregistered (UMA mode for an integrated GPU: no KFD userptr, resident through the working-set lock); opt-in, not default 2026-10-19 20:38:55 +02:00
Niko1221 ce968a2fac hip: integrated Radeons (gfx1103/gfx1150/gfx1151, UMA flag) take the matrix-core prompt set by default; STRATA_IGPU_DEFAULTS=0 and each switch =0 turn it off; unit test with mock devices 2026-10-19 20:38:55 +02:00
Niko1221 40d1a3e7d7 [TUI] strata connect --write: a test with no keyboard, and an ended input counts as no 2026-10-19 20:38:37 +02:00
Niko1221 a269d1b983 [Other] engine hash tests: mock the release probe, keep the engine folder in a temp dir
get_prebuilt has probed the release with a HEAD request since 0.2.0; the refusal tests reached GitHub, got a 404 and compiled instead of refusing. The code is right, the test was stale.
2026-10-19 20:38:04 +02:00
Niko1221 9d2cb4f6d2 [Other] --data-dir never moves the model library without a choice
A --data-dir that names a new folder used to move Models, packs and mtp into it and rewrite the saved data_dir. Now the terminal asks (keep or move), --yes and a missing keyboard keep the old library in place (still found as a root), --move-data moves, and the saved default changes only on a move, --set-default-data-dir, or a PC with no models. Tests use temp folders.
2026-10-19 20:37:29 +02:00
Niko1221 74e25e165b Merge branch 'f-021-hostA' into f-021-int
# Conflicts:
#	apps/host/src/locales/de.json
#	apps/host/src/locales/en.json
#	apps/host/src/locales/es.json
#	apps/host/src/locales/ja.json
#	apps/host/src/locales/ko.json
#	apps/host/src/locales/pt.json
#	apps/host/src/locales/ru.json
#	apps/host/src/locales/zh.json
2026-10-19 20:30:04 +02:00
Niko1221 f5690d51a8 web-ask test: one data folder per bridge so the run exits; the scheduled-task case checks the refusal and the report 2026-10-19 20:25:20 +02:00
Niko1221 e2071c2f8b Downloads: 16 connections, several pieces per request, ModelScope length probe, faster MTP fetch
A single connection to Hugging Face's CDN stalls to 1-3 MB/s for 10-30 s again and again on the
owner's route; eight connections hide it, but a request per 32 MiB piece paid TCP slow start every
time. A request now covers up to four neighbouring pieces (the piece list and resumes are unchanged),
a try that moved bytes forward does not count as a failed one, and the default is 16 connections.
A server whose HEAD gives no length (ModelScope) is asked for bytes 0-0 so it gets the connections
too. mtp_fetch reuses the CDN address, reads the 28 shard headers side by side and lets the
connections run ahead of a slow range.
2026-10-19 20:07:33 +02:00
Niko1221 f2345ebd66 The model page and first-run page show the engine's own pick for this PC (memory tiers, Gyro-M as the other choice); the fit counts the draft layer (F) 2026-10-19 19:56:02 +02:00
Niko1221 671f4573da Fit checks and slot sizing count the draft layer's ~1 GB of graphics memory while MTP is on 2026-10-19 19:56:01 +02:00
Niko1221 3d0a79ca23 SAFETY.md: update checks only on a button press, web search asks once per chat in Manual, starter list refused 2026-10-19 19:54:54 +02:00
Niko1221 f523a94227 Chat footer speed: tool-call writing time counts as decoding time, so tok/s is output tokens over decode time 2026-10-19 19:54:48 +02:00
Niko1221 267f35f777 Chat with no model: plain words from the host, nothing sent before the engine's first state is known; tests for every state 2026-10-19 19:54:48 +02:00
Niko1221 71bdfda0b3 Offline web search: a pretend-offline mode for the search plugin and a test that the model gets the plain sentence 2026-10-19 19:54:48 +02:00
Niko1221 7662bf85c4 Starter model list: with the real engine on, the host refuses to download from it (unpinned files); it is display only 2026-10-19 19:54:47 +02:00
Niko1221 44a56865f5 Manual mode asks once per chat before the first web search or page; Settings, Tools switch to skip the question (off by default) 2026-10-19 19:54:37 +02:00
Niko1221 7f037f4451 Terminal: 'already downloaded' for a model on disk; strata monitor finds a running launch without --url (K) 2026-10-19 19:48:18 +02:00
Niko1221 5c9884cdee A build for another make of graphics card says so in plain words with the right download and is not unpacked (I) 2026-10-19 19:40:57 +02:00
Niko1221 5b4db785ca Start-up line: an installed engine with no model loaded is not 'No Strata engine' (J), and the wrong-make notice wiring 2026-10-19 19:40:57 +02:00
Niko1221 321f159032 Offline: the update check says there is no internet connection in plain words; offline start and load test 2026-10-19 19:40:05 +02:00
Niko1221 54a02dc32c Settings: opening Engine and Update makes no network call; the last check is shown and a new one runs only on a button press 2026-10-19 19:40:05 +02:00
Niko1221 40bf5f5c40 Merge branch 'f-021-settings' into f-021-int 2026-10-19 19:34:47 +02:00
Niko1221 bc54d945f6 Merge branch 'f-021-crash' into f-021-int 2026-10-19 19:34:46 +02:00
Niko1221 7a2bfc52ae Models: a per-model draft layer (MTP) switch, picture-off and custom context that stick
The installed app listed every model's pictures as "off", so each Load of a picture-capable model ran
setup again, which writes the arguments (--mtp, --spec-min-p, --vision, --max-context) and the vision
section from its own choices. Hand edits and the Read pictures tick came back each time.

- host: images comes from the run config's vision section, so a prepared model loads straight away
- host: Read pictures off no longer downloads/keeps the reader; --vision is left out of the engine arguments
- host: Draft layer (MTP) setting; "MTP per request" and it are the app's own and no longer frozen
- host: a context size set by hand shows in the Context list
- "mtp": false in the run config (strata_control/draft_layer.py) is applied at every start by
  serve/server.py, kept by setup, written by `setup --mtp off` and `strata launch --mtp off`
- strata_install accepts any context size, not only the listed ones
- docs: draft layer off, and what the app owns in the run config
2026-10-19 19:29:39 +02:00
Niko1221 e88ee39d1d Docs: state exactly which refusal checks ran (Linux packages) and the Windows PATH note for --version 2026-10-19 19:18:52 +02:00
Niko1221 a5ee74ffa3 Desktop: a failed start on Windows and Mac leaves a log, a crash file and a message box instead of 0xc0000409
Reported crash (0.2.0-beta, Windows 11 26300): strata-desktop.exe exits with 0xc0000409 at 0x31a315 and no log. That offset is the
fast-fail in Rust's abort after a panic (panic = "abort"); a Tauri setup error is a panic ("Failed to setup app"), and the shell had no
log on Windows, no console (windows_subsystem), so the reason was lost.

- the run log opens first in main (%LOCALAPPDATA%\Strata\logs, the folder Save logs already reads) and a panic hook writes the
  message and place to it and to desktop-crash-<time>.log, and shows a plain message box before the abort
- setup no longer returns an error to Tauri: a host that does not start or a window that cannot be made is reported the same way
  and ends the app with exit code 1; the build error is no longer an expect
- the host's output goes to the run log (it was lost without a console)
- plain next step in the box for a WebView2 problem and for a blocked host
- STRATA_NO_DIALOG (scripted runs) and STRATA_DESKTOP_TEST_PANIC (tests) for proving the net
2026-10-19 19:17:43 +02:00
Niko1221 74652692e1 Release matrix tests: Windows rdna3 (RX 7000, Strix Halo) and Linux halo builds, no arch in two hip builds of a platform 2026-10-19 18:32:56 +02:00
Niko1221 85f77658f3 Docs: RX 7000 and Strix Halo installers (Windows rdna3, Linux strixhalo) in the READMEs, AMD_HIP, STRIX_HALO and the 0.2.0 release notes 2026-10-19 18:27:13 +02:00
Niko1221 f57c0adcb3 Merge branch 'f-020-reset' into f-020-int 2026-10-19 17:12:10 +02:00
Niko1221 3fa1b524d7 Portable monitor bundle: carries consoleinfo.py and overview.py (manifest schema 6), the plain Monitor runs when launch is not bundled, smokes accept the Overview's /metrics read, test derives the bundle's import closure 2026-10-19 16:35:51 +02:00
Niko1221 76e51103c7 Merge branch 'f-020-ci' into f-020-int 2026-10-19 16:26:40 +02:00
Niko1221 fcca2ccb84 CI smokes: serve smoke expects the server-package preflight, server smoke accepts OK (skipped=N), nested-telemetry fixture deep enough for Python 3.12+ 2026-10-19 16:23:26 +02:00
Niko1221 eac694ae4c Issue and PR reset bot: intake check, /reopen, 5-day sweep, with node tests 2026-10-19 16:10:26 +02:00
Niko1221 4caf857c76 README translations: the new app and Terminal screenshots with translated captions 2026-10-19 16:03:04 +02:00
Niko1221 e5d987a553 Merge branch 'f-020-shots' into f-020-int 2026-10-19 16:02:32 +02:00
Niko1221 dde39e8a8f README app screenshot: crop the chat footer (its speed figure is wrong, see the 0.2.1 plan) 2026-10-19 16:02:32 +02:00
Niko1221 03b75b8da9 README: new app and Terminal screenshots for 0.2.0-beta 2026-10-19 15:52:41 +02:00
590 changed files with 52972 additions and 2276 deletions

No files matched your search

+1 -1
View File
@@ -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.0-beta · engine 0.1.45.3"
placeholder: "Strata 0.2.2 · engine 0.1.47"
validations:
required: true
- type: input
+218
View File
@@ -0,0 +1,218 @@
'use strict';
// Format check for issues and pull requests (see CONTRIBUTING.md and .github/ISSUE_TEMPLATE/*.yml).
// Pure functions only: no network, no filesystem. Used by intake.yml and reopen.yml through reset-bot.js.
const AREAS = ['Engine', 'GUI', 'TUI', 'Benchmark', 'Docs', 'Other'];
const TITLE_RE = new RegExp('^\\[(' + AREAS.join('|') + ')\\] \\S');
const NO_ANSWER = /^(_no response_|none|n\/a|na|-|todo|tbd|\.|\?)$/i;
const NOT_RUN_LABELS = /^\*\*(passed|failed|not run)\s*:?\*\*\s*:?/gim;
// Version the reopened work must be tested on.
const CURRENT_VERSION_RE = /\b0\.2\.\d+|0\.2\.0-beta/i;
// Any version or commit.
const ANY_VERSION_RE = /\bv?\d+\.\d+(\.\d+)*(-[a-z0-9.]+)?\b|\b[0-9a-f]{7,40}\b|\bcommit\b/i;
const HARDWARE_RE = /\b(rtx|gtx|geforce|quadro|tesla|radeon|rx ?\d|instinct|arc|a\d{3,4}|b\d{2,3}|apple|m[1-5]( pro| max| ultra)?|nvidia|amd|intel|gpu|cpu|vram|\d+ ?gb|ram|windows|linux|ubuntu|debian|fedora|arch|macos|mac os|wsl)\b/i;
const REVISION_RE = /0\.2\.\d|beta|\bmain\b|\b[0-9a-f]{7,40}\b/i;
function stripComments(text) {
return String(text || '').replace(/<!--[\s\S]*?-->/g, '');
}
function hasAreaPrefix(title) {
return TITLE_RE.test(String(title || ''));
}
// Issue forms render as "### Label\n\nvalue". Returns { label: value } for those headings.
function parseSections(body) {
const out = {};
let cur = null;
for (const line of stripComments(body).split(/\r?\n/)) {
const m = /^#{1,6}\s+(.+?)\s*#*\s*$/.exec(line);
if (m) {
cur = m[1].trim();
out[cur] = '';
} else if (cur !== null) {
out[cur] += line + '\n';
}
}
for (const k of Object.keys(out)) out[k] = out[k].trim();
return out;
}
function realText(value) {
const t = String(value || '').replace(NOT_RUN_LABELS, '').replace(/\s+/g, ' ').trim();
return NO_ANSWER.test(t) ? '' : t;
}
function findSection(sections, re) {
for (const k of Object.keys(sections)) if (re.test(k)) return { key: k, value: sections[k] };
return null;
}
// Required parts of each issue form: [display name, heading regex, minimum characters of real text].
const FORMS = {
bug: {
detect: /what happened/i,
parts: [
['Area', /^area$/i, 2],
['Desktop app, Terminal or source?', /desktop app, terminal or source/i, 2],
['What happened', /what happened/i, 20],
['Reproduction status', /reproduction status/i, 2],
['Strata version', /strata version/i, 3],
['Graphics card, memory and system', /graphics card|memory and system/i, 3],
],
needsCurrentVersion: 'Strata version',
},
feature: {
detect: /what do you want to do/i,
parts: [
['Area', /^area$/i, 2],
['What do you want to do?', /what do you want to do/i, 20],
],
},
question: {
detect: /your question/i,
parts: [
['Area', /^area$/i, 2],
['Your question', /your question/i, 15],
],
},
benchmark: {
detect: /what did you measure/i,
parts: [
['What did you measure, or what went wrong', /what did you measure/i, 30],
['Reproduction status', /reproduction status/i, 2],
],
needsCurrentVersionInBody: true,
},
};
function checkIssue({ title, body, labels = [], mode = 'intake' }) {
const missing = [];
if (!hasAreaPrefix(title)) {
missing.push('Start the title with its area: [Engine], [GUI], [TUI], [Benchmark], [Docs] or [Other], then a short description. Example: "[Engine] decode stops after 2 minutes".');
}
const text = stripComments(body);
const sections = parseSections(text);
const labelSet = new Set(labels.map((l) => String(l).toLowerCase()));
const needCurrent = mode === 'reopen';
let formKey = null;
for (const [key, f] of Object.entries(FORMS)) {
if (findSection(sections, f.detect)) { formKey = key; break; }
}
if (formKey) {
const form = FORMS[formKey];
for (const [name, re, min] of form.parts) {
const s = findSection(sections, re);
if (!s || realText(s.value).length < min) missing.push('Fill in "' + name + '".');
}
if (needCurrent && form.needsCurrentVersion) {
const s = findSection(sections, /strata version/i);
if (s && realText(s.value) && !CURRENT_VERSION_RE.test(s.value)) {
missing.push('Re-test on Strata 0.2.0-beta and put that version in "' + form.needsCurrentVersion + '". If it no longer happens, say so in a comment instead.');
}
}
if (needCurrent && form.needsCurrentVersionInBody && !CURRENT_VERSION_RE.test(text)) {
missing.push('Say which Strata version you measured on. Re-measure on 0.2.0-beta if you can.');
}
return { ok: missing.length === 0, missing, kind: formKey };
}
// Free-form text (older issues, blank issues).
const plain = realText(text);
const lenient = labelSet.has('enhancement') || labelSet.has('question');
if (plain.length < (lenient ? 40 : 80)) {
missing.push('Describe it in the issue body: what happened, what you expected, and the exact error if there is one. The issue forms (New issue > Bug report) list what to include.');
}
if (!lenient) {
if (!ANY_VERSION_RE.test(text)) missing.push('Add the Strata version (Settings > About, or `strata --version`).');
else if (needCurrent && !CURRENT_VERSION_RE.test(text)) missing.push('Re-test on Strata 0.2.0-beta and name that version in the issue. If it no longer happens, say so in a comment instead.');
if (!HARDWARE_RE.test(text)) missing.push('Add your graphics card with its VRAM, your RAM and your operating system.');
}
return { ok: missing.length === 0, missing, kind: 'free-form' };
}
function checkPull({ title, body, mode = 'intake' }) {
const missing = [];
if (!hasAreaPrefix(title)) {
missing.push('Start the title with its area: [Engine], [GUI], [TUI], [Benchmark], [Docs] or [Other], then a short description. Example: "[Engine] fix decode stop on long prompts".');
}
const text = stripComments(body);
const sections = parseSections(text);
const problem = findSection(sections, /problem|result|summary|what.*change/i);
const validation = findSection(sections, /validation|testing|tests?\b|how.*tested/i);
let valText = '';
if (problem || validation) {
if (!problem || realText(problem.value).length < 20) missing.push('Fill in "Problem and result": what triggered the change, and how it behaves before and after.');
if (!validation || realText(validation.value).length < 20) {
missing.push('Fill in "Validation": the exact commands, the revision and environment tested, what you saw. Keep Passed, Failed and Not run apart.');
} else {
valText = validation.value;
}
} else {
const plain = realText(text);
if (plain.length < 150) {
missing.push('Describe the pull request: the problem and the result. The pull request template shows the parts (Problem and result, Related discussion, Approach, Validation, Limits).');
}
if (!/(problem|result|before|after|fix|because|bug)/i.test(plain)) missing.push('Add "Problem and result": what triggered the change, and how it behaves before and after.');
if (!/(validat|tested|passed|ran |built|build|not run)/i.test(plain)) missing.push('Add "Validation": the exact commands, the revision and environment tested, and what you saw.');
valText = plain;
}
if (mode === 'reopen' && valText && !REVISION_RE.test(valText)) {
missing.push('Say in Validation which revision you tested: rebase on main (or 0.2.0-beta) and re-run your checks.');
}
return { ok: missing.length === 0, missing, kind: 'pull' };
}
function checkItem({ isPull, ...rest }) {
return isPull ? checkPull(rest) : checkIssue(rest);
}
function checklist(missing) {
return missing.map((m) => '- [ ] ' + m).join('\n');
}
const CONTRIB = 'https://github.com/Niko1221/Strata/blob/main/CONTRIBUTING.md';
function intakeComment(isPull, missing) {
return [
'Thanks for the ' + (isPull ? 'pull request' : 'issue') + '. To keep the tracker readable, every item needs an area prefix in the title and the parts of the ' + (isPull ? 'pull request template' : 'issue form') + ' filled in. This one is missing:',
'',
checklist(missing),
'',
'Edit the ' + (isPull ? 'pull request' : 'issue') + ' (title and description) and this note goes away on its own. If it is still missing parts in 5 days it will be closed, which is not a rejection: fix it and comment `/reopen`.',
'',
'Details: [CONTRIBUTING.md](' + CONTRIB + ')',
].join('\n');
}
function reopenMissingComment(isPull, missing) {
return [
'Not reopened yet. Still missing:',
'',
checklist(missing),
'',
'Edit the ' + (isPull ? 'pull request' : 'issue') + ' and comment `/reopen` again. Details: [CONTRIBUTING.md](' + CONTRIB + ')',
].join('\n');
}
function reopenOkComment(isPull) {
return 'Reopened, thank you. A maintainer will pick it up from here.' + (isPull ? ' If it conflicts with main, rebase it first; that speeds up review.' : '');
}
function staleComment(isPull) {
return [
'Closing this ' + (isPull ? 'pull request' : 'issue') + ' because it still did not follow the format after 5 days. That is housekeeping, not a rejection.',
'',
'To bring it back: fix the title (area prefix) and the missing parts, then comment `/reopen`. A check runs and reopens it, or tells you what is still missing. See [CONTRIBUTING.md](' + CONTRIB + ').',
].join('\n');
}
module.exports = {
AREAS, TITLE_RE, hasAreaPrefix, parseSections, stripComments, realText,
checkIssue, checkPull, checkItem, checklist,
intakeComment, reopenMissingComment, reopenOkComment, staleComment,
};
+192
View File
@@ -0,0 +1,192 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const c = require('./item-check');
const bot = require('./reset-bot');
const bugForm = (over = {}) => {
const f = {
'Area': '[Engine] (speed, kernels, models, the server and API)',
'Desktop app, Terminal or source?': 'Terminal (the `strata` command)',
'What happened': 'Decode stops after two minutes with "CUDA error 700". I expected it to continue.',
'Smallest steps or command that reproduce it': '_No response_',
'Reproduction status': 'Reproduced locally',
'Strata version': 'Strata 0.2.0-beta · engine 0.1.45.3',
'Graphics card, memory and system': 'RTX 5070 12 GB · 64 GB RAM · Windows 11',
...over,
};
return Object.entries(f).map(([k, v]) => `### ${k}\n\n${v}`).join('\n\n');
};
test('title prefix', () => {
assert.ok(c.hasAreaPrefix('[Engine] fix decode'));
assert.ok(c.hasAreaPrefix('[Docs] x'));
assert.ok(!c.hasAreaPrefix('Engine fix'));
assert.ok(!c.hasAreaPrefix('[engine] fix'));
assert.ok(!c.hasAreaPrefix('[Engine]'));
assert.ok(!c.hasAreaPrefix('[Engine] '));
assert.ok(!c.hasAreaPrefix('[Area] foo'));
assert.ok(!c.hasAreaPrefix(''));
assert.ok(!c.hasAreaPrefix(undefined));
});
test('filled bug form passes in both modes', () => {
for (const mode of ['intake', 'reopen']) {
const r = c.checkIssue({ title: '[Engine] decode stops', body: bugForm(), labels: ['bug'], mode });
assert.deepStrictEqual(r.missing, []);
assert.strictEqual(r.kind, 'bug');
}
});
test('bug form: missing parts are named', () => {
const r = c.checkIssue({ title: 'decode stops', body: bugForm({ 'Strata version': '_No response_', 'What happened': 'bad' }) });
assert.ok(!r.ok);
assert.ok(r.missing.some((m) => /title/i.test(m)));
assert.ok(r.missing.some((m) => m.includes('What happened')));
assert.ok(r.missing.some((m) => m.includes('Strata version')));
});
test('reopen needs a 0.2 version, intake does not', () => {
const body = bugForm({ 'Strata version': 'Strata 0.1.44' });
assert.ok(c.checkIssue({ title: '[Engine] x y', body, mode: 'intake' }).ok);
const r = c.checkIssue({ title: '[Engine] x y', body, mode: 'reopen' });
assert.ok(!r.ok);
assert.ok(r.missing.some((m) => m.includes('0.2.0-beta')));
});
test('html comments and placeholders do not count', () => {
const r = c.checkIssue({ title: '[Engine] x y', body: bugForm({ 'What happened': '<!-- describe -->' }) });
assert.ok(r.missing.some((m) => m.includes('What happened')));
});
test('feature and question forms', () => {
const f = '### Area\n\n[GUI] (the desktop app)\n\n### What do you want to do?\n\nLet me pin a model to the top of the list in the app.';
assert.ok(c.checkIssue({ title: '[GUI] pin models', body: f, mode: 'reopen' }).ok);
assert.ok(!c.checkIssue({ title: '[GUI] pin models', body: '### Area\n\n[GUI]\n\n### What do you want to do?\n\n_No response_' }).ok);
const q = '### Area\n\n[Other]\n\n### Your question\n\nHow do I move the model folder to another drive?';
assert.ok(c.checkIssue({ title: '[Other] model folder', body: q }).ok);
});
test('free-form issue', () => {
const good = 'Strata 0.2.0-beta on an RTX 3060 12 GB with 32 GB RAM on Linux: the server hangs when I send two requests at once. Expected both to finish.';
assert.ok(c.checkIssue({ title: '[Engine] hang on two requests', body: good, mode: 'reopen' }).ok);
assert.ok(!c.checkIssue({ title: '[Engine] hang', body: 'it crashes', mode: 'intake' }).ok);
const old = good.replace('0.2.0-beta', '0.1.40');
assert.ok(c.checkIssue({ title: '[Engine] hang', body: old, mode: 'intake' }).ok);
assert.ok(!c.checkIssue({ title: '[Engine] hang', body: old, mode: 'reopen' }).ok);
assert.ok(c.checkIssue({ title: '[Other] idea', body: 'It would help if the app remembered my last model between runs, today I pick it again.', labels: ['enhancement'], mode: 'reopen' }).ok);
});
const prBody = (over = {}) => {
const p = {
problem: 'Decode stopped on prompts above 8k because the stop check read the wrong length. Now it continues.',
validation: '**Passed:** `ctest -R decode`, commit abcdef1, RTX 3060, 12 of 12.\n\n**Failed:** none observed\n\n**Not run:** HIP build',
...over,
};
return `## Problem and result\n<!-- hint -->\n${p.problem}\n\n## Related discussion\nnone\n\n## Approach\nFix the length.\n\n## Validation\n${p.validation}\n\n## Limits\nnone`;
};
test('filled PR passes', () => {
assert.deepStrictEqual(c.checkPull({ title: '[Engine] fix stop', body: prBody(), mode: 'reopen' }).missing, []);
});
test('untouched PR template fails', () => {
const tpl = '## Problem and result\n<!-- What triggered -->\n\n## Validation\n<!-- cmds -->\n\n**Passed:**\n<!-- x -->\n\n**Failed:**\n\n**Not run:**\n';
const r = c.checkPull({ title: '[Engine] fix stop', body: tpl });
assert.ok(!r.ok);
assert.ok(r.missing.some((m) => m.includes('Problem and result')));
assert.ok(r.missing.some((m) => m.includes('Validation')));
});
test('PR: empty body and bad title', () => {
const r = c.checkPull({ title: 'fix', body: null });
assert.ok(!r.ok);
assert.ok(r.missing.length >= 2);
});
test('PR reopen needs a revision', () => {
const body = prBody({ validation: '**Passed:** I tried it and it worked for me on my machine, looked fine.' });
assert.ok(c.checkPull({ title: '[Engine] x y', body, mode: 'intake' }).ok);
assert.ok(!c.checkPull({ title: '[Engine] x y', body, mode: 'reopen' }).ok);
});
test('free-form PR', () => {
const body = 'This fixes a crash when the context is above 8k, because the length was read from the wrong field. Before it aborted, after it continues. I built it on CUDA and ran the decode test, passed.';
assert.ok(c.checkPull({ title: '[Engine] fix crash', body }).ok);
assert.ok(!c.checkPull({ title: '[Engine] fix crash', body: 'fixes stuff' }).ok);
});
test('messages', () => {
const m = c.intakeComment(false, ['a', 'b']);
assert.ok(m.includes('- [ ] a') && m.includes('/reopen') && m.includes('CONTRIBUTING.md'));
assert.ok(c.staleComment(true).includes('/reopen'));
});
// handlers with a fake GitHub client
function fake(calls, extra = {}) {
const rest = {
issues: {
getLabel: async () => ({}),
createLabel: async (a) => calls.push(['createLabel', a.name]),
addLabels: async (a) => calls.push(['addLabels', a.labels]),
removeLabel: async (a) => calls.push(['removeLabel', a.name]),
createComment: async (a) => calls.push(['comment', a.body]),
update: async (a) => calls.push(['update', a.state]),
get: async () => ({ data: extra.live }),
listEvents: async () => {},
listForRepo: async () => {},
},
pulls: { get: async () => ({ data: { mergeable_state: 'clean' } }) },
};
return { rest, paginate: async (fn) => (fn === rest.issues.listEvents ? (extra.events || []) : (extra.items || [])) };
}
const core = { info() {} };
const repo = { owner: 'o', repo: 'r' };
test('intake labels and comments once', async () => {
const calls = [];
const item = { number: 1, title: 'bad', body: '', labels: [], user: { type: 'User' }, author_association: 'NONE' };
await bot.intake({ github: fake(calls), context: { repo, payload: { action: 'opened', issue: item } }, core });
assert.deepStrictEqual(calls.map((c) => c[0]), ['addLabels', 'comment']);
calls.length = 0;
item.labels = [{ name: 'needs-format' }];
await bot.intake({ github: fake(calls), context: { repo, payload: { action: 'edited', issue: item } }, core });
assert.deepStrictEqual(calls, []);
});
test('intake clears the label when edited into shape', async () => {
const calls = [];
const item = { number: 1, title: '[Engine] x y', body: bugForm(), labels: [{ name: 'needs-format' }], user: { type: 'User' } };
await bot.intake({ github: fake(calls), context: { repo, payload: { action: 'edited', issue: item } }, core });
assert.deepStrictEqual(calls.map((c) => c[0]), ['removeLabel', 'comment']);
});
test('reopen: ignores strangers, reopens the author when it passes', async () => {
const issue = { number: 5, state: 'closed', user: { login: 'amy' }, labels: [] };
let calls = [];
const comment = { body: '/reopen', user: { login: 'bob', type: 'User' }, author_association: 'NONE' };
await bot.reopen({ github: fake(calls), context: { repo, payload: { issue, comment } }, core });
assert.deepStrictEqual(calls, []);
const live = { number: 5, title: '[Engine] x y', body: bugForm(), labels: [] };
comment.user.login = 'amy';
await bot.reopen({ github: fake(calls, { live }), context: { repo, payload: { issue, comment } }, core });
assert.deepStrictEqual(calls.map((c) => c[0]), ['update', 'removeLabel', 'comment']);
assert.strictEqual(calls[0][1], 'open');
calls = [];
live.title = 'no prefix';
await bot.reopen({ github: fake(calls, { live }), context: { repo, payload: { issue, comment } }, core });
assert.deepStrictEqual(calls.map((c) => c[0]), ['comment']);
assert.ok(calls[0][1].includes('Not reopened'));
});
test('sweep closes only old flagged items', async () => {
const calls = [];
const old = new Date(Date.now() - 6 * 86400000).toISOString();
const fresh = new Date(Date.now() - 1 * 86400000).toISOString();
const mk = (n, created) => ({ number: n, title: 'bad', body: '', labels: [{ name: 'needs-format' }], created_at: created });
const gh = fake(calls, { items: [mk(1, old), mk(2, fresh)] });
await bot.sweep({ github: gh, context: { repo }, core });
assert.deepStrictEqual(calls.map((c) => c[0]), ['comment', 'update']);
});
+164
View File
@@ -0,0 +1,164 @@
'use strict';
// GitHub-side handlers for intake.yml, reopen.yml and the 5-day sweep.
// They take the actions/github-script objects ({ github, context, core }) and never touch PR code:
// everything comes from the event payload and the REST API.
const check = require('./item-check');
const LABEL = 'needs-format';
const STALE_DAYS = 5;
const MAINTAINER = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
// Items a maintainer closed on purpose stay closed.
const BLOCK_LABELS = new Set(['wontfix', 'invalid', 'duplicate', 'declined']);
function labelNames(item) {
return (item.labels || []).map((l) => (typeof l === 'string' ? l : l.name));
}
async function ensureLabel(github, context) {
const { owner, repo } = context.repo;
try {
await github.rest.issues.getLabel({ owner, repo, name: LABEL });
} catch (e) {
if (e.status !== 404) throw e;
await github.rest.issues.createLabel({
owner, repo, name: LABEL, color: 'fbca04',
description: 'Title or description does not follow CONTRIBUTING.md yet',
});
}
}
async function removeLabel(github, context, number) {
const { owner, repo } = context.repo;
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: number, name: LABEL });
} catch (e) {
if (e.status !== 404) throw e;
}
}
function itemFromPayload(context) {
const p = context.payload;
return p.pull_request || p.issue;
}
function isPullItem(item, context) {
return Boolean(context.payload.pull_request || item.pull_request);
}
// issues: opened/edited/reopened, pull_request_target: opened/edited/reopened
async function intake({ github, context, core }) {
const { owner, repo } = context.repo;
const item = itemFromPayload(context);
if (!item || (item.user && item.user.type === 'Bot')) return;
if (MAINTAINER.has(item.author_association) && context.payload.action === 'opened') {
// Maintainer items are not nagged on creation.
return;
}
const isPull = isPullItem(item, context);
const labels = labelNames(item);
const hasLabel = labels.includes(LABEL);
const res = check.checkItem({ isPull, title: item.title, body: item.body, labels, mode: 'intake' });
core.info('check ' + (isPull ? 'PR' : 'issue') + ' #' + item.number + ': ' + (res.ok ? 'ok' : res.missing.join(' | ')));
if (res.ok) {
if (hasLabel) {
await removeLabel(github, context, item.number);
await github.rest.issues.createComment({ owner, repo, issue_number: item.number, body: 'Thanks, this follows the format now.' });
}
return;
}
if (context.payload.action === 'edited' && !hasLabel) return; // edits only matter for items already flagged
if (hasLabel) return; // one note only
await ensureLabel(github, context);
await github.rest.issues.addLabels({ owner, repo, issue_number: item.number, labels: [LABEL] });
await github.rest.issues.createComment({ owner, repo, issue_number: item.number, body: check.intakeComment(isPull, res.missing) });
}
// issue_comment created, body starts with /reopen
async function reopen({ github, context, core }) {
const { owner, repo } = context.repo;
const comment = context.payload.comment;
const issue = context.payload.issue;
if (!/^\s*\/reopen\b/.test(comment.body || '')) return;
if (comment.user.type === 'Bot') return;
const isAuthor = comment.user.login === issue.user.login;
const isMaint = MAINTAINER.has(comment.author_association);
if (!isAuthor && !isMaint) {
core.info('ignored /reopen from ' + comment.user.login + ' (not the author or a maintainer)');
return;
}
const isPull = Boolean(issue.pull_request);
const say = (body) => github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body });
const labels = labelNames(issue);
const wasOpen = issue.state === 'open';
if (labels.some((l) => BLOCK_LABELS.has(l.toLowerCase())) && !isMaint) {
await say('This one was closed on purpose (' + labels.filter((l) => BLOCK_LABELS.has(l.toLowerCase())).join(', ') + '). Please add your new evidence in a comment and a maintainer will decide.');
return;
}
if (wasOpen && !labels.includes(LABEL)) {
core.info('already open, nothing to do');
return;
}
// The bot reads the live item, not the possibly stale event copy.
const live = (await github.rest.issues.get({ owner, repo, issue_number: issue.number })).data;
const res = check.checkItem({ isPull, title: live.title, body: live.body, labels: labelNames(live), mode: 'reopen' });
core.info('reopen check #' + live.number + ': ' + (res.ok ? 'ok' : res.missing.join(' | ')));
if (!res.ok) {
await say(check.reopenMissingComment(isPull, res.missing));
return;
}
if (!wasOpen) {
try {
await github.rest.issues.update({ owner, repo, issue_number: live.number, state: 'open' });
} catch (e) {
await say('The check passed, but GitHub would not reopen it (' + (e.message || e.status) + '). ' + (isPull ? 'If the branch was deleted, restore it or open a new pull request and link this one.' : 'Please open a new issue and link this one.'));
return;
}
}
await removeLabel(github, context, live.number);
let extra = '';
if (isPull) {
try {
const pr = (await github.rest.pulls.get({ owner, repo, pull_number: live.number })).data;
if (pr.mergeable_state === 'dirty') extra = '\n\nIt has conflicts with main now: please rebase it.';
} catch (e) { /* best effort */ }
}
await say(check.reopenOkComment(isPull) + extra);
}
async function labelTime(github, owner, repo, number, fallback) {
const events = await github.paginate(github.rest.issues.listEvents, { owner, repo, issue_number: number, per_page: 100 });
let t = null;
for (const e of events) {
if (e.event === 'labeled' && e.label && e.label.name === LABEL) t = e.created_at;
}
return t || fallback;
}
// scheduled: close items that kept the needs-format label for more than 5 days
async function sweep({ github, context, core }) {
const { owner, repo } = context.repo;
const cutoff = Date.now() - STALE_DAYS * 86400 * 1000;
const items = await github.paginate(github.rest.issues.listForRepo, { owner, repo, state: 'open', labels: LABEL, per_page: 100 });
core.info(items.length + ' open items with ' + LABEL);
for (const item of items) {
const since = new Date(await labelTime(github, owner, repo, item.number, item.created_at)).getTime();
if (since > cutoff) continue;
const isPull = Boolean(item.pull_request);
// Re-check first: an edit that fixed it should have cleared the label already; do not close good items.
const res = check.checkItem({ isPull, title: item.title, body: item.body, labels: labelNames(item), mode: 'intake' });
if (res.ok) {
await removeLabel(github, context, item.number);
continue;
}
await github.rest.issues.createComment({ owner, repo, issue_number: item.number, body: check.staleComment(isPull) });
await github.rest.issues.update({ owner, repo, issue_number: item.number, state: 'closed', state_reason: 'not_planned' });
core.info('closed #' + item.number);
}
}
module.exports = { intake, reopen, sweep, LABEL, STALE_DAYS };
+114
View File
@@ -0,0 +1,114 @@
name: Docker images
# Builds the Strata container images of one release from its engine packages and pushes them to ghcr.io.
# Run by hand only (Actions tab -> Docker images -> Run workflow). Nothing starts it on its own.
#
# For each flavor the job downloads the engine package from the release and checks it against the signed update feed
# (docker/fetch-engines.py: the feed's Ed25519 signature, the archive's SHA-256, the manifest hash, the package signature,
# every file's hash), builds the image with docker/build.sh and, only when "push" is on, pushes
# ghcr.io/niko1221/strata:<version>-<flavor> and ghcr.io/niko1221/strata:latest-<flavor>
# The first run with "push" off builds everything without publishing it (a dry run).
on:
workflow_dispatch:
inputs:
release_tag:
description: 'Release tag whose engine packages go into the images (for example v0.2.2)'
required: true
type: string
flavors:
description: 'Flavors to build, comma separated (empty: every flavor the release has a package for)'
required: false
default: ''
type: string
push:
description: 'Push the images to ghcr.io (off: build only, nothing is published)'
required: true
default: false
type: boolean
latest:
description: 'Also move the latest-<flavor> tags (turn off for a pre-release or a test)'
required: true
default: true
type: boolean
source_ref:
description: 'Git ref of the Dockerfile and the Terminal sources (empty: the release tag)'
required: false
default: ''
type: string
permissions:
contents: read
env:
IMAGE: ghcr.io/niko1221/strata
jobs:
plan:
runs-on: ubuntu-24.04
outputs:
flavors: ${{ steps.list.outputs.flavors }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.source_ref || inputs.release_tag }}
- name: Which flavors does the release have a package for
id: list
env:
WANT: ${{ inputs.flavors }}
TAG: ${{ inputs.release_tag }}
run: |
set -eu
args="--release-tag $TAG --list --skip-missing"
[ -n "$WANT" ] && args="$args --flavors $WANT"
python3 docker/fetch-engines.py $args > list.tsv
cat list.tsv
flavors=$(awk -F'\t' '$3 != "SKIP" {printf "%s\"%s\"", (n++ ? "," : ""), $1}' list.tsv)
[ -n "$flavors" ] || { echo "the release has no engine package for any requested flavor"; exit 1; }
echo "flavors=[$flavors]" >> "$GITHUB_OUTPUT"
image:
needs: plan
runs-on: ubuntu-24.04
timeout-minutes: 120
permissions:
contents: read
packages: write
strategy:
fail-fast: false
max-parallel: 3
matrix:
flavor: ${{ fromJSON(needs.plan.outputs.flavors) }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.source_ref || inputs.release_tag }}
- name: Free disk space (the runner has about 14 GB; a package plus its image needs about 10 GB)
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL || true
docker system prune -af >/dev/null 2>&1 || true
df -h /
- name: Log in to ghcr.io
if: ${{ inputs.push }}
env:
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REGISTRY_USER: ${{ github.actor }}
run: printf '%s' "$REGISTRY_TOKEN" | docker login ghcr.io -u "$REGISTRY_USER" --password-stdin
- name: Fetch, verify, build (and push when push is on)
env:
TAG: ${{ inputs.release_tag }}
FLAVOR: ${{ matrix.flavor }}
PUSH: ${{ inputs.push && '--push' || '' }}
LATEST: ${{ !inputs.latest && '--no-latest' || '' }}
run: |
docker/publish.sh --release-tag "$TAG" --flavors "$FLAVOR" --image "$IMAGE" $PUSH $LATEST
- name: Summary
if: ${{ always() }}
run: |
{
echo "### ${{ matrix.flavor }}"
echo "- release: ${{ inputs.release_tag }}, pushed: ${{ inputs.push }}"
echo "- image: \`$IMAGE:${INPUT_TAG#v}-${{ matrix.flavor }}\`"
} >> "$GITHUB_STEP_SUMMARY"
env:
INPUT_TAG: ${{ inputs.release_tag }}
+58
View File
@@ -0,0 +1,58 @@
name: Intake
# New issues and pull requests that do not follow CONTRIBUTING.md get the needs-format label and one friendly note.
# Items that keep the label for 5 days are closed by the daily sweep, with a note on how to /reopen.
#
# pull_request_target is used instead of pull_request on purpose: a pull request from a fork gets a read-only
# GITHUB_TOKEN under pull_request, so it could not be labelled or commented on. It is safe here because this
# workflow never checks out or runs the pull request's code. It checks out the default branch only (the bot
# scripts) and reads the title and description from the event payload and the REST API.
on:
issues:
types: [opened, edited]
pull_request_target:
types: [opened, edited]
schedule:
- cron: '17 4 * * *'
workflow_dispatch:
permissions:
contents: read
issues: write
pull-requests: write
concurrency:
group: intake-${{ github.event.issue.number || github.event.pull_request.number || 'sweep' }}
cancel-in-progress: false
jobs:
check:
if: github.event_name == 'issues' || github.event_name == 'pull_request_target'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}
sparse-checkout: .github/scripts
persist-credentials: false
- uses: actions/github-script@v7
with:
script: |
const bot = require('./.github/scripts/reset-bot.js');
await bot.intake({ github, context, core });
sweep:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}
sparse-checkout: .github/scripts
persist-credentials: false
- uses: actions/github-script@v7
with:
script: |
const bot = require('./.github/scripts/reset-bot.js');
await bot.sweep({ github, context, core });
+38
View File
@@ -0,0 +1,38 @@
name: Reopen
# An author comments /reopen on their closed issue or pull request. The check runs and the item is reopened,
# or the bot replies with what is still missing. GitHub does not let authors reopen items a maintainer closed,
# so this workflow does it for them.
#
# issue_comment always runs the workflow file from the default branch with a write-capable token, which is what
# we need for fork authors too. It never checks out or executes the pull request's code: it reads the live
# title and description through the REST API. Only the author of the item or a maintainer can trigger it.
on:
issue_comment:
types: [created]
permissions:
contents: read
issues: write
pull-requests: write
concurrency:
group: reopen-${{ github.event.issue.number }}
cancel-in-progress: false
jobs:
reopen:
if: startsWith(github.event.comment.body, '/reopen') && github.event.comment.user.type != 'Bot'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}
sparse-checkout: .github/scripts
persist-credentials: false
- uses: actions/github-script@v7
with:
script: |
const bot = require('./.github/scripts/reset-bot.js');
await bot.reopen({ github, context, core });
+21
View File
@@ -0,0 +1,21 @@
name: Scripts test
on:
pull_request:
paths:
- '.github/scripts/**'
- '.github/workflows/intake.yml'
- '.github/workflows/reopen.yml'
permissions:
contents: read
jobs:
node-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: node --test .github/scripts/item-check.test.js
+2 -2
View File
@@ -2,7 +2,7 @@
Strata runs the Qwen3.8-Flash-Next mixture-of-experts model (and its Coder, Swift 1.5, Unsloth, Gyro and NVFP4
variants, and Qwen3.6-35B-A3B) on a normal PC: one NVIDIA or AMD graphics card plus system RAM on Windows or Linux,
or an Apple Silicon Mac. Version 0.2.0-beta, engine 0.1.45.3. It has three parts:
or an Apple Silicon Mac. Version 0.2.2, engine 0.1.47. It has three parts:
- **The app** (`apps/desktop`, the Tauri shell; `apps/host`, the Node host and the web UI). It is for people who do
not want a terminal. The installer has the engine inside.
@@ -45,7 +45,7 @@ Follow **[docs/AI_SETUP.md](docs/AI_SETUP.md)**. For an agent that has a shell,
4. Connect the user's apps: `strata connect` lists them, `strata connect <app> --check` sends one prompt and one tool
call the way that app does.
Never expose the server beyond `127.0.0.1` without an API key. `--access lan` makes a key for you. The old route (clone
Keep the server on `127.0.0.1` unless the user asks for more. Beyond it an API key is recommended: `--access lan` makes one for you, and `--no-api-key` serves without one (a warning, never a refusal; on a public tunnel the warning is stronger). The old route (clone
the repository and run `START-HERE.bat --yes` or `./setup.sh --yes`) still works and is the fallback when the
Terminal cannot run. Strata's MCP server ([docs/MCP_SERVER.md](docs/MCP_SERVER.md)) offers the same steps as tools.
+3
View File
@@ -2,6 +2,7 @@
rem Benchmark this Strata install the way it is used and write a report you can share (docs/COMMUNITY_BENCHMARKS.md).
rem It starts the model with your settings (or uses the one already running), takes about 10 minutes and changes nothing.
rem Options: --clients 4 (several requests at once), --long (a 32K prompt), --compare <older results folder>.
rem This is the quick speed check. The standard benchmark (docs/BENCHMARK_PROTOCOL.md), whose results other people can compare with, is `strata bench run`.
setlocal
title Strata - benchmark
cd /d "%~dp0"
@@ -10,6 +11,8 @@ if not exist ".venv\Scripts\python.exe" (
pause
exit /b 1
)
echo Quick speed check. For results other people can compare with (the standard benchmark, a few minutes), run: .venv\Scripts\python.exe strata.py bench run
echo.
".venv\Scripts\python.exe" tools\strata_bench.py %*
set "RC=%ERRORLEVEL%"
pause
+70 -4
View File
@@ -8,7 +8,7 @@
# below refuses anything older: silently building for a pre-Ampere arch would produce a binary that runs
# and is wrong about its own performance, which is the worst of both outcomes.
cmake_minimum_required(VERSION 3.24)
project(strata VERSION 0.1.45.3 LANGUAGES CXX)
project(strata VERSION 0.1.47 LANGUAGES CXX)
# EXPERIMENTAL: the Intel Arc engine, the SYCL port in sycl/ (PR #423; icpx + oneMKL, docs/INTEL_ARC.md). It is its
# own project that takes the unported sources from src/; with this on, nothing below is configured.
option(STRATA_ENABLE_SYCL "EXPERIMENTAL: build the Intel Arc (SYCL) engine from sycl/ - docs/INTEL_ARC.md" OFF)
@@ -48,6 +48,17 @@ if(STRATA_PORTABLE AND MSVC)
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded")
endif()
# #3202: Windows hands a program its command line in the system's ANSI code page (cp1251 / cp866 on a Russian Windows, cp1252 on an
# English one), but the engine reads every path as UTF-8 (the run configs and the server write UTF-8). A model folder with
# Cyrillic letters arrived as other bytes, or as "?" where the page has no such letter, and the model did not open. This manifest
# makes the process's ANSI code page UTF-8 (Windows 10 1903 and newer; older Windows ignores it and nothing changes), so the
# command line, fopen and std::ifstream all take UTF-8.
function(strata_utf8_manifest target)
if(MSVC)
target_sources(${target} PRIVATE ${CMAKE_CURRENT_FUNCTION_LIST_DIR}/cmake/strata-utf8.manifest)
endif()
endfunction()
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
@@ -722,8 +733,16 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
endif()
add_executable(strata src/program/generate.cpp)
strata_utf8_manifest(strata)
target_include_directories(strata PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(strata PRIVATE strata_engine strata_prefill strata_spec ${_strata_gpu_runtime_target})
if(WIN32 AND STRATA_ENABLE_HIP)
# 0.2.1.2: the ROCm math libraries live in <package>\rocm\bin, which is on PATH only when the launcher starts the engine; load them on first
# use (main() adds that folder to the DLL search first) so `strata.exe --version` works from the package folder. amdhip64_7.dll and the
# C++ runtime sit beside the exe (tools/hip/package_windows.py) and stay normal imports.
target_link_libraries(strata PRIVATE delayimp)
target_link_options(strata PRIVATE "LINKER:/DELAYLOAD:hipblas.dll" "LINKER:/DELAYLOAD:libhipblaslt.dll" "LINKER:/DELAYLOAD:rocblas.dll")
endif()
# NEW-MODELS-PLAN.md T1: Qwen3.6-35B-A3B's reference path (one GPU, the model resident, plain kernels), checked
# against llama.cpp by tools/qwen35/; the engine itself does not run the family yet
@@ -732,7 +751,10 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_link_libraries(strata-q36ref PRIVATE strata_kernels strata_warnings ${_strata_gpu_runtime_target})
# the engine path for the same family (serve protocol, prompt chunks, decode graph): src/qwen35/q36_engine.cu, a
# library that `strata` switches to for a qwen35moe model (the family switch at the top of its main) and that
# strata-q36 wraps on its own
# strata-q36 wraps on its own. Not on gfx906 (#3172, #3271): it calls cuBLAS entry points the wave64 compat layer does
# not map (cublasSgemm, cublasGemmStridedBatchedEx) and its kernels assume 32-lane warps; main() guards every use with
# STRATA_HAVE_QWEN35, so a gfx906 build links and runs the Qwen3.8 family, and `strata --families` says so
if(NOT STRATA_HIP_GFX906)
add_library(strata_qwen35 STATIC src/qwen35/q36_engine.cu)
target_include_directories(strata_qwen35 PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(strata_qwen35 PRIVATE strata_prefill strata_kernels strata_warnings ${_strata_gpu_runtime_target})
@@ -742,9 +764,11 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_compile_definitions(strata_qwen35 PRIVATE STRATA_Q36_CPU_POOL=1)
endif()
add_executable(strata-q36 src/qwen35/q36_main.cpp)
strata_utf8_manifest(strata-q36)
target_link_libraries(strata-q36 PRIVATE strata_qwen35 ${_strata_gpu_runtime_target})
target_link_libraries(strata PRIVATE strata_qwen35)
target_compile_definitions(strata PRIVATE STRATA_HAVE_QWEN35=1)
endif()
if(STRATA_ENABLE_HIP)
enable_testing()
@@ -968,6 +992,12 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_include_directories(iq_multi_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(iq_multi_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME iq_multi_parity COMMAND iq_multi_parity)
# the Gyro trellis decode kernels (TQ2_T / TQK6 / TQK7) on synthetic blocks: decode-once vs per-column bitwise, output
# checksums to compare two builds byte for byte, --bench for timings (GPU, no model)
add_executable(tq_pascal_bench src/kernels/tq_pascal_bench.cpp)
target_include_directories(tq_pascal_bench PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(tq_pascal_bench PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME tq_pascal_bench COMMAND tq_pascal_bench)
set_tests_properties(iq_multi_parity PROPERTIES ENVIRONMENT "STRATA_KQ_SPLIT=1") # the Q4_K / Q5_K / Q8_0 Split path against the old kernels
# the same Split path on the other routes it can meet: the generic multi kernels (no V2K) and the fused gate/up + SwiGLU kernel, and the sub-16-row
# gate/up route switched off (a K-quant row has 160 dots, not the 80 the sub-16 rows assume: STRATA_KQ_SPLIT=1 must stay bitwise on all of them)
@@ -1383,6 +1413,9 @@ if(STRATA_BUILD_TESTS)
add_executable(arch_defaults_test src/core/arch_defaults_test.cpp src/core/arch_defaults.cpp)
target_include_directories(arch_defaults_test PRIVATE include)
add_test(NAME arch_defaults_test COMMAND arch_defaults_test)
add_executable(uma_cache_test src/core/uma_cache_test.cpp)
target_include_directories(uma_cache_test PRIVATE include)
add_test(NAME uma_cache_test COMMAND uma_cache_test)
add_executable(suffix_drafter_test src/spec/suffix_drafter_test.cpp)
target_link_libraries(suffix_drafter_test PRIVATE strata_spec)
add_test(NAME suffix_drafter_test COMMAND suffix_drafter_test)
@@ -1392,6 +1425,9 @@ if(STRATA_BUILD_TESTS)
add_executable(draft_policy_test src/spec/draft_policy_test.cpp)
target_link_libraries(draft_policy_test PRIVATE strata_spec)
add_test(NAME draft_policy_test COMMAND draft_policy_test)
add_executable(spec_start_test src/spec/spec_start_test.cpp)
target_include_directories(spec_start_test PRIVATE include)
add_test(NAME spec_start_test COMMAND spec_start_test)
endif()
if(STRATA_BUILD_TESTS OR STRATA_BUILD_CONVERSATION_TESTS)
add_executable(message_boundary_test src/program/message_boundary_test.cpp)
@@ -2071,14 +2107,14 @@ if(STRATA_BUILD_TESTS)
add_executable(native_hadamard_test src/kernels/cpu/native_hadamard_test.cpp)
target_link_libraries(native_hadamard_test PRIVATE strata_kernels_cpu strata_artifact)
add_test(NAME native_hadamard_test COMMAND native_hadamard_test)
set_tests_properties(native_hadamard_test PROPERTIES TIMEOUT 300)
set_tests_properties(native_hadamard_test PROPERTIES TIMEOUT 300 SKIP_RETURN_CODE 77)
# the CPU trellis kernels (tq_cpu.hpp): AVX2 / AVX-512 vs the scalar kernel bit for bit, vs ggml-cpu's vec_dot
# and to_float, plain and with the Hadamard rotation; `tq_cpu_parity --bench` is the micro-benchmark
add_executable(tq_cpu_parity src/kernels/cpu/tq_cpu_parity.cpp)
target_link_libraries(tq_cpu_parity PRIVATE strata_kernels_cpu)
add_test(NAME tq_cpu_parity COMMAND tq_cpu_parity)
add_test(NAME tq_cpu_parity_avx2 COMMAND ${CMAKE_COMMAND} -E env STRATA_FORCE_AVX2=1 $<TARGET_FILE:tq_cpu_parity>)
set_tests_properties(tq_cpu_parity tq_cpu_parity_avx2 PROPERTIES TIMEOUT 300)
set_tests_properties(tq_cpu_parity tq_cpu_parity_avx2 PROPERTIES TIMEOUT 300 SKIP_RETURN_CODE 77)
endif()
add_executable(expert_parity src/kernels/cpu/expert_parity.cpp)
@@ -2091,10 +2127,23 @@ if(STRATA_BUILD_TESTS)
add_executable(pool_test src/kernels/cpu/pool_test.cpp)
target_link_libraries(pool_test PRIVATE strata_kernels_cpu)
add_test(NAME pool_test COMMAND pool_test --selftest WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR})
# where the workers are placed (one per physical core by default) and --pool-cores (real pools, no model)
add_executable(pool_cores_test src/kernels/cpu/pool_cores_test.cpp)
target_link_libraries(pool_cores_test PRIVATE strata_kernels_cpu)
add_test(NAME pool_cores_test COMMAND pool_cores_test)
# the default CPU expert worker count on mock CPUs (8c/16t, 16c/32t, 64c/128t, 2-socket 128c/256t, ...)
add_executable(pool_plan_test src/kernels/cpu/pool_plan_test.cpp)
target_include_directories(pool_plan_test PRIVATE include)
add_test(NAME pool_plan_test COMMAND pool_plan_test)
# 0.2.2: when the pool splits per NUMA node by itself (a fake sysfs node folder, mock cores; STRATA_NUMA_POOL=0, one node = unchanged)
add_executable(numa_pool_test src/kernels/cpu/numa_pool_test.cpp)
target_include_directories(numa_pool_test PRIVATE include)
target_compile_features(numa_pool_test PRIVATE cxx_std_17)
add_test(NAME numa_pool_test COMMAND numa_pool_test)
# #3204: when the HIP start-up warm-up of hipBLAS may run (pure policy, no GPU)
add_executable(warm_policy_test src/prefill/warm_policy_test.cpp)
target_include_directories(warm_policy_test PRIVATE include)
add_test(NAME warm_policy_test COMMAND warm_policy_test)
# issue #29: tiny batches with sleeping workers, as on a big-VRAM card; a hang fails it
add_executable(pool_stress src/kernels/cpu/pool_stress.cpp)
target_link_libraries(pool_stress PRIVATE strata_kernels_cpu)
@@ -2164,6 +2213,23 @@ if(STRATA_HIP_GFX906)
file(GLOB _strata_mmq_cu ${_strata_mmq}/*.cu ${_strata_mmq}/template-instances/*.cu)
list(APPEND _strata_cu ${_strata_mmq_cu})
target_compile_definitions(strata_mmq PRIVATE GGML_USE_HIP)
# #3172: strata_mmq_w4a4 is built from llama.cpp's headers too; without GGML_USE_HIP its common.cuh took the CUDA vendor header
# (cuda.h, cuda_bf16.h: not found). Here it only reports that W4A4 (RTX 50's FP4 MMA) is not available.
if(TARGET strata_mmq_w4a4)
target_compile_definitions(strata_mmq_w4a4 PRIVATE GGML_USE_HIP)
endif()
# #3271: three more link errors of a 0.1.44+ gfx906 build, all sources that strata_mmq lists but the glob above never saw:
# the trellis overlay's quantize.cu and instances (they are generated into the build tree, so the glob missed them: CMake drops a
# .cu source whose language is not enabled) and moe_f16_kq.cu, which moe_fused_iq.cu calls (a stub here: no gfx11 device code, so
# f16kq::covers() answers false and the int8 kernels run, as on every other card)
if(_strata_mmq_tq_overlay)
set(_strata_tq_hip ${_strata_tq_overlay_dir}/quantize.cu)
foreach(_t tq2_t tqk6 tqk7)
list(APPEND _strata_tq_hip ${_strata_tq_overlay_dir}/template-instances/mmq-instance-${_t}.cu)
endforeach()
list(APPEND _strata_cu ${_strata_tq_hip})
endif()
target_sources(strata_mmq PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/prefill/moe_f16_kq.cu)
endif()
set_source_files_properties(${_strata_cu} PROPERTIES LANGUAGE HIP)
endif()
+9 -3
View File
@@ -5,6 +5,8 @@
#
# docker build -t strata:src . # RTX 20/30/40/50 (compiles for each, ~25 min)
# docker build -t strata:src --build-arg CUDA_ARCHITECTURES=86 . # one card only (faster); 86 = RTX 30, 89 = RTX 40, 120 = RTX 50
# docker build -t strata:src-cuda12 --build-arg CUDA_IMAGE=nvidia/cuda:12.9.1-devel-ubuntu24.04 \
# --build-arg CUDA_ARCHITECTURES="60;61;70;75;80;86;89" --build-arg BUILD_VISION=0 . # GTX 10 / P100 / V100 (driver 575+)
# docker run --rm --gpus all -p 127.0.0.1:8080:8080 --ulimit memlock=-1 --shm-size 2g --stop-timeout 120 \
# -v strata-data:/data -e MODEL=IQ3_XXS -e API_KEY=change-me-please-123 strata:src
#
@@ -35,7 +37,9 @@ RUN /venv/bin/python -c "import setup; print(setup.get_llama_cpp())"
RUN set -e; \
real=""; for a in $(echo "$CUDA_ARCHITECTURES" | tr ';,' ' '); do real="$real;${a}-real"; done; real=${real#;}; \
last=$(echo "$CUDA_ARCHITECTURES" | tr ';,' ' ' | awk '{print $NF}'); \
sm60=OFF; for a in $(echo "$CUDA_ARCHITECTURES" | tr ';,' ' '); do if [ "$a" -lt 75 ]; then sm60=ON; fi; done; \
/venv/bin/cmake -G Ninja -S . -B build -DCMAKE_BUILD_TYPE=Release -DSTRATA_ENABLE_CUDA=ON -DSTRATA_BUILD_TESTS=OFF \
-DSTRATA_EXPERIMENTAL_SM60=$sm60 \
-DSTRATA_NATIVE_EXPERTS=ON -DSTRATA_PORTABLE=ON -DSTRATA_Q6K_EXPERTS=ON -DSTRATA_MMQ_KQUANTS=ON \
"-DCMAKE_CUDA_ARCHITECTURES=${real};${last}-virtual" -DSTRATA_GGML_DIR=/src/third_party/llama.cpp; \
/venv/bin/cmake --build build --target strata strata_kq -j "$(nproc)"; \
@@ -50,19 +54,21 @@ RUN set -e; \
[ -f build/libstrata_kq.so ] && cp build/libstrata_kq.so /stage/ || true; \
[ -f build-vision/bin/strata-vision ] && cp build-vision/bin/strata-vision /stage/ || true; \
ver="${ENGINE_VERSION:-$(sed -n 's/^[[:space:]]*project([^)]*VERSION[[:space:]]*\([0-9][0-9.]*\).*/\1/p' CMakeLists.txt | head -1)}"; \
echo "{\"version\": \"$ver\", \"source\": \"docker build\", \"archs\": \"$CUDA_ARCHITECTURES\", \"cuda\": \"13.0\", \"portable\": true}" > /stage/BUILD.json; \
cudafull=$(nvcc --version | sed -n 's/.*release \([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' | head -1); cudamajor=${cudafull%%.*}; \
if [ "$cudamajor" = 13 ]; then mindriver=580.65; else mindriver=575.51; fi; \
echo "{\"version\": \"$ver\", \"source\": \"docker build\", \"archs\": \"$CUDA_ARCHITECTURES\", \"cuda\": \"$cudafull\", \"portable\": true}" > /stage/BUILD.json; \
python3 packaging/collect_libs.py --binary build/strata --search /usr/local/cuda/lib64 --out /stage --also 'libcublasLt.so*'; \
archs=""; for a in $(echo "$CUDA_ARCHITECTURES" | tr ';,' ' '); do archs="$archs --arch sm_$a"; done; \
last=$(echo "$CUDA_ARCHITECTURES" | tr ';,' ' ' | awk '{print $NF}'); \
python3 packaging/package_engine.py --src /stage --backend cuda --version "$ver" --out /engine $archs --arch "compute_$last" \
--min-driver nvidia=580.65 --variant cuda13 --platform linux-x86_64 --build-id "docker-source-${ver}-cuda13.0" --include SYSTEM-LIBS.txt --include 'libstrata_kq*' \
--min-driver nvidia=$mindriver --variant cuda$cudamajor --platform linux-x86_64 --build-id "docker-source-${ver}-cuda${cudafull}" --include SYSTEM-LIBS.txt --include 'libstrata_kq*' \
--include strata-vision; \
for d in /engine/*/; do python3 packaging/package_engine.py --verify "$d"; python3 packaging/binary_deps.py "$d"; done
# ---- 2. the Terminal (the release archive, built here)
FROM ${RUNTIME_BASE} AS terminal
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends python3 ca-certificates && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends python3 ca-certificates git && rm -rf /var/lib/apt/lists/*
COPY . /src/
RUN python3 /src/packaging/package_terminal.py --out /out --cache /cache \
&& mkdir -p /opt/strata \
+22 -16
View File
@@ -33,7 +33,7 @@ Bilder und arbeitet mit deinen Apps und Coding-Agents zusammen. Deine Chats und
- **Kostenlos.** MIT-Lizenz, kein Konto, keine Cloud.
- **Privat.** Keine Telemetrie. Was Strata herunterlädt, wann es das Internet nutzt und wie es Downloads prüft, steht in [docs/SAFETY.md](docs/SAFETY.md).
Version 0.2.0-beta · Engine 0.1.45.3
Version 0.2.2 · Engine 0.1.47
## Was du brauchst
@@ -73,18 +73,20 @@ unsicher, welche du brauchst? Lies den Absatz unter der Tabelle.
| System | Deine Grafikkarte | Datei zum Herunterladen |
| --- | --- | --- |
| **Windows 10 / 11** | NVIDIA RTX, Treiber 580 oder neuer | [`Strata-0.2.0-beta-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-cuda13.exe) |
| | NVIDIA, ältere Karte oder Treiber älter als 580 | [`Strata-0.2.0-beta-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-cuda12.exe) |
| | AMD Radeon RX 9000 Serie oder Radeon AI PRO R9700 | [`Strata-0.2.0-beta-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-hip.exe) |
| **Linux** | NVIDIA RTX, Treiber 580 oder neuer | [`Strata-0.2.0-beta-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, ältere Karte oder Treiber älter als 580 | [`Strata-0.2.0-beta-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 Serie oder Radeon AI PRO R9700 | [`Strata-0.2.0-beta-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 Serie | [`Strata-0.2.0-beta-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-amd-rx7900.AppImage) |
| **Mac** (Apple Silicon, macOS 15 oder neuer) | eingebaut | [`Strata-0.2.0-beta-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-mac.dmg) |
| **Windows 10 / 11** | NVIDIA RTX, Treiber 580 oder neuer | [`Strata-0.2.2-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda13.exe) |
| | NVIDIA, ältere Karte oder Treiber älter als 580 | [`Strata-0.2.2-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda12.exe) |
| | AMD Radeon RX 9000 Serie oder Radeon AI PRO R9700 | [`Strata-0.2.2-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip.exe) |
| | AMD Radeon RX 7000 Serie oder Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, Treiber 580 oder neuer | [`Strata-0.2.2-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, ältere Karte oder Treiber älter als 580 | [`Strata-0.2.2-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 Serie oder Radeon AI PRO R9700 | [`Strata-0.2.2-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 Serie | [`Strata-0.2.2-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 oder neuer) | eingebaut | [`Strata-0.2.2-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-mac.dmg) |
**Wenn du unsicher bist:** Bei NVIDIA nimmst du `cuda13`, wenn dein Treiber Version 580 oder neuer ist, sonst `cuda12`.
`cuda12` ist für ältere Karten und ältere Treiber. Die Dateien `hip` und `amd` sind für AMD-Karten. Für andere AMD-Karten
(RX 6000 oder RX 7000 unter Windows) gibt es noch keine fertige App: siehe [AMD-Karten](docs/AMD_HIP.md).
(RX 6000) gibt es noch keine fertige App: siehe [AMD-Karten](docs/AMD_HIP.md).
Danach:
@@ -160,9 +162,9 @@ Größen, mehr oder weniger stark komprimiert. Kleinere Größen sind schneller.
| Größe | Benötigter Speicher (RAM + VRAM) | Download | Was es ist |
| --- | ---: | ---: | --- |
| **Gyro-S** | 27,6 GB | 58,5 GB | Für PCs, auf denen nichts anderes läuft. Die Qualität ist geringer als bei IQ2_XS. |
| **Gyro-M** | 35 GB | 92,0 GB | Ungefähr die Qualität von IQ3_XXS bei etwa 20 % weniger GPU-Speicher. Es schreibt Antworten auf einer 12-GB-Karte und auf einer R9700 17-21 % langsamer als Q2_0. |
| **Gyro-M** | 35 GB | 92,0 GB | Ungefähr die Qualität von IQ3_XXS bei etwa 20 % weniger GPU-Speicher. Es schreibt Antworten langsamer als Q2_0: 16 % auf einer RTX 3060 mit 12 GB, 21 % auf einer R9700. |
Gyro läuft auf NVIDIA- und AMD-Karten (bei AMD experimentell), nicht auf Intel Arc und nicht auf dem Mac. Details und Messwerte:
Gyro läuft auf NVIDIA- und AMD-Karten (bei AMD experimentell) auf Intel-Arc-Karten unter Linux (getestet auf einer Arc A750) und ab 0.2.2 auf Macs mit Apple Silicon (Gyro-S auf einem Mac mini mit 32 GB: 9 Tokens/s, die Experten werden von der SSD gelesen). Details und Messwerte:
[docs/GYRO.md](docs/GYRO.md). Starte es mit `strata launch --model gyro-s`.
### Die anderen Größen
@@ -170,7 +172,7 @@ Gyro läuft auf NVIDIA- und AMD-Karten (bei AMD experimentell), nicht auf Intel
| Dein Speicher (RAM + VRAM) | Nimm | Hinweise |
| --- | --- | --- |
| **32 GB** | **Gyro-S**, oder den **[Coder](docs/MODELS.md#coder)** für Code | Der Coder braucht 32 GB RAM und behält die Hälfte der Experten, deshalb ist er außerhalb von Code schwächer |
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 GB), oder **Gyro-M** (35 GB) | Swift denkt kürzer nach, bevor es antwortet. Gyro-M kommt der Qualität von IQ3_XXS nahe, schreibt Antworten aber 17-21 % langsamer als Q2_0 |
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 GB), oder **Gyro-M** (35 GB) | Swift denkt kürzer nach, bevor es antwortet. Gyro-M kommt der Qualität von IQ3_XXS nahe, schreibt Antworten aber 16-21 % langsamer als Q2_0 |
| **48 GB** | **IQ3_XXS** (47,0 GB) | Langsamer, besser |
| **64 GB** | **IQ3_S** (54,8 GB), das Original oder Swift 1.5 | Am langsamsten, am besten. Es erreicht in den veröffentlichten Tests das volle Modell |
| **96 GB oder mehr** | **UD-IQ4_XS** von Unsloth (etwa 72 GB RAM) bis **Q8_0** (etwa 136 GB), 4 bis 8 Bit | Näher am vollen Modell. Bei weniger RAM liest Strata einen Teil von der SSD, das ist viel langsamer |
@@ -230,11 +232,14 @@ Suchschlüssel hinzufügen. Die Suche ist die einzige Funktion, die etwas von de
Um zwei Modelle auszuführen, schalte **Mehrere Modelle ausführen** ein und gib jedem Modell eine eigene Karte desselben
Herstellers.
<p align="center"><img src="docs/media/runpagoda.png" width="900" alt="The Strata app's Monitor tab next to a coding agent"><br>
<sub>Der <b>Monitor</b> der Strata-App (links), während ein Coding-Agent den Pagodengarten aus dem Video schreibt (rechts)</sub></p>
<p align="center"><img src="docs/media/app-chat.png" width="900" alt="The Strata app: a chat where the agent built a small voxel pagoda garden, with the page open in the preview on the right"><br>
<sub>Ein Chat in der Strata-App: Der Agent hat einen kleinen Voxel-Pagodengarten als eine HTML-Datei geschrieben, und die Seite öffnet sich in der Vorschau neben dem Gespräch</sub></p>
### Im Terminal
<p align="center"><img src="docs/media/terminal-monitor.png" width="900" alt="The Strata Terminal monitor overview while the model writes a reply"><br>
<sub><code>strata monitor</code>, während das Modell antwortet: Geschwindigkeit, GPU, Speicher und die letzten Anfragen</sub></p>
```sh
strata launch # guided screens: model, context, pictures, access, then serve
strata launch --model q2_0 --yes --no-tui # no questions: serve, print the address, return
@@ -277,7 +282,8 @@ Du kannst Bilder pro Modell ausschalten oder den Leser mit `--vision off`, `--vi
Die Zahlen hinter der Regel: Auf einer Radeon AI PRO R9700 brauchte der Bildleser der Karte 57 ms für ein Bild mit 260 Tokens,
der des Prozessors 3,1 s. Auf Karten mit 16 GB (Tesla P100 und RTX A4000, Linux) kostete der Bildleser der Karte 3-5 % des
Texttempos, weil er etwa 1,4 GB belegt, die sonst das Modell nutzen würde. Der Mac hat noch keinen Bildleser.
Texttempos, weil er etwa 1,4 GB belegt, die sonst das Modell nutzen würde. Auf dem Mac liest die GPU, egal wie viel Speicher er hat (0,45 s für ein Bild mit 260 Tokens auf einem M4 mit 32 GB, etwa 1,2 GB des gemeinsamen
Speichers); der Prozessor braucht dort etwa 50 s pro Bild und liest deshalb nur mit `--vision cpu`.
### Gut zu wissen
+22 -16
View File
@@ -33,7 +33,7 @@ con tus aplicaciones y tus agentes de programación. Tus chats y tus archivos se
- **Gratis.** Licencia MIT, sin cuenta, sin nube.
- **Privado.** Sin telemetría. Lo que Strata descarga, cuándo usa internet y cómo comprueba las descargas está en [docs/SAFETY.md](docs/SAFETY.md).
Versión 0.2.0-beta · motor 0.1.45.3
Versión 0.2.2 · motor 0.1.47
## Lo que necesitas
@@ -73,18 +73,20 @@ sabes cuál elegir? Mira el párrafo que hay debajo de la tabla.
| Sistema | Tu tarjeta gráfica | Archivo que descargar |
| --- | --- | --- |
| **Windows 10 / 11** | NVIDIA RTX, controlador 580 o posterior | [`Strata-0.2.0-beta-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-cuda13.exe) |
| | NVIDIA, tarjeta más antigua o controlador anterior a 580 | [`Strata-0.2.0-beta-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-cuda12.exe) |
| | AMD Radeon serie RX 9000 o Radeon AI PRO R9700 | [`Strata-0.2.0-beta-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-hip.exe) |
| **Linux** | NVIDIA RTX, controlador 580 o posterior | [`Strata-0.2.0-beta-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, tarjeta más antigua o controlador anterior a 580 | [`Strata-0.2.0-beta-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon serie RX 9000 o Radeon AI PRO R9700 | [`Strata-0.2.0-beta-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-amd-rx9070.AppImage) |
| | AMD Radeon serie RX 7000 | [`Strata-0.2.0-beta-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-amd-rx7900.AppImage) |
| **Mac** (Apple Silicon, macOS 15 o posterior) | integrada | [`Strata-0.2.0-beta-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-mac.dmg) |
| **Windows 10 / 11** | NVIDIA RTX, controlador 580 o posterior | [`Strata-0.2.2-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda13.exe) |
| | NVIDIA, tarjeta más antigua o controlador anterior a 580 | [`Strata-0.2.2-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda12.exe) |
| | AMD Radeon serie RX 9000 o Radeon AI PRO R9700 | [`Strata-0.2.2-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip.exe) |
| | AMD Radeon serie RX 7000 o Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, controlador 580 o posterior | [`Strata-0.2.2-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, tarjeta más antigua o controlador anterior a 580 | [`Strata-0.2.2-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon serie RX 9000 o Radeon AI PRO R9700 | [`Strata-0.2.2-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx9070.AppImage) |
| | AMD Radeon serie RX 7000 | [`Strata-0.2.2-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 o posterior) | integrada | [`Strata-0.2.2-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-mac.dmg) |
**Si no estás seguro:** con NVIDIA, elige `cuda13` si la versión de tu controlador es la 580 o posterior, y `cuda12` en caso
contrario. `cuda12` es para tarjetas y controladores más antiguos. Los archivos `hip` y `amd` son para tarjetas AMD. Las
demás tarjetas AMD (RX 6000, o RX 7000 en Windows) todavía no tienen una aplicación ya preparada: mira
demás tarjetas AMD (RX 6000) todavía no tienen una aplicación ya preparada: mira
[Tarjetas AMD](docs/AMD_HIP.md).
Después:
@@ -160,9 +162,9 @@ comprimido. Los tamaños pequeños son más rápidos. Los grandes son algo más
| Tamaño | Memoria que necesita (RAM + VRAM) | Descarga | Qué es |
| --- | ---: | ---: | --- |
| **Gyro-S** | 27,6 GB | 58,5 GB | Para PC donde no cabe nada más. La calidad es menor que la de IQ2_XS. |
| **Gyro-M** | 35 GB | 92,0 GB | Calidad parecida a IQ3_XXS con un 20 % menos de memoria de GPU. Escribe las respuestas un 17-21 % más despacio que Q2_0 en una tarjeta de 12 GB y en una R9700. |
| **Gyro-M** | 35 GB | 92,0 GB | Calidad parecida a IQ3_XXS con un 20 % menos de memoria de GPU. Escribe las respuestas un 16 % más despacio que Q2_0 en una RTX 3060 de 12 GB y un 21 % más despacio en una R9700. |
Gyro funciona en tarjetas NVIDIA y AMD (en AMD es experimental), no en Intel Arc ni en el Mac. Detalles y mediciones:
Gyro funciona en tarjetas NVIDIA y AMD (en AMD es experimental) en tarjetas Intel Arc con Linux (probado en una Arc A750) y, desde 0.2.2, en Mac con Apple Silicon (Gyro-S en un Mac mini de 32 GB: 9 tokens/s, con los expertos leídos del SSD). Detalles y mediciones:
[docs/GYRO.md](docs/GYRO.md). Arráncalo con `strata launch --model gyro-s`.
### Los demás tamaños
@@ -170,7 +172,7 @@ Gyro funciona en tarjetas NVIDIA y AMD (en AMD es experimental), no en Intel Arc
| Tu memoria (RAM + VRAM) | Elige | Notas |
| --- | --- | --- |
| **32 GB** | **Gyro-S**, o el **[Coder](docs/MODELS.md#coder)** para código | El Coder necesita 32 GB de RAM y conserva la mitad de los expertos, así que es más flojo fuera del código |
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 GB), o **Gyro-M** (35 GB) | Swift piensa menos tiempo antes de responder. Gyro-M se acerca a la calidad de IQ3_XXS, pero escribe las respuestas un 17-21 % más despacio que Q2_0 |
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 GB), o **Gyro-M** (35 GB) | Swift piensa menos tiempo antes de responder. Gyro-M se acerca a la calidad de IQ3_XXS, pero escribe las respuestas un 16-21 % más despacio que Q2_0 |
| **48 GB** | **IQ3_XXS** (47,0 GB) | Más lento, mejor |
| **64 GB** | **IQ3_S** (54,8 GB), el original o Swift 1.5 | El más lento, el mejor. Iguala al modelo completo en las pruebas publicadas |
| **96 GB o más** | **UD-IQ4_XS** de Unsloth (unos 72 GB de RAM) hasta **Q8_0** (unos 136 GB), de 4 a 8 bits | Más cerca del modelo completo. Con menos RAM, Strata lee una parte desde el SSD, que es mucho más lento |
@@ -228,11 +230,14 @@ Herramientas**. La búsqueda es la única función que envía algo fuera de tu P
Para ejecutar dos modelos, activa **Ejecutar varios modelos** y dale a cada modelo su propia tarjeta de la misma marca.
<p align="center"><img src="docs/media/runpagoda.png" width="900" alt="The Strata app's Monitor tab next to a coding agent"><br>
<sub>El <b>Monitor</b> de la aplicación Strata (a la izquierda) mientras un agente de programación escribe el jardín de pagodas del vídeo (a la derecha)</sub></p>
<p align="center"><img src="docs/media/app-chat.png" width="900" alt="The Strata app: a chat where the agent built a small voxel pagoda garden, with the page open in the preview on the right"><br>
<sub>Un chat en la app de Strata: el agente escribió un pequeño jardín de pagoda de vóxeles en un solo archivo HTML, y la página se abre en la vista previa junto a la conversación</sub></p>
### En la Terminal
<p align="center"><img src="docs/media/terminal-monitor.png" width="900" alt="The Strata Terminal monitor overview while the model writes a reply"><br>
<sub><code>strata monitor</code> mientras el modelo responde: velocidad, GPU, memoria y las últimas solicitudes</sub></p>
```sh
strata launch # guided screens: model, context, pictures, access, then serve
strata launch --model q2_0 --yes --no-tui # no questions: serve, print the address, return
@@ -276,7 +281,8 @@ NVIDIA y AMD. Puedes desactivar las imágenes por modelo, o elegir el lector con
Las cifras detrás de la regla: en una Radeon AI PRO R9700, el lector de la tarjeta tardó 57 ms en una imagen de 260 tokens y
el del procesador 3,1 s. En tarjetas de 16 GB (Tesla P100 y RTX A4000, Linux), el lector de la tarjeta costó un 3-5 % de
velocidad en texto, porque ocupa unos 1,4 GB que de otro modo usaría el modelo. El Mac todavía no tiene lector de imágenes.
velocidad en texto, porque ocupa unos 1,4 GB que de otro modo usaría el modelo. En un Mac lee la GPU, tenga la memoria que tenga (0,45 s para una imagen de 260 tokens en un M4 de 32 GB, unos 1,2 GB de la memoria compartida);
el procesador tarda unos 50 s por imagen, así que solo lee con `--vision cpu`.
### Conviene saber
+22 -17
View File
@@ -33,7 +33,7 @@ fonctionne avec vos applications et vos agents de code. Vos conversations et vos
- **Gratuit.** Licence MIT, pas de compte, pas de cloud.
- **Privé.** Aucune télémétrie. Ce que Strata télécharge, quand il utilise internet et comment il vérifie les téléchargements est expliqué dans [docs/SAFETY.md](docs/SAFETY.md).
Version 0.2.0-beta · moteur 0.1.45.3
Version 0.2.2 · moteur 0.1.47
## Ce qu'il vous faut
@@ -73,18 +73,20 @@ Vous ne savez pas lequel prendre ? Lisez le paragraphe sous le tableau.
| Système | Votre carte graphique | Fichier à télécharger |
| --- | --- | --- |
| **Windows 10 / 11** | NVIDIA RTX, pilote 580 ou plus récent | [`Strata-0.2.0-beta-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-cuda13.exe) |
| | NVIDIA, carte plus ancienne ou pilote antérieur à 580 | [`Strata-0.2.0-beta-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-cuda12.exe) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.0-beta-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-hip.exe) |
| **Linux** | NVIDIA RTX, pilote 580 ou plus récent | [`Strata-0.2.0-beta-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, carte plus ancienne ou pilote antérieur à 580 | [`Strata-0.2.0-beta-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.0-beta-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-amd-rx9070.AppImage) |
| | AMD Radeon série RX 7000 | [`Strata-0.2.0-beta-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-amd-rx7900.AppImage) |
| **Mac** (Apple Silicon, macOS 15 ou plus récent) | intégrée | [`Strata-0.2.0-beta-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-mac.dmg) |
| **Windows 10 / 11** | NVIDIA RTX, pilote 580 ou plus récent | [`Strata-0.2.2-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda13.exe) |
| | NVIDIA, carte plus ancienne ou pilote antérieur à 580 | [`Strata-0.2.2-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda12.exe) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.2-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip.exe) |
| | AMD Radeon série RX 7000 ou Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, pilote 580 ou plus récent | [`Strata-0.2.2-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, carte plus ancienne ou pilote antérieur à 580 | [`Strata-0.2.2-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.2-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx9070.AppImage) |
| | AMD Radeon série RX 7000 | [`Strata-0.2.2-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 ou plus récent) | intégrée | [`Strata-0.2.2-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-mac.dmg) |
**Si vous hésitez :** pour NVIDIA, prenez `cuda13` si la version de votre pilote est 580 ou plus récente, sinon `cuda12`.
`cuda12` convient aux cartes plus anciennes et aux pilotes plus anciens. Les fichiers `hip` et `amd` sont pour les cartes
AMD. Les autres cartes AMD (RX 6000, ou RX 7000 sous Windows) n'ont pas encore d'application prête à l'emploi : voir
AMD. Les autres cartes AMD (RX 6000) n'ont pas encore d'application prête à l'emploi : voir
[Cartes AMD](docs/AMD_HIP.md).
Ensuite :
@@ -161,9 +163,9 @@ plus ou moins compressé. Les petites tailles sont plus rapides. Les grandes son
| Taille | Mémoire nécessaire (RAM + VRAM) | Téléchargement | Ce que c'est |
| --- | ---: | ---: | --- |
| **Gyro-S** | 27,6 Go | 58,5 Go | Pour les PC où rien d'autre ne tient. La qualité est inférieure à celle d'IQ2_XS. |
| **Gyro-M** | 35 Go | 92,0 Go | À peu près la qualité d'IQ3_XXS avec environ 20 % de mémoire GPU en moins. Il écrit les réponses 17 à 21 % plus lentement que Q2_0 sur une carte de 12 Go et sur une R9700. |
| **Gyro-M** | 35 Go | 92,0 Go | À peu près la qualité d'IQ3_XXS avec environ 20 % de mémoire GPU en moins. Il écrit les réponses 16 % plus lentement que Q2_0 sur une RTX 3060 de 12 Go et 21 % plus lentement sur une R9700. |
Gyro fonctionne sur les cartes NVIDIA et AMD (expérimental sur AMD), pas sur Intel Arc ni sur Mac. Détails et mesures :
Gyro fonctionne sur les cartes NVIDIA et AMD (expérimental sur AMD) sur les cartes Intel Arc sous Linux (testé sur une Arc A750) et, depuis la 0.2.2, sur les Mac Apple Silicon (Gyro-S sur un Mac mini de 32 Go : 9 tokens/s, experts lus sur le SSD). Détails et mesures :
[docs/GYRO.md](docs/GYRO.md). Lancez-le avec `strata launch --model gyro-s`.
### Les autres tailles
@@ -171,7 +173,7 @@ Gyro fonctionne sur les cartes NVIDIA et AMD (expérimental sur AMD), pas sur In
| Votre mémoire (RAM + VRAM) | Prenez | Remarques |
| --- | --- | --- |
| **32 Go** | **Gyro-S**, ou le **[Coder](docs/MODELS.md#coder)** pour le code | Le Coder demande 32 Go de RAM et garde la moitié des experts, il est donc moins bon hors du code |
| **40 Go** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 Go), ou **Gyro-M** (35 Go) | Swift réfléchit moins longtemps avant de répondre. Gyro-M approche la qualité d'IQ3_XXS, mais il écrit les réponses 17 à 21 % plus lentement que Q2_0 |
| **40 Go** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 Go), ou **Gyro-M** (35 Go) | Swift réfléchit moins longtemps avant de répondre. Gyro-M approche la qualité d'IQ3_XXS, mais il écrit les réponses 16 à 21 % plus lentement que Q2_0 |
| **48 Go** | **IQ3_XXS** (47,0 Go) | Plus lent, meilleur |
| **64 Go** | **IQ3_S** (54,8 Go), l'original ou Swift 1.5 | Le plus lent, le meilleur. Il égale le modèle complet dans les tests publiés |
| **96 Go ou plus** | **UD-IQ4_XS** d'Unsloth (environ 72 Go de RAM) jusqu'à **Q8_0** (environ 136 Go), 4 à 8 bits | Plus proche du modèle complet. Avec moins de RAM, Strata lit une partie depuis le SSD, ce qui est beaucoup plus lent |
@@ -230,11 +232,14 @@ Le chat peut chercher sur le web avec DuckDuckGo (ou Bing), sans compte. Vous po
Pour faire tourner deux modèles, activez **Run multiple models** (exécuter plusieurs modèles) et donnez à chacun sa propre carte de la même marque.
<p align="center"><img src="docs/media/runpagoda.png" width="900" alt="The Strata app's Monitor tab next to a coding agent"><br>
<sub>Le <b>Monitor</b> de l'application Strata (à gauche) pendant qu'un agent de code écrit le jardin de pagode de la vidéo (à droite)</sub></p>
<p align="center"><img src="docs/media/app-chat.png" width="900" alt="The Strata app: a chat where the agent built a small voxel pagoda garden, with the page open in the preview on the right"><br>
<sub>Une discussion dans l'app Strata : l'agent a écrit un petit jardin de pagode en voxels dans un seul fichier HTML, et la page s'ouvre dans l'aperçu à côté de la conversation</sub></p>
### Dans le Terminal
<p align="center"><img src="docs/media/terminal-monitor.png" width="900" alt="The Strata Terminal monitor overview while the model writes a reply"><br>
<sub><code>strata monitor</code> pendant que le modèle répond : vitesse, GPU, mémoire et les dernières requêtes</sub></p>
```sh
strata launch # guided screens: model, context, pictures, access, then serve
strata launch --model q2_0 --yes --no-tui # no questions: serve, print the address, return
@@ -278,8 +283,8 @@ règle est la même pour NVIDIA et AMD. Vous pouvez désactiver les images par m
Les chiffres derrière la règle : sur une Radeon AI PRO R9700, le lecteur de la carte a pris 57 ms pour une image de
260 tokens et celui du processeur 3,1 s. Sur des cartes de 16 Go (Tesla P100 et RTX A4000, Linux), le lecteur de la carte
a coûté 3 à 5 % de vitesse sur le texte, parce qu'il occupe environ 1,4 Go que le modèle utiliserait sinon. Le Mac n'a pas
encore de lecteur d'images.
a coûté 3 à 5 % de vitesse sur le texte, parce qu'il occupe environ 1,4 Go que le modèle utiliserait sinon. Sur un Mac, le lecteur tourne sur le GPU quelle que soit la mémoire (0,45 s pour une image de 260 tokens sur un M4 de 32 Go, environ 1,2 Go de la
mémoire partagée) ; le processeur met environ 50 s par image, il ne lit donc qu'avec `--vision cpu`.
### Bon à savoir
+21 -16
View File
@@ -33,7 +33,7 @@ Strata は **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next
- **無料です。** MIT ライセンス。アカウントもクラウドも不要です。
- **プライバシーを守ります。** テレメトリはありません。Strata が何をダウンロードするか、いつインターネットを使うか、ダウンロードをどう検証するかは [docs/SAFETY.md](docs/SAFETY.md) に書いてあります。
バージョン 0.2.0-beta · エンジン 0.1.45.3
バージョン 0.2.2 · エンジン 0.1.47
## 必要な環境
@@ -72,17 +72,19 @@ Strata は **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next
| OS | グラフィックスカード | ダウンロードするファイル |
| --- | --- | --- |
| **Windows 10 / 11** | NVIDIA RTX、ドライバー 580 以降 | [`Strata-0.2.0-beta-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-cuda13.exe) |
| | NVIDIA、古いカード、またはドライバーが 580 より古い場合 | [`Strata-0.2.0-beta-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-cuda12.exe) |
| | AMD Radeon RX 9000 シリーズまたは Radeon AI PRO R9700 | [`Strata-0.2.0-beta-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-hip.exe) |
| **Linux** | NVIDIA RTX、ドライバー 580 以降 | [`Strata-0.2.0-beta-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-nvidia-cuda13.AppImage) |
| | NVIDIA、古いカード、またはドライバーが 580 より古い場合 | [`Strata-0.2.0-beta-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 シリーズまたは Radeon AI PRO R9700 | [`Strata-0.2.0-beta-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 シリーズ | [`Strata-0.2.0-beta-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-amd-rx7900.AppImage) |
| **Mac**(Apple Silicon、macOS 15 以降) | 内蔵 | [`Strata-0.2.0-beta-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-mac.dmg) |
| **Windows 10 / 11** | NVIDIA RTX、ドライバー 580 以降 | [`Strata-0.2.2-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda13.exe) |
| | NVIDIA、古いカード、またはドライバーが 580 より古い場合 | [`Strata-0.2.2-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda12.exe) |
| | AMD Radeon RX 9000 シリーズまたは Radeon AI PRO R9700 | [`Strata-0.2.2-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip.exe) |
| | AMD Radeon RX 7000 シリーズまたは Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX、ドライバー 580 以降 | [`Strata-0.2.2-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda13.AppImage) |
| | NVIDIA、古いカード、またはドライバーが 580 より古い場合 | [`Strata-0.2.2-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 シリーズまたは Radeon AI PRO R9700 | [`Strata-0.2.2-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 シリーズ | [`Strata-0.2.2-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-strixhalo.AppImage) |
| **Mac**(Apple Silicon、macOS 15 以降) | 内蔵 | [`Strata-0.2.2-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-mac.dmg) |
**迷ったときは:** NVIDIA の場合、ドライバーがバージョン 580 以降なら `cuda13`、それより古ければ `cuda12` を選びます。`cuda12` は古いカードと古いドライバー向けです。
`hip` と `amd` のファイルは AMD のカード用です。それ以外の AMD カード(RX 6000、または Windows の RX 7000)には、まだ用意されたアプリがありません。
`hip` と `amd` のファイルは AMD のカード用です。それ以外の AMD カード(RX 6000)には、まだ用意されたアプリがありません。
[AMD のカード](docs/AMD_HIP.md)を見てください。
そのあとの手順:
@@ -154,9 +156,9 @@ AI ツールは [MCP サーバー](docs/MCP_SERVER.md)を通じて、Strata の
| サイズ | 必要なメモリ(RAM + VRAM) | ダウンロード | 内容 |
| --- | ---: | ---: | --- |
| **Gyro-S** | 27.6 GB | 58.5 GB | ほかのサイズが入らないPC向け。品質は IQ2_XS より下がります。 |
| **Gyro-M** | 35 GB | 92.0 GB | 品質は IQ3_XXS 程度で、GPUメモリの使用量は約20%少なくなります。12 GBのカードと R9700 では、回答の書き出しが Q2_0 より17〜21%遅くなります。 |
| **Gyro-M** | 35 GB | 92.0 GB | 品質は IQ3_XXS 程度で、GPUメモリの使用量は約20%少なくなります。回答の書き出しは Q2_0 より、12 GBの RTX 3060 で16%、R9700 で21%遅くなります。 |
Gyro は NVIDIA と AMD のカードで動きます(AMD は実験的)。Intel Arc と Mac では動きません。詳細と測定結果は
Gyro は NVIDIA と AMD のカード(AMD は実験的)、および Linux 上の Intel Arc カード(Arc A750 で確認)で動きます。0.2.2 からは Apple Silicon の Mac でも動きます(32 GB の Mac mini で Gyro-S は 9 トークン/秒、エキスパートは SSD から読み込み)。詳細と測定結果は
[docs/GYRO.md](docs/GYRO.md) にあります。`strata launch --model gyro-s` で起動します。
### ほかのサイズ
@@ -164,7 +166,7 @@ Gyro は NVIDIA と AMD のカードで動きます(AMD は実験的)。Inte
| メモリ(RAM + VRAM) | 選ぶモデル | 備考 |
| --- | --- | --- |
| **32 GB** | **Gyro-S**、またはコード向けの **[Coder](docs/MODELS.md#coder)** | Coder はRAM 32 GBが必要で、エキスパートを半分だけ保持するため、コード以外は弱くなります |
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS(39.2 GB)、または **Gyro-M**(35 GB) | Swift は答える前に考える時間が短くなります。Gyro-M は品質が IQ3_XXS に近いものの、回答の書き出しは Q2_0 より17〜21%遅くなります |
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS(39.2 GB)、または **Gyro-M**(35 GB) | Swift は答える前に考える時間が短くなります。Gyro-M は品質が IQ3_XXS に近いものの、回答の書き出しは Q2_0 より16〜21%遅くなります |
| **48 GB** | **IQ3_XXS**(47.0 GB) | 遅くなりますが、品質は上がります |
| **64 GB** | **IQ3_S**(54.8 GB)、オリジナルまたは Swift 1.5 | いちばん遅く、いちばん高品質です。公開されているテストでは完全版と同等です |
| **96 GB 以上** | Unsloth の **UD-IQ4_XS**(RAM 約72 GB)から **Q8_0**(約136 GB)まで、4〜8ビット | 完全版に近づきます。RAM が足りないと、Strata は一部を SSD から読むため、ずっと遅くなります |
@@ -219,11 +221,14 @@ Gyro は NVIDIA と AMD のカードで動きます(AMD は実験的)。Inte
2つのモデルを動かすには、**複数のモデルを実行**をオンにして、それぞれのモデルに同じメーカーのカードを1枚ずつ割り当てます。
<p align="center"><img src="docs/media/runpagoda.png" width="900" alt="The Strata app's Monitor tab next to a coding agent"><br>
<sub>Strata アプリの<b>モニター</b>(左)と、動画の塔の庭園をコーディングエージェントが書いているところ(右)</sub></p>
<p align="center"><img src="docs/media/app-chat.png" width="900" alt="The Strata app: a chat where the agent built a small voxel pagoda garden, with the page open in the preview on the right"><br>
<sub>Strata アプリでのチャット。エージェントが小さなボクセルの塔の庭を 1 つの HTML ファイルとして書き、そのページが会話の横のプレビューで開きます</sub></p>
### ターミナルで
<p align="center"><img src="docs/media/terminal-monitor.png" width="900" alt="The Strata Terminal monitor overview while the model writes a reply"><br>
<sub>モデルが返信を書いている間の <code>strata monitor</code>。速度、GPU、メモリ、最近のリクエストが表示されます</sub></p>
```sh
strata launch # ガイド付きの画面: モデル、コンテキスト、画像、アクセス、そして開始
strata launch --model q2_0 --yes --no-tui # 質問なし: 開始し、アドレスを表示して戻る
@@ -265,7 +270,7 @@ strata doctor # インストール済みのエ
このルールの根拠となる数値です。Radeon AI PRO R9700 では、カードのリーダーは260トークンの画像に57 ms、プロセッサのリーダーは3.1秒かかりました。
16 GBのカード(Tesla P100 と RTX A4000、Linux)では、カードのリーダーを使うとテキストの速度が3〜5%下がりました。
本来モデルが使う約1.4 GBをリーダーが占めるためです。Mac にはまだ画像リーダーがありません。
本来モデルが使う約1.4 GBをリーダーが占めるためです。Mac ではメモリ量にかかわらず GPU が読み取ります(32 GB の M4 で260トークンの画像に0.45秒、共有メモリを約1.2 GB使います)。プロセッサでは1枚あたり約50秒かかるため、`--vision cpu` を指定したときだけ使います。
### 知っておくと便利なこと
+27 -18
View File
@@ -33,7 +33,7 @@ agents. Your chats and files stay on your PC.
- **Free.** MIT license, no account, no cloud.
- **Private.** No telemetry. What Strata downloads, when it uses the internet and how it checks downloads is in [docs/SAFETY.md](docs/SAFETY.md).
Version 0.2.0-beta · engine 0.1.45.3
Version 0.2.2 · engine 0.1.47
## What you need
@@ -73,18 +73,21 @@ line under the table.
| System | Your graphics card | File to download |
| --- | --- | --- |
| **Windows 10 / 11** | NVIDIA RTX, driver 580 or newer | [`Strata-0.2.0-beta-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-cuda13.exe) |
| | NVIDIA, older card or driver older than 580 | [`Strata-0.2.0-beta-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-cuda12.exe) |
| | AMD Radeon RX 9000 series or Radeon AI PRO R9700 | [`Strata-0.2.0-beta-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-hip.exe) |
| **Linux** | NVIDIA RTX, driver 580 or newer | [`Strata-0.2.0-beta-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, older card or driver older than 580 | [`Strata-0.2.0-beta-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 series or Radeon AI PRO R9700 | [`Strata-0.2.0-beta-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 series | [`Strata-0.2.0-beta-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-amd-rx7900.AppImage) |
| **Mac** (Apple Silicon, macOS 15 or newer) | built in | [`Strata-0.2.0-beta-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-mac.dmg) |
| **Windows 10 / 11** | NVIDIA RTX, driver 580 or newer | [`Strata-0.2.2-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda13.exe) |
| | NVIDIA, older card or driver older than 580 | [`Strata-0.2.2-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda12.exe) |
| | AMD Radeon RX 9000 series or Radeon AI PRO R9700 | [`Strata-0.2.2-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip.exe) |
| | AMD Radeon RX 7000 series or Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, driver 580 or newer | [`Strata-0.2.2-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, older card or driver older than 580 | [`Strata-0.2.2-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 series or Radeon AI PRO R9700 | [`Strata-0.2.2-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 series | [`Strata-0.2.2-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 or newer) | built in | [`Strata-0.2.2-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-mac.dmg) |
**If you are unsure:** for NVIDIA, take `cuda13` if your driver is version 580 or newer, otherwise `cuda12`. `cuda12` is
for older cards and older drivers. The `hip` and `amd` files are for AMD cards. Other AMD cards (RX 6000, or RX 7000 on
Windows) have no ready-made app yet: see [AMD cards](docs/AMD_HIP.md).
for older cards and older drivers. The `hip` and `amd` files are for AMD cards. RX 6000 cards have no installer of
their own: install `hip-rdna3` (Windows) or `amd-rx7900` (Linux), and on a PC with an RX 6000 card it fetches the RX 6000 engine by itself
(also `strata update`). That engine is built but not yet tested on a real RX 6000 card: see [AMD cards](docs/AMD_HIP.md).
Then:
@@ -155,17 +158,19 @@ Smaller sizes are faster. Larger sizes are a bit smarter.
| Size | Memory it needs (RAM + VRAM) | Download | What it is |
| --- | ---: | ---: | --- |
| **Gyro-S** | 27.6 GB | 58.5 GB | For PCs that fit nothing else. Quality is lower than IQ2_XS. |
| **Gyro-M** | 35 GB | 92.0 GB | About IQ3_XXS quality with about 20% less GPU memory. It writes answers 17-21% slower than Q2_0 on a 12 GB card and on an R9700. |
| **Gyro-M** | 35 GB | 92.0 GB | About IQ3_XXS quality with about 20% less GPU memory. It writes answers 16% (12 GB RTX 3060) to 21% (R9700) slower than Q2_0. |
Gyro runs on NVIDIA and AMD cards (AMD is experimental), not on Intel Arc or the Mac. Details and measurements:
Gyro runs on NVIDIA and AMD cards (AMD is experimental) on Intel Arc cards on Linux (tested on an Arc A750) and, from 0.2.2, on Apple Silicon Macs (Gyro-S on a 32 GB Mac mini: 9 tok/s, streaming from the SSD; [docs/MAC.md](docs/MAC.md#gyro-s-and-gyro-m-agentionais-trellis-experts)). Details and measurements:
[docs/GYRO.md](docs/GYRO.md). Start it with `strata launch --model gyro-s`.
### The other sizes
The tiers count your RAM plus your VRAM, less 5 GB that Windows and the system keep for themselves. Every other size stays a choice.
| Your memory (RAM + VRAM) | Take | Notes |
| --- | --- | --- |
| **32 GB** | **Gyro-S**, or the **[Coder](docs/MODELS.md#coder)** for code | The Coder needs 32 GB of RAM and keeps half of the experts, so it is weaker outside code |
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39.2 GB), or **Gyro-M** (35 GB) | Swift thinks for a shorter time before it answers. Gyro-M is close to IQ3_XXS in quality, but it writes answers 17-21% slower than Q2_0 |
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39.2 GB), or **Gyro-M** (35 GB) | Swift thinks for a shorter time before it answers. Gyro-M is close to IQ3_XXS in quality, but it writes answers 16-21% slower than Q2_0 |
| **48 GB** | **IQ3_XXS** (47.0 GB) | Slower, better |
| **64 GB** | **IQ3_S** (54.8 GB), the original or Swift 1.5 | The slowest, the best: it matches the full model on the published tests |
| **96 GB or more** | Unsloth's **UD-IQ4_XS** (about 72 GB of RAM) to **Q8_0** (about 136 GB), 4 to 8 bit | Closer to the full model. With less RAM, Strata reads part of them from the SSD, which is much slower |
@@ -223,11 +228,14 @@ Search is the one feature that sends something off your PC, the search words.
To run two models, turn on **Run multiple models** and give each model its own card of the same make.
<p align="center"><img src="docs/media/runpagoda.png" width="900" alt="The Strata app's Monitor tab next to a coding agent"><br>
<sub>The Strata app's <b>Monitor</b> (left) while a coding agent writes the pagoda garden from the video (right)</sub></p>
<p align="center"><img src="docs/media/app-chat.png" width="900" alt="The Strata app: a chat where the agent built a small voxel pagoda garden, with the page open in the preview on the right"><br>
<sub>A chat in the Strata app: the agent wrote a small voxel pagoda garden as one HTML file, and the page opens in the preview next to the conversation</sub></p>
### In the Terminal
<p align="center"><img src="docs/media/terminal-monitor.png" width="900" alt="The Strata Terminal monitor overview while the model writes a reply"><br>
<sub><code>strata monitor</code> while the model writes a reply: speed, GPU, memory and the recent requests</sub></p>
```sh
strata launch # guided screens: model, context, pictures, access, then serve
strata launch --model q2_0 --yes --no-tui # no questions: serve, print the address, return
@@ -241,7 +249,7 @@ strata doctor # check the installed engine file
`strata --help` lists everything. Starting and serving never download anything. Only the install, `strata launch` (when
you choose a model) and `strata update` do. To reach Strata from your phone or another PC, use
`strata launch --access lan`. It makes an API key and will not start on a network without one. No browser opens unless
`strata launch --access lan`. It makes an API key (recommended; add `--no-api-key` to go without, with a warning). No browser opens unless
you add `--open`. Guide: [docs/TERMINAL_LAUNCH.md](docs/TERMINAL_LAUNCH.md). Whole client: [docs/TERMINAL.md](docs/TERMINAL.md).
### With AI agents
@@ -270,7 +278,8 @@ off per model, or choose the reader with `--vision off`, `--vision gpu` or `--vi
The numbers behind the rule: on a Radeon AI PRO R9700 the card's reader took 57 ms for a 260-token picture and the
processor's took 3.1 s. On 16 GB cards (Tesla P100 and RTX A4000, Linux) the card's reader cost 3-5% of text speed,
because it holds about 1.4 GB that the model would use otherwise. The Mac has no picture reader yet.
because it holds about 1.4 GB that the model would use otherwise. On a Mac the reader runs on the Mac's GPU whatever its memory (0.45 s for a 260-token picture on an M4 with 32 GB, about 1.2 GB of the shared
memory); the processor reader needs about 50 s per picture there, so it runs only when you ask for it with `--vision cpu`.
### Good to know
+22 -17
View File
@@ -33,7 +33,7 @@ agentes de programação. Suas conversas e arquivos ficam no seu PC.
- **Gratuito.** Licença MIT, sem conta, sem nuvem.
- **Privado.** Sem telemetria. O que o Strata baixa, quando usa a internet e como confere os downloads está em [docs/SAFETY.md](docs/SAFETY.md).
Versão 0.2.0-beta · mecanismo 0.1.45.3
Versão 0.2.2 · mecanismo 0.1.47
## O que você precisa
@@ -73,18 +73,19 @@ logo abaixo da tabela.
| Sistema | Sua placa de vídeo | Arquivo para baixar |
| --- | --- | --- |
| **Windows 10 / 11** | NVIDIA RTX, driver 580 ou mais novo | [`Strata-0.2.0-beta-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-cuda13.exe) |
| | NVIDIA, placa mais antiga ou driver anterior ao 580 | [`Strata-0.2.0-beta-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-cuda12.exe) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.0-beta-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-hip.exe) |
| **Linux** | NVIDIA RTX, driver 580 ou mais novo | [`Strata-0.2.0-beta-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, placa mais antiga ou driver anterior ao 580 | [`Strata-0.2.0-beta-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.0-beta-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-amd-rx9070.AppImage) |
| | AMD Radeon série RX 7000 | [`Strata-0.2.0-beta-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-amd-rx7900.AppImage) |
| **Mac** (Apple Silicon, macOS 15 ou mais novo) | integrada | [`Strata-0.2.0-beta-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-mac.dmg) |
| **Windows 10 / 11** | NVIDIA RTX, driver 580 ou mais novo | [`Strata-0.2.2-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda13.exe) |
| | NVIDIA, placa mais antiga ou driver anterior ao 580 | [`Strata-0.2.2-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda12.exe) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.2-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip.exe) |
| | AMD Radeon série RX 7000 ou Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, driver 580 ou mais novo | [`Strata-0.2.2-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, placa mais antiga ou driver anterior ao 580 | [`Strata-0.2.2-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.2-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx9070.AppImage) |
| | AMD Radeon série RX 7000 | [`Strata-0.2.2-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 ou mais novo) | integrada | [`Strata-0.2.2-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-mac.dmg) |
**Se você estiver em dúvida:** para NVIDIA, use `cuda13` se o seu driver for a versão 580 ou mais nova, e `cuda12` nos outros casos. O `cuda12`
serve para placas e drivers mais antigos. Os arquivos `hip` e `amd` são para placas AMD. Outras placas AMD (RX 6000, ou RX 7000 no
Windows) ainda não têm app pronto: veja [Placas AMD](docs/AMD_HIP.md).
serve para placas e drivers mais antigos. Os arquivos `hip` e `amd` são para placas AMD. Outras placas AMD (RX 6000) ainda não têm app pronto: veja [Placas AMD](docs/AMD_HIP.md).
Depois:
@@ -155,9 +156,9 @@ O [Gyro](https://huggingface.co/agentionai/Qwen3.8-Flash-Next-Gyro-GGUF) (da age
| Tamanho | Memória necessária (RAM + VRAM) | Download | O que é |
| --- | ---: | ---: | --- |
| **Gyro-S** | 27,6 GB | 58,5 GB | Para PCs onde nenhum outro tamanho cabe. A qualidade é menor que a do IQ2_XS. |
| **Gyro-M** | 35 GB | 92,0 GB | Qualidade próxima à do IQ3_XXS, com cerca de 20% menos memória da GPU. Ele escreve as respostas 17 a 21% mais devagar que o Q2_0 numa placa de 12 GB e numa R9700. |
| **Gyro-M** | 35 GB | 92,0 GB | Qualidade próxima à do IQ3_XXS, com cerca de 20% menos memória da GPU. Ele escreve as respostas 16% mais devagar que o Q2_0 numa RTX 3060 de 12 GB e 21% mais devagar numa R9700. |
O Gyro roda em placas NVIDIA e AMD (AMD é experimental), e não roda em Intel Arc nem no Mac. Detalhes e medições:
O Gyro roda em placas NVIDIA e AMD (AMD é experimental) em placas Intel Arc no Linux (testado numa Arc A750) e, desde a 0.2.2, em Macs com Apple Silicon (Gyro-S num Mac mini de 32 GB: 9 tokens/s, com os especialistas lidos do SSD). Detalhes e medições:
[docs/GYRO.md](docs/GYRO.md). Inicie com `strata launch --model gyro-s`.
### Os outros tamanhos
@@ -165,7 +166,7 @@ O Gyro roda em placas NVIDIA e AMD (AMD é experimental), e não roda em Intel A
| Sua memória (RAM + VRAM) | Escolha | Observações |
| --- | --- | --- |
| **32 GB** | **Gyro-S**, ou o **[Coder](docs/MODELS.md#coder)** para código | O Coder precisa de 32 GB de RAM e mantém metade dos experts, então é mais fraco fora de código |
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 GB), ou **Gyro-M** (35 GB) | O Swift pensa por menos tempo antes de responder. O Gyro-M chega perto do IQ3_XXS em qualidade, mas escreve as respostas 17 a 21% mais devagar que o Q2_0 |
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 GB), ou **Gyro-M** (35 GB) | O Swift pensa por menos tempo antes de responder. O Gyro-M chega perto do IQ3_XXS em qualidade, mas escreve as respostas 16 a 21% mais devagar que o Q2_0 |
| **48 GB** | **IQ3_XXS** (47,0 GB) | Mais lento, melhor |
| **64 GB** | **IQ3_S** (54,8 GB), o original ou o Swift 1.5 | O mais lento, o melhor: empata com o modelo completo nos testes publicados |
| **96 GB ou mais** | **UD-IQ4_XS** da Unsloth (cerca de 72 GB de RAM) até **Q8_0** (cerca de 136 GB), de 4 a 8 bits | Mais perto do modelo completo. Com menos RAM, o Strata lê parte deles do SSD, o que é bem mais lento |
@@ -222,11 +223,14 @@ A busca é o único recurso que envia algo para fora do seu PC: os termos da pes
Para rodar dois modelos, ative **Executar vários modelos** e dê a cada modelo a sua própria placa da mesma marca.
<p align="center"><img src="docs/media/runpagoda.png" width="900" alt="The Strata app's Monitor tab next to a coding agent"><br>
<sub>O <b>Monitor</b> do app Strata (à esquerda) enquanto um agente de programação escreve o jardim de pagodes do vídeo (à direita)</sub></p>
<p align="center"><img src="docs/media/app-chat.png" width="900" alt="The Strata app: a chat where the agent built a small voxel pagoda garden, with the page open in the preview on the right"><br>
<sub>Um chat no app Strata: o agente escreveu um pequeno jardim de pagode em voxels em um único arquivo HTML, e a página abre na pré-visualização ao lado da conversa</sub></p>
### No Terminal
<p align="center"><img src="docs/media/terminal-monitor.png" width="900" alt="The Strata Terminal monitor overview while the model writes a reply"><br>
<sub><code>strata monitor</code> enquanto o modelo responde: velocidade, GPU, memória e as solicitações recentes</sub></p>
```sh
strata launch # telas guiadas: modelo, contexto, imagens, acesso e depois servir
strata launch --model q2_0 --yes --no-tui # sem perguntas: serve, mostra o endereço e volta
@@ -269,7 +273,8 @@ por modelo, ou escolha o leitor com `--vision off`, `--vision gpu` ou `--vision
Os números por trás da regra: numa Radeon AI PRO R9700, o leitor da placa levou 57 ms para uma imagem de 260 tokens e o do
processador levou 3,1 s. Em placas de 16 GB (Tesla P100 e RTX A4000, Linux), o leitor da placa custou de 3 a 5% da velocidade do texto,
porque ocupa cerca de 1,4 GB que o modelo usaria de outra forma. O Mac ainda não tem leitor de imagens.
porque ocupa cerca de 1,4 GB que o modelo usaria de outra forma. No Mac, a GPU faz a leitura, seja qual for a memória (0,45 s para uma imagem de 260 tokens em um M4 de 32 GB, cerca de 1,2 GB da memória compartilhada);
o processador leva cerca de 50 s por imagem, então só lê com `--vision cpu`.
### Bom saber
+21 -16
View File
@@ -33,7 +33,7 @@ Strata 运行 **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-N
- **免费。** MIT 许可证,不用注册账号,不走云端。
- **隐私。** 没有遥测。Strata 下载什么、什么时候联网、如何校验下载内容,都写在 [docs/SAFETY.md](docs/SAFETY.md)。
版本 0.2.0-beta · 引擎 0.1.45.3
版本 0.2.2 · 引擎 0.1.47
## 硬件要求
@@ -72,17 +72,19 @@ Strata 运行 **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-N
| 系统 | 你的显卡 | 要下载的文件 |
| --- | --- | --- |
| **Windows 10 / 11** | NVIDIA RTX,驱动 580 或更新 | [`Strata-0.2.0-beta-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-cuda13.exe) |
| | NVIDIA,较旧的显卡,或驱动低于 580 | [`Strata-0.2.0-beta-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-cuda12.exe) |
| | AMD Radeon RX 9000 系列或 Radeon AI PRO R9700 | [`Strata-0.2.0-beta-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-hip.exe) |
| **Linux** | NVIDIA RTX,驱动 580 或更新 | [`Strata-0.2.0-beta-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-nvidia-cuda13.AppImage) |
| | NVIDIA,较旧的显卡,或驱动低于 580 | [`Strata-0.2.0-beta-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 系列或 Radeon AI PRO R9700 | [`Strata-0.2.0-beta-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 系列 | [`Strata-0.2.0-beta-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-amd-rx7900.AppImage) |
| **Mac**(Apple 芯片,macOS 15 及以上) | 内置 | [`Strata-0.2.0-beta-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-mac.dmg) |
| **Windows 10 / 11** | NVIDIA RTX,驱动 580 或更新 | [`Strata-0.2.2-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda13.exe) |
| | NVIDIA,较旧的显卡,或驱动低于 580 | [`Strata-0.2.2-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda12.exe) |
| | AMD Radeon RX 9000 系列或 Radeon AI PRO R9700 | [`Strata-0.2.2-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip.exe) |
| | AMD Radeon RX 7000 系列或 Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX,驱动 580 或更新 | [`Strata-0.2.2-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda13.AppImage) |
| | NVIDIA,较旧的显卡,或驱动低于 580 | [`Strata-0.2.2-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 系列或 Radeon AI PRO R9700 | [`Strata-0.2.2-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 系列 | [`Strata-0.2.2-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-strixhalo.AppImage) |
| **Mac**(Apple 芯片,macOS 15 及以上) | 内置 | [`Strata-0.2.2-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-mac.dmg) |
**不确定时:** NVIDIA 显卡的驱动版本在 580 及以上就选 `cuda13`,否则选 `cuda12`。`cuda12` 适用于较旧的显卡和驱动。
`hip` 和 `amd` 的文件是给 AMD 显卡用的。其他 AMD 显卡(RX 6000,或 Windows 上的 RX 7000)目前还没有现成的应用,
`hip` 和 `amd` 的文件是给 AMD 显卡用的。其他 AMD 显卡(RX 6000)目前还没有现成的应用,
请看 [AMD 显卡](docs/AMD_HIP.md)。
然后:
@@ -153,9 +155,9 @@ Set up Strata on this PC for me: https://github.com/Niko1221/Strata - follow doc
| 尺寸 | 所需内存(RAM + VRAM) | 下载大小 | 说明 |
| --- | ---: | ---: | --- |
| **Gyro-S** | 27.6 GB | 58.5 GB | 给其他尺寸都放不下的电脑。质量低于 IQ2_XS。 |
| **Gyro-M** | 35 GB | 92.0 GB | 质量约相当于 IQ3_XXS,显存占用少约 20%。在 12 GB 显卡和 R9700 上,输出回答比 Q2_0 慢 17-21%。 |
| **Gyro-M** | 35 GB | 92.0 GB | 质量约相当于 IQ3_XXS,显存占用少约 20%。输出回答比 Q2_0 慢,12 GB 的 RTX 3060 上慢 16%,R9700 上慢 21%。 |
Gyro 可在 NVIDIA 和 AMD 显卡上运行(AMD 为实验性支持),不支持 Intel Arc 和 Mac。详情和测试数据:
Gyro 可在 NVIDIA 和 AMD 显卡(AMD 为实验性支持)以及 Linux 上的 Intel Arc 显卡(已在 Arc A750 上测试)上运行,自 0.2.2 起也可在 Apple Silicon 的 Mac 上运行(32 GB 的 Mac mini 上 Gyro-S 为 9 token/秒,专家权重从 SSD 读取)。详情和测试数据:
[docs/GYRO.md](docs/GYRO.md)。用 `strata launch --model gyro-s` 启动。
### 其他尺寸
@@ -163,7 +165,7 @@ Gyro 可在 NVIDIA 和 AMD 显卡上运行(AMD 为实验性支持),不支
| 你的内存(RAM + VRAM) | 选择 | 备注 |
| --- | --- | --- |
| **32 GB** | **Gyro-S**,写代码可选 **[Coder](docs/MODELS.md#coder)** | Coder 需要 32 GB 内存,只保留一半的专家,所以代码以外的能力较弱 |
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS(39.2 GB),或 **Gyro-M**(35 GB) | Swift 回答前思考的时间更短。Gyro-M 的质量接近 IQ3_XXS,但输出回答比 Q2_0 慢 17-21% |
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS(39.2 GB),或 **Gyro-M**(35 GB) | Swift 回答前思考的时间更短。Gyro-M 的质量接近 IQ3_XXS,但输出回答比 Q2_0 慢 16-21% |
| **48 GB** | **IQ3_XXS**(47.0 GB) | 更慢,更好 |
| **64 GB** | **IQ3_S**(54.8 GB),原版或 Swift 1.5 | 最慢,最好:在公开测试中与完整模型持平 |
| **96 GB 及以上** | Unsloth 的 **UD-IQ4_XS**(约 72 GB 内存)到 **Q8_0**(约 136 GB),4 到 8 位 | 更接近完整模型。内存不足时,Strata 会从固态硬盘读取一部分,速度会慢很多 |
@@ -218,11 +220,14 @@ Gyro 可在 NVIDIA 和 AMD 显卡上运行(AMD 为实验性支持),不支
要同时运行两个模型,请打开**运行多个模型**,并为每个模型分配同一品牌的单独一张显卡。
<p align="center"><img src="docs/media/runpagoda.png" width="900" alt="The Strata app's Monitor tab next to a coding agent"><br>
<sub>Strata 应用的<b>监控</b>(左),右边是编程智能体正在写视频里的宝塔花园</sub></p>
<p align="center"><img src="docs/media/app-chat.png" width="900" alt="The Strata app: a chat where the agent built a small voxel pagoda garden, with the page open in the preview on the right"><br>
<sub>Strata 应用中的一次对话:智能体用一个 HTML 文件写出了一个小型体素宝塔花园,页面在对话旁的预览中打开</sub></p>
### 在终端里
<p align="center"><img src="docs/media/terminal-monitor.png" width="900" alt="The Strata Terminal monitor overview while the model writes a reply"><br>
<sub>模型回复时的 <code>strata monitor</code>:速度、GPU、内存和最近的请求</sub></p>
```sh
strata launch # 引导界面:模型、上下文、图片、访问方式,然后开始服务
strata launch --model q2_0 --yes --no-tui # 不提问:开始服务,打印地址,然后返回
@@ -263,7 +268,7 @@ strata doctor # 检查已安装的引擎文件
这条规则背后的数据:在 Radeon AI PRO R9700 上,显卡读一张 260 token 的图片用了 57 ms,处理器用了 3.1 秒。在 16 GB 的显卡
(Tesla P100 和 RTX A4000,Linux)上,用显卡读图会让文字速度下降 3-5%,因为它占用约 1.4 GB 显存,否则这部分会被模型使用。
Mac 目前还没有读图功能。
在 Mac 上,无论内存多大,都由 GPU 读图(32 GB 的 M4 上读一张 260 token 的图片用 0.45 秒,占用约 1.2 GB 共享内存);处理器读一张约需 50 秒,所以只有指定 `--vision cpu` 时才用。
### 使用须知
+2
View File
@@ -2,6 +2,8 @@
rem Strata for Windows: the first run installs everything and starts the model; later runs just start it.
rem Needs only an NVIDIA or AMD graphics driver. Python is installed for your user account if it is missing (no admin needed).
setlocal
rem Python reads and writes text as UTF-8, not in the Windows code page (cp1251, cp866, ...): a folder or user name with Cyrillic or other letters stays intact (#3202).
set "PYTHONUTF8=1"
title Strata
cd /d "%~dp0"
rem A bundled Python runtime (the python\ folder of the terminal archive) is used when it is there: nothing to find,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "strata-desktop",
"productName": "Strata",
"version": "0.2.0-beta",
"version": "0.2.2",
"private": true,
"description": "Strata Desktop - local AI, tuned to one model.",
"scripts": {
+1 -1
View File
@@ -3524,7 +3524,7 @@ checksum = "ff7589a1859b09232522bb9edf076ca7fcf14ded96685d3fa629db73023ffbb7"
[[package]]
name = "strata-desktop"
version = "0.2.0-beta"
version = "0.2.2"
dependencies = [
"gtk",
"serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "strata-desktop"
version = "0.2.0-beta"
version = "0.2.2"
description = "Strata Desktop — local AI, tuned to one model."
edition = "2021"
rust-version = "1.77"
+24 -4
View File
@@ -168,7 +168,8 @@ VIAddVersionKey "ProductVersion" "${VERSION}"
!define MUI_LANGDLL_REGISTRY_KEY "${MANUPRODUCTKEY}"
!define MUI_LANGDLL_REGISTRY_VALUENAME "Installer Language"
; Strata's look and words: the app's light surface and ink, short copy, no directory page, straight on to the progress.
; Strata's look and words: the app's light surface and ink, short copy. The install folder page is shown on a first or manual install (#3186 #3190: C: can be short on space) and
; skipped in an update (passive), which goes on into the folder the earlier install wrote to the registry.
!define MUI_BGCOLOR "F9FAFB"
!define MUI_TEXTCOLOR "171B1E"
!define MUI_INSTFILESPAGE_PROGRESSBAR "smooth"
@@ -421,8 +422,19 @@ Function PageLeaveReinstall
reinst_done:
FunctionEnd
; 5. Choose install directory page
!define MUI_PAGE_CUSTOMFUNCTION_PRE Skip
; 5. Choose install directory page. The app is installed for this user only (no administrator), so the folder must be one the
; user can write to: another drive (D:\Strata) or a folder in the profile, not Program Files. An update (passive: the app's own
; updater) and a silent install skip it and keep the earlier folder (RestorePreviousInstallLocation).
!ifdef MUI_PAGE_HEADER_TEXT
!undef MUI_PAGE_HEADER_TEXT
!endif
!ifdef MUI_PAGE_HEADER_SUBTEXT
!undef MUI_PAGE_HEADER_SUBTEXT
!endif
!define MUI_PAGE_HEADER_TEXT "Choose where to install Strata"
!define MUI_PAGE_HEADER_SUBTEXT "Pick another drive if C: is short on space."
!define MUI_DIRECTORYPAGE_TEXT_TOP "Strata is installed into this folder, for your user only (no administrator needed). Models and chats are stored separately, in the data folder you choose inside Strata."
!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive
!insertmacro MUI_PAGE_DIRECTORY
; 6. Start menu shortcut page
@@ -435,7 +447,15 @@ Var AppStartMenuFolder
!endif
!insertmacro MUI_PAGE_STARTMENU Application $AppStartMenuFolder
; 7. Installation page
; 7. Installation page (a page macro uses up the header texts, so they are set again here)
!ifdef MUI_PAGE_HEADER_TEXT
!undef MUI_PAGE_HEADER_TEXT
!endif
!ifdef MUI_PAGE_HEADER_SUBTEXT
!undef MUI_PAGE_HEADER_SUBTEXT
!endif
!define MUI_PAGE_HEADER_TEXT "Installing Strata"
!define MUI_PAGE_HEADER_SUBTEXT "Copying the app. You can keep working meanwhile."
!insertmacro MUI_PAGE_INSTFILES
; 8. Finish page
+230 -4
View File
@@ -8,11 +8,21 @@
//!
//! `strata-desktop --save-logs` puts the newest crash log, the tails of the newest shell logs and the engine server logs into one text file
//! in the Downloads folder. The same pieces go into Settings > Engine > Diagnostics > Save logs (the host reads this folder).
//!
//! Windows and macOS (one process owns the host and the window) use the same folder and file names for what happens while the app starts:
//! `init_startup` opens the run log first thing in `main`, before anything that can fail, and installs a panic hook; a start that fails
//! (the host does not come up, the window cannot be made, any panic) ends in `startup_failed`: the reason goes into the run log and a
//! `desktop-crash-` file, and the person sees a plain message box instead of a process that vanished with no trace. On Windows the folder
//! is `%LOCALAPPDATA%\Strata\logs`, next to the components folder.
// Linux uses the shell log, the crash log and --save-logs; Windows and macOS use the start-up half. Each side leaves the other unused.
#![allow(dead_code)]
use std::collections::VecDeque;
use std::io::{BufRead, BufReader, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const KEEP: usize = 10;
@@ -23,6 +33,23 @@ pub fn data_dir() -> PathBuf {
if let Some(dir) = std::env::var_os("STRATA_DESKTOP_DATA_DIR") {
return PathBuf::from(dir);
}
platform_data_dir()
}
/// Windows: `%LOCALAPPDATA%\Strata`, the folder the host and Control already use for the components and the install.
#[cfg(windows)]
fn platform_data_dir() -> PathBuf {
std::env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.filter(|p| p.is_absolute())
.or_else(|| std::env::var_os("USERPROFILE").map(|h| PathBuf::from(h).join("AppData").join("Local")))
.unwrap_or_else(std::env::temp_dir)
.join("Strata")
}
/// Linux and macOS: `$XDG_DATA_HOME/strata`, usually `~/.local/share/strata`.
#[cfg(not(windows))]
fn platform_data_dir() -> PathBuf {
let base = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.filter(|p| p.is_absolute())
@@ -105,8 +132,11 @@ impl Log {
log
}
pub fn line(&self, who: &str, text: &str) {
if let Some(f) = self.file.lock().unwrap().as_mut() {
let _ = writeln!(f, "[{who}] {text}");
// never panics: this runs inside the panic hook too
if let Ok(mut guard) = self.file.lock() {
if let Some(f) = guard.as_mut() {
let _ = writeln!(f, "[{who}] {text}");
}
}
}
}
@@ -311,8 +341,180 @@ pub fn save_logs(out_dir: &Path) -> std::io::Result<PathBuf> {
Ok(path)
}
// ---------------------------------------------------------------------------------------------------------------------------------
// Start-up failures (Windows and macOS)
// ---------------------------------------------------------------------------------------------------------------------------------
/// The run log and whether a failure may show a message box. Set once, first thing in `main`.
struct Run {
log: Log,
dialog: bool,
}
static RUN: OnceLock<Run> = OnceLock::new();
/// One message box per run: a failure inside the panic hook and the abort that follows must not show two.
static REPORTED: AtomicBool = AtomicBool::new(false);
/// This run's log, once `init_startup` has opened it.
pub fn run_log() -> Option<Log> {
RUN.get().map(|r| r.log.clone())
}
/// Open the run log and install the panic hook. Call it first in `main`: nothing that can fail runs before it. `dialog` is false for
/// the self-test (no person to read a message box); `STRATA_NO_DIALOG` does the same for scripted runs.
pub fn init_startup(dialog: bool) {
let dialog = dialog && std::env::var_os("STRATA_NO_DIALOG").is_none();
let log = Log::open();
log.line("shell", &system_line());
if RUN.set(Run { log, dialog }).is_ok() {
install_panic_hook();
}
}
/// "Windows 10.0.26300 (64-bit)" or the name of the system; one line for the log and the crash file.
pub fn system_line() -> String {
format!("System: {} {}", std::env::consts::OS, system_version())
}
#[cfg(windows)]
fn system_version() -> String {
#[repr(C)]
struct OsVersionInfo {
size: u32,
major: u32,
minor: u32,
build: u32,
platform: u32,
csd: [u16; 128],
}
#[link(name = "ntdll")]
extern "system" {
fn RtlGetVersion(info: *mut OsVersionInfo) -> i32;
}
let mut info = OsVersionInfo { size: std::mem::size_of::<OsVersionInfo>() as u32, major: 0, minor: 0, build: 0, platform: 0, csd: [0; 128] };
// RtlGetVersion reports the real version (GetVersionEx lies to apps without a manifest entry)
if unsafe { RtlGetVersion(&mut info) } == 0 {
format!("{}.{}.{}", info.major, info.minor, info.build)
} else {
"version unknown".into()
}
}
#[cfg(not(windows))]
fn system_version() -> String {
first_line("/etc/os-release").unwrap_or_default()
}
/// What to tell a person, in plain words, for a technical reason. Always ends with the one next step.
pub fn plain_reason(detail: &str) -> String {
let lower = detail.to_ascii_lowercase();
if lower.contains("webview2") || lower.contains("webview") {
"Strata shows its window with Microsoft's WebView2 component, and it could not be started. \
Update or repair \"Microsoft Edge WebView2 Runtime\" (Settings > Apps > Installed apps), or install it from \
https://go.microsoft.com/fwlink/p/?LinkId=2124703, then start Strata again."
.into()
} else if lower.contains("app host") || lower.contains("node runtime") || lower.contains("could not start the app host") {
"Strata's background program did not start. Security software may have blocked a file in the Strata folder. \
Allow the Strata folder in it (or reinstall Strata) and start Strata again."
.into()
} else {
"Strata stopped while it was starting. Starting it again may work; if it does not, please send us the log file named below.".into()
}
}
/// The text of the crash file for a failed start, as text (so it can be tested without a failure).
pub fn startup_report(detail: &str, app_version: &str, log_path: Option<&Path>) -> String {
let mut t = String::new();
t.push_str("Strata app start failure\n========================\n\n");
t.push_str(&format!("When: {} UTC\n", stamp(SystemTime::now())));
t.push_str(&format!("App: Strata desktop {app_version}\n"));
t.push_str(&format!("{}\n", system_line()));
t.push_str(&format!("What: {detail}\n"));
t.push_str(&format!("Meaning: {}\n", plain_reason(detail)));
if let Some(p) = log_path {
t.push_str(&format!("\nFull log of this run: {}\n", p.display()));
}
t
}
/// Write the crash file of a failed start into `dir`; returns where it is.
pub fn write_startup_failure(dir: &Path, detail: &str, log_path: Option<&Path>) -> Option<PathBuf> {
std::fs::create_dir_all(dir).ok()?;
prune(dir, "desktop-crash-", false);
let path = dir.join(format!("desktop-crash-{}.log", stamp(SystemTime::now())));
std::fs::write(&path, startup_report(detail, env!("CARGO_PKG_VERSION"), log_path)).ok()?;
Some(path)
}
/// The message box (Windows). Elsewhere the text goes to the terminal only.
#[cfg(windows)]
fn show_error(text: &str) {
use std::os::windows::ffi::OsStrExt;
#[link(name = "user32")]
extern "system" {
fn MessageBoxW(window: *mut std::ffi::c_void, text: *const u16, caption: *const u16, kind: u32) -> i32;
}
const MB_ICONERROR: u32 = 0x10;
const MB_SETFOREGROUND: u32 = 0x1_0000;
const MB_TOPMOST: u32 = 0x4_0000;
let wide = |s: &str| std::ffi::OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect::<Vec<u16>>();
let (text, caption) = (wide(text), wide("Strata"));
unsafe {
MessageBoxW(std::ptr::null_mut(), text.as_ptr(), caption.as_ptr(), MB_ICONERROR | MB_SETFOREGROUND | MB_TOPMOST);
}
}
#[cfg(not(windows))]
fn show_error(text: &str) {
eprintln!("[strata] {text}");
}
/// Record a failed start (run log and crash file) and tell the person. Runs inside the panic hook as well, so it cannot panic.
fn report_failure(detail: &str) {
let Some(run) = RUN.get() else {
eprintln!("[strata] {detail}");
return;
};
run.log.line("shell", &format!("START FAILED: {detail}"));
let dir = logs_dir();
let saved = write_startup_failure(&dir, detail, run.log.path.as_deref());
if let Some(p) = &saved {
run.log.line("shell", &format!("The crash file is {}", p.display()));
}
if run.dialog && !REPORTED.swap(true, Ordering::SeqCst) {
let short: String = detail.chars().take(400).collect();
let place = saved.or_else(|| run.log.path.clone()).map(|p| p.display().to_string()).unwrap_or_else(|| dir.display().to_string());
show_error(&format!("Strata could not start.\n\n{}\n\nWhat happened: {short}\n\nA log was saved here, please send it to us:\n{place}", plain_reason(detail)));
}
}
/// A start that cannot go on: say why (log, crash file, message box) and end the process with a failure code. Never returns.
pub fn startup_failed(detail: &str) -> ! {
report_failure(detail);
std::process::exit(1)
}
/// Every panic in the app (a Tauri setup error is one) ends in an abort with panic = "abort", which Windows reports as 0xc0000409 with
/// nothing else. The hook runs before the abort: it writes what the panic said and where, and shows the message box.
fn install_panic_hook() {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
previous(info);
let message = if let Some(s) = info.payload().downcast_ref::<&str>() {
(*s).to_string()
} else if let Some(s) = info.payload().downcast_ref::<String>() {
s.clone()
} else {
"a panic without a message".to_string()
};
let place = info.location().map(|l| format!("{}:{}", l.file(), l.line())).unwrap_or_else(|| "an unknown place".into());
let thread = std::thread::current().name().unwrap_or("unnamed").to_string();
report_failure(&format!("the app stopped unexpectedly: {message} (at {place}, thread {thread})"));
}));
}
pub fn downloads_dir() -> PathBuf {
std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Downloads")).unwrap_or_else(std::env::temp_dir)
std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(|h| PathBuf::from(h).join("Downloads")).unwrap_or_else(std::env::temp_dir)
}
#[cfg(test)]
@@ -371,4 +573,28 @@ mod tests {
std::env::remove_var("STRATA_DESKTOP_DATA_DIR");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_failed_start_is_explained_in_plain_words_and_written_to_a_crash_file() {
let webview = plain_reason("Failed to setup app: error encountered during setup hook: WebView2 error: 0x8007139F");
assert!(webview.contains("WebView2") && webview.contains("go.microsoft.com"));
assert!(plain_reason("the Strata App Host did not become healthy at http://127.0.0.1:47831/api/health").contains("background program"));
assert!(plain_reason("something else").contains("log file"));
let dir = std::env::temp_dir().join(format!("strata-startlog-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
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:"] {
assert!(text.contains(needle), "{needle}");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(windows)]
#[test]
fn on_windows_the_logs_sit_in_the_local_app_data_strata_folder() {
let dir = platform_data_dir();
assert!(dir.ends_with("Strata"), "{}", dir.display());
assert!(system_version().split('.').count() == 3, "{}", system_version());
}
}
+51 -10
View File
@@ -38,7 +38,6 @@ use std::time::{Duration, Instant};
use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
mod updater;
#[cfg(target_os = "linux")]
mod desktoplog;
#[cfg(target_os = "linux")]
mod supervisor;
@@ -491,9 +490,23 @@ fn start_host(app: &tauri::AppHandle) -> Result<u16, String> {
*app.state::<ShutdownToken>().0.lock().unwrap() = Some(token);
let node = node_binary(&res)?;
let child = cmd
// The host's lines go to the run log (a packaged Windows app has no console, so they were lost before).
let log = desktoplog::run_log();
if log.is_some() {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
}
let mut child = cmd
.spawn()
.map_err(|e| format!("could not start the App Host on {}: {e}", node.display()))?;
if let Some(log) = log {
let ring = desktoplog::Ring::default();
if let Some(out) = child.stdout.take() {
desktoplog::tee(out, "host", false, ring.clone(), log.clone());
}
if let Some(err) = child.stderr.take() {
desktoplog::tee(err, "host", true, ring, log);
}
}
*app.state::<HostProcess>().0.lock().unwrap() = Some(child);
*app.state::<HostPort>().0.lock().unwrap() = Some(port);
@@ -529,6 +542,19 @@ fn stop_host(app: &tauri::AppHandle) {
}
}
/// End the host at once, without the clean stop (a start that failed: there is nothing to drain).
#[cfg(not(target_os = "linux"))]
fn kill_host(app: &tauri::AppHandle) {
if let Some(state) = app.try_state::<HostProcess>() {
if let Ok(mut guard) = state.0.lock() {
if let Some(mut child) = guard.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}
}
/// A clean stop first: the host stops, through Control, the engines this app started, then exits. Engines another client
/// started (the Terminal) are not touched. If it does not finish in time the process is ended.
fn stop_host_child(child: &mut Child, port: Option<u16>, token: Option<&str>) {
@@ -803,6 +829,10 @@ fn main() {
// at the screen — the lesson from the Electron attempt, where a packaged app that never started
// its host hung with no output at all.
let args: Vec<String> = std::env::args().collect();
// Windows and macOS: the run log and the panic hook come first, before anything that can fail. A start that goes wrong then leaves a
// log, a crash file and a message box instead of a process that is gone with 0xc0000409 and nothing else (Linux: supervisor.rs).
#[cfg(not(target_os = "linux"))]
desktoplog::init_startup(self_test_path(&args).is_none());
contain_app();
#[cfg(target_os = "linux")]
{
@@ -849,14 +879,25 @@ fn run_embedded() {
.manage(HostPort(Mutex::new(None)))
.manage(ShutdownToken(Mutex::new(None)))
.setup(|app| {
// A way to prove the safety net on a computer where the app starts fine (tests only): a panic at the place a setup error ends in.
if std::env::var_os("STRATA_DESKTOP_TEST_PANIC").is_some() {
panic!("STRATA_DESKTOP_TEST_PANIC is set: the app panics on purpose");
}
let handle = app.handle().clone();
let port = start_host(&handle).map_err(|e| {
// A startup failure must be visible, not a blank window.
eprintln!("[strata] {e}");
Box::<dyn std::error::Error>::from(e)
})?;
let shell_token = handle.state::<ShutdownToken>().0.lock().unwrap().clone().unwrap_or_default();
open_window(app, port, &shell_token)?;
// A setup error would become a panic inside Tauri ("Failed to setup app"), and with panic = "abort" a crash with no message.
// A failed start is reported (log, crash file, message box) and ends the app here instead.
let port = match start_host(&handle) {
Ok(port) => port,
Err(e) => {
kill_host(&handle);
desktoplog::startup_failed(&e)
}
};
let shell_token = handle.state::<ShutdownToken>().0.lock().map(|t| t.clone().unwrap_or_default()).unwrap_or_default();
if let Err(e) = open_window(app, port, &shell_token) {
kill_host(&handle);
desktoplog::startup_failed(&format!("the Strata window could not be opened: {e}"))
}
Ok(())
})
.on_window_event(|window, event| {
@@ -865,7 +906,7 @@ fn run_embedded() {
}
})
.build(tauri::generate_context!())
.expect("error while building the Strata application")
.unwrap_or_else(|e| desktoplog::startup_failed(&format!("the Strata application could not be built: {e}")))
.run(|app, event| {
if let tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit = event {
stop_host(app);
+1 -2
View File
@@ -27,8 +27,7 @@ START IT
Closing the window stops Strata and its engine.
Pictures: this Mac build has no picture reader yet, so models read text only here. Picture input works on the Windows and
Linux builds.
Pictures: on by default. This Mac build reads them on the Mac's GPU (under a second for a small picture on an M4).
@INCLUDE:common-gatekeeper-mac.txt@
+28 -2
View File
@@ -1,14 +1,15 @@
{
"name": "strata",
"version": "0.2.0-beta",
"version": "0.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "strata",
"version": "0.2.0-beta",
"version": "0.2.2",
"dependencies": {
"html-to-image": "^1.11.13",
"katex": "^0.19.0",
"pdfjs-dist": "^6.4.299",
"prismjs": "1.30.0",
"react": "^19.1.0",
@@ -1608,6 +1609,15 @@
],
"license": "CC-BY-4.0"
},
"node_modules/commander": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
"integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
"license": "MIT",
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1781,6 +1791,22 @@
"node": ">=6"
}
},
"node_modules/katex": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.19.0.tgz",
"integrity": "sha512-v6Tznz3zJ7u3niRCoDTsumM2+HA2XXcCu+WAacCeHD2z3p9A9Ks987o5FzfTGBN0e8A0vjEgIDvLbICrXpdw/Q==",
"funding": [
"https://opencollective.com/katex",
"https://github.com/sponsors/katex"
],
"license": "MIT",
"dependencies": {
"commander": "^15.0.0"
},
"bin": {
"katex": "cli.js"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "strata",
"private": true,
"version": "0.2.0-beta",
"version": "0.2.2",
"type": "module",
"description": "Strata \u2014 local AI, tuned to one model. Web shell + engine host.",
"scripts": {
@@ -28,6 +28,7 @@
},
"dependencies": {
"html-to-image": "^1.11.13",
"katex": "^0.19.0",
"pdfjs-dist": "^6.4.299",
"prismjs": "1.30.0",
"react": "^19.1.0",
+1
View File
@@ -57,6 +57,7 @@ The chat's Mode menu is real Harness behaviour. Each turn the page sends the cha
- Manual relies on the gate, so a Manual turn does not start unless the bridge has confirmed the gate is loaded (`/strata-gate` command): without it Manual would be an open sandbox that nobody asks about.
- The gate listens to `tools/pre-execute` (the seam the Harness's own Auto-review layer uses) and returns an `ask` decision; the Harness's approval service then asks, runs the tool only after `allowed-once`, and records the answer. Read-only tools and a short allowlist of look-only shell commands (Get-ChildItem, git status, ...) run unasked; MCP and unknown tools ask.
- Web search and web pages (`web_search`, `web_fetch`) ask once per chat in Manual, not once per call. The gate asks through the approval service itself (`webDecision`: "The assistant wants to search the web for: ... Search words go to <service>." / "... open the web page ..."), remembers a yes for the chat (a helper agent shares its chat's answer, and two searches started together share one card), and on a no gives the model `WEB_REFUSAL` ("The person did not allow web access ... Go on without it and say so."). Plan and Bypass are unchanged. Settings -> Tools has a switch, "Let the assistant search the web without asking" (off by default, `webWithoutAsking` in `tools-settings.mjs`, passed to the gate as `config.web.skipAsk`), that skips the question in Manual. A scheduled task has nobody at the card, so its web call is answered no unless that switch is on. Tests: `tests/web-ask.test.mjs`.
- The mode is read once per turn; an unknown or missing value is Manual.
## Tools
+19 -3
View File
@@ -28,6 +28,7 @@ import { ChildProjector } from './children.mjs';
import { nearestBudget } from './llm-shape.mjs';
import { DeliverableTracker, ActivityGroups } from './deliverables.mjs';
import { SkillsUsed, skillInfoFrom } from './skills-used.mjs';
import { turnEngineSpeed } from './engine-speed.mjs';
import { TurnGuard, guardNote, GUARD_NOTES } from './turn-guard.mjs';
import { PlanStore, planPrompt, parsePlan, parsePoints, stepObjective, stepLine, firstSentence, stepSummary, planAsPrompt, editedSteps, planView, researchPrompt, researchWritePrompt, RESEARCH_NUDGE, parseSources } from './plan.mjs';
@@ -228,6 +229,13 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
return next;
}
/** The local engine's own speed for this turn's requests (null when it cannot be read: the stream's estimate stays). */
async function engineSpeedOf(route, projector) {
const inst = localInstances().find(i => i.id === route.instanceId);
if (!inst) return null;
return turnEngineSpeed({ baseUrl: inst.baseUrl, key: inst.key, since: projector.startedAt, attempts: projector.streams.size, timeoutMs: 800 });
}
/** Make sure a runtime exists that knows the route this turn needs. */
async function ensureRuntime(choice, signal) {
for (;;) {
@@ -429,7 +437,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
turn.finish(`The assistant stopped answering for ${mins > 1 ? mins + ' minutes' : 'a minute'}, so I ended this turn. Anything it already finished is kept. Send your message again to go on.`);
}, Math.min(15_000, Math.max(200, Math.floor(stallMs / 4))));
turn.watchdog.unref?.();
function finishTurn(failure) {
async function finishTurn(failure) {
if (turn.finished) return;
turn.finished = true;
clearTimeout(turn.settleTimer);
@@ -450,7 +458,9 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
}
if (noteReason && !extra.some(f => f.type === 'plan' && f.event === 'proposed')) { const note = guardNote(noteReason); if (note) extra.push(note); }
for (const f of extra) emit(f);
const frames = failure ? [{ type: 'error', message: plainAgentFailure(failure), details: failure, code: 'AGENT_STOPPED' }] : projector.finish({ model: route.name, contextMax: route.contextWindow });
// The answer speed of a turn on this computer is the engine's own (tokens it produced over its decode time, as the Monitor shows it).
const engine = !failure && route.kind === 'local' ? await engineSpeedOf(route, projector) : null;
const frames = failure ? [{ type: 'error', message: plainAgentFailure(failure), details: failure, code: 'AGENT_STOPPED' }] : projector.finish({ model: route.name, contextMax: route.contextWindow, engine });
for (const f of frames.slice(0, -1)) emit(f);
// Messages typed while the goal ran and not delivered between steps go back to the page now, to be sent as ordinary messages.
if (turn.goalRun && attached) {
@@ -1180,6 +1190,12 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
.map(p => (p.sessionId === sessionId ? p : { ...p, callId: `${p.sessionId}:${p.callId}` })).map(({ callId, tool, reason, at }) => ({ callId, tool, reason, at }));
}
/** Every chat that has an approval waiting, for the task board: the chat, its working folder and what is waiting (same items as pendingFor). */
function pendingAll() {
if (!pending.size) return [];
return Object.keys(sessionMap).map(chatId => ({ chatId, folder: sessionMap[chatId]?.cwd ?? null, pending: pendingFor(chatId) })).filter(c => c.pending.length);
}
async function close() {
const r = rt; rt = null;
if (r) { r.events?.close(); r.gw.close(); await r.hp.stop(); }
@@ -1188,7 +1204,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
/** Let go of a chat's Harness session mapping (a task starts every run in a session of its own). */
function forget(chatId) { if (chats.get(chatId)?.turn) return; delete sessionMap[chatId]; chats.delete(chatId); persist(); }
return { goalState, goalControl, harnessDir, status, runTurn, goalCommand, planCommand, researchCommand, llmTarget, setPlanBriefing, resetSession, reopenPlan, skipStep, planGoal, planControl, planState, queueMessage, cancelQueued, takeQueue, forget, approve, setMode, stop, pendingFor, close, sessionOf: chatId => sessionMap[chatId]?.sessionId ?? null, workingFolder: chatId => sessionMap[chatId]?.cwd ?? null, isRunning: chatId => !!chats.get(chatId)?.turn, _debug: () => ({ rt, sessionMap }) };
return { goalState, goalControl, harnessDir, status, runTurn, goalCommand, planCommand, researchCommand, llmTarget, setPlanBriefing, resetSession, reopenPlan, skipStep, planGoal, planControl, planState, queueMessage, cancelQueued, takeQueue, forget, approve, setMode, stop, pendingFor, pendingAll, close, sessionOf: chatId => sessionMap[chatId]?.sessionId ?? null, workingFolder: chatId => sessionMap[chatId]?.cwd ?? null, isRunning: chatId => !!chats.get(chatId)?.turn, _debug: () => ({ rt, sessionMap }) };
}
/** A goal that stopped by itself, in words. */
+41
View File
@@ -0,0 +1,41 @@
/**
* The engine's own answer speed for one assistant turn: every token its requests produced (thinking, answer and tool calls; with
* MTP drafting only the tokens it kept) over the time they spent decoding. These are the numbers the Monitor and `/metrics` show,
* so the chat footer reads the same. Nothing here is a guess from the stream: it returns null when the engine does not say.
*/
/** Sum the engine's finished requests that began at or after `since` (ms). `attempts` is how many model requests the turn made:
* when the engine lists more (another chat ran at the same time), only the newest `attempts` are the turn's. */
export function sumEngineRequests(requests, { since, attempts = Infinity } = {}) {
if (!Array.isArray(requests)) return null;
const mine = requests
.filter(r => Number.isFinite(r?.time) && r.time * 1000 >= since - 1500)
.sort((a, b) => a.time - b.time)
.slice(-Math.max(1, attempts));
let tokens = 0, decodeMs = 0, promptRead = 0, promptMs = 0;
for (const r of mine) {
const made = Number.isFinite(r.engine_generated) ? r.engine_generated : r.output_tokens;
if (!(made > 0) || !(r.decode_ms > 0)) continue;
tokens += made; decodeMs += r.decode_ms;
const read = Number.isFinite(r.prompt_read) ? r.prompt_read : (r.prompt_tokens ?? 0) - (r.reused ?? 0);
if (read > 0 && r.prompt_ms > 0) { promptRead += read; promptMs += r.prompt_ms; }
}
if (!(tokens > 0 && decodeMs > 0)) return null;
return {
tokens, decodeMs, tps: Number((tokens / (decodeMs / 1000)).toFixed(1)),
promptTps: promptRead > 0 && promptMs > 0 ? Number((promptRead / (promptMs / 1000)).toFixed(1)) : null,
};
}
/** Ask a local engine for its request history. Any failure is null: the caller keeps the stream's own estimate. */
export async function turnEngineSpeed({ baseUrl, key, since, attempts, fetcher = fetch, timeoutMs = 1500 }) {
try {
const origin = new URL(baseUrl).origin;
const res = await fetcher(`${origin}/metrics?requests=all`, {
signal: AbortSignal.timeout(timeoutMs),
headers: key ? { authorization: `Bearer ${key}` } : undefined,
});
if (!res.ok) return null;
return sumEngineRequests((await res.json())?.requests, { since, attempts });
} catch { return null; }
}
+13 -4
View File
@@ -236,7 +236,7 @@ export class TurnProjector {
return { type: 'agent-tool', tool: entry.card };
}
/* The speed shown while a reply streams, and after it: everything the model generated (reasoning included) over the time its
/* The speed shown while a reply streams, and after it: everything the model generated (reasoning and tool calls included) over the time its
requests spent producing it, not over the whole turn (tool runs and the wait for the prompt are not decoding). One stream per model
request; tokens are one per streamed chunk, or characters / 4 when a server batches. */
#produced(attemptId, text, out) {
@@ -284,6 +284,8 @@ export class TurnProjector {
const call = this.composing.get(c.id) ?? { name: c.name ?? '', head: '', bytes: 0 };
if (c.name) call.name = c.name;
const piece = String(c.argumentsDelta ?? '');
// Writing a tool call (a whole file, say) is decoding too: its tokens are in the engine's output count, so its time belongs in the speed.
this.#produced(f.attemptId, piece, out);
call.bytes += piece.length;
if (call.head.length < 4000) call.head += piece;
this.composing.set(c.id, call);
@@ -393,7 +395,7 @@ export class TurnProjector {
}
/** The terminal frames for a finished turn. */
finish({ model, contextMax } = {}) {
finish({ model, contextMax, engine = null } = {}) {
const reason = this.ended ?? { kind: 'interrupted' };
const durationMs = Date.now() - this.startedAt;
const usage = this.usageSeen
@@ -401,8 +403,15 @@ export class TurnProjector {
: { tokensIn: null, tokensOut: null, usageSource: 'unavailable' };
const decoded = this.decode();
const generated = this.usageSeen && this.tokensOut > 0 ? this.tokensOut : decoded.tokens;
const tps = decoded.seconds >= 0.3 && generated > 0 ? Number((generated / decoded.seconds).toFixed(1)) : null;
const base = { durationMs, ttftMs: this.firstTokenAt ? this.firstTokenAt - this.startedAt : null, model, contextMax, tps, contextTokens: this.contextTokens };
// Answer speed, one definition everywhere: all the tokens the model produced over its decode time. The engine's own figures (the ones the
// Monitor shows) when the bridge could read them; otherwise the stream's own span. A very short reply has almost no span, so its decode time
// is then the time from its first chunk to the end of the turn (only when no tool ran, so no tool time is in it): the number is still shown.
let tps = null;
if (engine?.tps > 0) tps = engine.tps;
else if (generated > 0 && decoded.seconds >= 0.3) tps = Number((generated / decoded.seconds).toFixed(1));
else if (generated > 0 && this.tools.size === 0 && this.firstTokenAt && Date.now() - this.firstTokenAt >= 50) tps = Number((generated / ((Date.now() - this.firstTokenAt) / 1000)).toFixed(1));
const promptTps = engine?.promptTps > 0 ? engine.promptTps : null;
const base = { durationMs, ttftMs: this.firstTokenAt ? this.firstTokenAt - this.startedAt : null, model, contextMax, tps, ...(promptTps ? { promptTps } : {}), contextTokens: this.contextTokens };
switch (reason.kind) {
case 'completed':
return [{ type: 'done', ...base, ...usage, finishReason: 'stop' }];
+2 -1
View File
@@ -13,6 +13,7 @@ import { spawn, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { externalEnvironment } from '../external-env.mjs';
import { utf8Script } from '../powershell-utf8.mjs';
const URL_PATTERN = /http:\/\/127\.0\.0\.1:(\d+)\/\?token=([A-Za-z0-9_-]+)/;
@@ -127,7 +128,7 @@ export class HarnessProcess {
function commandLineOf(pid) {
if (!Number.isInteger(pid) || pid <= 0) return null;
if (process.platform === 'win32') {
const r = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`], { encoding: 'utf8', windowsHide: true, timeout: 15000 });
const r = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', utf8Script(`(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`)], { encoding: 'utf8', windowsHide: true, timeout: 15000 });
return r.status === 0 && r.stdout.trim() ? r.stdout.trim() : null;
}
const r = spawnSync('ps', ['-p', String(pid), '-o', 'args='], { encoding: 'utf8' });
+70 -6
View File
@@ -41,8 +41,7 @@ export const READ_ONLY_TOOLS = new Set([
'read', 'glob', 'grep', 'read_image', 'working_directory', 'todo_write', 'skill', 'present', 'lsp',
'get_goal', 'create_goal', 'update_goal', 'list_agents', 'list_subagent_models', 'job_list', 'job_output',
'ask_user_question', 'exit_plan_mode',
// Reading the internet is part of what the app promises; changing nothing here.
'web_fetch', 'web_search',
// (web_search and web_fetch are not here: the first use in a chat asks, see webDecision.)
// Delegation: the child it starts is governed by this same gate.
'subagent', 'subagent_fork', 'send_message', 'interrupt_agent', 'workflow',
]);
@@ -103,14 +102,37 @@ const clip = (value, max) => {
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
};
/** The tools that reach the internet. In Manual the first one in a chat asks once; the answer holds for the rest of that chat. */
export const WEB_TOOLS = new Set(['web_search', 'web_fetch']);
/** What the model reads when the person says no. Plain words, and it tells the model what to do next. */
export const WEB_REFUSAL = 'The person did not allow web access, so nothing was searched or opened. Go on without it and say so.';
/**
* The question for a web call, in plain words: what is about to leave this computer and where it goes.
* @param {{name:string, arguments:any}} exec
* @param {{provider?: string}} [options] provider is the search service's name as the person knows it
*/
export function webDecision(exec, options = {}) {
const tool = String(exec.name ?? '');
const args = exec.arguments && typeof exec.arguments === 'object' ? exec.arguments : {};
if (tool === 'web_search') {
const queries = Array.isArray(args.queries) ? args.queries : [args.query ?? args.q];
const words = clip(queries.filter(q => typeof q === 'string' && q.trim()).join('; '), 160);
return { kind: 'ask', web: true, reason: `The assistant wants to search the web for: ${words}. Search words go to ${options.provider || 'the search service'}.` };
}
return { kind: 'ask', web: true, reason: `The assistant wants to open the web page ${clip(args.url, 200)}. The page's site sees the request.` };
}
/**
* The decision for one call in Manual mode, or null when it needs no question.
* @param {{name:string, arguments:any}} exec
* @returns {{kind:'ask', reason:string}|null}
* @param {{provider?: string}} [options]
* @returns {{kind:'ask', reason:string, web?:true}|null}
*/
export function manualDecision(exec) {
export function manualDecision(exec, options = {}) {
const tool = String(exec.name ?? '');
const args = exec.arguments && typeof exec.arguments === 'object' ? exec.arguments : {};
if (WEB_TOOLS.has(tool)) return webDecision(exec, options);
if (READ_ONLY_TOOLS.has(tool)) return null;
if (SHELL_TOOLS.has(tool)) {
if (isReadOnlyShell(args.command)) return null;
@@ -150,7 +172,40 @@ export function planDecision(exec) {
return { kind: 'deny', reason: 'Plan mode only looks and changes nothing, so it cannot use this tool. Switch to Manual or Bypass to use it.' };
}
export function apply(ctx) {
/**
* @param {object} ctx
* @param {{web?: {provider?: string, skipAsk?: boolean}}} [config] `web.provider`: the search service's name for the question;
* `web.skipAsk`: the person's switch in Settings -> Tools, "search the web without asking" (off unless they turned it on)
*/
export function apply(ctx, config = {}) {
const web = config.web ?? {};
/** Chats (their first session) where the person said yes to the web. One yes holds for the rest of the chat. */
const webAllowed = new Set();
/** A question already on the screen for a chat, so two searches started together share one card. */
const webAsking = new Map();
const chatOf = (session, depth = 0) => {
const parentId = session.header?.parentSession;
if (session.header?.origin === 'subagent' && parentId && depth < 6) {
const parent = ctx.sessions.get(parentId);
if (parent) return chatOf(parent, depth + 1);
}
return session.id;
};
async function askWeb(exec, gate, agent) {
const chat = chatOf(agent.session);
let question = webAsking.get(chat);
if (!question) {
question = ctx.approval.request({ agent, toolName: exec.name, callId: exec.callId, reason: gate.reason, displayReason: { en: gate.reason }, signal: exec.signal });
webAsking.set(chat, question);
const forget = () => { if (webAsking.get(chat) === question) webAsking.delete(chat); };
question.then(forget, forget);
}
const outcome = await question;
if (outcome === 'allowed-once') { webAllowed.add(chat); return null; }
if (outcome === 'cancelled') return { kind: 'cancel' };
return { kind: 'deny', reason: WEB_REFUSAL };
}
/** The preset a session is really working under: a child follows the chat that started it. */
const effectivePreset = (session, depth = 0) => {
const parentId = session.header?.parentSession;
@@ -173,8 +228,17 @@ export function apply(ctx) {
}
if (preset === PLAN_PRESET) return planDecision(exec) ?? next();
if (preset !== MANUAL_PRESET) return next();
const gate = manualDecision(exec);
const gate = manualDecision(exec, { provider: web.provider });
if (!gate) return next();
if (gate.web) {
// The person's switch, or a yes already given in this chat: no question.
if (web.skipAsk === true || webAllowed.has(chatOf(agent.session))) return next();
// A helper's own approval policy is `never`; it must be `ask` for the question to reach the person.
if (agent.session.header?.origin === 'subagent' && ctx.approval.overrideOf(agent.session) !== 'ask') {
agent.session.append('approval/policy', { policy: 'ask' });
}
return (await askWeb(exec, gate, agent)) ?? next();
}
// A child's approval policy is pinned to `never` by the Harness; asking is how its request reaches the person.
if (agent.session.header?.origin === 'subagent' && ctx.approval.overrideOf(agent.session) !== 'ask') {
agent.session.append('approval/policy', { policy: 'ask' });
@@ -16,7 +16,7 @@ export const inject = ['web'];
export function apply(ctx, config = {}) {
const keyOf = () => (config.keyEnv ? process.env[config.keyEnv] ?? '' : '');
// Tests only (the bridge option `searchMock`): the same search code on a pretend web, so nothing leaves the machine.
const pretend = config.mock === 'limited' ? { fetch: async url => new Response('<html>please verify you are human: captcha</html>', { status: /duckduckgo/.test(String(url)) ? 202 : 429 }) } : null;
const pretend = config.mock === 'offline' ? { fetch: async () => { throw new TypeError('fetch failed'); } } : config.mock === 'limited' ? { fetch: async url => new Response('<html>please verify you are human: captcha</html>', { status: /duckduckgo/.test(String(url)) ? 202 : 429 }) } : null;
const provider = pretend ? createDuckDuckGoSearch(pretend)
: config.provider === 'exa' ? createExaSearch({ key: keyOf })
: config.provider === 'perplexity' ? createPerplexitySearch({ key: keyOf })
+6 -2
View File
@@ -60,8 +60,11 @@ export const SEARCH_PLUGIN = fileURLToPath(new URL('./plugins/strata-search.mjs'
/** The app's own tool server for the tools a person writes (instructions, commands). */
export const CUSTOM_TOOLS_SCRIPT = fileURLToPath(new URL('./custom-tools-mcp.mjs', import.meta.url));
/** The search service as the person knows it, for the question that comes before the first web search in a chat. */
const SEARCH_NAMES = Object.freeze({ builtin: 'DuckDuckGo, or Bing when DuckDuckGo is busy', exa: 'Exa', perplexity: 'Perplexity', deepseek: 'DeepSeek' });
/** What Settings -> Tools says when nothing was chosen. */
const NO_TOOLS_SETTINGS = Object.freeze({ off: new Set(), search: { provider: 'builtin', key: '' }, browserEnabled: false, browserPath: null, browserCli: null, servers: {}, hasInstructionTools: false, hasCommandTools: false, toolFile: null });
const NO_TOOLS_SETTINGS = Object.freeze({ off: new Set(), search: { provider: 'builtin', key: '' }, webWithoutAsking: false, browserEnabled: false, browserPath: null, browserCli: null, servers: {}, hasInstructionTools: false, hasCommandTools: false, toolFile: null });
/**
* The tool servers (MCP) set up for the model in Settings, as Harness client rows. Their tools appear to the agent as
@@ -281,7 +284,8 @@ export function buildProfile({ local = [], connections = [], gate = true, tools
{ id: 'permission', config: { presets: Object.fromEntries(Object.values(MODE_PRESETS).map(({ key, sandbox, approval, name, description }) => [key, { sandbox, approval, name, description }])), defaultPreset: MODE_PRESETS[DEFAULT_MODE].key } },
{ id: 'subagent', config: { maxActiveSubagents: slots > 0 ? Math.max(slots, MIN_LOCAL_HELPERS) : DEFAULT_API_HELPERS } },
// Manual asks before a change runs, so the person sees one Approve/Deny card and nothing has happened yet.
...(gate ? [{ insert: [{ id: 'strata-gate', name: GATE_PLUGIN }] }] : []),
// Its web question names the search service the person chose (a keyed one only when its key is saved), and the person's switch skips the question.
...(gate ? [{ insert: [{ id: 'strata-gate', name: GATE_PLUGIN, config: { web: { provider: SEARCH_NAMES[keyed ?? 'builtin'], skipAsk: tools.webWithoutAsking === true } } }] }] : []),
// Helper agents on a model the person names: every model the app can reach is allowed, and the plugin makes the choice forgiving (plugins/strata-models.mjs).
...(subagentRoutes.length ? [{ id: 'subagent-model-selection-settings', config: { enabled: true, allowedModels: subagentRoutes.map(r => ({ provider: r.provider, model: r.model })) } }] : []),
...(subagentRoutes.length ? [{ insert: [{ id: 'strata-models', name: MODELS_PLUGIN, config: { routes: subagentRoutes } }] }] : []),
+2 -2
View File
@@ -12,8 +12,8 @@ export const TOOL_GROUPS = [
{ id: 'files', area: 'computer', name: 'Read and change files', about: 'Lets the assistant read, create and edit files in your chat folder.', asks: true, plugins: ['tool-fs'] },
{ id: 'find', area: 'computer', name: 'Find files and text', about: 'Lets the assistant look for files by name and search inside them. It only reads.', asks: false, plugins: ['tool-fs-search'] },
{ id: 'jobs', area: 'computer', name: 'Long-running jobs', about: 'Lets the assistant start a slow command in the background and check on it later.', asks: true, plugins: ['tool-jobs'] },
{ id: 'fetch', area: 'web', name: 'Open web pages', about: 'Lets the assistant read a web page you point it to.', asks: false, web: 'fetch' },
{ id: 'search', area: 'web', name: 'Search the web', about: 'Lets the assistant search the web when it needs fresh information.', asks: false, web: 'search' },
{ id: 'fetch', area: 'web', name: 'Open web pages', about: 'Lets the assistant read a web page you point it to.', asks: true, web: 'fetch' },
{ id: 'search', area: 'web', name: 'Search the web', about: 'Lets the assistant search the web when it needs fresh information.', asks: true, web: 'search' },
{ id: 'helpers', area: 'together', name: 'Helper assistants', about: 'Lets the assistant start helpers that work on parts of a task at the same time.', asks: false, plugins: ['delegation'] },
{ id: 'goals', area: 'together', name: 'Goals', about: 'Lets you give a goal with /goal. The assistant keeps working until it is done.', asks: false, plugins: ['command-goal', 'tool-goal'] },
{ id: 'todo', area: 'together', name: 'To-do list', about: 'Lets the assistant keep a checklist while it works on a big task.', asks: false, plugins: ['tool-todo'] },
@@ -32,6 +32,8 @@ function normalize(raw) {
off: [...new Set((Array.isArray(r.off) ? r.off : []).filter(id => GROUP_IDS.includes(id)))],
search: { provider: SEARCH_PROVIDERS.includes(search.provider) ? search.provider : 'builtin', key: typeof search.key === 'string' ? search.key : '' },
browser: { enabled: r.browser?.enabled === true },
// Manual mode asks once per chat before the first web search or page. This switch (off unless the person turned it on) skips that question.
webWithoutAsking: r.webWithoutAsking === true,
custom: (Array.isArray(r.custom) ? r.custom : []).filter(t => t && NAME.test(t.name ?? '') && KINDS.includes(t.kind)).slice(0, MAX_CUSTOM).map(t => ({
id: String(t.id), name: String(t.name), about: clean(t.about, 300), kind: t.kind, enabled: t.enabled !== false,
instructions: clean(t.instructions, 20000), command: clean(t.command, 2000), address: clean(t.address, 2000),
@@ -106,6 +108,7 @@ export function createAgentTools({ read, write, dataDir, browser = () => ({ foun
return {
groups: TOOL_GROUPS.map(g => ({ id: g.id, name: g.name, about: g.about, area: g.area, asks: g.asks, enabled: !s.off.includes(g.id) })),
search: { provider: s.search.provider, keySet: !!s.search.key },
webWithoutAsking: s.webWithoutAsking,
// `found` means the browser tool can run: a browser on this computer AND the tool server in the agent component.
browser: { enabled: s.browser.enabled, found: !!(probe.found && probe.cli), name: probe.name ?? null, ...(probe.found && !probe.cli ? { missing: 'component' } : {}) },
custom: s.custom.map(t => ({
@@ -131,6 +134,7 @@ export function createAgentTools({ read, write, dataDir, browser = () => ({ foun
return {
off: new Set(s.off),
search: s.search,
webWithoutAsking: s.webWithoutAsking,
browserEnabled: s.browser.enabled && !!(browser().found && browser().cli),
browserPath: browser().path ?? null,
browserCli: browser().cli ?? null,
@@ -160,6 +164,7 @@ export function createAgentTools({ read, write, dataDir, browser = () => ({ foun
s.search = { ...s.search, key };
}
}
if (typeof change?.webWithoutAsking === 'boolean') s.webWithoutAsking = change.webWithoutAsking;
if (change?.browser && typeof change.browser.enabled === 'boolean') s.browser = { enabled: change.browser.enabled };
if (change?.custom) {
for (const [id, on] of Object.entries(change.custom)) {
+47 -2
View File
@@ -9,7 +9,7 @@
* The copy is made under a hidden temporary name and renamed into place, so an interrupted run leaves nothing half-installed
* (the next start removes the leftover and tries again).
*/
import { createReadStream, existsSync, readFileSync } from 'node:fs';
import { createReadStream, existsSync, readFileSync, readdirSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { copyFile, mkdir, readdir, readFile, rename, rm, stat } from 'node:fs/promises';
import { createHash } from 'node:crypto';
@@ -64,6 +64,40 @@ export function nvidiaDriverVersion() {
return null;
}
/** Where the builds are published; the wrong-make sentence points here. */
export const RELEASES_URL = 'https://github.com/Niko1221/Strata/releases';
/**
* The engine family this PC's graphics card needs ('cuda', 'hip', 'sycl'), or null when it cannot be told. The same order as Control
* (NVIDIA first, then AMD, then Intel); STRATA_ENGINE_BACKEND wins. Reads only the driver and (Linux) the PCI vendor of the display adapters.
*/
export function gpuBackendHere(env = process.env) {
const forced = String(env.STRATA_ENGINE_BACKEND ?? '').trim().toLowerCase();
if (forced) return ['cuda', 'hip', 'sycl'].includes(forced) ? forced : null;
if (nvidiaDriverVersion()) return 'cuda';
try {
const base = '/sys/class/drm';
const vendors = new Set();
for (const card of readdirSync(base)) {
if (!/^card\d+$/.test(card)) continue;
try { vendors.add(readFileSync(`${base}/${card}/device/vendor`, 'utf8').trim().toLowerCase()); } catch { /* no vendor file */ }
}
for (const [vendor, backend] of [['0x10de', 'cuda'], ['0x1002', 'hip'], ['0x8086', 'sycl']]) if (vendors.has(vendor)) return backend;
} catch { /* not Linux */ }
return null;
}
const HIP_FAMILIES = [['RX 9000', ['gfx1200', 'gfx1201']], ['RX 7000', ['gfx1100', 'gfx1101', 'gfx1102']], ['RX 6000', ['gfx1030', 'gfx1031']], ['Ryzen AI Max', ['gfx1151']]];
const MAKE_NAME = { cuda: 'NVIDIA', hip: 'AMD', sycl: 'Intel' };
/** The plain sentence for a build made for another make of graphics card than this PC has. */
export function wrongMakeNotice(manifest, here) {
const built = manifest.backend;
const families = [...new Set((manifest.archs ?? []).map(a => HIP_FAMILIES.find(([, list]) => list.includes(a))?.[0]).filter(Boolean))];
const builtFor = built === 'hip' && families.length ? `AMD ${families.join(' and ')}` : MAKE_NAME[built] ?? built;
return `This Strata is for ${builtFor} cards. Your PC has an ${MAKE_NAME[here] ?? here} card: download the ${MAKE_NAME[here] ?? here} version from ${RELEASES_URL}`;
}
/** One engine can ship as several builds of the same backend (CUDA 13 and CUDA 12 differ in the oldest driver they run on). */
const variantOf = manifest => `${manifest.platform}/${manifest.backend}/${manifest.min_driver?.nvidia ?? ''}`;
@@ -85,7 +119,7 @@ export async function packageIsWhole(folder, manifest, { deep = false } = {}) {
* Install the bundled engine when it is needed.
* Returns { action: 'installed' | 'repaired' | 'kept' | 'none' | 'refused', folder?, version?, reason? }; never throws.
*/
export async function installBundledEngine({ bundleDir, components, platform = null, log = () => {}, driver = undefined } = {}) {
export async function installBundledEngine({ bundleDir, components, platform = null, log = () => {}, driver = undefined, backend = undefined } = {}) {
try {
if (!bundleDir || !components || !existsSync(bundleDir)) return { action: 'none' };
let packages = [];
@@ -96,6 +130,17 @@ export async function installBundledEngine({ bundleDir, components, platform = n
}
if (platform) packages = packages.filter(p => p.manifest.platform === platform);
if (!packages.length) return { action: 'none' };
// A build for another make of graphics card than this PC has can never run here: it is not unpacked, and the caller says so in plain words.
const wanted = backend === undefined ? gpuBackendHere() : backend;
if (wanted && ['cuda', 'hip', 'sycl'].includes(wanted)) {
const gpu = packages.filter(p => ['cuda', 'hip', 'sycl'].includes(p.manifest.backend));
const fits = packages.filter(p => !gpu.includes(p) || p.manifest.backend === wanted);
if (gpu.length && !fits.length) {
const first = gpu.sort((a, b) => compareVersions(b.manifest.version, a.manifest.version))[0].manifest;
return { action: 'wrong-make', built: first.backend, here: wanted, notice: wrongMakeNotice(first, wanted) };
}
packages = fits;
}
// The CUDA builds the NVIDIA driver here can run: all of them when the driver is unknown or fits several; when none fits, the one with the lowest
// requirement, so that Control can name the driver to install (copying the others would only waste disk).
const driverHere = driver === undefined ? nvidiaDriverVersion() : driver;
+1 -1
View File
@@ -103,7 +103,7 @@ export function expertParams(cores = null) {
{ id: 'server.parallel', group: 'Server', label: 'Engine request slots', effect: 'This UI host dispatches one request at a time', type: 'number', value: 1, recommended: 1 },
{ id: 'server.queue', group: 'Server', label: 'Queue depth', effect: 'Requests allowed to wait', type: 'number', value: 8, recommended: 8 },
{ id: 'server.timeout', group: 'Server', label: 'Request timeout', effect: 'Seconds before a call is dropped', type: 'number', value: 600, recommended: 600 },
{ id: 'server.cors', group: 'Server', label: 'Allow browser origins', effect: 'CORS for local web apps', type: 'toggle', value: false, recommended: false },
{ id: 'server.cors', group: 'Server', label: 'Allow browser origins', effect: 'This switch does nothing in the app. Web pages from any site can already call the API shown under Serving. To allow only some sites, or to use the engine’s own server (port 8080), add "cors_origins": ["https://your.site"] to the model’s config file.', type: 'toggle', value: false, recommended: false },
// Advanced
{ id: 'adv.rope', group: 'Advanced', label: 'RoPE scaling', effect: 'Extends context beyond training', type: 'number', value: 1, recommended: 1, advanced: true },
+3 -2
View File
@@ -3,6 +3,8 @@ import { spawn } from 'node:child_process';
import { probe, which } from './util.mjs';
import { classifyRunning, describeConnector, otherTunnelMessage, ownTunnelMessage } from './cloudflared-find.mjs';
/** A tunnel without a key is allowed (owner decision, 0.2.2); because the link is public the warning is stronger than the local one. */
export const NO_KEY_TUNNEL_WARNING = 'No API key is set, so anyone who finds this link can use your model from the internet. Create an API key to protect it.';
export function tunnelHostname(value) {
const hostname = String(value ?? '').trim().toLowerCase();
if (hostname.length > 253 || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(hostname)) throw new Error('Enter a hostname such as ai.example.com, without https:// or a path.');
@@ -13,7 +15,7 @@ export function createCloudflare({ origin, originUrl, hasKey, changed = () => {}
let value = { status: 'idle', mode: null, url: null, error: null };
const state = () => {
const info = binary ? connectorInfo() : null;
return { ...value, installed: !!binary, originUrl, connector: info ? { path: info.path, version: info.version, source: info.source, label: info.label, summary: describeConnector(info) } : null, external: other?.running ? externalState() : null };
return { ...value, warning: value.status === 'connected' && !hasKey() ? NO_KEY_TUNNEL_WARNING : null, installed: !!binary, originUrl, connector: info ? { path: info.path, version: info.version, source: info.source, label: info.label, summary: describeConnector(info) } : null, external: other?.running ? externalState() : null };
};
const update = patch => { value = { ...value, ...patch }; changed(state()); };
function externalState(token = '') {
@@ -52,7 +54,6 @@ export function createCloudflare({ origin, originUrl, hasKey, changed = () => {}
}
async function start(body) {
if (busy || ['starting', 'connected', 'stopping'].includes(value.status)) throw new Error('Stop the current tunnel before starting another.');
if (!hasKey()) throw new Error('Create a Strata API key before connecting Cloudflare.');
if (!['quick', 'domain'].includes(body.mode)) throw new Error('Choose a temporary URL or your own domain.');
const hostname = body.mode === 'domain' ? tunnelHostname(body.hostname) : null;
const token = body.mode === 'domain' ? String(body.token ?? '').trim() : '';
+2 -1
View File
@@ -10,6 +10,7 @@
* The first one that runs `cloudflared --version` and is new enough is used; the answer says which one and from where.
* Nothing here downloads or installs anything.
*/
import { utf8Script } from './powershell-utf8.mjs';
import { execFile } from 'node:child_process';
import { access, stat } from 'node:fs/promises';
import { constants } from 'node:fs';
@@ -134,7 +135,7 @@ export async function findRunningConnector({ platform = process.platform, exclud
const skip = new Set(exclude.filter(Number.isInteger));
let procs = [];
if (platform === 'win32') {
const out = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', "Get-CimInstance Win32_Process -Filter \"Name='cloudflared.exe'\" | ForEach-Object { \"$($_.ProcessId)`t$($_.CommandLine)\" }"]);
const out = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', utf8Script("Get-CimInstance Win32_Process -Filter \"Name='cloudflared.exe'\" | ForEach-Object { \"$($_.ProcessId)`t$($_.CommandLine)\" }")]);
procs = out.split(/\r?\n/).map(l => /^(\d+)\t(.*)$/.exec(l)).filter(Boolean).map(m => ({ pid: Number(m[1]), command: m[2] }));
if (!procs.length) { // no PowerShell answer: at least the pids
const list = await run('tasklist', ['/FI', 'IMAGENAME eq cloudflared.exe', '/NH', '/FO', 'CSV']);
+37
View File
@@ -207,6 +207,43 @@ export function createControl({ control = findControl(), env = process.env, run
if (result.json?.plan && Array.isArray(result.json.plan.issues)) return result.json.plan;
throw failure(result, 'The files could not be checked.');
},
/** What picking `to` as the models folder means (sizes, free space, which choices are possible, why not): nothing is changed. */
async planModelsMove(from, to) {
const result = await call(['models', 'move', to, ...(from ? ['--from', from] : []), '--plan', '--output', 'json'], { timeoutMs: 120_000 });
if (result.json?.plan) return result.json.plan;
throw failure(result, 'The folder could not be checked.');
},
/**
* Move the model files to `to` (copied, checked, then the old copies removed). `onEvent` gets the progress lines; a file at `cancelFile` stops it
* safely. Resolves with the summary (done / stopped, what moved, what is left); a failure is a ControlError whose `output` carries the details.
*/
async moveModels(from, to, { installDir = null, cancelFile = null, onEvent = null } = {}) {
const args = ['models', 'move', to, ...(from ? ['--from', from] : []), ...(installDir ? ['--install-dir', installDir] : []), ...(cancelFile ? ['--cancel-file', cancelFile] : []), '--output', 'jsonl'];
const result = await call(args, { timeoutMs: 24 * 3600_000, onLine: onEvent ?? (() => {}) });
if (result.json?.ok && result.json.event === 'result') return result.json;
const error = result.json?.error;
throw Object.assign(new ControlError(error?.message ?? (result.stderr.split(/\r?\n/).filter(Boolean).pop() || 'The models could not be moved.'), { exitCode: result.code, output: result.json }), { code: error?.code ?? 'move_failed', details: error?.details ?? '' });
},
/** A move of the models folder that the last run left unfinished (`strata models move --status`): null, or {state, from, to, bytes}. */
async interruptedModelsMove(dataFolder) {
const result = await call(['models', 'move', '--status', ...(dataFolder ? ['--from', dataFolder] : []), '--output', 'json'], { timeoutMs: 30_000 });
if (result.json?.ok) return result.json.interrupted ?? null;
throw failure(result, 'The models folder could not be checked.');
},
/** "Finish it": the same move again, from where it stopped. Same events and result as moveModels. */
async resumeModelsMove(dataFolder, { installDir = null, cancelFile = null, onEvent = null } = {}) {
const args = ['models', 'move', '--resume', ...(dataFolder ? ['--from', dataFolder] : []), ...(installDir ? ['--install-dir', installDir] : []), ...(cancelFile ? ['--cancel-file', cancelFile] : []), '--output', 'jsonl'];
const result = await call(args, { timeoutMs: 24 * 3600_000, onLine: onEvent ?? (() => {}) });
if (result.json?.ok && result.json.event === 'result') return result.json;
const error = result.json?.error;
throw Object.assign(new ControlError(error?.message ?? (result.stderr.split(/\r?\n/).filter(Boolean).pop() || 'The models could not be moved.'), { exitCode: result.code, output: result.json }), { code: error?.code ?? 'move_failed', details: error?.details ?? '' });
},
/** "Leave it": keep the old folder, remove the partial copies in the new one. */
async leaveModelsMove(dataFolder, { installDir = null } = {}) {
const result = await call(['models', 'move', '--leave', ...(dataFolder ? ['--from', dataFolder] : []), ...(installDir ? ['--install-dir', installDir] : []), '--output', 'json'], { timeoutMs: 24 * 3600_000 });
if (result.json?.ok) return result.json;
throw Object.assign(failure(result, 'The partial copies could not be removed.'), { code: result.json?.error?.code ?? 'leave_failed', details: result.json?.error?.details ?? '' });
},
async saveProfile(name, configPath) {
const listed = await call(['profile', 'list', '--output', 'json'], { timeoutMs: 20_000 });
const known = (listed.json?.profiles ?? []).some(p => p.id === name);
+10 -6
View File
@@ -56,6 +56,7 @@ export function watchedWindowsDialog(shell, args, { readyMs = 30_000, openMs = 2
return new Promise((resolve, reject) => {
const child = spawnProcess(shell, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
let stdout = '', ready = false, settled = false;
child.stdout?.setEncoding?.('utf8'); // a multi-byte letter of a Cyrillic folder must not be cut between two chunks
const end = (fn, value) => { if (settled) return; settled = true; clearTimeout(timer); try { child.kill(); } catch { /* gone */ } fn(value); };
const timer = setTimeout(() => end(resolve, ready ? { stdout: '' } : { noDialog: true }), readyMs);
const later = () => { clearTimeout(timer); ready = true; setTimeout(() => end(resolve, { stdout: '' }), openMs).unref?.(); };
@@ -181,13 +182,16 @@ export function chooserUnavailable(picked) {
return { message: picked.reason, code: 'folder-chooser-unavailable', details: picked.detail };
}
/** A typed path stands in for the dialog: it must be an absolute directory that exists. */
export async function typedFolder(value, { isAbsolute }) {
const text = typeof value === 'string' ? value.trim() : '';
/** A typed path stands in for the dialog: it must be an absolute directory that exists. `fileType` (a RegExp on the name) also lets a
* file of that kind through (the model page takes a folder or one .gguf file). Quotes around the path (Windows "Copy as path") go. */
export async function typedFolder(value, { isAbsolute, fileType = null }) {
let text = typeof value === 'string' ? value.trim() : '';
if (text.length > 1 && /^(["'])[\s\S]*\1$/.test(text)) text = text.slice(1, -1).trim();
if (!text || text.includes('\0')) return null;
if (!isAbsolute(text)) throw new Error('Enter the full path of the folder.');
if (!isAbsolute(text)) throw new Error(fileType ? 'Enter the full path of the folder or the file.' : 'Enter the full path of the folder.');
let info;
try { info = await stat(text); } catch { throw new Error('That folder does not exist.'); }
if (!info.isDirectory()) throw new Error('Choose a folder, not a file.');
try { info = await stat(text); } catch { throw new Error(fileType ? 'That folder or file does not exist.' : 'That folder does not exist.'); }
if (info.isFile() && fileType?.test(text)) return text;
if (!info.isDirectory()) throw new Error(fileType ? 'Choose a folder, or a .gguf file.' : 'Choose a folder, not a file.');
return cleanChosenPath(text);
}
+2 -1
View File
@@ -1,5 +1,6 @@
import {execFile} from 'node:child_process';
import {promisify} from 'node:util';
import {utf8Script} from './powershell-utf8.mjs';
const execute=promisify(execFile);
let cached=null;
export async function systemFonts({platform=process.platform,run=execute}={}){
@@ -11,7 +12,7 @@ export async function systemFonts({platform=process.platform,run=execute}={}){
const {stdout}=await read('/usr/sbin/system_profiler',['SPFontsDataType','-json','-detailLevel','mini']);
const collect=value=>{if(!value||typeof value!=='object')return;if(typeof value.family==='string')names.push(value.family);for(const nested of Object.values(value))if(typeof nested==='object')collect(nested);};collect(JSON.parse(stdout));
}else if(platform==='win32'){
const {stdout}=await read('powershell.exe',['-NoProfile','-NonInteractive','-Command','Add-Type -AssemblyName System.Drawing; (New-Object System.Drawing.Text.InstalledFontCollection).Families.Name | ConvertTo-Json -Compress']);
const {stdout}=await read('powershell.exe',['-NoProfile','-NonInteractive','-Command',utf8Script('Add-Type -AssemblyName System.Drawing; (New-Object System.Drawing.Text.InstalledFontCollection).Families.Name | ConvertTo-Json -Compress')]);
const result=JSON.parse(stdout.replace(/^\uFEFF/,''));names=Array.isArray(result)?result:[result];
}else{
const {stdout}=await read('fc-list',['-f','%{family}\n']);names=stdout.split(/[\n,]/);
+52
View File
@@ -0,0 +1,52 @@
/**
* The host's copy of the roles rules it needs to WRITE a model's settings (src/lib/gpu-roles.ts has the screen's, strata_control/gpu_roles.py the
* Terminal's and the server's; tests/data/gpu_roles_vectors.json is run against all three).
* Roles are { [card number]: 'main' | 'helper' | 'off' }. The engine takes the cards in stage order: Helpers, then the Main card LAST.
*/
export const ROLES = ['main', 'helper', 'off'];
const mem = c => Number(c.vram_gb) || 0;
const speed = c => (typeof c.speed === 'number' && c.speed > 0 ? c.speed : 0);
const nums = roles => Object.keys(roles).map(Number).sort((a, b) => a - b);
const onList = roles => nums(roles).filter(i => roles[i] !== 'off');
const shortName = c => { let n = String(c.name || `GPU ${c.index + 1}`); for (const j of ['NVIDIA GeForce ', 'NVIDIA ', 'GeForce ', 'AMD Radeon ', 'AMD ', 'Intel(R) ', 'Intel ']) if (n.startsWith(j)) { n = n.slice(j.length); break; } return n.trim(); };
export function parseSaved(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const out = {};
for (const [k, v] of Object.entries(value)) {
if (!/^\d+$/.test(k.trim()) || !ROLES.includes(String(v).toLowerCase())) return null;
out[Number(k)] = String(v).toLowerCase();
}
if (!Object.keys(out).length || Object.values(out).filter(r => r === 'main').length > 1) return null;
return out;
}
export function normalize(roles, cards) {
const cs = new Map(cards.map(c => [c.index, c]));
const out = {};
for (const i of nums(roles)) if (cs.has(i)) out[i] = ROLES.includes(roles[i]) ? roles[i] : 'off';
const list = onList(out), mains = list.filter(i => out[i] === 'main');
if (list.length && !mains.length) {
const best = [...list].sort((a, b) => speed(cs.get(b)) - speed(cs.get(a)) || Math.round(mem(cs.get(b))) - Math.round(mem(cs.get(a))) || a - b)[0];
out[best] = 'main';
}
for (const extra of mains.slice(1)) out[extra] = 'helper';
if (list.length === 1) out[list[0]] = 'main';
return out;
}
export function order(roles, cards) {
const cs = new Map(cards.map(c => [c.index, c]));
const r = normalize(roles, cards);
const helpers = nums(r).filter(i => r[i] === 'helper').sort((a, b) => speed(cs.get(a)) - speed(cs.get(b)) || a - b);
return [...helpers, ...nums(r).filter(i => r[i] === 'main')];
}
/** "gpu_roles" plus the legacy mirror ("gpu" Main last, "gpu_order": "as_given"); nothing on: "gpu": [] and "gpu_none": true. */
export function mirror(roles, cards) {
const r = normalize(roles, cards), lst = order(r, cards);
const out = { gpu_roles: Object.fromEntries(nums(r).map(i => [String(i), r[i]])), gpu: [], gpu_cards: {} };
if (lst.length) { out.gpu = lst.length === 1 ? lst[0] : lst; out.gpu_order = 'as_given'; } else out.gpu_none = true;
for (const c of cards) if (c.index in r) out.gpu_cards[String(c.index)] = [shortName(c), c.bus ?? ''].filter(Boolean).join('|');
return out;
}
+3 -2
View File
@@ -11,6 +11,7 @@ import { parseCpuTopology, parseWindowsProcessors, parseNvidiaDevices, detectDrm
import { CONTEXT_TOKENS, VARIANTS, footprint } from './catalog.mjs';
import { MODELS_DIR, diskFreeGb, diskTotalGb } from './store.mjs';
import { probe, round } from './util.mjs';
import { utf8Script } from './powershell-utf8.mjs';
let cached = null;
let cachedAt = 0;
@@ -126,7 +127,7 @@ async function detectCpu(platform) {
}}catch{}
processors=parseCpuTopology(raw,model,process.arch,ghz,names);
} else if(platform==='win32') {
const raw=await probe('powershell.exe',['-NoProfile','-NonInteractive','-Command','Get-CimInstance Win32_Processor | Select-Object DeviceID,SocketDesignation,Name,NumberOfCores,NumberOfLogicalProcessors,MaxClockSpeed | ConvertTo-Json -Compress']);
const raw=await probe('powershell.exe',['-NoProfile','-NonInteractive','-Command',utf8Script('Get-CimInstance Win32_Processor | Select-Object DeviceID,SocketDesignation,Name,NumberOfCores,NumberOfLogicalProcessors,MaxClockSpeed | ConvertTo-Json -Compress')]);
processors=parseWindowsProcessors(raw,process.arch);
}
const names=[...new Set(processors.map(p=>p.model))];
@@ -185,7 +186,7 @@ async function detectGpus(platform, totalGb) {
}
}
if(platform==='win32') {
const raw=await probe('powershell.exe',['-NoProfile','-NonInteractive','-Command','Get-CimInstance Win32_VideoController | Select-Object Name,PNPDeviceID | ConvertTo-Json -Compress']);
const raw=await probe('powershell.exe',['-NoProfile','-NonInteractive','-Command',utf8Script('Get-CimInstance Win32_VideoController | Select-Object Name,PNPDeviceID | ConvertTo-Json -Compress')]);
try {const parsed=JSON.parse(raw);for(const row of Array.isArray(parsed)?parsed:[parsed]) {
if(!row?.Name||(/nvidia/i.test(row.Name)&&gpus.some(g=>g.vendor==='nvidia')))continue;
const vendor=/amd|radeon/i.test(row.Name)?'amd':/intel/i.test(row.Name)?'intel':'unknown';
+49 -13
View File
@@ -44,7 +44,7 @@ import * as serving from './serving.mjs';
import * as store from './store.mjs';
import * as supervisor from './supervisor.mjs';
import * as engineInstall from './engine-install.mjs';
import { detectBackend, currentBackend, streamChat } from './inference.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';
@@ -58,6 +58,7 @@ import { createForkTimeskip } from './timeskip-fork.mjs';
import { createChatFolders, chatRootOf, openFile, resolveChatFile, previewFile, rawFile, createAttachments, withAttachments } from './chat-files.mjs';
import { createLlmProxy, samplingFromExpert } from './agent/llm-shape.mjs';
import { checkUpdates, checkComponentUpdates } from './updates.mjs';
import { plainNoModel } from './no-model-text.mjs';
import { createAppUi } from './app-ui.mjs';
import { appVersionFrom, appVersionToShow } from './app-version.mjs';
import { createImageModels } from './image-models.mjs';
@@ -71,6 +72,7 @@ import { randomBytes, timingSafeEqual } from 'node:crypto';
import { nativeGateway } from './native-gateway.mjs';
import { proxyCompletion } from './inference.mjs';
import { apiPath } from './api-paths.mjs';
import { findFreePort, listenProblem } from './ports.mjs';
import { validateChatImages } from './chat-images.mjs';
import { createTimeSkip, measureFolder, timeskipRevisionMessage, imageTypeOf } from './timeskip.mjs';
@@ -126,6 +128,16 @@ const bundledEngine = control && !process.env.STRATA_NO_BUNDLED_ENGINE
: Promise.resolve({ action: 'none' });
let bundledEngineSettled = !(control && !process.env.STRATA_NO_BUNDLED_ENGINE);
bundledEngine.then(() => { bundledEngineSettled = true; }, () => { bundledEngineSettled = true; });
/* A build made for another make of graphics card is not unpacked; Control's answer then carries the plain sentence (with the right download) instead of its own. */
let bundledWrongMake = null;
bundledEngine.then(result => { if (result?.action === 'wrong-make') { bundledWrongMake = result.notice; console.log(result.notice); } }, () => {});
if (control) {
const engineOf = control.engine.bind(control);
control.engine = async (...args) => {
const answer = await engineOf(...args);
return answer && !answer.usable && bundledWrongMake ? { ...answer, notice: bundledWrongMake, wrongMake: true } : answer;
};
}
const native=createNativeControl({control,ready:bundledEngine,appUpdater,hardwareProbe:()=>(hardware.cachedHardware()?.gpus??[]).map((g,n)=>({index:g.index??n,vram_gb:g.vramGb})),directory:store.DATA_DIR,modelsDirectory:()=>store.MODELS_DIR,nativeDataDirectory:store.nativeDataDirectory,beforeLoad:async()=>{if(models.REAL&&models.library().loaded){if(serving.state().requests.some(r=>runtimeModels.state().instances.some(i=>i.id===r.instanceId)&&["queued","generating"].includes(r.state)))throw new Error("Finish requests before switching engines.");await models.unload();runtimeState();}},requests:()=>serving.state().requests,changed:value=>hub.send('native',value)});
/* The agent: chats become DeepSeek Harness sessions when that component is installed. It is a separate
pinned component (STRATA_HARNESS_DIR), not part of this host, so its absence is a visible capability
@@ -240,7 +252,9 @@ const backend = await detectBackend({ force: true });
console.log(`strata host · http://${HOST}:${PORT}`);
console.log(`hardware · ${boot.accelerator.label} / ${boot.accelerator.backend} / ${boot.cpu.cores} cores / ${Math.round(boot.memory.totalGb)} GB`);
console.log(`inference · ${models.REAL ? (bundledEngineSettled ? backend.name : 'unpacking the bundled engine… (about a minute the first time)') : 'simulated (design iteration) — set STRATA_REAL_ENGINE=1 for a real engine'}`);
const printInference = engine => console.log(`inference · ${models.REAL ? inferenceLine({ backend, engine, settled: bundledEngineSettled }) : 'simulated (design iteration) — set STRATA_REAL_ENGINE=1 for a real engine'}`);
if (models.REAL && control && backend.kind === 'unavailable' && bundledEngineSettled) void control.engine().catch(() => null).then(printInference);
else printInference(null);
console.log(`models dir · ${store.MODELS_DIR}`);
console.log(`workspace · ${WORKSPACE}`);
@@ -256,7 +270,7 @@ function setupState() {
flowVersion: 2,
completedAt: s.setup.completedAt,
history: s.setup.history ?? [],
app: { version: appVersion(), modelsDir: store.MODELS_DIR, dataDir: store.DATA_DIR },
app: { version: appVersion(), modelsDir: store.MODELS_DIR, dataDir: store.DATA_DIR, modelsDirNotice: store.MODELS_DIR_NOTICE },
};
}
@@ -572,7 +586,7 @@ async function runAgentTurn({ res, controller, chatId, body, message, workspaceI
}
else {
try { instance = native.resolveInstance(body.nativeModelId ?? native.state().defaultId); }
catch (e) { write({ type: 'error', message: e.message }); return; }
catch (e) { write({ type: 'error', message: plainNoModel(e.message) }); return; }
choice = { instanceId: instance.id };
}
const started = Date.now();
@@ -604,7 +618,7 @@ async function runAgentTurn({ res, controller, chatId, body, message, workspaceI
async function runTurn({ message, history = [], onFrame, signal, client, workspaceId = null, sessionId = null, remoteModel = null, runtimeModelId = undefined, nativeModelId, images=[], useTools=false, thinking='medium', thinkingBudget=0 }) {
if (remoteModel) return runRemoteTurn({ selection: remoteModel, message, history, images, onFrame, signal, workspaceId });
if(native.hasLive()||nativeModelId){
let instance;try{instance=native.resolveInstance(nativeModelId??native.state().defaultId);}catch(e){onFrame({type:'error',message:e.message});return {error:e.message};}const started=Date.now(),cancel=new AbortController();
let instance;try{instance=native.resolveInstance(nativeModelId??native.state().defaultId);}catch(e){const message=plainNoModel(e.message);onFrame({type:'error',message});return {error:message};}const started=Date.now(),cancel=new AbortController();
const entry=serving.open({client:client??'Strata chat',endpoint:'/chat/completions',model:instance.apiModelId,instanceId:instance.id,gpuIds:instance.gpus.map(String),cancel:()=>cancel.abort()});
try{serving.mark(entry.id,{state:'generating'});const applied=selectSkills(skillState,message,workspaceId);const result=await proxyCompletion({baseUrl:`http://127.0.0.1:${instance.port}/v1`,modelId:instance.apiModelId,authorization:instance.key,message,history,images,extra:{strata_mcp:useTools===true,...(thinkingBudget>0&&String(thinking).toLowerCase()!=='off'?{reasoning_budget_tokens:thinkingBudget}:{}),reasoning_effort:({off:'none',max:'high'}[String(thinking).toLowerCase()]??String(thinking).toLowerCase())},systemPrompt:[SYSTEM_PROMPT,skillInstructions(applied)].filter(Boolean).join('\n\n'),signal:signal?AbortSignal.any([signal,cancel.signal]):cancel.signal,onFrame:frame=>onFrame({...frame,requestId:entry.id,model:instance.label,contextMax:instance.metrics?.engine?.max_context??instance.context,skills:applied.map(s=>s.name)})});serving.mark(entry.id,{...result,usageSource:result.usageSource});serving.finish(entry.id,{tokensOut:result.tokensOut,durationMs:Date.now()-started});return result;}catch(e){serving.finish(entry.id,{durationMs:Date.now()-started,error:e.message});onFrame({type:'error',message:e.message,requestId:entry.id});return {error:e.message};}
}
@@ -696,6 +710,7 @@ async function runTurn({ message, history = [], onFrame, signal, client, workspa
durationMs: Date.now() - started,
ttftMs: firstTokenMs ?? undefined,
cachedTokens: result.cachedTokens,
tps: result.tps,
usageSource: result.usageSource,
});
if(models.REAL){monitor.setRecordedContext({ ...result, requestId: entry.id, sessionId });monitor.idle();}
@@ -793,7 +808,7 @@ function createTuning({ repoRoot, python, pythonArgs = [], env = process.env } =
function runCli(args) {
return new Promise((resolve) => {
const child = spawn(python, [...pythonArgs, cli, ...args, '--output', 'json'], {
cwd: repoRoot, windowsHide: true, env: { ...env, STRATA_TUNE_DIR: stateDir() },
cwd: repoRoot, windowsHide: true, env: { ...env, PYTHONUTF8: '1', PYTHONIOENCODING: 'utf-8', STRATA_TUNE_DIR: stateDir() },
stdio: ['ignore', 'pipe', 'pipe'],
});
let out = '', err = '';
@@ -895,8 +910,10 @@ const tasks = createTaskService({
const taskTimer = setInterval(() => tasks.tick(), 15000);
taskTimer.unref();
const localShare = createLocalShare({ handle: openai, hasKey: () => !!store.get().serving?.apiKey, port: Number(process.env.STRATA_SHARE_PORT ?? PORT + 1), changed: () => serving.setSharing(localShare.state()) });
const tunnelPort = Number(process.env.STRATA_TUNNEL_PORT ?? PORT + 2);
// The share and tunnel listeners take a free port (the next ones above the host's own) instead of a fixed PORT+1 / PORT+2 that Docker, WSL or Hyper-V may hold or reserve.
const sharePort = await findFreePort(Number(process.env.STRATA_SHARE_PORT ?? PORT + 1), '0.0.0.0', { skip: [PORT] });
const localShare = createLocalShare({ handle: openai, hasKey: () => !!store.get().serving?.apiKey, port: sharePort, changed: () => serving.setSharing(localShare.state()) });
const tunnelPort = await findFreePort(Number(process.env.STRATA_TUNNEL_PORT ?? PORT + 2), '127.0.0.1', { skip: [PORT, sharePort] });
const tunnelOrigin = createLocalShare({ handle: openai, hasKey: () => !!store.get().serving?.apiKey, host: '127.0.0.1', port: tunnelPort });
const connector = createConnectorInstaller({ directory: join(store.HOME, 'engine', 'cloudflare'), componentDirectory: join(componentsDirectory(), 'cloudflared') });
const cloudflare = createCloudflare({ origin: tunnelOrigin, originUrl: `http://127.0.0.1:${tunnelPort}`, hasKey: () => !!store.get().serving?.apiKey, changed: serving.setTunnel, locate: connector.locate, prepareConnector: connector.ensure, connectorInfo: connector.connector, findRunning: findRunningConnector });
@@ -949,6 +966,17 @@ async function api(req, res, path, method, url) {
if(path==='/api/native'&&method==='GET')return json(res,200,await native.refresh(url.searchParams.get('refresh')==='1'));
if(path==='/api/native'&&method==='PATCH')return json(res,200,await native.configure(await readBody(req)));
if(path==='/api/native/directory'&&method==='POST')return json(res,200,native.setDownloadDirectory((await readBody(req)).directory));
/* Settings > Storage > Models folder: pick (or type) a folder, say what it means, then start the choice. Copying is Control's (strata models move). */
if(path==='/api/native/models-folder/plan'&&method==='POST'){
const typed=await typedFolder((await readBody(req)).path,{isAbsolute});
const picked=typed?{path:typed}:await chooseFolder('Choose a folder for your models');
if(picked.unavailable){const u=chooserUnavailable(picked);return error(res,501,u.message,{code:u.code});}
if(picked.cancelled)return json(res,200,{cancelled:true});
return json(res,200,{plan:await native.planModelsFolder(picked.path)});}
if(path==='/api/native/models-folder/start'&&method==='POST'){const body=await readBody(req);const result=await native.startModelsFolder({folder:body.path,choice:body.choice,stopRunning:body.stopRunning===true});return json(res,result.id?202:200,result);}
if(path==='/api/native/models-folder/interrupted'&&method==='GET')return json(res,200,{interrupted:await native.interruptedMove()});
if(path==='/api/native/models-folder/resume'&&method==='POST'){const body=await readBody(req);return json(res,202,await native.finishInterruptedMove({stopRunning:body.stopRunning===true}));}
if(path==='/api/native/models-folder/leave'&&method==='POST')return json(res,200,await native.leaveInterruptedMove());
if(path==='/api/native/plan'&&method==='POST')return json(res,200,await native.plan(await readBody(req)));
if(path==='/api/native/operations'&&method==='POST'){if(storageChanging)return error(res,409,'Wait for the model folder check to finish.');const body=await readBody(req);return json(res,202,native.operation(body.kind,body.args));}
if(path==='/api/native/logs'&&method==='GET')return json(res,200,{logs:native.logs()});
@@ -966,7 +994,7 @@ async function api(req, res, path, method, url) {
return json(res,200,{path:file});}
if(path==='/api/native/local/preview'&&method==='POST'){
/* "Use a local GGUF": pick (or type) the folder, and Control says what is in it. Nothing is copied or changed. */
const typed=await typedFolder((await readBody(req)).path,{isAbsolute});
const typed=await typedFolder((await readBody(req)).path,{isAbsolute,fileType:/\.gguf$/i});
const picked=typed?{path:typed}:await chooseFolder('Choose the folder that holds your GGUF files');
if(picked.unavailable){const u=chooserUnavailable(picked);return error(res,501,u.message,{code:u.code});};
if(picked.cancelled)return json(res,200,{cancelled:true});
@@ -993,7 +1021,7 @@ async function api(req, res, path, method, url) {
if(nativeSettingsFile&&method==='POST')return json(res,200,await native.openSettingsFile(nativeSettingsFile[1]));
// Credentials are handled only on the host. Responses and events expose a key-set flag.
return error(res,404,'Unknown native control action.');}catch(e){return error(res,/Finish|Unload|running|current operation/.test(e.message)?409:400,e.message);}}
return error(res,404,'Unknown native control action.');}catch(e){return error(res,/Finish|Unload|running|current operation/.test(e.message)?409:400,e.message,{...(e.code&&typeof e.code==='string'?{code:e.code}:{}),...(e.details?{details:String(e.details)}:{})});}}
if (path === '/api/model-providers' && method === 'GET') return json(res, 200, withPictureAnswers(modelProviders.list()));
if (path === '/api/model-providers/discover' && method === 'POST') {
@@ -1410,7 +1438,7 @@ async function api(req, res, path, method, url) {
if (modelMatch) {
const [, id, action] = modelMatch;
if (method === 'DELETE') {if(runtimeModels.hasVariant(id))return error(res,409,'Unload every instance of this model before deleting its files.');return json(res, 200, runtimeModels.libraryState(models.remove(id)));}
if (method === 'POST' && action === 'install') return json(res, 200, runtimeModels.libraryState(await models.install(id)));
if (method === 'POST' && action === 'install') { try { return json(res, 200, runtimeModels.libraryState(await models.install(id))); } catch (err) { return error(res, err.status ?? 500, err.message); } }
if (method === 'POST' && action === 'cancel') return json(res, 200, runtimeModels.libraryState(models.cancel(id)));
if (method === 'POST' && action === 'load') {
const hw = await hardware.detect();
@@ -1844,8 +1872,10 @@ async function api(req, res, path, method, url) {
try { return json(res, 200, agentTools.removeCustom(customToolRoute[1])); } catch (e) { return error(res, 400, e.message); }
}
if (path === '/api/agent/pending' && method === 'GET') {
const chat = new URL(req.url, 'http://x').searchParams.get('chatId') ?? '';
return json(res, 200, { pending: agent.pendingFor(chat) });
const params = new URL(req.url, 'http://x').searchParams;
// No chat named: every chat with an approval waiting (the task board lists them without opening each chat).
if (!params.has('chatId')) return json(res, 200, { chats: agent.pendingAll() });
return json(res, 200, { pending: agent.pendingFor(params.get('chatId') ?? '') });
}
/* Approve / Deny a tool the agent asked about. The answer reaches the Harness approval for exactly
that chat and call; a stale or foreign id is refused. */
@@ -2146,6 +2176,12 @@ async function staticFile(req, res, path) {
/* ------------------------------------------------------------------ start --- */
server.on('error', error => {
if (server.listening) { console.error(`[strata] ${error.message}`); return; }
// a taken or Windows-reserved port: one plain sentence instead of a stack trace
console.error(`[strata] ${listenProblem(error, PORT, HOST)}`);
process.exit(1);
});
server.listen(PORT, HOST, () => {
if (process.send) process.send({ type: 'strata-ready', port: PORT });
// Engines a lost owner left behind (an earlier crash, an older version): ended after a PID + start time + command-line check.
+12
View File
@@ -523,3 +523,15 @@ async function demoCompletion({ message, modelLabel, onFrame, signal, engine, ca
onFrame({ type: 'done', tokensIn: promptTokens, tokensOut: emitted, tps, durationMs, demo: true, usageSource: 'demo' });
return { tokensIn: promptTokens, tokensOut: emitted, durationMs, tps, usageSource: 'demo' };
}
/**
* The start-up line "inference · ...". The worker that serves a model is only running once a model is loaded, so on a restart nothing answers
* at the worker's address even though the engine is installed and usable; the line says that instead of "No Strata engine".
* `engine` is Control's answer (null when there is no Control, as in a source checkout).
*/
export function inferenceLine({ backend, engine = null, settled = true }) {
if (!settled) return 'unpacking the bundled engine… (about a minute the first time)';
if (backend?.kind !== 'unavailable' || !engine) return backend?.name ?? 'No Strata engine';
if (engine.usable) return `Strata engine${engine.engine?.version ? ' ' + engine.engine.version : ''} is installed; no model is loaded yet`;
return engine.notice ? `No Strata engine for this PC. ${engine.notice}` : backend.name;
}
+5 -3
View File
@@ -2,14 +2,16 @@
import http from 'node:http';
import os from 'node:os';
import { apiPath } from './api-paths.mjs';
import { listenProblem } from './ports.mjs';
/** Sharing without a key is allowed (owner decision, 0.2.2): the person is told once, in plain words, and the app keeps offering to make a key. */
export const NO_KEY_WARNING = 'No API key is set, so any device on this network can use your model. Create an API key to protect it.';
export function createLocalShare({ handle, hasKey, changed = () => {}, host = '0.0.0.0', port = 8792 }) {
let listener = null, active = false;
const address = () => Object.values(os.networkInterfaces()).flat().find(n => n?.family === 'IPv4' && !n.internal)?.address ?? '127.0.0.1';
const state = () => ({ active, url: active ? `http://${host === '127.0.0.1' ? host : address()}:${listener.address().port}/v1` : null });
const state = () => ({ active, url: active ? `http://${host === '127.0.0.1' ? host : address()}:${listener.address().port}/v1` : null, warning: active && !hasKey() ? NO_KEY_WARNING : null });
async function start() {
if (active) return state();
if (listener) throw new Error('Sharing is already starting.');
if (!hasKey()) throw new Error('Set an API key before sharing with other devices.');
listener = http.createServer(async (req, res) => {
const path = apiPath(new URL(req.url ?? '/', 'http://localhost').pathname);
if (!['/v1/models','/v1/chat/completions','/v1/messages'].includes(path)) { res.writeHead(404); res.end('Not found'); return; }
@@ -17,7 +19,7 @@ export function createLocalShare({ handle, hasKey, changed = () => {}, host = '0
catch { if (!res.headersSent) res.writeHead(500); res.end('Request failed'); }
});
try { await new Promise((resolve,reject) => { listener.once('error',reject); listener.listen(port,host,resolve); }); }
catch(e) { listener.close(); listener=null; throw e; }
catch(e) { listener.close(); listener=null; throw new Error(listenProblem(e, port, host)); }
active=true; changed(); return state();
}
async function stop() {
+2 -1
View File
@@ -2,9 +2,10 @@ import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { posix, win32 } from 'node:path';
import { utf8Script } from './powershell-utf8.mjs';
/** Resolve the user's Documents folder without evaluating shell configuration. */
export function defaultModelDirectory({platform=process.platform,home=homedir(),env=process.env,read=readFileSync,windowsDocuments=()=>execFileSync('powershell.exe',['-NoProfile','-NonInteractive','-Command',"[Environment]::GetFolderPath('MyDocuments')"],{encoding:'utf8',timeout:5000,windowsHide:true}).trim()}={}) {
export function defaultModelDirectory({platform=process.platform,home=homedir(),env=process.env,read=readFileSync,windowsDocuments=()=>execFileSync('powershell.exe',['-NoProfile','-NonInteractive','-Command',utf8Script("[Environment]::GetFolderPath('MyDocuments')")],{encoding:'utf8',timeout:5000,windowsHide:true}).trim()}={}) {
const path=platform==='win32'?win32:posix;
if(env.STRATA_MODELS_DIR)return path.resolve(env.STRATA_MODELS_DIR);
let documents=env.STRATA_DOCUMENTS_DIR;
+112
View File
@@ -0,0 +1,112 @@
/**
* Where the person's models go: what a picked folder means (0.2.2).
*
* - A drive on its own (D:\ or /) is never filled with model files: it becomes D:\Strata (the data folder) and D:\Strata\Models (the
* model library), which the person is told.
* - The data folder is the person's own choice, so it is written into the settings file Strata Control reads (`data_dir`). Control's
* installer accepts a data folder that is that setting, whatever the folder is called; for a folder it has not been told about it wants a
* name starting with "Strata" (a rule against an AI tool filling an arbitrary folder, tools/strata_mcp.py check_data_dir).
* - The link that makes `<data>/models` and `<data>/Models` one folder is only needed where the file system tells them apart; where it cannot be
* made (exFAT, FAT, a network share) the models simply go in `<data>/models`.
*/
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join, parse, resolve } from 'node:path';
export const isDriveRoot = path => { const p = resolve(path); return p === parse(p).root; };
/** The data folder for a picked folder: a drive root gets a `Strata` folder of its own. `{ path, note }`. */
export function dataFolderFromPick(picked) {
const path = resolve(picked);
if (!isDriveRoot(path)) return { path, note: null };
const target = join(path, 'Strata');
return { path: target, note: `${path} is a whole drive, so the models go in ${target}.` };
}
/** The model library folder for a picked folder: a drive root gets `Strata/Models`. `{ path, note }`. */
export function libraryFolderFromPick(picked) {
const path = resolve(picked);
if (!isDriveRoot(path)) return { path, note: null };
const target = join(path, 'Strata', 'Models');
return { path: target, note: `${path} is a whole drive, so the models go in ${target}.` };
}
/** Make a folder the person asked for; a refusal of the disk is said in words. */
export function makeFolder(path) {
try { mkdirSync(path, { recursive: true }); }
catch (error) { throw new Error(`Strata could not create ${path} (${error.code === 'EACCES' || error.code === 'EPERM' ? 'access was denied' : error.code === 'ENOSPC' ? 'the drive is full' : error.message}). Choose another folder.`); }
return path;
}
/** `models` beside `Models`: a link where the two are different names. A disk that cannot hold the link gets a plain `models` folder instead. */
export function alignModels(data, library, { link = symlinkSync } = {}) {
const models = join(data, 'models');
if (existsSync(models)) return { data, linked: false };
try { link(process.platform === 'win32' ? library : 'Models', models, process.platform === 'win32' ? 'junction' : 'dir'); return { data, linked: true }; }
catch { /* exFAT, FAT, NFS, a share: no links there */ }
try { mkdirSync(models, { recursive: true }); } catch { /* the install says what it cannot write */ }
return { data, linked: false };
}
function readSettings(file) {
try { const parsed = JSON.parse(readFileSync(file, 'utf8').replace(/^/, '')); return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; } catch { return {}; }
}
/** The data folder a settings file names and when it was last changed (`data_dir_at`, seconds; the file's own time for a file that has none). */
export function readDataFolder(file) {
if (!file) return null;
const current = readSettings(file);
if (typeof current.data_dir !== 'string' || !current.data_dir) return null;
let at = Number(current.data_dir_at);
if (!Number.isFinite(at) || at <= 0) { try { at = statSync(file).mtimeMs / 1000; } catch { at = 0; } }
return { folder: current.data_dir, at };
}
/** The settings file the Terminal (command-line Strata) keeps: the same place setup.py and strata_control/discovery.py use. */
export function terminalSettingsFile(env = process.env, platform = process.platform) {
if (env.STRATA_TERMINAL_SETTINGS_FILE) return env.STRATA_TERMINAL_SETTINGS_FILE;
if (platform === 'win32') return join(env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'Strata', 'settings.json');
return join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'strata', 'settings.json');
}
/** Write the person's data folder into a settings file (merged, atomically) with the time of the change; returns whether it changed. Never
* throws: the folder still works for the app, only the Terminal and the installer's own check lose the shared setting. */
export function rememberDataFolder(file, folder, at = Date.now() / 1000, touch = false) {
if (!file || !folder) return false;
try {
const current = readSettings(file);
if (current.data_dir === folder && !touch) return false;
mkdirSync(dirname(file), { recursive: true });
const temp = `${file}.${process.pid}.tmp`;
writeFileSync(temp, JSON.stringify({ ...current, data_dir: folder, data_dir_at: at }, null, 1) + '\n', { mode: 0o600 });
renameSync(temp, file);
return true;
} catch { return false; }
}
const same = (a, b) => resolve(a).toLowerCase() === resolve(b).toLowerCase();
/**
* One models folder for the app and the Terminal. The app keeps its choice in its own settings file (`appFile`), the Terminal in `terminalFile`;
* whichever was changed last wins and the other file is brought up to date. Nothing is moved. A folder that is gone (a disk that is not
* connected) is never adopted. `app` is the app's own answer when its file names none (`{ folder, at }`).
* Returns `{ folder, from }`: the shared folder (null when neither names one) and `from` = 'terminal' when the app has to take over the
* Terminal's folder, 'app' when the Terminal's file was updated, null when nothing changed.
*/
export function syncDataFolder({ appFile, terminalFile, app = null, exists = path => existsSync(path) }) {
const mine = readDataFolder(appFile) ?? app;
const theirs = readDataFolder(terminalFile);
const theirsUsable = theirs && exists(theirs.folder);
if (!theirsUsable) {
if (mine && theirs && !same(mine.folder, theirs.folder)) { rememberDataFolder(terminalFile, mine.folder, mine.at); return { folder: mine.folder, from: 'app' }; }
return { folder: mine?.folder ?? null, from: null };
}
if (!mine || (!same(mine.folder, theirs.folder) && theirs.at > mine.at)) {
rememberDataFolder(appFile, theirs.folder, theirs.at);
return { folder: theirs.folder, from: 'terminal' };
}
if (!same(mine.folder, theirs.folder)) { rememberDataFolder(terminalFile, mine.folder, mine.at); return { folder: mine.folder, from: 'app' }; }
return { folder: mine.folder, from: null };
}
+11 -1
View File
@@ -30,6 +30,16 @@ export const PROVIDER_CATALOG = [
const PROTOCOLS = ['openai-chat', 'openai-responses', 'anthropic'];
const fail = message => { throw new Error(message); };
const text = (value, max = 160) => typeof value === 'string' ? value.trim().slice(0, max) : '';
/** The answer speed of a model API's reply: the tokens it reports over the time from its first streamed token to the end, never over the whole
* wait (that would put the time to the first token, often seconds of prompt reading, into the speed). null when it reported no tokens;
* with no usable first-token time (or under a quarter of a second) the whole time is the best there is. */
export function answerSpeed(tokensOut, durationMs, firstTokenMs) {
if (!Number.isFinite(tokensOut) || tokensOut <= 0) return null;
const decodeMs = Number.isFinite(firstTokenMs) ? durationMs - firstTokenMs : NaN;
const ms = decodeMs >= 250 ? decodeMs : durationMs;
return Number((tokensOut / Math.max(0.001, ms / 1000)).toFixed(1));
}
export function normalizeBaseUrl(value) {
let url; try { url = new URL(text(value, 2048)); } catch { fail('Enter a full http:// or https:// API address.'); }
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) fail('Use an HTTP or HTTPS base URL without credentials, a query, or a fragment.');
@@ -207,7 +217,7 @@ export function createModelProviders({ directory, changed = () => {}, fetchImpl
if (!textLength) fail('The model returned no text. Choose a model that supports text chat.');
} finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
const durationMs = Date.now() - started;
const result = { tokensIn, tokensOut, usageSource: tokensIn !== null && tokensOut !== null ? 'reported' : 'unavailable', durationMs, ttftMs: firstToken, tps: tokensOut !== null ? tokensOut / Math.max(0.001, durationMs / 1000) : null };
const result = { tokensIn, tokensOut, usageSource: tokensIn !== null && tokensOut !== null ? 'reported' : 'unavailable', durationMs, ttftMs: firstToken, tps: answerSpeed(tokensOut, durationMs, firstToken) };
onFrame({ type: 'done', ...result }); return result;
}
/** Connections WITH their keys, for the host's own agent bridge only; never serialised to a client. */
+10 -99
View File
@@ -1,14 +1,11 @@
/**
* Model library.
*
* Downloads are real: weights stream from Hugging Face into the configured model folder (Documents/Strata/Models by default)
* with progress, resume-by-restart and a size check. Loading starts a real
* engine process through the supervisor — no model files, no process, no load.
* With the real engine on, the app downloads models from the Models screen of the native engine, not from this list (see install). Loading
* starts a real engine process through the supervisor — no model files, no process, no load.
*/
import { createWriteStream, existsSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs';
import { existsSync, rmSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { CONTEXT_TOKENS, VARIANTS, downloadUrl, footprint, localModels, refreshLocalCatalog, variant } from './catalog.mjs';
@@ -140,6 +137,12 @@ function emit() {
/* ---------------------------------------------------------------- download --- */
/**
* With the real engine on, models come from the Models screen of the native engine (its installer pins every file by hash). This
* starter list names files by name only and has no hash, so it is never downloaded: the route refuses before any request is made.
*/
export const LIST_DOWNLOAD_REFUSED = "The app downloads models from the Models screen of the native engine, not from this list.";
export async function install(id) {
const v = variant(id);
if (!v) throw new Error(`Unknown model ${id}`);
@@ -153,99 +156,7 @@ export async function install(id) {
if (downloads.has(id)) return library();
if (!REAL) return simulateInstall(v);
const dir = join(MODELS_DIR, v.id);
mkdirSync(dir, { recursive: true });
const finalPath = pathFor(v);
const partPath = `${finalPath}.part`;
const controller = new AbortController();
downloads.set(id, controller);
const startedAt = Date.now();
runtime.set(id, {
state: 'downloading',
progress: { pct: 0, mbps: 0, etaSec: 0, stage: `Fetching ${v.source.file}` },
});
emit();
hubRef?.send('log', { level: 'info', message: `Downloading ${v.label} from ${v.source.repo}`, at: nowIso() });
// Keep the UI fed even while the socket is quiet.
const ticker = setInterval(() => {
const entry = runtime.get(id);
if (!entry || entry.state !== 'downloading') return;
emit();
}, 400);
try {
const res = await fetch(downloadUrl(v), { signal: controller.signal, redirect: 'follow' });
if (!res.ok || !res.body) throw new Error(`Download failed (${res.status} ${res.statusText})`);
const total = Number(res.headers.get('content-length')) || v.source.bytes;
let received = 0;
let lastEmit = 0;
const progressStream = new ReadableStream({
start(controllerStream) {
const reader = res.body.getReader();
const pump = async () => {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
received += value.byteLength;
controllerStream.enqueue(value);
const now = Date.now();
if (now - lastEmit > 350) {
lastEmit = now;
const seconds = (now - startedAt) / 1000;
const mbps = received / 1024 ** 2 / Math.max(seconds, 0.001);
runtime.set(id, {
state: 'downloading',
progress: {
pct: (received / total) * 100,
mbps,
etaSec: mbps > 0 ? (total - received) / 1024 ** 2 / mbps : 0,
stage: received / total < 0.9 ? `Fetching ${v.source.file}` : 'Verifying and finalising',
},
});
}
}
controllerStream.close();
};
void pump().catch((err) => controllerStream.error(err));
},
});
await pipeline(Readable.fromWeb(progressStream), createWriteStream(partPath));
renameSync(partPath, finalPath);
const size = statSync(finalPath).size;
if (size < v.source.bytes * 0.9) {
throw new Error(`File is smaller than expected (${(size / 1e9).toFixed(2)} GB of ${(v.source.bytes / 1e9).toFixed(2)} GB)`);
}
const s = getStore();
update({
installed: { ...(s.installed ?? {}), [id]: { installedAt: nowIso(), bytes: size } },
current: s.current ?? id,
});
runtime.set(id, { state: 'installed' });
hubRef?.send('log', { level: 'info', message: `${v.label} installed (${(size / 1e9).toFixed(2)} GB)`, at: nowIso() });
} catch (err) {
const aborted = err?.name === 'AbortError';
try {
if (existsSync(partPath)) rmSync(partPath);
} catch {
/* ignore */
}
runtime.set(id, aborted ? { state: 'available' } : { state: 'error', error: err.message });
if (!aborted) hubRef?.send('log', { level: 'error', message: `Download failed: ${err.message}`, at: nowIso() });
} finally {
clearInterval(ticker);
downloads.delete(id);
emit();
}
return library();
throw Object.assign(new Error(LIST_DOWNLOAD_REFUSED), { status: 409 });
}
export function cancel(id) {
+5 -3
View File
@@ -347,12 +347,14 @@ export function idle(label = 'Waiting for a request') {
hubRef?.send('monitor', snapshot());
}
export function record({ tokensIn, tokensOut, durationMs = 0, ttftMs = null, cachedTokens = null }) {
export function record({ tokensIn, tokensOut, durationMs = 0, ttftMs = null, cachedTokens = null, tps: answerTps = null }) {
if (Number.isFinite(tokensIn)) state.totals.tokensIn += tokensIn;
if (Number.isFinite(tokensOut)) state.totals.tokensOut += tokensOut;
state.totals.requests += 1;
if (Number.isFinite(tokensOut) && durationMs > 0) {
const tps = tokensOut / (durationMs / 1000);
// The average is of answer speeds (tokens over decode time). A request with no decode figure is left out: dividing by the whole request would
// put the prompt reading into it and read lower than the engine and the chat do.
if (Number.isFinite(answerTps) && answerTps > 0) {
const tps = answerTps;
const n = ++state.totals.usageRequests;
state.totals.avgTps = round((state.totals.avgTps * (n - 1) + tps) / n, 1);
}
+141 -22
View File
@@ -2,23 +2,26 @@
import { spawn } from 'node:child_process';
import {chooseParallel,applyParallel,withoutParallel,FALLBACK_NOTE} from './native-parallel.mjs';
import { randomBytes, randomUUID, createHash } from 'node:crypto';
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, realpathSync, copyFileSync, renameSync, unlinkSync, chmodSync, statSync, accessSync, constants } from 'node:fs';
import { join, resolve, dirname, basename } from 'node:path';
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, realpathSync, copyFileSync, renameSync, rmSync, statfsSync, unlinkSync, chmodSync, statSync, accessSync, constants } from 'node:fs';
import { join, resolve, dirname, basename, sep, relative, isAbsolute } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createServer } from 'node:net';
import { NATIVE_FIELDS, validateNativeSettings, validateMcpServers, publicMcpServers, publicNativeEnv, restoreNativeEnv } from './native-settings.mjs';
import { resourceSettings, saferStart } from './native-resources.mjs';
import { NATIVE_FIELDS, APP_OWNED_FIELDS, withoutDraftLayer, validateNativeSettings, validateMcpServers, publicMcpServers, publicNativeEnv, restoreNativeEnv } from './native-settings.mjs';
import { resourceSettings, saferStart, reserveChoice } from './native-resources.mjs';
import { USER_KEY, capture as captureUserArgs, merge as mergeUserArgs, setFlag as setUserFlag, stamped as stampedUserArgs } from './native-user-args.mjs';
import { resolvePython, venvPython } from './python-resolver.mjs';
import os from 'node:os';
import { runtimeVision } from './native-vision.mjs';
import { componentsDirectory, installerEnvironment, controlEnvironment } from './control-runtime.mjs';
import { createEngineStep } from './engine-step.mjs';
import { dataFolderFromPick, isDriveRoot, makeFolder, rememberDataFolder, syncDataFolder, terminalSettingsFile } from './model-folder.mjs';
import { createAutoTune, writeTuneConfig } from './native-autotune.mjs';
import { plainError, PHASE_TEXT } from './update-messages.mjs';
import { checkRunConfig, describeIssues } from './run-config-check.mjs';
import { openTextFile } from './open-text-file.mjs';
import { markEngine, engineRefusal } from './engine-version.mjs';
import { friendlyModelName } from './model-names.mjs';
import { parseSaved as parseRoles, mirror as mirrorRoles } from './gpu-roles.mjs';
const ROOT=resolve(dirname(fileURLToPath(import.meta.url)),'..');
const safeId=value=>typeof value==='string'&&/^[a-z0-9_-]{1,80}$/i.test(value);
@@ -40,7 +43,7 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
updated or fetched from here. Without `control` (a source checkout) every path below is the original one. */
const packaged=!!control;
/* The auto-tuner and the saved tunings are Strata Control's (`strata tune auto`, `strata presets`): the app runs them and follows their status file. */
const autotuner=createAutoTune({spawnProcess,workDir:join(directory,'native-tune'),launcher:()=>{if(packaged)return {command:control.control.python,prefix:[control.control.cli],cwd:control.control.dir,env:controlEnvironment(control.control)};const py=python();return {command:py.command,prefix:[...py.args,join(repo(),'strata.py')],cwd:repo(),env:{...process.env,PYTHONUNBUFFERED:'1',PYTHONIOENCODING:'utf-8'}};}});
const autotuner=createAutoTune({spawnProcess,workDir:join(directory,'native-tune'),launcher:()=>{if(packaged)return {command:control.control.python,prefix:[control.control.cli],cwd:control.control.dir,env:controlEnvironment(control.control)};const py=python();return {command:py.command,prefix:[...py.args,join(repo(),'strata.py')],cwd:repo(),env:{...process.env,PYTHONUNBUFFERED:'1',PYTHONUTF8:'1',PYTHONIOENCODING:'utf-8'}};}});
/* In the packaged app the model catalog, the GPU probe and the install job are Control's bundled tools/strata_mcp.py and
setup.py, run on Control's own Python in the installer's mode (STRATA_CONTROL_INSTALL: no pip, no engine, no compiler).
The install only runs when the user clicks Download; starting and serving (control.start) keep STRATA_NO_ACQUIRE. */
@@ -50,10 +53,11 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
let defaultId=null,timer=null,closing=false,polling=false;
const emit=()=>{const value=state();writeFileSync(join(directory,'native-jobs.json'),JSON.stringify(value.jobs),{mode:0o600});changed(value);};
const append=(instanceId,line)=>{logs.push({at:new Date().toISOString(),instanceId,line:String(line).slice(0,2000)});if(logs.length>400)logs.shift();};
function state(){return {...snapshot,downloadDirectory:preferences.downloadDirectory??(modelsDirectory?modelsDirectory():snapshot.installed?.data_folder),fields:nativeFields(),instances:[...instances.values()].map(({key,configPath,fingerprint,mcpServers,...i})=>structuredClone(i)),defaultId,jobs:[...jobs.values()].slice(-12).map(({process,abort,engineAbort,rawResult,...j})=>j),capabilities:{responses:sourceText().includes('/v1/responses'),updates:!!snapshot.available,rollback:previousEngine(),componentUpdates:packaged&&typeof control.update==='function',calibration:packaged?false:snapshot.hardware?.gpus?.some(g=>/nvidia/i.test(g.name??''))??false}};}
function diskSpace(path){try{const s=statfsSync(path);return {free:Number(s.bavail)*Number(s.bsize),total:Number(s.blocks)*Number(s.bsize)};}catch{return null;}}
function state(){const folder=preferences.downloadDirectory??(modelsDirectory?modelsDirectory():snapshot.installed?.data_folder),space=folder?diskSpace(folder):null;return {...snapshot,downloadDirectory:folder,downloadFreeBytes:space?.free??null,downloadTotalBytes:space?.total??null,fields:nativeFields(),instances:[...instances.values()].map(({key,configPath,fingerprint,mcpServers,...i})=>structuredClone(i)),defaultId,jobs:[...jobs.values()].slice(-12).map(({process,abort,engineAbort,rawResult,...j})=>j),capabilities:{responses:sourceText().includes('/v1/responses'),updates:!!snapshot.available,rollback:previousEngine(),componentUpdates:packaged&&typeof control.update==='function',calibration:packaged?false:snapshot.hardware?.gpus?.some(g=>/nvidia/i.test(g.name??''))??false}};}
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:field.id.startsWith('vision_')||field.id.startsWith('mcp_')||field.id==='vram_reserve_mib'||source.includes(field.id)}));}
function nativeFields(){const source=sourceText();return NATIVE_FIELDS.map(field=>({...field,supported:APP_OWNED_FIELDS.includes(field.id)||field.id.startsWith('vision_')||field.id.startsWith('mcp_')||field.id==='vram_reserve_mib'||source.includes(field.id)}));}
const modelArena=id=>({arenaGb:(snapshot.catalog?.families??[]).flatMap(f=>f.sizes??[]).find(s=>s.id===id)?.experts_gb});
/* A start that ends with `server_exited` carries the engine's last log line with it; the app turns the code into words and
keeps this text under "Details" (src/lib/plain-errors.ts). */
@@ -69,8 +73,12 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
for(const name of names){const path=join(dir,name),config=readJson(path,null);if(!config||typeof config!=='object')continue;
const next={...config,...(config.cwd!==undefined?{cwd:fix(config.cwd)}:{}),...(Array.isArray(config.args)?{args:config.args.map(fix)}:{})};
if(JSON.stringify(next)!==JSON.stringify(config)){try{writeModelConfig(path,next);}catch{/* read-only folder: the load reports it */}}}}
function installedInfo(info){if(!info)return info;return {...info,models:(info.models??[]).map(entry=>{const cfg=/^strata-[a-z0-9_-]+\.json$/i.test(entry.config??'')?readJson(entry.configPath??join(repo(),entry.config)):{};const at=cfg.args?.indexOf('--native')??-1,nativeFile=at>=0?cfg.args[at+1]:null;const dataFolder=typeof nativeFile==='string'&&basename(dirname(dirname(nativeFile)))==='models'?dirname(dirname(dirname(nativeFile))):info.data_folder;return {...entry,diskGb:entry.diskGb??entry.disk_gb,diskBytes:entry.diskBytes??entry.disk_bytes,sharedBytes:entry.sharedBytes??entry.shared_bytes,removableGb:entry.removableGb??entry.removable_gb,dataFolder,visionPrepared:!!cfg.vision,/* Picture reading is ON unless the person turned it off: no saved choice and not loaded is undefined, not false (owner rule: pictures on by default). */visionEnabled:cfg.ui_vision_enabled??(()=>{const running=[...instances.values()].find(i=>i.model===entry.model&&i.status==='ready');return running?!!running.vision:undefined;})(),visionDevice:cfg.ui_vision_device,resourceProfile:cfg.ui_resource_profile,lowRamMode:cfg.args?.includes('--resident-experts')?'resident':cfg.args?.includes('--mmap-experts')?'mmap':'auto'};})};}
function installedInfo(info){if(!info)return info;return {...info,models:(info.models??[]).map(entry=>{const cfg=/^strata-[a-z0-9_-]+\.json$/i.test(entry.config??'')?readJson(entry.configPath??join(repo(),entry.config)):{};const at=cfg.args?.indexOf('--native')??-1,nativeFile=at>=0?cfg.args[at+1]:null;const dataFolder=typeof nativeFile==='string'&&basename(dirname(dirname(nativeFile)))==='models'?dirname(dirname(dirname(nativeFile))):info.data_folder;/* A model the person pointed at their own GGUF folder (not <data>/models/<tag>): remembered, so a later load runs setup with that folder again instead of downloading the files (#3199). */const ownGgufDir=typeof nativeFile==='string'&&basename(dirname(dirname(nativeFile)))!=='models'&&existsSync(nativeFile)?dirname(nativeFile):undefined;return {...entry,ownGgufDir,diskGb:entry.diskGb??entry.disk_gb,diskBytes:entry.diskBytes??entry.disk_bytes,sharedBytes:entry.sharedBytes??entry.shared_bytes,removableGb:entry.removableGb??entry.removable_gb,dataFolder,visionPrepared:!!cfg.vision,/* The picture files are prepared when the run config has a vision section: the Models screen then loads straight away instead of running setup again (it used to read 'off' for every model, which sent every load of a picture-capable model through "Apply settings and load", and setup writes the arguments, the draft layer and the reader back each time). */images:cfg.vision?'yes':'off',draftLayer:cfg.mtp!==false,/* Picture reading is ON unless the person turned it off: no saved choice and not loaded is undefined, not false (owner rule: pictures on by default). */visionEnabled:cfg.ui_vision_enabled??(()=>{const running=[...instances.values()].find(i=>i.model===entry.model&&i.status==='ready');return running?!!running.vision:undefined;})(),visionDevice:cfg.ui_vision_device,resourceProfile:cfg.ui_resource_profile,lowRamMode:cfg.args?.includes('--resident-experts')?'resident':cfg.args?.includes('--mmap-experts')?'mmap':'auto'};})};}
function installRoot(){const found=[preferences.installDir].find(d=>d&&existsSync(d));if(found)return realpathSync(found);mkdirSync(defaultInstall(),{recursive:true});return realpathSync(defaultInstall());}
/* The settings file Strata Control and the installer read (control-runtime.mjs sets STRATA_SETTINGS_FILE to it): the app's data folder is written there. */
function controlSettingsFile(){return process.env.STRATA_SETTINGS_FILE||join(dirname(componentsDirectory()),'app-settings.json');}
/* One models folder for the app and the Terminal: the one changed last wins and the other settings file is updated (nothing is moved). */
function shareDataFolder(){if(!packaged)return;try{let at=0;if(preferences.downloadDirectory)try{at=statSync(settingsPath).mtimeMs/1000;}catch{/* no file yet */}const found=syncDataFolder({appFile:controlSettingsFile(),terminalFile:terminalSettingsFile(),app:preferences.downloadDirectory?{folder:preferences.downloadDirectory,at}:null});if(found.from==='terminal'&&found.folder&&found.folder!==preferences.downloadDirectory){preferences={...preferences,downloadDirectory:found.folder};writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});}}catch{/* the shared setting is a convenience: never stop the app */}}
function repo(){if(packaged)return installRoot();const path=realpathSync(preferences.repo);if(!existsSync(join(path,'setup.py'))||!existsSync(join(path,'tools/strata_mcp.py'))||!existsSync(join(path,'serve/server.py')))throw new Error('Choose the Strata repository folder containing setup.py and tools/strata_mcp.py.');return path;}
function python(){return resolvePython({repo:preferences.repo});}
function send(method,params,timeout=30000){return new Promise((resolve,reject)=>{const id=++seq;const alarm=setTimeout(()=>{pending.delete(id);reject(new Error('The native engine did not respond in time. Check engine logs.'));},timeout);pending.set(id,{resolve,reject,alarm});child.stdin.write(JSON.stringify({jsonrpc:'2.0',id,method,params})+'\n');});}
@@ -80,7 +88,7 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
const folder=repo();
buffer='';const py0=packaged?{command:control.control.python,args:[]}:python();
const script=packaged?join(control.control.dir,'tools/strata_mcp.py'):join(folder,'tools/strata_mcp.py');
const client=child=spawnProcess(py0.command,[...py0.args,script,...(packaged?['--root',folder,'--src',control.control.dir]:[])],{cwd:packaged?control.control.dir:folder,windowsHide:true,stdio:['pipe','pipe','pipe'],env:packaged?installerEnvironment(control.control,{installDir:folder}):{...process.env,PYTHONUNBUFFERED:'1',PYTHONIOENCODING:'utf-8'}});
const client=child=spawnProcess(py0.command,[...py0.args,script,...(packaged?['--root',folder,'--src',control.control.dir]:[])],{cwd:packaged?control.control.dir:folder,windowsHide:true,stdio:['pipe','pipe','pipe'],env:packaged?installerEnvironment(control.control,{installDir:folder}):{...process.env,PYTHONUNBUFFERED:'1',PYTHONUTF8:'1',PYTHONIOENCODING:'utf-8'}});
client.stdout.on('data',chunk=>{if(child!==client)return;buffer+=chunk.toString();let end;while((end=buffer.indexOf('\n'))>=0){const line=buffer.slice(0,end);buffer=buffer.slice(end+1);let message;try{message=JSON.parse(line);}catch{continue;}const p=pending.get(message.id);if(p){clearTimeout(p.alarm);pending.delete(message.id);message.error?p.reject(new Error(message.error.message)):p.resolve(message.result);}}});
child.stderr.on('data',chunk=>append('control',chunk.toString()));
client.on('error',error=>{if(child===client){snapshot.error=error.message;disconnect();}});
@@ -102,7 +110,7 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
const models=(list.models??[]).filter(m=>m.component_status!=='invalid'&&m.source_config).map(m=>({...(disk[m.id]??{}),model:m.id,config:basename(m.source_config),configPath:m.source_config,model_name:m.name,context:m.context_tokens??0,backend:m.backend,ready:m.component_status==='present'&&!(disk[m.id]?.partsMissing),images:'off',port:undefined,last_used:null}));
const cores=os.cpus().length;
return {summary:engine.usable?`Strata Control is ready; engine ${engine.engine.version} (${engine.engine.platform}-${engine.engine.backend}).`:(engine.notice??'No engine for this PC was found.'),
installDir:list.install_dir,engine:{usable:engine.usable,notice:engine.notice,version:engine.engine?.version??null,backend:engine.backend,componentsDir:engine.components_dir,previous:engine.previous??null,pinned:engine.pinned??null,updateNotice:engine.update_notice??null},
installDir:list.install_dir,engine:{usable:engine.usable,notice:engine.notice,wrongMake:engine.wrongMake===true,version:engine.engine?.version??null,backend:engine.backend,componentsDir:engine.components_dir,previous:engine.previous??null,pinned:engine.pinned??null,updateNotice:engine.update_notice??null},
installed:{strata_folder:list.install_dir,data_folder:null,engine:engine.engine?{version:engine.engine.version,backend:engine.engine.backend,folder:engine.engine.root}:{folder:null,version:null},models,usage:installer.installed?.usage??null},
hardware:installer.hardware?{...installer.hardware,cpu:{...(installer.hardware.cpu??{}),cores:installer.hardware.cpu?.cores??cores},gpus:installer.hardware.gpus??[]}:{cpu:{cores},ram_gb:Math.round(os.totalmem()/1024**3),gpus:[]},
recommendation:installer.recommendation??null,installerError:installer.error??null,install:installer.install??null,running:null};
@@ -120,7 +128,7 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
refreshing=(async()=>{try{const [catalog,status]=packaged?await (async()=>{const s=await tool('strata_status',{hardware:true});const c=await tool('strata_models').catch(error=>({families:[],contexts:[],recommendation:{family:null,model:null},context_note:'',error:error.message}));return [c,s];})():await Promise.all([tool('strata_models'),tool('strata_status',{hardware:true})]);let visionReader=false;if(packaged){try{const e=await control.engine();const root=e?.engine?.root??(e?.engine?.exe?dirname(e.engine.exe):'');visionReader=!!root&&['strata-vision','strata-vision.exe'].some(n=>existsSync(join(root,n)));}catch{visionReader=false;}}const markImages=c=>c&&Array.isArray(c.families)?{...c,families:c.families.map(f=>({...f,imagesCapable:!!f.images,images:!!f.images&&visionReader,sizes:(f.sizes??[]).map(s=>({...s,imagesCapable:!!s.images,images:!!s.images&&visionReader}))}))}:c;snapshot={available:true,repo:packaged?status.installDir:repo(),version:packaged?(control.version??null):await execute('git',['describe','--tags','--always'],repo()).then(s=>s.trim()).catch(()=>null),summary:status.summary,catalog:markEngine(packaged?markImages(catalog):catalog,status.engine),engineInfo:{version:status.engine?.version??null,source:status.engine?.source??null},visionReader:packaged?visionReader:true,installed:installedInfo(status.installed),hardware:status.hardware,error:null,install:status.install,external:status.running??null,...(packaged?{packaged:true,installDir:status.installDir,engine:status.engine,installerError:status.installerError}:{})};lastRead=Date.now();if(status.install?.running&&![...jobs.values()].some(j=>j.state==='running'))begin('install',j=>watchInstall(j,status.install));}catch(error){snapshot={...snapshot,available:false,error:error.message};lastRead=Date.now();}emit();return state();})().finally(()=>{refreshing=null;});const run=refreshing;if(!force&&snapshot.available){run.catch(()=>{});return state();}return run;
}
function configure({repository}){if(packaged){if(instances.size||[...jobs.values()].some(j=>j.state==='running'))throw new Error('Unload models and finish operations before changing the installation.');const next=realpathSync(String(repository));if(!statSync(next).isDirectory())throw new Error('Choose the folder that holds your Strata model configurations.');preferences={...preferences,installDir:next};packagedInstall=null;disconnect();writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});lastRead=0;return refresh(true);}if(instances.size||[...jobs.values()].some(j=>j.state==='running'))throw new Error('Unload models and finish operations before changing the installation.');const next=realpathSync(String(repository));const old=preferences;preferences={...preferences,repo:next};try{repo();}catch(e){preferences=old;throw e;}disconnect();writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});lastRead=0;return refresh(true);}
function setDownloadDirectory(input){if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish the current operation before changing the folder.');if(typeof input!=='string'||!input.trim()||/[\r\n\0]/.test(input))throw new Error('Choose a model folder.');const path=realpathSync(input.trim());if(!statSync(path).isDirectory())throw new Error('Choose a folder, not a file.');accessSync(path,constants.R_OK|constants.W_OK);preferences={...preferences,downloadDirectory:path};writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});emit();return state();}
function setDownloadDirectory(input,{insideJob=false}={}){if(!insideJob&&[...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish the current operation before changing the folder.');if(typeof input!=='string'||!input.trim()||/[\r\n\0]/.test(input))throw new Error('Choose a model folder.');let path;try{path=realpathSync(input.trim());}catch{throw new Error('That folder does not exist. Check the path, or that the drive is connected.');}if(!statSync(path).isDirectory())throw new Error('Choose a folder, not a file.');/* A whole drive (D:\) is not filled with models: D:\Strata is made and used. */if(isDriveRoot(path)){const own=dataFolderFromPick(path).path;makeFolder(own);path=realpathSync(own);}try{accessSync(path,constants.R_OK|constants.W_OK);}catch{throw new Error('Strata cannot write in that folder. Choose a folder you can save files in.');}preferences={...preferences,downloadDirectory:path};/* the same choice for the installer and the Terminal: the Control settings file */if(packaged)rememberDataFolder(controlSettingsFile(),path,Date.now()/1000,true);writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});shareDataFolder();emit();return state();}
function recordFile(model){if(!safeId(model))throw new Error('Choose an installed model.');const entry=snapshot.installed?.models?.find(i=>i.model===model);if(!entry)throw new Error('This model is not installed. Download and prepare it first.');const path=realpathSync(entry.configPath??join(repo(),entry.config));if(dirname(path)!==repo()||!/^strata-[a-z0-9_-]+\.json$/i.test(basename(path)))throw new Error('The model configuration is outside the Strata installation.');return {entry,path};}
function record(model){const {entry,path}=recordFile(model);return {entry,path,config:readJson(path)};}
/* The text file a power user edits by hand: where it is and what is wrong in it (run-config-check.mjs). */
@@ -129,10 +137,12 @@ 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 assertSettingsFile(path){const check=checkRunConfig(readFileSync(path,'utf8'));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(' ')}`);}
function settings(model){const {config}=record(model);const values=Object.fromEntries(NATIVE_FIELDS.map(f=>[f.id,config[f.id]??null]));if(typeof values.aliases==='string')values.aliases=values.aliases.split(',').map(v=>v.trim()).filter(Boolean);values.sampling=config.sampling??{};values.args=config.args??[];values.env=publicNativeEnv(config.env??{});return {model,values,mcp:publicMcpServers(config.mcp_servers??config.mcpServers??{}),restartNeeded:true};}
function save(model,{values,mcp}){if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish the current operation before saving native settings.');const {path,config}=record(model);if(busy(requests().filter(r=>instances.get(r.instanceId)?.model===model)))throw new Error('Finish this model’s requests before saving its settings.');const patch=validateNativeSettings(values??{}),next={...config};for(const field of nativeFields())if(field.supported===false&&patch[field.id]!=null&&JSON.stringify(patch[field.id])!==JSON.stringify(config[field.id]))throw new Error(`${field.label} needs a newer native Strata installation. Update it in Settings → About.`);for(const [key,value] of Object.entries(patch)){if(key==='env')next.env=restoreNativeEnv(value,config.env??{});else if(value===null)delete next[key];else next[key]=value;}if(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 save(model,{values,mcp}){if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish the current operation before saving native settings.');const {path,config}=record(model);if(busy(requests().filter(r=>instances.get(r.instanceId)?.model===model)))throw new Error('Finish this model’s requests before saving its settings.');const patch=validateNativeSettings(values??{}),next={...config};for(const field of nativeFields())if(field.supported===false&&patch[field.id]!=null&&JSON.stringify(patch[field.id])!==JSON.stringify(config[field.id]))throw new Error(`${field.label} needs a newer native Strata installation. Update it in Settings → About.`);for(const [key,value] of Object.entries(patch)){if(key==='env')next.env=restoreNativeEnv(value,config.env??{});else if(value===null)delete next[key];else next[key]=value;}if('vram_reserve_mib' in patch){const base=captureUserArgs(next);const mine=setUserFlag(base,'--vram-reserve-mib',patch.vram_reserve_mib);if(mine.length)next[USER_KEY]=mine;else delete next[USER_KEY];next.args=mine.length&&patch.vram_reserve_mib!==null?mergeUserArgs(next.args??[],mine):setUserFlag(next.args??[],'--vram-reserve-mib',null);delete next.ui_reserve_applied;if(Array.isArray(next.args_written))next.args_written=[...next.args];}if(mcp!==undefined){next.mcp_servers=validateMcpServers(mcp,config.mcp_servers??config.mcpServers??{});delete next.mcpServers;}copyFileSync(path,path+'.bak');chmodSync(path+'.bak',0o600);const temp=path+'.ui-tmp';writeFileSync(temp,JSON.stringify(next,null,2)+'\n',{mode:0o600});renameSync(temp,path);return settings(model);}
function previousEngine(){if(packaged)return !!snapshot.engine?.previous;try{const data=snapshot.installed?.data_dir??snapshot.installed?.data_folder;return [join(preferences.repo,'engine/.previous'),data&&join(data,'engine/.previous')].filter(Boolean).some(existsSync)&&readFileSync(join(preferences.repo,'setup.py'),'utf8').includes('--rollback-engine');}catch{return false;}}
async function plan(args){await refresh();if(!snapshot.available)throw new Error(snapshot.error);return tool('strata_install',installArgs(args,false));}
function installArgs(args,confirm){const allowed=['family','model','context','vision','kv','gpu','gpus','backend','low_ram','port','gguf_dir'];const size=snapshot.catalog?.families?.find(f=>f.family===args.family)?.sizes.find(s=>s.model===args.model);const existing=snapshot.installed?.models?.find(m=>m.model===size?.id);/* A model adopted from the person's own GGUF folder reports that folder's parent as its data folder: it is theirs, not Strata's, so a second preparation never uses it for the packs. */const data=nativeDataDirectory(args.data_dir??(args.gguf_dir?undefined:existing?.dataFolder)??preferences.downloadDirectory??modelsDirectory?.());return {...(data?{data_dir:data}:{}),...Object.fromEntries(allowed.filter(k=>args[k]!==undefined).map(k=>[k,args[k]])),/* Picture reading is only downloaded when it was asked for (Read pictures); the default stays the smaller download. */...(packaged&&args.vision===undefined?{vision:'no'}:{}),confirm};}
/* The folder the person picked in the first-run chooser can be the one that holds their GGUF files (or a folder inside it). It is theirs, not a Strata data folder: packs are never made in it, and a Strata folder around it (<Strata>/models/<tag>) is used instead (#3507, #3424). */
function ownFilesDataFolder(candidate,args){if(!candidate||args.data_dir!==undefined||!args.gguf_dir)return candidate;const same=(a,b)=>{const x=resolve(a),y=resolve(b);return process.platform==='win32'?x.toLowerCase()===y.toLowerCase():x===y;};const inside=(a,b)=>{const rel=relative(resolve(b),resolve(a));return rel===''||(!!rel&&!rel.startsWith('..')&&!isAbsolute(rel));};if(!same(candidate,args.gguf_dir)&&!inside(candidate,args.gguf_dir))return candidate;const models=dirname(resolve(args.gguf_dir)),top=dirname(models);return basename(models).toLowerCase()==='models'&&/strata/i.test(basename(top))?top:undefined;}
function installArgs(args,confirm){const allowed=['family','model','context','vision','kv','gpu','gpus','backend','low_ram','port','gguf_dir'];const size=snapshot.catalog?.families?.find(f=>f.family===args.family)?.sizes.find(s=>s.model===args.model);const existing=snapshot.installed?.models?.find(m=>m.model===size?.id);/* A model adopted from the person's own GGUF folder reports that folder's parent as its data folder: it is theirs, not Strata's, so a second preparation never uses it for the packs. */if(args.gguf_dir===undefined&&existing?.ownGgufDir)args={...args,gguf_dir:existing.ownGgufDir};const data=nativeDataDirectory(ownFilesDataFolder(args.data_dir??(args.gguf_dir?undefined:existing?.dataFolder)??preferences.downloadDirectory??modelsDirectory?.(),args));if(data&&packaged&&!args.data_dir)rememberDataFolder(controlSettingsFile(),data);return {...(data?{data_dir:data}:{}),...Object.fromEntries(allowed.filter(k=>args[k]!==undefined).map(k=>[k,args[k]])),/* One card is "gpu", never "gpus" (the installer wants two or more there: "Gpus='0' is not valid", #3203). */...(/^\d{1,2}$/.test(String(args.gpus??''))?{gpu:Number(args.gpus),gpus:undefined}:{}),/* Picture reading is only downloaded when it was asked for (Read pictures); the default stays the smaller download. */...(packaged&&args.vision===undefined?{vision:'no'}:{}),confirm};}
function begin(kind,run,metadata={}){if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish or cancel the current engine operation first.');const job={id:randomUUID(),kind,state:'running',startedAt:new Date().toISOString(),summary:'Starting…',result:null,error:null,...metadata};for(const [id,old] of jobs)if(jobs.size>=12&&old.state!=='running')jobs.delete(id);jobs.set(job.id,job);emit();void run(job).then(result=>{if(job.state==='running'){Object.assign(job,{state:'done',summary:result?.summary??'Complete',result,finishedAt:new Date().toISOString()});}}).catch(error=>{if(job.state==='running')Object.assign(job,{state:'error',error:error.message,summary:error.message,...(error.details?{details:error.details}:{}),finishedAt:new Date().toISOString()});}).finally(()=>{if(job.kind.startsWith('update')||job.kind==='rollback')disconnect();lastRead=0;emit();void refresh(true);});return state().jobs.find(j=>j.id===job.id);}
async function watchInstall(job,current){job.summary=current.step??'Resuming preparation status…';job.result={install:current};while(job.state==='running'){await new Promise(r=>setTimeout(r,1500));const result=await tool('strata_install');job.summary=result.summary;job.result=result;emit();if(!result.install?.running){if(result.install?.exit_code!==0)throw new Error(result.install?.error??'Preparation did not finish. Retry to reuse completed files.');return {summary:'Model preparation complete. Load it from Models.'};}}}
@@ -192,10 +202,24 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
function buildConfig(args,config){
const vision=runtimeVision(config,args.vision);
const visionPreferences=args.vision!==undefined?{ui_vision_enabled:args.vision!=='no',ui_vision_device:args.vision==='no'?config.ui_vision_device??'yes':args.vision}:{};
const resources=args.resourceProfile?resourceSettings({...config,vision},snapshot.hardware,args.resourceProfile,modelArena(args.model)):null;
/* The engine options the person set by hand (user_args, and what they wrote into "args" since the last write) go over everything the app generates, and an
explicit GPU-memory reserve (the Expert setting, user_args, a value they wrote) is never replaced by the safety default (#3215 #3270 #3216). */
const values0=args.values??{},cleared='vram_reserve_mib' in values0&&(values0.vram_reserve_mib===null||values0.vram_reserve_mib===''),base0=cleared?{...config,vram_reserve_mib:undefined,[USER_KEY]:setUserFlag(config[USER_KEY],'--vram-reserve-mib',null)}:config;
let mine=captureUserArgs(base0);const asked=cleared?null:reserveChoice({...base0,[USER_KEY]:mine},snapshot.hardware,values0).asked;
if(asked!==null)mine=setUserFlag(mine,'--vram-reserve-mib',asked);
const resources=args.resourceProfile?resourceSettings({...base0,[USER_KEY]:mine,vision},snapshot.hardware,args.resourceProfile,modelArena(args.model),values0):null;
const cfg={...config,...visionPreferences,...(resources?{...resources,ui_resource_profile:args.resourceProfile}:{}),host:'127.0.0.1',open_browser:false,...validateNativeSettings(args.values??{}),vision};
if(args.gpus!==undefined){const detected=new Set((snapshot.hardware?.gpus??[]).map(g=>g.index));if(!Array.isArray(args.gpus)||!args.gpus.length||new Set(args.gpus).size!==args.gpus.length||args.gpus.some(g=>!Number.isInteger(g)||!detected.has(g)))throw new Error('Choose distinct detected GPUs. CPU-only text inference is not supported by this native adapter.');cfg.gpu=args.gpus.length===1?args.gpus[0]:args.gpus;cfg.layer_split=args.gpus.length>1?validateNativeSettings({layer_split:args.layerSplit??'auto'}).layer_split:'auto';if(cfg.layer_split!=='auto'&&cfg.layer_split.split(',').length!==args.gpus.length-1)throw new Error('Choose one layer split point between each pair of GPUs.');}
if(cfg.vram_reserve_mib!==undefined&&cfg.vram_reserve_mib!==null){cfg.args=[...(cfg.args??[])];const at=cfg.args.indexOf('--vram-reserve-mib');if(at>=0)cfg.args.splice(at,2);cfg.args.push('--vram-reserve-mib',String(cfg.vram_reserve_mib));}if(cfg.vision){if(cfg.vision_tokens!=null)cfg.vision={...cfg.vision,max_tokens:cfg.vision_tokens};if(cfg.vision_min_tokens!=null)cfg.vision={...cfg.vision,min_tokens:cfg.vision_min_tokens};}cfg.mcp={...(cfg.mcp??{}),...(cfg.mcp_timeout_s!=null?{timeout_s:cfg.mcp_timeout_s}:{}),...(cfg.mcp_max_rounds!=null?{max_rounds:cfg.mcp_max_rounds}:{}),...(cfg.mcp_max_result_chars!=null?{max_result_chars:cfg.mcp_max_result_chars}:{})};
/* The app's suggested cards only fill a gap: a model whose settings file already names its GPUs ("gpu": [1, 0], with "gpu_order": "as_given") keeps them (#3256). A card the person picked in the screen always wins. */
const namedInFile=(Array.isArray(config.gpu)?config.gpu.length>0:config.gpu!==undefined&&config.gpu!==null&&config.gpu!=='')||!!config.gpu_roles;
/* Roles chosen in the screen (Main / Helper / Off per card, gpu-roles.mjs): the card list is the roles' mirror - Helpers first, the Main card LAST, Off cards left out - with "gpu_roles" beside it. Every card Off is allowed: the server says plainly what the engine can do. */
if(args.gpuRoles!==undefined){const make=snapshot.hardware?.engine_vendor,cards=(snapshot.hardware?.gpus??[]).filter(g=>!make||!g.vendor||g.vendor===make),named=parseRoles(args.gpuRoles);if(!named)throw new Error('Choose Main, Helper or Off for each graphics card, with one Main.');const known=new Set(cards.map(g=>g.index));if(Object.keys(named).some(k=>!known.has(Number(k))))throw new Error('Choose detected graphics cards.');const roles=Object.fromEntries(cards.map(g=>[g.index,named[g.index]??'off'])),m=mirrorRoles(roles,cards);for(const k of ['gpu','gpu_order','gpu_none','gpu_roles','gpu_cards','hip_ordinal'])delete cfg[k];Object.assign(cfg,m);const count=Array.isArray(m.gpu)?m.gpu.length:m.gpu_none?0:1;cfg.layer_split=count>1?validateNativeSettings({layer_split:args.layerSplit??'auto'}).layer_split:'auto';if(cfg.layer_split!=='auto'&&cfg.layer_split.split(',').length!==count-1)throw new Error('Choose one layer split point between each pair of GPUs.');}
else if(args.gpus!==undefined&&!(args.gpusFromRecommendation&&namedInFile)){const detected=new Set((snapshot.hardware?.gpus??[]).map(g=>g.index));if(!Array.isArray(args.gpus)||!args.gpus.length||new Set(args.gpus).size!==args.gpus.length||args.gpus.some(g=>!Number.isInteger(g)||!detected.has(g)))throw new Error('Choose distinct detected GPUs. CPU-only text inference is not supported by this native adapter.');cfg.gpu=args.gpus.length===1?args.gpus[0]:args.gpus;for(const k of ['gpu_roles','gpu_cards','gpu_none'])delete cfg[k];delete cfg.hip_ordinal;cfg.layer_split=args.gpus.length>1?validateNativeSettings({layer_split:args.layerSplit??'auto'}).layer_split:'auto';if(cfg.layer_split!=='auto'&&cfg.layer_split.split(',').length!==args.gpus.length-1)throw new Error('Choose one layer split point between each pair of GPUs.');}
if(cfg.vram_reserve_mib!==undefined&&cfg.vram_reserve_mib!==null){cfg.args=[...(cfg.args??[])];const at=cfg.args.indexOf('--vram-reserve-mib');if(at>=0)cfg.args.splice(at,2);cfg.args.push('--vram-reserve-mib',String(cfg.vram_reserve_mib));}
if(mine.length){cfg.args=mergeUserArgs(cfg.args??[],mine);cfg[USER_KEY]=mine;}else delete cfg[USER_KEY];
if(cfg.vision){if(cfg.vision_tokens!=null)cfg.vision={...cfg.vision,max_tokens:cfg.vision_tokens};if(cfg.vision_min_tokens!=null)cfg.vision={...cfg.vision,min_tokens:cfg.vision_min_tokens};}cfg.mcp={...(cfg.mcp??{}),...(cfg.mcp_timeout_s!=null?{timeout_s:cfg.mcp_timeout_s}:{}),...(cfg.mcp_max_rounds!=null?{max_rounds:cfg.mcp_max_rounds}:{}),...(cfg.mcp_max_result_chars!=null?{max_result_chars:cfg.mcp_max_result_chars}:{})};
/* The model's own choices win over what setup wrote into the arguments: "Draft layer (MTP)" off leaves --mtp out of every start, and a picture reading that is off leaves --vision out (setup writes both again whenever it runs; the saved settings are applied here, on every load). */
if(cfg.mtp===false&&Array.isArray(cfg.args))cfg.args=withoutDraftLayer(cfg.args);
if(!cfg.vision&&Array.isArray(cfg.args))cfg.args=cfg.args.filter(a=>a!=='--vision');
for(const arg of cfg.args??[])if(arg.endsWith('.gguf')&&!existsSync(resolve(repo(),arg)))throw new Error('An engine argument names a missing GGUF. Restore the saved arguments or repair the files.');
/* The app's own choice of request slots (4, fewer where the card has no room) is part of the configuration, so a tune measures the engine a load will start: slots take VRAM from the expert cache. */
const parallelChoice=packaged?chooseParallel({config:cfg,values:args.values??{},hardware:snapshot.hardware?.gpus?.length?snapshot.hardware:{gpus:hardwareProbe()??[]}}):null;
@@ -258,14 +282,14 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
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);}}}
} 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:{...process.env,PYTHONUNBUFFERED:'1',PYTHONIOENCODING:'utf-8',STRATA_API_KEY:''}});
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:{...process.env,PYTHONUNBUFFERED:'1',PYTHONUTF8:'1',PYTHONIOENCODING:'utf-8',STRATA_API_KEY:''}});
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'){
if(instance.status==='error')throw new Error(instance.error);
try{const response=await fetcher(`http://127.0.0.1:${instance.port}/health`,{headers:{authorization:`Bearer ${key}`},signal:AbortSignal.timeout(2000)});if(job.state!=='running'){await unload(id);return;}if(response.ok){instance.status='ready';instance.phase='idle';if(resources||args.vision!==undefined)writeModelConfig(record(args.model).path,{...config,...(resources?{...resources,ui_resource_profile:args.resourceProfile}:{}),...visionPreferences});emit();ensurePoll();instance.parallelNote=parallelNote;return {summary:retried?`${instance.label} is ready, after a second try: ${retried.words}`:`${instance.label} is ready.${parallelNote?' '+parallelNote:''}`,instanceId:id};}}catch{}
try{const response=await fetcher(`http://127.0.0.1:${instance.port}/health`,{headers:{authorization:`Bearer ${key}`},signal:AbortSignal.timeout(2000)});if(job.state!=='running'){await unload(id);return;}if(response.ok){instance.status='ready';instance.phase='idle';if(resources||args.vision!==undefined||JSON.stringify(cfg[USER_KEY]??null)!==JSON.stringify(config[USER_KEY]??null))writeModelConfig(record(args.model).path,{...config,...(resources?{...resources,ui_resource_profile:args.resourceProfile}:{}),...visionPreferences,...(cfg[USER_KEY]?{[USER_KEY]:cfg[USER_KEY]}:{})});emit();ensurePoll();instance.parallelNote=parallelNote;return {summary:retried?`${instance.label} is ready, after a second try: ${retried.words}`:`${instance.label} is ready.${parallelNote?' '+parallelNote:''}`,instanceId:id};}}catch{}
await new Promise(resolve=>setTimeout(resolve,1200));
}
if(job.state!=='running'){await unload(id);return;}
@@ -285,7 +309,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
else if(job.kind==='update-engine'){if(!snapshot.installed?.models?.length)throw new Error('Prepare a model first. Its installer downloads the matching engine.');argv=[join(folder,'setup.py'),'--update'];}
else if(job.kind==='update-strata'){const status=await execute('git',['status','--porcelain'],folder);if(status.trim())throw new Error('This Strata checkout has local edits. Save or commit them before updating.');const remote=await execute('git',['remote','get-url','origin'],folder);if(!/^https:\/\/github\.com\/Niko1221\/Strata(?:\.git)?\s*$/.test(remote))throw new Error('The native installation must use the official Strata repository to update here.');await execute('git',['pull','--ff-only'],folder);argv=[join(folder,'setup.py'),'--update'];}
else throw new Error('Unsupported native operation.');
return new Promise((resolve,reject)=>{const proc=spawnProcess(command,[...prefix,...argv],{cwd:folder,windowsHide:true,detached:process.platform!=='win32',stdio:['ignore','pipe','pipe'],env:{...process.env,PYTHONUNBUFFERED:'1'}});job.process=proc;for(const stream of [proc.stdout,proc.stderr])stream.on('data',chunk=>{chunk.toString().split('\n').filter(Boolean).forEach(line=>append(job.id,line));job.summary=chunk.toString().trim().slice(-500);emit();});proc.on('error',reject);proc.on('exit',code=>{delete job.process;code===0?resolve({summary:job.kind==='calibrate'?'Calibration complete. Native settings were saved.':'Native installation updated.'}):reject(new Error(`Operation stopped (exit ${code}). Open diagnostics for details.`));});});
return new Promise((resolve,reject)=>{const proc=spawnProcess(command,[...prefix,...argv],{cwd:folder,windowsHide:true,detached:process.platform!=='win32',stdio:['ignore','pipe','pipe'],env:{...process.env,PYTHONUNBUFFERED:'1',PYTHONUTF8:'1',PYTHONIOENCODING:'utf-8'}});job.process=proc;for(const stream of [proc.stdout,proc.stderr])stream.on('data',chunk=>{chunk.toString().split('\n').filter(Boolean).forEach(line=>append(job.id,line));job.summary=chunk.toString().trim().slice(-500);emit();});proc.on('error',reject);proc.on('exit',code=>{delete job.process;code===0?resolve({summary:job.kind==='calibrate'?'Calibration complete. Native settings were saved.':'Native installation updated.'}):reject(new Error(`Operation stopped (exit ${code}). Open diagnostics for details.`));});});
}
/* Packaged app: engine and Control updates and the engine rollback go through Control's explicit update step (`strata update`,
`strata components rollback`), never through git, setup.py or a download in this process. The app itself is updated by the
@@ -318,7 +342,8 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
}
function updateSummary(done){const r=(done.results??[]).filter(x=>x.status==='updated').map(x=>x.component==='engine'?'The engine is updated and ready.':'Strata is updated. It takes effect the next time you open the app.');return r.length?r.join(' '):'Everything is already up to date.';}
function execute(cmd,args,cwd){return new Promise((resolve,reject)=>{let output='';const process=spawnProcess(cmd,args,{cwd,stdio:['ignore','pipe','pipe']});process.stdout.on('data',c=>{output+=c;if(output.length>100000)process.kill();});process.stderr.on('data',c=>append('update',c.toString()));process.on('error',reject);process.on('exit',code=>code===0?resolve(output):reject(new Error('Git could not update this installation. Open diagnostics.')));});}
function writeModelConfig(path,next){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);}
/* Every write of a model's run config keeps the person's options: those they wrote into "args" or "user_args" since the file was last written are read from the file itself, go over what the caller built, and "args_written" is refreshed. */
function writeModelConfig(path,next){const mine=mergeUserArgs(captureUserArgs(readJson(path)),next[USER_KEY]??[]);next=stampedUserArgs(next,mine);copyFileSync(path,path+'.bak');chmodSync(path+'.bak',0o600);const temp=path+'.ui-tmp';writeFileSync(temp,JSON.stringify(next,null,2)+'\n',{mode:0o600});renameSync(temp,path);}
async function tune(job,args){
await refresh(true);if(job.state!=='running')return;
const chosen=record(args.model);resourceSettings(chosen.config,snapshot.hardware,args.profile,modelArena(args.model));
@@ -358,10 +383,104 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
return autotuner.start(job,job.kind==='recheck'?'recheck':'tune',{configPath:path,presetId:args.presetId,mode:args.mode,use:args.use,goal:room?'room':'speed',leaveRam:room?20:0,allowSmallDifferences:args.allowSmallDifferences===true,name:args.name},{onProgress:p=>{job.summary=p.text??job.summary;emit();}});
}
function operation(kind,args={}){if(!['install','load','benchmark','tune','autotune','recheck','calibrate','update-engine','update-strata','rollback','remove'].includes(kind))throw new Error('Choose a supported native operation.');if(!snapshot.available)throw new Error(snapshot.error??'Connect the native Strata installation first.');if(kind==='install'||kind==='load'){const refusal=engineRefusal(snapshot.catalog,args.model,snapshot.engineInfo);if(refusal)throw new Error(refusal);}return begin(kind,job=>kind==='install'?install(job,args):kind==='load'?loadEngine(job,args):kind==='benchmark'?benchmark(job,args):kind==='tune'?tune(job,args):kind==='autotune'||kind==='recheck'?autotune(job,args):kind==='remove'?removeModel(job,args):program(job,args),{guided:args.guided===true,model:args.model,...(args.family?{family:args.family}:{})});}
async function cancel(id){const job=jobs.get(id);if(!job||job.state!=='running')throw new Error('This operation is no longer running.');if(job.kind==='install'){job.engineAbort?.abort();await tool('strata_install',{cancel:true});}else if(job.abort)job.abort.abort();else if(job.process){if(!['tune','calibrate','autotune','recheck'].includes(job.kind))throw new Error('File updates cannot be cancelled safely while files are being replaced.');job.state='cancelled';await stopProcess(job.process);}job.state='cancelled';job.summary=job.kind==='install'?'Stopped. Completed downloads are kept.':'Stopped.';emit();return state();}
/* ---- The models folder card (0.2.2): what a picked folder means, and the three choices. The copying is Control's (strata models move). ---- */
const currentModelsFolder=()=>preferences.downloadDirectory??(modelsDirectory?modelsDirectory():snapshot.installed?.data_folder)??null;
const moveFile=()=>join(directory,'models-move.cancel');
const folderWords=error=>({message:error.message,details:error.details||''});
/** A picked folder as the data folder: a whole drive (D:\) becomes D:\Strata, and the person is told (the models then live in D:\Strata\Models). */
function pickedModelsFolder(input){
if(typeof input!=='string'||!input.trim()||/[\r\n\0]/.test(input))throw new Error('Choose a models folder.');
const picked=resolve(input.trim()),target=dataFolderFromPick(picked);
return {picked,folder:target.path,wholeDrive:!!target.note,shown:target.note?join(target.path,'Models'):target.path};
}
async function planModelsFolder(input){
if(!packaged)throw new Error('Changing the models folder is part of the Strata app.');
const now=currentModelsFolder();if(!now)throw new Error('Strata is still reading your models folder. Try again in a moment.');
const pick=pickedModelsFolder(input),plan=await control.planModelsMove(now,pick.folder);
return {...plan,...pick,current:now,running:instances.size,busy:[...jobs.values()].some(j=>j.state==='running')};
}
/* "Start empty": the old models are no longer listed. Their run configs are moved (not deleted) into old-models beside the others; the model files are not touched. */
function setAsideConfigs(old){
const folder=repo(),kept=join(folder,'old-models'),aside=[];
const under=value=>typeof value==='string'&&[ 'models','packs','mtp' ].some(item=>{const base=join(old,item).toLowerCase();const v=value.toLowerCase();return v===base||v.startsWith(base+sep)||v.startsWith(base+'/');});
const scan=value=>Array.isArray(value)?value.some(scan):value&&typeof value==='object'?Object.values(value).some(scan):under(value);
for(const name of readdirSync(folder)){
if(!/^strata-[a-z0-9_-]+\.json$/i.test(name)||name.endsWith('.shared-settings.json'))continue;
if(!scan(readJson(join(folder,name))))continue;
mkdirSync(kept,{recursive:true});renameSync(join(folder,name),join(kept,name));aside.push(name);
}
return aside;
}
async function startModelsFolder({folder:input,choice,stopRunning=false}){
if(!packaged)throw new Error('Changing the models folder is part of the Strata app.');
if(!['move','keep','empty'].includes(choice))throw new Error('Choose what to do with your models.');
if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish or stop the download to change the folder.');
const plan=await planModelsFolder(input);
if(plan.issue)throw Object.assign(new Error(plan.issue.message),{details:plan.issue.details,code:plan.issue.code});
if(!plan.choices[choice].possible)throw Object.assign(new Error(plan.choices[choice].reason||'That choice is not possible here.'),{code:'not_possible'});
if(instances.size){
if(!stopRunning)throw Object.assign(new Error('A model is running. Stop it first, then change the folder.'),{code:'running'});
for(const id of [...instances.keys()])await unload(id);
}
const old=plan.current,target=plan.folder;
if(choice!=='move'){
makeFolder(target);
const aside=choice==='empty'?setAsideConfigs(old):[];
setDownloadDirectory(target);lastRead=0;await refresh(true);
return {done:true,choice,folder:target,oldFolder:old,setAside:aside};
}
return runModelsMove(old,target,(from,to,options)=>control.moveModels(from,to,options));
}
/* The move as a job (the progress dialog reads it). `run` is moveModels for a new move, resumeModelsMove for "Finish it". */
function runModelsMove(old,target,run){
rmSync(moveFile(),{force:true});
return begin('models-move',async job=>{
job.cancelFile=moveFile();job.summary='Starting…';
try{
const result=await run(old,target,{installDir:repo(),cancelFile:moveFile(),onEvent:event=>{
if(event.event!=='progress')return;
job.progress={done:event.done,total:event.total,file:event.file,group:event.group,index:event.index,count:event.count,secondsLeft:event.seconds_left||0,sameDrive:!!event.same_drive};
job.summary=`${Math.round(event.done/1e8)/10} of ${Math.round(event.total/1e8)/10} GB`;emit();}});
const finished={done:!!result.done,stopped:!!result.stopped,moved:result.moved??[],remaining:result.remaining??[],bytes:result.bytes_moved??0,total:result.total_bytes??0,from:old,to:target,files:result.files_moved??0,sameDrive:!!result.same_drive};
/* Only a whole move changes the folder the app uses: a stopped move leaves it where it was, and every model works where it is. */
if(result.done){setDownloadDirectory(target,{insideJob:true});finished.folder=target;}
job.progress=null;
return {summary:result.done?'Your models are moved.':'Moving stopped. Nothing was lost.',...finished};
}catch(error){
const left=error.output;
job.result={failed:true,code:error.code,moved:left?.moved??[],remaining:left?.remaining??[],bytes:left?.bytes_moved??0,total:left?.total_bytes??0,from:old,to:target};
job.progress=null;
throw error;
}finally{rmSync(moveFile(),{force:true});}
},{family:undefined});
}
/* "A move was interrupted. Finish it or leave it": the next start finds the unfinished move (Control keeps a note in the models folder in use). */
async function interruptedMove(){
if(!packaged)return null;
const now=currentModelsFolder();if(!now)return null;
if([...jobs.values()].some(j=>j.kind==='models-move'&&j.state==='running'))return null;
try{return await control.interruptedModelsMove(now);}catch{return null;}
}
async function finishInterruptedMove({stopRunning=false}={}){
const st=await interruptedMove();if(!st)throw new Error('No move was interrupted.');
if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish or stop the download to change the folder.');
if(instances.size){
if(!stopRunning)throw Object.assign(new Error('A model is running. Stop it first, then change the folder.'),{code:'running'});
for(const id of [...instances.keys()])await unload(id);
}
return runModelsMove(st.from,st.to,(from,to,options)=>control.resumeModelsMove(from,options));
}
async function leaveInterruptedMove(){
const st=await interruptedMove();if(!st)throw new Error('No move was interrupted.');
if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish or stop the download to change the folder.');
const result=await control.leaveModelsMove(st.from,{installDir:repo()});
lastRead=0;await refresh(true);
return {done:true,folder:st.from,partial:st.to,removed:result.removed??0,restored:result.restored??0};
}
async function cancel(id){const job=jobs.get(id);if(!job||job.state!=='running')throw new Error('This operation is no longer running.');if(job.kind==='install'){job.engineAbort?.abort();await tool('strata_install',{cancel:true});}else if(job.kind==='models-move'){/* a safe stop: the copy in flight is dropped, files already moved stay */try{writeFileSync(job.cancelFile,'stop');}catch{/* the run ends anyway */}job.summary='Stopping…';emit();return state();}else if(job.abort)job.abort.abort();else if(job.process){if(!['tune','calibrate','autotune','recheck'].includes(job.kind))throw new Error('File updates cannot be cancelled safely while files are being replaced.');job.state='cancelled';await stopProcess(job.process);}job.state='cancelled';job.summary=job.kind==='install'?'Stopped. Completed downloads are kept.':'Stopped.';emit();return state();}
async function close(){closing=true;clearInterval(timer);const installing=packaged&&child&&[...jobs.values()].some(j=>j.kind==='install'&&j.state==='running');if(installing)await send('tools/call',{name:'strata_install',arguments:{cancel:true}},15000).catch(()=>{});const operations=[];for(const job of jobs.values()){job.abort?.abort();if(job.process)operations.push(stopProcess(job.process));if(job.state==='running')job.state='cancelled';}await Promise.all(operations);await Promise.all([...instances.keys()].map(id=>unload(id,true).catch(()=>{})));disconnect();}
// Remove nonserializable process references from public jobs.
const publicState=()=>{const value=state();value.jobs=value.jobs.map(({process,engineAbort,rawResult,...job})=>job);return value;};
async function localPreview(path){if(!packaged)throw new Error('Checking local model files is part of the Strata app.');if(typeof path!=='string'||!path.trim()||[...path].some(c=>c.charCodeAt(0)<32))throw new Error('Choose a folder.');return control.importPlan(path.trim());}
return {state:publicState,tuneStatus,tuneEffective,tuneOff,tunePresets:{list:()=>autotuner.list(),action:(kind,id,extra)=>autotuner.action(kind,id,extra),undoFor:async args=>autotuner.undoFor(tuneConfig(args).path)},refresh,localPreview,configure,setDownloadDirectory,plan,settings,save,settingsFile,openSettingsFile,operation,cancel,unload,resolveInstance,poll,logs:()=>logs.slice(),benchmarkReport:()=>readJson(join(directory,'native-benchmark.json'),null),setDefault(id){defaultId=resolveInstance(id).id;emit();return publicState();},hasLive:()=>[...instances.values()].some(i=>i.status==='ready'),readyInstances:()=>[...instances.values()].filter(i=>i.status==='ready'),close};
return {state:publicState,tuneStatus,tuneEffective,tuneOff,tunePresets:{list:()=>autotuner.list(),action:(kind,id,extra)=>autotuner.action(kind,id,extra),undoFor:async args=>autotuner.undoFor(tuneConfig(args).path)},refresh,localPreview,configure,setDownloadDirectory,planModelsFolder,startModelsFolder,interruptedMove,finishInterruptedMove,leaveInterruptedMove,plan,settings,save,settingsFile,openSettingsFile,operation,cancel,unload,resolveInstance,poll,logs:()=>logs.slice(),benchmarkReport:()=>readJson(join(directory,'native-benchmark.json'),null),setDefault(id){defaultId=resolveInstance(id).id;emit();return publicState();},hasLive:()=>[...instances.values()].some(i=>i.status==='ready'),readyInstances:()=>[...instances.values()].filter(i=>i.status==='ready'),close};
}
+8 -2
View File
@@ -41,6 +41,12 @@ export const CACHE_LOSS_SHARE = 0.2;
* about 6.9 GiB goes to everything else; a second card in a layer split about 2 GiB).
*/
export const FIRST_CARD_OTHER_GB = 6.9;
/**
* The draft layer (MTP) is part of that 6.9: about 1 GB of graphics memory (786 MiB of files and the draft head's ~218 MiB,
* phaseA-tests/notes-021-settings.md). With "Draft layer (MTP)" off in Expert settings (`"mtp": false`) the engine does not load it, so
* the card has that much more cache and the slots may use it.
*/
export const MTP_VRAM_GB = 1.0;
export const NEXT_CARD_OTHER_GB = 2.0;
/**
* `--batch-mtp` lets each slot verify draft tokens. Where it pays and where it does not (all measured):
@@ -77,7 +83,7 @@ export function engineFacts(config = {}) {
const gpus = Array.isArray(config.gpu) ? config.gpu : config.gpu === undefined || config.gpu === null ? [0] : [config.gpu];
return {
args, context, kv, family, gpus,
mtp: args.includes('--mtp'),
mtp: args.includes('--mtp') && config.mtp !== false,
batchMtp: args.includes('--batch-mtp'),
explicitBatch: args.includes('--batch') || args.includes('--slots'),
};
@@ -107,7 +113,7 @@ export function chooseParallel({ config = {}, values = {}, hardware = null, requ
if (!cards.length) return { slots: PARALLEL_POLICY === 'fixed' ? requested : 1, batchMtp: false, explicit: false, requested, note: null, slotGb: slot };
const total = cards.reduce((a, b) => a + b, 0);
const oneCard = facts.gpus.length === 1 && cards.length === 1;
const soloCache = Math.max(0, total - FIRST_CARD_OTHER_GB - NEXT_CARD_OTHER_GB * (cards.length - 1));
const soloCache = Math.max(0, total - (FIRST_CARD_OTHER_GB - (facts.mtp ? 0 : MTP_VRAM_GB)) - NEXT_CARD_OTHER_GB * (cards.length - 1));
let slots = requested;
if (PARALLEL_POLICY === 'fit') while (slots > 1 && slots * slot > CACHE_LOSS_SHARE * soloCache) slots -= 1;
const note = slots >= requested ? null
+74 -10
View File
@@ -1,10 +1,62 @@
import {USER_KEY,flagValue} from './native-user-args.mjs';
/** The engine's own default reserve, and the one setup writes for pictures read on the GPU: neither is a choice of the person. */
const ENGINE_DEFAULT_RESERVE_MIB=700;
/** A card that drives no display needs little beside the engine; the engine keeps the last few hundred MiB for allocations it makes after the cache. */
const COMPUTE_ONLY_RESERVE_MIB=512;
const wholeMib=v=>v!==null&&v!==undefined&&v!==''&&Number.isFinite(Number(v))&&Number(v)>=0?Math.trunc(Number(v)):null;
/** The graphics memory the person asked to keep free, or null when they asked for nothing (#3215 #3270): the Expert setting of this load,
* "user_args", the setting saved for the model ("vram_reserve_mib", unless it is the value the app itself wrote last), or a value written by hand into "args"
* (not the engine's default 700, and not the one the app wrote). Any number counts, 0 too. */
function candidateReserve(config,values={},legacyDefaults=new Set()){
if(values&&typeof values==='object'&&'vram_reserve_mib' in values){const v=wholeMib(values.vram_reserve_mib);if(v!==null)return v;}
const user=wholeMib(flagValue(config?.[USER_KEY],'--vram-reserve-mib'));if(user!==null)return user;
const applied=config?.ui_reserve_applied,legacy=applied===undefined&&config?.ui_resource_baseline!==undefined; // 0.2.0-beta wrote the key itself, without saying so
const key=wholeMib(config?.vram_reserve_mib),arg=wholeMib(flagValue(config?.args,'--vram-reserve-mib'));
const keyOk=key!==null&&key!==applied,argOk=arg!==null&&arg!==applied&&arg!==ENGINE_DEFAULT_RESERVE_MIB;
if(legacy)return [arg,key].map((n,i)=>(i===0?argOk:keyOk)?n:null).find(n=>n!==null&&!(n===1536||n===2048||legacyDefaults.has(n)))??null; // a 0.2.0-beta file: the app wrote both, so what is not one of its own numbers is the person's
return keyOk?key:argOk?arg:null;
}
/** What the app keeps free when the person asked for nothing: [work, performance]. A card that drives a display (or is not known to be headless) gets no
* less than 1536 MiB on a card of 16 GB or less, 1024 above, 2048 with pictures (the engine warns below ~1-1.5 GiB of free VRAM: the driver then pages GPU memory to
* system RAM and decoding falls to a few tokens a second, #2065); "work" also keeps a fifth of the card. A card the probe says drives no display keeps 512 MiB
* (700 with pictures) on either profile. */
export function defaultReserves(config,hardware,baselineReserve=0){
const placement=Array.isArray(config.gpu)?config.gpu:[config.gpu??0];
const placed=(hardware?.gpus??[]).filter(g=>placement.includes(g.index)),gpuMemory=placed.map(g=>g.vram_gb).filter(n=>Number.isFinite(n)&&n>0);
const headless=placed.length>0&&placed.every(computeOnly); // no display on the card (the desktop is on an integrated GPU): no desktop to keep room for
const floor=headless?(config.vision?ENGINE_DEFAULT_RESERVE_MIB:COMPUTE_ONLY_RESERVE_MIB):gpuMemory.length&&Math.min(...gpuMemory)>16?1024:1536;
const safety=headless?floor:Math.max(config.vision?2048:floor,baselineReserve);
/* A card that shares the PC's memory (Strix Halo, other APUs) has no memory of its own to keep a fifth of: that fifth is RAM, and "Leave 20% for my work" already keeps it
* through the RAM budget. A fifth of the shared pool here held back 16 GB of expert cache for nothing (#3510); the display floor stays. */
const shared=placed.length>0&&placed.some(g=>g.uma===true||g.unified===true);
return {work:gpuMemory.length&&!headless&&!shared?Math.max(safety,Math.floor(Math.min(...gpuMemory)*1024*.2)):safety,performance:safety};
}
/** The reserve the person asked for (null: nothing) and the default for each profile. A 0.2.0-beta file has the key and the same number in "args" with no note that the
* app wrote them: a number that is one of the app's two defaults is the app's, any other is the person's. */
export function reserveChoice(config,hardware,values={},baselineReserve=0){
const defaults=defaultReserves(config,hardware,config.ui_resource_baseline?.reserve??baselineReserve);
const asked=candidateReserve(config,values,new Set([defaults.work,defaults.performance]));
return {asked,defaults};
}
export const explicitReserve=(config,values={},hardware=null)=>reserveChoice(config,hardware,values).asked;
/** True when the probe says this card drives no display: only compute runs on it. Unknown counts as a display card. */
const computeOnly=g=>g?.drives_display===false;
/** Headroom is a worker/memory budget, not a hard utilisation throttle. */
export function resourceSettings(config,hardware,profile,model={}){
export function resourceSettings(config,hardware,profile,model={},values={}){
if(!['work','performance'].includes(profile))throw new Error('Choose a resource preference.');
const args=[...(config.args??[])];
const remove=flag=>{for(let at=args.indexOf(flag);at>=0;at=args.indexOf(flag))args.splice(at,2);};
const value=flag=>{const at=args.indexOf(flag);return at>=0?Number(args[at+1])||0:0;};
const baseline=config.ui_resource_baseline??{workers:value('--pool-workers'),reserve:Math.max(Number(config.vram_reserve_mib)||0,value('--vram-reserve-mib'))};
const first=reserveChoice(config,hardware,values).asked;
const baseline=config.ui_resource_baseline??{workers:value('--pool-workers'),reserve:first!==null?0:Math.max(Number(config.vram_reserve_mib)||0,value('--vram-reserve-mib'))};
const {asked,defaults}=reserveChoice(config,hardware,values,baseline.reserve);
if(profile==='performance'&&config.ui_resource_profile==='work'){
remove('--pool-workers');if(baseline.workers>0)args.push('--pool-workers',String(baseline.workers));
}
@@ -15,13 +67,8 @@ export function resourceSettings(config,hardware,profile,model={}){
args.push('--pool-workers',String(previous>0?Math.min(previous,workers):workers));
}
// Preserve calibrated worker counts and the installation's safety/vision reserve.
const placement=Array.isArray(config.gpu)?config.gpu:[config.gpu??0];
const gpuMemory=(hardware?.gpus??[]).filter(g=>placement.includes(g.index)).map(g=>g.vram_gb).filter(n=>Number.isFinite(n)&&n>0);
/* The engine warns below ~1-1.5 GiB of free VRAM (the driver then pages GPU memory to system RAM and decoding falls to a few tokens a
second, #2065), so neither profile may reserve less: 1536 MiB on a card of 16 GB or less, 1024 above; with pictures 2048. */
const floor=gpuMemory.length&&Math.min(...gpuMemory)>16?1024:1536;
const safety=Math.max(config.vision?2048:floor,baseline.reserve);
const reserve=profile==='work'&&gpuMemory.length?Math.max(safety,Math.floor(Math.min(...gpuMemory)*1024*.2)):safety;
/* A reserve the person set is theirs, 0 included: no floor and no share of the card (#3215 #3270). Without one the card gets the safety default (defaultReserves). */
const reserve=asked!==null?asked:defaults[profile];
remove('--vram-reserve-mib');args.push('--vram-reserve-mib',String(reserve));
// RAM: "leave 20% for my work" keeps the model's RAM within 80% of the PC. A model whose experts do not fit there (the
// setup rule: experts plus 10 GB) keeps only that many GiB of them in RAM and reads the rest from the model files
@@ -37,7 +84,7 @@ export function resourceSettings(config,hardware,profile,model={}){
// available for other programs (it keeps fewer experts if the PC is busy), and it said nothing about this before.
let ramHeadroom=false;
if(ramBudget&&!(config.env?.STRATA_RESIDENT_HEADROOM_GIB)){env={...(env??config.env??{}),STRATA_RESIDENT_HEADROOM_GIB:String(Math.ceil(ram*.2))};ramHeadroom=true;}
return {args,vram_reserve_mib:reserve,...(env?{env}:{}),ui_resource_baseline:{...baseline,ramBudget,ramHeadroom}};
return {args,vram_reserve_mib:reserve,...(asked!==null?{}:{ui_reserve_applied:reserve}),...(env?{env}:{}),ui_resource_baseline:{...baseline,ramBudget,ramHeadroom}};
}
/* One automatic second try after a start that died. Returns null when there is nothing safer to try (the caller then reports the first
@@ -49,6 +96,23 @@ export function saferStart(config,hardware,model,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';
if(!/server_exited|exited before|died before|engine stopped|pinned-memory-exhausted|mapped staging|out of memory|memory guard|\bkilled\b/i.test(text))return null;
// The engine stopped because the GPU's expert cache got no room (a 10 GB card with the app's 20% reserve and pictures on): the engine can shrink its own
// reserve only when none was given, and the app always gives one. Ask for less kept free (never under 1024 MiB: below that the driver pages
// graphics memory to RAM, #2065) and, when that is not enough, for a shorter context. Never saved.
if(/no VRAM (?:is|was) left for the expert cache|needs the profile-filled VRAM expert tier/i.test(text)){
const args=[...(config.args??[])],at=args.indexOf('--vram-reserve-mib'),cur=at>=0?Number(args[at+1])||0:Number(config.vram_reserve_mib)||0;
const short=Number((text.match(/about (\d+) MiB more than this card has free/)??[])[1])||700,need=short+256;
const notes=[];let left=need;
if(cur>1024){const next=Math.max(1024,cur-need);left=need-(cur-next);if(next<cur){if(at>=0)args[at+1]=String(next);else args.push('--vram-reserve-mib',String(next));notes.push(`${next} MiB of graphics memory kept free for other programs (was ${cur})`);}}
const cx=args.indexOf('--max-context'),ctx=cx>=0?Number(args[cx+1])||0:0,sess=text.match(/takes (\d+) MiB at (\d+) tokens/);
if(left>0&&ctx>16384&&sess){
const session=Number(sess[1]),tokens=Number(sess[2]);let next=ctx;
while(next>16384&&session*(ctx-next)/tokens<left)next=Math.max(16384,Math.floor(next/2));
if(next<ctx){args[cx+1]=String(next);notes.push(`a shorter conversation context (${next} tokens, was ${ctx})`);}
}
if(!notes.length)return null;
return {config:{...config,args},words:`This graphics card had no room left for the model's expert cache, so Strata started again with ${notes.join(' and ')}. Nothing is saved; change it in the model's settings if you want it permanently.`,detail:notes.join('; ')};
}
const args=[...(config.args??[])],env={...(config.env??{})};
const ram=Number(hardware?.ram_gb)||0,arena=Number(model?.arenaGb)||0;
// Windows lets a program lock only about half the RAM for the graphics card; the engine pins the model's experts up to that.
+9
View File
@@ -7,6 +7,7 @@ export const NATIVE_FIELDS=[
{id:'aliases',label:'API model aliases',group:'Serving',type:'array',hint:'Comma-separated names clients may use for this model.'},
{id:'idle_unload_s',label:'Unload after idle seconds',group:'Serving',type:'number',min:0,max:86400,hint:'Zero keeps the model loaded.'},
{id:'lazy_load',label:'Load on first request',group:'Serving',type:'boolean'},
{id:'mtp',label:'Draft layer (MTP)',group:'Serving',type:'boolean',hint:'The draft layer guesses several words ahead, which makes answers faster, and costs about 1 GB of graphics memory and some RAM. Turn it off if the model stops while it starts, or the card is small: the expert cache gets that memory back and answers are slower. Default is on.'},
{id:'batchMtp',label:'MTP per request',group:'Serving',type:'boolean',hint:'Each concurrent request uses the draft layer. Left alone, Strata turns it on for one graphics card with 24 GB or more and several requests at once, and off otherwise; choose on or off here to decide yourself.'},
{id:'parallel',label:'Concurrent requests',group:'Serving',type:'number',min:1,max:8,hint:'More slots need more memory and can slow each response.'},
{id:'engine_silence_s',label:'Engine stall timeout (seconds)',group:'Recovery',type:'number',min:10,max:3600},
@@ -22,6 +23,14 @@ export const NATIVE_FIELDS=[
{id:'mcp_max_rounds',label:'Maximum tool rounds',group:'Tools',type:'number',min:1,max:100},
{id:'mcp_max_result_chars',label:'Maximum tool result characters',group:'Tools',type:'number',min:100,max:1000000},
];
/** Settings the app itself applies when it starts a model (they are not read from the engine's source, so the engine's text never says it supports them). */
export const APP_OWNED_FIELDS=['mtp','batchMtp'];
/** The engine arguments without the draft layer: --mtp <folder or count>, --spec-min-p <p> (its confidence threshold) and --batch-mtp. --spec stays: prompt lookup still uses its windows. Same rule as strata_control/draft_layer.py. */
export function withoutDraftLayer(args){
const out=[];const list=(args??[]).map(String);
for(let i=0;i<list.length;i++){const a=list[i];if(a==='--batch-mtp')continue;if(a==='--mtp'||a==='--spec-min-p'){if(i+1<list.length&&!list[i+1].startsWith('--'))i++;continue;}out.push(a);}
return out;
}
export function validateNativeSettings(values){
if(!values||typeof values!=='object'||Array.isArray(values))throw new Error('Native settings must be an object.');
const result={};
+47
View File
@@ -0,0 +1,47 @@
/** The engine options a person set by hand in a model's run config (strata_control/user_args.py is the same rule in Python).
* "user_args" holds them; "args_written" is the whole "args" as setup or the app last wrote it, so an option in "args" that is not in
* that copy was added or changed by hand since. Every load and every write puts the person's options over the generated ones. */
export const USER_KEY='user_args',WRITTEN_KEY='args_written';
/** Options the app or setup choose from their own answers (context, KV type, pictures, draft layer, layer split): never taken from "args" as hand edits. */
export const CHOICE_FLAGS=new Set(['--max-context','--kv','--vision','--mtp','--spec-min-p','--batch-mtp','--layer-split']);
export function groups(args){
const out=[];
for(const raw of Array.isArray(args)?args:[]){const a=String(raw);if(a.startsWith('--')||!out.length)out.push([a]);else out[out.length-1].push(a);}
return out;
}
export const flat=gs=>gs.flat();
export const flagOf=g=>g?.[0]?.startsWith('--')?g[0]:'';
/** `args` with every option the person set replaced by theirs (appended after the generated ones). */
export function merge(args,user){
const ug=groups(user),mine=new Set(ug.map(flagOf).filter(Boolean));
return flat([...groups(args).filter(g=>!mine.has(flagOf(g))),...ug]);
}
function byFlag(gs){const m=new Map();for(const g of gs){const f=flagOf(g);if(!m.has(f))m.set(f,[]);m.get(f).push(g);}return m;}
const same=(a,b)=>JSON.stringify(a??null)===JSON.stringify(b??null);
/** The person's options of a config: its user_args, with the options of "args" that differ from "args_written" on top. */
export function capture(cfg,skip=CHOICE_FLAGS){
const mine=byFlag(groups(cfg?.[USER_KEY]));
if(Array.isArray(cfg?.[WRITTEN_KEY])&&Array.isArray(cfg?.args)){
const cur=byFlag(groups(cfg.args)),was=byFlag(groups(cfg[WRITTEN_KEY]));
for(const [flag,gs] of cur)if(flag&&!skip.has(flag)&&!same(was.get(flag),gs))mine.set(flag,gs);
for(const flag of [...mine.keys()])if(was.has(flag)&&!cur.has(flag))mine.delete(flag);
}
return flat([...mine.values()].flat());
}
export function setFlag(user,flag,value){
const rest=groups(user).filter(g=>flagOf(g)!==flag);
if(value!==null&&value!==undefined)rest.push([flag,String(value)]);
return flat(rest);
}
export function flagValue(args,flag){
const list=(Array.isArray(args)?args:[]).map(String),at=list.indexOf(flag);
return at>=0&&at+1<list.length?list[at+1]:null;
}
/** `config` with the person's options merged into "args", recorded in "user_args", and "args_written" refreshed (only when the config has one). */
export function stamped(config,user){
const next={...config};
if(user.length){next.args=merge(next.args,user);next[USER_KEY]=user;}else delete next[USER_KEY];
if(WRITTEN_KEY in config)next[WRITTEN_KEY]=[...(next.args??[])];
return next;
}
+3
View File
@@ -0,0 +1,3 @@
/** A chat sent while no model can answer: the engine's own wording ("Choose a loaded model ID.") said nothing to a person. */
export const plainNoModel = message => /not ready/i.test(message) ? 'Your model is still loading. You can chat in a moment.'
: /choose a loaded model/i.test(message) ? 'No model is loaded. Load one in Models first.' : message;
+30
View File
@@ -0,0 +1,30 @@
/** Ports the host listens on: a free one for the helpers, and a plain sentence when the main one cannot be taken. */
import net from 'node:net';
/** Why a listen failed, in words a person can act on (Windows keeps whole ranges for Hyper-V, WSL and Docker). */
export function listenProblem(error, port, host = '127.0.0.1') {
const code = error?.code;
if (code === 'EADDRINUSE') return `Port ${port} is already in use. Strata may already be running; close it or the other program, or start Strata with another port (STRATA_PORT).`;
if (code === 'EACCES') return `Windows or a firewall does not let Strata use port ${port}. Hyper-V, WSL and Docker keep ranges of ports for themselves (see "netsh int ipv4 show excludedportrange protocol=tcp"). Start Strata with another port (STRATA_PORT), for example 8801.`;
if (code === 'EADDRNOTAVAIL') return `${host} is not an address of this computer, so Strata cannot listen on it. Check STRATA_BIND.`;
return `Strata cannot listen on ${host}:${port} (${error?.message ?? code}).`;
}
function tryListen(port, host) {
return new Promise(resolve => {
const probe = net.createServer();
probe.once('error', () => resolve(null));
probe.listen(port, host, () => { const got = probe.address().port; probe.close(() => resolve(got)); });
});
}
/** `preferred` when it can be taken, else the next free port above it (up to `tries` of them), else any free port. Never throws. */
export async function findFreePort(preferred, host = '127.0.0.1', { skip = [], tries = 20 } = {}) {
for (let offset = 0; offset < tries; offset++) {
const port = preferred + offset;
if (port > 65535 || skip.includes(port)) continue;
const got = await tryListen(port, host);
if (got) return got;
}
return (await tryListen(0, host)) ?? preferred;
}
+9
View File
@@ -0,0 +1,9 @@
/**
* Windows PowerShell writes to a pipe in the console's OEM code page (cp866 on a Russian Windows, cp437 on an English one),
* not in UTF-8, while Node reads the pipe as UTF-8 - so a Cyrillic folder name came back as mojibake or as question marks (#3202).
* Every script whose output can hold a folder, file or font name starts with this line, before anything is written.
*/
export const POWERSHELL_UTF8 = '[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false; $OutputEncoding = [Console]::OutputEncoding; ';
/** The script with the UTF-8 line in front of it. */
export const utf8Script = script => POWERSHELL_UTF8 + script;
+5 -1
View File
@@ -1,4 +1,8 @@
/** Verified file identities for the optional real engine. Never relabel these as demo weights. */
/**
* The starter list shown with the optional real engine. It is display only: nothing downloads from it (models.install refuses when the real
* engine is on), because these files are named without a hash. Models come from the Models screen of the native engine, which pins them.
* Never relabel these as demo weights.
*/
export const REAL_VARIANTS = [
{
id: 'qwen2.5-0.5b-q4km',
+3 -2
View File
@@ -70,7 +70,7 @@ export function patch(body) {
const s = getStore();
const serving = { ...(s.serving ?? {}) };
if (typeof body.otherDevices === 'boolean' && body.otherDevices !== !isLocal()) throw new Error('Change the host listen address and restart to update network access.');
if (body.apiKey === null && (!isLocal() || sharing.active || tunnelActive())) throw new Error('Stop local sharing and Cloudflare before removing the API key.');
// The key is optional (owner decision, 0.2.2): removing it while sharing or the tunnel runs is allowed; the app warns in plain words (local-share.mjs, cloudflare.mjs).
if (body.apiKey === null) serving.apiKey = null;
else if (typeof body.apiKey === 'string' && body.apiKey.trim()) serving.apiKey = body.apiKey.trim();
else if (body.generateKey) serving.apiKey = `sk-strata-${randomBytes(12).toString('hex')}`;
@@ -149,7 +149,8 @@ export function cancel(id) {
export function rateSample() {
const done = requests.filter((r) => r.state === 'done' && r.tokensOut > 0);
if (!done.length) return 0;
const tps = done.map((r) => r.tokensOut / Math.max(0.001, r.durationMs / 1000));
const tps = done.filter((r) => r.tps > 0).map((r) => r.tps); // answer speeds (decode time), not tokens over the whole request
if (!tps.length) return 0;
return round(tps.reduce((a, b) => a + b, 0) / tps.length, 1);
}
+2 -1
View File
@@ -1,6 +1,7 @@
/** Native Save As for a rendered Monitor PNG. The client cannot choose a host path. */
import { writeFile } from 'node:fs/promises';
import { exec } from './util.mjs';
import { utf8Script } from './powershell-utf8.mjs';
export function snapshotBytes(data) {
if (typeof data !== 'string' || data.length > 24 * 1024 * 1024 || !/^data:image\/png;base64,[A-Za-z0-9+/=]+$/.test(data)) throw new Error('Expected a PNG screenshot smaller than 18 MB.');
@@ -14,7 +15,7 @@ export async function saveSnapshot(data, { platform = process.platform, run = ex
let result;
try {
if (platform === 'darwin') result = await run('osascript', ['-e', `POSIX path of (choose file name with prompt "Save Monitor screenshot" default name "${name}")`]);
else if (platform === 'win32') result = await run('powershell.exe', ['-NoProfile', '-STA', '-Command', `Add-Type -AssemblyName System.Windows.Forms; $dialog = New-Object System.Windows.Forms.SaveFileDialog; $dialog.Filter = 'PNG image (*.png)|*.png'; $dialog.FileName = '${name}'; if ($dialog.ShowDialog() -eq 'OK') { $dialog.FileName }`]);
else if (platform === 'win32') result = await run('powershell.exe', ['-NoProfile', '-STA', '-Command', utf8Script(`Add-Type -AssemblyName System.Windows.Forms; $dialog = New-Object System.Windows.Forms.SaveFileDialog; $dialog.Filter = 'PNG image (*.png)|*.png'; $dialog.FileName = '${name}'; if ($dialog.ShowDialog() -eq 'OK') { $dialog.FileName }`)]);
else result = await run('zenity', ['--file-selection', '--save', '--confirm-overwrite', '--title=Save Monitor screenshot', `--filename=${name}`, '--file-filter=PNG images | *.png']);
} catch (e) {
if (/User canceled|\(-128\)/i.test(e.stderr ?? '') || (platform === 'linux' && e.code === 1)) return { cancelled: true };
+31 -15
View File
@@ -1,6 +1,7 @@
import { migrateSetup } from './setup.mjs';
/** Tiny persisted state for the host: one JSON file under STRATA_HOME. */
import { accessSync, constants, mkdirSync, readFileSync, writeFileSync, existsSync, realpathSync, readdirSync, statSync, statfsSync, symlinkSync } from 'node:fs';
import { alignModels, isDriveRoot, libraryFolderFromPick, makeFolder } from './model-folder.mjs';
import { dirname, isAbsolute, join, parse, relative, resolve } from 'node:path';
import { homedir } from 'node:os';
import { defaultModelDirectory } from './model-directory-default.mjs';
@@ -58,29 +59,42 @@ function read() {
let state = read();
const DEFAULT_MODELS_DIRECTORY=defaultModelDirectory();
function initialModelsDirectory() {
if(state.modelsDir)return state.modelsDir;
const legacy=realpathSync(join(HOME,'models'));
// Keep an existing library in place. A new install uses Documents/Strata/Models.
/**
* The model folder for a new install: the saved one, an existing library inside STRATA_HOME, else Documents/Strata/Models. A Documents folder
* that cannot be made (Controlled folder access, a disconnected share, an offline OneDrive known folder) must not stop the host from starting:
* the models go to STRATA_HOME/models instead, and `notice` says so in plain words.
*/
export function pickModelsDirectory({ saved, home, preferred, fixedByEnv = false }) {
if (saved) return { directory: saved, notice: '' };
const fallback = join(home, 'models');
let legacy = fallback;
try { legacy = realpathSync(fallback); } catch { /* the folder is made at start; keep the plain path */ }
function hasWeights(folder,depth=0){try{return readdirSync(folder,{withFileTypes:true}).some(entry=>entry.isFile()&&/\.gguf(?:\.part|\.partial)?$/i.test(entry.name)||entry.isDirectory()&&depth<4&&hasWeights(join(folder,entry.name),depth+1));}catch{return false;}}
if(!process.env.STRATA_MODELS_DIR&&hasWeights(legacy))return legacy;
const directory=DEFAULT_MODELS_DIRECTORY;
mkdirSync(directory,{recursive:true});
return realpathSync(directory);
// Keep an existing library in place. A new install uses Documents/Strata/Models.
if (!fixedByEnv && hasWeights(legacy)) return { directory: legacy, notice: '' };
try {
mkdirSync(preferred, { recursive: true });
accessSync(preferred, constants.R_OK | constants.W_OK | constants.X_OK);
return { directory: realpathSync(preferred), notice: '' };
} catch (error) {
return { directory: legacy, notice: `Strata could not use ${preferred} for models (${error.code ?? error.message}), so models go to ${legacy}. You can pick another folder in Settings.` };
}
}
export let MODELS_DIR = initialModelsDirectory();
const MODELS_PICK = pickModelsDirectory({ saved: state.modelsDir, home: HOME, preferred: DEFAULT_MODELS_DIRECTORY, fixedByEnv: !!process.env.STRATA_MODELS_DIR });
/** Plain words for the person when the usual model folder could not be used (empty otherwise). */
export const MODELS_DIR_NOTICE = MODELS_PICK.notice;
if (MODELS_DIR_NOTICE) console.warn(`[strata] ${MODELS_DIR_NOTICE}`);
export let MODELS_DIR = MODELS_PICK.directory;
/** Native setup writes weights to data_dir/models. Keep its data root one level up. */
export function nativeDataDirectory(input) {
if(!input||process.env.STRATA_MODELS_DIR||!existsSync(DEFAULT_MODELS_DIRECTORY))return input;
if(MODELS_DIR_NOTICE)return input;
const root=realpathSync(DEFAULT_MODELS_DIRECTORY);
if(resolve(input)!==resolve(DEFAULT_MODELS_DIRECTORY)&&realpathSync(input)!==root)return input;
const data=dirname(root),nativeModels=join(data,'models');
// On Windows/macOS case-insensitive volumes, Models already resolves as models.
// On Linux, a relative alias keeps the native layout inside the requested folder.
if(!existsSync(nativeModels))symlinkSync(process.platform==='win32'?root:'Models',nativeModels,process.platform==='win32'?'junction':'dir');
const a=statSync(nativeModels),b=statSync(root);
if(realpathSync(nativeModels)!==root&&!(a.ino&&a.ino===b.ino&&a.dev===b.dev))throw new Error('The default model folder conflicts with another library. Choose a different folder.');
const data=dirname(root);
// `models` and `Models` are one folder on Windows and macOS; where they are not (Linux) a link makes them one, and where no link can be made (exFAT, a share) models go in `models`.
alignModels(data,root);
return data;
}
@@ -95,6 +109,8 @@ export function validateModelsDirectory(input) {
if (!statSync(directory).isDirectory()) throw new Error();
accessSync(directory, constants.R_OK | constants.W_OK | constants.X_OK);
} catch { throw new Error('This folder is unavailable or not writable. Check the path and folder permissions.'); }
// A whole drive (D:\) is not filled with models: D:\Strata\Models is made and used.
if (isDriveRoot(directory)) directory = realpathSync(makeFolder(libraryFolderFromPick(directory).path));
const data = realpathSync(DATA_DIR);
const contains = (parent, child) => { const r = relative(parent, child); return !r || !r.startsWith('..') && !isAbsolute(r); };
if (directory === parse(directory).root || contains(directory, data) || contains(data, directory)) throw new Error('Choose a separate folder for models, outside Strata’s app data.');
+16
View File
@@ -33,3 +33,19 @@ export function taskReport({ text, cards }) {
if (attention) out += `\n\n> ${attention}`;
return { text: out, attention, demo: false };
}
/**
* One plain line for a board card: the first sentence of the answer, with the markdown taken off and the "What it did" list and the
* "needed a yes" note left out. '' when there is nothing to say.
*/
export function lastLine(text, max = 160) {
let body = String(text ?? '').split(/\n\n---\n\n/)[0].replace(/\n\n> [\s\S]*$/, '');
body = body.replace(/```[\s\S]*?```/g, ' ').replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/^\s*(#{1,6}|[-*+]|\d+[.)]|>)\s+/gm, '').replace(/[*_`~]/g, '');
body = body.replace(/\s+/g, ' ').trim();
if (!body) return '';
const sentence = /^.*?[.!?](?=\s|$)/.exec(body)?.[0] ?? body;
// A very short first sentence ("Done.") says nothing: take the next one too.
const two = /^.*?[.!?](?=\s|$)\s*.*?[.!?](?=\s|$)/.exec(body)?.[0] ?? body;
const pick = sentence.length < 24 && body.length > sentence.length ? two : sentence;
return clip(pick, max);
}
+7 -3
View File
@@ -4,6 +4,7 @@
* first thing that needs a yes and says what that was) or `bypass` (full access). The default is `plan`.
*/
import { randomUUID } from 'node:crypto';
import { lastLine } from './task-report.mjs';
const frequencies = ['hourly', 'daily', 'weekdays', 'weekly'];
export const TASK_MODES = ['plan', 'manual', 'bypass'];
export function validateTask(input) {
@@ -52,6 +53,8 @@ export function createTaskService({ read, write, run, notify = () => {}, now = D
if (old?.status === 'running') throw new Error('Stop the current run before editing.');
const fields = validateTask({ ...old, ...input });
const task = { ...old, ...fields, id: old?.id ?? randomUUID(), createdAt: old?.createdAt ?? new Date(now()).toISOString(), status: old?.status ?? 'idle', archived: input.archived ?? old?.archived ?? false };
// "I have seen this": the board hides a finished or failed run the person dismissed. It is only a mark; the result stays in the task.
if ('ackedAt' in input) task.ackedAt = input.ackedAt ? String(input.ackedAt) : null;
task.nextRunAt = task.enabled && !task.archived ? nextRun(task, now()) : null;
records = old ? records.map(t => t.id === id ? task : t) : [...records, task]; save(); return task;
}
@@ -59,12 +62,13 @@ export function createTaskService({ read, write, run, notify = () => {}, now = D
const t = find(id); if (t.status === 'running') return;
if (t.archived) throw new Error('Restore this task first.');
const controller = new AbortController(); controllers.set(id, controller);
t.status = 'running'; t.lastRunAt = new Date(now()).toISOString(); t.error = null; t.files = [];
// A new run replaces the last one's problem (error and the note that something needed a yes) and its dismissal, so the board only ever shows the newest.
t.status = 'running'; t.lastRunAt = new Date(now()).toISOString(); t.error = null; t.attention = null; t.ackedAt = null; t.lastLine = null; t.finishedAt = null; t.files = [];
t.nextRunAt = t.enabled ? nextRun(t, now()) : null; save();
const timeout = setTimeout(() => controller.abort(), timeoutMs); timeout.unref?.();
try { const result = await run(t, controller.signal); t.output = result.text.slice(0, 100000); t.demo = result.demo; t.attention = result.attention ?? null; t.files = Array.isArray(result.files) ? result.files.slice(0, 30) : []; t.status = 'done'; }
try { const result = await run(t, controller.signal); t.output = result.text.slice(0, 100000); t.demo = result.demo; t.attention = result.attention ?? null; t.lastLine = lastLine(result.text); t.files = Array.isArray(result.files) ? result.files.slice(0, 30) : []; t.status = 'done'; }
catch (e) { t.status = controller.signal.aborted ? 'cancelled' : 'error'; t.error = controller.signal.aborted ? 'Run stopped.' : String(e.message ?? e); }
finally { clearTimeout(timeout); controllers.delete(id); save(); try { notify(t); } catch { /* a notification is a courtesy */ } }
finally { t.finishedAt = new Date(now()).toISOString(); clearTimeout(timeout); controllers.delete(id); save(); try { notify(t); } catch { /* a notification is a courtesy */ } }
}
function tick() { for (const t of records) if (t.enabled && !t.archived && t.status !== 'running' && t.nextRunAt && Date.parse(t.nextRunAt) <= now()) void execute(t.id); }
return { list, put, execute, tick, stop: id => controllers.get(id)?.abort(), close: () => controllers.forEach(c => c.abort()) };
+2 -2
View File
@@ -305,7 +305,7 @@ export function createTimeSkip({ repoRoot, python, pythonArgs = [], timeoutMs =
const child = spawn(python, [...pythonArgs, cli, ...args, '--output', 'json'], {
cwd: repoRoot,
windowsHide: true,
env: { ...env, STRATA_TIMESKIP_DIR: root },
env: { ...env, PYTHONUTF8: '1', PYTHONIOENCODING: 'utf-8', STRATA_TIMESKIP_DIR: root },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
@@ -452,7 +452,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, ...extraEnv, STRATA_TIMESKIP_DIR: root }, stdio: ['ignore', 'pipe', 'pipe'],
cwd: repoRoot, windowsHide: true, 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(); });
+10
View File
@@ -13,6 +13,13 @@ export const PHASE_TEXT = {
done: 'Done.',
};
/** True when the failure looks like a computer with no internet (no DNS, no route, refused), not a slow or broken server. */
export function looksOffline(error) {
const text = [error?.message, error?.cause?.message, error?.cause?.code, error?.code, error].filter(x => typeof x === 'string').join(' ');
return /ENOTFOUND|EAI_AGAIN|ENETUNREACH|ENETDOWN|EHOSTUNREACH|ECONNREFUSED|getaddrinfo|fetch failed|network is unreachable|name or service not known|temporary failure in name resolution|nodename nor servname|No address associated|URLError/i.test(text);
}
export const OFFLINE_MESSAGE = "No internet connection. Try again when you're online.";
/** { message, details, next } for an error thrown by Control's update calls (or the shell bridge). `subject` is "engine" or "Strata". */
export function plainUpdateError(error, subject = 'engine') {
const raw = String(error?.message ?? error ?? '');
@@ -30,6 +37,9 @@ export function plainUpdateError(error, subject = 'engine') {
next = `Wait a moment, then press ${button} in Settings.`;
} else if (/test start|did not answer a one-token|did not become ready/i.test(raw)) {
message = `The new ${subject} didn't start correctly, so nothing was changed. ${keep}`;
} else if (looksOffline(error) || looksOffline(raw)) {
message = 'No internet connection.';
next = "Try again when you're online.";
} else if (/could not be read|interrupted|timed out|timeout|ECONN|ENOTFOUND|fetch failed|could not reach|server answered|not answer within/i.test(raw)) {
message = "Couldn't reach the update server.";
next = `Check your internet connection, then press ${button} in Settings.`;
+3 -3
View File
@@ -1,4 +1,4 @@
import { plainUpdateError } from './update-messages.mjs';
import { OFFLINE_MESSAGE, looksOffline, plainUpdateError } from './update-messages.mjs';
/** Read-only release checks. Installing/rolling back needs a managed installer. */
const RELEASES = 'https://api.github.com/repos/Niko1221/Strata/releases?per_page=30';
@@ -33,7 +33,7 @@ export async function checkUpdates({ appVersion, engineVersion }, fetcher = fetc
strata = { status: newer ? 'available' : 'current', latestVersion: latest.tag_name, releaseUrl, message: newer ? `Strata ${latest.tag_name} is available.` : 'Strata is up to date.' };
}
} catch (error) {
strata = { status: 'error', message: error.name === 'TimeoutError' ? 'The release check timed out. Try again.' : error.message ?? 'Could not check for updates.' };
strata = { status: 'error', message: looksOffline(error) ? OFFLINE_MESSAGE : error.name === 'TimeoutError' ? 'The release check timed out. Try again.' : error.message ?? 'Could not check for updates.' };
}
return {
checkedAt: new Date().toISOString(),
@@ -56,7 +56,7 @@ export async function checkComponentUpdates({ control, appUpdater = null, appVer
report = await control.updateCheck({ appVersion });
} catch (error) {
const plain = plainUpdateError(error, 'Strata');
const message = /update server|aren't available/.test(plain.headline) ? plain.message : `Couldn't check for updates. Try again in a moment.`;
const message = /update server|aren't available|No internet/.test(plain.headline) ? plain.message : `Couldn't check for updates. Try again in a moment.`;
return { checkedAt, strata: { installedVersion: appVersion ?? control.version ?? null, status: 'error', message, details: plain.details, canInstall: false }, engine: { installedVersion: null, status: 'error', message, details: plain.details, canInstall: false } };
}
const mib = bytes => `${Math.round((bytes ?? 0) / (1 << 20))} MB`;
+4 -2
View File
@@ -6,6 +6,7 @@ import { navigate, href, useLocation } from '../lib/router';
import { blankTask, type Task } from '../lib/tasks';
import { TaskEditor } from '../components/TaskEditor';
import { pushToast, setNavOpen } from '../lib/store';
import { useBoard } from '../lib/use-board';
type TaskLeaf = Task & {
id: string;
};
@@ -14,6 +15,7 @@ export function TaskTree({ collapsed }: {
}) {
const [editor, setEditor] = useState<Task | null>(null);
const location = useLocation();
const { board } = useBoard();
const [open, setOpen] = useState(() => localStorage.getItem('strata.tasks-open') !== 'false');
const [tasks, setTasks] = useState<TaskLeaf[]>([]), [remove, setRemove] = useState<TaskLeaf | null>(null), [error, setError] = useState(''), [busy, setBusy] = useState(false);
const [leaving, setLeaving] = useState<string | null>(null);
@@ -76,8 +78,8 @@ export function TaskTree({ collapsed }: {
if (collapsed)
return dialogs;
return <section className="task-tree" aria-label={tr("Scheduled tasks")}>
<div className="ws__head"><button className="navgroup__label task-tree__heading" aria-expanded={open} onClick={() => { setOpen(!open); localStorage.setItem('strata.tasks-open', String(!open)); }}>{tr("Tasks")}<Icon name="chevron" size={12} className={`ws__chev ${open ? 'is-open' : ''}`}/></button><div className="ws__tools"><IconBtn name="plus" title={tr("Add a task")} onClick={() => { setNavOpen(false); setEditor(blankTask()); }}/></div></div>
{tr(open ? <div className="navsessions">{tr(tasks.map(t => <div className="ws__session row task-tree-row" data-leaving={leaving === t.id || undefined} key={t.id}><a className="navsession" href={href('tasks', t.id)} aria-current={location.route === 'tasks' && location.param === t.id ? 'page' : undefined} title={t.title}><span className="navsession__title">{t.title}</span>{tr(!t.enabled ? <Icon name="clock" size={12}/> : null)}</a><button className="st-btn st-btn--icon session-overflow" aria-label={tr("Edit task {0}", [t.title])} title={tr("Edit {0}", [t.title])} disabled={t.status === 'running'} onClick={() => { setNavOpen(false); setEditor({ ...t }); }}><Icon name="pencil" size={15}/></button><button className="st-btn st-btn--icon session-overflow" aria-label={tr("Delete task {0}", [t.title])} disabled={t.status === 'running'} title={tr(t.status === 'running' ? 'Stop this task before deleting' : `Delete ${t.title}`)} onClick={() => { setNavOpen(false); setRemove(t); setError(''); }}><Icon name="trash" size={15}/></button></div>))}{tr(!tasks.length ? <p className="small muted ws__empty">{tr("No tasks yet")}</p> : null)}{tr(error && !remove ? <p className="xs inline-error">{tr("Couldn\u2019t load tasks.")}</p> : null)}</div> : null)}
<div className="ws__head"><button className="navgroup__label task-tree__heading" aria-expanded={open} onClick={() => { setOpen(!open); localStorage.setItem('strata.tasks-open', String(!open)); }}>{tr("Tasks")}{tr(board.need ? <span className="navcount" title={tr("{0} need your attention", [board.need])}><span className="sr-only">{tr("{0} need your attention", [board.need])}</span><span aria-hidden="true">{board.need}</span></span> : null)}<Icon name="chevron" size={12} className={`ws__chev ${open ? 'is-open' : ''}`}/></button><div className="ws__tools"><IconBtn name="plus" title={tr("Add a task")} onClick={() => { setNavOpen(false); setEditor(blankTask()); }}/></div></div>
{tr(open ? <div className="navsessions"><div className="ws__session row task-tree-row"><a className="navsession" href="#/tasks?view=board" aria-current={location.route === 'tasks' && !location.param && location.query.get('view') === 'board' ? 'page' : undefined} onClick={() => setNavOpen(false)}><span className="navsession__title">{tr("Task board")}</span></a></div>{tr(tasks.map(t => <div className="ws__session row task-tree-row" data-leaving={leaving === t.id || undefined} key={t.id}><a className="navsession" href={href('tasks', t.id)} aria-current={location.route === 'tasks' && location.param === t.id ? 'page' : undefined} title={t.title}><span className="navsession__title">{t.title}</span>{tr(board.problemTasks.includes(t.id) ? <span className="navsession__dot navsession__dot--warn" role="img" aria-label={tr("Needs attention")} title={tr("Needs attention")}/> : null)}{tr(!t.enabled ? <Icon name="clock" size={12}/> : null)}</a><button className="st-btn st-btn--icon session-overflow" aria-label={tr("Edit task {0}", [t.title])} title={tr("Edit {0}", [t.title])} disabled={t.status === 'running'} onClick={() => { setNavOpen(false); setEditor({ ...t }); }}><Icon name="pencil" size={15}/></button><button className="st-btn st-btn--icon session-overflow" aria-label={tr("Delete task {0}", [t.title])} disabled={t.status === 'running'} title={tr(t.status === 'running' ? 'Stop this task before deleting' : `Delete ${t.title}`)} onClick={() => { setNavOpen(false); setRemove(t); setError(''); }}><Icon name="trash" size={15}/></button></div>))}{tr(!tasks.length ? <p className="small muted ws__empty">{tr("No tasks yet")}</p> : null)}{tr(error && !remove ? <p className="xs inline-error">{tr("Couldn\u2019t load tasks.")}</p> : null)}</div> : null)}
{dialogs}
</section>;
}
@@ -19,7 +19,7 @@ export function CloudflareDialog({ open, onClose, onCreateKey }: {
const steps = mode === 'quick' ? ['Address', 'Connector', 'API key', 'Connect'] : ['Address', 'Connector', 'API key', 'Domain', 'Connect'];
const last = steps.length - 1;
const installed = !!tunnel?.installed, keyReady = !!serving?.access.apiKeySet, other = tunnel?.external ?? null;
const canContinue = step === 1 ? installed : step === 2 ? keyReady : step === 3 && mode === 'domain' ? !!hostname.trim() && !!token.trim() : true;
const canContinue = step === 1 ? installed : step === 2 ? true : step === 3 && mode === 'domain' ? !!hostname.trim() && !!token.trim() : true;
async function inspect() { setPending(true); setError(''); try {
setDetected(await api.get<TunnelState>('/api/serving/tunnel'));
}
@@ -80,7 +80,7 @@ export function CloudflareDialog({ open, onClose, onCreateKey }: {
} }
const titles = ['Choose your address', installed ? 'Connector ready' : 'Preparing your connector', 'Protect your connection', ...(mode === 'domain' ? ['Set up your domain'] : []), 'Ready to connect'];
return <Dialog open={open} title={tr("Connect with Cloudflare")} onClose={dismiss} actions={<>
{tr(active ? <><Btn onClick={dismiss}>{tr("Done")}</Btn><Btn disabled={pending || tunnel?.status === 'stopping'} onClick={() => void stop()}>{tr(tunnel?.status === 'stopping' ? 'Stopping…' : 'Stop tunnel')}</Btn></> : <><Btn disabled={pending} onClick={back}>{tr(step ? 'Back' : 'Cancel')}</Btn>{tr(step === last ? <Btn variant="primary" disabled={pending || !installed || !keyReady || (mode === 'domain' && (!hostname.trim() || !token.trim()))} onClick={() => void connect()}>{tr(pending ? 'Connecting…' : (error || tunnel?.error) ? 'Try again' : mode === 'quick' ? 'Create temporary URL' : 'Connect domain')}</Btn> : <Btn variant="primary" disabled={pending || !canContinue} onClick={next}>{tr("Continue")}<Icon name="chevron" className="wizard-next" size={14}/></Btn>)}</>)}
{tr(active ? <><Btn onClick={dismiss}>{tr("Done")}</Btn><Btn disabled={pending || tunnel?.status === 'stopping'} onClick={() => void stop()}>{tr(tunnel?.status === 'stopping' ? 'Stopping…' : 'Stop tunnel')}</Btn></> : <><Btn disabled={pending} onClick={back}>{tr(step ? 'Back' : 'Cancel')}</Btn>{tr(step === last ? <Btn variant="primary" disabled={pending || !installed || (mode === 'domain' && (!hostname.trim() || !token.trim()))} onClick={() => void connect()}>{tr(pending ? 'Connecting…' : (error || tunnel?.error) ? 'Try again' : mode === 'quick' ? 'Create temporary URL' : 'Connect domain')}</Btn> : <Btn variant="primary" disabled={pending || !canContinue} onClick={next}>{tr("Continue")}<Icon name="chevron" className="wizard-next" size={14}/></Btn>)}</>)}
</>}>
<div className="tunnel-wizard">
{tr(!active ? <ol className="tunnel-progress" aria-label={tr("Connection progress")}>{tr(steps.map((label, i) => <li key={label} data-current={step === i || undefined} data-complete={step > i || undefined}><button disabled={i >= step || pending} aria-current={step === i ? 'step' : undefined} onClick={() => { setError(''); setStep(i); }}><span>{tr(step > i ? <Icon name="check" size={13}/> : i + 1)}</span><small>{tr(label)}</small></button></li>))}</ol> : null)}
@@ -92,9 +92,9 @@ export function CloudflareDialog({ open, onClose, onCreateKey }: {
(e.currentTarget.parentElement?.children[mode === 'quick' ? 1 : 0] as HTMLElement)?.focus();
} }} onClick={() => setMode(choice.value)}><Icon name={choice.icon} size={20}/><span><strong>{tr(choice.title)}</strong><small>{tr(choice.detail)}</small></span><span className="tunnel-choice-dot">{tr(mode === choice.value ? <Icon name="check" size={13}/> : null)}</span></button>))}</div></> : null)}
{tr(step === 1 ? <div className="tunnel-automatic" data-ready={installed || undefined} aria-live="polite" aria-busy={pending}><span className="tunnel-stage__symbol"><Icon name={installed ? 'check' : error ? 'warning' : 'refresh'} size={28}/></span><p className="small muted">{tr(installed ? 'Your Cloudflare connector is installed and ready.' : error ? 'Automatic setup needs another try.' : 'Strata downloads and sets up the Cloudflare connector for you.')}</p>{tr(installed ? <>{tr(tunnel?.connector ? <p className="xs muted">{tr("Using {0}", [tunnel.connector.summary])}</p> : null)}{tr(other ? <p className="xs muted" role="status">{tr(other.others.length === 1 && !other.own.length ? "cloudflared is already running for another tunnel (PID {0})." : other.own.length ? "Strata’s tunnel for this address is already running (PID {0})." : "cloudflared is already running for other tunnels (PIDs {0}).", [(other.own.length ? other.own : other.others).join(", ")])}</p> : null)}</> : error ? <Btn onClick={() => setRetry(v => v + 1)}>{tr("Retry setup")}</Btn> : <span className="small muted" role="status">{tr("Setting up\u2026")}</span>)}</div> : null)}
{tr(step === 2 ? <><p className="small muted">{tr("Only apps with your Strata API key can use this connection.")}</p>{tr(keyReady ? <div className="tunnel-check"><Icon name="shield"/><div><strong>{tr("API key ready")}</strong><span>{tr("Use your existing key in the app you connect.")}</span></div></div> : <div className="tunnel-key-step"><Icon name="shield" size={30}/><p className="small">{tr("Create a key to protect your API. You\u2019ll return here when it\u2019s ready.")}</p><Btn onClick={onCreateKey}>{tr("Create an API key")}</Btn></div>)}</> : null)}
{tr(step === 2 ? <><p className="small muted">{tr(keyReady ? "Only apps with your Strata API key can use this connection." : "Without an API key, anyone who finds the link can use your model.")}</p>{tr(keyReady ? <div className="tunnel-check"><Icon name="shield"/><div><strong>{tr("API key ready")}</strong><span>{tr("Use your existing key in the app you connect.")}</span></div></div> : <div className="tunnel-key-step"><Icon name="shield" size={30}/><p className="small">{tr("Create a key to protect your API. You\u2019ll return here when it\u2019s ready. You can also continue without one, but anyone who finds the link can then use your model.")}</p><Btn onClick={onCreateKey}>{tr("Create an API key (recommended)")}</Btn></div>)}</> : null)}
{tr(mode === 'domain' && step === 3 ? <><p className="small muted">{tr("Create a tunnel in your ")}<a href="https://dash.cloudflare.com/" target="_blank" rel="noreferrer">{tr("Cloudflare dashboard \u2197")}</a>{tr(". Copy the token from its connector setup.")}</p><label className="input-field"><span>{tr("Tunnel token")}</span><input type="password" className="st-input" autoComplete="off" spellCheck={false} value={token} onChange={e => setToken(e.target.value)} placeholder={tr("Paste only the token after --token")}/></label><label className="input-field"><span>{tr("Your hostname")}</span><input className="st-input" autoComplete="off" spellCheck={false} value={hostname} onChange={e => setHostname(e.target.value)} placeholder={tr("ai.example.com")}/></label><div><p className="small muted">{tr("Add a published application route for this hostname with this service URL:")}</p><div className="install-command"><code>{tunnel?.originUrl ?? 'Loading…'}</code><IconBtn name="copy" title={tr("Copy tunnel service URL")} disabled={!tunnel?.originUrl} onClick={() => void navigator.clipboard.writeText(tunnel!.originUrl)}/></div></div><p className="xs muted">{tr("The token is used for this connection only and is not saved.")}</p></> : null)}
{tr(step === last ? <><p className="small muted">{tr(mode === 'quick' ? 'Cloudflare will create your address when you connect.' : 'Start the connector for the domain you configured.')}</p><dl className="tunnel-review"><div><dt>{tr("Address")}</dt><dd>{tr(mode === 'quick' ? 'Free temporary URL' : hostname)}</dd></div><div><dt>{tr("Connector")}</dt><dd><Icon name="check" size={14}/>{tr(tunnel?.connector?.version ? "Installed · cloudflared {0}" : "Installed", tunnel?.connector?.version ? [tunnel.connector.version] : undefined)}</dd></div><div><dt>{tr("Access")}</dt><dd><Icon name="shield" size={14}/>{tr("API key required")}</dd></div></dl><p className="small muted">{tr(mode === 'quick' ? 'The address changes each time. Turn off streaming in your client; replies arrive all at once.' : 'Use this hostname and your Strata API key in your client. Streaming is supported.')}</p><p className="xs muted">{tr("Only the inference API is shared. Your computer and Strata must stay on.")}</p></> : null)}
{tr(step === last ? <><p className="small muted">{tr(mode === 'quick' ? 'Cloudflare will create your address when you connect.' : 'Start the connector for the domain you configured.')}</p><dl className="tunnel-review"><div><dt>{tr("Address")}</dt><dd>{tr(mode === 'quick' ? 'Free temporary URL' : hostname)}</dd></div><div><dt>{tr("Connector")}</dt><dd><Icon name="check" size={14}/>{tr(tunnel?.connector?.version ? "Installed · cloudflared {0}" : "Installed", tunnel?.connector?.version ? [tunnel.connector.version] : undefined)}</dd></div><div><dt>{tr("Access")}</dt><dd><Icon name="shield" size={14}/>{tr(keyReady ? "API key required" : "No API key: anyone with the link can use it")}</dd></div></dl><p className="small muted">{tr(mode === 'quick' ? 'The address changes each time. Turn off streaming in your client; replies arrive all at once.' : 'Use this hostname and your Strata API key in your client. Streaming is supported.')}</p><p className="xs muted">{tr("Only the inference API is shared. Your computer and Strata must stay on.")}</p></> : null)}
</section>)}
{tr(error || tunnel?.error ? <ErrorNotice message={error || tunnel?.error || 'Connection failed'} next={tr("Check your internet connection, then retry.")}/> : null)}
</div>
@@ -8,5 +8,6 @@ export const engineMissing = (native: NativeState | null | undefined) => !!nativ
export function EngineWarning({ native }: { native: NativeState | null | undefined }) {
if (!engineMissing(native)) return null;
if (native?.engine?.wrongMake && native.engine.notice) return <div className="engine-warning" role="status"><Icon name="info" size={18}/><div><p>{native.engine.notice}</p></div></div>;
return <div className="engine-warning" role="status"><Icon name="info" size={18}/><div><p>{tr("Strata didn’t recognise an engine for this computer. The model will download, but it may not run until the right engine is installed (Settings → Engine).")}</p>{native?.engine?.notice ? <small className="muted">{native.engine.notice}</small> : null}</div></div>;
}
+136
View File
@@ -0,0 +1,136 @@
import { tr } from '../lib/i18n';
import { useId, useState } from 'react';
import { Btn, Icon } from './ui';
import {
CPU_ONLY_LINE, NO_CARD_LINE, OFF_AT_OR_BELOW_GB, gpuLabel, noteText, order, recommend, setRole, shortName, vramOn, warnings,
type Role, type RoleCard, type Roles, type RoleWarning,
} from '../lib/gpu-roles';
const ROLE_WORD: Record<Role, string> = { main: 'Main', helper: 'Helper', off: 'Off' };
const HINT: Record<Role, string> = { main: 'Does the main work', helper: 'Holds part of the model', off: 'Not used' };
/** The one plain line above the cards, built from translated pieces. */
export function recommendationLine(cards: RoleCard[], vendor?: string | null): string {
const rec = recommend(cards, vendor);
if (rec.main === null) return tr('No graphics card that Strata can use was found.');
const byi = new Map(cards.map(c => [c.index, c]));
const m = byi.get(rec.main)!;
if (cards.length === 1) return tr('Strata will use your {0}.', [shortName(m)]);
const why = { same: 'the cards are alike', memory: 'it has more memory', faster: 'it is faster', only: 'it is the only card', none: '' }[rec.why];
let s = tr('{0} is Main: {1}.', [shortName(m), tr(why)]);
const helpers = Object.keys(rec.roles).map(Number).filter(i => rec.roles[i] === 'helper').map(i => shortName(byi.get(i)!));
if (helpers.length) s += ' ' + tr(helpers.length > 1 ? '{0} help.' : '{0} helps.', [helpers.join(', ')]);
for (const i of Object.keys(rec.roles).map(Number)) if (rec.roles[i] === 'off') {
const code = rec.reasons[i], c = byi.get(i)!;
s += ' ' + tr('{0} starts Off: {1}', [shortName(c), code === 'cannot_run' ? tr('Strata cannot run it.') : code === 'igpu' ? tr('built-in graphics.') : tr('{0} GB or less.', [OFF_AT_OR_BELOW_GB])]);
}
return s;
}
function warningText(w: RoleWarning, cards: RoleCard[]): string {
const c = w.card !== null ? cards.find(x => x.index === w.card) : undefined;
if (w.code === 'cannot_run') return tr('{0} cannot run Strata.', [c ? shortName(c) : '']);
if (w.code === 'igpu_main') return tr('{0} is built-in graphics; it is slow as Main.', [c ? shortName(c) : '']);
if (w.code === 'cpu_only') return tr(w.text === CPU_ONLY_LINE ? CPU_ONLY_LINE : NO_CARD_LINE);
return tr('Not enough memory for this setup: about {0} GB is read from the drive.', [w.text.match(/about (\d+) GB/)?.[1] ?? '']);
}
export function noteLine(code: string | null, cards: RoleCard[]): string {
if (!code) return '';
const kind = code.split(':')[0], c = cards.find(x => x.index === Number(code.split(':')[1]));
if (kind === 'old_main_helper') return tr('{0} is now a Helper.', [c ? shortName(c) : '']);
if (kind === 'promoted') return tr('{0} is now Main.', [c ? shortName(c) : '']);
if (kind === 'only_main') return tr('The only card that is on has to be Main.');
return noteText(code, cards);
}
/**
* Main / Helper / Off for every graphics card (the mockup GpuRoles-mockup.html). One click changes a role; "Back to recommended" restores the
* recommendation. Warnings are short and never block Load. `roles` are the roles in force (the recommendation until the person changes one).
*/
export function GpuRoles({ cards, roles, custom, recommendedRoles, vendor, onChange, onBack, memoryGb, ramGb, spillGb = 0, engineRunsCpuOnly = false, disabled = false, usedBy }: {
cards: RoleCard[];
roles: Roles;
/** The person changed a role: "Your choice", and "Back to recommended" is offered. */
custom: boolean;
/** The roles Strata recommends (the tags and "Back to recommended" use them); the pure recommendation when not given. */
recommendedRoles?: Roles;
vendor?: string | null;
onChange: (next: Roles) => void;
onBack: () => void;
memoryGb?: number | null;
ramGb?: number | null;
/** About how many GB of the model are read from the drive with these cards (0 when everything fits or it is unknown). */
spillGb?: number;
engineRunsCpuOnly?: boolean;
disabled?: boolean;
/** Names of the loaded models that already use a card ("In use by ..."). */
usedBy?: (card: number) => string[];
}) {
const titleId = useId();
const [note, setNote] = useState('');
const rec = { ...recommend(cards, vendor), ...(recommendedRoles ? { roles: recommendedRoles } : {}) };
const single = cards.length === 1;
const warns = warnings(roles, cards, { spillGb, engineRunsCpuOnly });
const cardWarn = (i: number) => warns.filter(w => w.card === i);
const globalWarn = warns.filter(w => w.card === null);
const lst = order(roles, cards);
const change = (idx: number, role: Role) => {
const out = setRole(roles, cards, idx, role);
setNote(noteLine(out.note, cards));
onChange(out.roles);
};
const back = () => { setNote(''); onBack(); };
const fix = (w: RoleWarning) => w.fix && change(w.fix.card, w.fix.role);
const fixLabel = (w: RoleWarning) => {
if (!w.fix) return '';
const target = cards.find(c => c.index === w.fix!.card);
return w.fix.role === 'off' ? tr('Turn it off') : tr('Make {0} Main', [target ? shortName(target) : '']);
};
const have = vramOn(roles, cards) + Math.max(0, (ramGb ?? 0) - 10);
return <div className="gpuroles" aria-labelledby={titleId} data-custom={custom}>
<div className="gpuroles__head">
<span className="native-choice__label" id={titleId}>{tr('Graphics cards')}</span>
{custom ? <button type="button" className="gpuroles__link" disabled={disabled} onClick={back}>{tr('Back to recommended')}</button> : null}
</div>
<div className="gpuroles__reco" data-custom={custom}>
<Icon name="bolt" size={18}/>
<p><strong>{tr(custom ? 'Your choice.' : 'Recommended.')}</strong> {custom ? tr('You changed the roles. Strata will use exactly these.') : recommendationLine(cards, vendor)}</p>
</div>
<div className="gpuroles__rows" role="list">
{cards.map(c => {
const role = roles[c.index] ?? 'off', isRec = rec.roles[c.index] === role, w = cardWarn(c.index), other = usedBy?.(c.index) ?? [];
const shared = !!(c.integrated || c.uma || c.unified_gb);
const options: Role[] = single ? ['main', 'off'] : ['main', 'helper', 'off'];
return <div className="gpuroles__row" role="listitem" data-role={role} key={c.index}>
<div className="gpuroles__top">
<span className="gpuroles__ico"><Icon name="gpu" size={20}/></span>
<span className="gpuroles__name">
<strong>{gpuLabel(c)} · {c.name}{isRec ? <span className="gpuroles__tag">{tr('Recommended')}</span> : <span className="gpuroles__tag gpuroles__tag--neutral">{tr('Recommended: {0}', [tr(ROLE_WORD[rec.roles[c.index] ?? 'off'])])}</span>}</strong>
<small>{shared ? tr("Uses your computer's memory (built-in graphics)") : tr('{0} GB memory', [Math.round(c.vram_gb)])} · {tr(HINT[role])}</small>
</span>
<span className="gpuroles__roles" role="radiogroup" aria-label={tr('Role of {0}', [gpuLabel(c)])}>
{options.map(r => <button type="button" role="radio" key={r} data-r={r} aria-checked={role === r} disabled={disabled} onClick={() => change(c.index, r)}>{tr(ROLE_WORD[r])}</button>)}
</span>
</div>
{other.length ? <div className="gpuroles__note gpuroles__note--info"><Icon name="info" size={16}/><p>{tr('In use by {0}', [other.join(', ')])}</p></div> : null}
{w.map(x => <div className="gpuroles__note gpuroles__note--warn" key={x.code}><Icon name="warning" size={16}/><p>{warningText(x, cards)}</p>{x.fix ? <Btn size="sm" disabled={disabled} onClick={() => fix(x)}>{fixLabel(x)}</Btn> : null}</div>)}
</div>;
})}
</div>
<div className="gpuroles__toast" role="status" aria-live="polite">{note ? <div className="gpuroles__note gpuroles__note--info"><Icon name="info" size={16}/><p>{note}</p></div> : null}</div>
{globalWarn.map(x => <div className="gpuroles__note gpuroles__note--warn" key={x.code}><Icon name="warning" size={16}/><p>{warningText(x, cards)}</p>{x.code === 'cpu_only' && custom ? <Btn size="sm" disabled={disabled} onClick={back}>{tr('Back to recommended')}</Btn> : null}</div>)}
<details className="gpuroles__details">
<summary>{tr('Details')}</summary>
<div className="gpuroles__body">
<dl>
<dt>{tr('Main')}</dt><dd>{tr('The card that does the final steps of every answer: it writes the next words, runs the draft-ahead step and checks it, and keeps your conversation. Pick your fastest card with plenty of memory. There is only ever one.')}</dd>
<dt>{tr('Helper')}</dt><dd>{tr('Holds a share of the model and its fast memory, so more of it fits on graphics cards instead of being read from your drive. Prompts are read about 20% faster.')}</dd>
<dt>{tr('Off')}</dt><dd>{tr('Strata does not touch this card. Good for a card you use for something else, such as a game or your screen.')}</dd>
</dl>
{memoryGb ? <div className="gpuroles__fit" data-state={lst.length ? (spillGb >= 1 ? 'bad' : 'ok') : 'bad'}><span className="gpuroles__dot" aria-hidden="true"/><span>{lst.length ? (spillGb >= 1 ? tr('The model needs about {0} GB. These cards and your memory give about {1} GB, so about {2} GB would be read from your drive.', [Math.round(memoryGb), Math.round(have), Math.round(spillGb)]) : tr('The model needs about {0} GB. These cards and your memory give about {1} GB. It fits.', [Math.round(memoryGb), Math.round(have)])) : tr('Only your processor and memory are used.')}</span></div> : null}
<div className="gpuroles__expert xs muted">{lst.length ? tr('For experts: the engine starts on cards {0} (Main last).', [lst.join(',')]) : null} {custom ? '' : tr('Not saved while you keep the recommendation: Strata picks again if you add or remove a card.')}</div>
</div>
</details>
</div>;
}
+22 -7
View File
@@ -16,7 +16,7 @@ import { installProgress, installStage, stageText, useDownloadEta } from '../lib
import { VersionPicker } from './VersionPicker';
import { DownloadParts } from './DownloadParts';
import { EngineWarning } from './EngineWarning';
import { recommendedVersions } from '../lib/native-fit';
import { OTHER_NOTE, PICK_NOTE, recommendedVersions } from '../lib/native-fit';
import { nativeCatalogModels, catalogVersionLabel } from '../lib/native-catalog';
type Choices = NonNullable<NonNullable<SetupState['choices']>['onboarding']>;
function radioKeys(event: KeyboardEvent<HTMLDivElement>) { if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key))
@@ -78,6 +78,19 @@ export function GuidedSetup() {
throw e;
} });
}
/* Files the person already has are checked and adopted by the "Use a local GGUF" page, not made the data folder (that downloaded them again, #3507). */
async function ownModel(path?: string) {
await perform(async () => { leaving.current = true; if (saveTimer.current)
clearTimeout(saveTimer.current); try {
await persist(step, { manual: true }, true);
openSetup(false);
navigate('models', 'local', path ? { path } : undefined);
}
catch (e) {
leaving.current = false;
throw e;
} });
}
async function finish() {
if (leaving.current)
return;
@@ -165,12 +178,14 @@ export function GuidedSetup() {
} });
return () => { cancelled = true; };
}, [step, plan, preparing, native?.available, version?.id, context, directory]);
const recommendedCards = recommendedVersions(selectedFamily?.sizes ?? []).map(x => x.v);
const recommendedRows = recommendedVersions(selectedFamily?.sizes ?? [], native?.catalog?.recommendation);
const recommendedCards = recommendedRows.map(x => x.v);
const pickNote = (v: { id: string }) => recommendedRows.find(x => x.v.id === v.id && [PICK_NOTE, OTHER_NOTE].includes(x.r.note))?.r.note ?? '';
// The recommended four first, then a list with every other version (the Models page's version list); a family without a recommended set shows them all.
const cards = recommendedCards.length ? recommendedCards : selectedFamily?.sizes ?? [];
const others = recommendedCards.length ? (selectedFamily?.sizes ?? []).filter(v => !recommendedCards.includes(v)) : [];
const readyIds = new Set((native?.installed?.models ?? []).filter(m => m.ready).map(m => m.model));
const versionCard = (v: NonNullable<typeof version>) => <button key={v.id} type="button" role="radio" tabIndex={version?.id === v.id ? 0 : -1} aria-checked={version?.id === v.id} data-selected={version?.id === v.id} onClick={() => { setSize(v.id); setPlan(null); }}><span className="onboard-version-top"><span className="onboard-version-copy"><strong>{tr(catalogVersionLabel(v))}</strong><small>{tr(v.model)}</small></span><span className="onboard-version-radio">{tr(version?.id === v.id ? <Icon name="check" size={12}/> : null)}</span></span><span className="onboard-version-bottom"><span className="onboard-version-size"><Icon name="download" size={15}/>{tr(v.download_gb.toFixed(1))} <small>{tr("GB")}</small></span><span className="onboard-version-fit">{tr(v.on_this_pc === 'does not fit' || v.on_this_pc === 'tight' || v.on_this_pc?.includes('slower') || v.on_this_pc?.startsWith('experimental:') ? <><Icon name="clock" size={13}/>{tr("May run slower")}</> : v.installed ? <><Icon name="check" size={13}/>{tr("On this computer")}</> : <>{tr("Download size")}</>)}</span></span></button>;
const versionCard = (v: NonNullable<typeof version>) => <button key={v.id} type="button" role="radio" tabIndex={version?.id === v.id ? 0 : -1} aria-checked={version?.id === v.id} data-selected={version?.id === v.id} onClick={() => { setSize(v.id); setPlan(null); }}><span className="onboard-version-top"><span className="onboard-version-copy"><strong>{tr(catalogVersionLabel(v))}</strong><small>{tr(v.model)}{tr(pickNote(v) ? <> · {tr(pickNote(v))}</> : null)}</small></span><span className="onboard-version-radio">{tr(version?.id === v.id ? <Icon name="check" size={12}/> : null)}</span></span><span className="onboard-version-bottom"><span className="onboard-version-size"><Icon name="download" size={15}/>{tr(v.download_gb.toFixed(1))} <small>{tr("GB")}</small></span><span className="onboard-version-fit">{tr(v.on_this_pc === 'does not fit' || v.on_this_pc === 'tight' || v.on_this_pc?.includes('slower') || v.on_this_pc?.startsWith('experimental:') ? <><Icon name="clock" size={13}/>{tr("May run slower")}</> : v.installed ? <><Icon name="check" size={13}/>{tr("On this computer")}</> : <>{tr("Download size")}</>)}</span></span></button>;
const counter = downloadCounter(plan?.plan?.download_gb ?? version?.download_gb, progress, preparing);
const eta = useDownloadEta(preparing ? progress : undefined, installJob?.id);
const title = step === 1 ? 'State of the art. At home.' : step === 2 ? 'Select the model you want to run.' : step === 3 ? 'Downloading the model...' : tuneJob?.state === 'running' ? 'A little tuning. A better fit.' : 'How you would like to work with Strata?';
@@ -190,9 +205,9 @@ export function GuidedSetup() {
<OnboardingMemory hardware={hardware} native={native?.hardware}/>{tr(!version || version.needsNewerEngine ? <p className="small muted">{tr("This model needs a newer engine. Update your engine or import a local GGUF for this model.")}</p> : null)}
<div className="onboard-version-heading"><strong>{tr("Choose a version")}</strong><span>{tr(version ? `${catalogVersionLabel(version)} selected` : '')}</span></div>
<div className="onboard-versions" role="radiogroup" aria-label={tr("Model version")} onKeyDown={radioKeys}>{tr(cards.map(v => versionCard(v)))}</div>
{tr(others.length ? <div className="onboard-more-versions"><VersionPicker versions={others} selectedId={version?.id ?? ''} hardware={hardware ? { ram_gb: native?.hardware?.ram_gb ?? hardware.memory.totalGb, vram_gb: Math.max(0, ...(native?.hardware?.gpus ?? []).filter(g => g.usable !== false).map(g => g.vram_gb)) } : null} downloaded={readyIds} showRecommended={false} moreLabel="More versions" onSelect={id => { setSize(id); setPlan(null); }} onOwnGguf={() => setFolderOpen(true)}/></div> : null)}
{tr(others.length ? <div className="onboard-more-versions"><VersionPicker versions={others} selectedId={version?.id ?? ''} hardware={hardware ? { ram_gb: native?.hardware?.ram_gb ?? hardware.memory.totalGb, vram_gb: Math.max(0, ...(native?.hardware?.gpus ?? []).filter(g => g.usable !== false).map(g => g.vram_gb)) } : null} downloaded={readyIds} showRecommended={false} moreLabel="More versions" onSelect={id => { setSize(id); setPlan(null); }} onOwnGguf={() => void ownModel()}/></div> : null)}
<div className="onboard-folder"><Icon name="folder" size={20}/><div><strong>{tr(directory ? 'Your model folder' : 'Already downloaded the model?')}</strong><small>{tr(directory || "Choose the folder and we'll start your models with Strata.")}</small></div><Btn size="sm" onClick={() => setFolderOpen(v => !v)}>{tr(folderOpen ? 'Close' : directory ? 'Change' : 'Choose folder')}</Btn></div>
{tr(folderOpen ? <OnboardingFolder directory={folder} onChoose={path => void perform(async () => { await nativeAction('directory', { directory: path }); setDirectory(path); setFolderOpen(false); setPlan(null); })}/> : null)}
{tr(folderOpen ? <OnboardingFolder directory={folder} onChoose={(path, ggufFiles) => ggufFiles > 0 && !/strata/i.test(path.split(/[\\/]/).filter(Boolean).pop() ?? '') ? void ownModel(path) : void perform(async () => { await nativeAction('directory', { directory: path }); setDirectory(path); setFolderOpen(false); setPlan(null); })}/> : null)}
</> : <div className="onboard-engine-connect"><Icon name="models" size={24}/><strong>{tr("Connect your Strata installation")}</strong><Btn onClick={() => void skip(false, 'Engine')}>{tr("Connect engine")}</Btn></div>)}
</div> : step === 3 ? <div className="onboard-prepare-stage">
<section className="onboard-download"><EngineWarning native={native}/><div className="onboard-download-identity"><Icon name="models" size={19}/><strong>{tr(selectedFamily?.title)}</strong><span>{tr(version ? catalogVersionLabel(version) : '')}</span><small>{tr(version?.model)}</small></div><OnboardingArt kind="download" progress={progress?.percent ?? 0} counter={counter}/><div className="onboard-download-info">
@@ -226,7 +241,7 @@ function OnboardingError({ message, onFolder }: {
}
function OnboardingFolder({ directory, onChoose }: {
directory: string;
onChoose: (path: string) => void;
onChoose: (path: string, ggufFiles: number) => void;
}) {
const [path, setPath] = useState(directory), [folder, setFolder] = useState<LocalModelFolder | null>(null), [pending, setPending] = useState(false), [error, setError] = useState('');
async function browse(input: string, initial = false) { setPending(true); setError(''); try {
@@ -250,5 +265,5 @@ function OnboardingFolder({ directory, onChoose }: {
setPending(false);
} }
useEffect(() => { void browse(directory, true); }, []);
return <section className="onboard-folder-browser" aria-label={tr("Choose your model folder")}><form onSubmit={e => { e.preventDefault(); void browse(path); }}><input className="st-input" aria-label={tr("Existing model folder")} value={path} onChange={e => { setPath(e.target.value); setFolder(null); }} placeholder={tr("Full folder path")} spellCheck={false}/><Btn type="submit" size="sm" disabled={pending || !path.trim()}>{tr(pending ? 'Checking…' : 'Open')}</Btn></form><p>{tr("Choose the Strata data folder that holds your model downloads. Matching files are reused; missing files are prepared automatically.")}</p><div className="onboard-folder-shortcuts">{folder?.shortcuts?.map(item => <Btn key={item.path} size="sm" disabled={pending} onClick={() => void browse(item.path)}>{tr(item.name)}</Btn>)}</div><div className="onboard-folder-files">{tr(folder?.parent ? <button onClick={() => void browse(folder.parent!)}><Icon name="folder" size={16}/>{tr(" Up a folder")}</button> : null)}{folder?.entries.filter(e => e.kind === 'folder').slice(0, 30).map(e => <button key={e.path} onClick={() => void browse(e.path)}><Icon name="folder" size={16}/>{tr(e.name)}<Icon name="chevron" size={12}/></button>)}{tr(folder ? <small>{tr(folder.entries.filter(e => e.kind === 'model').length)}{tr(" GGUF files in this folder. Nested downloads are checked by the installer.")}</small> : null)}</div>{tr(folder ? <Btn variant="primary" size="sm" disabled={pending} onClick={() => onChoose(folder.directory)}>{tr("Use this folder")}</Btn> : null)}{tr(error ? <ErrorNotice message={error}/> : null)}</section>;
return <section className="onboard-folder-browser" aria-label={tr("Choose your model folder")}><form onSubmit={e => { e.preventDefault(); void browse(path); }}><input className="st-input" aria-label={tr("Existing model folder")} value={path} onChange={e => { setPath(e.target.value); setFolder(null); }} placeholder={tr("Full folder path")} spellCheck={false}/><Btn type="submit" size="sm" disabled={pending || !path.trim()}>{tr(pending ? 'Checking…' : 'Open')}</Btn></form><p>{tr("Choose the Strata data folder that holds your model downloads. Matching files are reused; missing files are prepared automatically.")}</p><div className="onboard-folder-shortcuts">{folder?.shortcuts?.map(item => <Btn key={item.path} size="sm" disabled={pending} onClick={() => void browse(item.path)}>{tr(item.name)}</Btn>)}</div><div className="onboard-folder-files">{tr(folder?.parent ? <button onClick={() => void browse(folder.parent!)}><Icon name="folder" size={16}/>{tr(" Up a folder")}</button> : null)}{folder?.entries.filter(e => e.kind === 'folder').slice(0, 30).map(e => <button key={e.path} onClick={() => void browse(e.path)}><Icon name="folder" size={16}/>{tr(e.name)}<Icon name="chevron" size={12}/></button>)}{tr(folder ? <small>{tr(folder.entries.filter(e => e.kind === 'model').length)}{tr(" GGUF files in this folder. Nested downloads are checked by the installer.")}</small> : null)}</div>{tr(folder ? <Btn variant="primary" size="sm" disabled={pending} onClick={() => onChoose(folder.directory, folder.entries.filter(e => e.kind === 'model').length)}>{tr("Use this folder")}</Btn> : null)}{tr(error ? <ErrorNotice message={error}/> : null)}</section>;
}
+21 -1
View File
@@ -3,7 +3,9 @@ import { tr } from '../lib/i18n';
import { Fragment, type ReactNode } from 'react';
import { Btn, IconBtn } from './ui';
import { SyntaxCode } from './SyntaxCode';
function inline(text: string, onOpenLink?: (url: string) => void): ReactNode {
import { MathView } from './MathView';
import { displayBlock, hasMath, splitMath } from '../lib/math';
function inlineMarks(text: string, onOpenLink?: (url: string) => void): ReactNode {
return text.split(/(`[^`]+`|\*\*[^*]+\*\*|\*[^*\n]+\*|\[[^\]]+\]\([^\s)]+\))/g).map((part, i) => {
if (part.startsWith('`') && part.endsWith('`'))
return <code key={i} className="md-code">{part.slice(1, -1)}</code>;
@@ -20,6 +22,18 @@ function inline(text: string, onOpenLink?: (url: string) => void): ReactNode {
return <Fragment key={i}>{part}</Fragment>;
});
}
/** Text with its inline marks and, outside code spans, its math (#3268). Math is cut out first: a * or _ inside a formula is not emphasis. */
function inline(text: string, onOpenLink?: (url: string) => void): ReactNode {
if (!hasMath(text))
return inlineMarks(text, onOpenLink);
return text.split(/(`[^`]+`)/g).map((chunk, c) => {
if (chunk.length > 1 && chunk.startsWith('`') && chunk.endsWith('`'))
return <Fragment key={c}>{inlineMarks(chunk, onOpenLink)}</Fragment>;
return <Fragment key={c}>{splitMath(chunk).map((part, p) => part.kind === 'math'
? <MathView key={p} tex={part.tex} display={part.display}/>
: <Fragment key={p}>{inlineMarks(part.text, onOpenLink)}</Fragment>)}</Fragment>;
});
}
export function Markdown({ text, onOpenLink, onPreviewHtml }: {
text: string;
onOpenLink?: (url: string) => void;
@@ -43,6 +57,12 @@ export function Markdown({ text, onOpenLink, onPreviewHtml }: {
nodes.push(<div className="st-code" key={key}><div className="st-code__head"><span>{language || tr('code')}</span>{tr(/^html$/i.test(language) && onPreviewHtml ? <Btn size="sm" onClick={() => onPreviewHtml(body.join('\n'))}>{tr("Open preview")}</Btn> : null)}<IconBtn name="copy" title={tr("Copy code")} onClick={() => void navigator.clipboard.writeText(body.join('\n'))}/></div><SyntaxCode text={body.join('\n')} language={language}/></div>);
continue;
}
const block = displayBlock(lines, i);
if (block) {
nodes.push(<div className="md-math-block" key={i}><MathView tex={block.tex.trim()} display/></div>);
i += block.used;
continue;
}
const heading = line.match(/^(#{1,6})\s+(.+)$/);
if (heading) {
const Tag = heading[1].length <= 2 ? 'h2' : heading[1].length === 3 ? 'h3' : 'h4';
+43
View File
@@ -0,0 +1,43 @@
/**
* One formula of an answer (#3268), drawn by KaTeX. KaTeX and its fonts are bundled with the app (no network) and load as a separate
* chunk the first time an answer holds math, so a chat without math pays nothing. Until the chunk is there, and if it cannot load,
* the formula shows as its source text, as before.
*/
import { useEffect, useState } from 'react';
type Katex = typeof import('katex').default;
let loading: Promise<Katex> | null = null;
const drawn = new Map<string, string>();
function loadKatex(): Promise<Katex> {
loading ??= Promise.all([import('katex'), import('katex/dist/katex.min.css')]).then(([module]) => module.default);
return loading;
}
function draw(katex: Katex, tex: string, display: boolean): string {
const key = (display ? 'D' : 'I') + tex;
const known = drawn.get(key);
if (known !== undefined)
return known;
// trust stays off (no \href, \url, \includegraphics); a broken formula is shown as its source, not as an error page
const html = katex.renderToString(tex, { displayMode: display, throwOnError: false, strict: 'ignore', trust: false, maxSize: 30, maxExpand: 500 });
if (drawn.size > 400)
drawn.clear();
drawn.set(key, html);
return html;
}
const source = (tex: string, display: boolean) => (display ? `$$${tex}$$` : `$${tex}$`);
export function MathView({ tex, display }: { tex: string; display: boolean }) {
const [html, setHtml] = useState<string | null>(() => drawn.get((display ? 'D' : 'I') + tex) ?? null);
useEffect(() => {
let live = true;
loadKatex().then(katex => { if (live) setHtml(draw(katex, tex, display)); }).catch(() => { /* stays as source text */ });
return () => { live = false; };
}, [tex, display]);
const cls = display ? 'md-math md-math--display' : 'md-math';
if (html === null)
return <code className={cls + ' md-math--source'}>{source(tex, display)}</code>;
return <span className={cls} dangerouslySetInnerHTML={{ __html: html }}/>;
}
@@ -0,0 +1,273 @@
import { tr, localeTag } from '../lib/i18n';
import { useEffect, useMemo, useState } from 'react';
import { Bar, Btn, Dialog, Icon } from './ui';
import { api, ApiError } from '../lib/client';
import { nativeAction, refreshNative, useNative, type NativeJob, type NativeState } from '../lib/native';
import { nativeShortName } from '../lib/native-names';
import { sizeGb } from '../lib/format';
type Choice = 'move' | 'keep' | 'empty';
interface Plan {
ok: boolean;
issue: { code: string; message: string; details: string } | null;
old: string; new: string; same_drive: boolean;
free_bytes: number; models_bytes: number; needed_bytes: number;
models: { group: string; name: string; bytes: number }[];
choices: Record<Choice, { possible: boolean; reason: string }>;
folder: string; picked: string; wholeDrive: boolean; shown: string; current: string; running: number; busy: boolean;
}
interface Outcome { done?: boolean; stopped?: boolean; failed?: boolean; code?: string; moved?: string[]; remaining?: string[]; bytes?: number; total?: number; from?: string; to?: string; sameDrive?: boolean }
const gb = (bytes: number) => sizeGb(bytes / 1e9);
const driveOf = (path: string) => /^[A-Za-z]:/.test(path) ? path.slice(0, 2) : path;
const list = (items: string[]) => { try { return new Intl.ListFormat(localeTag(), { type: 'conjunction' }).format(items); } catch { return items.join(', '); } };
/** The model a folder name belongs to, in the words the Storage list uses ("Gyro-S"); the draft layer and loose files have no name of their own. */
function modelName(group: string, native: NativeState | null) {
const hit = native?.installed?.models.find(m => m.model.toLowerCase() === group.toLowerCase());
return hit ? nativeShortName(hit.model) : null;
}
function timeLeft(seconds: number) {
if (seconds <= 0) return null;
if (seconds < 60) return tr('Less than a minute left');
const minutes = Math.ceil(seconds / 60);
if (minutes <= 1) return tr('About a minute left');
if (minutes < 120) return tr('About {0} minutes left', [minutes]);
return tr('About {0} hours left', [Math.ceil(minutes / 60)]);
}
function Failure({ message, next, details }: { message: string; next?: string; details?: string }) {
return <div className="error-notice" role="alert"><Icon name="error" size={18}/><div><strong>{tr(message)}</strong>{next ? <p>{tr(next)}</p> : null}{details ? <details className="error-detail"><summary className="small">{tr('Details')}</summary><pre className="small muted">{details}</pre></details> : null}</div></div>;
}
/** Settings > Storage > Models folder: the card, the folder dialog with its three choices, the progress of a move and what came of it. */
export function ModelsFolderCard({ blocked }: { blocked: boolean }) {
const native = useNative();
const [plan, setPlan] = useState<Plan | null>(null);
const [stage, setStage] = useState<'issue' | 'drive' | 'choose' | null>(null);
const [choice, setChoice] = useState<Choice>('move');
const [pending, setPending] = useState(false);
const [error, setError] = useState<{ message: string; details?: string } | null>(null);
const [typing, setTyping] = useState(false), [typed, setTyped] = useState('');
const [jobId, setJobId] = useState<string | null>(null);
const [outcome, setOutcome] = useState<{ job: NativeJob } | null>(null);
const [notice, setNotice] = useState('');
const [stopping, setStopping] = useState(false);
const [unfinished, setUnfinished] = useState<{ from: string; to: string } | null>(null);
const [resumed, setResumed] = useState<{ from: string; to: string } | null>(null);
const folder = native?.downloadDirectory ?? '';
const free = native?.downloadFreeBytes ?? null;
const usage = native?.installed?.usage?.total_bytes ?? 0;
const moving = native?.jobs.find(j => j.kind === 'models-move' && j.state === 'running') ?? null;
const finished = jobId ? native?.jobs.find(j => j.id === jobId && j.kind === 'models-move' && j.state !== 'running') : undefined;
useEffect(() => { if (finished && !outcome) { setOutcome({ job: finished }); setStopping(false); void refreshNative(true); } }, [finished, outcome]);
const total = native?.downloadTotalBytes ?? null;
const used = free !== null && total ? Math.min(100, Math.max(1, ((total - free) / total) * 100)) : 0;
useEffect(() => { if (moving && !jobId) setJobId(moving.id); }, [moving, jobId]);
/* A move that a crash or a stop left half done: found again at the next start, with its two choices. */
useEffect(() => {
if (moving || outcome) return;
let live = true;
api.get<{ interrupted: { from: string; to: string } | null }>('/api/native/models-folder/interrupted')
.then(r => { if (live) setUnfinished(r.interrupted); }).catch(() => { if (live) setUnfinished(null); });
return () => { live = false; };
}, [moving, outcome, folder]);
async function finishMove() {
if (!unfinished) return;
setPending(true);
setError(null);
try {
const result = await api.post<NativeJob>('/api/native/models-folder/resume', { stopRunning: true });
setResumed(unfinished);
setUnfinished(null);
if (result.id) { setJobId(result.id); setOutcome(null); await refreshNative(true); }
}
catch (e) { setError({ message: (e as Error).message, details: ((e as ApiError).body as { details?: string } | undefined)?.details }); }
finally { setPending(false); }
}
async function leaveMove() {
if (!unfinished) return;
setPending(true);
setError(null);
try {
await api.post('/api/native/models-folder/leave', {});
setNotice(tr('The partial copies were removed. Your models are in {0}.', [unfinished.from]));
setUnfinished(null);
await refreshNative(true);
}
catch (e) { setError({ message: (e as Error).message, details: ((e as ApiError).body as { details?: string } | undefined)?.details }); }
finally { setPending(false); }
}
function leave() { setPlan(null); setStage(null); setError(null); setTyping(false); }
async function pick(path = '') {
setPending(true);
setError(null);
setNotice('');
try {
const result = await api.post<{ cancelled?: boolean; plan?: Plan }>('/api/native/models-folder/plan', { path });
if (result.cancelled || !result.plan) return;
const next = result.plan;
setTyping(false);
setPlan(next);
if (next.issue?.code === 'same') { setPlan(null); return; }
setChoice(next.choices.move.possible ? 'move' : 'keep');
setStage(next.issue ? 'issue' : next.wholeDrive ? 'drive' : 'choose');
}
catch (e) {
if (e instanceof ApiError && e.status === 501) { setTyping(true); setError({ message: e.message }); }
else setError({ message: (e as Error).message, details: ((e as ApiError).body as { details?: string } | undefined)?.details });
}
finally { setPending(false); }
}
async function start() {
if (!plan) return;
setPending(true);
setError(null);
try {
const result = await api.post<NativeJob & { done?: boolean; setAside?: string[] }>('/api/native/models-folder/start', { path: plan.picked, choice, stopRunning: true });
if (result.id) { setJobId(result.id); setOutcome(null); setStage(null); await refreshNative(true); setPlan(prev => prev); return; }
setNotice(choice === 'keep' ? tr('Strata now also uses {0}. New downloads go there, and your current models stay where they are.', [plan.shown])
: tr('Strata now uses {0}. Your old model files are still in {1}; Strata does not delete them.', [plan.shown, plan.current]));
await refreshNative(true);
leave();
}
catch (e) { setError({ message: (e as Error).message, details: ((e as ApiError).body as { details?: string } | undefined)?.details }); }
finally { setPending(false); }
}
async function stop() {
if (!moving) return;
setStopping(true);
try { await nativeAction(`operations/${moving.id}/cancel`); }
catch (e) { setError({ message: (e as Error).message }); }
}
function closeResult() { setOutcome(null); setJobId(null); setResumed(null); leave(); setNotice(''); }
const progress = moving?.progress as { done: number; total: number; file: string; group: string; index: number; count: number; secondsLeft: number; sameDrive: boolean } | null | undefined;
const percent = progress && progress.total > 0 ? Math.round((progress.done / progress.total) * 100) : 0;
const planNames = useMemo(() => (plan?.models ?? []).map(m => ({ ...m, label: modelName(m.group, native) })).filter(m => m.label), [plan, native]);
const savedPlan = useMemo(() => plan, [plan]);
return <>
<section className="storage-directory" aria-labelledby="models-folder-title">
<div className="storage-directory__heading"><span><Icon name="folder" size={18}/><strong id="models-folder-title">{tr('Models folder')}</strong></span><Btn size="sm" disabled={!folder || blocked || pending || !!moving} onClick={() => void pick()}>{tr(pending && !plan ? 'Waiting for the folder…' : 'Change folder')}</Btn></div>
<p className="small muted">{tr('Strata saves the big model files here.')}</p>
<code className="storage-directory__path">{folder || tr('Reading model directory…')}</code>
{free !== null ? <div className="models-free"><Bar value={used} label={tr('Disk use')}/><div className="models-free__row small"><span><strong>{tr('{0} free', [gb(free)])}</strong> {tr('on this drive')}</span><span className="muted">{tr('Your models use {0}', [gb(usage)])}</span></div></div> : null}
{blocked && !moving ? <p className="small muted">{tr('Finish or stop the download to change the folder.')}</p> : null}
{typing ? <form className="storage-directory__edit" onSubmit={e => { e.preventDefault(); if (typed.trim()) void pick(typed.trim()); }}>
<label htmlFor="models-folder-path" className="small">{tr('Directory path')}</label>
<input id="models-folder-path" className="st-input" value={typed} onChange={e => setTyped(e.target.value)} placeholder={tr('Full path to an existing folder')} spellCheck={false} autoFocus/>
<div className="row"><Btn variant="primary" type="submit" disabled={pending || !typed.trim()}>{tr('Use this directory')}</Btn></div>
</form> : null}
{unfinished && !moving ? <div className="models-notice models-notice--info" role="alert"><Icon name="info" size={18}/><div>
<strong>{tr('A move was interrupted. Finish it or leave it.')}</strong><br/>
{tr('Strata was moving your models from {0} to {1} when it stopped. Nothing is lost.', [unfinished.from, unfinished.to])}
<p className="small muted">{tr('Finishing puts all your models in {1}. Leaving it keeps them in {0} and removes the partial copies from {1}.', [unfinished.from, unfinished.to])}</p>
<div className="row"><Btn variant="primary" size="sm" disabled={pending || blocked} onClick={() => void finishMove()}>{tr('Finish the move')}</Btn><Btn size="sm" disabled={pending || blocked} onClick={() => void leaveMove()}>{tr('Leave it')}</Btn></div>
</div></div> : null}
{notice ? <p className="small storage-directory__notice" role="status"><Icon name="check" size={15}/>{notice}</p> : null}
{error && !stage ? <Failure message={error.message} details={error.details}/> : null}
</section>
<Dialog className="models-dialog" open={stage === 'issue' && !!plan?.issue} compact title={tr(plan?.issue?.code === 'not_found' ? "That folder isn't there" : "That folder can't be used")} onClose={leave}
actions={<>{plan?.issue?.code === 'not_found' ? null : <Btn onClick={leave}>{tr('Cancel')}</Btn>}<Btn variant="primary" onClick={() => { leave(); void pick(); }}>{tr('Choose another folder')}</Btn></>}>
{plan?.issue ? <Failure message={plan.issue.message} details={plan.issue.details}
next={plan.issue.code === 'not_writable' ? 'Choose a folder you own, for example one in Documents or on another drive.' : plan.issue.code === 'not_found' ? 'Check that the drive is plugged in, then pick the folder again. Your models were not touched.' : undefined}/> : null}
</Dialog>
<Dialog className="models-dialog" open={stage === 'drive' && !!plan} compact title={tr('Use this folder for your models?')} onClose={leave}
actions={<><Btn onClick={leave}>{tr('Cancel')}</Btn><Btn variant="primary" onClick={() => setStage('choose')}>{tr('Continue')}</Btn></>}>
{plan ? <>
<div className="models-picked"><span className="small muted">{tr('New folder')}</span><code className="storage-directory__path">{plan.shown}</code><span className="small muted">{tr('{0} free on this drive', [gb(plan.free_bytes)])}</span></div>
<div className="models-notice models-notice--info"><Icon name="info" size={18}/><div>{tr('You picked the whole drive {0}. Strata keeps its files in a folder of their own, so your models go in {1}.', [plan.picked, plan.shown])}</div></div>
<p className="small muted">{tr('The three choices come next.')}</p>
</> : null}
</Dialog>
<Dialog className="models-dialog" open={stage === 'choose' && !!plan} title={tr('Use this folder for your models?')} onClose={() => { if (!pending) leave(); }}
actions={plan ? <>{plan.choices.move.possible ? <Btn disabled={pending} onClick={leave}>{tr('Cancel')}</Btn> : <Btn disabled={pending} onClick={() => { leave(); void pick(); }}>{tr('Choose another folder')}</Btn>}<Btn variant="primary" disabled={pending} onClick={() => void start()}>{tr(pending ? 'Working…' : choice === 'move' ? 'Move my models' : choice === 'keep' ? 'Use both folders' : 'Start empty')}</Btn></> : null}>
{plan ? <ChooseBody plan={plan} choice={choice} setChoice={setChoice} names={planNames} error={error}/> : null}
</Dialog>
<Dialog className="models-dialog" open={!!moving && !outcome} compact noClose title={tr('Moving your models')} onClose={() => undefined}
actions={<Btn disabled={stopping} onClick={() => void stop()}>{tr(stopping ? 'Stopping…' : 'Stop moving')}</Btn>}>
<div className="models-free"><Bar value={percent} label={tr('Progress')}/><div className="models-free__row small"><span><strong>{tr('{0} of {1} GB', [progress ? (progress.done / 1e9 >= 10 ? Math.round(progress.done / 1e9) : (progress.done / 1e9).toFixed(1)) : 0, Math.round((progress?.total ?? 0) / 1e9)])}</strong></span><span className="muted">{progress && !progress.sameDrive ? timeLeft(progress.secondsLeft) : null}</span></div></div>
<p className="small">{tr('Keep Strata open. You can keep using other apps. Your models cannot be loaded until the move is done.')}</p>
{progress ? <details className="models-details"><summary>{tr('Details')}</summary><ul>
<li>{tr('Now copying: {0}, file {1} of {2}', [modelName(progress.group, native) ?? progress.group, progress.index, progress.count])}</li>
<li>{tr('From {0} to {1}', [savedPlan?.current ?? resumed?.from ?? '', savedPlan?.folder ?? resumed?.to ?? ''])}</li></ul></details> : null}
</Dialog>
<ResultDialog outcome={outcome?.job ?? null} plan={savedPlan} native={native} onClose={closeResult} onAgain={() => { closeResult(); void pick(); }}/>
</>;
}
function ChooseBody({ plan, choice, setChoice, names, error }: { plan: Plan; choice: Choice; setChoice: (c: Choice) => void; names: (Plan['models'][number] & { label: string | null })[]; error: { message: string; details?: string } | null }) {
const none = plan.models_bytes === 0;
const short = !none && !plan.choices.move.possible;
const lacking = Math.max(0, plan.needed_bytes - plan.free_bytes);
const options: { id: Choice; title: string; text: string; need: string }[] = [
{ id: 'move', title: 'Move my models there', text: none ? 'You have no models to move.' : plan.same_drive ? 'Strata moves your {0} of models to the new folder. This is the same drive, so it takes a moment.' : 'Strata copies your {0} of models to the new folder, checks them, then removes the old copies.',
need: none ? '' : plan.same_drive ? 'Needs no extra space.' : 'Needs {0} free in the new folder. You have {1}.' },
{ id: 'keep', title: 'Keep them where they are and also use the new folder', text: 'Nothing is copied. Your current models keep working, and new downloads go to the new folder.', need: short ? 'Needs no space now. New downloads must fit in {1}.' : 'Needs no space now.' },
{ id: 'empty', title: 'Start empty', text: 'Strata uses the new folder from now on and does not list your current models. Your old files are not deleted.', need: 'Needs no space now. Each model you download later needs its own space.' },
];
const values = [gb(plan.models_bytes), gb(plan.free_bytes)];
const running = plan.running > 0;
return <>
<div className="models-picked"><span className="small muted">{tr('New folder')}</span><code className="storage-directory__path">{plan.shown}</code><span className="small muted">{tr('{0} free on this drive', [gb(plan.free_bytes)])}</span></div>
{short ? <div className="models-notice models-notice--warn" role="alert"><Icon name="warning" size={18}/><div><strong>{tr('Your models need {0}, this drive has {1} free.', [gb(plan.needed_bytes), gb(plan.free_bytes)])}</strong><br/>{tr('Free up about {0} there, pick another folder, or choose one of the other two options.', [gb(lacking)])}</div></div> : null}
<fieldset className="models-choices" aria-label={tr('What should happen to your models')}>
{options.map(o => { const possible = plan.choices[o.id].possible; return <label className="models-choice" key={o.id} data-disabled={!possible || undefined}>
<input type="radio" name="models-folder-choice" checked={choice === o.id} disabled={!possible} onChange={() => setChoice(o.id)}/>
<span><strong>{tr(o.title)}</strong>{o.id === 'move' && possible && !none ? <span className="models-choice__tag">{tr('Recommended')}</span> : null}<br/><span className="small">{tr(o.text, o.id === 'move' ? [gb(plan.models_bytes)] : undefined)}</span></span>
{o.need ? <span className="models-choice__need">{tr(o.need, o.id === 'move' ? [gb(plan.needed_bytes), gb(plan.free_bytes)] : [values[0], values[1]])}</span> : null}
</label>; })}
</fieldset>
{running ? <p className="small muted">{tr('A running model is stopped first; load it again when the move is done.')}</p> : null}
{error ? <Failure message={error.message} details={error.details}/> : null}
<details className="models-details"><summary>{tr('Details')}</summary><ul>
{names.length ? <li>{tr('{0} would move.', [list(names.map(m => `${m.label}, ${gb(m.bytes)}`))])}</li> : null}
{short ? <li>{tr('Free space is measured on the drive that holds {0}.', [plan.shown])}</li> : <>
<li>{tr('Old folder: {0}', [plan.current])}</li>
<li>{tr('Strata copies one file at a time and checks its size before it removes the old one.')}</li>
<li>{tr('You can stop at any time; files already moved stay usable.')}</li></>}
</ul></details>
</>;
}
function ResultDialog({ outcome, plan, native, onClose, onAgain }: { outcome: NativeJob | null; plan: Plan | null; native: NativeState | null; onClose: () => void; onAgain: () => void }) {
if (!outcome) return null;
const result = (outcome.result ?? {}) as Outcome;
const sized = (groups: string[]) => list(groups.map(g => { const m = plan?.models.find(x => x.group === g); const name = modelName(g, native); return name ? (m ? `${name} (${gb(m.bytes)})` : name) : null; }).filter((x): x is string => !!x));
if (outcome.state === 'error') {
const full = result.code === 'disk_full';
const where = driveOf(result.to ?? '');
const some = (result.moved?.length ?? 0) > 0;
return <Dialog open compact title={tr(full ? 'The drive ran out of space' : 'The move stopped')} onClose={onClose}
actions={<><Btn onClick={onClose}>{tr('Close')}</Btn><Btn variant="primary" onClick={onAgain}>{tr('Choose another folder')}</Btn></>}>
<div className="error-notice" role="alert"><Icon name="error" size={18}/><div>
<strong>{full ? tr('{0} filled up after {1}, so the move stopped.', [where, gb(result.bytes ?? 0)]) : tr(outcome.error ?? 'The models could not be moved.')}</strong>
<p>{full ? (some ? tr('Nothing was lost. Models already moved are in the new folder, the rest are still in {0}, and the half-copied file was removed. Free up space on {1} or choose another folder, then try again.', [result.from ?? '', where])
: tr('Nothing was lost. All your models are still in {0}, and the half-copied file was removed. Free up space on {1} or choose another folder, then try again.', [result.from ?? '', where]))
: tr('Nothing was lost. Your models that were not moved are still in {0}. Choose another folder to try again.', [result.from ?? ''])}</p>
{outcome.details ? <details className="error-detail"><summary className="small">{tr('Details')}</summary><pre className="small muted">{outcome.details}</pre></details> : null}
</div></div>
</Dialog>;
}
if (result.stopped) {
const moved = sized(result.moved ?? []), left = sized(result.remaining ?? []);
return <Dialog open compact title={tr('Moving stopped')} onClose={onClose} actions={<Btn variant="primary" onClick={onClose}>{tr('Close')}</Btn>}>
<div className="models-notice models-notice--info"><Icon name="info" size={18}/><div><strong>{tr('Nothing was lost.')}</strong><br/>
{moved ? tr('{0} is in the new folder.', [moved]) + ' ' : ''}{left ? tr('{0} is still in the old folder.', [left]) + ' ' : ''}{moved && left ? tr('Both work.') : ''}</div></div>
<p className="small muted">{tr('Models in both folders are listed as normal. You can finish moving later with Change folder.')}</p>
</Dialog>;
}
return <Dialog open compact title={tr('Your models are moved')} onClose={onClose} actions={<Btn variant="primary" onClick={onClose}>{tr('Done')}</Btn>}>
<div className="models-ok"><span className="models-ok__mark"><Icon name="check" size={20}/></span><div><strong>{(() => { const [before, after] = tr('{0} now live in {1}', [gb(result.total ?? 0), '@@']).split('@@'); return <>{before}<code className="models-path">{plan?.shown ?? result.to ?? ''}</code>{after}</>; })()}</strong>
{result.sameDrive ? null : <><br/><span className="small muted">{tr('The old copies were removed and {0} is free again on {1}.', [gb(result.total ?? 0), driveOf(result.from ?? '')])}</span></>}</div></div>
</Dialog>;
}
+18 -8
View File
@@ -1,25 +1,28 @@
import { tr } from '../lib/i18n';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Screen } from './layout';
import { Btn, ErrorNotice, Icon } from './ui';
import { api } from '../lib/client';
import { parseLocation } from '../lib/router';
import { nativeAction, useNative, type NativeJob } from '../lib/native';
import { nativeCatalogModels, catalogVersionLabel } from '../lib/native-catalog';
import { sizeGb } from '../lib/format';
import { NativeProgress } from './NativeModels';
interface Preview { path: string; preview: { file_count: number; total_bytes: number; file_set_status: string; issues: string[]; messages?: string[]; projectors?: { name: string; status: string; pinned: boolean; plain: string }[]; reads_pictures?: boolean; candidate_selection: { family: string; model: string } | null } }
interface Preview { path: string; preview: { model_dir?: string; plain_issues?: string[]; already_set_up?: { data_dir?: string | null; pack?: string | null } | null; file_count: number; total_bytes: number; file_set_status: string; issues: string[]; messages?: string[]; projectors?: { name: string; status: string; pinned: boolean; plain: string }[]; reads_pictures?: boolean; candidate_selection: { family: string; model: string } | null } }
/** The words for why a folder of GGUF files cannot be used: a sentence and what to do, the checker's own codes under Details. */
function problem(issues: string[]): { message: string; next: string } {
const has = (re: RegExp) => issues.some(i => re.test(i));
if (has(/incomplete_or_invalid_shard_set|multiple_model_groups/))
return { message: 'Some parts of this model are missing, or the folder holds more than one model.', next: 'Put every part of one model in one folder, then check again.' };
if (has(/multiple_model_groups/))
return { message: 'This folder holds more than one model.', next: 'Type the full path of the .gguf file of the one you want, or choose the folder of that model.' };
if (has(/incomplete_or_invalid_shard_set/))
return { message: 'Some parts of this model are missing.', next: 'Put every part of one model (all the -0000N-of-0000M files) in one folder, then check again.' };
if (has(/unsupported_architecture|unsupported_or_missing_architecture/))
return { message: 'This model type isn’t supported by the Strata engine.', next: 'Add it as a Custom model API instead: start it with llama-server, then open Settings > Models > Add model provider > Custom model API.' };
if (has(/unrecognized_or_unsupported_model_variant|expert_quantization|tokenizer/))
return { message: 'Strata can’t run these files yet.', next: 'Strata runs the Qwen3.8-Flash-Next models. Download one from Models, or choose a folder with its files.' };
return { message: 'These files could not be checked.', next: 'Check that the folder holds complete GGUF files, then try again.' };
return { message: 'These files could not be checked.', next: 'Check that the folder holds complete GGUF files, then try again. The file named under Details says what is wrong with it.' };
}
/** "Use a local GGUF": choose the folder of a model you already have, Strata checks it, and prepares and loads it without copying
@@ -47,13 +50,18 @@ export function NativeLocalModel({ onBack }: { onBack: () => void }) {
catch (e) { setError(e instanceof Error ? e.message : String(e)); }
finally { setPending(false); }
}
/* The first-run page hands over the folder the person chose (#3507): it is checked at once, as if they had pressed Check files. */
useEffect(() => { const path = parseLocation(window.location.hash).query.get('path'); if (path) { setTyped(path); void check(path); } }, []);
/* Files that sit in a Strata data folder (<data>/models/<tag>) with their prepared model data beside them: that folder is used as the data
* folder, so the prepared data is not made a second time. A folder the installer would not accept as a data folder is not passed. */
const ownDataFolder = (() => { const d = checked?.preview.already_set_up?.pack ? checked.preview.already_set_up.data_dir : null; return d && /strata/i.test(d.split(/[\\/]/).filter(Boolean).pop() ?? '') ? d : null; })();
async function prepare() {
if (!usable || !selection || !checked)
return;
setPending(true);
setError('');
try {
const result = await nativeAction<NativeJob>('operations', { kind: 'install', args: { family: selection.family, model: selection.model, gguf_dir: checked.path, context: native?.catalog?.recommendation?.context ?? 32768, vision: checked.preview.projectors?.some(p => p.status === 'ok' && p.pinned) && native?.visionReader ? 'yes' : 'no', kv: 'int8', loadAfter: true } });
const result = await nativeAction<NativeJob>('operations', { kind: 'install', args: { family: selection.family, model: selection.model, gguf_dir: checked.preview.model_dir ?? checked.path, ...(ownDataFolder ? { data_dir: ownDataFolder } : {}), context: native?.catalog?.recommendation?.context ?? 32768, vision: checked.preview.projectors?.some(p => p.status === 'ok' && p.pinned) && native?.visionReader ? 'yes' : 'no', kv: 'int8', loadAfter: true } });
setJobId(result.id);
}
catch (e) { setError(e instanceof Error ? e.message : String(e)); }
@@ -64,8 +72,8 @@ export function NativeLocalModel({ onBack }: { onBack: () => void }) {
<div className="native-model-layout">
<div className="stack stack--lg">
<section className="st-card native-choice"><h2 className="h3">{tr("Use a local GGUF…")}</h2>
<p className="small muted">{tr("Already have a supported model on this computer? Choose the folder that holds its files. Strata checks them, prepares the model and loads it. Nothing is copied or downloaded.")}</p>
<label className="input-field"><span>{tr("Folder with the GGUF files")}</span><input className="st-input" value={typed} placeholder={tr("Full path to the folder")} spellCheck={false} onChange={e => setTyped(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && typed.trim()) void check(typed); }}/></label>
<p className="small muted">{tr("Already have a supported model on this computer? Choose the folder that holds its files, or one of its .gguf files. Strata checks them, prepares the model and loads it. Nothing is copied or downloaded.")}</p>
<label className="input-field"><span>{tr("Folder with the GGUF files, or one .gguf file")}</span><input className="st-input" value={typed} placeholder={tr("Full path to the folder or the file")} spellCheck={false} onChange={e => setTyped(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && typed.trim()) void check(typed); }}/></label>
<div className="native-settings-actions"><Btn icon="folder" disabled={pending || !!active} onClick={() => void check('')}>{tr("Choose folder")}</Btn><Btn disabled={pending || !typed.trim() || !!active} onClick={() => void check(typed)}>{tr(pending && !jobId ? 'Checking…' : 'Check files')}</Btn></div>
</section>
{tr(checked ? <section className="st-card native-choice">{tr(usable ? <>
@@ -77,6 +85,8 @@ export function NativeLocalModel({ onBack }: { onBack: () => void }) {
</> : <>
<h2 className="h3">{tr(found!.message)}</h2>
<p className="small muted">{tr(found!.next)}</p>
{tr(checked.preview.plain_issues?.map(m => <p className="small muted" key={m}>{tr(m)}</p>))}
{tr(checked.preview.messages?.map(m => <p className="small muted" key={m}>{tr(m)}</p>))}
{tr(checked.preview.issues.length ? <details><summary className="small">{tr("Details")}</summary><pre className="small muted error-detail-text">{checked.preview.issues.join(String.fromCharCode(10))}</pre></details> : null)}
</>)}</section> : null)}
{tr(active ? <NativeProgress job={active}/> : job?.state === 'error' ? <ErrorNotice message={job.error ?? job.summary} detail={job.details}/> : job?.state === 'done' ? <p className="small" role="status"><Icon name="check" size={15}/> {tr(job.summary)}</p> : null)}
+35 -27
View File
@@ -4,7 +4,9 @@ import { Screen } from './layout';
import { VersionPicker } from './VersionPicker';
import { FamilySwitch } from './FamilySwitch';
import { PcFitCard } from './PcFitCard';
import { fitOn } from '../lib/native-fit';
import { fitOn, memoryNeeded } from '../lib/native-fit';
import { GpuRoles } from './GpuRoles';
import { fromLegacy, normalize as normalizeRoles, order as roleOrder, recommend as recommendRoles, rows as roleRows, sameRoles, vramOn, type RoleCard, type Roles } from '../lib/gpu-roles';
import { Btn, Dialog, ErrorNotice, Icon, Select } from './ui';
import { installProgress, installStage, stageText, useDownloadEta } from '../lib/download-eta';
import { useKept } from '../lib/kept-state';
@@ -37,8 +39,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
// The choices are kept while the page is closed (Models -> Monitor -> Models used to start them over and a person lost track of what was downloading).
const [family, setFamily] = useKept('models.family', ''), [size, setSize] = useKept('models.size', ''), [context, setContext] = useKept('models.context', 32768), [vision, setVision] = useKept('models.vision', 'yes');
const manual = useStore(s => s.setup?.choices?.onboarding?.manual ?? false);
const [expert, setExpert] = useKept('models.expert', manual), [multiple, setMultiple] = useKept('models.multiple', false), [gpus, setGpus] = useKept<number[]>('models.gpus', []), [kv, setKv] = useKept('models.kv', 'int8'), [lowRam, setLowRam] = useKept('models.lowRam', 'auto'), [split, setSplit] = useKept('models.split', 'auto'), [deviceChoice, setDeviceChoice] = useKept('models.device', 'auto');
const [cardsKey, setCardsKey] = useKept<string | null>('models.cards', null);
const [expert, setExpert] = useKept('models.expert', manual), [multiple, setMultiple] = useKept('models.multiple', false), [roles, setRoles] = useKept<Roles | null>('models.roles', null), [kv, setKv] = useKept('models.kv', 'int8'), [lowRam, setLowRam] = useKept('models.lowRam', 'auto'), [split, setSplit] = useKept('models.split', 'auto');
const [resourceProfile, setResourceProfile] = useKept<'work' | 'performance'>('models.profile', 'work');
const [pending, setPending] = useState(false), [error, setError] = useState(''), [plan, setPlan] = useState<Record<string, any> | null>(null), [loadedError, setLoadedError] = useState(''), [tuneOpen, setTuneOpen] = useState(false), [jobId, setJobId] = useKept('models.job', ''), [manage, setManage] = useState(false);
const [toastedJob, setToastedJob] = useKept('models.toasted', ''), [appliedSig, setAppliedSig] = useKept('models.applied', '');
@@ -53,6 +54,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
// The model that is running is shown as running, with Unload: not offered for loading again.
const downloaded = native?.installed?.models ?? [], allSizes = families.flatMap(f => f.sizes);
// This computer for the fit figures: the biggest usable graphics card, or the ones chosen under Expert settings.
const engineVendor = native?.hardware?.engine_vendor ?? null, roleCards: RoleCard[] = roleRows(native?.hardware?.gpus as RoleCard[] | undefined, engineVendor);
const pcGpus = (native?.hardware?.gpus ?? []).filter(g => g.usable !== false), pcGpu = [...pcGpus].sort((x, y) => y.vram_gb - x.vram_gb)[0] ?? null;
const pcRam = native?.hardware?.ram_gb || hardware?.memory.totalGb || null, pcVram = pcGpu?.vram_gb ?? 0;
// What the models that stay loaded (Run multiple models) already take: the fit figures are for what is left.
@@ -84,20 +86,21 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
// Graphics cards (strata_control/gpu_fit.py: the one rule of the app, the Terminal and setup): the recommended option is pre-selected, the person may pick another
// A card a loaded model already uses (a second model on it usually does not fit): said on its row, and not the pre-selected one while another is free.
const usedBy = (cards: number[]) => (native?.instances ?? []).filter(i => i.status !== 'error' && i.model !== selectedSize?.id && i.gpus.some(g => cards.includes(g))).map(i => i.label);
const cardOptions = selectedSize?.gpu_options && selectedSize.gpu_options.options.length >= 2 ? selectedSize.gpu_options : null;
const loadedCards = loadedSelected ? cardOptions?.options.find(o => o.cards.length === loadedSelected.gpus.length && o.cards.every(c => loadedSelected.gpus.includes(c))) : undefined;
const activeCards = cardOptions ? (cardOptions.options.find(o => o.key === cardsKey) ?? loadedCards ?? (multiple ? cardOptions.options.find(o => o.recommended && !usedBy(o.cards).length) ?? cardOptions.options.find(o => !usedBy(o.cards).length) : undefined) ?? cardOptions.options.find(o => o.recommended) ?? cardOptions.options[0]) : null;
const cardsGpus: number[] = activeCards?.cards ?? [];
const chosenCards = expert && gpus.length ? gpus : cardsGpus;
const fitHardware = { ram_gb: pcRam ? Math.max(0, pcRam - keptRam) : pcRam, vram_gb: Math.max(0, pcVram - keptVramOn(chosenCards)) };
// "(card 1)" in a card name is the number the screens call GPU 2 (Monitor, the model picker, the loaded list): one numbering everywhere.
const gpuNumbers = (text: string) => text.replace(/\(card (\d+)\)/g, (_m, n) => `(GPU ${Number(n) + 1})`);
// The fit sentence for a card choice, less what the kept models take of RAM and of that card's memory.
const lessKept = (words: string, cards: number[]) => { const gone = Math.round(keptRam + keptVramOn(cards)); return gone > 0 ? words.replace(/of the (\d+) GB available/, (_m, n) => `of the ${Math.max(0, Number(n) - gone)} GB available`) : words; };
const shownSize = useRef(selectedSize?.id);
useEffect(() => { if (shownSize.current !== selectedSize?.id) { shownSize.current = selectedSize?.id; setCardsKey(null); } }, [selectedSize?.id]);
const values = useMemo(() => ({ family: selectedSize?.sourceFamily, model: selectedSize?.model, context, vision: selectedSize?.images ? vision : 'no', kv, low_ram: lowRam, resourceProfile, ...(expert && gpus.length ? { gpus: gpus.join(',') } : cardsGpus.length > 1 ? { gpus: cardsGpus.join(',') } : cardsGpus.length === 1 ? { gpu: cardsGpus[0] } : {}), loadAfter: true, multiple: expert && multiple }), [selectedSize?.sourceFamily, selectedSize?.model, context, vision, kv, lowRam, resourceProfile, gpus.join(','), cardsGpus.join(','), expert, multiple]);
const downloadValues = { ...values, vision: selectedSize?.images ? 'yes' : 'no', loadVision: selectedSize?.images ? vision : 'no' };
// Main / Helper / Off per card (src/lib/gpu-roles.ts, strata_control/gpu_roles.py): the recommendation until the person changes a role; a loaded model shows what it was started with.
// With Run multiple models, a card another loaded model already uses starts Off for this one (while a free card exists).
const busy = (i: number) => expert && multiple && usedBy([i]).length > 0, freeCards = roleCards.filter(c => !busy(c.index));
const recommended = freeCards.length && freeCards.length < roleCards.length ? { ...recommendRoles(freeCards, engineVendor), roles: { ...Object.fromEntries(roleCards.map(c => [c.index, 'off'])), ...recommendRoles(freeCards, engineVendor).roles } as Roles } : recommendRoles(roleCards, engineVendor);
const loadedRoles = loadedSelected && roleCards.length > 1 ? fromLegacy(loadedSelected.gpus, roleCards, 'as_given') : null;
const effectiveRoles: Roles = roleCards.length > 1 ? (roles ? normalizeRoles(roles, roleCards) : loadedRoles ?? recommended.roles) : recommended.roles;
const customRoles = !sameRoles(effectiveRoles, recommended.roles);
const changeRoles = (next: Roles) => setRoles(sameRoles(next, recommended.roles) ? null : next);
const cardsGpus: number[] = roleCards.length > 1 ? roleOrder(effectiveRoles, roleCards) : [];
const chosenCards = cardsGpus;
const fitHardware = { ram_gb: pcRam ? Math.max(0, pcRam - keptRam) : pcRam, vram_gb: Math.max(0, (roleCards.length > 1 ? vramOn(effectiveRoles, roleCards) : pcVram) - keptVramOn(chosenCards)) };
// A layer split written for another number of cards no longer fits: back to automatic.
useEffect(() => { if (cardsGpus.length <= 1 && split !== 'auto') setSplit('auto'); }, [cardsGpus.length]);
const values = useMemo(() => ({ family: selectedSize?.sourceFamily, model: selectedSize?.model, context, vision: selectedSize?.images ? vision : 'no', kv, low_ram: lowRam, resourceProfile, ...(cardsGpus.length > 1 ? { gpus: cardsGpus.join(',') } : cardsGpus.length === 1 ? { gpu: cardsGpus[0] } : {}), loadAfter: true, multiple: expert && multiple }), [selectedSize?.sourceFamily, selectedSize?.model, context, vision, kv, lowRam, resourceProfile, cardsGpus.join(','), expert, multiple]);
const downloadValues = { ...values, vision: selectedSize?.images && vision !== 'no' ? 'yes' : 'no', loadVision: selectedSize?.images ? vision : 'no' };
useEffect(() => { if (!native?.catalog || family)
return; const current = native.instances.find(i => i.id === native.defaultId); const rec = native.catalog.recommendation ?? { family: null, model: null }; const loadedFamily = families.find(f => f.sizes.some(s => s.id === current?.model)); const have = new Set((native.installed?.models ?? []).filter(m => m.ready).map(m => m.model)); const installedFamily = families.find(f => f.sizes.some(s => have.has(s.id))); const f = loadedFamily ?? installedFamily ?? families.find(f => f.family === rec.family || f.sizes.some(s => s.sourceFamily === rec.family)) ?? families[0]; if (f) {
setFamily(f.family);
@@ -147,8 +150,8 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
setPending(false);
} }
async function load() { if (!selectedSize)
return; await perform(async () => { if (installed?.ready && context === installed.context && (!selectedSize?.images || installed.images && installed.images !== 'off') && (!installed.kv || kv === installed.kv || kv === tunedNow?.fields.kv) && (lowRam === (installed.lowRamMode ?? 'auto') || lowRam === tunedNow?.fields.lowRam)) {
const result = await nativeAction<NativeJob>('operations', { kind: 'load', args: { model: selectedSize.id, resourceProfile, vision: selectedSize?.images ? vision : 'no', multiple: expert && multiple, ...(expert && gpus.length ? { gpus, layerSplit: split } : cardsGpus.length ? { gpus: cardsGpus } : {}), values: {} } });
return; await perform(async () => { if (installed?.ready && context === installed.context && (!selectedSize?.images || vision === 'no' || installed.images && installed.images !== 'off') && (!installed.kv || kv === installed.kv || kv === tunedNow?.fields.kv) && (lowRam === (installed.lowRamMode ?? 'auto') || lowRam === tunedNow?.fields.lowRam)) {
const result = await nativeAction<NativeJob>('operations', { kind: 'load', args: { model: selectedSize.id, resourceProfile, vision: selectedSize?.images ? vision : 'no', multiple: expert && multiple, ...(roleCards.length > 1 ? (roles && customRoles ? { gpuRoles: effectiveRoles, layerSplit: expert && cardsGpus.length > 1 ? split : 'auto' } : cardsGpus.length ? { gpus: cardsGpus, gpusFromRecommendation: true, layerSplit: expert && cardsGpus.length > 1 ? split : 'auto' } : {}) : {}), values: {} } });
setJobId(result.id);
}
else
@@ -164,21 +167,22 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
setPending(false);
} }
async function download() { await perform(async () => { const result = await nativeAction<NativeJob>('operations', { kind: 'install', args: downloadValues }); setJobId(result.id); setPlan(null); }); }
return <Screen plainHeader title={<span className="sr-only">{tr("Models")}</span>} titleDetail={<FamilySwitch families={families} selected={selectedFamily?.family} onPick={f => { setFamily(f.family); setSize((f.sizes.find(s => native?.installed?.models.some(m => m.model === s.id && m.ready)) ?? f.sizes[0])?.id ?? ''); setVision('yes'); setError(''); }}/>} actions={<Btn icon="sliders" aria-pressed={expert} aria-expanded={expert} aria-controls="native-expert-settings" onClick={() => { setExpert(v => !v); setMultiple(false); setGpus([]); setDeviceChoice('auto'); }}>{tr(expert ? 'Close expert mode' : 'Expert mode')}</Btn>}>
return <Screen plainHeader title={<span className="sr-only">{tr("Models")}</span>} titleDetail={<FamilySwitch families={families} selected={selectedFamily?.family} onPick={f => { setFamily(f.family); setSize((f.sizes.find(s => native?.installed?.models.some(m => m.model === s.id && m.ready)) ?? f.sizes[0])?.id ?? ''); setVision('yes'); setError(''); }}/>} actions={<Btn icon="sliders" aria-pressed={expert} aria-expanded={expert} aria-controls="native-expert-settings" onClick={() => { setExpert(v => !v); setMultiple(false); }}>{tr(expert ? 'Close expert mode' : 'Expert mode')}</Btn>}>
<div className="native-model-layout native-model-layout--fit">
<div className="stack">
{tr(native?.instances.length && expert ? <section className="st-card native-running"><h2 className="h4">{tr(native.instances.length === 1 ? 'Loaded model' : 'Loaded models')}</h2>{tr(native.instances.map(i => <div key={i.id}><span><strong>{tr(i.label)}</strong><small>{tr(i.gpus.length ? i.gpus.map(g => `GPU ${g + 1}`).join(' + ') : 'Automatic GPU')} · {tr(i.status === 'ready' ? i.phase : i.status)}{tr(native.defaultId === i.id ? ' · Chat default' : '')}</small>{tr(i.error ? <small className="inline-error">{tr(plainFailureText(i.error).message)}</small> : null)}</span>{tr(expert && multiple && native.defaultId !== i.id ? <Btn size="sm" variant="ghost" onClick={() => void loadedAction(`instances/${i.id}/default`)}>{tr("Use in chat")}</Btn> : null)}<Btn size="sm" disabled={pending || !!active} onClick={() => void loadedAction(`instances/${i.id}/unload`)}>{tr("Unload")}</Btn></div>))}{tr(loadedError ? <ErrorNotice message={loadedError} next={tr("Stop or answer the chat that is still using this model, then try again.")}/> : null)}</section> : null)}
{tr(native?.instances.length && expert ? <section className="st-card native-running"><h2 className="h4">{tr(native.instances.length === 1 ? 'Loaded model' : 'Loaded models')}</h2>{tr(native.instances.map(i => <div key={i.id}><span><strong>{tr(i.label)}</strong><small>{tr(i.gpus.length ? i.gpus.map((g, n) => `GPU ${g + 1}${i.gpus.length > 1 && n === i.gpus.length - 1 ? ' (Main)' : ''}`).join(' + ') : 'Automatic GPU')} · {tr(i.status === 'ready' ? i.phase : i.status)}{tr(native.defaultId === i.id ? ' · Chat default' : '')}</small>{tr(i.error ? <small className="inline-error">{tr(plainFailureText(i.error).message)}</small> : null)}</span>{tr(expert && multiple && native.defaultId !== i.id ? <Btn size="sm" variant="ghost" onClick={() => void loadedAction(`instances/${i.id}/default`)}>{tr("Use in chat")}</Btn> : null)}<Btn size="sm" disabled={pending || !!active} onClick={() => void loadedAction(`instances/${i.id}/unload`)}>{tr("Unload")}</Btn></div>))}{tr(loadedError ? <ErrorNotice message={loadedError} next={tr("Stop or answer the chat that is still using this model, then try again.")}/> : null)}</section> : null)}
<section className="st-card native-choice">{tr(selectedFamily && (!selectedSize || selectedSize.needsNewerEngine) ? <><h2 className="h3">{tr("This model needs a newer engine.")}</h2><p className="small muted">{tr("Update your engine or import a local GGUF for this model.")}</p><Btn onClick={() => { setSettingsSection('Engine'); go('settings'); }}>{tr("Update engine")}</Btn><Btn variant="ghost" onClick={onImport ?? onOtherModels}>{tr("Use a local GGUF\u2026")}</Btn></> : null)}{tr(!selectedFamily ? <><h2 className="h3">{tr("Set up Strata")}</h2><p className="small muted">{tr("Connect your local installation to choose an optimized model.")}</p><Btn variant="primary" onClick={onConnectEngine ?? (() => { setSettingsSection('Engine'); go('settings'); })}>{tr("Connect engine")}</Btn>{tr(native?.error ? <p className="small inline-error">{tr(native.error)}</p> : null)}<Btn variant="ghost" onClick={onOtherModels}>{tr("Other local models")}</Btn></> : null)}
{tr(selectedSize ? <>
<div className="native-choice__head"><span className="native-choice__label">{tr("Version")}</span><span className="native-choice__tools"><NativePresets values={values} onApply={v => { const f = families.find(f => f.family === v.family || f.sizes.some(s => s.sourceFamily === v.family)); if (!f)
return; presetPending.current = true; setFamily(f.family); setSize(f.sizes.find(s => s.model === v.model && (s.sourceFamily === v.family || f.family === v.family))?.id ?? ''); setContext(v.context ?? 32768); setVision(v.vision ?? 'no'); setKv(v.kv ?? 'int8'); setLowRam(v.low_ram ?? 'auto'); }}/><Btn icon="models" onClick={() => setManage(true)}>{tr("Manage models")}</Btn></span></div>
<div className="native-version"><VersionPicker versions={allSizes} selectedId={selectedSize.id} hardware={fitHardware} downloaded={readyIds} onSelect={id => { const f = families.find(f => f.sizes.some(s => s.id === id)); if (f) { setFamily(f.family); setSize(id); setError(''); } }} onOwnGguf={() => (onImport ?? onOtherModels)()}/></div>
<div className="native-version"><VersionPicker pick={native?.catalog?.recommendation} versions={allSizes} selectedId={selectedSize.id} hardware={fitHardware} downloaded={readyIds} onSelect={id => { const f = families.find(f => f.sizes.some(s => s.id === id)); if (f) { setFamily(f.family); setSize(id); setError(''); } }} onOwnGguf={() => (onImport ?? onOtherModels)()}/></div>
{tr(noFit ? <p className="small muted native-spill-note"><Icon name="disk" size={16}/>{tr("May run slower \u00B7 Model data can be read from SSD.")}</p> : null)}
<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={(native?.catalog?.contexts ?? [8192, 32768, 65536, 131072, 262144]).map(n => ({ value: String(n), label: `${n / 1024}K` }))} onChange={n => setContext(Number(n))}/></div>
<div className="native-choice__col"><span className="native-choice__label">{tr("Conversation context")}</span><Select ariaLabel={tr("Conversation context")} value={String(context)} options={contextChoices(native?.catalog?.contexts ?? [8192, 32768, 65536, 131072, 262144], context).map(n => ({ value: String(n), label: n % 1024 === 0 ? `${n / 1024}K` : `${n.toLocaleString()} tokens` }))} onChange={n => setContext(Number(n))}/></div>
<div className="native-choice__col"><span className="native-choice__label">{tr("Computer use")}</span><div className="native-resource-choice"><button aria-pressed={resourceProfile === 'work'} data-selected={resourceProfile === 'work'} onClick={() => setResourceProfile('work')}><Icon name="computer" size={20}/><span><strong>{tr("Leave 20% for my work")}</strong><small>{tr("Headroom for your other apps")}</small></span>{tr(resourceProfile === 'work' ? <Icon name="check" size={15}/> : null)}</button><button aria-pressed={resourceProfile === 'performance'} data-selected={resourceProfile === 'performance'} onClick={() => setResourceProfile('performance')}><Icon name="bolt" size={20}/><span><strong>{tr("Full performance")}</strong><small>{tr("Give Strata priority")}</small></span>{tr(resourceProfile === 'performance' ? <Icon name="check" size={15}/> : null)}</button></div></div>
</div>
{tr(cardOptions ? <div className="native-cards"><span className="native-choice__label">{tr("Graphics cards")}</span><div className="native-cards__list" role="radiogroup" aria-label={tr("Graphics cards")}>{cardOptions.options.map(o => { const on = activeCards?.key === o.key; return <button key={o.key} type="button" role="radio" aria-checked={on} data-selected={on} disabled={!!active} className="native-cards__opt" onClick={() => setCardsKey(o.key)}><span className="native-cards__dot" aria-hidden="true"/><span><strong>{gpuNumbers(o.label)}</strong><small data-warn={o.verdict === 'spills' || o.verdict === 'tight' || usedBy(o.cards).length > 0}>{[lessKept(o.words, o.cards), ...o.notes, ...(usedBy(o.cards).length ? [tr('Already used by {0}', [usedBy(o.cards).join(', ')])] : [])].join('. ')}.</small></span></button>; })}{cardOptions.ignored.length ? <p className="small muted native-cards__note">{`Not used: ${cardOptions.ignored.map(g => `${g.name ?? 'a card'} (${g.why_ignored})`).join('; ')}.`}</p> : null}</div></div> : null)}
{roleCards.length > 1 ? <GpuRoles cards={roleCards} roles={effectiveRoles} custom={customRoles} recommendedRoles={recommended.roles} vendor={engineVendor} disabled={!!active} onChange={changeRoles} onBack={() => setRoles(null)} memoryGb={selectedSize ? memoryNeeded(selectedSize) || null : null} ramGb={pcRam} spillGb={selectedSize ? fitOn(selectedSize, fitHardware).spillGb : 0} usedBy={i => usedBy([i])}/> : null}
{tr(roleCards.length > 1 && (native?.hardware?.gpus ?? []).length > roleCards.length ? <p className="small muted native-cards__note">{tr('Not used: {0} (another make of graphics card than this engine drives).', [(native?.hardware?.gpus ?? []).filter(g => !roleCards.some(r => r.index === g.index && r.vendor === g.vendor)).map(g => g.name).join(', ')])}</p> : null)}
{tr(selectedSize?.imagesCapable && !selectedSize.images ? <p className="small muted native-spill-note">{tr("This model can read pictures, but the engine installed here does not include the picture reader yet, so Read pictures is off. Update the engine in Settings → About to turn it on.")}</p> : null)}
{tr(selectedSize?.images ? <><div className="native-image-reading"><label><input type="checkbox" checked={vision !== 'no'} disabled={!selectedSize?.images || !!active} onChange={e => setVision(e.target.checked ? 'yes' : 'no')}/><span><strong>{tr("Read pictures ")}<small>{tr("On by default")}</small></strong><small>{tr("Understand photos, screenshots and charts. The picture files download automatically. Turn it off to save memory.")}</small></span></label>{tr(expert && vision !== 'no' ? <Select ariaLabel={tr("Image encoder device")} value={vision === 'yes' ? 'yes' : vision} options={[{ value: 'yes', label: 'Automatic' }, { value: 'gpu', label: 'GPU' }, { value: 'cpu', label: 'CPU' }]} onChange={setVision}/> : null)}</div>{tr(vision !== 'no' && installed?.ready && installed.visionPrepared === false ? <div className="native-vision-missing" role="status"><span>{tr("Picture reading needs a few more files that are not on this computer yet.")}</span><Btn size="sm" disabled={pending || !!active} onClick={() => void download()}>{tr("Download picture-reading files")}</Btn></div> : null)}</> : null)}
</> : null)}
@@ -187,10 +191,10 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
{tr(job?.state === 'error' ? <ErrorNotice message={job.error ?? job.summary} action={tr("Retry")} onRetry={() => void load()}/> : null)}
{tr(error ? <ErrorNotice message={error}/> : null)}
{selectedSize && installed?.ready ? <NativeTuneBadge model={selectedSize.id} resourceProfile={resourceProfile} vision={selectedSize.images ? vision : 'no'}/> : null}
<div className="native-model-actions">{tr(loadedSelected ? <><Btn disabled={pending || !!active} onClick={() => void perform(() => nativeAction(`instances/${loadedSelected.id}/unload`))}>{tr("Unload")}</Btn>{tr(onReady ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <span className="native-ready">{tr("Ready")}</span>)}</> : onReady && native?.instances.some(i => i.status === 'ready') ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <><Btn icon="gauge" disabled={!installed?.ready || !native?.available || !!active || !selectedSize || selectedSize.needsNewerEngine} onClick={() => setTuneOpen(true)}>{tr("Tune model")}</Btn><Btn variant="primary" icon="models" disabled={pending || !native?.available || !!active || !selectedSize || !!selectedSize.needsNewerEngine || expert && deviceChoice === 'split' && gpus.length < 2} onClick={() => void load()}>{tr(pending ? 'Checking…' : installed?.ready ? 'Load model' : 'Download & load')}</Btn></>)}</div>
<div className="native-model-actions">{tr(loadedSelected ? <><Btn disabled={pending || !!active} onClick={() => void perform(() => nativeAction(`instances/${loadedSelected.id}/unload`))}>{tr("Unload")}</Btn>{tr(onReady ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <span className="native-ready">{tr("Ready")}</span>)}</> : onReady && native?.instances.some(i => i.status === 'ready') ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <><Btn icon="gauge" disabled={!installed?.ready || !native?.available || !!active || !selectedSize || selectedSize.needsNewerEngine} onClick={() => setTuneOpen(true)}>{tr("Tune model")}</Btn><Btn variant="primary" icon="models" disabled={pending || !native?.available || !!active || !selectedSize || !!selectedSize.needsNewerEngine} onClick={() => void load()}>{tr(pending ? 'Checking…' : installed?.ready ? 'Load model' : 'Download & load')}</Btn></>)}</div>
</section>
{tr(expert && IMAGE_GENERATION_VISIBLE ? <><details className="native-generation"><summary>{tr("Image generation ")}<span className="muted">{tr("Optional")}</span></summary><RunImageModel library={imageLibrary} disabled={pending || !!active} onChange={() => { }}/></details></> : null)}
{tr(expert ? <section id="native-expert-settings" ref={expertSection} className="st-card native-execution"><h2 className="h4">{tr("Expert settings")}</h2>{tr(tunedNow ? <p className="small muted native-tuned-note">{tr("Showing the values of your tuning '{0}'.", [tunedNow.name])}</p> : null)}<label className="native-toggle"><span><strong>{tr("Run multiple models")}</strong><small>{tr("Loading another model keeps the others loaded.")}</small></span><button className="st-toggle" role="switch" aria-label={tr("Run multiple models")} aria-checked={multiple} onClick={() => setMultiple(v => !v)}/></label><h3 className="small strong">{tr("Run on")}</h3><p className="small muted">{tr("Automatic uses the installation\u2019s GPU placement. Choose a GPU, or split this model across several GPUs.")}</p><Select ariaLabel={tr("Run on")} value={deviceChoice} options={[{ value: 'auto', label: 'Automatic' }, ...(native?.hardware?.gpus ?? []).map(g => ({ value: String(g.index), label: `GPU ${g.index + 1} · ${g.name}` })), ...((native?.hardware?.gpus.length ?? 0) > 1 ? [{ value: 'split', label: 'Split across GPUs…' }] : [])]} onChange={value => { setDeviceChoice(value); setGpus(value === 'auto' ? [] : value === 'split' ? (native?.hardware?.gpus ?? []).map(g => g.index) : [Number(value)]); }}/>{tr(deviceChoice === 'split' ? <div className="native-gpu-options">{tr(native?.hardware?.gpus.map(g => <label key={g.index}><input type="checkbox" checked={gpus.includes(g.index)} onChange={e => setGpus(old => e.target.checked ? [...old, g.index] : old.filter(i => i !== g.index))}/><span>{tr("GPU ")}{tr(g.index + 1)} · {tr(g.name)}<small>{tr(g.vram_gb)}{tr(" GB VRAM")}</small></span></label>))}</div> : null)}{tr(gpus.length > 1 ? <label className="input-field"><span>{tr("Layer split")}</span><input className="st-input" value={split} placeholder={tr("auto or 16,32")} onChange={e => setSplit(e.target.value)}/></label> : null)}<div className="native-field-grid"><label className="input-field"><span>{tr("Context memory")}</span><Select ariaLabel={tr("Native KV format")} value={kv} options={[{ value: 'int8', label: 'INT8' }, { value: 'q4_0', label: 'Q4' }, { value: 'k8v4', label: 'K8 / V4' }]} onChange={setKv}/></label><label className="input-field"><span>{tr("Low-RAM mode")}</span><Select ariaLabel={tr("Native low RAM mode")} value={lowRam} options={['auto', 'resident', 'mmap'].map(v => ({ value: v, label: v === 'auto' ? 'Automatic' : v === 'resident' ? 'Keep experts in RAM' : 'Read experts from disk' }))} onChange={setLowRam}/></label></div>{tr(installed ? <><NativeSettingsFile model={installed.model}/><NativeSettings model={installed.model}/></> : <p className="small muted">{tr("More engine parameters become available after preparing this version.")}</p>)}</section> : null)}
{tr(expert ? <section id="native-expert-settings" ref={expertSection} className="st-card native-execution"><h2 className="h4">{tr("Expert settings")}</h2>{tr(tunedNow ? <p className="small muted native-tuned-note">{tr("Showing the values of your tuning '{0}'.", [tunedNow.name])}</p> : null)}<label className="native-toggle"><span><strong>{tr("Run multiple models")}</strong><small>{tr("Loading another model keeps the others loaded.")}</small></span><button className="st-toggle" role="switch" aria-label={tr("Run multiple models")} aria-checked={multiple} onClick={() => setMultiple(v => !v)}/></label>{tr(cardsGpus.length > 1 ? <label className="input-field"><span>{tr("Layer split")}</span><input className="st-input" value={split} placeholder={tr("auto or 16,32")} onChange={e => setSplit(e.target.value)}/></label> : null)}<div className="native-field-grid"><label className="input-field"><span>{tr("Context memory")}</span><Select ariaLabel={tr("Native KV format")} value={kv} options={[{ value: 'int8', label: 'INT8' }, { value: 'q4_0', label: 'Q4' }, { value: 'k8v4', label: 'K8 / V4' }]} onChange={setKv}/></label><label className="input-field"><span>{tr("Low-RAM mode")}</span><Select ariaLabel={tr("Native low RAM mode")} value={lowRam} options={['auto', 'resident', 'mmap'].map(v => ({ value: v, label: v === 'auto' ? 'Automatic' : v === 'resident' ? 'Keep experts in RAM' : 'Read experts from disk' }))} onChange={setLowRam}/></label></div>{tr(installed ? <><NativeSettingsFile model={installed.model}/><NativeSettings model={installed.model}/></> : <p className="small muted">{tr("More engine parameters become available after preparing this version.")}</p>)}</section> : null)}
</div>
<aside className="stack stack--lg"><PcFitCard gpuName={pcGpu?.name} ramGb={pcRam} vramGb={pcVram} inUse={keptModels.length ? { ramGb: Math.round(keptRam), vramGb: Math.round(keptVramOn(chosenCards)) } : null} fit={selectedSize ? fitOn(selectedSize, fitHardware) : null}/><section className="st-card"><h2 className="h4">{tr("Model files")}</h2><p className="small muted">{tr("Strata prepares all shards, the draft layer and shared files. Completed downloads are reused.")}</p><code className="native-path">{installed?.dataFolder ?? native?.downloadDirectory ?? native?.installed?.data_folder ?? 'Choose an installation'}</code><Btn size="sm" variant="ghost" onClick={() => { setSettingsSection('Storage'); go('settings'); }}>{tr("Storage settings")}</Btn></section><Btn size="sm" variant="ghost" onClick={onOtherModels}>{tr("Other local models\u2026")}</Btn></aside>
</div>
@@ -209,6 +213,10 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
</Screen>;
}
/** setup's "5/7: downloading …" as a sentence a person can read. */
/** The listed context sizes, plus the model's own when it is a size set by hand in its run config (the select would show nothing, and a load would silently pick another). */
export function contextChoices(listed: number[], current: number): number[] {
return current > 0 && !listed.includes(current) ? [...listed, current].sort((a, b) => a - b) : listed;
}
function installStepLabel(step?: string): string {
const n = Number(step?.match(/^(\d+)\//)?.[1]);
if (!n)
@@ -226,4 +234,4 @@ catch (e) {
}
finally {
setStopping(false);
} } return <section className="st-card native-progress" aria-live="polite"><div className="section-toolbar"><h2 className="h4">{tr(job.kind === 'install' ? (label ? tr('Preparing {0}', [label]) : 'Preparing your model') : job.kind === 'load' ? 'Loading model' : job.kind === 'autotune' || job.kind === 'recheck' ? 'Tuning your model' : 'Engine operation')}</h2>{tr(job.kind === 'install' || job.kind === 'load' || job.kind === 'autotune' || job.kind === 'recheck' ? <div className="onboard-progress-actions">{tr(eta ? <span className="small muted">{eta}</span> : null)}<Btn size="sm" disabled={stopping} onClick={() => void stop()}>{tr(stopping ? 'Stopping…' : 'Stop')}</Btn></div> : null)}</div><p className="small">{tr(job.kind === 'install' ? (download && /^5\//.test(job.result?.install?.step ?? '') ? stageText(installStage(job.result?.install)) : installStepLabel(job.result?.install?.step)) : job.summary)}</p>{tr(download ? <><progress max={100} value={download.percent ?? 0}/><p className="xs muted">{tr(download.file === 'model files' ? 'Model files' : download.file === 'picture reader' ? 'Picture reader' : download.file)} · {tr(download.percent ?? 0)}%{tr(Number.isFinite(download.done_gb) && Number.isFinite(download.total_gb) ? ` · ${download.done_gb!.toFixed(1)} / ${download.total_gb!.toFixed(1)} GB` : '')}</p></> : null)}<DownloadParts parts={job.result?.install?.parts}/>{tr(detailsText ? <details><summary className="small">{tr("Details")}</summary><pre className="small muted error-detail-text">{detailsText}</pre></details> : null)}{tr(error ? <ErrorNotice message={error}/> : null)}</section>; }
} } return <section className="st-card native-progress" aria-live="polite"><div className="section-toolbar"><h2 className="h4">{tr(job.kind === 'install' ? (label ? tr('Preparing {0}', [label]) : 'Preparing your model') : job.kind === 'load' ? 'Loading model' : job.kind === 'autotune' || job.kind === 'recheck' ? 'Tuning your model' : 'Engine operation')}</h2>{tr(job.kind === 'install' || job.kind === 'load' || job.kind === 'autotune' || job.kind === 'recheck' ? <div className="onboard-progress-actions">{tr(eta ? <span className="small muted">{eta}</span> : null)}<Btn size="sm" disabled={stopping} onClick={() => void stop()}>{tr(stopping ? 'Stopping…' : 'Stop')}</Btn></div> : null)}</div><p className="small">{tr(job.kind === 'install' ? (job.result?.install?.label ?? (download && /^5\//.test(job.result?.install?.step ?? '') ? stageText(installStage(job.result?.install)) : installStepLabel(job.result?.install?.step))) : job.summary)}</p>{tr(download ? <><progress max={100} value={download.percent ?? 0}/><p className="xs muted">{tr(download.file === 'model files' ? 'Model files' : download.file === 'picture reader' ? 'Picture reader' : download.file === 'checking files' ? 'Checking files' : download.file === 'MTP draft layer' ? 'Draft layer' : download.file)} · {tr(download.percent ?? 0)}%{tr(Number.isFinite(download.done_gb) && Number.isFinite(download.total_gb) ? ` · ${download.done_gb!.toFixed(1)} / ${download.total_gb!.toFixed(1)} GB` : '')}</p></> : null)}<DownloadParts parts={job.result?.install?.parts}/>{tr(detailsText ? <details><summary className="small">{tr("Details")}</summary><pre className="small muted error-detail-text">{detailsText}</pre></details> : null)}{tr(error ? <ErrorNotice message={error}/> : null)}</section>; }
+23 -8
View File
@@ -2,6 +2,7 @@ import {localeTag} from '../lib/i18n';
import { tr } from '../lib/i18n';
import { useEffect, useState } from 'react';
import { api } from '../lib/client';
import { fetchUpdateCheck, publishUpdateCheck, useInstalledVersions, useLastUpdateCheck } from '../lib/update-check';
import { nativeAction, useNative, type NativeJob } from '../lib/native';
import { Btn, Dialog, ErrorNotice, Select } from './ui';
import { NativeProgress } from './NativeModels';
@@ -53,14 +54,28 @@ export function NativeOperations() {
instanceId: string;
line: string;
}[]>([]), [showLogs, setShowLogs] = useState(false), [confirm, setConfirm] = useState(false);
const [update, setUpdate] = useState<'checking' | 'available' | 'current' | 'unknown'>('checking');
// Opening this page shows the last check kept on this computer and makes no network call. The check runs when "Check for updates" is pressed.
const versions = useInstalledVersions();
const last = useLastUpdateCheck(versions);
const [checking, setChecking] = useState(false), [checkError, setCheckError] = useState('');
const update: 'checking' | 'available' | 'current' | 'unknown' = checking ? 'checking' : last?.engine.status === 'available' ? 'available' : last?.engine.status === 'current' ? 'current' : 'unknown';
const active = native?.jobs.find(j => j.state === 'running');
// The engine's update is offered only when the update check says there is one.
useEffect(() => {
let alive = true;
void api.get<{ engine?: { status?: string } }>('/api/updates/check').then(r => { if (alive) setUpdate(r.engine?.status === 'available' ? 'available' : r.engine?.status === 'current' ? 'current' : 'unknown'); }).catch(() => { if (alive) setUpdate('unknown'); });
return () => { alive = false; };
}, []);
async function checkForUpdates() {
setChecking(true);
setCheckError('');
try {
const data = await fetchUpdateCheck();
publishUpdateCheck(data, versions);
if (data.engine.status === 'error')
setCheckError(data.engine.message);
}
catch (e) {
setCheckError(e instanceof Error ? e.message : String(e));
}
finally {
setChecking(false);
}
}
useEffect(() => {
if (!showLogs)
return;
@@ -98,7 +113,7 @@ export function NativeOperations() {
}
}
return <div className="stack stack--lg native-engine-settings">
<section className="st-card"><div className="section-toolbar"><h2 className="h3">{tr("Update component")}</h2>{tr(update === 'available' ? <Btn size="sm" variant="primary" disabled={!!active || (native?.instances.length ?? 0) > 0} onClick={() => setConfirm(true)}>{tr("Update available")}</Btn> : null)}</div><p className="small muted">{tr("Update the engine for this computer. Your models and settings are kept.")}</p>{tr(update === 'current' ? <p className="small muted">{tr("Up to date")}</p> : null)}{tr(update === 'available' && (native?.instances.length ?? 0) > 0 ? <p className="small muted">{tr("Open Models and unload the running native models first.")}</p> : null)}</section>
<section className="st-card"><div className="section-toolbar"><h2 className="h3">{tr("Update component")}</h2>{tr(update === 'available' ? <Btn size="sm" variant="primary" disabled={!!active || (native?.instances.length ?? 0) > 0} onClick={() => setConfirm(true)}>{tr("Update available")}</Btn> : null)}{tr(update !== 'available' ? <Btn size="sm" disabled={update === 'checking'} onClick={() => void checkForUpdates()}>{tr(update === 'checking' ? 'Checking…' : 'Check for updates')}</Btn> : null)}</div><p className="small muted">{tr("Update the engine for this computer. Your models and settings are kept.")}</p>{tr(update === 'current' ? <p className="small muted">{tr("Up to date")}</p> : null)}{tr(checkError ? <p className="small inline-error" role="alert">{tr(checkError)}</p> : null)}{tr(update === 'available' && (native?.instances.length ?? 0) > 0 ? <p className="small muted">{tr("Open Models and unload the running native models first.")}</p> : null)}</section>
<section className="st-card"><div className="section-toolbar"><h2 className="h3">{tr("Diagnostics")}</h2><Btn size="sm" onClick={() => setShowLogs(true)}>{tr("View logs")}</Btn></div><p className="small muted">{tr("Downloads, preparation and engine failures include their original messages here.")}</p>{tr(native?.jobs.filter(j => j.state === 'error').slice(-3).map(j => <ErrorNotice key={j.id} message={j.error ?? j.summary} detail={j.details}/>))}</section>
<Dialog open={showLogs} wide title={tr("Engine logs")} onClose={() => setShowLogs(false)} actions={<><Btn icon="download" disabled={saving} onClick={() => void saveLogs()}>{tr(saving ? 'Working…' : 'Save logs')}</Btn><Btn variant="primary" onClick={() => setShowLogs(false)}>{tr("Close")}</Btn></>}>
<pre className="native-log" aria-label={tr("Engine log")}>{logs.length ? logs.map(l => `${l.at} · ${l.line}`).join(String.fromCharCode(10)) : tr('No engine messages yet.')}</pre>
@@ -6,6 +6,7 @@ import { pickerGroups, pickerVersionState } from '../lib/model-picker';
import { sizeGb } from '../lib/format';
import { useNative } from '../lib/native';
import { NativeDeleteDialog } from './NativeDelete';
import { ModelsFolderCard } from './ModelsFolderDialog';
import { nativeShortName } from '../lib/native-names';
export function SettingsStorage() {
const state = useStore(s => s);
@@ -77,7 +78,9 @@ export function SettingsStorage() {
setPending(false);
}
}
const downloading = !!native?.jobs.some(j => j.state === 'running' && j.kind !== 'models-move') || !!library?.downloads.active || !!state.imageModels?.variants.some(v => v.state === 'downloading');
return <>
{native?.available && native.downloadDirectory ? <ModelsFolderCard blocked={downloading}/> : null}
<section className="storage-directory" aria-labelledby="storage-directory-title">
<div className="storage-directory__heading"><span><Icon name="folder" size={18}/><strong id="storage-directory-title">{tr("Model directory")}</strong></span><Btn size="sm" disabled={!currentDirectory || directoryPending || pending} onClick={() => { setDirectory(currentDirectory); setEditing(!editing); setDirectoryError(''); setNotice(''); }}>{tr(editing ? 'Cancel' : 'Change directory')}</Btn></div>
<p className="small muted">{tr("Your models are saved here. Use an existing folder to reuse supported downloads.")}</p>
@@ -95,6 +98,7 @@ export function SettingsStorage() {
<div className="row"><Btn variant="primary" type="submit" disabled={directoryPending || directoryBlocked || !directory.trim()}>{tr(directoryPending ? 'Checking files…' : changing && loadedCount > 0 ? 'Unload and change directory' : changing ? 'Use this directory' : 'Check folder')}</Btn></div>
</form> : <Btn size="sm" icon="refresh" disabled={directoryPending || directoryBlocked || !currentDirectory} onClick={() => void saveDirectory(currentDirectory)}>{tr(directoryPending ? 'Checking files…' : 'Check folder')}</Btn>)}
{tr(directoryBlocked && !directoryPending ? <p className="small muted">{tr(!lifecycle.allowed ? lifecycle.reason : 'Finish or cancel downloads and loading to change the directory.')}</p> : null)}
{tr(state.setup?.app.modelsDirNotice && !notice ? <p className="small storage-directory__notice" role="status"><Icon name="info" size={15}/>{tr(state.setup.app.modelsDirNotice)}</p> : null)}
{tr(notice ? <p className="small storage-directory__notice" role="status"><Icon name="check" size={15}/>{tr(notice)}</p> : null)}
{tr(directoryError ? <ErrorNotice message={directoryError}/> : null)}
</section>
@@ -35,6 +35,8 @@ export interface CustomTool {
export interface ToolsState {
groups: ToolGroup[];
search: { provider: 'builtin' | 'exa' | 'perplexity' | 'deepseek'; keySet: boolean };
/** Skip the once-per-chat question before the first web search or page in Manual mode. Off unless the person turned it on. */
webWithoutAsking: boolean;
browser: { enabled: boolean; found: boolean; name: string | null; missing?: 'component' };
custom: CustomTool[];
}
@@ -139,6 +141,9 @@ export function SettingsTools({ preview }: { preview?: ToolsState }) {
<Btn size="sm" disabled={busy || !key.trim()} onClick={() => { void save({ search: { provider: state.search.provider, key: key.trim() } }); setKey(''); pushToast('success', tr('Key saved')); }}>{tr('Save key')}</Btn>
</div>
</Field> : null}
<Field label={tr('Let the assistant search the web without asking')} hint={tr('In Manual mode the assistant asks once per chat before it searches the web or opens a web page. Turn this on to skip that question.')}>
<button className="st-toggle" role="switch" aria-checked={state.webWithoutAsking} aria-label={tr('Let the assistant search the web without asking')} disabled={busy} onClick={() => void save({ webWithoutAsking: !state.webWithoutAsking })} />
</Field>
</div>
</section>
+19 -12
View File
@@ -7,6 +7,12 @@ import type { SoftwareUpdate, UpdateCheckResult } from '../lib/types';
import { nativeAction, useNative } from '../lib/native';
import { NativeProgress } from './NativeModels';
import { appVersionLabel } from '../lib/app-version';
import { fetchUpdateCheck, publishUpdateCheck, useInstalledVersions, useLastUpdateCheck } from '../lib/update-check';
/** A check from an earlier day shows its date too, because the result is kept between visits. */
function checkedLabel(at: string) {
const when = new Date(at), sameDay = when.toDateString() === new Date().toDateString();
return sameDay ? when.toLocaleTimeString(localeTag(), { hour: '2-digit', minute: '2-digit' }) : when.toLocaleString(localeTag(), { dateStyle: 'medium', timeStyle: 'short' });
}
/** Installed versions are real host values. Installation needs a host updater. */
export function SettingsUpdates() {
const native = useNative();
@@ -16,12 +22,16 @@ export function SettingsUpdates() {
const [rollback, setRollback] = useState(false);
const [component, setComponent] = useState<'strata' | 'engine'>('strata');
const [checking, setChecking] = useState(false);
const [result, setResult] = useState<UpdateCheckResult | null>(null);
// Opening Settings shows the last check kept on this computer. The network is touched only when "Check for updates" is pressed.
const versions = useInstalledVersions();
const last = useLastUpdateCheck(versions);
const [fresh, setFresh] = useState<UpdateCheckResult | null>(null);
const [error, setError] = useState('');
const request = useRef<AbortController | null>(null);
useEffect(() => () => request.current?.abort(), []);
const installed = component === 'strata' ? appVersion : native?.available ? (native.installed?.engine?.version ?? 'Not installed') : engineVersion;
const active = native?.jobs.find(j => j.state === 'running');
const result = checking || error ? null : (fresh ?? last);
async function operate(kind: string) {
setError('');
try {
@@ -39,16 +49,13 @@ export function SettingsUpdates() {
request.current = controller;
setChecking(true);
setError('');
setResult(null);
setFresh(null);
try {
const response = await fetch('/api/updates/check', { signal: controller.signal });
if (!response.ok)
throw new Error('Could not check for updates. Try again.');
const data = await response.json() as UpdateCheckResult;
if (!data.strata?.status || !data.engine?.status || !data.checkedAt)
throw new Error('The update service returned an incomplete result.');
if (!controller.signal.aborted)
setResult(data);
const data = await fetchUpdateCheck(controller.signal);
if (!controller.signal.aborted) {
setFresh(data);
publishUpdateCheck(data, versions);
}
}
catch (e) {
if (!controller.signal.aborted)
@@ -72,7 +79,7 @@ export function SettingsUpdates() {
{tr(versionRow('Strata', appVersion, result?.strata))}
{tr(versionRow('Engine', native?.available ? (native.installed?.engine?.version ?? 'Not installed') : engineVersion, result?.engine))}
<div className="settings-update-actions"><Btn size="sm" icon="refresh" disabled={checking} className={checking ? 'settings-update-checking' : undefined} onClick={() => void check()}>{tr(checking ? 'Checking…' : error || result?.strata.status === 'error' ? 'Try again' : result ? 'Check again' : 'Check for updates')}</Btn><Btn size="sm" variant="ghost" onClick={() => setRollback(true)}>{tr("Roll back\u2026")}</Btn></div>
<div className="settings-update-inline" role="status" aria-live="polite">{tr(checking ? 'Checking for new versions…' : error ? <span className="inline-error">{tr(error)}</span> : result ? <><span className="sr-only">{tr(result.strata.message)} {tr(result.engine.message)} </span>{tr("Checked at ")}{tr(new Date(result.checkedAt).toLocaleTimeString(localeTag(), { hour: '2-digit', minute: '2-digit' }))}</> : null)}</div>
<div className="settings-update-inline" role="status" aria-live="polite">{tr(checking ? 'Checking for new versions…' : error ? <span className="inline-error">{tr(error)}</span> : result ? <><span className="sr-only">{tr(result.strata.message)} {tr(result.engine.message)} </span>{tr("Checked at ")}{tr(checkedLabel(result.checkedAt))}</> : null)}</div>
</div>
<Dialog open={rollback} compact title={tr("Roll back a version")} onClose={() => setRollback(false)} actions={<Btn onClick={() => setRollback(false)}>{tr("Done")}</Btn>}>
<div className="settings-update-dialog">
@@ -87,7 +94,7 @@ export function SettingsUpdates() {
</>;
}
/** "Strata 0.2.0-beta · Engine 0.1.45.3": the app's own version first, the engine's beside it (About). */
/** "Strata 0.2.2 · Engine 0.1.47": 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);
+202
View File
@@ -0,0 +1,202 @@
import { useRef, useState, type ReactNode } from 'react';
import { tr, localeTag } from '../lib/i18n';
import { api } from '../lib/client';
import { go, answerChatTool, continueChatInNewChat, continueTaskInNewChat, dismissChat, selectSession, stopChat, stopLoop } from '../lib/store';
import { pushToast } from '../lib/store';
import { askQuestion } from '../lib/ask';
import { LOOP_MAX_FAILURES, loopEvery } from '../lib/loop';
import { plainFailure } from '../lib/plain-errors';
import { userError } from '../lib/format';
import { type BoardAction, type BoardCard, type BoardColumn, type BoardReason } from '../lib/board';
import { useBoard, refreshBoard } from '../lib/use-board';
import { Btn, Icon, IconBtn, type IconName } from './ui';
import { FloatingMenu } from './FloatingMenu';
/**
* The task board: what is running, what waits for a yes, what found a problem and what finished, in four columns. The cards come from
* src/lib/board.ts; this file only draws them and calls the same endpoints and store functions the rest of the app uses. Cards are not
* dragged: they move by themselves when the facts change.
*/
const COLUMNS: { id: BoardColumn; name: string; hint?: string; tone: 'run' | 'wait' | 'bad' | 'done' }[] = [
{ id: 'running', name: 'Running', tone: 'run' },
{ id: 'waiting', name: 'Waiting for you', hint: 'Stopped at a question only you can answer.', tone: 'wait' },
{ id: 'attention', name: 'Needs attention', hint: 'Found a problem. Stays here until you deal with it.', tone: 'bad' },
{ id: 'done', name: 'Done', hint: 'Finished cleanly in the last 7 days.', tone: 'done' },
];
const ACTION: Record<BoardAction, { label: string; icon?: IconName }> = {
'stop-task': { label: 'Stop', icon: 'stop' },
'run-again': { label: 'Run again', icon: 'refresh' },
'open-task': { label: 'Open task', icon: 'pencil' },
'open-chat': { label: 'Open chat', icon: 'chat' },
'stop-chat': { label: 'Stop chat', icon: 'stop' },
'stop-loop': { label: 'Stop loop', icon: 'stop' },
approve: { label: 'Approve', icon: 'check' },
deny: { label: 'Deny' },
continue: { label: 'Continue in a new chat', icon: 'chat' },
dismiss: { label: 'Dismiss', icon: 'x' },
'view-result': { label: 'View result' },
};
/* ------------------------------------------------------------------ words */
/** "every 10 min": short enough for a card; whole hours keep the loop's own words ("every hour"). */
const everyShort = (ms: number) => (ms % 3_600_000 === 0 || ms < 60_000 ? loopEvery(ms) : tr('every {0} min', [Math.round(ms / 60_000)]));
const span = (ms: number) => {
const min = Math.floor(ms / 60_000);
if (min < 1) return tr('under 1 min');
if (min < 60) return tr('{0} min', [min]);
if (min < 48 * 60) return tr('{0} h', [Math.floor(min / 60)]);
return tr('{0} d', [Math.floor(min / 1440)]);
};
const ago = (ms: number) => (ms < 60_000 ? tr('just now') : tr('{0} ago', [span(ms)]));
const clock = (at: number) => new Date(at).toLocaleTimeString(localeTag(), { hour: '2-digit', minute: '2-digit' });
/** "today 07:00", "yesterday", "Mon 07:00" for a day that has a time on it. */
function day(at: number, now: number) {
const d = new Date(at), n = new Date(now);
const midnight = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
const days = Math.round((midnight(n) - midnight(d)) / 86_400_000);
if (days <= 0) return tr('today {0}', [clock(at)]);
if (days === 1) return tr('yesterday');
return d.toLocaleDateString(localeTag(), { weekday: 'short', day: 'numeric', month: 'short' });
}
export function cardTime(card: BoardCard, now: number): string {
switch (card.timeKind) {
case 'started': return tr('started {0}', [ago(now - card.at)]);
case 'waiting': return tr('waiting {0}', [span(now - card.at)]);
case 'next': return card.nextAt && card.nextAt > now ? tr('next in {0}', [span(card.nextAt - now)]) : tr('answering now');
case 'stopped': return tr('stopped {0}', [now - card.at < 86_400_000 ? clock(card.at) : day(card.at, now)]);
default: return card.source === 'task' ? day(card.at, now) : now - card.at < 86_400_000 ? ago(now - card.at) : day(card.at, now);
}
}
function ReasonText({ reason }: { reason: BoardReason }) {
let text = '', raw: string | undefined;
switch (reason.kind) {
case 'task-failed': case 'chat-failed': {
const plain = plainFailure(reason.detail ?? '');
const known = plain.kind !== 'plain';
text = known ? tr(plain.message) : tr(userError(String(reason.detail ?? '').slice(0, 240)) || 'It did not finish.');
// The words a person reads are the sentence; the original text stays one click away for experts.
raw = known ? (plain.detail ?? reason.detail) : undefined;
if (!known && !reason.detail) text = tr('It did not finish.');
break;
}
case 'task-stopped': text = tr('This run was stopped before it finished.'); break;
case 'task-closed': text = tr('Strata closed while this was running, so it did not finish.'); break;
case 'task-needs-yes': text = tr('Finished, but wanted to change something and could not ask. Nothing was changed.'); break;
case 'loop-failed': text = tr('Stopped: {0} runs in a row did not work.', [LOOP_MAX_FAILURES]); break;
default: text = reason.ask ? askQuestion(reason.ask) : reason.fallback ?? '';
}
return <span>{text}{raw ? <details className="bcard__detail"><summary>{tr('Details')}</summary><pre>{raw}</pre></details> : null}</span>;
}
/* ------------------------------------------------------------------- card */
function Card({ card, now, busy, canRun, run, openMenu }: { card: BoardCard; now: number; busy: boolean; canRun: boolean; run: (card: BoardCard, action: BoardAction) => void; openMenu: (card: BoardCard, anchor: HTMLElement) => void }) {
const titleId = `bcard-${card.key.replace(/[^a-z0-9]+/gi, '-')}`;
const source = card.source === 'task' ? <><Icon name="clock" size={14}/>{tr('Scheduled task')}</> : card.source === 'chat' ? <><Icon name="chat" size={14}/>{tr('Chat')}</> : <><Icon name="refresh" size={14}/>{tr('Loop · {0}', [everyShort(card.everyMs ?? 0)])}</>;
const where = card.folder || card.meta ? <div className="bcard__where">{card.folder ? <><Icon name="folder" size={14}/>{card.folder}</> : null}{card.folder && card.meta ? ' · ' : ''}{card.meta ? tr(card.meta.text, card.meta.params) : null}</div> : null;
const button = (action: BoardAction, variant: 'primary' | 'secondary' | 'ghost') => {
const a = ACTION[action];
const off = busy || (action === 'run-again' && !canRun);
return <Btn key={action} size="sm" variant={variant} icon={variant === 'ghost' ? undefined : a.icon} disabled={off} title={action === 'run-again' && !canRun ? tr('Load a model first') : undefined} onClick={() => run(card, action)}>{tr(a.label)}</Btn>;
};
const iconFor = (action: BoardAction, label?: string) => <IconBtn key={action} name={ACTION[action].icon ?? 'x'} title={tr(label ?? ACTION[action].label)} disabled={busy} onClick={() => run(card, action)}/>;
let actions: ReactNode;
if (card.column === 'waiting') {
actions = card.approve
? <>{button('approve', 'primary')}{button('deny', 'secondary')}<span className="spacer"/>{iconFor('open-chat')}</>
: <>{button(card.primary, 'secondary')}</>;
} else if (card.column === 'done') {
actions = <>{card.chip ? <span className={`bchip${card.chip.tone === 'good' ? ' bchip--good' : ''}`}><Icon name="check" size={12}/>{tr(card.chip.text)}</span> : null}<span className="spacer"/>{button(card.primary, 'ghost')}{iconFor('dismiss')}</>;
} else {
const variant = card.column === 'attention' ? 'primary' : 'secondary';
actions = <>{button(card.primary, variant)}<span className="spacer"/>
{card.menu.length === 1 ? iconFor(card.menu[0]) : card.menu.length > 1
? <button type="button" className="st-btn st-btn--icon" aria-haspopup="menu" aria-label={tr('More actions for {0}', [card.title])} title={tr('More actions')} disabled={busy} onClick={e => openMenu(card, e.currentTarget)}><Icon name="ellipsis" size={19}/></button> : null}</>;
}
return <article className="bcard" aria-labelledby={titleId} data-source={card.source}>
<div className="bcard__top"><span className="bsrc">{source}</span><span className="btime">{cardTime(card, now)}</span></div>
<h3 className="bcard__title" id={titleId}>{card.title}</h3>
{where}
{card.line ? <p className="bcard__line">{card.line}</p> : null}
{card.reason ? <div className={`bcard__why bcard__why--${card.reason.tone}`}><Icon name={card.reason.tone === 'bad' ? 'error' : card.reason.tone === 'warn' ? 'warning' : 'shield'} size={16}/><ReasonText reason={card.reason}/></div> : null}
{card.busy ? <div className="bcard__bar bcard__bar--busy" aria-hidden="true"><i/></div> : null}
<div className="bcard__actions">{actions}</div>
</article>;
}
/* ------------------------------------------------------------------ board */
export function TaskBoard({ query, canRun, onViewResult }: { query: string; canRun: boolean; onViewResult: (taskId: string) => void }) {
const { board, ready, tasks } = useBoard();
const [busy, setBusy] = useState<Record<string, boolean>>({});
const [menu, setMenu] = useState<{ card: BoardCard; anchor: HTMLElement } | null>(null);
const now = Date.now();
const q = query.trim().toLowerCase();
const shown = (list: BoardCard[]) => (q ? list.filter(c => `${c.title} ${c.line ?? ''} ${c.folder ?? ''}`.toLowerCase().includes(q)) : list);
const guard = useRef(new Set<string>());
async function run(card: BoardCard, action: BoardAction) {
if (guard.current.has(card.key)) return;
const task = card.source === 'task' ? tasks.find(t => t.id === card.id) : undefined;
const local = () => {
if (action === 'open-chat') { selectSession(card.id); go('chat', card.id); }
else if (action === 'open-task') go('tasks', card.id);
else if (action === 'view-result') onViewResult(card.id);
else if (action === 'stop-chat') stopChat(card.id);
else if (action === 'stop-loop') stopLoop(card.id);
else if (action === 'approve' || action === 'deny') { if (card.approve) answerChatTool(card.id, card.approve.messageId, card.approve.toolId, action === 'approve'); }
else if (action === 'continue') { if (task) continueTaskInNewChat(task); else continueChatInNewChat(card.id); }
else if (action === 'dismiss' && card.source !== 'task') dismissChat(card.id);
else return false;
return true;
};
if (local()) return;
guard.current.add(card.key);
setBusy(b => ({ ...b, [card.key]: true }));
try {
if (action === 'stop-task') await api.post(`/api/tasks/${card.id}/stop`);
else if (action === 'run-again') await api.post(`/api/tasks/${card.id}/run`);
else if (action === 'dismiss') await api.patch(`/api/tasks/${card.id}`, { ackedAt: new Date().toISOString() });
window.dispatchEvent(new Event('strata:tasks'));
} catch (e) {
pushToast('error', 'That did not work', e instanceof Error ? e.message : String(e));
} finally {
guard.current.delete(card.key);
setBusy(b => ({ ...b, [card.key]: false }));
refreshBoard();
}
}
const openMenu = (card: BoardCard, anchor: HTMLElement) => setMenu(m => (m?.card.key === card.key ? null : { card, anchor }));
const total = Object.values(board.columns).reduce((n, c) => n + c.length, 0);
return <div className="board-wrap">
<p className="board-note"><Icon name="info" size={16}/>{tr('Cards move on their own as things happen. A card in Needs attention stays there until you deal with it or dismiss it.')}</p>
<p className="sr-only" role="status" aria-live="polite">{board.need ? tr('{0} need your attention', [board.need]) : tr('Nothing needs you right now.')}</p>
<div className="board">
{COLUMNS.map(col => {
const cards = shown(board.columns[col.id]);
const headId = `bcol-${col.id}`;
return <section key={col.id} className={`bcol bcol--${col.id}`} data-tone={col.tone} aria-labelledby={headId}>
<div className="bcol__head"><span className="bcol__dot" aria-hidden="true"/><h2 className="bcol__name" id={headId}>{tr(col.name)}</h2><span className="bcol__count" aria-label={tr('{0} cards', [cards.length])}>{cards.length}</span></div>
{col.hint ? <p className="bcol__hint">{tr(col.hint)}</p> : null}
{cards.map(card => <Card key={card.key} card={card} now={now} busy={!!busy[card.key]} canRun={canRun} run={run} openMenu={openMenu}/>)}
{!cards.length ? <p className="bcol__empty">{ready ? tr(q && total ? 'No matching cards.' : 'Nothing here right now.') : tr('Loading tasks…')}</p> : null}
</section>;
})}
</div>
{board.upcoming.length ? <div className="upnext" role="region" aria-label={tr('Coming up')}><Icon name="clock" size={18}/><span className="navgroup__label">{tr('Coming up')}</span>
{board.upcoming.map((u, i) => <span className="upnext__item" key={u.id}>{i ? <span className="sep" aria-hidden="true"/> : null}<a href={`#/tasks/${u.id}`}>{u.title}</a> <span className="i">{day(u.at, now)}</span></span>)}</div> : null}
{menu ? <FloatingMenu anchor={menu.anchor} title={tr('More actions for {0}', [menu.card.title])} width={230} onClose={() => setMenu(null)}>
{menu.card.menu.map(action => <div key={action}>{action === 'dismiss' && menu.card.menu.length > 1 ? <hr className="board-menu-sep"/> : null}<button type="button" role="menuitem" className="menu__item" onClick={() => { const card = menu.card; setMenu(null); void run(card, action); }}><Icon name={ACTION[action].icon ?? 'x'}/>{tr(ACTION[action].label)}</button></div>)}
</FloatingMenu> : null}
</div>;
}
@@ -50,7 +50,7 @@ export function ChatTelemetry({ session }: {
const approx = m.estimated ? tr('≈ ') : tr('');
const dur = (n: number) => elapsed(n);
return <div className="telemetry xs muted" aria-label={tr("Chat statistics")}>
<Chip icon="gauge" label={tr("{0}{1} tok/s{2}", [approx, speed === null ? tr('—') : Math.round(speed), m.streaming ? tr('') : tr(' · last reply')])} live={around(m.streaming ? "{0}{1} tok/s" : "{0}{1} tok/s · last reply", approx, <Num value={speed} format={int}/>)} title={tr("Session statistics")}>{tr(m.estimated ? <p className="xs muted">{tr("Live estimate from response length and elapsed time. Final usage comes from the engine.")}</p> : null)}<Row label={tr("Thinking time")} value={<Num value={m.thinkMs} format={dur}/>}/><Row label={tr("Writing time")} value={<Num value={m.writeMs} format={dur}/>}/><Row label={tr("Tool time")} value={<Num value={m.toolMs} format={dur}/>}/><Row label={tr("Average time to first token")} value={m.ttft === null ? tr('Not reported') : <Num value={m.ttft} format={dur}/>}/><Row label={tr("Latest response speed")} value={speed === null ? tr('Not reported') : <>{<Num value={speed} format={int}/>}{tr(' tokens/s')}</>}/><Row label={tr("Turns / tool calls")} value={<><Num value={m.turns} format={int}/> / <Num value={m.steps} format={int}/></>}/></Chip>
<Chip icon="gauge" label={tr("{0}{1} tok/s{2}", [approx, speed === null ? tr('—') : Math.round(speed), m.streaming ? tr('') : tr(' · last reply')])} live={around(m.streaming ? "{0}{1} tok/s" : "{0}{1} tok/s · last reply", approx, <Num value={speed} format={int}/>)} title={tr("Session statistics")}>{tr(m.estimated ? <p className="xs muted">{tr("Live estimate from response length and elapsed time. Final usage comes from the engine.")}</p> : null)}<Row label={tr("Thinking time")} value={<Num value={m.thinkMs} format={dur}/>}/><Row label={tr("Writing time")} value={<Num value={m.writeMs} format={dur}/>}/><Row label={tr("Tool time")} value={<Num value={m.toolMs} format={dur}/>}/><Row label={tr("Average time to first token")} value={m.ttft === null ? tr('Not reported') : <Num value={m.ttft} format={dur}/>}/><Row label={tr("Latest response speed")} value={speed === null ? tr('Not reported') : <>{<Num value={speed} format={int}/>}{tr(' tokens/s')}</>}/><Row label={tr("Prompt reading")} value={m.promptSpeed === null ? tr('Not reported') : <>{<Num value={m.promptSpeed} format={int}/>}{tr(' tokens/s')}</>}/><Row label={tr("Turns / tool calls")} value={<><Num value={m.turns} format={int}/> / <Num value={m.steps} format={int}/></>}/></Chip>
<Chip icon="memory" label={tr("{0}{1} total tokens", [approx, compactTokens(m.total)])} live={around("{0}{1} total tokens", approx, <Num value={m.total} format={compactTokens}/>)} title={tr("Token usage")}><Row label={tr("Total in this chat")} value={<Num value={m.total} format={compactTokens}/>}/><Row label={tr("Input tokens")} value={<Num value={m.input} format={compactTokens}/>}/><Row label={tr("Output tokens")} value={<Num value={m.output} format={compactTokens}/>}/></Chip>
<Chip visual={<ContextRing percent={m.pct}/>} label={tr(m.pct === null ? '—' : `${Math.round(m.pct)}% of context`)} live={m.pct === null ? undefined : <><Num value={m.pct} format={int}/>{tr('% of context')}</>} title={tr("Context length")}><Row label={tr("Context used")} value={m.pct === null ? tr('Not reported') : <><Num value={m.pct} format={int}/>%</>}/><div className="context-meter" role="meter" aria-label={tr("Context window used")} aria-valuemin={0} aria-valuemax={100} aria-valuenow={m.pct ?? undefined}><span style={{ width: `${m.pct ?? 0}%` }}/></div><Row label={tr("Current context")} value={<Num value={m.used} format={compactTokens}/>}/><Row label={tr("Context limit")} value={<Num value={m.max} format={compactTokens}/>}/><Row label={tr("Space remaining")} value={m.used !== null && m.max !== null ? <Num value={Math.max(0, m.max - m.used)} format={compactTokens}/> : '—'}/></Chip>
</div>;
+5 -3
View File
@@ -5,7 +5,7 @@ import { FloatingMenu } from './FloatingMenu';
import { cx } from '../lib/format';
import { versionNote, type CatalogVersion } from '../lib/native-catalog';
import { nvfp4Version } from '../lib/native-names';
import { VERSION_GROUPS, fitOn, groupOpen, recommendedVersions, type FitHardware } from '../lib/native-fit';
import { VERSION_GROUPS, fitOn, groupOpen, recommendedVersions, type FitHardware, type PcPick } from '../lib/native-fit';
/** The name a version goes by in the list: the quantization, with the maker where the quantization alone is ambiguous. */
export function versionName(v: CatalogVersion, withMaker = false): string {
@@ -26,7 +26,7 @@ const gb = (n: number) => `${Number.isInteger(n) ? n : n.toFixed(1)} GB`;
* which stays on the SSD, is not in it); the download size is secondary. "Uses SSD N GB" appears only where this computer would read
* part of the model from its SSD. The first group is a fixed list of four; the rest fold away.
*/
export function VersionPicker({ versions, selectedId, hardware, onSelect, onOwnGguf, downloaded, showRecommended = true, moreLabel }: {
export function VersionPicker({ versions, selectedId, hardware, onSelect, onOwnGguf, downloaded, showRecommended = true, moreLabel, pick }: {
versions: CatalogVersion[];
selectedId: string;
hardware: FitHardware | null | undefined;
@@ -38,13 +38,15 @@ export function VersionPicker({ versions, selectedId, hardware, onSelect, onOwnG
showRecommended?: boolean;
/** What the button says while the selected version is one of the recommended four shown elsewhere. */
moreLabel?: string;
/** The engine's own pick for this PC (catalog.recommendation); it comes first in the Recommended group. */
pick?: PcPick | null;
}) {
const trigger = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
// A group is open when the person opened it; until they touch it, only the group that holds the selected version is open.
const [opened, setOpened] = useState<Record<string, boolean>>({});
const selected = versions.find(v => v.id === selectedId);
const recommended = recommendedVersions(versions);
const recommended = recommendedVersions(versions, pick);
// Where the recommended four are shown outside the menu, the menu holds only the others.
const apart = new Set(showRecommended ? [] : recommended.map(x => x.v.id));
const rest = versions.filter(v => !apart.has(v.id));
+6 -3
View File
@@ -128,6 +128,7 @@ export function Seg<T extends string>({ value, options, onChange, ariaLabel, pan
options: {
value: T;
label: string;
icon?: IconName;
title?: string;
disabled?: boolean;
}[];
@@ -150,7 +151,7 @@ export function Seg<T extends string>({ value, options, onChange, ariaLabel, pan
onChange(options[next].value);
(e.currentTarget.parentElement?.children[next] as HTMLElement)?.focus();
}} title={tr(o.title)} onClick={() => onChange(o.value)}>
{tr(o.label)}
{tr(o.icon ? <Icon name={o.icon}/> : null)}{tr(o.label)}
</button>)))}
</div>);
}
@@ -383,7 +384,7 @@ export function Select<T extends string>({ value, valueSuffix, options, onChange
</>);
}
/** One modal surface for confirmations and narrow-screen inspectors. */
export function Dialog({ open, title, onClose, children, actions, headerActions, compact = false, sheet = false, wide = false, hideTitle = false, className }: {
export function Dialog({ open, title, onClose, children, actions, headerActions, compact = false, sheet = false, wide = false, hideTitle = false, className, noClose = false }: {
open: boolean;
title: ReactNode;
onClose: () => void;
@@ -395,6 +396,8 @@ export function Dialog({ open, title, onClose, children, actions, headerActions,
wide?: boolean;
hideTitle?: boolean;
className?: string;
/** No close button: the person must use the dialog's own buttons (a move that is running). */
noClose?: boolean;
}) {
const ref = useRef<HTMLElement>(null);
const id = useId();
@@ -417,7 +420,7 @@ export function Dialog({ open, title, onClose, children, actions, headerActions,
return createPortal(<div className="dialog-layer">
<div className="dialog-scrim" onClick={onClose} aria-hidden="true"/>
<section ref={ref} className={cx(`${sheet ? 'dialog-surface dialog-surface--sheet' : 'dialog-surface'}${wide ? ' dialog-surface--wide' : ''}${compact ? ' dialog-surface--compact' : ''}`, className)} role="dialog" aria-modal="true" aria-labelledby={id} tabIndex={-1}>
<header className="dialog-head"><h2 className={hideTitle ? 'sr-only' : 'h3'} id={id}>{tr(title)}</h2><div className="dialog-head__actions">{tr(headerActions)}<IconBtn name="x" title={typeof title === 'string' ? tr("Close {0}", [title]) : tr("Close")} onClick={onClose}/></div></header>
<header className="dialog-head"><h2 className={hideTitle ? 'sr-only' : 'h3'} id={id}>{tr(title)}</h2><div className="dialog-head__actions">{tr(headerActions)}{noClose ? null : <IconBtn name="x" title={typeof title === 'string' ? tr("Close {0}", [title]) : tr("Close")} onClick={onClose}/>}</div></header>
<div className="dialog-body">{tr(children)}</div>
{tr(actions ? <footer className="dialog-actions">{tr(actions)}</footer> : null)}
</section>
+2 -2
View File
@@ -1,6 +1,6 @@
/**
* The version the person sees as "Strata". It is the DESKTOP APP's version (0.2.0-beta), not that of Strata Control, the tools the app runs
* (0.1.45, the version of the Terminal). Showing Control's number as "Strata" made a tester report "Strata 0.1.45" for the 0.2.0-beta app.
* The version the person sees as "Strata". It is the DESKTOP APP's version (0.2.2), not that of Strata Control, the tools the app runs
* (0.1.45, the version of the Terminal). Showing Control's number as "Strata" made a tester report "Strata 0.1.45" for the 0.2.2 app.
* Without a real app version (a source checkout reports 'dev') the other number is the best there is.
*/
export function appVersionLabel(app: string | null | undefined, control?: string | null): string | undefined {
+234
View File
@@ -0,0 +1,234 @@
import type { Ask } from './ask';
import type { Session, ToolCall } from './types';
import type { Task } from './tasks';
/**
* The task board (Tasks screen, "Board"). One pure function turns what the app already knows (scheduled tasks from the host, chats and
* their loops from the browser, the approvals the host is holding) into cards in four columns. Nothing here is stored as "board state":
* a card is where the facts put it, and it moves when the facts change. It never touches the DOM, the store or the clock, so it is tested
* like loop.ts.
*
* One card per thing: a chat shows once, in the first place that fits (waiting for you, running, needs attention, done), and a task
* shows once. A task or chat the person dismissed stays off the board until a newer run or turn brings a problem back.
*/
export type BoardColumn = 'running' | 'waiting' | 'attention' | 'done';
/** What a button on a card does. The component maps each one to a call; the order of `menu` is the order shown. */
export type BoardAction = 'stop-task' | 'run-again' | 'open-task' | 'open-chat' | 'stop-chat' | 'stop-loop' | 'approve' | 'deny' | 'continue' | 'dismiss' | 'view-result';
/** Why a card needs the person. `kind` names the sentence; the component writes it in the person's language. */
export interface BoardReason {
tone: 'bad' | 'warn' | 'ask';
kind: 'task-failed' | 'task-stopped' | 'task-closed' | 'task-needs-yes' | 'chat-failed' | 'loop-failed' | 'ask';
/** The original error text, for the failures a person can open under Details. */
detail?: string;
/** The approval question, for `ask`. */
ask?: Ask;
/** What the tool wants to do, when the host sent no question. */
fallback?: string;
params?: (string | number)[];
}
export interface BoardCard {
/** Stable across refreshes: the source and its id. */
key: string;
column: BoardColumn;
source: 'task' | 'chat' | 'loop';
/** Task id for a task; chat id for a chat or a loop. */
id: string;
title: string;
/** Milliseconds. What the time on the card is about, and the sort key (newest first). */
at: number;
timeKind: 'started' | 'waiting' | 'next' | 'stopped' | 'plain';
/** For a loop that is running: when its next answer is due. */
nextAt?: number;
/** A loop's rhythm in milliseconds, for "Loop · every 10 min". */
everyMs?: number;
folder?: string;
/** The muted part after the folder: "2 helpers working", "answer 3 of 6". */
meta?: { text: string; params?: (string | number)[] };
line?: string;
reason?: BoardReason;
/** Done cards: the chip beside the button. */
chip?: { text: string; tone: 'good' | 'plain' };
/** A card whose run is going on shows a moving bar. */
busy?: boolean;
primary: BoardAction;
menu: BoardAction[];
/** Set on a Waiting card that may be answered from the board (a file read or write inside the chat's folder). */
approve?: { messageId: string; toolId: string };
}
export interface BoardPending { chatId: string; callIds: string[]; at?: number; folder?: string | null }
export interface BoardInput {
tasks: readonly Task[];
sessions: readonly Session[];
/** Chats with a turn going. */
liveChats: readonly string[];
/** Chats with a /loop going. */
loopChats: readonly string[];
/** What the host holds, per chat. `null` while it is not known (not asked yet or unreachable): the cards in the chats are trusted then. */
pending: readonly BoardPending[] | null;
activeSession: string | null;
now: number;
days?: number;
}
export interface BoardResult {
columns: Record<BoardColumn, BoardCard[]>;
/** Waiting for you plus Needs attention: the number the sidebar shows. */
need: number;
/** The next scheduled runs. */
upcoming: { id: string; title: string; at: number }[];
/** Task ids that have a problem the person has not dismissed (the sidebar's dot). */
problemTasks: string[];
}
export const BOARD_DAYS = 7;
const DAY = 86_400_000;
const time = (iso?: string | null) => { const n = iso ? Date.parse(iso) : NaN; return Number.isFinite(n) ? n : 0; };
/** The last folder name of a path: how a chat's working folder is shown on a card. */
export function folderName(path?: string | null): string | undefined {
const parts = String(path ?? '').split(/[\\/]+/).filter(Boolean);
return parts.length ? parts[parts.length - 1] : undefined;
}
const normal = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '');
/**
* Is `target` inside `folder`? Relative paths count (the agent works in the folder) unless they climb out with "..". Anything else
* (another drive, another tree, a path that cannot be told) is not: those need the whole conversation in front of the person.
*/
export function isInside(target: string | undefined | null, folder: string | undefined | null): boolean {
if (!target || !folder) return false;
const t = normal(String(target).trim()), f = normal(String(folder).trim());
if (!t || !f || t.split('/').includes('..')) return false;
const windows = /^[a-z]:/i.test(f) || String(folder).includes('\\');
const absolute = /^([a-z]:)?\//i.test(t);
if (!absolute) return !/^[a-z]:/i.test(t);
const a = windows ? t.toLowerCase() : t, b = windows ? f.toLowerCase() : f;
return a === b || a.startsWith(b + '/');
}
/** Can this waiting card be answered from the board? A file read or write inside the chat's folder; shell commands and connected tools open the chat. */
export function canApproveOnBoard(tool: Pick<ToolCall, 'scope' | 'target'>, folder: string | undefined | null): boolean {
return (tool.scope === 'read' || tool.scope === 'write') && isInside(tool.target, folder);
}
/** One plain line from an answer: markdown off, first sentence (or two when the first is tiny), cut at 160 characters. */
export function plainLine(text: string | undefined | null, max = 160): string {
let body = String(text ?? '').replace(/```[\s\S]*?```/g, ' ').replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/^\s*(#{1,6}|[-*+]|\d+[.)]|>)\s+/gm, '').replace(/[*_`~]/g, '');
body = body.replace(/\s+/g, ' ').trim();
if (!body) return '';
const sentence = /^.*?[.!?](?=\s|$)/.exec(body)?.[0] ?? body;
const two = /^.*?[.!?](?=\s|$)\s*.*?[.!?](?=\s|$)/.exec(body)?.[0] ?? body;
const pick = sentence.length < 24 && body.length > sentence.length ? two : sentence;
return pick.length > max ? `${pick.slice(0, max - 1).trimEnd()}…` : pick;
}
function lastAssistant(s: Session) {
for (let i = s.messages.length - 1; i >= 0; i--) { const m = s.messages[i]; if (m.role === 'assistant' && !(m as { local?: boolean }).local) return m; }
return undefined;
}
/** Dismissed: the person dismissed it at or after the moment of the thing shown. */
const dismissed = (ackedAt: string | null | undefined, since: number) => !!ackedAt && time(ackedAt) >= since;
export function boardCards(input: BoardInput): BoardResult {
const { now } = input, window = (input.days ?? BOARD_DAYS) * DAY;
const live = new Set(input.liveChats), loops = new Set(input.loopChats);
const columns: Record<BoardColumn, BoardCard[]> = { running: [], waiting: [], attention: [], done: [] };
const problemTasks: string[] = [];
const upcoming: BoardResult['upcoming'] = [];
/* ---- scheduled tasks ---- */
for (const t of input.tasks) {
if (t.archived || !t.id) continue;
const base = { key: `task:${t.id}`, source: 'task' as const, id: t.id, title: t.title };
const ran = time(t.lastRunAt), fin = time(t.finishedAt) || ran;
if (t.enabled && t.nextRunAt) upcoming.push({ id: t.id, title: t.title, at: time(t.nextRunAt) });
if (t.status === 'running') { columns.running.push({ ...base, column: 'running', at: ran, timeKind: 'started', line: plainLine(t.lastLine ?? '') || undefined, busy: true, primary: 'stop-task', menu: ['open-task'] }); continue; }
if (dismissed(t.ackedAt, fin)) continue;
const failed = t.status === 'error' || t.status === 'cancelled' || t.status === 'interrupted';
if (failed) {
problemTasks.push(t.id);
const kind = t.status === 'error' ? 'task-failed' : t.status === 'cancelled' ? 'task-stopped' : 'task-closed';
columns.attention.push({ ...base, column: 'attention', at: fin || ran, timeKind: 'plain', reason: { tone: 'bad', kind, ...(t.status === 'error' && t.error ? { detail: t.error } : {}) }, primary: 'run-again', menu: ['open-task', 'continue', 'dismiss'] });
} else if (t.attention) {
problemTasks.push(t.id);
// A run that was auto-denied: the useful next step is a chat that can carry on with the person's yes.
columns.attention.push({ ...base, column: 'attention', at: fin || ran, timeKind: 'plain', line: plainLine(t.lastLine ?? t.output) || undefined, reason: { tone: 'warn', kind: 'task-needs-yes', detail: t.attention }, primary: 'continue', menu: ['open-task', 'dismiss'] });
} else if (t.status === 'done' && fin && now - fin <= window) {
columns.done.push({ ...base, column: 'done', at: fin, timeKind: 'plain', line: plainLine(t.lastLine ?? t.output) || undefined, chip: { text: 'Finished', tone: 'good' }, primary: 'view-result', menu: ['dismiss'] });
}
}
/* ---- chats and loops ---- */
const pendingBy = new Map((input.pending ?? []).map(p => [p.chatId, p]));
for (const s of input.sessions) {
if (s.archived || s.showcase) continue;
const base = { id: s.id, title: s.title, folder: folderName(s.workspace) };
const answer = lastAssistant(s);
// Waiting for you: a card the host still holds.
const held = pendingBy.get(s.id);
let proposed: { messageId: string; tool: ToolCall } | undefined;
for (const m of s.messages) for (const tool of m.tools ?? []) if (tool.live && tool.state === 'proposed' && (input.pending === null || held?.callIds.includes(tool.id))) { proposed = { messageId: m.id, tool }; break; }
if (proposed) {
const { tool, messageId } = proposed;
const folder = held?.folder ?? s.workspace;
const ok = canApproveOnBoard(tool, folder);
columns.waiting.push({ ...base, key: `chat:${s.id}`, source: 'chat', column: 'waiting', at: held?.at || time(s.updatedAt), timeKind: 'waiting',
reason: { tone: 'ask', kind: 'ask', ...(tool.ask ? { ask: tool.ask } : {}), fallback: tool.effect || tool.preview || tool.name },
primary: ok ? 'approve' : 'open-chat', menu: ok ? ['deny', 'open-chat'] : [], ...(ok ? { approve: { messageId, toolId: tool.id } } : {}) });
continue;
}
// Running: a loop that is going, or a turn that is.
if (loops.has(s.id) && s.loop) {
const l = s.loop;
columns.running.push({ ...base, key: `loop:${s.id}`, source: 'loop', column: 'running', at: l.startedAt, timeKind: 'next', nextAt: live.has(s.id) ? undefined : l.nextAt, everyMs: l.everyMs, title: l.prompt || s.title,
meta: { text: l.maxRuns ? 'answer {0} of {1}' : 'answer {0}', params: l.maxRuns ? [l.answers + 1, l.maxRuns] : [l.answers + 1] }, line: plainLine(l.lastText) || undefined, busy: live.has(s.id), primary: 'stop-loop', menu: ['open-chat'] });
continue;
}
if (live.has(s.id)) {
const helpers = (s.agents ?? []).filter(a => a.status === 'working').length;
const lastUser = [...s.messages].reverse().find(m => m.role === 'user');
columns.running.push({ ...base, key: `chat:${s.id}`, source: 'chat', column: 'running', at: time(lastUser?.at) || time(s.updatedAt), timeKind: 'started', busy: true,
...(helpers ? { meta: { text: helpers === 1 ? '1 helper working' : '{0} helpers working', params: [helpers] } } : {}), line: plainLine(answer?.content) || undefined, primary: 'open-chat', menu: ['stop-chat'] });
continue;
}
// A loop that ended (or that the window closed on) speaks for the whole chat.
if (s.loop) {
const l = s.loop;
const why = l.state === 'running' ? 'closed' : l.endedWhy ?? 'closed';
const when = l.endedAt ?? time(s.updatedAt);
if (dismissed(s.ackedAt, when) || now - when > window) continue;
const common = { ...base, key: `loop:${s.id}`, source: 'loop' as const, at: when, timeKind: 'stopped' as const, everyMs: l.everyMs, title: l.prompt || s.title, line: plainLine(l.lastText) || undefined };
if (why === 'failures') columns.attention.push({ ...common, column: 'attention', reason: { tone: 'bad', kind: 'loop-failed' }, primary: 'open-chat', menu: ['continue', 'dismiss'] });
else columns.done.push({ ...common, column: 'done', chip: why === 'stopped' ? { text: 'Stopped by you', tone: 'plain' } : why === 'closed' ? { text: 'Stopped when Strata closed', tone: 'plain' } : { text: 'Ended on its own', tone: 'plain' }, primary: 'open-chat', menu: ['dismiss'] });
continue;
}
// Chats: only what has something to do, in the window.
const when = time(s.updatedAt);
if (!when || now - when > window || dismissed(s.ackedAt, when) || !answer || answer.streaming) continue;
if (answer.error) {
columns.attention.push({ ...base, key: `chat:${s.id}`, source: 'chat', column: 'attention', at: when, timeKind: 'plain', reason: { tone: 'bad', kind: 'chat-failed', detail: answer.error }, primary: 'open-chat', menu: ['continue', 'dismiss'] });
continue;
}
const unread = s.id !== input.activeSession && (s.lastReadAt ? s.updatedAt > s.lastReadAt : s.messages.length > 0);
if (unread && answer.content?.trim() && s.messages[s.messages.length - 1] === answer) {
columns.done.push({ ...base, key: `chat:${s.id}`, source: 'chat', column: 'done', at: when, timeKind: 'plain', line: plainLine(answer.content) || undefined, chip: { text: 'Finished', tone: 'good' }, primary: 'open-chat', menu: ['dismiss'] });
}
}
for (const list of Object.values(columns)) list.sort((a, b) => b.at - a.at);
upcoming.sort((a, b) => a.at - b.at);
return { columns, need: columns.waiting.length + columns.attention.length, upcoming: upcoming.slice(0, 5), problemTasks };
}
+5 -2
View File
@@ -16,12 +16,15 @@ export function chatMetrics(session:Session|null, monitor:MonitorState|null, con
const used=live?(liveUsed??finished):(finished??liveUsed);
const max=remote?(replies.at(-1)?.contextMax||null):configuredContext??(active?monitor!.context.maxTokens:latest?.contextMax??null);
const streaming=replies.find(m=>m.streaming);
const speed=streaming?.tps??[...replies].reverse().find(m=>m.tps!==undefined)?.tps??null;
// Answer speed: everything the model generated over its decode time. While a reply streams it is that reply's; after it, the LAST reply's own
// (never an older reply's number under the words "last reply").
const lastReply=streaming??replies.at(-1);
const speed=lastReply?.tps??null,promptSpeed=lastReply?.promptTps??null;
const ttfts=replies.flatMap(m=>m.ttftMs===undefined?[]:[m.ttftMs]);
const hasUsage=session?.statsAvailable===true;
const llmTotal=replies.reduce((n,m)=>n+(m.durationMs??(m.streaming?Math.max(0,now-(Date.parse(m.at)||now)):0)),0),thinkTotal=replies.reduce((n,m)=>n+(m.reasoningMs??(m.streaming&&m.reasoningStartedAt?Math.max(0,now-m.reasoningStartedAt):0)),0);
return {streaming:!!streaming,writeMs:Math.max(0,llmTotal-thinkTotal),estimated:streaming?.usageSource==='estimated',hasReplies:replies.length>0,example:!!session?.showcase||replies.some(m=>m.usageSource==='demo'),
speed:active&&monitor?.phase==='generating'?monitor.outputTps:speed,
speed:active&&['generating','thinking'].includes(monitor?.phase??'')&&monitor!.outputTps>0?monitor!.outputTps:speed,promptSpeed,
total:session?.statsAvailable===false?null:hasUsage?session.tokensIn+session.tokensOut+(streaming?.tokens??0)+(streaming?.inputTokens??0):replies.every(m=>m.inputTokens!==undefined&&m.tokens!==undefined)?replies.reduce((n,m)=>n+m.inputTokens!+m.tokens!,0):null,
input:hasUsage?session.tokensIn:null,output:hasUsage?session.tokensOut:null,
llmMs:replies.reduce((n,m)=>n+(m.durationMs??(m.streaming?Math.max(0,now-(Date.parse(m.at)||now)):0)),0),
+3 -2
View File
@@ -55,12 +55,12 @@ export function installProgress(install: InstallParts | undefined): { done_gb: n
}
/** Which part of a multi-part install is being fetched now: the model's parts (with "part 2 of 4"), the picture reader or the engine. */
export interface InstallStage { kind: 'model' | 'vision' | 'engine' | 'other'; index?: number; count?: number }
export interface InstallStage { kind: 'model' | 'vision' | 'engine' | 'draft' | 'other'; index?: number; count?: number }
export function installStage(install: { parts?: { kind?: string; state?: string; files_done?: number; files_total?: number }[]; running?: boolean } | undefined): InstallStage | undefined {
const parts = install?.parts ?? [];
const now = parts.find(p => p.state === 'downloading') ?? parts.find(p => !['done', 'current', 'skipped', 'failed'].includes(p.state ?? ''));
if (!now) return undefined;
const kind = now.kind === 'model' || now.kind === 'vision' || now.kind === 'engine' ? now.kind : 'other';
const kind = now.kind === 'model' || now.kind === 'vision' || now.kind === 'engine' || now.kind === 'draft' ? now.kind : 'other';
const count = Number(now.files_total) > 1 ? Number(now.files_total) : undefined;
return { kind, ...(count ? { index: Math.min(count, Number(now.files_done ?? 0) + 1), count } : {}) };
}
@@ -68,6 +68,7 @@ export function installStage(install: { parts?: { kind?: string; state?: string;
export function stageText(stage: InstallStage | undefined): string {
if (!stage) return tr('Downloading model files…');
if (stage.kind === 'vision') return tr('Downloading the picture reader…');
if (stage.kind === 'draft') return tr('Preparing your model…'); // the speed-up layer's own download (setup step 6)
if (stage.kind === 'engine') return tr('Downloading the engine for this PC…');
if (stage.count && stage.index) return tr('Downloading model files · part {0} of {1}', [stage.index, stage.count]);
return tr('Downloading model files…');
+274
View File
@@ -0,0 +1,274 @@
/**
* Main / Helper / Off for every graphics card. The same rules as strata_control/gpu_roles.py (the Terminal, setup and the server); both run
* tests/data/gpu_roles_vectors.json.
*
* - Main: the card that runs the LAST stage of the layer split (the output head, the draft layer and the check of its guesses). Exactly one.
* - Helper: holds a share of the model, so more of it fits on graphics cards instead of being read from the drive.
* - Off: Strata does not touch it.
*
* The engine takes the cards in stage order (`gpu: [helper, ..., main]`, `gpu_order: "as_given"`), so Main is the LAST one.
*/
export type Role = 'main' | 'helper' | 'off';
export const ROLES: Role[] = ['main', 'helper', 'off'];
export type Roles = Record<number, Role>;
export interface RoleCard {
index: number;
name: string;
vram_gb: number;
/** SMs x clock (or the AMD equivalent): only the order between cards matters. */
speed?: number;
usable?: boolean;
problem?: string | null;
vendor?: string;
uma?: boolean;
integrated?: boolean;
unified_gb?: number;
/** PCI bus id when known: used to notice a renumbering. */
bus?: string;
}
export const OFF_AT_OR_BELOW_GB = 8;
const SAME_SPEED = 1.05;
const MEMORY_GUARD_SPEED = 0.5;
const MIN_CARD_GB = 4;
const SPLIT_CARD_OVERHEAD_GB = 2;
export const CPU_ONLY_LINE = 'Performance is degraded on CPU only.';
export const NO_CARD_LINE = 'This engine needs a graphics card to start. Turn one on.';
const mem = (c: RoleCard) => Number(c.vram_gb) || 0;
const speed = (c: RoleCard) => (typeof c.speed === 'number' && c.speed > 0 ? c.speed : 0);
const shares = (c: RoleCard) => !!(c.integrated || c.uma || c.unified_gb || c.vendor === 'apple');
const canRun = (c: RoleCard) => c.usable !== false;
const rnd = (n: number) => Math.round(n);
export function shortName(c: RoleCard): string {
let n = String(c.name || `GPU ${c.index + 1}`);
for (const junk of ['NVIDIA GeForce ', 'NVIDIA ', 'GeForce ', 'AMD Radeon ', 'AMD ', 'Intel(R) ', 'Intel ']) if (n.startsWith(junk)) { n = n.slice(junk.length); break; }
return n.trim();
}
/** The number the screens show: GPU 1 is card 0. */
export const gpuLabel = (c: RoleCard) => `GPU ${c.index + 1}`;
/** The cards shown as rows: every card the engine's make can name, in card order. */
export function rows<T extends RoleCard>(cards: T[] | null | undefined, vendor?: string | null): T[] {
const list = cards ?? [];
const make = vendor || pickVendor(list);
const mine = make ? list.filter(c => !c.vendor || c.vendor === make) : list;
return [...mine].sort((a, b) => a.index - b.index);
}
function pickVendor(cards: RoleCard[]): string | null {
const makes = new Set(cards.filter(c => canRun(c) && c.vendor).map(c => c.vendor as string));
if (makes.size <= 1) return [...makes][0] ?? null;
return makes.has('nvidia') ? 'nvidia' : cards[0]?.vendor ?? null;
}
function eligible(cs: RoleCard[]): RoleCard[] {
const real = cs.filter(c => canRun(c) && !shares(c) && mem(c) >= MIN_CARD_GB);
return real.length ? real : cs.filter(canRun);
}
export type MainWhy = 'only' | 'faster' | 'same' | 'memory' | 'none';
export function pickMain(cs: RoleCard[]): { main: RoleCard | null; why: MainWhy } {
const pool = eligible(cs);
if (!pool.length) return { main: null, why: 'none' };
if (pool.length === 1) return { main: pool[0], why: 'only' };
const top = Math.max(...pool.map(speed));
const group = top > 0 ? pool.filter(c => speed(c) * SAME_SPEED >= top) : [...pool];
const byMem = (a: RoleCard, b: RoleCard) => rnd(mem(b)) - rnd(mem(a)) || a.index - b.index;
let main = [...group].sort(byMem)[0];
let why: MainWhy = group.length === 1 ? 'faster' : 'same';
if (group.length > 1 && new Set(group.map(c => `${c.name}|${rnd(mem(c))}`)).size === 1) main = [...group].sort((a, b) => b.index - a.index)[0]; // identical cards: the HIGHEST number is Main
if (top <= 0 && new Set(pool.map(c => rnd(mem(c)))).size > 1) why = 'memory';
const bigger = pool.filter(c => rnd(mem(c)) > rnd(mem(main)) && (top <= 0 || speed(c) >= MEMORY_GUARD_SPEED * top));
if (bigger.length) { main = [...bigger].sort(byMem)[0]; why = 'memory'; }
return { main, why };
}
export type OffWhy = 'cannot_run' | 'igpu' | 'small' | 'helper';
export interface Recommendation { roles: Roles; main: number | null; why: MainWhy; reasons: Record<number, MainWhy | OffWhy> }
/** The best card is Main; every other card starts as Helper, EXCEPT a card with 8 GB or less, built-in graphics beside a real card, and a card the engine cannot run: those start Off. */
export function recommend(cards: RoleCard[] | null | undefined, vendor?: string | null): Recommendation {
const cs = rows(cards, vendor);
const { main, why } = pickMain(cs);
const roles: Roles = {}, reasons: Recommendation['reasons'] = {};
for (const c of cs) {
const i = c.index;
if (main && c === main) { roles[i] = 'main'; reasons[i] = why; }
else if (!canRun(c)) { roles[i] = 'off'; reasons[i] = 'cannot_run'; }
else if (shares(c) && main) { roles[i] = 'off'; reasons[i] = 'igpu'; }
else if (rnd(mem(c)) <= OFF_AT_OR_BELOW_GB) { roles[i] = 'off'; reasons[i] = 'small'; }
else { roles[i] = 'helper'; reasons[i] = 'helper'; }
}
return { roles, main: main ? main.index : null, why, reasons };
}
const on = (roles: Roles) => Object.keys(roles).map(Number).filter(i => roles[i] !== 'off');
const byNumber = (roles: Roles) => Object.keys(roles).map(Number).sort((a, b) => a - b);
/** Exactly one Main among the cards that are on; a lone card that is on is Main. */
export function normalize(roles: Roles, cards: RoleCard[]): Roles {
const cs = new Map(cards.map(c => [c.index, c]));
const out: Roles = {};
for (const i of byNumber(roles)) if (cs.has(i)) out[i] = ROLES.includes(roles[i]) ? roles[i] : 'off';
const onList = on(out), mains = onList.filter(i => out[i] === 'main');
if (onList.length && !mains.length) {
const best = [...onList].sort((a, b) => speed(cs.get(b)!) - speed(cs.get(a)!) || rnd(mem(cs.get(b)!)) - rnd(mem(cs.get(a)!)) || a - b)[0];
out[best] = 'main';
}
for (const extra of mains.slice(1)) out[extra] = 'helper';
if (onList.length === 1) out[onList[0]] = 'main';
return out;
}
export interface RoleChange { roles: Roles; note: string | null }
/** One click: the new roles and a code for the quiet line (old_main_helper:<i>, promoted:<i>, only_main). */
export function setRole(roles: Roles, cards: RoleCard[], idx: number, role: Role): RoleChange {
const cs = new Map(cards.map(c => [c.index, c]));
const out: Roles = { ...roles };
let note: string | null = null;
if (!cs.has(idx) || !ROLES.includes(role)) return { roles: out, note };
const ids = byNumber(out);
const fastest = (list: number[]) => [...list].sort((a, b) => speed(cs.get(b)!) - speed(cs.get(a)!) || a - b);
if (role === 'main') {
const old = ids.find(i => i !== idx && out[i] === 'main');
out[idx] = 'main';
if (old !== undefined) { out[old] = 'helper'; note = `old_main_helper:${old}`; }
} else if (role === 'helper') {
if (out[idx] === 'main') {
const others = fastest(ids.filter(i => i !== idx && out[i] === 'helper'));
if (!others.length) return { roles: out, note: 'only_main' };
out[idx] = 'helper'; out[others[0]] = 'main'; note = `promoted:${others[0]}`;
} else out[idx] = 'helper';
} else {
const wasMain = out[idx] === 'main';
out[idx] = 'off';
if (wasMain) {
const next = fastest(ids.filter(i => out[i] === 'helper'));
if (next.length) { out[next[0]] = 'main'; note = `promoted:${next[0]}`; }
}
}
const onList = on(out);
if (onList.length === 1) out[onList[0]] = 'main';
else if (onList.length && !onList.some(i => out[i] === 'main')) out[onList[0]] = 'main';
return { roles: out, note };
}
/** The engine's card list: Helpers (slowest first, ties by number), then the Main card LAST; Off cards left out. */
export function order(roles: Roles, cards: RoleCard[]): number[] {
const cs = new Map(cards.map(c => [c.index, c]));
const r = normalize(roles, cards);
const helpers = byNumber(r).filter(i => r[i] === 'helper').sort((a, b) => speed(cs.get(a)!) - speed(cs.get(b)!) || a - b);
return [...helpers, ...byNumber(r).filter(i => r[i] === 'main')];
}
export interface RoleMirror { gpu_roles: Record<string, Role>; gpu: number | number[]; gpu_order?: 'as_given'; gpu_none?: true; gpu_cards: Record<string, string> }
/** What a writer stores: "gpu_roles" plus the legacy mirror older engines and the Monitor read. Nothing on: "gpu": [] and "gpu_none": true. */
export function mirror(roles: Roles, cards: RoleCard[]): RoleMirror {
const r = normalize(roles, cards), lst = order(r, cards);
const out: RoleMirror = { gpu_roles: Object.fromEntries(byNumber(r).map(i => [String(i), r[i]])), gpu: [], gpu_cards: {} };
if (lst.length) { out.gpu = lst.length === 1 ? lst[0] : lst; out.gpu_order = 'as_given'; } else out.gpu_none = true;
for (const c of cards) if (c.index in r) out.gpu_cards[String(c.index)] = [shortName(c), c.bus ?? ''].filter(Boolean).join('|');
return out;
}
/** Roles of an old "gpu" setting, for display: the LAST card of the engine's order is Main, earlier ones Helpers, unlisted cards Off. */
export function fromLegacy(gpu: unknown, cards: RoleCard[], gpuOrder?: string | null): Roles {
const cs = new Map(cards.map(c => [c.index, c]));
let items: number[] = Array.isArray(gpu) ? gpu.map(Number) : gpu === null || gpu === undefined || gpu === '' ? [] : String(gpu).split(',').filter(x => x.trim() !== '').map(Number);
items = items.filter(i => cs.has(i));
if (!items.length) return { ...recommend(cards).roles };
if (items.length > 1 && gpuOrder !== 'as_given') {
const sp = items.map(i => speed(cs.get(i)!));
if (sp.every(x => x > 0) && Math.max(...sp) >= SAME_SPEED * Math.min(...sp)) items = [...items].sort((a, b) => speed(cs.get(a)!) - speed(cs.get(b)!));
}
const roles: Roles = {};
for (const c of cards) roles[c.index] = 'off';
for (const i of items.slice(0, -1)) roles[i] = 'helper';
roles[items[items.length - 1]] = 'main';
return roles;
}
export const sameRoles = (a: Roles, b: Roles) => { const ka = byNumber(a), kb = byNumber(b); return ka.length === kb.length && ka.every((k, n) => k === kb[n] && a[k] === b[k]); };
export interface RoleWarning { code: 'igpu_main' | 'cannot_run' | 'low_memory' | 'cpu_only'; card: number | null; text: string; fix: { card: number; role: Role } | null }
/** One short line each, only what a person could not know. Never blocking. */
export function warnings(roles: Roles, cards: RoleCard[], opts: { spillGb?: number; engineRunsCpuOnly?: boolean } = {}): RoleWarning[] {
const cs = new Map(cards.map(c => [c.index, c]));
const out: RoleWarning[] = [];
for (const i of byNumber(roles)) {
const c = cs.get(i), role = roles[i];
if (!c || role === 'off') continue;
if (!canRun(c)) out.push({ code: 'cannot_run', card: i, text: `${shortName(c)} cannot run Strata.`, fix: { card: i, role: 'off' } });
else if (role === 'main' && shares(c) && cards.some(x => !shares(x) && canRun(x))) {
const real = cards.filter(x => !shares(x) && canRun(x)).sort((a, b) => speed(b) - speed(a) || mem(b) - mem(a) || a.index - b.index)[0];
out.push({ code: 'igpu_main', card: i, text: `${shortName(c)} is built-in graphics; it is slow as Main.`, fix: { card: real.index, role: 'main' } });
}
}
if (!on(roles).length) out.push({ code: 'cpu_only', card: null, text: opts.engineRunsCpuOnly ? CPU_ONLY_LINE : NO_CARD_LINE, fix: null });
else if ((opts.spillGb ?? 0) >= 1) out.push({ code: 'low_memory', card: null, text: `Not enough memory for this setup: about ${Math.round(opts.spillGb!)} GB is read from the drive.`, fix: null });
return out;
}
/** The graphics memory the cards that are on give for the model (an extra card costs its own dense layers); 0 when all are off. */
export function vramOn(roles: Roles, cards: RoleCard[]): number {
const cs = new Map(cards.map(c => [c.index, c]));
const onCards = on(roles).map(i => cs.get(i)).filter((c): c is RoleCard => !!c && !shares(c));
return Math.max(0, onCards.reduce((n, c) => n + mem(c), 0) - SPLIT_CARD_OVERHEAD_GB * Math.max(0, onCards.length - 1));
}
export function noteText(code: string | null, cards: RoleCard[]): string {
if (!code) return '';
const [kind, arg] = code.split(':');
const c = arg !== undefined ? cards.find(x => x.index === Number(arg)) : undefined;
const name = c ? shortName(c) : 'That card';
if (kind === 'old_main_helper') return `${name} is now a Helper.`;
if (kind === 'promoted') return `${name} is now Main.`;
if (kind === 'only_main') return 'The only card that is on has to be Main.';
return '';
}
/** The one plain line above the cards. */
export function sentence(cards: RoleCard[] | null | undefined, vendor?: string | null): string {
const cs = rows(cards, vendor), rec = recommend(cards, vendor);
if (rec.main === null) return 'No graphics card that Strata can use was found.';
const byi = new Map(cs.map(c => [c.index, c]));
const m = byi.get(rec.main)!;
if (cs.length === 1) return `Strata will use your ${shortName(m)}.`;
const why = { same: 'the cards are alike', memory: 'it has more memory', faster: 'it is faster', only: 'it is the only card', none: '' }[rec.why];
let s = `${shortName(m)} is Main: ${why}.`;
const helpers = byNumber(rec.roles).filter(i => rec.roles[i] === 'helper').map(i => shortName(byi.get(i)!));
if (helpers.length) s += ` ${helpers.join(' and ')} ${helpers.length > 1 ? 'help' : 'helps'}.`;
for (const i of byNumber(rec.roles)) if (rec.roles[i] === 'off') {
const code = rec.reasons[i], c = byi.get(i)!;
s += ` ${shortName(c)} starts Off: ${code === 'cannot_run' ? 'Strata cannot run it.' : code === 'igpu' ? 'built-in graphics.' : `${OFF_AT_OR_BELOW_GB} GB or less.`}`;
}
return s;
}
/** `Main: GPU 2 RTX 4070 Ti. Helper: GPU 1 RTX 3060.` */
export function describe(roles: Roles, cards: RoleCard[]): string {
const cs = new Map(cards.map(c => [c.index, c]));
const parts: string[] = [];
for (const [role, word] of [['main', 'Main'], ['helper', 'Helper'], ['off', 'Off']] as const) {
const names = byNumber(roles).filter(i => roles[i] === role).map(i => (cs.has(i) ? `GPU ${i + 1} ${shortName(cs.get(i)!)}` : `GPU ${i + 1}`));
if (names.length) parts.push(`${word}: ${names.join(', ')}.`);
}
return parts.join(' ');
}
/** A saved "gpu_roles" object -> roles; null when it is not one. */
export function parseSaved(value: unknown): Roles | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const out: Roles = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (!/^\d+$/.test(k.trim()) || !ROLES.includes(String(v).toLowerCase() as Role)) return null;
out[Number(k)] = String(v).toLowerCase() as Role;
}
if (!Object.keys(out).length || Object.values(out).filter(r => r === 'main').length > 1) return null;
return out;
}
+1 -1
View File
@@ -36,7 +36,7 @@ const patterns=Object.entries(english).filter(([,value])=>/\{\d+\}/.test(value))
expression+=escape(value.slice(at));
return{id,slots,pattern:new RegExp('^'+expression+'$'),specificity:value.replace(/\{\d+\}/g,'').length};
}).sort((a,b)=>b.specificity-a.specificity);
const localizedTemplateSlots:Record<string,number[]>={s2473:[1,2],s0825:[0,1],s2455:[0],s2456:[0]};
const localizedTemplateSlots:Record<string,number[]>={s2473:[1,2],s0825:[0,1],s2455:[0],s2456:[0],s7400:[1]};
/** Translate interface copy only. Non-string React children pass through unchanged. */
export function tr<T>(value:T,values?:readonly unknown[]):T{
+25 -3
View File
@@ -1,4 +1,5 @@
import { tr } from './i18n';
import type { LoopRecord } from './types';
import { LOOP_MAX_FAILURES, LOOP_MAX_RUNS, judgeAnswer, loopEndNote, loopEvery, loopLimitNote, withStopRule, type LoopLimits, type LoopStop } from './loop';
/**
@@ -20,6 +21,8 @@ export interface LoopHost {
now(): number;
setTimer(fn: () => void, ms: number): unknown;
clearTimer(handle: unknown): void;
/** Keep a small record of the loop on the chat (running, or how it ended) so the task board can show it. Optional: the tests do not need it. */
track?(sessionId: string, record: LoopRecord): void;
}
export interface LoopStart extends LoopLimits { everyMs: number; prompt: string }
@@ -30,6 +33,8 @@ interface LoopState extends LoopStart {
startedAt: number;
timer: unknown;
running: boolean;
lastText?: string;
nextAt?: number;
}
export class LoopScheduler {
@@ -41,6 +46,15 @@ export class LoopScheduler {
isRunning(sessionId: string | null): boolean { return !!sessionId && this.loops.has(sessionId); }
/** The chats that have a loop going. */
ids(): string[] { return [...this.loops.keys()]; }
private record(sessionId: string, loop: LoopState, endedWhy?: NonNullable<LoopRecord['endedWhy']>) {
const record: LoopRecord = { prompt: loop.prompt, everyMs: loop.everyMs, startedAt: loop.startedAt, state: endedWhy ? 'ended' : 'running', answers: loop.answers, ...(loop.maxRuns !== undefined ? { maxRuns: loop.maxRuns } : {}), ...(loop.lastText ? { lastText: loop.lastText } : {}) };
if (endedWhy) { record.endedAt = this.host.now(); record.endedWhy = endedWhy; } else if (loop.nextAt !== undefined) record.nextAt = loop.nextAt;
try { this.host.track?.(sessionId, record); } catch { /* the record is a courtesy to the board */ }
}
/** What the person is told for /loop with nothing after it. */
describe(sessionId: string): string | null {
const loop = this.loops.get(sessionId);
@@ -49,11 +63,15 @@ export class LoopScheduler {
return tr('A loop is running in this chat: it sends "{0}" {1}, while this chat is open. Type /loop stop to stop it.', [loop.prompt, loopEvery(loop.everyMs)]) + (limit ? ' ' + limit : '');
}
stop(sessionId: string): boolean {
/** The person stopped it (/loop stop or the board's Stop loop). */
stop(sessionId: string): boolean { return this.end(sessionId, 'stopped'); }
private end(sessionId: string, why: NonNullable<LoopRecord['endedWhy']>): boolean {
const loop = this.loops.get(sessionId);
if (!loop) return false;
if (loop.timer) this.host.clearTimer(loop.timer);
this.loops.delete(sessionId);
this.record(sessionId, loop, why);
return true;
}
@@ -62,6 +80,7 @@ export class LoopScheduler {
if (this.loops.has(sessionId)) { this.host.note(sessionId, tr('This chat already has a loop. Type /loop stop first, then start a new one.')); return false; }
const loop: LoopState = { ...command, answers: 0, failures: 0, startedAt: this.host.now(), timer: null, running: false };
this.loops.set(sessionId, loop);
this.record(sessionId, loop);
const limit = loopLimitNote(loop);
this.host.note(sessionId, tr('Loop started: "{0}" {1}, while this chat is open. Type /loop stop to stop it. For work that must go on with Strata closed, use the Tasks screen.', [loop.prompt, loopEvery(loop.everyMs)]) + (limit ? ' ' + limit : ''));
await this.run(sessionId);
@@ -79,6 +98,7 @@ export class LoopScheduler {
// The person may have typed /loop stop while the answer was coming: nothing more happens then.
if (this.loops.get(sessionId) !== loop) return;
if (result.answered) {
loop.lastText = String(result.text ?? '').replace(/\s+/g, ' ').trim().slice(0, 200);
if (result.failed) loop.failures += 1; else { loop.failures = 0; loop.answers += 1; }
why = judgeAnswer(loop, loop, result, this.host.now());
}
@@ -94,13 +114,15 @@ export class LoopScheduler {
}
private finish(sessionId: string, loop: LoopState, why: LoopStop) {
this.stop(sessionId);
this.end(sessionId, why);
this.host.note(sessionId, loopEndNote(why, loop.answers));
}
private schedule(sessionId: string, ms: number) {
const loop = this.loops.get(sessionId);
if (!loop) return;
loop.nextAt = this.host.now() + ms;
this.record(sessionId, loop);
loop.timer = this.host.setTimer(() => void this.tick(sessionId), ms);
}
@@ -108,7 +130,7 @@ export class LoopScheduler {
const loop = this.loops.get(sessionId);
if (!loop) return;
loop.timer = null;
if (!this.host.exists(sessionId)) { this.stop(sessionId); return; }
if (!this.host.exists(sessionId)) { this.end(sessionId, 'stopped'); return; }
// The time is up, or the count is reached: never fire again, even if the last answer was not judged (a busy chat).
if (loop.maxMs !== undefined && this.host.now() - loop.startedAt >= loop.maxMs) { this.finish(sessionId, loop, 'time'); return; }
if (loop.maxRuns !== undefined && loop.answers >= loop.maxRuns) { this.finish(sessionId, loop, 'count'); return; }
+130
View File
@@ -0,0 +1,130 @@
/**
* Finding the math in an answer (#3268). Pure text work, no rendering: the pieces are drawn by components/MathView.tsx.
*
* Recognised, as models write them:
* $$ ... $$ and \[ ... \] display math (may span lines)
* $ ... $ and \( ... \) inline math (one line)
* A dollar sign that is money stays text: "costs $5 and $10" has no math, because an opening $ must be followed by a non-space,
* a closing $ must follow a non-space and must not be followed by a digit. `\$` is a literal dollar sign.
* Code (backtick spans, fenced blocks) is never searched: the caller splits it off first.
*/
export type MathPart = { kind: 'text'; text: string } | { kind: 'math'; tex: string; display: boolean };
const isSpace = (c: string | undefined) => c === undefined || /\s/.test(c);
const isDigit = (c: string | undefined) => c !== undefined && c >= '0' && c <= '9';
/** Where the unescaped `close` starts at or after `from`, or -1. `\\` and `\$` style escapes are skipped. */
function findClose(text: string, from: number, close: string, oneLine: boolean): number {
for (let i = from; i < text.length; i++) {
const c = text[i];
if (oneLine && c === '\n')
return -1;
if (c === '\\' && close !== '\\)' && close !== '\\]') {
i++;
continue;
}
if (text.startsWith(close, i))
return i;
}
return -1;
}
export function splitMath(text: string): MathPart[] {
const parts: MathPart[] = [];
let plain = '';
const flush = () => {
if (plain) {
parts.push({ kind: 'text', text: plain });
plain = '';
}
};
const push = (tex: string, display: boolean) => {
if (!tex.trim())
return false;
flush();
parts.push({ kind: 'math', tex: tex.trim(), display });
return true;
};
for (let i = 0; i < text.length;) {
const c = text[i];
if (c === '\\') {
const next = text[i + 1];
if (next === '$') { // \$ is a dollar sign
plain += '$';
i += 2;
continue;
}
if (next === '(' || next === '[') {
const close = next === '(' ? '\\)' : '\\]';
const end = findClose(text, i + 2, close, next === '(');
if (end > 0 && push(text.slice(i + 2, end), next === '[')) {
i = end + 2;
continue;
}
}
plain += c + (next ?? '');
i += 2;
continue;
}
if (c === '$') {
if (text[i + 1] === '$') {
const end = findClose(text, i + 2, '$$', false);
if (end > 0 && push(text.slice(i + 2, end), true)) {
i = end + 2;
continue;
}
plain += '$$';
i += 2;
continue;
}
if (!isSpace(text[i + 1])) {
const end = findClose(text, i + 1, '$', true);
if (end > i + 1 && !isSpace(text[end - 1]) && !isDigit(text[end + 1]) && push(text.slice(i + 1, end), false)) {
i = end + 1;
continue;
}
}
}
plain += c;
i++;
}
flush();
return parts;
}
/** True when the text holds any math, so a plain answer costs nothing. */
export function hasMath(text: string): boolean {
return text.includes('$') || text.includes('\\(') || text.includes('\\[');
}
/**
* A block of display math that starts a line: `$$ ... $$` on one line, or `$$` / `\[` on its own line up to the line with the closing
* delimiter. Returns the formula and the number of lines used, or null (an unclosed block, e.g. while the answer is still streaming,
* stays text).
*/
export function displayBlock(lines: string[], at: number): { tex: string; used: number } | null {
const first = lines[at].trim();
const opens = first.startsWith('$$') ? '$$' : first.startsWith('\\[') ? '\\[' : '';
if (!opens)
return null;
const closes = opens === '$$' ? '$$' : '\\]';
const rest = first.slice(2);
const sameLine = rest.indexOf(closes);
if (sameLine >= 0) {
// "$$ x $$" alone on the line; text after the closing delimiter makes it an inline display in a paragraph instead
return rest.slice(sameLine + closes.length).trim() ? null : { tex: rest.slice(0, sameLine), used: 1 };
}
const body = [rest];
for (let j = at + 1; j < lines.length && j < at + 60; j++) {
const t = lines[j].trim();
const end = t.indexOf(closes);
if (end >= 0) {
if (t.slice(end + closes.length).trim())
return null;
body.push(t.slice(0, end));
return { tex: body.join('\n'), used: j - at + 1 };
}
body.push(lines[j]);
}
return null;
}
+44 -6
View File
@@ -20,6 +20,8 @@ export interface Fit {
}
const DENSE_GB = 5, SYSTEM_ROOM_GB = 10, DENSE_EXTRA_GB = 1;
/** The draft layer (MTP) takes about 1 GB of graphics memory (786 MiB of files + the draft head's ~218 MiB); it comes out of the expert cache while MTP is on (setup.py MTP_VRAM_GB). */
export const MTP_VRAM_GB = 1;
const round1 = (n: number) => Math.round(n * 10) / 10;
export function memoryNeeded(v: FitInput): number {
@@ -28,16 +30,16 @@ export function memoryNeeded(v: FitInput): number {
return round1((v.experts_gb ?? v.ram_needed_gb ?? 0) + DENSE_EXTRA_GB);
}
export function fitOn(v: FitInput, hw: FitHardware | null | undefined): Fit {
export function fitOn(v: FitInput, hw: FitHardware | null | undefined, mtp = true): Fit {
const memoryGb = memoryNeeded(v);
const ram = Number(hw?.ram_gb), vram = Number(hw?.vram_gb) || 0;
if (!Number.isFinite(ram) || ram <= 0) return { memoryGb, vramUseGb: 0, ramUseGb: 0, spillGb: 0, known: false };
const experts = Math.max(0, memoryNeeded(v) - DENSE_EXTRA_GB);
const onGpu = Math.max(0, Math.min(experts, vram - DENSE_GB));
const onGpu = Math.max(0, Math.min(experts, vram - DENSE_GB - (mtp && vram > 0 ? MTP_VRAM_GB : 0)));
const ramRoom = Math.max(0, ram - SYSTEM_ROOM_GB);
const spillGb = Math.max(0, experts - onGpu - ramRoom);
const inRam = Math.max(0, experts - onGpu - spillGb);
const vramUseGb = vram > 0 ? Math.min(vram, DENSE_GB + onGpu) : 0;
const vramUseGb = vram > 0 ? Math.min(vram, DENSE_GB + (mtp ? MTP_VRAM_GB : 0) + onGpu) : 0;
return { memoryGb, vramUseGb: round1(vramUseGb), ramUseGb: round1(inRam + DENSE_EXTRA_GB * (vram > 0 ? 0 : 1)), spillGb: round1(spillGb), known: true };
}
@@ -62,7 +64,43 @@ export const RECOMMENDED: { family: string; model: string; note: string }[] = [
* (the running version's group used to stay open whatever was clicked). */
export const groupOpen = (opened: Record<string, boolean>, id: string, containsSelected: boolean): boolean => opened[id] ?? containsSelected;
/** The recommended four, found in a version list (a version the catalog does not offer is skipped). */
export function recommendedVersions<V extends { sourceFamily: string; model: string }>(versions: V[]): { r: typeof RECOMMENDED[number]; v: V }[] {
return RECOMMENDED.map(r => ({ r, v: versions.find(v => v.sourceFamily === r.family && v.model === r.model) })).filter((x): x is { r: typeof RECOMMENDED[number]; v: V } => !!x.v);
/** What the engine's own pick for this PC is (catalog.recommendation: the memory tiers of setup.py and tools/strata_mcp.py, the same answer `strata launch` shows). */
export interface PcPick { family?: string | null; model?: string | null; why?: string }
export const PICK_NOTE = 'the pick for this PC';
export const OTHER_NOTE = 'the other choice';
/**
* The recommended set, found in a version list (a version the catalog does not offer is skipped). The four fixed cards are only a display list;
* the pick comes from the engine's own recommendation for this PC (RAM + VRAM tiers: 40 GB Swift 1.5 IQ2_XS, 48 GB IQ3_XXS, 64 GB and up IQ3_S), so
* the page and `strata launch` agree. The pick comes first and is marked; when the engine names Gyro-M as the other choice (40 GB tier, NVIDIA and AMD only,
* which the engine decides) that card follows it. Without a pick the list is the fixed four.
*/
export function recommendedVersions<V extends { sourceFamily: string; model: string }>(versions: V[], pick?: PcPick | null): { r: typeof RECOMMENDED[number]; v: V }[] {
type Row = { r: typeof RECOMMENDED[number]; v: V };
const find = (family: string, model: string) => versions.find(v => v.sourceFamily === family && v.model === model);
const rows: Row[] = RECOMMENDED.map(r => ({ r, v: find(r.family, r.model) })).filter((x): x is Row => !!x.v);
if (!rows.length || !pick?.family || !pick.model) return rows;
const chosen = find(pick.family, pick.model);
if (!chosen) return rows;
const rest = rows.filter(x => x.v !== chosen);
const first: Row = { r: { family: pick.family, model: pick.model, note: PICK_NOTE }, v: chosen };
const out: Row[] = [first];
const gyroM = /Gyro-M/.test(pick.why ?? '') ? find('gyro', 'Gyro-M') : undefined;
if (gyroM) out.push({ r: { family: 'gyro', model: 'Gyro-M', note: OTHER_NOTE }, v: gyroM });
return [...out, ...rest.filter(x => x.v !== gyroM)];
}
/** A card as the installer's hardware report lists it. */
export interface RunCard { index: number; name: string; vram_gb: number; usable?: boolean; vendor?: string; uma?: boolean; integrated?: boolean; unified_gb?: number }
/**
* The cards a model can be placed on: the ones this engine can drive (strata_control/gpu_fit.py `usable_cards` and `for_vendor`, same rule).
* A card the engine has no code for, a card of the other make (an engine drives one make; the report numbers each make from 0, so
* two cards could share a number) and a chip that shares the system memory while the computer has a card of its own are left out.
*/
export function runCards<T extends RunCard>(gpus: T[] | null | undefined, engineVendor?: string | null): T[] {
const sharesMemory = (g: RunCard) => !!(g.integrated || g.uma || g.unified_gb);
const mine = (gpus ?? []).filter(g => g.usable !== false && (!engineVendor || !g.vendor || g.vendor === engineVendor));
const hasReal = mine.some(g => !sharesMemory(g) && g.vram_gb >= 4);
return mine.filter(g => !(hasReal && sharesMemory(g)));
}
Loaded 100 of 590 files, more files were not shown because too many files have changed in this diff. Show more